1<?php /** @noinspection PhpUndefinedFieldInspection */
2/** @noinspection PhpMissingParamTypeInspection */
3/** @noinspection PhpMissingReturnTypeInspection */
4/** @noinspection PhpPossiblePolymorphicInvocationInspection */
5/** @noinspection PhpFullyQualifiedNameUsageInspection */
6/** @noinspection PhpComposerExtensionStubsInspection */
7
8
9namespace OCA\Appointments\Backend;
10
11use OCA\Appointments\AppInfo\Application;
12use OCP\DB\Exception;
13use OCP\IDBConnection;
14use OCP\IURLGenerator;
15use Psr\Log\LoggerInterface;
16use Sabre\VObject\Reader;
17
18class BackendUtils
19{
20
21    const APPT_CAT = "Appointment";
22    const TZI_PROP = "X-TZI";
23    const XAD_PROP = "X-APPT-DATA";
24    // original description
25    const X_DSR = "X-APPT-DSR";
26
27    const CIPHER = "AES-128-CFB";
28    const HASH_TABLE_NAME = "appointments_hash";
29    const PREF_TABLE_NAME = "appointments_pref";
30    const SYNC_TABLE_NAME = "appointments_sync";
31
32    const PREF_STATUS_TENTATIVE = 0;
33    const PREF_STATUS_CONFIRMED = 1;
34    const PREF_STATUS_CANCELLED = 2;
35
36    const FLOAT_TIME_FORMAT = "Ymd.His";
37
38    public const APPT_SES_KEY_HINT = "appointment_hint";
39
40    public const APPT_SES_BOOK = "0";
41    public const APPT_SES_CONFIRM = "1";
42    public const APPT_SES_CANCEL = "2";
43    public const APPT_SES_SKIP = "3";
44    public const APPT_SES_TYPE_CHANGE = "4";
45
46    public const KEY_USE_DEF_EMAIL = 'useDefaultEmail';
47    public const KEY_LIMIT_TO_GROUPS = 'limitToGroups';
48    public const KEY_EMAIL_FIX = 'emailFixOpt';
49
50    public const KEY_ORG = 'org_info';
51    public const ORG_NAME = 'organization';
52    public const ORG_EMAIL = 'email';
53    public const ORG_ADDR = 'address';
54    public const ORG_PHONE = 'phone';
55
56    // Email Settings
57    public const KEY_EML = 'email_options';
58    public const EML_ICS = 'icsFile';
59    public const EML_SKIP_EVS = 'skipEVS';
60    public const EML_AMOD = 'attMod';
61    public const EML_ADEL = 'attDel';
62    public const EML_MREQ = 'meReq';
63    public const EML_MCONF = 'meConfirm';
64    public const EML_MCNCL = 'meCancel';
65    public const EML_VLD_TXT = 'vldNote';
66    public const EML_CNF_TXT = 'cnfNote';
67
68    // Calendar Settings
69    public const KEY_CLS = 'calendar_settings';
70    // simple mode
71    public const CLS_MAIN_ID = 'mainCalId'; // this cal_id now
72    public const CLS_DEST_ID = 'destCalId';
73    // external mode
74    public const CLS_XTM_SRC_ID = 'nrSrcCalId';
75    public const CLS_XTM_DST_ID = 'nrDstCalId';
76    public const CLS_XTM_PUSH_REC = 'nrPushRec';
77    public const CLS_XTM_REQ_CAT = 'nrRequireCat';
78    public const CLS_XTM_AUTO_FIX = 'nrAutoFix';
79    // template mode
80    public const CLS_TMM_DST_ID = 'tmmDstCalId';
81    public const CLS_TMM_MORE_CALS = 'tmmMoreCals';
82    public const CLS_TMM_SUBSCRIPTIONS = 'tmmSubscriptions';
83    public const CLS_TMM_SUBSCRIPTIONS_SYNC = 'tmmSubscriptionsSync';
84    // --
85    public const CLS_PREP_TIME = 'prepTime';
86    public const CLS_ON_CANCEL = 'whenCanceled';
87    public const CLS_ALL_DAY_BLOCK = 'allDayBlock';
88    public const CLS_TS_MODE = 'tsMode';
89    // values for tsMode
90    public const CLS_TS_MODE_SIMPLE = '0';
91    public const CLS_TS_MODE_EXTERNAL = '1';
92    public const CLS_TS_MODE_TEMPLATE = '2';
93
94    public const KEY_TMPL_DATA = 'template_data';
95    public const KEY_TMPL_INFO = 'template_info';
96    public const TMPL_TZ_NAME = "tzName";
97    public const TMPL_TZ_DATA = "tzData";
98
99    public const KEY_PSN = "page_options";
100    public const PSN_PAGE_TITLE = "pageTitle";
101    public const PSN_FNED = "startFNED";
102    public const PSN_PAGE_STYLE = "pageStyle";
103    public const PSN_GDPR = "gdpr";
104    public const PSN_FORM_TITLE = "formTitle";
105    public const PSN_META_NO_INDEX = "metaNoIndex";
106    public const PSN_EMPTY = "showEmpty";
107    public const PSN_WEEKEND = "showWeekends";
108    public const PSN_PAGE_SUB_TITLE = "pageSubTitle";
109    public const PSN_NWEEKS = "nbrWeeks";
110    public const PSN_TIME2 = "time2Cols";
111    public const PSN_HIDE_TEL = "hidePhone";
112    public const PSN_END_TIME = "endTime";
113    public const PSN_SHOW_TZ = "showTZ";
114
115    public const KEY_MPS_COL = "more_pages";
116    public const KEY_MPS = "more_pages_";
117
118    public const KEY_PAGES = "pages";
119    public const PAGES_ENABLED = "enabled";
120    public const PAGES_LABEL = "label";
121
122    public const PAGES_VAL_DEF = array(
123        self::PAGES_ENABLED => 0,
124        self::PAGES_LABEL => ""
125    );
126
127    public const KEY_DIR = "dir_info";
128
129    public const KEY_TALK = "appt_talk";
130    public const TALK_ENABLED = "enabled";
131    public const TALK_DEL_ROOM = "delete";
132    public const TALK_EMAIL_TXT = "emailText";
133    public const TALK_LOBBY = "lobby";
134    public const TALK_PASSWORD = "password";
135    public const TALK_NAME_FORMAT = "nameFormat";
136    public const TALK_FORM_ENABLED = "formFieldEnable";
137    public const TALK_FORM_LABEL = "formLabel";
138    public const TALK_FORM_PLACEHOLDER = "formPlaceholder";
139    public const TALK_FORM_REAL_TXT = "formTxtReal";
140    public const TALK_FORM_VIRTUAL_TXT = "formTxtVirtual";
141
142    public const TALK_FORM_DEF_LABEL = "formDefLabel";
143    public const TALK_FORM_DEF_PLACEHOLDER = "formDefPlaceholder";
144    public const TALK_FORM_DEF_REAL = "formDefReal";
145    public const TALK_FORM_DEF_VIRTUAL = "formDefVirtual";
146
147    public const TALK_FORM_TYPE_CHANGE_TXT = "formTxtTypeChange";
148
149    public const KEY_FORM_INPUTS_JSON = 'fi_json';
150    public const KEY_FORM_INPUTS_HTML = 'fi_html';
151
152    public const KEY_REMINDERS = "reminders";
153    public const REMINDER_DATA = "data";
154    public const REMINDER_DATA_TIME = "seconds";
155    public const REMINDER_DATA_ACTIONS = "actions";
156    public const REMINDER_SEND_ON_FRIDAY = "friday";
157    public const REMINDER_MORE_TEXT = "moreText";
158    // Read only background_job_mode from appconfig and overwrite.cli.url from getSystemValue
159    public const REMINDER_BJM = "bjm";
160    public const REMINDER_CLI_URL = "cliUrl";
161
162
163    private $appName = Application::APP_ID;
164    /** @var array */
165    private $settings = null;
166
167    private $logger;
168
169    /** @var IDBConnection */
170    private $db;
171
172    private $urlGenerator;
173
174    public function __construct(LoggerInterface $logger,
175                                IDBConnection   $db,
176                                IURLGenerator   $urlGenerator
177    ) {
178        $this->logger = $logger;
179        $this->db = $db;
180        $this->urlGenerator = $urlGenerator;
181    }
182
183    /**
184     * @param \DateTimeImmutable $new_start
185     * @param \DateTimeImmutable $new_end
186     * @param int $skipped number of skipped recurrences (to adjust the 'COUNT')
187     * @param \Sabre\VObject\Component\VCalendar $vo
188     */
189    function optimizeRecurrence($new_start, $new_end, $skipped, $vo) {
190
191        /**  @var \Sabre\VObject\Component\VEvent $evt */
192        $evt = $vo->VEVENT;
193
194        $is_floating = $evt->DTSTART->isFloating();
195
196        $evt->DTSTART->setDateTime($new_start, $is_floating);
197        // there can be "DURATION" instead of "DTSTART"
198        if (isset($evt->DTEND)) {
199            // adjust end time
200            $evt->DTEND->setDateTime($new_end, $is_floating);
201        }
202
203        $this->setSEQ($evt);
204
205        //adjust count if present
206        $rra = $evt->RRULE->getParts();
207        if (isset($rra['COUNT'])) {
208            $rra['COUNT'] -= $skipped;
209            $evt->RRULE->setParts($rra);
210        }
211    }
212
213    /**
214     * @param $data
215     * @param $info
216     * @param $userId
217     * @param $uri
218     * @return string   Event Data |
219     *                  "1"=Bad Status (Most likely booked while waiting),
220     *                  "2"=Other Error
221     */
222    function dataSetAttendee($data, $info, $userId, $uri) {
223
224        $vo = Reader::read($data);
225
226        if ($vo === null || !isset($vo->VEVENT)) {
227            $this->logger->error("Bad Data: not an event");
228            return "2";
229        }
230
231        /** @var \Sabre\VObject\Component\VEvent $evt */
232        $evt = $vo->VEVENT;
233
234        if (!isset($evt->STATUS) || $evt->STATUS->getValue() !== 'TENTATIVE') {
235            $this->logger->error("Bad Status: must be TENTATIVE");
236            return "1";
237        }
238
239        if (!isset($evt->CATEGORIES) || $evt->CATEGORIES->getValue() !== BackendUtils::APPT_CAT) {
240            $this->logger->error("Bad Category: not an " . BackendUtils::APPT_CAT);
241            return "2";
242        }
243
244        $config = \OC::$server->getConfig();
245        // @see issues #120 and #116
246        // Should this be documented ???
247        // TODO: this should be $config->getAppValue(...)
248        $e_fix = $config->getUserValue($userId, $this->appName, self::KEY_EMAIL_FIX);
249
250        if ($e_fix === 'none') {
251            $a = $evt->add('ATTENDEE', "mailto:" . $info['email']);
252        } elseif ($e_fix === 'scheme') {
253            $a = $evt->add('ATTENDEE', "acct:" . $info['email']);
254        } else {
255            $a = $evt->add('ATTENDEE', "mailto:" . $info['email']);
256            $a['SCHEDULE-AGENT'] = "CLIENT";
257        }
258
259        $a['CN'] = $info['name'];
260        $a['PARTSTAT'] = "NEEDS-ACTION";
261
262        $title = "";
263        if (!isset($evt->SUMMARY)) {
264            $evt->add('SUMMARY');
265        } else {
266            $t = $evt->SUMMARY->getValue();
267            if ($t[0] === "_") $title = $t;
268        }
269        $evt->SUMMARY->setValue("⌛ " . $info['name']);
270
271        $dsr = $info['name'] . "\n" . (empty($info['phone']) ? "" : ($info['phone'] . "\n")) . $info['email'] . $info['_more_data'];
272        if (!isset($evt->DESCRIPTION)) $evt->add('DESCRIPTION');
273        $evt->DESCRIPTION->setValue($dsr);
274
275        if (!isset($evt->{self::X_DSR})) $evt->add(self::X_DSR);
276        $evt->{self::X_DSR}->setValue($dsr);
277
278        if (!isset($evt->STATUS)) $evt->add('STATUS');
279        $evt->STATUS->setValue("CONFIRMED");
280
281        if (!isset($evt->TRANSP)) $evt->add('TRANSP');
282        $evt->TRANSP->setValue("OPAQUE");
283
284        // Attendee's timezone info at the time of booking
285        if (!isset($evt->{self::TZI_PROP})) $evt->add(self::TZI_PROP);
286        $evt->{self::TZI_PROP}->setValue($info['tzi']);
287
288        // isset($info['talk_type_real']) === No need for Talk room
289
290        // Additional appointment info (XAD_PROP):
291        //  0: userId
292        //  1: _title
293        //  2: pageId
294        //  3: embed (uri)
295        //  4: reserved for Talk link @see $this->dataConfirmAttendee()
296        //      'd' === add self::TALK_FORM_REAL_TXT to description - no need for Talk room
297        //      '_' === check if Talk room is needed
298        //      'f' === finished
299        //  5: reserved for Talk pass @see $this->dataConfirmAttendee()
300        if (!isset($evt->{self::XAD_PROP})) $evt->add(self::XAD_PROP);
301        $evt->{self::XAD_PROP}->setValue($this->encrypt(
302            $userId . chr(31)
303            . $title . chr(31)
304            . $info['_page_id'] . chr(31)
305            . $info['_embed'] . chr(31)
306            // talk link, if isset($info['talk_type_real']) means no need for talk room, @see PageController->showFormPost()
307            . (isset($info['talk_type_real']) ? 'd' : '_') . chr(31)
308            . '_', // talk pass
309            $evt->UID));
310
311        $this->setSEQ($evt);
312
313        $this->setApptHash($evt, $userId, $info['_page_id'], $uri);
314
315        return $vo->serialize();
316    }
317
318    /**
319     * @param $uri
320     * @param $userId
321     * @return string[] [new meeting type, '' === error, data]
322     */
323    function dataChangeApptType($data, $userId) {
324        $r = ['', ''];
325
326        $vo = $this->getAppointment($data, 'CONFIRMED');
327        if ($vo === null) return $r;
328
329        /** @var \Sabre\VObject\Component\VEvent $evt */
330        $evt = $vo->VEVENT;
331
332        if (isset($evt->{BackendUtils::XAD_PROP})) {
333            // @see BackendUtils->dataSetAttendee for BackendUtils::XAD_PROP
334            $xad = explode(chr(31), $this->decrypt(
335                $evt->{BackendUtils::XAD_PROP}->getValue(),
336                $evt->UID->getValue()));
337
338            if (count($xad) > 4) {
339
340                $a = $this->getAttendee($evt);
341                if ($a === null) {
342                    return $r;
343                }
344
345                if ($xad[4] === 'f') {
346                    // the appointment was previously finalized as "in-person"
347                    // ... so, set $xad[4]='_' @see BackendUtils->dataSetAttendee
348                    // this will add a talk room and description when a addTalkInfo is called
349                    $xad[4] = '_';
350
351                } elseif (strlen($xad[4]) > 1) {
352                    // this was a virtual appointment...
353                    // ... $xad[4] is the room token.
354                    // delete the room first...
355
356                    $tlk = $this->getUserSettings(self::KEY_TALK, $userId);
357                    $ti = new TalkIntegration($tlk, $this);
358                    $ti->deleteRoom($xad[4]);
359
360                    // set $xad[4]='d' which will just and description @see BackendUtils->dataSetAttendee
361                    $xad[4] = 'd';
362                }
363
364                $new_type = $this->addEvtTalkInfo($userId, $xad, $evt, $a);
365                $r[0] = $new_type;
366                $r[1] = $vo->serialize();
367            }
368        }
369
370        return $r;
371    }
372
373    /**
374     * @param $data
375     * @param string $userId
376     * @return array [string|null, string|null, string|null]
377     *                  null=error|""=already confirmed,
378     *                  Localized DateTime string
379     *                  $pageId
380     */
381    function dataConfirmAttendee($data, $userId) {
382
383        $vo = $this->getAppointment($data, 'CONFIRMED');
384        if ($vo === null) return [null, null, null];
385
386        /** @var \Sabre\VObject\Component\VEvent $evt */
387        $evt = $vo->VEVENT;
388
389        $a = $this->getAttendee($evt);
390        if ($a === null) {
391            return [null, null, null];
392        }
393
394        if (isset($evt->{BackendUtils::XAD_PROP})) {
395            // @see BackendUtils->dataSetAttendee for BackendUtils::XAD_PROP
396            $xad = explode(chr(31), $this->decrypt(
397                $evt->{BackendUtils::XAD_PROP}->getValue(),
398                $evt->UID->getValue()));
399            if (count($xad) > 2) {
400                $pageId = $xad[2];
401            } else {
402                $pageId = 'p0';
403            }
404        } else {
405            return [null, null, null];
406        }
407
408        $dts = $this->getDateTimeString(
409            $evt->DTSTART->getDateTime(),
410            $evt->{self::TZI_PROP}->getValue()
411        );
412
413        if ($a->parameters['PARTSTAT']->getValue() === 'ACCEPTED') {
414            return ["", $dts, $pageId];
415        }
416
417        $a->parameters['PARTSTAT']->setValue('ACCEPTED');
418
419        if (!isset($evt->SUMMARY)) $evt->add('SUMMARY'); // ???
420        $evt->SUMMARY->setValue("✔️ " . $a->parameters['CN']->getValue());
421
422        //Talk link
423        $this->addEvtTalkInfo($userId, $xad, $evt, $a);
424
425        $this->setSEQ($evt);
426
427        $this->setApptHash($evt, $xad[0], $pageId);
428
429        return [$vo->serialize(), $dts, $pageId];
430    }
431
432    /**
433     * @param $userId
434     * @param $xad
435     * @param $evt
436     * @param $a - attendee
437     * @return string new appointment type virtual/in-person (from talk settings)
438     */
439    private function addEvtTalkInfo($userId, $xad, $evt, $a) {
440        $r = '';
441
442        if (count($xad) > 4) {
443            if ($xad[4] === '_') {
444                $tlk = $this->getUserSettings(self::KEY_TALK, $userId);
445                // check if Talk link is needed
446                if ($tlk[self::TALK_ENABLED] === true) {
447                    $ti = new TalkIntegration($tlk, $this);
448                    $token = $ti->createRoomForEvent(
449                        $a->parameters['CN']->getValue(),
450                        $evt->DTSTART,
451                        $userId);
452                    if (!empty($token)) {
453
454                        $l10n = \OC::$server->getL10N($this->appName);
455                        if ($token !== "-") {
456                            $pi = '';
457                            if (strpos($token, chr(31)) === false) {
458                                // just token
459                                $xad[4] = $token;
460                            } else {
461                                // taken + pass
462                                list($xad[4], $xad[5]) = explode(chr(31), $token);
463                                $pi = "\n" . $l10n->t("Guest password:") . " " . $xad[5];
464                                $token = $xad[4];
465                            }
466                            $evt->{self::XAD_PROP}->setValue($this->encrypt(
467                                implode(chr(31), $xad), $evt->UID));
468
469                            $this->updateDescription($evt, "\n\n" .
470                                $ti->getRoomURL($token) . $pi);
471
472                            $r = (!empty($tlk[self::TALK_FORM_VIRTUAL_TXT])
473                                ? $tlk[self::TALK_FORM_VIRTUAL_TXT]
474                                : $tlk[self::TALK_FORM_DEF_VIRTUAL]);
475
476                        } else {
477
478                            $this->updateDescription($evt, "\n\n" .
479                                $l10n->t("Talk integration error: check logs"));
480                        }
481                    }
482                }
483            } elseif ($xad[4] === 'd') {
484                // meeting type is overridden by client to real,
485                // set xad to 'f' and add self::TALK_FORM_REAL_TXT to description
486                $xad[4] = 'f';
487                $evt->{self::XAD_PROP}->setValue($this->encrypt(
488                    implode(chr(31), $xad), $evt->UID));
489
490                $tlk = $this->getUserSettings(self::KEY_TALK, $userId);
491
492                $r = (!empty($tlk[self::TALK_FORM_REAL_TXT])
493                    ? $tlk[self::TALK_FORM_REAL_TXT]
494                    : $tlk[self::TALK_FORM_DEF_REAL]);
495
496                $this->updateDescription($evt, "\n\n" . $r);
497            }
498        }
499
500        return $r;
501    }
502
503    /**
504     * @param \Sabre\VObject\Component\VEvent $evt
505     * @param $addString string text to be added to original description
506     */
507    private function updateDescription($evt, $addString) {
508        // just in-case
509        if (!isset($evt->DESCRIPTION)) $evt->add('DESCRIPTION');
510
511        if (isset($evt->{self::X_DSR})) {
512            // we have original description
513            $d = $evt->{self::X_DSR}->getValue();
514        } else {
515            $d = $evt->DESCRIPTION->getValue();
516        }
517
518        $evt->DESCRIPTION->setValue($d . $addString);
519    }
520
521    /**
522     * @param $data
523     * @return array [string|null, string|null, string|null]
524     *                  null=error|""=already canceled
525     *                  Localized DateTime string
526     */
527    function dataCancelAttendee($data) {
528
529        $vo = $this->getAppointment($data, '*');
530        if ($vo === null) return [null, null, null];
531
532        /** @var \Sabre\VObject\Component\VEvent $evt */
533        $evt = $vo->VEVENT;
534
535        if ($evt->STATUS->getValue() === 'TENTATIVE') {
536            // Can not cancel tentative appointments
537            return [null, null, null];
538        }
539
540        $a = $this->getAttendee($evt);
541        if ($a === null) {
542            return [null, null, null];
543        }
544
545        if (isset($evt->{BackendUtils::XAD_PROP})) {
546            // @see BackendUtils->dataSetAttendee for BackendUtils::XAD_PROP
547            $xad = explode(chr(31), $this->decrypt(
548                $evt->{BackendUtils::XAD_PROP}->getValue(),
549                $evt->UID->getValue()));
550            if (count($xad) > 2) {
551                $pageId = $xad[2];
552            } else {
553                $pageId = 'p0';
554            }
555        } else {
556            return [null, null, null];
557        }
558
559        $dts = $this->getDateTimeString(
560            $evt->DTSTART->getDateTime(),
561            $evt->{self::TZI_PROP}->getValue()
562        );
563
564        if ($a->parameters['PARTSTAT']->getValue() === 'DECLINED'
565            || $evt->STATUS->getValue() === 'CANCELLED') {
566            // Already cancelled
567            return ["", $dts, $pageId];
568        }
569
570        $this->evtCancelAttendee($evt);
571
572        $this->setSEQ($evt);
573
574        $this->setApptHash($evt, $xad[0], $pageId);
575
576        return [$vo->serialize(), $dts, $pageId];
577    }
578
579    /**
580     * This is also called from DavListener
581     * @param \Sabre\VObject\Component\VEvent $evt
582     * @noinspection PhpParameterByRefIsNotUsedAsReferenceInspection
583     */
584    function evtCancelAttendee(&$evt) {
585
586        $a = $this->getAttendee($evt);
587        if ($a === null) {
588            $this->logger->error("evtCancelAttendee() bad attendee");
589            return;
590        }
591
592        $a->parameters['PARTSTAT']->setValue('DECLINED');
593
594        if (!isset($evt->SUMMARY)) $evt->add('SUMMARY'); // ???
595        $evt->SUMMARY->setValue($a->parameters['CN']->getValue());
596
597        $evt->STATUS->setValue('CANCELLED');
598
599        if (!isset($evt->TRANSP)) $evt->add('TRANSP');
600        $evt->TRANSP->setValue("TRANSPARENT");
601
602
603    }
604
605
606    /**
607     * Returns Array [
608     *          Localized DateTime string,
609     *          "dtsamp,dtstart,dtend" (string)
610     *          $tz_data for new appointment can be one of:
611     *                  VTIMEZONE data,
612     *                  'L' = floating (default)
613     *                  'UTC' for UTC/GMT
614     *          $title the title might need to be reset to original when the appointment is canceled (can be empty)
615     * ]
616     * @param string $data
617     * @return string[]
618     * @noinspection PhpDocMissingThrowsInspection
619     */
620    function dataDeleteAppt($data) {
621        $f = "";
622        $vo = $this->getAppointment($data, 'CONFIRMED');
623        if ($vo === null) return ['', '', $f, ''];
624
625        /** @var \Sabre\VObject\Component\VEvent $evt */
626        $evt = $vo->VEVENT;
627
628        if (isset($evt->DTSTART) && isset($evt->DTEND)) {
629            /** @noinspection PhpUnhandledExceptionInspection */
630            $dt = (new \DateTime('now', new \DateTimeZone('utc')))->format("Ymd\THis") . "Z," .
631                rtrim($evt->DTSTART->getRawMimeDirValue(), 'Z') . "," .
632                rtrim($evt->DTEND->getRawMimeDirValue(), 'Z');
633
634            if (!$evt->DTSTART->isFloating()) {
635                if (isset($evt->DTSTART['TZID']) && isset($vo->VTIMEZONE)) {
636                    $f = $vo->VTIMEZONE->serialize();
637                    if (empty($f)) $f = 'UTC'; // <- ???
638                } else {
639                    $f = 'UTC';
640                }
641            }
642        } else {
643            $dt = "";
644        }
645
646        $title = "";
647        $xad = explode(chr(31), $this->decrypt(
648            $evt->{BackendUtils::XAD_PROP}->getValue(),
649            $evt->UID->getValue()));
650
651        // @see dataSetAttendee() $xad=...
652        if (count($xad) > 1 && !empty($xad[1]) && $xad[1][0] === '_') {
653            $title = $xad[1];
654        }
655
656        return [$this->getDateTimeString(
657            $evt->DTSTART->getDateTime(),
658            $evt->{self::TZI_PROP}->getValue()
659        ), $dt, $f, $title];
660    }
661
662    /**
663     * @param \Sabre\VObject\Component\VEvent $evt
664     * @return \Sabre\VObject\Property|null
665     */
666    function getAttendee($evt) {
667        $r = null;
668        $ao = null;
669
670        $ov = $evt->ORGANIZER->getValue();
671        $ov = trim(substr($ov, strpos($ov, ":") + 1));
672
673        $aa = $evt->ATTENDEE;
674        $c = count($aa);
675        for ($i = 0; $i < $c; $i++) {
676            $a = $aa[$i];
677            $v = $a->getValue();
678            if (isset($a->parameters['CN']) && isset($a->parameters['PARTSTAT'])) {
679                // Some external clients set SCHEDULE-STATUS to 3.7 because of the "acct" scheme
680                if (isset($a->parameters['SCHEDULE-STATUS'])) {
681                    unset($a->parameters['SCHEDULE-STATUS']);
682                }
683                // Some external clients add organizer as attendee we only use if it is the only attendee (testing), otherwise we look for the one that does NOT match the organizer
684                if ($ov === trim(substr($v, strpos($v, ":") + 1))) {
685                    $ao = $a;
686                    continue;
687                }
688                $r = $a;
689                break;
690            }
691        }
692        return $r !== null ? $r : $ao;
693    }
694
695    /**
696     * @param string $uid
697     * @return string|null
698     */
699    function getApptHash($uid) {
700        $query = $this->db->getQueryBuilder();
701        $query->select(['hash'])
702            ->from(self::HASH_TABLE_NAME)
703            ->where($query->expr()->eq('uid', $query->createNamedParameter($uid)));
704        $stmt = $query->execute();
705        $row = $stmt->fetch(\PDO::FETCH_ASSOC);
706        $stmt->closeCursor();
707
708        if (!$row) {
709            return null;
710        } else {
711            return $row['hash'];
712        }
713    }
714
715    function setApptHash(\Sabre\VObject\Component\VEvent $evt, string $userId, string $pageId, $uri = null) {
716        if (!isset($evt->UID)) {
717            $this->logger->error("can't set appt_hash, no UID");
718            return;
719        }
720        if (!isset($evt->DTSTART)) {
721            $this->logger->error("can't set appt_hash, no DTSTART");
722            return;
723        }
724
725        $status = null;
726        if (isset($evt->STATUS)) {
727            switch ($evt->STATUS->getValue()) {
728                case "TENTATIVE":
729                    $status = self::PREF_STATUS_TENTATIVE;
730                    break;
731                case "CONFIRMED":
732                    $status = self::PREF_STATUS_CONFIRMED;
733                    if (rand(0, 10) > 5) {
734                        // cleanup hash table once in while
735                        // TODO: use "start" instead of hash after 2022-01-02
736                        $cutoff_str = (new \DateTime())->modify('-42 days')->format(BackendUtils::FLOAT_TIME_FORMAT);
737                        $query = $this->db->getQueryBuilder();
738                        $query->delete(BackendUtils::HASH_TABLE_NAME)
739                            ->where($query->expr()->lt('hash',
740                                $query->createNamedParameter($cutoff_str)))
741                            ->execute();
742                    }
743                    break;
744                case "CANCELLED":
745                    $status = self::PREF_STATUS_CANCELLED;
746                    break;
747                default;
748                    $status = null;
749            }
750        }
751
752        $uid = $evt->UID->getValue();
753
754        $query = $this->db->getQueryBuilder();
755
756        $start_ts = $evt->DTSTART->getDateTime()->getTimestamp();
757        if ($this->getApptHash($uid) === null) {
758
759            $values = [
760                'uid' => $query->createNamedParameter($uid),
761                'hash' => $query->createNamedParameter(
762                    $this->makeApptHash($evt)),
763                'user_id' => $query->createNamedParameter($userId),
764                'start' => $query->createNamedParameter($start_ts),
765                'status' => $query->createNamedParameter($status),
766                'page_id' => $query->createNamedParameter($pageId),
767            ];
768            if ($uri !== null) {
769                $values['uri'] = $query->createNamedParameter($pageId);
770            }
771
772            $query->insert(self::HASH_TABLE_NAME)
773                ->values($values)
774                ->execute();
775        } else {
776            $query->update(self::HASH_TABLE_NAME)
777                ->set('uid', $query->createNamedParameter($uid))
778                ->set('hash', $query->createNamedParameter(
779                    $this->makeApptHash($evt)))
780                ->set('start', $query->createNamedParameter($start_ts))
781                ->set('status', $query->createNamedParameter($status))
782                ->set('page_id', $query->createNamedParameter($pageId));
783            if ($uri !== null) {
784                $query->set('uri', $query->createNamedParameter($uri));
785            }
786
787            $query->where($query->expr()->eq('uid', $query->createNamedParameter($uid)))
788                ->execute();
789        }
790    }
791
792    function deleteApptHash($evt) {
793
794        if (!isset($evt->UID)) {
795            $this->logger->error("can't delete appt_hash, no UID");
796            return;
797        }
798
799        $this->deleteApptHashByUID(
800            $this->db,
801            $evt->UID->getValue()
802        );
803    }
804
805    function deleteApptHashByUID(IDBConnection $db, string $uid) {
806        $query = $db->getQueryBuilder();
807        $query->delete(self::HASH_TABLE_NAME)
808            ->where($query->expr()->eq('uid',
809                $query->createNamedParameter($uid)))
810            ->execute();
811    }
812
813
814    function makeApptHash($evt) {
815        // !! ORDER IS IMPORTANT - DO NOT CHANGE !! //
816        $hs = "";
817        if (isset($evt->DTSTART)) {
818            $hs .= str_replace("T", ".", $evt->DTSTART->getRawMimeDirValue());
819        } else {
820            $hs .= "99999999.000000";
821        }
822        if (isset($evt->STATUS)) {
823            $hs .= hash("crc32", $evt->STATUS->getValue(), false);
824        } else {
825            $hs .= "00000000";
826        }
827        if (isset($evt->LOCATION)) {
828            $hs .= hash("crc32", $evt->LOCATION->getValue(), false);
829        } else {
830            $hs .= "00000000";
831        }
832        return $hs;
833    }
834
835    /**
836     * @param string $hash
837     * @param \Sabre\VObject\Component\VEvent $evt
838     * @return bool
839     */
840    function isApptCancelled($hash, $evt) {
841        // 1e5189eb = hash("crc32", "CANCELLED", false)
842        return $evt->STATUS->getValue() === "CANCELLED" && substr($hash, 15, 8) === "1e5189eb";
843    }
844
845    /**
846     * @param string $hash
847     * @return float
848     */
849    function getHashDTStart($hash) {
850        // TODO: this really should be the DTEND
851        return (float)substr($hash, 0, 15);
852    }
853
854    /**
855     * Returns null when there are no changes, array otherwise:
856     *  [index 0 - true if DTSTART changed,
857     *   index 1 - true if STATUS changed,
858     *   index 2 - true if LOCATION changed]
859     *
860     * @param string $hash
861     * @param \Sabre\VObject\Component\VEvent $evt
862     * @return bool[]|null
863     */
864    function getHashChanges($hash, $evt) {
865        $evt_hash = $this->makeApptHash($evt);
866        if ($hash === $evt_hash) return null; // not changed
867
868        return [
869            substr($hash, 0, 15) !== substr($evt_hash, 0, 15),
870            substr($hash, 15, 8) !== substr($evt_hash, 15, 8),
871            substr($hash, 23, 8) !== substr($evt_hash, 23, 8)
872        ];
873    }
874
875    /**
876     * @param \Sabre\VObject\Component\VEvent $evt
877     */
878    function setSEQ($evt) {
879        if (!isset($evt->SEQUENCE)) $evt->add('SEQUENCE', 1);
880        else {
881            $sv = intval($evt->SEQUENCE->getValue());
882            $evt->SEQUENCE->setValue($sv + 1);
883        }
884        if (!isset($evt->{'LAST-MODIFIED'})) $evt->add('LAST-MODIFIED');
885        $evt->{'LAST-MODIFIED'}->setValue(new \DateTime());
886    }
887
888    /**
889     * @param string $data
890     * @param string $status fail is STATUS does not match
891     * @return \Sabre\VObject\Document|null
892     */
893    function getAppointment($data, $status) {
894        $vo = Reader::read($data);
895
896        if ($vo === null || !isset($vo->VEVENT)) {
897            $this->logger->error("Bad Data: not an event");
898            return null;
899        }
900        /** @var \Sabre\VObject\Component\VEvent $evt */
901        $evt = $vo->VEVENT;
902
903        if (!$evt->DTSTART->hasTime()) {
904            // no all-day events
905            return null;
906        }
907
908        if (!isset($evt->STATUS) || ($status !== "*" && $evt->STATUS->getValue() !== $status)) {
909            $this->logger->error("Bad Status: must be " . $status);
910            return null;
911        }
912
913        if (!isset($evt->CATEGORIES) || $evt->CATEGORIES->getValue() !== BackendUtils::APPT_CAT) {
914            $this->logger->error("Bad Category: not an " . BackendUtils::APPT_CAT);
915            return null;
916        }
917
918        if (!isset($evt->{self::TZI_PROP})) {
919            $this->logger->error("Missing " . self::TZI_PROP . " property");
920            return null;
921        }
922
923        if ($this->getAttendee($evt) === null) {
924            $this->logger->error("Bad ATTENDEE attribute");
925            return null;
926        }
927
928        return $vo;
929    }
930
931    function getDefaultForKey($key) {
932        switch ($key) {
933            case self::KEY_ORG:
934                $d = array(
935                    self::ORG_NAME => "",
936                    self::ORG_EMAIL => "",
937                    self::ORG_ADDR => "",
938                    self::ORG_PHONE => "");
939                break;
940            case self::KEY_EML:
941                $d = array(
942                    self::EML_ICS => false,
943                    self::EML_SKIP_EVS => false,
944                    self::EML_AMOD => true,
945                    self::EML_ADEL => true,
946                    self::EML_MREQ => false,
947                    self::EML_MCONF => false,
948                    self::EML_MCNCL => false,
949                    self::EML_VLD_TXT => "",
950                    self::EML_CNF_TXT => "");
951                break;
952            case self::KEY_CLS:
953                $d = array(
954                    self::CLS_MAIN_ID => '-1',
955                    self::CLS_DEST_ID => '-1',
956
957                    self::CLS_XTM_SRC_ID => '-1',
958                    self::CLS_XTM_DST_ID => '-1',
959                    self::CLS_XTM_PUSH_REC => true,
960                    self::CLS_XTM_REQ_CAT => false,
961                    self::CLS_XTM_AUTO_FIX => false,
962
963                    self::CLS_TMM_DST_ID => '-1',
964                    self::CLS_TMM_MORE_CALS => [],
965                    self::CLS_TMM_SUBSCRIPTIONS => [],
966                    self::CLS_TMM_SUBSCRIPTIONS_SYNC => '0',
967
968                    self::CLS_PREP_TIME => "0",
969                    self::CLS_ON_CANCEL => 'mark',
970                    self::CLS_ALL_DAY_BLOCK => false,
971
972                    self::CLS_TS_MODE => self::CLS_TS_MODE_TEMPLATE);
973                break;
974            case self::KEY_PSN:
975                $d = array(
976                    self::PSN_FORM_TITLE => "",
977                    self::PSN_NWEEKS => "1",
978                    self::PSN_EMPTY => true,
979                    self::PSN_FNED => false, // start at first not empty day
980                    self::PSN_WEEKEND => false,
981                    self::PSN_TIME2 => false,
982                    self::PSN_END_TIME => false,
983                    self::PSN_HIDE_TEL => false,
984                    self::PSN_SHOW_TZ => false,
985                    self::PSN_GDPR => "",
986                    self::PSN_PAGE_TITLE => "",
987                    self::PSN_PAGE_SUB_TITLE => "",
988                    self::PSN_META_NO_INDEX => false,
989                    self::PSN_PAGE_STYLE => "");
990                break;
991            case self::KEY_MPS_COL:
992                $d = null;
993                break;
994            case self::KEY_MPS:
995                $d = array(
996                    self::CLS_MAIN_ID => '-1',
997                    self::CLS_DEST_ID => '-1',
998                    self::CLS_XTM_SRC_ID => '-1',
999                    self::CLS_XTM_DST_ID => '-1',
1000                    self::CLS_TMM_DST_ID => '-1',
1001                    self::CLS_TMM_MORE_CALS => [],
1002                    self::CLS_TMM_SUBSCRIPTIONS => [],
1003
1004                    self::CLS_TS_MODE => self::CLS_TS_MODE_TEMPLATE,
1005
1006                    self::ORG_NAME => "",
1007                    self::ORG_EMAIL => "",
1008                    self::ORG_ADDR => "",
1009                    self::ORG_PHONE => "",
1010
1011                    self::PSN_FORM_TITLE => "");
1012                break;
1013            case self::KEY_PAGES:
1014                $d = array('p0' => self::PAGES_VAL_DEF);
1015                break;
1016            case self::KEY_TALK:
1017                $d = array(
1018                    self::TALK_ENABLED => false,
1019                    self::TALK_DEL_ROOM => false,
1020                    self::TALK_EMAIL_TXT => "",
1021                    self::TALK_LOBBY => false,
1022                    self::TALK_PASSWORD => false,
1023                    // 0=Name+DT, 1=DT+Name, 2=Name Only
1024                    self::TALK_NAME_FORMAT => 0,
1025                    self::TALK_FORM_ENABLED => false,
1026                    self::TALK_FORM_LABEL => "",
1027                    self::TALK_FORM_PLACEHOLDER => "",
1028                    self::TALK_FORM_REAL_TXT => "",
1029                    self::TALK_FORM_VIRTUAL_TXT => "",
1030                    self::TALK_FORM_TYPE_CHANGE_TXT => "");
1031                break;
1032            case self::KEY_DIR:
1033            case self::KEY_TMPL_DATA:
1034                $d = array();
1035                break;
1036            case self::KEY_TMPL_INFO:
1037                $d = array('p0' => array(
1038                    self::TMPL_TZ_NAME => "",
1039                    self::TMPL_TZ_DATA => "")
1040                );
1041                break;
1042            case self::KEY_REMINDERS:
1043                $d = array(
1044                    self::REMINDER_DATA => [
1045                        [
1046                            self::REMINDER_DATA_TIME => "0",
1047                            self::REMINDER_DATA_ACTIONS => true
1048                        ],
1049                        [
1050                            self::REMINDER_DATA_TIME => "0",
1051                            self::REMINDER_DATA_ACTIONS => true
1052                        ],
1053                        [
1054                            self::REMINDER_DATA_TIME => "0",
1055                            self::REMINDER_DATA_ACTIONS => true
1056                        ],
1057                    ],
1058                    self::REMINDER_SEND_ON_FRIDAY => false,
1059                    self::REMINDER_MORE_TEXT => "");
1060                break;
1061            default:
1062                $d = null;
1063        }
1064        return $d;
1065    }
1066
1067
1068    private function loadSettingsFromDB($userId) {
1069        $qb = $this->db->getQueryBuilder();
1070        $c = $qb->select('*')
1071            ->from(self::PREF_TABLE_NAME)
1072            ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)))
1073            ->execute();
1074        $t = $c->fetch();
1075        $c->closeCursor();
1076
1077        if ($t === false) {
1078            $this->settings = [];
1079        } else {
1080            $this->settings = $t;
1081        }
1082    }
1083
1084    function getTemplateData($pageId, $userId) {
1085        return $this->getUserSettings(self::KEY_TMPL_DATA, $userId)[$pageId] ?? array([], [], [], [], [], [], []);
1086    }
1087
1088    function setTemplateData($pageId, $value, $userId) {
1089        $td = $this->getUserSettings(self::KEY_TMPL_DATA, $userId);
1090        $td[$pageId] = json_decode($value, true) ?? array([], [], [], [], [], [], []);
1091        $jv = json_encode($td);
1092        if ($jv === false) {
1093            return false;
1094        } else {
1095            return $this->setDBValue($userId, self::KEY_TMPL_DATA, $jv);
1096        }
1097    }
1098
1099    function clearSettingsCache() {
1100        $this->settings = null;
1101    }
1102
1103    /**
1104     * @param string $key
1105     * @param string $userId
1106     * @return array
1107     */
1108    function getUserSettings($key, $userId) {
1109
1110        if ($this->settings === null) {
1111            $this->loadSettingsFromDB($userId);
1112        }
1113
1114        $pn = "";
1115
1116        $default = $this->getDefaultForKey($key);
1117
1118        if ($key === self::KEY_TMPL_DATA) {
1119            return json_decode($this->settings[self::KEY_TMPL_DATA] ?? null, true) ?? $default;
1120        } else if (strpos($key, self::KEY_MPS) === 0) {
1121            $pn = substr($key, strlen(self::KEY_MPS));
1122            $key = self::KEY_MPS_COL;
1123            $default = $this->getDefaultForKey(self::KEY_MPS);
1124        } else if ($key === self::KEY_TALK) {
1125            // Translate defaults
1126            $l10n = \OC::$server->getL10N($this->appName);
1127            $default[self::TALK_FORM_DEF_LABEL] = $l10n->t('Meeting Type');
1128            $default[self::TALK_FORM_DEF_PLACEHOLDER] = $l10n->t('Select meeting type');
1129            $default[self::TALK_FORM_DEF_REAL] = $l10n->t('In-person meeting');
1130            $default[self::TALK_FORM_DEF_VIRTUAL] = $l10n->t('Online (audio/video)');
1131        } else if ($key === self::KEY_FORM_INPUTS_JSON) {
1132            // this is a special case
1133            $sa = json_decode(($this->settings[$key] ?? null), true);
1134            if ($sa !== null) {
1135                return $sa;
1136            } else {
1137                return [];
1138            }
1139        } else if ($key === self::KEY_FORM_INPUTS_HTML) {
1140            // this is a special case
1141            return [$this->settings[$key] ?? ''];
1142        } else if ($key === self::KEY_TMPL_INFO) {
1143            return json_decode($this->settings[self::KEY_TMPL_INFO] ?? null, true) ?? $default;
1144        }
1145
1146        $sa = json_decode(($this->settings[$key] ?? null), true);
1147        if (!empty($pn)) $sa = $sa[$pn] ?? null; // <- mps
1148
1149        if ($sa === null) {
1150            return $default;
1151        }
1152
1153        if ($default !== null) {
1154            foreach ($default as $k => $v) {
1155                if (!isset($sa[$k])) {
1156                    $sa[$k] = $v;
1157                }
1158            }
1159        }
1160
1161        return $sa;
1162    }
1163
1164    // This is a temp work-around for multiple "template mode" pages, use this instead getUserSettings(BackendUtils::KEY_TMPL_INFO, $userId) until data is normalized.
1165    function getTemplateInfo(string $userId, string $pageId): array {
1166
1167        $templateInfo = $this->getUserSettings(BackendUtils::KEY_TMPL_INFO, $userId);
1168
1169        if (isset($templateInfo[self::TMPL_TZ_NAME]) || isset($templateInfo[self::TMPL_TZ_DATA])) {
1170            // we have old ( single page data ), fix/normalize...
1171            $newData = array(
1172                'old_info' => $templateInfo
1173            );
1174            // This is not perfect but since we only have one data, we just duplicate it for other pages
1175            $newData[$pageId] = $templateInfo;
1176            $this->setTemplateInfo($userId, null, $newData);
1177
1178            return $newData[$pageId];
1179
1180        } else if (!isset($templateInfo[$pageId])) {
1181            // This is not perfect, but we use 'old_info' and if that does not exist just use default
1182            if (isset($templateInfo['old_info'])) {
1183                return $templateInfo['old_info'];
1184            } else {
1185                // 'p0' has defaults
1186                return $this->getDefaultForKey(BackendUtils::KEY_TMPL_INFO)['p0'];
1187            }
1188        }
1189        return $templateInfo[$pageId];
1190    }
1191
1192    /**
1193     * @param string $userId
1194     * @param string|null $pageId
1195     * @param array $value
1196     * @return bool
1197     */
1198    function setTemplateInfo($userId, $pageId, $value) {
1199
1200        if ($pageId !== null) {
1201            $td = $this->getUserSettings(self::KEY_TMPL_INFO, $userId);
1202
1203            if (isset($templateInfo[self::TMPL_TZ_NAME]) || isset($templateInfo[self::TMPL_TZ_DATA])) {
1204                // we have old ( single page data ), fix/normalize...
1205                $td = array(
1206                    'old_info' => $td
1207                );
1208            }
1209            $td[$pageId] = $value;
1210        } else {
1211            // $pageId can be null when the multipage fix is first applied and in that case value contains data with page Id(s)
1212            $td = $value;
1213        }
1214        $jv = json_encode($td);
1215        if ($jv === false) {
1216            return false;
1217        } else {
1218            return $this->setDBValue($userId, self::KEY_TMPL_INFO, $jv);
1219        }
1220    }
1221
1222
1223    /**
1224     * @param string $userId
1225     * @param string $key
1226     * @param string|null $value
1227     */
1228    function setDBValue($userId, $key, $value) {
1229        try {
1230            $qb = $this->db->getQueryBuilder();
1231            $r = $qb->update(self::PREF_TABLE_NAME)
1232                ->set($key, $qb->createNamedParameter($value))
1233                ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)))
1234                ->execute();
1235            if ($r === 0) {
1236                $qb = $this->db->getQueryBuilder();
1237                $qb->insert(self::PREF_TABLE_NAME)
1238                    ->setValue('user_id', $qb->createNamedParameter($userId))
1239                    ->setValue($key, $qb->createNamedParameter($value))
1240                    ->execute();
1241            }
1242            // set cached
1243            $this->settings[$key] = $value;
1244            return true;
1245        } catch (\Exception $e) {
1246            $this->logger->error($e);
1247            return false;
1248        }
1249    }
1250
1251    /**
1252     * @param string $userId
1253     * @param string $page
1254     * @param array|null $value
1255     * @return bool
1256     */
1257    function setDBMpsValue($userId, $page, $value) {
1258        $mps = $this->getUserSettings(self::KEY_MPS_COL, $userId) ?? [];
1259        $mps[$page] = $value;
1260        $v = json_encode($mps);
1261        if ($v === false) {
1262            return false;
1263        }
1264        $this->setDBValue($userId, self::KEY_MPS_COL, $v);
1265        return true;
1266    }
1267
1268
1269    /**
1270     * @param string $key
1271     * @param string $value_str JSON String
1272     * @param array $default_or_pgs this is value when $key===self::KEY_PAGES
1273     * @param string $userId
1274     * @param string $appName
1275     * @return bool
1276     */
1277    function setUserSettings($key, $value_str, $default_or_pgs, $userId, $appName) {
1278        if ($key === self::KEY_PAGES) {
1279            // already filtered array @see StateController:set_pages
1280            $sa = $default_or_pgs;
1281        } elseif ($key === self::KEY_REMINDERS) {
1282            // already filtered or null
1283            $this->setDBValue($userId, $key, $value_str);
1284            return true;
1285        } else {
1286            $va = json_decode($value_str, true);
1287            if ($va === null) {
1288                return false;
1289            }
1290            $sa = [];
1291            foreach ($default_or_pgs as $k => $v) {
1292                if (isset($va[$k]) && gettype($va[$k]) === gettype($v)) {
1293                    $sa[$k] = $va[$k];
1294                } else {
1295                    $sa[$k] = $v;
1296                }
1297            }
1298        }
1299
1300        if (strpos($key, self::KEY_MPS) === 0) {
1301            return $this->setDBMpsValue(
1302                $userId, substr($key, strlen(self::KEY_MPS)), $sa);
1303        }
1304
1305        $js = json_encode($sa);
1306        if ($js === false) {
1307            return false;
1308        }
1309
1310
1311        $this->setDBValue($userId, $key, $js);
1312
1313        return true;
1314    }
1315
1316    /**
1317     * For simple mode:
1318     *  Main = CLS_MAIN_ID
1319     *  Other = CLS_DEST_ID
1320     *
1321     * For external mode:
1322     *  Main = XTM_DST_ID (destination calendar)
1323     *  Other = XTM_SRC_ID (source calendar)
1324     *
1325     * @param string $userId
1326     * @param string $pageId
1327     * @param IBackendConnector|null $bc checks backend if provided
1328     * @param string|null $otherCal get the ID of the other calendar "-1"=not found
1329     * @return string calendar Id or "-1" = no main cal
1330     */
1331    function getMainCalId($userId, $pageId, $bc, &$otherCal = null) {
1332
1333        if ($pageId === 'p0') {
1334            // main calendar is provider
1335            $csProvider = $this->getUserSettings(self::KEY_CLS, $userId);
1336        } else {
1337            // more_pages_ holds $dst/$src cals
1338            $csProvider = $this->getUserSettings(self::KEY_MPS . $pageId, $userId);
1339        }
1340
1341        // What mode are we in ??
1342        $ts_mode = $csProvider[self::CLS_TS_MODE];
1343        if ($ts_mode === self::CLS_TS_MODE_TEMPLATE) {
1344            $dst = $csProvider[self::CLS_TMM_DST_ID];
1345            return ($bc !== null && $bc->getCalendarById($dst, $userId) === null) ? '-1' : $dst;
1346        } else if ($ts_mode === self::CLS_TS_MODE_EXTERNAL) {
1347            $dst = $csProvider[self::CLS_XTM_DST_ID];
1348            $src = $csProvider[self::CLS_XTM_SRC_ID];
1349            // External mode - main calendar is destination calendar
1350            if ($src === "-1" || $dst === "-1" || $src === $dst) {
1351                if (isset($otherCal)) {
1352                    $otherCal = '-1';
1353                }
1354                return "-1";
1355            } else {
1356                if (isset($otherCal)) {
1357                    $otherCal = ($bc !== null && $bc->getCalendarById($src, $userId) === null) ? '-1' : $src;
1358                }
1359                return ($bc !== null && $bc->getCalendarById($dst, $userId) === null) ? "-1" : $dst;
1360            }
1361        } else {
1362            // Manual $ts_mode==="0"
1363            if (isset($otherCal)) {
1364                $dst = $csProvider[self::CLS_DEST_ID];
1365                $otherCal = ($bc !== null && $bc->getCalendarById($dst, $userId) === null) ? '-1' : $dst;
1366            }
1367            $src = $csProvider[self::CLS_MAIN_ID];
1368            return ($bc !== null && $bc->getCalendarById($src, $userId) === null) ? '-1' : $src;
1369        }
1370    }
1371
1372    /**
1373     * @param string $userId
1374     * @param string $pageId
1375     * @param string $appName
1376     * @param string $tz_data_str Can be VTIMEZONE data, 'UTC'
1377     * @param string $cr_date 20200414T073008Z must be UTC (ends with Z),
1378     * @param string $title title is used when the appointment is being reset
1379     * @return string[] ['1_before_uid'=>'string...','2_before_dts'=>'string...','3_before_dte'=>'string...','4_last'=>'string...'] or ['err'=>'Error text...']
1380     */
1381    function makeAppointmentParts($userId, $pageId, $appName, $tz_data_str, $cr_date, $title = "") {
1382
1383        $l10n = \OC::$server->getL10N($appName);
1384        $iUser = \OC::$server->getUserManager()->get($userId);
1385        if ($iUser === null) {
1386            return ['err' => 'Bad user Id.'];
1387        }
1388        $rn = "\r\n";
1389        $cr_date_rn = $cr_date . "\r\n";
1390
1391        $tz_id = "";
1392        $tz_Z = "";
1393        $tz_data = "";
1394        if ($tz_data_str !== "UTC" && !empty($tz_data_str)) {
1395            $tzo = Reader::read("BEGIN:VCALENDAR\r\nPRODID:-//IDN nextcloud.com//Appointments App//EN\r\nCALSCALE:GREGORIAN\r\nVERSION:2.0\r\n" . $tz_data_str . "\r\nEND:VCALENDAR");
1396            if (isset($tzo->VTIMEZONE) && isset($tzo->VTIMEZONE->TZID)) {
1397                $tz_id = ';TZID=' . $tzo->VTIMEZONE->TZID->getValue();
1398                $tz_data = trim($tzo->VTIMEZONE->serialize()) . "\r\n";
1399            }
1400        } else {
1401            $tz_Z = "Z";
1402        }
1403
1404        $org = $this->getUserSettings(self::KEY_ORG, $userId);
1405        if ($pageId === 'p0') {
1406            $org_name = $org[BackendUtils::ORG_NAME];
1407            $addr = $org[BackendUtils::ORG_ADDR];
1408        } else {
1409            $mps = $this->getUserSettings(
1410                BackendUtils::KEY_MPS . $pageId, $userId);
1411            $org_name = !empty($mps[BackendUtils::ORG_NAME])
1412                ? $mps[BackendUtils::ORG_NAME]
1413                : $org[BackendUtils::ORG_NAME];
1414            $addr = !empty($mps[BackendUtils::ORG_ADDR])
1415                ? $mps[BackendUtils::ORG_ADDR]
1416                : $org[BackendUtils::ORG_ADDR];
1417        }
1418
1419        // email is not per page
1420        $email = $org[self::ORG_EMAIL];
1421
1422        $name = trim($iUser->getDisplayName());
1423        if (empty($name)) {
1424            $name = $org_name;
1425        }
1426
1427        if (empty($email)) $email = $iUser->getEMailAddress();
1428        if (empty($email)) {
1429            return ['err' => $l10n->t("Your email address is required for this operation.")];
1430        }
1431        if (empty($addr)) {
1432            return ['err' => $l10n->t("A location, address or URL is required for this operation. Check User/Organization settings.")];
1433        }
1434//        ESCAPED-CHAR = ("\\" / "\;" / "\," / "\N" / "\n")
1435//        \\ encodes \ \N or \n encodes newline \; encodes ; \, encodes ,
1436        $addr = str_replace(array("\\", ";", ",", "\r\n", "\r", "\n"), array('\\\\', '\;', '\,', ' \n', ' \n', ' \n'), $addr);
1437
1438        if (empty($name)) {
1439            return ['err' => $l10n->t("Can't find your name. Check User/Organization settings.")];
1440        }
1441
1442        if (empty($title)) {
1443            $summary = \OC::$server->getL10N($appName)->t("Available");
1444        } else {
1445            $summary = $title;
1446        }
1447
1448
1449        return [
1450            '1_before_uid' => "BEGIN:VCALENDAR\r\n" .
1451                "PRODID:-//IDN nextcloud.com//Appointments App | srgdev.com//EN\r\n" .
1452                "CALSCALE:GREGORIAN\r\n" .
1453                "VERSION:2.0\r\n" .
1454                "BEGIN:VEVENT\r\n" .
1455                "SUMMARY:" . $summary . $rn .
1456                "STATUS:TENTATIVE\r\n" .
1457                "TRANSP:TRANSPARENT\r\n" .
1458                "LAST-MODIFIED:" . $cr_date_rn .
1459                "DTSTAMP:" . $cr_date_rn .
1460                "SEQUENCE:1\r\n" .
1461                "CATEGORIES:" . BackendUtils::APPT_CAT . $rn .
1462                "CREATED:" . $cr_date_rn . "UID:", // UID goes here
1463            '2_before_dts' => $rn . "DTSTART" . $tz_id . ":", // DTSTART goes here
1464            '3_before_dte' => $tz_Z . $rn . "DTEND" . $tz_id . ":", // DTEND goes here
1465            '4_last' => $tz_Z . $rn . $this->chunk_split_unicode("ORGANIZER;CN=" . $name . ":mailto:" . $email, 75, "\r\n ") . $rn . $this->chunk_split_unicode("LOCATION:" . $addr, 75, "\r\n ") . $rn . "END:VEVENT\r\n" . $tz_data . "END:VCALENDAR\r\n"
1466        ];
1467    }
1468
1469    private function chunk_split_unicode($str, $l = 76, $e = "\r\n") {
1470        $tmp = array_chunk(
1471            preg_split("//u", $str, -1, PREG_SPLIT_NO_EMPTY), $l);
1472        $str = "";
1473        foreach ($tmp as $t) {
1474            $str .= join("", $t) . $e;
1475        }
1476        return trim($str);
1477    }
1478
1479    /**
1480     * Try to get calendar timezone if it is not available fall back to getUserTimezone
1481     *
1482     * @param string $userId
1483     * @param \OCP\IConfig $config
1484     * @param array|null $cal
1485     * @return \DateTimeZone
1486     *
1487     * @see getUserTimezone
1488     */
1489    function getCalendarTimezone(string $userId, \OCP\IConfig $config, array $cal = null): \DateTimeZone {
1490
1491        // TODO: Double check if the following is the Calendar App order (#1 and #2 might be reversed):
1492        // 1. $config->getUserValue($userId, 'calendar', 'timezone');
1493        // 2. $cal['timezone']
1494        // 3. $config->getUserValue($userId, 'core', 'timezone')
1495        // 4. \OC::$server->getDateTimeZone()->getTimeZone();
1496
1497        $err = "";
1498        $tz = null;
1499
1500        if ($cal === null) {
1501            $err = "Calendar for user " . $userId . " is null";
1502        } elseif (empty($cal['timezone'])) {
1503            $err = "Calendar with ID " . $cal['id'] . " for user " . $userId . " missing 'timezone' prop";
1504        } else {
1505            $token = 'TZID:';
1506            $tokenPos = strpos($cal['timezone'], $token);
1507            if ($tokenPos === false) {
1508                $err = "Bad timezone data, calendarId: " . $cal['id'] . ", userId: " . $userId;
1509            } else {
1510                try {
1511                    $tz_start = $tokenPos + strlen($token);
1512                    $tz_name = trim(substr(
1513                        $cal['timezone'],
1514                        $tz_start,
1515                        strpos($cal['timezone'], "\n", $tz_start) - $tz_start));
1516                    $tz = new \DateTimeZone($tz_name);
1517                } catch (\Exception $e) {
1518                    $this->logger->error("getCalendarTimezone error: " . $e->getMessage());
1519                    $tz = new \DateTimeZone('utc'); // fallback to utc
1520                }
1521            }
1522        }
1523        if ($tz === null) {
1524            $this->logger->error("getCalendarTimezone fallback to getUserTimezone: " . $err);
1525            return $this->getUserTimezone($userId, $config);
1526        }
1527        return $tz;
1528    }
1529
1530    /**
1531     * @param $userId
1532     * @param \OCP\IConfig $config
1533     * @return \DateTimeZone
1534     */
1535    function getUserTimezone($userId, $config) {
1536        $tz_name = $config->getUserValue($userId, 'calendar', 'timezone');
1537        if (empty($tz_name) || strpos($tz_name, 'auto') !== false) {
1538            // Try Nextcloud default timezone
1539            $tz_name = $config->getUserValue($userId, 'core', 'timezone');
1540            if (empty($tz_name) || strpos($tz_name, 'auto') !== false) {
1541                return \OC::$server->getDateTimeZone()->getTimeZone();
1542            }
1543        }
1544        try {
1545            $tz = new \DateTimeZone($tz_name);
1546        } catch (\Exception $e) {
1547            $this->logger->error("getUserTimezone error: " . $e->getMessage());
1548            $tz = new \DateTimeZone('utc'); // fallback to utc
1549        }
1550
1551        return $tz;
1552    }
1553
1554    /**
1555     * @param \DateTimeImmutable $date
1556     * @param string $tzi Timezone info [UF][+-]\d{4} Ex: U+0300 @see dataSetAttendee() or [UF](valid timezone name) Ex: UAmerica/New_York
1557     * @param int $short_dt
1558     *      0 = long format
1559     *      1 = short format (for email subject)
1560     * @return string
1561     * @noinspection PhpDocMissingThrowsInspection
1562     */
1563    function getDateTimeString($date, $tzi, $short_dt = 0) {
1564
1565        $l10N = \OC::$server->getL10N($this->appName);
1566        if ($tzi[0] === "F") {
1567            $d = $date->format('Ymd\THis');
1568            if ($short_dt === 0) {
1569                $date_time =
1570                    $l10N->l('date', $d, ['width' => 'full']) . ', ' .
1571                    $l10N->l('time', $d, ['width' => 'short']);
1572            } else if ($short_dt === 1) {
1573                $date_time = $l10N->l('datetime', $d, ['width' => 'short']);
1574            } else {
1575                $date_time = '';
1576            }
1577        } else {
1578            try {
1579                $d = new \DateTime('now', new \DateTimeZone(substr($tzi, 1)));
1580            } catch (\Exception $e) {
1581                $this->logger->error($e->getMessage());
1582                /** @noinspection PhpUnhandledExceptionInspection */
1583                $d = new \DateTime('now', $date->getTimezone());
1584            }
1585            $d->setTimestamp($date->getTimestamp());
1586
1587            if ($short_dt === 0) {
1588                $date_time = $l10N->l('date', $d, ['width' => 'full']) . ', ' .
1589                    str_replace(':00 ', ' ',
1590                        $l10N->l('time', $d, ['width' => 'long']));
1591            } else if ($short_dt === 1) {
1592                $date_time = $l10N->l('datetime', $d, ['width' => 'short']);
1593            } else {
1594                $date_time = '';
1595            }
1596        }
1597
1598        return $date_time;
1599    }
1600
1601    /**
1602     * @param string $data
1603     * @param string $key
1604     * @param string $iv special case
1605     * @return string
1606     */
1607    function encrypt(string $data, string $key, $iv = ''): string {
1608        if ($iv === '') {
1609            $iv = $_iv = openssl_random_pseudo_bytes(
1610                openssl_cipher_iv_length(self::CIPHER));
1611        } else {
1612            $_iv = '';
1613        }
1614        $ciphertext_raw = openssl_encrypt(
1615            $data,
1616            self::CIPHER,
1617            $key,
1618            OPENSSL_RAW_DATA,
1619            $iv);
1620
1621        return $_iv !== ''
1622            ? base64_encode($_iv . $ciphertext_raw)
1623            : $_iv . $ciphertext_raw;
1624    }
1625
1626    /**
1627     * @param string $data
1628     * @param string $key
1629     * @param string $iv
1630     * @return string
1631     */
1632    function decrypt(string $data, string $key, $iv = ''): string {
1633        $s1 = $iv === '' ? base64_decode($data) : $data;
1634        if ($s1 === false || empty($key)) return '';
1635
1636        $s1 = $iv . $s1;
1637
1638        $ivlen = openssl_cipher_iv_length(self::CIPHER);
1639        $t = openssl_decrypt(
1640            substr($s1, $ivlen),
1641            self::CIPHER,
1642            $key,
1643            OPENSSL_RAW_DATA,
1644            substr($s1, 0, $ivlen));
1645        return $t === false ? '' : $t;
1646    }
1647
1648
1649    /**
1650     * @param string $token
1651     * @param bool $embed
1652     * @return string
1653     */
1654    function pubPrx($token, $embed) {
1655        return $embed ? 'embed/' . $token . '/' : 'pub/' . $token . '/';
1656    }
1657
1658
1659    function getPublicWebBase() {
1660//        return $this->urlGenerator->getBaseUrl() . '/index.php/apps/appointments';
1661        return $this->urlGenerator->getAbsoluteURL('/index.php/apps/appointments');
1662    }
1663
1664    /**
1665     * @param string $token
1666     * @param \OCP\IConfig $config
1667     * @return string[]|null[] [useId,pageId] on success, [null,null]=not verified
1668     * @throws \ErrorException
1669     */
1670    function verifyToken($token, $config) {
1671        if (empty($token) || strlen($token) > 256) return [null, null];
1672        $token = str_replace("_", "/", $token);
1673        $key = hex2bin($config->getAppValue($this->appName, 'hk'));
1674        $iv = hex2bin($config->getAppValue($this->appName, 'tiv'));
1675        if (empty($key) || empty($iv)) {
1676            throw new \ErrorException("Can't find key");
1677        }
1678
1679        $l = strlen($token);
1680        if (($l & 3) !== 0) {
1681            // not divisible by 4
1682            $m = intval($token[$l - 1]);
1683            $token = substr($token, 0, -($m === 1 ? 2 : 1));
1684            $token .= str_repeat('=', $m);
1685
1686            $rc = base64_decode($token);
1687            if ($rc === false) return [null, null];
1688
1689            $iv[0] = substr($rc, -1);
1690            $raw = substr($rc, 0, -1);
1691            $pageId = '';
1692        } else {
1693            $raw = base64_decode($token);
1694            if ($raw === false) return [null, null];
1695            $pageId = "p0";
1696        }
1697
1698        $td = $this->decrypt($raw, $key, $iv);
1699        if (strlen($td) > 4 && substr($td, 0, 4) === hash('adler32', substr($td, 4), true)) {
1700            $u = substr($td, 4);
1701            if ($pageId === '') {
1702                return [substr($u, 0, -1), 'p' . ord(substr($u, -1))];
1703            } else {
1704                return [$u, $pageId];
1705            }
1706        } else {
1707            return [null, null];
1708        }
1709    }
1710
1711
1712    /**
1713     * @param string $userId
1714     * @param string $pageId (optional)
1715     * @return string
1716     * @throws \ErrorException
1717     */
1718    function getToken($userId, $pageId = "p0") {
1719        $config = \OC::$server->getConfig();
1720        $key = hex2bin($config->getAppValue($this->appName, 'hk'));
1721        $iv = hex2bin($config->getAppValue($this->appName, 'tiv'));
1722        if (empty($key) || empty($iv)) {
1723            throw new \ErrorException("Can't find key");
1724        }
1725        if ($pageId === "p0") {
1726            $pfx = '';
1727            $upi = $userId;
1728        } else {
1729            $pn = intval(substr($pageId, 1));
1730            if ($pn < 1 || $pn > 14) {
1731                throw new \ErrorException("Bad page number");
1732            }
1733            $pfx = ($iv[0] ^ $iv[15]) ^ $iv[$pn];
1734
1735            $iv = $pfx . substr($iv, 1);
1736            $upi = $userId . chr($pn);
1737        }
1738
1739        $tkn = $this->encrypt(
1740            hash('adler32', $upi, true) . $upi, $key, $iv);
1741
1742
1743        if ($pfx === '') {
1744            $bd = base64_encode($tkn);
1745        } else {
1746            $v = base64_encode($tkn . $pfx);
1747            $bd = trim($v, '=');
1748            $ld = strlen($v) - strlen($bd);
1749            if ($ld === 1) {
1750                $bd .= "01";
1751            } else {
1752                $bd .= $ld;
1753            }
1754        }
1755        return urlencode(str_replace("/", "_", $bd));
1756    }
1757
1758    function transformCalInfo($c, $skipReadOnly = true) {
1759
1760        $isReadOnlyCal = isset($c['{http://owncloud.org/ns}read-only'])
1761            && $c['{http://owncloud.org/ns}read-only'] === true;
1762
1763        if ($skipReadOnly && $isReadOnlyCal) {
1764            // Do not use read only calendars
1765            return null;
1766        }
1767
1768        $a = [];
1769        $a['id'] = (string)$c["id"];
1770        $a['displayName'] = $c['{DAV:}displayname'] ?? "Calendar";
1771        $a['color'] = $c['{http://apple.com/ns/ical/}calendar-color'] ?? "#000000";
1772        $a['uri'] = $c['uri'];
1773        $a['timezone'] = $c['{urn:ietf:params:xml:ns:caldav}calendar-timezone'] ?? '';
1774        $a['isReadOnly'] = $isReadOnlyCal ? '1' : '0';
1775        return $a;
1776    }
1777
1778    /**
1779     * @param array $currentIds Ex: ['1','2',...]
1780     * @param array $real Ex: [['id'=>'1','x'=>'y'],['id'=>'2','x'=>'y'],...]
1781     * @return array
1782     */
1783    function filterCalsAndSubs(array $currentIds, array $real): array {
1784        // convert to array with ids as keys for fast look up
1785        $ids = [];
1786        for ($i = 0, $l = count($real); $i < $l; $i++) {
1787            $ids[$real[$i]['id']] = true;
1788        }
1789
1790        $curLen = count($currentIds);
1791        $realIds = [];
1792        for ($i = 0; $i < $curLen; $i++) {
1793            $id = $currentIds[$i];
1794            if (isset($ids[$id])) {
1795                $realIds[] = $id;
1796            }
1797        }
1798        return $realIds;
1799    }
1800
1801    function removeSubscriptionSync($subscriptionId) {
1802        $this->logger->info("removeSubscriptionSync, subscriptionId: " . $subscriptionId);
1803        $qb = $this->db->getQueryBuilder();
1804        try {
1805            $qb->delete(self::SYNC_TABLE_NAME)
1806                ->where($qb->expr()->eq('id',
1807                    $qb->createNamedParameter($subscriptionId)))
1808                ->execute();
1809        } catch (Exception $e) {
1810            $this->logger->error("removeSubscriptionSync error: " . $e->getMessage());
1811        }
1812    }
1813
1814}
1815
1816