HOME


Mini Shell 1.0
DIR:/usr/local/cwpsrv/var/services/roundcube/plugins/carddav/
Upload File :
Current File : //usr/local/cwpsrv/var/services/roundcube/plugins/carddav/carddav.php
<?php

/*
 * RCMCardDAV - CardDAV plugin for Roundcube webmail
 *
 * Copyright (C) 2011-2021 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);

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<ConfigurablePresetAttribute>,
 *     require_always: list<string>,
 *     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<PasswordStoreScheme> 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<string, Preset> Presets from config.inc.php */
    private $presets = [];

    public $task = 'addressbook|login|mail|settings|calendar';

    /** @var ?array<string, FullAbookRow> $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<string, RcAddressbookInfo>} $p
     * @return array{sources: array<string, RcAddressbookInfo>}
     */
    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<FullAbookRow> $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<string> $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<AddressbookCollection> 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<string, FullAbookRow>
     */
    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