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