1<?php 2 3/* 4 RCM CardDAV Plugin 5 Copyright (C) 2011-2016 Benjamin Schieder <rcmcarddav@wegwerf.anderdonau.de>, 6 Michael Stilkerich <ms@mike2k.de> 7 8 This program is free software; you can redistribute it and/or modify 9 it under the terms of the GNU General Public License as published by 10 the Free Software Foundation; either version 2 of the License, or 11 (at your option) any later version. 12 13 This program is distributed in the hope that it will be useful, 14 but WITHOUT ANY WARRANTY; without even the implied warranty of 15 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 GNU General Public License for more details. 17 18 You should have received a copy of the GNU General Public License along 19 with this program; if not, write to the Free Software Foundation, Inc., 20 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 21 */ 22 23use MStilkerich\CardDavClient\{Account, Config}; 24use MStilkerich\CardDavClient\Services\Discovery; 25use Psr\Log\LoggerInterface; 26use MStilkerich\CardDavAddressbook4Roundcube\{Addressbook, Database, RoundcubeLogger}; 27 28// phpcs:ignore PSR1.Classes.ClassDeclaration, Squiz.Classes.ValidClassName -- class name(space) expected by roundcube 29class carddav extends rcube_plugin 30{ 31 /** 32 * The version of this plugin. 33 * 34 * During development, it is set to the last release and added the suffix +dev. 35 */ 36 private const PLUGIN_VERSION = 'v4.0.4'; 37 38 /** 39 * Information about this plugin that is queried by roundcube. 40 */ 41 private const PLUGIN_INFO = [ 42 'name' => 'carddav', 43 'vendor' => 'Michael Stilkerich, Benjamin Schieder', 44 'version' => self::PLUGIN_VERSION, 45 'license' => 'GPL-2.0', 46 'uri' => 'https://github.com/blind-coder/rcmcarddav/' 47 ]; 48 49 /** @var string[] ABOOK_PROPS A list of addressbook property keys. These are both found in the settings form as well 50 * as in the database as columns. 51 */ 52 private const ABOOK_PROPS = [ 53 "name", "active", "use_categories", "username", "password", "url", "refresh_time", "sync_token" 54 ]; 55 56 /** @var string[] ABOOK_PROPS_BOOL A list of addressbook property keys of all boolean properties. */ 57 private const ABOOK_PROPS_BOOL = [ "active", "use_categories" ]; 58 59 /** @var string $pwstore_scheme encryption scheme */ 60 private static $pwstore_scheme = 'encrypted'; 61 62 /** @var array $admin_settings admin settings from config.inc.php */ 63 private static $admin_settings; 64 65 /** @var LoggerInterface $logger */ 66 public static $logger; 67 68 // the dummy task is used by the calendar plugin, which requires 69 // the addressbook to be initialized 70 public $task = 'addressbook|login|mail|settings|dummy'; 71 72 /** @var ?string[] $abooksDb Cache of the user's addressbook DB entries. 73 * Associative array mapping addressbook IDs to DB rows. 74 */ 75 private static $abooksDb = null; 76 77 78 79 /** 80 * Provide information about this plugin. 81 * 82 * @return array Meta information about a plugin or false if not implemented. 83 * As hash array with the following keys: 84 * name: The plugin name 85 * vendor: Name of the plugin developer 86 * version: Plugin version name 87 * license: License name (short form according to http://spdx.org/licenses/) 88 * uri: The URL to the plugin homepage or source repository 89 * src_uri: Direct download URL to the source code of this plugin 90 * require: List of plugins required for this one (as array of plugin names) 91 */ 92 public static function info() 93 { 94 return self::PLUGIN_INFO; 95 } 96 97 /** 98 * Default constructor. 99 * 100 * @param rcube_plugin_api $api Plugin API 101 */ 102 public function __construct($api) 103 { 104 // This supports a self-contained tarball installation of the plugin, at the risk of having conflicts with other 105 // versions of the library installed in the global roundcube vendor directory (-> use not recommended) 106 if (file_exists(dirname(__FILE__) . "/vendor/autoload.php")) { 107 include_once dirname(__FILE__) . "/vendor/autoload.php"; 108 } 109 110 parent::__construct($api); 111 112 // we do not currently use the roundcube mechanism to save preferences 113 // but store preferences to custom database tables 114 $this->allowed_prefs = []; 115 } 116 117 public function init(array $options = []): void 118 { 119 try { 120 $prefs = self::getAdminSettings(); 121 122 self::$logger = $options["logger"] ?? new RoundcubeLogger( 123 "carddav", 124 $prefs['_GLOBAL']['loglevel'] ?? \Psr\Log\LogLevel::ERROR 125 ); 126 $http_logger = $options["logger_http"] ?? new RoundcubeLogger( 127 "carddav_http", 128 $prefs['_GLOBAL']['loglevel_http'] ?? \Psr\Log\LogLevel::ERROR 129 ); 130 131 self::$logger->debug(__METHOD__); 132 133 // initialize carddavclient library 134 Config::init(self::$logger, $http_logger); 135 136 Database::init(self::$logger); 137 138 $this->add_texts('localization/', false); 139 140 $this->add_hook('addressbooks_list', [$this, 'listAddressbooks']); 141 $this->add_hook('addressbook_get', [$this, 'getAddressbook']); 142 143 // if preferences are configured as hidden by the admin, don't register the hooks handling preferences 144 if (!($prefs['_GLOBAL']['hide_preferences'] ?? false)) { 145 $this->add_hook('preferences_list', [$this, 'buildPreferencesPage']); 146 $this->add_hook('preferences_save', [$this, 'savePreferences']); 147 $this->add_hook('preferences_sections_list', [$this, 'addPreferencesSection']); 148 } 149 150 $this->add_hook('login_after', [$this, 'checkMigrations']); 151 $this->add_hook('login_after', [$this, 'initPresets']); 152 153 if (!key_exists('user_id', $_SESSION)) { 154 return; 155 } 156 157 // use this address book for autocompletion queries 158 // (maybe this should be configurable by the user?) 159 $config = rcmail::get_instance()->config; 160 $sources = (array) $config->get('autocomplete_addressbooks', ['sql']); 161 162 $carddav_sources = array_map( 163 function (string $id): string { 164 return "carddav_$id"; 165 }, 166 array_keys(self::getAddressbooks()) 167 ); 168 169 $config->set('autocomplete_addressbooks', array_merge($sources, $carddav_sources)); 170 $skin_path = $this->local_skin_path(); 171 $this->include_stylesheet($skin_path . '/carddav.css'); 172 } catch (\Exception $e) { 173 self::$logger->error("Could not init rcmcarddav: " . $e->getMessage()); 174 } 175 } 176 177 /*************************************************************************************** 178 * HOOK FUNCTIONS 179 **************************************************************************************/ 180 181 public function checkMigrations(): void 182 { 183 try { 184 self::$logger->debug(__METHOD__); 185 186 $scriptDir = dirname(__FILE__) . "/dbmigrations/"; 187 $config = rcmail::get_instance()->config; 188 Database::checkMigrations($config->get('db_prefix', ""), $scriptDir); 189 } catch (\Exception $e) { 190 self::$logger->error("Error execution DB schema migrations: " . $e->getMessage()); 191 } 192 } 193 194 public function initPresets(): void 195 { 196 try { 197 self::$logger->debug(__METHOD__); 198 199 $prefs = self::getAdminSettings(); 200 201 // Get all existing addressbooks of this user that have been created from presets 202 $existing_abooks = self::getAddressbooks(false, true); 203 204 // Group the addressbooks by their preset 205 $existing_presets = []; 206 foreach ($existing_abooks as $abookrow) { 207 $pn = $abookrow['presetname']; 208 if (!key_exists($pn, $existing_presets)) { 209 $existing_presets[$pn] = []; 210 } 211 $existing_presets[$pn][] = $abookrow; 212 } 213 214 // Walk over the current presets configured by the admin and add, update or delete addressbooks 215 foreach ($prefs as $presetname => $preset) { 216 // _GLOBAL contains plugin configuration not related to an addressbook preset - skip 217 if ($presetname === '_GLOBAL') { 218 continue; 219 } 220 221 // addressbooks exist for this preset => update settings 222 if (key_exists($presetname, $existing_presets)) { 223 if (is_array($preset['fixed'])) { 224 $this->updatePresetAddressbooks($preset, $existing_presets[$presetname]); 225 } 226 unset($existing_presets[$presetname]); 227 } else { // create new 228 $preset['presetname'] = $presetname; 229 $abname = $preset['name']; 230 231 try { 232 $username = self::replacePlaceholdersUsername($preset['username']); 233 $url = self::replacePlaceholdersUrl($preset['url']); 234 $password = self::replacePlaceholdersPassword($preset['password']); 235 236 self::$logger->info("Adding preset for $username at URL $url"); 237 $account = new Account($url, $username, $password); 238 $discover = new Discovery(); 239 $abooks = $discover->discoverAddressbooks($account); 240 241 foreach ($abooks as $abook) { 242 if ($preset['carddav_name_only']) { 243 $preset['name'] = $abook->getName(); 244 } else { 245 $preset['name'] = "$abname (" . $abook->getName() . ')'; 246 } 247 248 $preset['url'] = $abook->getUri(); 249 self::insertAddressbook($preset); 250 } 251 } catch (\Exception $e) { 252 self::$logger->error("Error adding addressbook from preset $presetname: {$e->getMessage()}"); 253 } 254 } 255 } 256 257 // delete existing preset addressbooks that were removed by admin 258 foreach ($existing_presets as $ep) { 259 self::$logger->info("Deleting preset addressbooks for " . $_SESSION['user_id']); 260 foreach ($ep as $abookrow) { 261 self::deleteAddressbook($abookrow['id']); 262 } 263 } 264 } catch (\Exception $e) { 265 self::$logger->error("Error initializing preconfigured addressbooks: " . $e->getMessage()); 266 } 267 } 268 269 /** 270 * Adds the user's CardDAV addressbooks to Roundcube's addressbook list. 271 */ 272 public function listAddressbooks(array $p): array 273 { 274 try { 275 self::$logger->debug(__METHOD__); 276 277 $prefs = self::getAdminSettings(); 278 279 foreach (self::getAddressbooks() as $abookId => $abookrow) { 280 $ro = false; 281 if (isset($abookrow['presetname']) && $prefs[$abookrow['presetname']]['readonly']) { 282 $ro = true; 283 } 284 285 $p['sources']["carddav_$abookId"] = [ 286 'id' => "carddav_$abookId", 287 'name' => $abookrow['name'], 288 'groups' => true, 289 'autocomplete' => true, 290 'readonly' => $ro, 291 ]; 292 } 293 } catch (\Exception $e) { 294 self::$logger->error("Error reading carddav addressbooks: " . $e->getMessage()); 295 } 296 297 return $p; 298 } 299 300 /** 301 * Hook called by roundcube to retrieve the instance of an addressbook. 302 * 303 * @param array $p The passed array contains the keys: 304 * id: ID of the addressbook as passed to roundcube in the listAddressbooks hook. 305 * writeable: Whether the addressbook needs to be writeable (checked by roundcube after returning an instance). 306 * @return array Returns the passed array extended by a key instance pointing to the addressbook object. 307 * If the addressbook is not provided by the plugin, simply do not set the instance and return what was passed. 308 */ 309 public function getAddressbook(array $p): array 310 { 311 try { 312 self::$logger->debug(__METHOD__ . "({$p['id']})"); 313 314 if (preg_match(";^carddav_(\d+)$;", $p['id'], $match)) { 315 $abookId = $match[1]; 316 $abooks = self::getAddressbooks(false); 317 318 // check that this addressbook ID actually refers to one of the user's addressbooks 319 if (isset($abooks[$abookId])) { 320 $presetname = $abooks[$abookId]["presetname"] ?? null; 321 $readonly = false; 322 $requiredProps = []; 323 324 if (isset($presetname)) { 325 $prefs = self::getAdminSettings(); 326 $readonly = !empty($prefs[$presetname]["readonly"]); 327 $requiredProps = $prefs[$presetname]["require_always"] ?? []; 328 } 329 $p['instance'] = new Addressbook($abookId, $this, $readonly, $requiredProps); 330 } 331 } 332 } catch (\Exception $e) { 333 self::$logger->error("Error loading carddav addressbook {$p['id']}: " . $e->getMessage()); 334 } 335 336 return $p; 337 } 338 339 /** 340 * Handler for preferences_list hook. 341 * Adds options blocks into CardDAV settings sections in Preferences. 342 * 343 * @param array Original parameters 344 * 345 * @return array Modified parameters 346 */ 347 public function buildPreferencesPage(array $args): array 348 { 349 try { 350 self::$logger->debug(__METHOD__); 351 352 if ($args['section'] != 'cd_preferences') { 353 return $args; 354 } 355 356 $this->include_stylesheet($this->local_skin_path() . '/carddav.css'); 357 $prefs = self::getAdminSettings(); 358 $abooks = self::getAddressbooks(false); 359 uasort( 360 $abooks, 361 function (array $a, array $b): int { 362 // presets first 363 $ret = strcasecmp($b["presetname"] ?? "", $a["presetname"] ?? ""); 364 if ($ret == 0) { 365 // then alphabetically by name 366 $ret = strcasecmp($a["name"] ?? "", $b["name"] ?? ""); 367 } 368 if ($ret == 0) { 369 // finally by id (normally the names will differ) 370 $ret = $a["id"] <=> $b["id"]; 371 } 372 return $ret; 373 } 374 ); 375 376 377 $fromPresetStringLocalized = rcmail::Q($this->gettext('cd_frompreset')); 378 foreach ($abooks as $abookId => $abookrow) { 379 $presetname = $abookrow['presetname']; 380 if ( 381 empty($presetname) 382 || !isset($prefs[$presetname]['hide']) 383 || $prefs[$presetname]['hide'] === false 384 ) { 385 $blockhdr = $abookrow['name']; 386 if (!empty($presetname)) { 387 $blockhdr .= str_replace("_PRESETNAME_", $presetname, $fromPresetStringLocalized); 388 } 389 $args["blocks"]["cd_preferences$abookId"] = $this->buildSettingsBlock($blockhdr, $abookrow); 390 } 391 } 392 393 // if allowed by admin, provide a block for entering data for a new addressbook 394 if (!($prefs['_GLOBAL']['fixed'] ?? false)) { 395 $args['blocks']['cd_preferences_section_new'] = $this->buildSettingsBlock( 396 rcmail::Q($this->gettext('cd_newabboxtitle')), 397 self::getAddressbookSettingsFromPOST('new') 398 ); 399 } 400 } catch (\Exception $e) { 401 self::$logger->error("Error building carddav preferences page: " . $e->getMessage()); 402 } 403 404 return $args; 405 } 406 407 // add a section to the preferences tab 408 public function addPreferencesSection(array $args): array 409 { 410 try { 411 self::$logger->debug(__METHOD__); 412 413 $args['list']['cd_preferences'] = [ 414 'id' => 'cd_preferences', 415 'section' => rcmail::Q($this->gettext('cd_title')) 416 ]; 417 } catch (\Exception $e) { 418 self::$logger->error("Error adding carddav preferences section: " . $e->getMessage()); 419 } 420 return $args; 421 } 422 423 /** 424 * Hook function called when the user saves the preferences. 425 * 426 * This function is called for any preferences section, not just that of the carddav plugin, so we need to check 427 * first whether we are in the proper section. 428 */ 429 public function savePreferences(array $args): array 430 { 431 try { 432 self::$logger->debug(__METHOD__); 433 434 if ($args['section'] != 'cd_preferences') { 435 return $args; 436 } 437 438 $prefs = self::getAdminSettings(); 439 440 // update existing in DB 441 foreach (self::getAddressbooks(false) as $abookId => $abookrow) { 442 if (isset($_POST["${abookId}_cd_delete"])) { 443 self::deleteAddressbook($abookId); 444 } else { 445 $newset = self::getAddressbookSettingsFromPOST($abookId); 446 447 // only set the password if the user entered a new one 448 if (empty($newset['password'])) { 449 unset($newset['password']); 450 } 451 452 // remove admin only settings 453 foreach ($newset as $pref => $value) { 454 if (self::noOverrideAllowed($pref, $abookrow, $prefs)) { 455 unset($newset[$pref]); 456 } 457 } 458 459 self::updateAddressbook($abookId, $newset); 460 461 if (isset($_POST["${abookId}_cd_resync"])) { 462 // read-only and required properties don't matter here, this instance is short-lived for sync 463 $backend = new Addressbook($abookId, $this, true, []); 464 $backend->resync(true); 465 } 466 } 467 } 468 469 // add a new address book? 470 $new = self::getAddressbookSettingsFromPOST('new'); 471 if ( 472 !($prefs['_GLOBAL']['fixed'] ?? false) // creation of addressbooks allowed by admin 473 && !empty($new['name']) // user entered a name (and hopefully more data) for a new addressbook 474 ) { 475 try { 476 $account = new Account( 477 $new['url'], 478 $new['username'], 479 self::replacePlaceholdersPassword($new['password']) 480 ); 481 $discover = new Discovery(); 482 $abooks = $discover->discoverAddressbooks($account); 483 484 if (count($abooks) > 0) { 485 $basename = $new['name']; 486 487 foreach ($abooks as $abook) { 488 $new['url'] = $abook->getUri(); 489 $new['name'] = "$basename ({$abook->getName()})"; 490 491 self::$logger->info("Adding addressbook {$new['username']} @ {$new['url']}"); 492 self::insertAddressbook($new); 493 } 494 495 // new addressbook added successfully -> clear the data from the form 496 foreach (self::ABOOK_PROPS as $k) { 497 unset($_POST["new_cd_$k"]); 498 } 499 } else { 500 throw new \Exception($new['name'] . ': ' . $this->gettext('cd_err_noabfound')); 501 } 502 } catch (\Exception $e) { 503 $args['abort'] = true; 504 $args['message'] = $e->getMessage(); 505 } 506 } 507 } catch (\Exception $e) { 508 self::$logger->error("Error saving carddav preferences: " . $e->getMessage()); 509 } 510 511 return $args; 512 } 513 514 /*************************************************************************************** 515 * PUBLIC FUNCTIONS 516 **************************************************************************************/ 517 518 private static function updateAddressbook(string $abookId, array $pa): void 519 { 520 // encrypt the password before storing it 521 if (key_exists('password', $pa)) { 522 $pa['password'] = self::encryptPassword($pa['password']); 523 } 524 525 // optional fields 526 $qf = []; 527 $qv = []; 528 529 foreach (self::ABOOK_PROPS as $f) { 530 if (key_exists($f, $pa)) { 531 $qf[] = $f; 532 $qv[] = $pa[$f]; 533 } 534 } 535 if (count($qf) <= 0) { 536 return; 537 } 538 539 Database::update($abookId, $qf, $qv, "addressbooks"); 540 self::$abooksDb = null; 541 } 542 543 public static function replacePlaceholdersUsername(string $username): string 544 { 545 $rcmail = rcmail::get_instance(); 546 $username = strtr($username, [ 547 '%u' => $_SESSION['username'], 548 '%l' => $rcmail->user->get_username('local'), 549 '%d' => $rcmail->user->get_username('domain'), 550 // %V parses username for macosx, replaces periods and @ by _, work around bugs in contacts.app 551 '%V' => strtr($_SESSION['username'], "@.", "__") 552 ]); 553 554 return $username; 555 } 556 557 public static function replacePlaceholdersUrl(string $url): string 558 { 559 // currently same as for username 560 return self::replacePlaceholdersUsername($url); 561 } 562 563 public static function replacePlaceholdersPassword(string $password): string 564 { 565 if ($password == '%p') { 566 $rcmail = rcmail::get_instance(); 567 $password = $rcmail->decrypt($_SESSION['password']); 568 } 569 570 return $password; 571 } 572 573 public static function encryptPassword(string $clear): string 574 { 575 $scheme = self::$pwstore_scheme; 576 577 if (strcasecmp($scheme, 'plain') === 0) { 578 return $clear; 579 } 580 581 if (strcasecmp($scheme, 'encrypted') === 0) { 582 if (empty($_SESSION['password'])) { // no key for encryption available, downgrade to DES_KEY 583 $scheme = 'des_key'; 584 } else { 585 // encrypted with IMAP password 586 $rcmail = rcmail::get_instance(); 587 588 $imap_password = self::getDesKey(); 589 $deskey_backup = $rcmail->config->set('carddav_des_key', $imap_password); 590 591 $crypted = $rcmail->encrypt($clear, 'carddav_des_key'); 592 593 // there seems to be no way to unset a preference 594 $deskey_backup = $rcmail->config->set('carddav_des_key', ''); 595 596 return '{ENCRYPTED}' . $crypted; 597 } 598 } 599 600 if (strcasecmp($scheme, 'des_key') === 0) { 601 // encrypted with global des_key 602 $rcmail = rcmail::get_instance(); 603 $crypted = $rcmail->encrypt($clear); 604 return '{DES_KEY}' . $crypted; 605 } 606 607 // default: base64-coded password 608 return '{BASE64}' . base64_encode($clear); 609 } 610 611 public static function decryptPassword(string $crypt): string 612 { 613 if (strpos($crypt, '{ENCRYPTED}') === 0) { 614 // return empty password if decruption key not available 615 if (empty($_SESSION['password'])) { 616 self::$logger->warning("Cannot decrypt password as now session password is available"); 617 return ""; 618 } 619 620 $crypt = substr($crypt, strlen('{ENCRYPTED}')); 621 $rcmail = rcmail::get_instance(); 622 623 $imap_password = self::getDesKey(); 624 $deskey_backup = $rcmail->config->set('carddav_des_key', $imap_password); 625 626 $clear = $rcmail->decrypt($crypt, 'carddav_des_key'); 627 628 // there seems to be no way to unset a preference 629 $deskey_backup = $rcmail->config->set('carddav_des_key', ''); 630 631 return $clear; 632 } 633 634 if (strpos($crypt, '{DES_KEY}') === 0) { 635 $crypt = substr($crypt, strlen('{DES_KEY}')); 636 $rcmail = rcmail::get_instance(); 637 638 return $rcmail->decrypt($crypt); 639 } 640 641 if (strpos($crypt, '{BASE64}') === 0) { 642 $crypt = substr($crypt, strlen('{BASE64}')); 643 return base64_decode($crypt); 644 } 645 646 // unknown scheme, assume cleartext 647 return $crypt; 648 } 649 650 /*************************************************************************************** 651 * PRIVATE FUNCTIONS 652 **************************************************************************************/ 653 654 /** 655 * Updates the fixed fields of addressbooks derived from presets against the current admin settings. 656 */ 657 private function updatePresetAddressbooks(array $preset, array $existing_abooks): void 658 { 659 if (!is_array($preset["fixed"] ?? "")) { 660 return; 661 } 662 663 foreach ($existing_abooks as $abookrow) { 664 // decrypt password so that the comparison works 665 $abookrow['password'] = self::decryptPassword($abookrow['password']); 666 667 // update only those attributes marked as fixed by the admin 668 // otherwise there may be user changes that should not be destroyed 669 $pa = []; 670 671 foreach ($preset['fixed'] as $k) { 672 if (key_exists($k, $abookrow) && key_exists($k, $preset)) { 673 // only update the name if it is used 674 if ($k === 'name') { 675 if (!($preset['carddav_name_only'] ?? false)) { 676 $fullname = $abookrow['name']; 677 $cnpos = strpos($fullname, ' ('); 678 if ($cnpos === false && $preset['name'] != $fullname) { 679 $pa['name'] = $preset['name']; 680 } elseif ($cnpos !== false && $preset['name'] != substr($fullname, 0, $cnpos)) { 681 $pa['name'] = $preset['name'] . substr($fullname, $cnpos); 682 } 683 } 684 } elseif ($k === 'url') { 685 // the URL cannot be automatically updated, as it was discovered and normally will 686 // not exactly match the discovery URI. Resetting it to the discovery URI would 687 // break the addressbook record 688 } elseif ($abookrow[$k] != $preset[$k]) { 689 $pa[$k] = $preset[$k]; 690 } 691 } 692 } 693 694 // only update if something changed 695 if (!empty($pa)) { 696 self::updateAddressbook($abookrow['id'], $pa); 697 } 698 } 699 } 700 701 /** 702 * Parses a time string to seconds. 703 * 704 * The time string must have the format HH[:MM[:SS]]. If the format does not match, an exception is thrown. 705 * 706 * @param string $refresht The time string to parse 707 * @return int The time in seconds 708 */ 709 private static function parseTimeParameter(string $refresht): int 710 { 711 if (preg_match('/^(\d+)(:([0-5]?\d))?(:([0-5]?\d))?$/', $refresht, $match)) { 712 $ret = 0; 713 714 $ret += intval($match[1] ?? 0) * 3600; 715 $ret += intval($match[3] ?? 0) * 60; 716 $ret += intval($match[5] ?? 0); 717 } else { 718 throw new \Exception("Time string $refresht could not be parsed"); 719 } 720 721 return $ret; 722 } 723 724 private static function noOverrideAllowed(string $pref, array $abook, array $prefs): bool 725 { 726 $pn = $abook['presetname']; 727 if (!isset($pn)) { 728 return false; 729 } 730 731 if (!is_array($prefs[$pn])) { 732 return false; 733 } 734 735 if (!is_array($prefs[$pn]['fixed'])) { 736 return false; 737 } 738 739 return in_array($pref, $prefs[$pn]['fixed']); 740 } 741 742 /** 743 * Builds a setting block for one address book for the preference page. 744 */ 745 private function buildSettingsBlock(string $blockheader, array $abook): array 746 { 747 $prefs = self::getAdminSettings(); 748 $abookId = $abook['id']; 749 750 if (self::noOverrideAllowed('active', $abook, $prefs)) { 751 $content_active = $abook['active'] ? $this->gettext('cd_enabled') : $this->gettext('cd_disabled'); 752 } else { 753 // check box for activating 754 $checkbox = new html_checkbox(['name' => $abookId . '_cd_active', 'value' => 1]); 755 $content_active = $checkbox->show($abook['active'] ? "1" : "0"); 756 } 757 758 if (self::noOverrideAllowed('use_categories', $abook, $prefs)) { 759 $content_use_categories = $abook['use_categories'] 760 ? $this->gettext('cd_enabled') 761 : $this->gettext('cd_disabled'); 762 } else { 763 // check box for use categories 764 $checkbox = new html_checkbox(['name' => $abookId . '_cd_use_categories', 'value' => 1]); 765 $content_use_categories = $checkbox->show($abook['use_categories'] ? "1" : "0"); 766 } 767 768 if (self::noOverrideAllowed('username', $abook, $prefs)) { 769 $content_username = self::replacePlaceholdersUsername($abook['username']); 770 } else { 771 // input box for username 772 $input = new html_inputfield([ 773 'name' => $abookId . '_cd_username', 774 'type' => 'text', 775 'autocomplete' => 'off', 776 'value' => $abook['username'] 777 ]); 778 $content_username = $input->show(); 779 } 780 781 if (self::noOverrideAllowed('password', $abook, $prefs)) { 782 $content_password = "***"; 783 } else { 784 // only display the password if it was entered for a new addressbook 785 $show_pw_val = ($abook['id'] === "new" && isset($abook['password'])) ? $abook['password'] : ''; 786 // input box for password 787 $input = new html_inputfield([ 788 'name' => $abookId . '_cd_password', 789 'type' => 'password', 790 'autocomplete' => 'off', 791 'value' => $show_pw_val 792 ]); 793 $content_password = $input->show(); 794 } 795 796 // generally, url is fixed, as it results from discovery and has no direct correlation with the admin setting 797 // if the URL of the addressbook changes, all URIs of our database objects would have to change, too -> in such 798 // cases, deleting and re-adding the addressbook would be simpler 799 if ($abook['id'] === "new") { 800 // input box for URL 801 $size = max(strlen($abook['url']), 40); 802 $input = new html_inputfield([ 803 'name' => $abookId . '_cd_url', 804 'type' => 'text', 805 'autocomplete' => 'off', 806 'value' => $abook['url'], 807 'size' => $size 808 ]); 809 $content_url = $input->show(); 810 } else { 811 $content_url = $abook['url']; 812 } 813 814 // input box for refresh time 815 if (isset($abook["refresh_time"])) { 816 $rt = $abook['refresh_time']; 817 $refresh_time_str = sprintf("%02d:%02d:%02d", floor($rt / 3600), ($rt / 60) % 60, $rt % 60); 818 } else { 819 $refresh_time_str = ""; 820 } 821 if (self::noOverrideAllowed('refresh_time', $abook, $prefs)) { 822 $content_refresh_time = $refresh_time_str . ", "; 823 } else { 824 $input = new html_inputfield([ 825 'name' => $abookId . '_cd_refresh_time', 826 'type' => 'text', 827 'autocomplete' => 'off', 828 'value' => $refresh_time_str, 829 'size' => 10 830 ]); 831 $content_refresh_time = $input->show(); 832 } 833 834 if (!empty($abook['last_updated'])) { // if never synced, last_updated is 0 -> don't show 835 $content_refresh_time .= rcube::Q($this->gettext('cd_lastupdate_time')) . ": "; 836 $content_refresh_time .= date("Y-m-d H:i:s", intval($abook['last_updated'])); 837 } 838 839 if (self::noOverrideAllowed('name', $abook, $prefs)) { 840 $content_name = $abook['name']; 841 } else { 842 $input = new html_inputfield([ 843 'name' => $abookId . '_cd_name', 844 'type' => 'text', 845 'autocomplete' => 'off', 846 'value' => $abook['name'], 847 'size' => 40 848 ]); 849 $content_name = $input->show(); 850 } 851 852 $retval = [ 853 'options' => [ 854 ['title' => rcmail::Q($this->gettext('cd_name')), 'content' => $content_name], 855 ['title' => rcmail::Q($this->gettext('cd_active')), 'content' => $content_active], 856 ['title' => rcmail::Q($this->gettext('cd_use_categories')), 'content' => $content_use_categories], 857 ['title' => rcmail::Q($this->gettext('cd_username')), 'content' => $content_username], 858 ['title' => rcmail::Q($this->gettext('cd_password')), 'content' => $content_password], 859 ['title' => rcmail::Q($this->gettext('cd_url')), 'content' => $content_url], 860 ['title' => rcmail::Q($this->gettext('cd_refresh_time')), 'content' => $content_refresh_time], 861 ], 862 'name' => $blockheader 863 ]; 864 865 if (empty($abook['presetname']) && preg_match('/^\d+$/', $abookId)) { 866 $checkbox = new html_checkbox(['name' => $abookId . '_cd_delete', 'value' => 1]); 867 $content_delete = $checkbox->show("0"); 868 $retval['options'][] = ['title' => rcmail::Q($this->gettext('cd_delete')), 'content' => $content_delete]; 869 } 870 871 if (preg_match('/^\d+$/', $abookId)) { 872 $checkbox = new html_checkbox(['name' => $abookId . '_cd_resync', 'value' => 1]); 873 $content_resync = $checkbox->show("0"); 874 $retval['options'][] = ['title' => rcmail::Q($this->gettext('cd_resync')), 'content' => $content_resync]; 875 } 876 877 return $retval; 878 } 879 880 /** 881 * This function gets the addressbook settings from a POST request. 882 * 883 * The behavior varies depending on whether the settings for an existing or a new addressbook are queried. 884 * For an existing addressbook, the result array will only have keys set for POSTed values. In particular, this 885 * means that for fixed settings of preset addressbooks, no setting values will be contained. 886 * For a new addressbook, all settings are set in the resulting array. If not provided by the user, default values 887 * are used. 888 * 889 * @param string $abookId The ID of the addressbook ("new" for new addressbooks, otherwise the numeric DB id) 890 * @return string[] An array with addressbook column keys and their setting. 891 */ 892 private static function getAddressbookSettingsFromPOST(string $abookId): array 893 { 894 $nonEmptyDefaults = [ 895 "active" => "1", 896 "use_categories" => "1", 897 ]; 898 899 // for name we must not whether it is null or not to detect whether the settings form was POSTed or not 900 $name = rcube_utils::get_input_value("${abookId}_cd_name", rcube_utils::INPUT_POST); 901 $active = rcube_utils::get_input_value("${abookId}_cd_active", rcube_utils::INPUT_POST); 902 $use_categories = rcube_utils::get_input_value("${abookId}_cd_use_categories", rcube_utils::INPUT_POST); 903 904 $result = [ 905 'id' => $abookId, 906 'name' => $name, 907 'username' => rcube_utils::get_input_value("${abookId}_cd_username", rcube_utils::INPUT_POST, true), 908 'password' => rcube_utils::get_input_value("${abookId}_cd_password", rcube_utils::INPUT_POST, true), 909 'url' => rcube_utils::get_input_value("${abookId}_cd_url", rcube_utils::INPUT_POST), 910 'active' => $active, 911 'use_categories' => $use_categories, 912 ]; 913 914 try { 915 $refresh_timestr = rcube_utils::get_input_value("${abookId}_cd_refresh_time", rcube_utils::INPUT_POST); 916 if (isset($refresh_timestr)) { 917 $result["refresh_time"] = (string) self::parseTimeParameter($refresh_timestr); 918 } 919 } catch (\Exception $e) { 920 // will use the DB default for new addressbooks, or leave the value unchanged for existing ones 921 } 922 923 if ($abookId == 'new') { 924 // detect if the POST request contains user-provided info for this addressbook or not 925 // (Problem: unchecked checkboxes don't appear with POSTed values, so we cannot discern not set values from 926 // actively unchecked values). 927 if (isset($name)) { 928 foreach (self::ABOOK_PROPS_BOOL as $boolOpt) { 929 if (!isset($result[$boolOpt])) { 930 $result[$boolOpt] = "0"; 931 } 932 } 933 } 934 935 // for new addressbooks, carry over the posted values or set defaults otherwise 936 foreach ($result as $k => $v) { 937 if (!isset($v)) { 938 $result[$k] = $nonEmptyDefaults[$k] ?? ''; 939 } 940 } 941 } else { 942 // for existing addressbooks, we only set the keys for that values were POSTed 943 // (for fixed settings, no values are posted) 944 foreach ($result as $k => $v) { 945 if (!isset($v)) { 946 unset($result[$k]); 947 } 948 } 949 foreach (self::ABOOK_PROPS_BOOL as $boolOpt) { 950 if (!isset($result[$boolOpt])) { 951 $result[$boolOpt] = "0"; 952 } 953 } 954 } 955 956 // this is for the static analyzer only, which will not detect from the above that 957 // array values will never be NULL 958 $r = []; 959 foreach ($result as $k => $v) { 960 if (isset($v)) { 961 $r[$k] = $v; 962 } 963 } 964 965 return $r; 966 } 967 968 private static function deleteAddressbook(string $abookId): void 969 { 970 try { 971 Database::startTransaction(false); 972 973 // we explicitly delete all data belonging to the addressbook, since 974 // cascaded deleted are not supported by all database backends 975 // ...custom subtypes 976 Database::delete($abookId, 'xsubtypes', 'abook_id'); 977 978 // ...groups and memberships 979 $delgroups = array_column(Database::get($abookId, 'id', 'groups', false, 'abook_id'), "id"); 980 if (!empty($delgroups)) { 981 Database::delete($delgroups, 'group_user', 'group_id'); 982 } 983 984 Database::delete($abookId, 'groups', 'abook_id'); 985 986 // ...contacts 987 Database::delete($abookId, 'contacts', 'abook_id'); 988 989 Database::delete($abookId, 'addressbooks'); 990 991 Database::endTransaction(); 992 } catch (\Exception $e) { 993 self::$logger->error("Could not delete addressbook: " . $e->getMessage()); 994 Database::rollbackTransaction(); 995 } 996 self::$abooksDb = null; 997 } 998 999 private static function insertAddressbook(array $pa): void 1000 { 1001 // check parameters 1002 if (key_exists('password', $pa)) { 1003 $pa['password'] = self::encryptPassword($pa['password']); 1004 } 1005 1006 $pa['user_id'] = $_SESSION['user_id']; 1007 1008 // required fields 1009 $qf = ['name','username','password','url','user_id']; 1010 $qv = []; 1011 foreach ($qf as $f) { 1012 if (!key_exists($f, $pa)) { 1013 throw new \Exception("Required parameter $f not provided for new addressbook"); 1014 } 1015 $qv[] = $pa[$f]; 1016 } 1017 1018 // optional fields 1019 $qfo = ['active','presetname','use_categories','refresh_time']; 1020 foreach ($qfo as $f) { 1021 if (key_exists($f, $pa)) { 1022 $qf[] = $f; 1023 $qv[] = $pa[$f]; 1024 } 1025 } 1026 1027 Database::insert("addressbooks", $qf, $qv); 1028 self::$abooksDb = null; 1029 } 1030 1031 /** 1032 * This function read and caches the admin settings from config.inc.php. 1033 * 1034 * Upon first call, the config file is read and the result is cached and returned. On subsequent calls, the cached 1035 * result is returned without reading the file again. 1036 * 1037 * @returns The admin settings array defined in config.inc.php. 1038 */ 1039 private static function getAdminSettings(): array 1040 { 1041 if (isset(self::$admin_settings)) { 1042 return self::$admin_settings; 1043 } 1044 1045 $prefs = []; 1046 $configfile = dirname(__FILE__) . "/config.inc.php"; 1047 if (file_exists($configfile)) { 1048 include($configfile); 1049 } 1050 1051 // empty preset key is not allowed 1052 if (isset($prefs[""])) { 1053 self::$logger->error("A preset key must be a non-empty string - ignoring preset!"); 1054 unset($prefs[""]); 1055 } 1056 1057 // initialize password store scheme if set 1058 if (isset($prefs['_GLOBAL']['pwstore_scheme'])) { 1059 $scheme = $prefs['_GLOBAL']['pwstore_scheme']; 1060 if (preg_match("/^(plain|base64|encrypted|des_key)$/", $scheme)) { 1061 self::$pwstore_scheme = $scheme; 1062 } 1063 } 1064 1065 // convert values to internal format 1066 foreach ($prefs as $presetname => &$preset) { 1067 // _GLOBAL contains plugin configuration not related to an addressbook preset - skip 1068 if ($presetname === '_GLOBAL') { 1069 continue; 1070 } 1071 1072 // boolean options are stored as 0 / 1 in the DB, internally we represent DB values as string 1073 foreach (self::ABOOK_PROPS_BOOL as $boolOpt) { 1074 if (isset($preset[$boolOpt])) { 1075 $preset[$boolOpt] = $preset[$boolOpt] ? '1' : '0'; 1076 } 1077 } 1078 1079 // refresh_time is stored in seconds 1080 try { 1081 if (isset($preset["refresh_time"])) { 1082 $preset["refresh_time"] = (string) self::parseTimeParameter($preset["refresh_time"]); 1083 } 1084 } catch (\Exception $e) { 1085 self::$logger->error("Error in preset $presetname: " . $e->getMessage()); 1086 unset($preset["refresh_time"]); 1087 } 1088 } 1089 1090 self::$admin_settings = $prefs; 1091 return $prefs; 1092 } 1093 1094 // password helpers 1095 private static function getDesKey(): string 1096 { 1097 $rcmail = rcmail::get_instance(); 1098 $imap_password = $rcmail->decrypt($_SESSION['password']); 1099 while (strlen($imap_password) < 24) { 1100 $imap_password .= $imap_password; 1101 } 1102 return substr($imap_password, 0, 24); 1103 } 1104 1105 /** 1106 * Returns all the users addressbooks, optionally filtered. 1107 * 1108 * @param $activeOnly If true, only the active addressbooks of the user are returned. 1109 * @param $presetsOnly If true, only the addressbooks created from an admin preset are returned. 1110 */ 1111 private static function getAddressbooks(bool $activeOnly = true, bool $presetsOnly = false): array 1112 { 1113 if (!isset(self::$abooksDb)) { 1114 self::$abooksDb = []; 1115 foreach (Database::get($_SESSION['user_id'], '*', 'addressbooks', false, 'user_id') as $abookrow) { 1116 self::$abooksDb[$abookrow["id"]] = $abookrow; 1117 } 1118 } 1119 1120 $result = self::$abooksDb; 1121 1122 if ($activeOnly) { 1123 $result = array_filter($result, function (array $v): bool { 1124 return $v["active"] == "1"; 1125 }); 1126 } 1127 1128 if ($presetsOnly) { 1129 $result = array_filter($result, function (array $v): bool { 1130 return !empty($v["presetname"]); 1131 }); 1132 } 1133 1134 return $result; 1135 } 1136} 1137 1138// vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120 1139