<?php /* * RCMCardDAV - CardDAV plugin for Roundcube webmail * * Copyright (C) 2011-2022 Benjamin Schieder <rcmcarddav@wegwerf.anderdonau.de>, * Michael Stilkerich <ms@mike2k.de> * * 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 <https://www.gnu.org/licenses/>. */ declare(strict_types=1); namespace MStilkerich\RCMCardDAV\Frontend; use Exception; use Psr\Log\LoggerInterface; use MStilkerich\RCMCardDAV\{Config, RoundcubeLogger}; use MStilkerich\CardDavClient\AddressbookCollection; /** * Represents the administrative settings of the plugin. * * @psalm-import-type Int1 from AddressbookManager * @psalm-import-type AbookCfg from AddressbookManager * @psalm-import-type AccountCfg from AddressbookManager * @psalm-import-type AccountSettings from AddressbookManager * @psalm-import-type AbookSettings from AddressbookManager * * @psalm-type PasswordStoreScheme = 'plain' | 'base64' | 'des_key' | 'encrypted' * @psalm-type ConfigurablePresetAttr = 'accountname'|'discovery_url'|'username'|'password'|'rediscover_time'| * 'active'|'refresh_time'|'use_categories'|'readonly'|'require_always_email'| * 'name'|'preemptive_basic_auth'|'ssl_noverify' * @psalm-type SpecialAbookType = 'collected_recipients'|'collected_senders'|'default_addressbook' * @psalm-type SpecialAbookMatch = array{preset: string, matchname?: non-empty-string, matchurl?: non-empty-string} * * @psalm-type PresetExtraAbook = array{ * url: string, * name: string, * active: Int1, * readonly: Int1, * refresh_time: numeric-string, * use_categories: Int1, * fixed: list<ConfigurablePresetAttr>, * require_always_email: Int1, * } * * @psalm-type Preset = array{ * accountname: string, * username: string, * password: string, * discovery_url: ?string, * rediscover_time: numeric-string, * hide: Int1, * preemptive_basic_auth: Int1, * ssl_noverify: Int1, * name: string, * active: Int1, * readonly: Int1, * refresh_time: numeric-string, * use_categories: Int1, * fixed: list<ConfigurablePresetAttr>, * require_always_email: Int1, * extra_addressbooks?: array<string, PresetExtraAbook>, * } * * @psalm-type SettingSpecification=array{'url'|'timestr'|'string'|'bool'|'string[]'|'skip', bool} */ class AdminSettings { /** @var list<PasswordStoreScheme> List of supported password store schemes */ public const PWSTORE_SCHEMES = [ 'plain', 'base64', 'des_key', 'encrypted' ]; /** * @var list<string> ALWAYS_FIXED * List of attributes that are always fixed. This makes sure the attribute is updated from the preset on login. */ private const ALWAYS_FIXED = ['readonly']; /** * @var list<string> ABOOK_ATTRS * List of attributes that are applicable to an addressbook. Used to filter out those attributes from the preset * that are relevant for the template addressbook settings. */ private const ABOOK_ATTRS = ['name', 'active', 'refresh_time', 'use_categories', 'require_always_email']; /** @var Preset Default values for the preset attributes */ private const PRESET_DEFAULTS = [ 'accountname' => '', 'username' => '', 'password' => '', 'discovery_url' => null, 'rediscover_time' => '86400', 'hide' => '0', 'preemptive_basic_auth' => '0', 'ssl_noverify' => '0', 'name' => '%N', 'active' => '1', 'readonly' => '0', 'refresh_time' => '3600', 'use_categories' => '1', 'fixed' => [], 'require_always_email' => '0', ]; /** * @var array<string, SettingSpecification> PRESET_SETTINGS_COMMON * This describes the valid attributes in a preset configuration that are available in the main account but can * also be overridden for extra addressbooks in their preset configuration. */ private const PRESET_SETTINGS_COMMON = [ // type, mandatory 'name' => [ 'string', false ], 'active' => [ 'bool', false ], 'readonly' => [ 'bool', false ], 'refresh_time' => [ 'timestr', false ], 'use_categories' => [ 'bool', false ], 'fixed' => [ 'string[]', false ], 'require_always_email' => [ 'bool', false ], ]; /** * @var array<string, SettingSpecification> PRESET_SETTINGS_EXTRA_ABOOK * This describes the valid attributes in a preset configuration of an extra addressbook (non-discovered), their * data type, whether they are mandatory to be specified by the admin, and the default value for optional * attributes. */ private const PRESET_SETTINGS_EXTRA_ABOOK = [ // type, mandatory 'url' => [ 'url', true ], ] + self::PRESET_SETTINGS_COMMON; /** * @var array<string, SettingSpecification> PRESET_SETTINGS * This describes the valid attributes in a preset configuration, their data type, whether they are mandatory to * be specified by the admin, and the default value for optional attributes. */ private const PRESET_SETTINGS = [ // type, mandatory 'accountname' => [ 'string', false ], 'username' => [ 'string', false ], 'password' => [ 'string', false ], 'discovery_url' => [ 'url', false ], 'rediscover_time' => [ 'timestr', false ], 'hide' => [ 'bool', false ], 'preemptive_basic_auth' => [ 'bool', false ], 'ssl_noverify' => [ 'bool', false ], 'extra_addressbooks' => [ 'skip', false ], ] + self::PRESET_SETTINGS_COMMON; /** * @var PasswordStoreScheme encryption scheme * @readonly */ public $pwStoreScheme = 'encrypted'; /** * @var bool Global preference "fixed" * @readonly */ public $forbidCustomAddressbooks = false; /** * @var bool Global preference "hide_preferences" * @readonly */ public $hidePreferences = false; /** * @var array<SpecialAbookType,SpecialAbookMatch> Match settings for special addressbooks */ private $specialAbookMatchers = []; /** * @var array<string, Preset> Presets from config.inc.php */ private $presets = []; /** * Initializes AdminSettings from a config.inc.php file, using default values if that file is not available. * * @param string $configfile Path of the config.inc.php file to load. */ public function __construct(string $configfile, LoggerInterface $logger, LoggerInterface $httpLogger) { $prefs = []; if (file_exists($configfile)) { include($configfile); /** @psalm-var mixed $prefs Will be set in the configfile. */ if (!is_array($prefs)) { $logger->error("Error in config.inc.php: \$prefs must be an array"); return; } } $gprefs = []; if (isset($prefs['_GLOBAL'])) { if (is_array($prefs['_GLOBAL'])) { $gprefs = $prefs['_GLOBAL']; } unset($prefs['_GLOBAL']); } // Extract global preferences if (isset($gprefs['pwstore_scheme'])) { $scheme = (string) $gprefs['pwstore_scheme']; if (in_array($scheme, self::PWSTORE_SCHEMES)) { /** @var PasswordStoreScheme $scheme */ $this->pwStoreScheme = $scheme; } else { $logger->error("Invalid pwStoreScheme $scheme in config.inc.php - using default 'encrypted'"); } } $this->forbidCustomAddressbooks = !empty($gprefs['fixed'] ?? false); $this->hidePreferences = !empty($gprefs['hide_preferences'] ?? false); foreach (['loglevel' => $logger, 'loglevel_http' => $httpLogger] as $setting => $cfgdLogger) { if (($cfgdLogger instanceof RoundcubeLogger) && isset($gprefs[$setting])) { try { $cfgdLogger->setLogLevel((string) $gprefs[$setting]); } catch (Exception $e) { $logger->error("Cannot set configured loglevel: " . $e->getMessage()); } } } // Store presets foreach ($prefs as $presetName => $preset) { if (!is_string($presetName) || strlen($presetName) == 0) { $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, $logger); } // Extract filter for special addressbooks foreach ([ 'collected_recipients', 'collected_senders', 'default_addressbook' ] as $setting) { if (isset($gprefs[$setting]) && is_array($gprefs[$setting])) { $matchSettings = $gprefs[$setting]; if ( isset($matchSettings['preset']) && is_string($matchSettings['preset']) && key_exists($matchSettings['preset'], $this->presets) ) { $presetName = $matchSettings['preset']; $matchSettings2 = [ 'preset' => $presetName ]; foreach (['matchname', 'matchurl'] as $matchType) { if (isset($matchSettings[$matchType]) && is_string($matchSettings[$matchType])) { $matchexpr = $matchSettings[$matchType]; $matchexpr = Utils::replacePlaceholdersUrl($matchexpr, true); if (strlen($matchexpr) > 0) { /** @psalm-var non-empty-string $matchexpr */ $matchSettings2[$matchType] = $matchexpr; } } } $this->specialAbookMatchers[$setting] = $matchSettings2; } else { $logger->error("Setting for $setting must include a valid preset attribute"); } } } } /** * Returns the preset with the given name. * * Optionally, the URL of a manually added addressbook may be given. In this case, the returned preset will contain * the values of that specific addressbook instead of those for auto-discovered addressbooks. * * @return Preset */ public function getPreset(string $presetName, ?string $xabookUrl = null): array { if (!isset($this->presets[$presetName])) { throw new Exception("Query for undefined preset $presetName"); } $preset = $this->presets[$presetName]; if (isset($xabookUrl) && isset($preset['extra_addressbooks'][$xabookUrl])) { /** * @psalm-var Preset $preset psalm assumes that extra keys (e.g. hide) may be present in the xabook preset * with unknown type, but this is not the case */ $preset = $preset['extra_addressbooks'][$xabookUrl] + $preset; } unset($preset['extra_addressbooks']); return $preset; } /** * Creates / updates / deletes preset addressbooks. */ public function initPresets(AddressbookManager $abMgr, Config $infra): void { $logger = $infra->logger(); try { $userId = (string) $_SESSION['user_id']; // Get all existing accounts of this user that have been created from presets $accountIds = $abMgr->getAccountIds(true); $existingPresets = []; foreach ($accountIds as $accountId) { $account = $abMgr->getAccountConfig($accountId); /** @psalm-var string $presetName Not null because filtered by getAccountIds() */ $presetName = $account['presetname']; $existingPresets[$presetName] = $accountId; } // Walk over the current presets configured by the admin and add, update or delete addressbooks foreach ($this->presets as $presetName => $preset) { try { $logger->info("Adding/Updating preset $presetName for user $userId"); // Map URL => ID of the existing extra addressbooks in the DB for this preset $existingExtraAbooksByUrl = []; if (isset($existingPresets[$presetName])) { $accountId = $existingPresets[$presetName]; // Update the extra addressbooks with the current set of the admin $existingExtraAbooksByUrl = array_column( $abMgr->getAddressbookConfigsForAccount($accountId, AddressbookManager::ABF_EXTRA), 'id', 'url' ); // Delete extra addressbooks that do not exist anymore in preset $delXAbooks = array_diff_key($existingExtraAbooksByUrl, $preset['extra_addressbooks'] ?? []); if (!empty($delXAbooks)) { $logger->info("Deleting deprecated extra addressbooks in $presetName for user $userId"); $abMgr->deleteAddressbooks(array_values($delXAbooks)); $existingExtraAbooksByUrl = array_diff_key($existingExtraAbooksByUrl, $delXAbooks); } // Update the fixed account/addressbook settings with the current admin values $this->updatePresetSettings($presetName, $accountId, $abMgr, $infra); } else { // Add new account first (addressbooks follow below) $accountCfg = $preset; // unset non-string attributes to psalm type compatibility unset($accountCfg['extra_addressbooks'], $accountCfg['fixed']); $accountCfg['presetname'] = $presetName; $accountId = $abMgr->insertAccount($accountCfg); } $accountCfg = $abMgr->getAccountConfig($accountId); // Create the extra addressbooks for this preset foreach (array_keys($preset['extra_addressbooks'] ?? []) as $xabookUrl) { if (!isset($existingExtraAbooksByUrl[$xabookUrl])) { // create new $this->insertExtraAddressbook($abMgr, $infra, $accountCfg, $xabookUrl, $presetName); } } } catch (Exception $e) { $logger->error("Error adding/updating preset $presetName for user $userId {$e->getMessage()}"); } unset($existingPresets[$presetName]); } // delete existing preset addressbooks that were removed by admin foreach ($existingPresets as $presetName => $accountId) { $logger->info("Deleting preset $presetName for user $userId"); $abMgr->deleteAccount($accountId); // also deletes the addressbooks } } catch (Exception $e) { $logger->error("Error initializing preconfigured addressbooks: {$e->getMessage()}"); } } /** * Updates the fixed fields of account and addressbooks derived from a preset with the current admin settings. * * This is done for all addressbooks of the account, including the template addressbook. * * Only fixed fields are updated, as non-fixed fields may have been changed by the user. * * @param AddressbookManager $abMgr The addressbook manager. */ private function updatePresetSettings( string $presetName, string $accountId, AddressbookManager $abMgr, Config $infra ): void { $accountCfg = $abMgr->getAccountConfig($accountId); $this->updatePresetAccount($accountCfg, $presetName, $abMgr); $abookCfgs = $abMgr->getAddressbookConfigsForAccount($accountId, AddressbookManager::ABF_ALL); foreach ($abookCfgs as $abookCfg) { $this->updatePresetAddressbook($accountCfg, $abookCfg, $presetName, $abMgr, $infra); } } /** * Updates the fixed fields of a preset account with the current admin settings. * * Only fixed fields are updated, as non-fixed fields may have been changed by the user. * * @param AccountCfg $accountCfg */ private function updatePresetAccount(array $accountCfg, string $presetName, AddressbookManager $abMgr): void { $preset = $this->getPreset($presetName); // 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($preset[$k]) && isset($accountCfg[$k]) && $accountCfg[$k] != $preset[$k]) { $pa[$k] = $preset[$k]; } } // only update if something changed if (!empty($pa)) { /** @psalm-var AccountSettings $pa */ $abMgr->updateAccount($accountCfg['id'], $pa); } } /** * Updates the fixed fields of one preset addressbook with the current admin settings. * * Only fixed fields are updated, as non-fixed fields may have been changed by the user. * * @param AccountCfg $accountCfg * @param AbookCfg $abookCfg */ private function updatePresetAddressbook( array $accountCfg, array $abookCfg, string $presetName, AddressbookManager $abMgr, Config $infra ): void { // extra addressbooks (discovered == 0) can have individual preset settings $preset = $this->getPreset($presetName, $abookCfg['url']); // 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($preset[$k]) && isset($abookCfg[$k])) { try { // no expansion of name template string for the template addressbook if (empty($abookCfg['template']) && $k === 'name') { $account = Config::makeAccount($accountCfg); $abook = $infra->makeWebDavResource($abookCfg['url'], $account); if ($abook instanceof AddressbookCollection) { $preset['name'] = $abMgr->replacePlaceholdersAbookName( $preset['name'], $accountCfg, $abook ); } else { throw new Exception("no addressbook collection at given URL"); } } if ($abookCfg[$k] != $preset[$k]) { $pa[$k] = $preset[$k]; } } catch (Exception $e) { // skip updating the name but update the remaining attributes, plus log an error $logger = $infra->logger(); $logger->error("Cannot update name of addressbook {$abookCfg['id']}: {$e->getMessage()}"); } } } // only update if something changed if (!empty($pa)) { /** @psalm-var AbookSettings $pa */ $abMgr->updateAddressbook($abookCfg['id'], $pa); } } /** * Adds a new non-discovered addressbook to an account. * * Performs a check with the server ensuring that the addressbook actually exists and can be accessed. * * @param AccountCfg $accountCfg Array with the settings of the account */ private function insertExtraAddressbook( AddressbookManager $abMgr, Config $infra, array $accountCfg, string $xabookUrl, string $presetName ): void { try { $account = Config::makeAccount($accountCfg); $abook = $infra->makeWebDavResource($xabookUrl, $account); if ($abook instanceof AddressbookCollection) { // Get values for the optional settings that the admin may have configured as part of the preset $abookTmpl = $this->getPreset($presetName, $xabookUrl); // unset non-string attributes to psalm type compatibility unset($abookTmpl['extra_addressbooks'], $abookTmpl['fixed']); $abookTmpl['account_id'] = $accountCfg['id']; $abookTmpl['discovered'] = '0'; $abookTmpl['sync_token'] = ''; $abookTmpl['url'] = $xabookUrl; $abookTmpl['name'] = $abMgr->replacePlaceholdersAbookName($abookTmpl['name'], $accountCfg, $abook); $abMgr->insertAddressbook($abookTmpl); } else { throw new Exception("no addressbook collection at given URL"); } } catch (Exception $e) { $logger = $infra->logger(); $logger->error("Failed to add extra addressbook $xabookUrl for preset $presetName: " . $e->getMessage()); } } /** * Adds the given preset from config.inc.php to $this->presets. */ private function addPreset(string $presetName, array $preset, LoggerInterface $logger): void { try { /** @psalm-var Preset $result Checked by parsePresetArray() */ $result = $this->parsePresetArray( self::PRESET_SETTINGS, $preset, ['accountname' => $presetName] + self::PRESET_DEFAULTS ); // Parse extra addressbooks $result['extra_addressbooks'] = []; if (isset($preset['extra_addressbooks'])) { if (!is_array($preset['extra_addressbooks'])) { throw new Exception("setting extra_addressbooks must be an array"); } foreach (array_keys($preset['extra_addressbooks']) as $k) { if (is_array($preset['extra_addressbooks'][$k])) { /** @psalm-var PresetExtraAbook $xabook Checked by parsePresetArray() */ $xabook = $this->parsePresetArray( self::PRESET_SETTINGS_EXTRA_ABOOK, $preset['extra_addressbooks'][$k], $result ); $result['extra_addressbooks'][$xabook['url']] = $xabook; } else { throw new Exception("setting extra_addressbooks[$k] must be an array"); } } } $this->presets[$presetName] = $result; } catch (Exception $e) { $logger->error("Error in preset $presetName: " . $e->getMessage()); } } /** * Parses / checks a user-input array according to a settings specification. * * @param array<string, SettingSpecification> $spec The specification of the expected fields. * @param array $preset The user-input array * @param Preset $defaults An array with defaults for all settings (for mandatory settings, they will not be used) * @return array If no error, the resulting array, containing only attributes from $spec. */ private function parsePresetArray(array $spec, array $preset, array $defaults): array { $result = []; foreach ($spec as $attr => $specs) { [ $type, $mandatory ] = $specs; if ($type === 'skip') { // this item has a special handler continue; } if (isset($preset[$attr])) { switch ($type) { case 'string': case 'timestr': case 'url': if (is_string($preset[$attr])) { if ($type == 'timestr') { $result[$attr] = (string) Utils::parseTimeParameter($preset[$attr]); } elseif ($type == 'url') { $result[$attr] = Utils::replacePlaceholdersUrl($preset[$attr]); } else { $result[$attr] = $preset[$attr]; } } else { throw new Exception("setting $attr must be a string"); } break; case 'bool': $result[$attr] = empty($preset[$attr]) ? '0' : '1'; break; case 'string[]': if (is_array($preset[$attr])) { $result[$attr] = []; foreach (array_keys($preset[$attr]) as $k) { if (is_string($preset[$attr][$k])) { $result[$attr][] = $preset[$attr][$k]; } else { throw new Exception("setting $attr\[$k\] must be string"); } } } else { throw new Exception("setting $attr must be array"); } } } elseif ($mandatory) { throw new Exception("required setting $attr is not set"); } else { $result[$attr] = $defaults[$attr]; } } /** @psalm-var Preset|PresetExtraAbook $result */ // Add attributes that are never user-configurable to fixed to they are updated from admin preset on login foreach (self::ALWAYS_FIXED as $attr) { if (!in_array($attr, $result['fixed'])) { $result['fixed'][] = $attr; } } return $result; } /** * Gets the special addressbooks that are configured to CardDAV sources by the admin. * * These special addressbooks as of roundcube 1.5 are collected recipients and collected senders. The admin can * configure a match expression for the name or the URL of the addressbook, that is looked for in a specific preset. * * @return array<SpecialAbookType, string> ID for each special addressbook for that a CardDAV source is selected */ public function getSpecialAddressbooks(AddressbookManager $abMgr, Config $infra): array { $logger = $infra->logger(); $ret = []; if (empty($this->specialAbookMatchers)) { return $ret; } // Create a mapping Presetname => AccountConfig $presetIdsByPresetname = []; foreach ($abMgr->getAccountIds(true) as $accountId) { $accountCfg = $abMgr->getAccountConfig($accountId); /** @psalm-var string $presetName Not null because filtered by getAccountIds() */ $presetName = $accountCfg['presetname']; $presetIdsByPresetname[$presetName] = $accountId; } // Search for the addressbook to use for each of the special addressbooks if configured foreach ($this->specialAbookMatchers as $type => $matchSettings) { $presetName = $matchSettings['preset']; $matches = []; // When an admin creates a new preset in the configuration and a user is still logged on, the user will not // have an account for that preset yet until the next login. If this new preset is referred to for a special // addressbook, we cannot set it just yet. if (!isset($presetIdsByPresetname[$presetName])) { $logger->debug("Cannot set special addressbook $type, no account for preset $presetName in DB"); continue; } $accountId = $presetIdsByPresetname[$presetName]; // Read-only, inactive and template addressbooks are not considered for candidates $abCandidates = $abMgr->getAddressbookConfigsForAccount($accountId, AddressbookManager::ABF_ACTIVE_RW); foreach ($abCandidates as $abookCfg) { // check all addressbooks for that preset // All specified matchers must match // If no matcher is set, any addressbook of the preset is considered a match foreach (['matchname', 'matchurl'] as $matchType) { $matchexpr = $matchSettings[$matchType] ?? 0; if (is_string($matchexpr)) { /** @psalm-var non-empty-string $matchexpr */ if (!preg_match($matchexpr, $abookCfg[substr($matchType, 5)])) { continue 2; } } } $matches[] = $abookCfg['id']; } // we need exactly one match, in any other case we leave the roundcube setting as is $numMatches = count($matches); if ($numMatches != 1) { $logger->error("Cannot set special addressbook $type, there are $numMatches candidates (need: 1)"); } else { $ret[$type] = $matches[0]; } } return $ret; } /** * Provides the addressbook template for new addressbooks of an account, incorporating fixed settings of presets. * * This function can be used for both user-defined and preset accounts. * * For a user-defined account, it will simply provide the addressbook template of that account, if available, or * otherwise an empty array. * * For a preset account, it will provide the preset settings unless a template addressbook is also available for the * account. In the latter case, it will adapt the template addressbook to overwrite all fixed fields with the * settings from the preset. * * @param AddressbookManager $abMgr * @param string $accountId ID of the account for that the addressbook template shall be provided * @return AbookSettings The initial addressbook settings */ public function getAddressbookTemplate(AddressbookManager $abMgr, string $accountId): array { $accountCfg = $abMgr->getAccountConfig($accountId); $abookTmpl = $abMgr->getTemplateAddressbookForAccount($accountId); $presetName = $accountCfg['presetname']; if ($presetName !== null) { $preset = $this->getPreset($presetName); if ($abookTmpl === null) { $abookTmpl = array_intersect_key($preset, array_flip(self::ABOOK_ATTRS)); } else { // take the fixed attributes from the preset foreach (array_keys($abookTmpl) as $attr) { if (in_array($attr, $preset['fixed'])) { if (isset($preset[$attr])) { $abookTmpl[$attr] = $preset[$attr]; } } } } } /** @psalm-var ?AbookSettings $abookTmpl */ return $abookTmpl ?? []; } } // vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120