, * Michael Stilkerich * * This file is part of RCMCardDAV. * * RCMCardDAV is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 2 of the License, or * (at your option) any later version. * * RCMCardDAV is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with RCMCardDAV. If not, see . */ declare(strict_types=1); use MStilkerich\CardDavClient\{Account, AddressbookCollection}; use Psr\Log\LoggerInterface; use MStilkerich\CardDavAddressbook4Roundcube\{Addressbook, Config, RoundcubeLogger, DataConversion}; use MStilkerich\CardDavAddressbook4Roundcube\Db\{Database, AbstractDatabase}; use Sabre\VObject\Component\VCard; /** * @psalm-type PasswordStoreScheme = 'plain' | 'base64' | 'des_key' | 'encrypted' * @psalm-type ConfigurablePresetAttribute = 'name'|'url'|'username'|'password'|'active'|'refresh_time' * @psalm-type Preset = array{ * name: string, * url: string, * username: string, * password: string, * active: bool, * use_categories: bool, * readonly: bool, * refresh_time: int, * fixed: list, * require_always: list, * hide: bool, * carddav_name_only: bool * } * @psalm-type AbookSettings = array{ * name?: string, * username?: string, * password?: string, * url?: string, * refresh_time?: int, * active?: bool, * use_categories?: bool, * presetname?: string * } * @psalm-import-type FullAbookRow from AbstractDatabase * @psalm-import-type SaveDataFromDC from DataConversion */ // phpcs:ignore PSR1.Classes.ClassDeclaration, Squiz.Classes.ValidClassName -- class name(space) expected by roundcube class carddav extends rcube_plugin { /** * The version of this plugin. * * During development, it is set to the last release and added the suffix +dev. */ private const PLUGIN_VERSION = 'v4.2.0'; /** * Information about this plugin that is queried by roundcube. */ private const PLUGIN_INFO = [ 'name' => 'carddav', 'vendor' => 'Michael Stilkerich, Benjamin Schieder', 'version' => self::PLUGIN_VERSION, 'license' => 'GPL-2.0', 'uri' => 'https://github.com/mstilkerich/rcmcarddav/' ]; /** @var list List of supported password store schemes */ private const PWSTORE_SCHEMES = [ 'plain', 'base64', 'des_key', 'encrypted' ]; /** * @var AbookSettings Template for addressbook settings from the settings page. * The default values in this template also serve do determine the type (bool, int, string). */ private const ABOOK_TEMPLATE = [ // standard addressbook settings 'name' => '', 'url' => '', 'username' => '', 'password' => '', 'active' => true, 'use_categories' => true, 'refresh_time' => 3600, ]; /** * @var Preset Template for a preset; has the standard addressbook settings plus some extra properties. * The default values in this template also serve do determine the type (bool, int, string, array). */ private const PRESET_TEMPLATE = self::ABOOK_TEMPLATE + [ // extra settings for presets 'readonly' => false, 'carddav_name_only' => false, 'hide' => false, 'fixed' => [], 'require_always' => [], ]; /** @var PasswordStoreScheme encryption scheme */ private $pwStoreScheme = 'encrypted'; /** @var bool Global preference "fixed" */ private $forbidCustomAddressbooks = false; /** @var bool Global preference "hide_preferences" */ private $hidePreferences = false; /** @var array Presets from config.inc.php */ private $presets = []; public $task = 'addressbook|login|mail|settings|calendar'; /** @var ?array $abooksDb Cache of the user's addressbook DB entries. * Associative array mapping addressbook IDs to DB rows. */ private $abooksDb = null; /** * Provide information about this plugin. * * @return array Meta information about a plugin or false if not implemented. * As hash array with the following keys: * name: The plugin name * vendor: Name of the plugin developer * version: Plugin version name * license: License name (short form according to http://spdx.org/licenses/) * uri: The URL to the plugin homepage or source repository * src_uri: Direct download URL to the source code of this plugin * require: List of plugins required for this one (as array of plugin names) */ public static function info() { return self::PLUGIN_INFO; } /** * Default constructor. * * @param rcube_plugin_api $api Plugin API */ public function __construct($api, array $options = []) { // This supports a self-contained tarball installation of the plugin, at the risk of having conflicts with other // versions of the library installed in the global roundcube vendor directory (-> use not recommended) if (file_exists(dirname(__FILE__) . "/vendor/autoload.php")) { include_once dirname(__FILE__) . "/vendor/autoload.php"; } parent::__construct($api); // we do not currently use the roundcube mechanism to save preferences // but store preferences to custom database tables $this->allowed_prefs = []; } public function init(): void { $infra = Config::inst(); $logger = $infra->logger(); try { $this->readAdminSettings(); // initialize carddavclient library MStilkerich\CardDavClient\Config::init($logger, $infra->httpLogger()); $this->add_texts('localization/', false); $this->add_hook('addressbooks_list', [$this, 'listAddressbooks']); $this->add_hook('addressbook_get', [$this, 'getAddressbook']); $this->add_hook('addressbook_export', [$this, 'exportVCards']); // if preferences are configured as hidden by the admin, don't register the hooks handling preferences if (!$this->hidePreferences) { $this->add_hook('preferences_list', [$this, 'buildPreferencesPage']); $this->add_hook('preferences_save', [$this, 'savePreferences']); $this->add_hook('preferences_sections_list', [$this, 'addPreferencesSection']); } $this->add_hook('login_after', [$this, 'checkMigrations']); $this->add_hook('login_after', [$this, 'initPresets']); if (!isset($_SESSION['user_id'])) { return; } // use this address book for autocompletion queries // (maybe this should be configurable by the user?) $config = rcube::get_instance()->config; $sources = (array) $config->get('autocomplete_addressbooks', ['sql']); $carddav_sources = array_map( function (string $id): string { return "carddav_$id"; }, array_keys($this->getAddressbooks()) ); $config->set('autocomplete_addressbooks', array_merge($sources, $carddav_sources)); $skin_path = $this->local_skin_path(); $this->include_stylesheet($skin_path . '/carddav.css'); } catch (\Exception $e) { $logger->error("Could not init rcmcarddav: " . $e->getMessage()); } } /*************************************************************************************** * HOOK FUNCTIONS **************************************************************************************/ public function checkMigrations(): void { $infra = Config::inst(); $logger = $infra->logger(); $db = $infra->db(); try { $logger->debug(__METHOD__); $scriptDir = dirname(__FILE__) . "/dbmigrations/"; $config = rcube::get_instance()->config; $dbprefix = (string) $config->get('db_prefix', ""); $db->checkMigrations($dbprefix, $scriptDir); } catch (\Exception $e) { $logger->error("Error execution DB schema migrations: " . $e->getMessage()); } } public function initPresets(): void { $infra = Config::inst(); $logger = $infra->logger(); try { $logger->debug(__METHOD__); // Get all existing addressbooks of this user that have been created from presets $existing_abooks = $this->getAddressbooks(false, true); // Group the addressbooks by their preset $existing_presets = []; foreach ($existing_abooks as $abookrow) { /** @var string $pn Not null because filtered by getAddressbooks() */ $pn = $abookrow['presetname']; if (!key_exists($pn, $existing_presets)) { $existing_presets[$pn] = []; } $existing_presets[$pn][] = $abookrow; } // Walk over the current presets configured by the admin and add, update or delete addressbooks foreach ($this->presets as $presetname => $preset) { // addressbooks exist for this preset => update settings if (key_exists($presetname, $existing_presets)) { if (!empty($preset['fixed'])) { $this->updatePresetAddressbooks($preset, $existing_presets[$presetname]); } unset($existing_presets[$presetname]); } else { // create new $preset['presetname'] = $presetname; $abname = $preset['name']; try { $username = self::replacePlaceholdersUsername($preset['username']); $url = self::replacePlaceholdersUrl($preset['url']); $password = self::replacePlaceholdersPassword($preset['password']); try { $account = Config::makeAccount($url, $username, $password, null); } catch (\Exception $e) { $logger->info("Skip adding preset for $username: required bearer token not available"); continue; } $logger->info("Adding preset for $username at URL $url"); $abooks = $this->determineAddressbooksToAdd($account); foreach ($abooks as $abook) { if ($preset['carddav_name_only']) { $preset['name'] = $abook->getName(); } else { $preset['name'] = "$abname (" . $abook->getName() . ')'; } $preset['url'] = $abook->getUri(); $this->insertAddressbook($preset); } } catch (\Exception $e) { $logger->error("Error adding addressbook from preset $presetname: {$e->getMessage()}"); } } } // delete existing preset addressbooks that were removed by admin foreach ($existing_presets as $ep) { $logger->info("Deleting preset addressbooks for " . (string) $_SESSION['user_id']); foreach ($ep as $abookrow) { $this->deleteAddressbook($abookrow['id']); } } } catch (\Exception $e) { $logger->error("Error initializing preconfigured addressbooks: " . $e->getMessage()); } } /** * Adds the user's CardDAV addressbooks to Roundcube's addressbook list. * * @psalm-type RcAddressbookInfo = array{id: string, name: string, groups: bool, autocomplete: bool, readonly: bool} * @psalm-param array{sources: array} $p * @return array{sources: array} */ public function listAddressbooks(array $p): array { $logger = Config::inst()->logger(); try { $logger->debug(__METHOD__); foreach ($this->getAddressbooks() as $abookrow) { $abookId = $abookrow["id"]; $presetname = $abookrow['presetname'] ?? ""; // empty string is not a valid preset name $ro = $this->presets[$presetname]['readonly'] ?? false; $p['sources']["carddav_$abookId"] = [ 'id' => "carddav_$abookId", 'name' => $abookrow['name'], 'groups' => true, 'autocomplete' => true, 'readonly' => $ro, ]; } } catch (\Exception $e) { $logger->error("Error reading carddav addressbooks: " . $e->getMessage()); } return $p; } /** * Hook called by roundcube to retrieve the instance of an addressbook. * * @param array $p The passed array contains the keys: * id: ID of the addressbook as passed to roundcube in the listAddressbooks hook. * writeable: Whether the addressbook needs to be writeable (checked by roundcube after returning an instance). * @psalm-param array{id: ?string} $p * @return array Returns the passed array extended by a key instance pointing to the addressbook object. * If the addressbook is not provided by the plugin, simply do not set the instance and return what was passed. */ public function getAddressbook(array $p): array { $infra = Config::inst(); $logger = $infra->logger(); $abookId = $p['id'] ?? 'null'; try { $logger->debug(__METHOD__ . "($abookId)"); if (preg_match(";^carddav_(\d+)$;", $abookId, $match)) { $abookId = $match[1]; $abooks = $this->getAddressbooks(false); // check that this addressbook ID actually refers to one of the user's addressbooks if (isset($abooks[$abookId])) { $config = $abooks[$abookId]; $presetname = $config["presetname"] ?? ""; // empty string is not a valid preset name $readonly = !empty($this->presets[$presetname]["readonly"] ?? '0'); $requiredProps = $this->presets[$presetname]["require_always"] ?? []; $config['username'] = self::replacePlaceholdersUsername($config["username"]); $config['password'] = self::replacePlaceholdersPassword( $this->decryptPassword($config["password"]) ); $abook = new Addressbook( $abookId, $config, $readonly, $requiredProps ); $p['instance'] = $abook; // refresh the address book if the update interval expired this requires a completely initialized // Addressbook object, so it needs to be at the end of this constructor $ts_syncdue = $abook->checkResyncDue(); if ($ts_syncdue <= 0) { $this->resyncAddressbook($abook); } } } } catch (\Exception $e) { $logger->error("Error loading carddav addressbook $abookId: " . $e->getMessage()); } return $p; } /** * Prepares the exported VCards when the user requested VCard export in roundcube. * * By adding a "vcard" member to a save_data set, we can override roundcube's own VCard creation * from the save_data and provide the VCard directly. * * Beware: This function is called also for non-carddav addressbooks, therefore it must handle entries * that cannot be found in the carddav addressbooks. * * @param array{result: rcube_result_set} $saveDataSet A result set as provided by Addressbook::list_records * @return array{abort: bool, result: rcube_result_set} The result set with added vcard members in each save_data */ public function exportVCards(array $saveDataSet): array { /** @psalm-var SaveDataFromDC $save_data */ foreach ($saveDataSet["result"]->records as &$save_data) { $vcard = $save_data["_carddav_vcard"] ?? null; if ($vcard instanceof VCard) { $vcf = DataConversion::exportVCard($vcard, $save_data); $save_data["vcard"] = $vcf; } } return [ "result" => $saveDataSet["result"], "abort" => false ]; } /** * Handler for preferences_list hook. * Adds options blocks into CardDAV settings sections in Preferences. * * @psalm-param array{section: string, blocks: array} $args Original parameters * @return array Modified parameters */ public function buildPreferencesPage(array $args): array { $logger = Config::inst()->logger(); try { $logger->debug(__METHOD__); if ($args['section'] != 'cd_preferences') { return $args; } $this->include_stylesheet($this->local_skin_path() . '/carddav.css'); $abooks = $this->getAddressbooks(false); uasort( $abooks, function (array $a, array $b): int { /** @var FullAbookRow $a */ $a = $a; /** @var FullAbookRow $b */ $b = $b; // presets first $ret = strcasecmp($b["presetname"] ?? "", $a["presetname"] ?? ""); if ($ret == 0) { // then alphabetically by name $ret = strcasecmp($a["name"], $b["name"]); } if ($ret == 0) { // finally by id (normally the names will differ) $ret = $a["id"] <=> $b["id"]; } return $ret; } ); $fromPresetStringLocalized = rcube::Q($this->gettext('cd_frompreset')); foreach ($abooks as $abookrow) { $abookId = $abookrow["id"]; $presetname = $abookrow['presetname'] ?? ""; // empty string is not a valid presetname if (!($this->presets[$presetname]['hide'] ?? false)) { $blockhdr = $abookrow['name']; if (!empty($presetname)) { $blockhdr .= str_replace("_PRESETNAME_", $presetname, $fromPresetStringLocalized); } $args["blocks"]["cd_preferences$abookId"] = $this->buildSettingsBlock($blockhdr, $abookrow, $abookId); } } // if allowed by admin, provide a block for entering data for a new addressbook if (!$this->forbidCustomAddressbooks) { $args['blocks']['cd_preferences_section_new'] = $this->buildSettingsBlock( rcube::Q($this->gettext('cd_newabboxtitle')), $this->getAddressbookSettingsFromPOST('new'), "new" ); } } catch (\Exception $e) { $logger->error("Error building carddav preferences page: " . $e->getMessage()); } return $args; } /** * add a section to the preferences tab * @psalm-param array{list: array, cols: array} $args */ public function addPreferencesSection(array $args): array { $logger = Config::inst()->logger(); try { $logger->debug(__METHOD__); $args['list']['cd_preferences'] = [ 'id' => 'cd_preferences', 'section' => rcube::Q($this->gettext('cd_title')) ]; } catch (\Exception $e) { $logger->error("Error adding carddav preferences section: " . $e->getMessage()); } return $args; } /** * Hook function called when the user saves the preferences. * * This function is called for any preferences section, not just that of the carddav plugin, so we need to check * first whether we are in the proper section. */ public function savePreferences(array $args): array { $infra = Config::inst(); $logger = $infra->logger(); try { $logger->debug(__METHOD__); if ($args['section'] != 'cd_preferences') { return $args; } // update existing in DB foreach ($this->getAddressbooks(false) as $abookrow) { $abookId = $abookrow["id"]; if (isset($_POST["${abookId}_cd_delete"])) { $this->deleteAddressbook($abookId); } else { $newset = $this->getAddressbookSettingsFromPOST($abookId, $abookrow["presetname"]); $this->updateAddressbook($abookId, $newset); if (isset($_POST["${abookId}_cd_resync"])) { [ 'instance' => $backend ] = $this->getAddressbook(['id' => "carddav_$abookId"]); if ($backend instanceof Addressbook) { $this->resyncAddressbook($backend); } } } } // add a new address book? $new = $this->getAddressbookSettingsFromPOST('new'); if ( !$this->forbidCustomAddressbooks // creation of addressbooks allowed by admin && !empty($new['name']) // user entered a name (and hopefully more data) for a new addressbook ) { try { $new["url"] = $new["url"] ?? ""; $new["username"] = $new['username'] ?? ""; $new["password"] = $new['password'] ?? ""; if (filter_var($new["url"], FILTER_VALIDATE_URL) === false) { throw new \Exception("Invalid URL: " . $new["url"]); } $account = Config::makeAccount( $new["url"], $new['username'], self::replacePlaceholdersPassword($new['password']), null ); $abooks = $this->determineAddressbooksToAdd($account); if (count($abooks) > 0) { $basename = $new['name']; foreach ($abooks as $abook) { $new['url'] = $abook->getUri(); $new['name'] = "$basename ({$abook->getName()})"; $logger->info("Adding addressbook {$new['username']} @ {$new['url']}"); $this->insertAddressbook($new); } // new addressbook added successfully -> clear the data from the form foreach (array_keys(self::ABOOK_TEMPLATE) as $k) { unset($_POST["new_cd_$k"]); } } else { throw new \Exception($new['name'] . ': ' . $this->gettext('cd_err_noabfound')); } } catch (\Exception $e) { $args['abort'] = true; $args['message'] = $e->getMessage(); } } } catch (\Exception $e) { $logger->error("Error saving carddav preferences: " . $e->getMessage()); } return $args; } /*************************************************************************************** * PRIVATE FUNCTIONS **************************************************************************************/ private static function replacePlaceholdersUsername(string $username): string { $rcube = rcube::get_instance(); $rcusername = (string) $_SESSION['username']; $username = strtr($username, [ '%u' => $rcusername, '%l' => $rcube->user->get_username('local'), '%d' => $rcube->user->get_username('domain'), // %V parses username for macosx, replaces periods and @ by _, work around bugs in contacts.app '%V' => strtr($rcusername, "@.", "__") ]); return $username; } private static function replacePlaceholdersUrl(string $url): string { // currently same as for username return self::replacePlaceholdersUsername($url); } private static function replacePlaceholdersPassword(string $password): string { if ($password == '%p') { $rcube = rcube::get_instance(); $password = $rcube->decrypt((string) $_SESSION['password']); if ($password === false) { $password = ""; } } return $password; } /** * Parses a time string to seconds. * * The time string must have the format HH[:MM[:SS]]. If the format does not match, an exception is thrown. * * @param string $refresht The time string to parse * @return int The time in seconds */ private static function parseTimeParameter(string $refresht): int { if (preg_match('/^(\d+)(:([0-5]?\d))?(:([0-5]?\d))?$/', $refresht, $match)) { $ret = 0; $ret += intval($match[1] ?? 0) * 3600; $ret += intval($match[3] ?? 0) * 60; $ret += intval($match[5] ?? 0); } else { throw new \Exception("Time string $refresht could not be parsed"); } return $ret; } /** * Compares the path components of two URIs. * * @return bool True if the normalized path components are equal. */ private static function compareUrlPaths(string $url1, string $url2): bool { $comp1 = \Sabre\Uri\parse($url1); $comp2 = \Sabre\Uri\parse($url2); $p1 = trim(rtrim($comp1["path"] ?? "", "/"), "/"); $p2 = trim(rtrim($comp2["path"] ?? "", "/"), "/"); return $p1 === $p2; } /** * @param AbookSettings $pa Array with the settings to update */ private function updateAddressbook(string $abookId, array $pa): void { // encrypt the password before storing it if (isset($pa['password'])) { $pa['password'] = $this->encryptPassword($pa['password']); } // optional fields $qf = []; $qv = []; foreach (array_keys(self::ABOOK_TEMPLATE) as $f) { if (isset($pa[$f])) { $v = $pa[$f]; $qf[] = $f; if (is_bool($v)) { $qv[] = $v ? '1' : '0'; } else { $qv[] = (string) $v; } } } if (!empty($qf)) { $db = Config::inst()->db(); $db->update($abookId, $qf, $qv, "addressbooks"); $this->abooksDb = null; } } /** * Converts a password to storage format according to the password storage scheme setting. * * @param string $clear The password in clear text. * @return string The password in storage format (e.g. encrypted with user password as key) */ private function encryptPassword(string $clear): string { $scheme = $this->pwStoreScheme; if (strcasecmp($scheme, 'plain') === 0) { return $clear; } if (strcasecmp($scheme, 'encrypted') === 0) { try { // encrypted with IMAP password $rcube = rcube::get_instance(); $imap_password = $this->getDesKey(); $rcube->config->set('carddav_des_key', $imap_password); $crypted = $rcube->encrypt($clear, 'carddav_des_key'); // there seems to be no way to unset a preference $rcube->config->set('carddav_des_key', ''); if ($crypted === false) { throw new \Exception("Password encryption with user password failed"); } return '{ENCRYPTED}' . $crypted; } catch (\Exception $e) { $logger = Config::inst()->logger(); $logger->warning( "Could not encrypt password with 'encrypted' method, falling back to 'des_key': " . $e->getMessage() ); $scheme = 'des_key'; } } if (strcasecmp($scheme, 'des_key') === 0) { // encrypted with global des_key $rcube = rcube::get_instance(); $crypted = $rcube->encrypt($clear); if ($crypted === false) { throw new \Exception("Could not encrypt password with 'des_key' method: "); } return '{DES_KEY}' . $crypted; } // default: base64-coded password return '{BASE64}' . base64_encode($clear); } private function decryptPassword(string $crypt): string { $logger = Config::inst()->logger(); if (strpos($crypt, '{ENCRYPTED}') === 0) { try { $crypt = substr($crypt, strlen('{ENCRYPTED}')); $rcube = rcube::get_instance(); $imap_password = $this->getDesKey(); $rcube->config->set('carddav_des_key', $imap_password); $clear = $rcube->decrypt($crypt, 'carddav_des_key'); // there seems to be no way to unset a preference $rcube->config->set('carddav_des_key', ''); if ($clear === false) { $clear = ""; } return $clear; } catch (\Exception $e) { $logger->warning("Cannot decrypt password: " . $e->getMessage()); return ""; } } if (strpos($crypt, '{DES_KEY}') === 0) { $crypt = substr($crypt, strlen('{DES_KEY}')); $rcube = rcube::get_instance(); $clear = $rcube->decrypt($crypt); if ($clear === false) { $clear = ""; } return $clear; } if (strpos($crypt, '{BASE64}') === 0) { $crypt = substr($crypt, strlen('{BASE64}')); return base64_decode($crypt); } // unknown scheme, assume cleartext return $crypt; } /** * Updates the fixed fields of addressbooks derived from presets against the current admin settings. * @param Preset $preset * @param list $existing_abooks for the given preset */ private function updatePresetAddressbooks(array $preset, array $existing_abooks): void { if (!is_array($preset["fixed"] ?? "")) { return; } foreach ($existing_abooks as $abookrow) { // decrypt password so that the comparison works $abookrow['password'] = $this->decryptPassword($abookrow['password']); // update only those attributes marked as fixed by the admin // otherwise there may be user changes that should not be destroyed $pa = []; foreach ($preset['fixed'] as $k) { if (isset($abookrow[$k]) && isset($preset[$k])) { // only update the name if it is used if ($k === 'name') { if (!$preset['carddav_name_only']) { $fullname = $abookrow['name']; $cnpos = strpos($fullname, ' ('); if ($cnpos === false && $preset['name'] != $fullname) { $pa['name'] = $preset['name']; } elseif ($cnpos !== false && $preset['name'] != substr($fullname, 0, $cnpos)) { $pa['name'] = $preset['name'] . substr($fullname, $cnpos); } } } elseif ($k === 'url') { // the URL cannot be automatically updated, as it was discovered and normally will // not exactly match the discovery URI. Resetting it to the discovery URI would // break the addressbook record } elseif ($abookrow[$k] != $preset[$k]) { $pa[$k] = $preset[$k]; } } } // only update if something changed if (!empty($pa)) { /** @psalm-var AbookSettings $pa */ $this->updateAddressbook($abookrow['id'], $pa); } } } /** * @param ?string $presetName If the setting is checked for an addressbook from a preset, the key of the preset. * Null if the setting is checked for a user-defined addressbook. * @return bool True if the setting is fixed for the given preset. Always false for user-defined addressbooks. */ private function noOverrideAllowed(string $pref, ?string $presetName): bool { // generally, url is fixed, as it results from discovery and has no direct correlation with the admin setting // if the URL of the addressbook changes, all URIs of our database objects would have to change, too -> in such // cases, deleting and re-adding the addressbook would be simpler if ($pref == "url") { return true; } $pn = $presetName ?? ""; // empty string is not a valid presetname return in_array($pref, $this->presets[$pn]['fixed'] ?? []); } /** * @param null|string|bool $value Value to show if the field can be edited. * @param null|string|bool $roValue Value to show if the field is shown in non-editable form. */ private function buildSettingField( string $abookId, string $attr, $value, ?string $presetName, $roValue = null ): string { // if the value is not set, use the default from the addressbook template $value = $value ?? self::ABOOK_TEMPLATE[$attr]; $roValue = $roValue ?? $value; // For new addressbooks, no attribute is fixed (note: noOverrideAllowed always returns true for URL) $attrFixed = $abookId != "new" && $this->noOverrideAllowed($attr, $presetName); if (is_bool(self::ABOOK_TEMPLATE[$attr])) { // boolean settings as a checkbox if ($attrFixed) { $content = $roValue ? $this->gettext('cd_enabled') : $this->gettext('cd_disabled'); } else { // check box for activating $checkbox = new html_checkbox(['name' => "${abookId}_cd_$attr", 'value' => 1]); $content = $checkbox->show($value ? "1" : "0"); } } elseif (is_string(self::ABOOK_TEMPLATE[$attr])) { if ($attrFixed) { $content = (string) $roValue; } else { // input box for username $input = new html_inputfield([ 'name' => "${abookId}_cd_$attr", 'type' => ($attr == 'password') ? 'password' : 'text', 'autocomplete' => 'off', 'value' => $value ]); $content = $input->show(); } } else { throw new \Exception("unsupported type"); } return $content; } /** * Builds a setting block for one address book for the preference page. * @param FullAbookRow|AbookSettings $abook */ private function buildSettingsBlock(string $blockheader, array $abook, string $abookId): array { $presetName = $abook["presetname"] ?? null; $content_active = $this->buildSettingField($abookId, "active", $abook['active'] ?? null, $presetName); $content_use_categories = $this->buildSettingField($abookId, "use_categories", $abook['use_categories'] ?? null, $presetName); $content_name = $this->buildSettingField($abookId, "name", $abook['name'] ?? null, $presetName); $content_username = $this->buildSettingField( $abookId, "username", $abook['username'] ?? null, $presetName, self::replacePlaceholdersUsername($abook['username'] ?? "") ); $content_password = $this->buildSettingField( $abookId, "password", // only display the password if it was entered for a new addressbook ($abookId == "new") ? ($abook['password'] ?? "") : "", $presetName, "***" ); $content_url = $this->buildSettingField( $abookId, "url", $abook['url'] ?? null, $presetName ); // input box for refresh time if (isset($abook["refresh_time"])) { $rt = $abook['refresh_time']; $refresh_time_str = sprintf("%02d:%02d:%02d", floor($rt / 3600), ($rt / 60) % 60, $rt % 60); } else { $refresh_time_str = ""; } if ($this->noOverrideAllowed('refresh_time', $presetName)) { $content_refresh_time = $refresh_time_str . ", "; } else { $input = new html_inputfield([ 'name' => $abookId . '_cd_refresh_time', 'type' => 'text', 'autocomplete' => 'off', 'value' => $refresh_time_str, 'size' => 10 ]); $content_refresh_time = $input->show(); } if (!empty($abook['last_updated'])) { // if never synced, last_updated is 0 -> don't show $content_refresh_time .= rcube::Q($this->gettext('cd_lastupdate_time')) . ": "; $content_refresh_time .= date("Y-m-d H:i:s", intval($abook['last_updated'])); } $retval = [ 'options' => [ ['title' => rcube::Q($this->gettext('cd_name')), 'content' => $content_name], ['title' => rcube::Q($this->gettext('cd_active')), 'content' => $content_active], ['title' => rcube::Q($this->gettext('cd_use_categories')), 'content' => $content_use_categories], ['title' => rcube::Q($this->gettext('cd_username')), 'content' => $content_username], ['title' => rcube::Q($this->gettext('cd_password')), 'content' => $content_password], ['title' => rcube::Q($this->gettext('cd_url')), 'content' => $content_url], ['title' => rcube::Q($this->gettext('cd_refresh_time')), 'content' => $content_refresh_time], ], 'name' => $blockheader ]; if (empty($presetName) && preg_match('/^\d+$/', $abookId)) { $checkbox = new html_checkbox(['name' => $abookId . '_cd_delete', 'value' => 1]); $content_delete = $checkbox->show("0"); $retval['options'][] = ['title' => rcube::Q($this->gettext('cd_delete')), 'content' => $content_delete]; } if ($abookId != "new") { $checkbox = new html_checkbox(['name' => $abookId . '_cd_resync', 'value' => 1]); $content_resync = $checkbox->show("0"); $retval['options'][] = ['title' => rcube::Q($this->gettext('cd_resync')), 'content' => $content_resync]; } return $retval; } /** * This function gets the addressbook settings from a POST request. * * The result array will only have keys set for POSTed values. * * For fixed settings of preset addressbooks, no setting values will be contained. * * Boolean settings will always be present in the result, since there is no way to differentiate whether a checkbox * was not checked or the value was not submitted at all - so the absence of a boolean setting is considered as a * false value for the setting. * * @param string $abookId The ID of the addressbook ("new" for new addressbooks, otherwise the numeric DB id) * @param ?string $presetName Name of the preset the addressbook belongs to; null for user-defined addressbook. * @return AbookSettings An array with addressbook column keys and their setting. */ private function getAddressbookSettingsFromPOST(string $abookId, ?string $presetName = null): array { $result = []; // Fill $result with all values that have been POSTed; for unset boolean values, false is assumed foreach (array_keys(self::ABOOK_TEMPLATE) as $attr) { // fixed settings for preset addressbooks are ignored if ($abookId != "new" && $this->noOverrideAllowed($attr, $presetName)) { continue; } $allow_html = ($attr == 'password'); $value = rcube_utils::get_input_value("${abookId}_cd_$attr", rcube_utils::INPUT_POST, $allow_html); if (is_bool(self::ABOOK_TEMPLATE[$attr])) { $result[$attr] = (bool) $value; } else { if (isset($value)) { if ($attr == "refresh_time") { try { $result["refresh_time"] = self::parseTimeParameter($value); } catch (\Exception $e) { // will use the DB default for new addressbooks, or leave the value unchanged for existing // ones } } elseif ($attr == "url") { $value = trim($value); if (!empty($value)) { // FILTER_VALIDATE_URL requires the scheme component, default to https if not specified if (strpos($value, "://") === false) { $value = "https://$value"; } } $result["url"] = $value; } elseif ($attr == "password") { // Password is only updated if not empty if (!empty($value)) { $result["password"] = $value; } } else { $result[$attr] = $value; } } } } // Set default values for boolean options of new addressbook; if name is null, it means the form is loaded for // the first time, otherwise it has been posted. if ($abookId == "new" && !isset($result["name"])) { foreach (self::ABOOK_TEMPLATE as $attr => $value) { if (is_bool($value)) { $result[$attr] = $value; } } } /** @psalm-var AbookSettings */ return $result; } private function deleteAddressbook(string $abookId): void { $infra = Config::inst(); $logger = $infra->logger(); $db = $infra->db(); try { $db->startTransaction(false); // we explicitly delete all data belonging to the addressbook, since // cascaded deleted are not supported by all database backends // ...custom subtypes $db->delete(['abook_id' => $abookId], 'xsubtypes'); // ...groups and memberships /** @psalm-var list $delgroups */ $delgroups = array_column($db->get(['abook_id' => $abookId], ['id'], 'groups'), "id"); if (!empty($delgroups)) { $db->delete(['group_id' => $delgroups], 'group_user'); } $db->delete(['abook_id' => $abookId], 'groups'); // ...contacts $db->delete(['abook_id' => $abookId], 'contacts'); $db->delete($abookId, 'addressbooks'); $db->endTransaction(); } catch (\Exception $e) { $logger->error("Could not delete addressbook: " . $e->getMessage()); $db->rollbackTransaction(); } $this->abooksDb = null; } /** * @param AbookSettings $pa Array with the settings for the new addressbook */ private function insertAddressbook(array $pa): void { $db = Config::inst()->db(); // check parameters if (isset($pa['password'])) { $pa['password'] = $this->encryptPassword($pa['password']); } $pa['user_id'] = (string) $_SESSION['user_id']; $pa['sync_token'] = ''; // required fields $qf = ['name','username','password','url','user_id','sync_token']; $qv = []; foreach ($qf as $f) { if (!isset($pa[$f])) { throw new \Exception("Required parameter $f not provided for new addressbook"); } $v = $pa[$f]; if (is_bool($v)) { $qv[] = $v ? '1' : '0'; } else { $qv[] = (string) $pa[$f]; } } // optional fields $qfo = ['active','presetname','use_categories','refresh_time']; foreach ($qfo as $f) { if (isset($pa[$f])) { $qf[] = $f; $v = $pa[$f]; if (is_bool($v)) { $qv[] = $v ? '1' : '0'; } else { $qv[] = (string) $pa[$f]; } } } $db->insert("addressbooks", $qf, [$qv]); $this->abooksDb = null; } /** * This function read and caches the admin settings from config.inc.php. * * Upon first call, the config file is read and the result is cached and returned. On subsequent calls, the cached * result is returned without reading the file again. */ private function readAdminSettings(): void { $logger = Config::inst()->logger(); $httpLogger = Config::inst()->httpLogger(); $prefs = []; $configfile = dirname(__FILE__) . "/config.inc.php"; if (file_exists($configfile)) { include($configfile); } // Extract global preferences if (isset($prefs['_GLOBAL']['pwstore_scheme']) && is_string($prefs['_GLOBAL']['pwstore_scheme'])) { $scheme = $prefs['_GLOBAL']['pwstore_scheme']; if (in_array($scheme, self::PWSTORE_SCHEMES)) { /** @var PasswordStoreScheme $scheme */ $this->pwStoreScheme = $scheme; } } $this->forbidCustomAddressbooks = ($prefs['_GLOBAL']['fixed'] ?? false) ? true : false; $this->hidePreferences = ($prefs['_GLOBAL']['hide_preferences'] ?? false) ? true : false; foreach (['loglevel' => $logger, 'loglevel_http' => $httpLogger] as $setting => $logger) { if (isset($prefs['_GLOBAL'][$setting]) && is_string($prefs['_GLOBAL'][$setting])) { if ($logger instanceof RoundcubeLogger) { $logger->setLogLevel($prefs['_GLOBAL'][$setting]); } } } // Store presets foreach ($prefs as $presetname => $preset) { // _GLOBAL contains plugin configuration not related to an addressbook preset - skip if ($presetname === '_GLOBAL') { continue; } if (!is_string($presetname) || empty($presetname)) { $logger->error("A preset key must be a non-empty string - ignoring preset!"); continue; } if (!is_array($preset)) { $logger->error("A preset definition must be an array of settings - ignoring preset $presetname!"); continue; } $this->addPreset($presetname, $preset); } } /** * Adds the given preset from config.inc.php to $this->presets. */ private function addPreset(string $presetname, array $preset): void { $logger = Config::inst()->logger(); // Resulting preset initialized with defaults $result = self::PRESET_TEMPLATE; try { foreach (array_keys($result) as $attr) { if ($attr == 'refresh_time') { // refresh_time is stored in seconds if (isset($preset["refresh_time"])) { if (is_string($preset["refresh_time"])) { $result["refresh_time"] = self::parseTimeParameter($preset["refresh_time"]); } else { $logger->error("Preset $presetname: setting $attr must be time string like 01:00:00"); } } } elseif (is_bool($result[$attr])) { if (isset($preset[$attr])) { if (is_bool($preset[$attr])) { $result[$attr] = $preset[$attr]; } else { $logger->error("Preset $presetname: setting $attr must be boolean"); } } } elseif (is_array($result[$attr])) { if (isset($preset[$attr]) && is_array($preset[$attr])) { foreach (array_keys($preset[$attr]) as $k) { if (is_string($preset[$attr][$k])) { $result[$attr][] = $preset[$attr][$k]; } } } } else { if (isset($preset[$attr]) && is_string($preset[$attr])) { $result[$attr] = $preset[$attr]; } } } /** @var Preset */ $this->presets[$presetname] = $result; } catch (\Exception $e) { $logger->error("Error in preset $presetname: " . $e->getMessage()); } } // password helpers private function getDesKey(): string { $rcube = rcube::get_instance(); $imap_password = $rcube->decrypt((string) $_SESSION['password']); if ($imap_password === false || strlen($imap_password) == 0) { throw new \Exception("No password available to use for encryption"); } while (strlen($imap_password) < 24) { $imap_password .= $imap_password; } return substr($imap_password, 0, 24); } /** * Determines the addressbooks to add for a given URI. * * We perform discovery to determine all the user's addressbooks. * * If the given URI might point to an addressbook directly (i.e. it has a non-empty path), we check if it is * contained in the discovered addressbooks. If it is not, we check if it actually points to an addressbook. If it * does, we add ONLY this addressbook, not the discovered ones. * * We need to perform the discovery to determine where the user's own addressbooks live (addressbook home). * * See https://github.com/mstilkerich/rcmcarddav/issues/339 for rationale. * * @param Account $account The account to discover the addressbooks for. The discovery URI is assumed as user input. * @return list The determined addressbooks, possible empty. */ private function determineAddressbooksToAdd(Account $account): array { $infra = Config::inst(); $logger = $infra->logger(); $uri = $account->getDiscoveryUri(); $discover = $infra->makeDiscoveryService(); $abooks = $discover->discoverAddressbooks($account); foreach ($abooks as $abook) { // If the discovery URI points to an addressbook that was also discovered, we use the discovered results // We deliberately only compare the path components, as the server part may contain a port (or not) or even // be a different server name after discovery, but the path part should be "unique enough" if (self::compareUrlPaths($uri, $abook->getUri())) { return $abooks; } } // If the discovery URI points to an addressbook that is not part of the discovered ones, we only use that // addressbook try { $directAbook = $infra->makeWebDavResource($uri, $account); if ($directAbook instanceof AddressbookCollection) { $logger->debug("Only adding non-individual addressbook $uri"); return [ $directAbook ]; } } catch (\Exception $e) { } return $abooks; } /** * Returns all the users addressbooks, optionally filtered. * * @param $activeOnly If true, only the active addressbooks of the user are returned. * @param $presetsOnly If true, only the addressbooks created from an admin preset are returned. * @return array */ private function getAddressbooks(bool $activeOnly = true, bool $presetsOnly = false): array { if (!isset($this->abooksDb)) { $db = Config::inst()->db(); $this->abooksDb = []; /** @var FullAbookRow $abookrow */ foreach ($db->get(['user_id' => (string) $_SESSION['user_id']], [], 'addressbooks') as $abookrow) { $this->abooksDb[$abookrow["id"]] = $abookrow; } } $result = $this->abooksDb; if ($activeOnly) { $result = array_filter($result, function (array $v): bool { return $v["active"] == "1"; }); } if ($presetsOnly) { $result = array_filter($result, function (array $v): bool { return !empty($v["presetname"]); }); } return $result; } /** * Resyncs the given addressbook and displays a popup message about duration. * * @param Addressbook $abook The addressbook object */ private function resyncAddressbook(Addressbook $abook): void { try { // To avoid unneccessary work followed by roll back with other time-triggered refreshes, we temporarily // set the last_updated time such that the next due time will be five minutes from now $ts_delay = time() + 300 - $abook->getRefreshTime(); $db = Config::inst()->db(); $db->update($abook->getId(), ["last_updated"], [(string) $ts_delay], "addressbooks"); $duration = $abook->resync(); $rcube = \rcube::get_instance(); $rcube->output->show_message( $this->gettext([ 'name' => 'cd_msg_synchronized', 'vars' => [ 'name' => $abook->get_name(), 'duration' => $duration, ] ]) ); } catch (\Exception $e) { $logger = Config::inst()->logger(); $logger->error("Failed to sync addressbook: " . $e->getMessage()); } } } // vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120