, * 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\RCMCardDAV\{Config, DataConversion}; use MStilkerich\RCMCardDAV\Frontend\{AddressbookManager,RcmInterface,UI}; use Sabre\VObject\Component\VCard; // 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) // phpcs:disable PSR1.Files.SideEffects -- need to load outside carddav class as it implements RcmInterface if (file_exists(dirname(__FILE__) . "/vendor/autoload.php")) { include_once dirname(__FILE__) . "/vendor/autoload.php"; } /** * @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 implements RcmInterface { /** * The version of this plugin. * * During development, it is set to the last release and added the suffix +dev. */ public const PLUGIN_VERSION = 'v5.1.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/' ]; /** * Regular expression defining task(s) to bind with * @var string */ public $task = 'addressbook|login|mail|settings|calendar'; /** * The addressbook manager. * @var AddressbookManager */ private $abMgr; /** * 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(): array { return self::PLUGIN_INFO; } /** * Default constructor. * * @param rcube_plugin_api $api Plugin API */ public function __construct($api) { parent::__construct($api); // we do not use the roundcube mechanism to save preferences but store preferences to custom DB tables $this->allowed_prefs = []; // constructor of AddressbookManager is empty, so it is safe to construct the object here $this->abMgr = new AddressbookManager(); } public function init(): void { try { $this->addHook('login_after', [$this, 'afterLogin']); // Until we have a logged on user, we have no business other than registering the afterLogin hook // Specifically, we delay the other initializations until this point since some actions (particularly // reading the admin presets, which may require substitution of the username in URLs) require the logged on // user to be available if (!isset($_SESSION['user_id'])) { return; } $this->basicInit(); $this->finalizeInit(); } catch (Exception $e) { $infra = Config::inst(); $logger = $infra->logger(); $logger->error("Could not init rcmcarddav: " . $e->getMessage()); } } /** * Performs basic initialization of the plugin. * * Particularly it reads the admin settings and initialized the loggers to the configured log levels. */ private function basicInit(): void { $infra = Config::inst(); // register this object as the roundcube adapter object $infra->rc($this); // initialize carddavclient library MStilkerich\CardDavClient\Config::init($infra->logger(), $infra->httpLogger()); } /** * Registers the plugin hook functions and the user's addressbooks with roundcube. */ private function finalizeInit(): void { $infra = Config::inst(); $rc = $infra->rc(); $rcmail = rcmail::get_instance(); $rcTask = $rcmail->task; $rc->addTexts('localization/'); $rc->addHook('addressbooks_list', [$this, 'listAddressbooks']); $rc->addHook('addressbook_get', [$this, 'getAddressbook']); $rc->addHook('addressbook_export', [$this, 'exportVCards']); // if preferences are configured as hidden by the admin, don't register the hooks handling preferences $admPrefs = $infra->admPrefs(); if (!$admPrefs->hidePreferences && $rcTask == "settings") { new UI($this->abMgr); } // 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"; }, $this->abMgr->getAddressbookIds() ); $config->set('autocomplete_addressbooks', array_merge($sources, $carddav_sources)); $specialAbooks = $admPrefs->getSpecialAddressbooks($this->abMgr, $infra); foreach ($specialAbooks as $type => $abookId) { // Set this option as immutable; otherwise it might be overridden from roundcube user preferences (#391) $config->set($type, "carddav_$abookId", true); } } /*************************************************************************************** * ROUNDCUBE ADAPTER **************************************************************************************/ public function locText(string $msgId, array $vars = []): string { $locMsg = $this->gettext(["name" => $msgId, "vars" => $vars]); return rcube::Q($locMsg); } public function inputValue(string $id, bool $allowHtml, int $source = rcube_utils::INPUT_POST): ?string { $value = rcube_utils::get_input_value($id, $source, $allowHtml); return is_string($value) ? $value : null; } public function showMessage(string $msg, string $msgType = 'notice', bool $override = false, int $timeout = 0): void { $rcube = rcube::get_instance(); $rcube->output->show_message($msg, $msgType, null, $override, $timeout); } public function clientCommand(string $method, ...$arguments): void { $rcube = rcube::get_instance(); $output = $rcube->output; if ($output instanceof rcmail_output) { $output->command($method, ...$arguments); } } public function addHook(string $hook, callable $callback): void { $this->add_hook($hook, $callback); } public function registerAction(string $action, callable $callback): void { $this->register_action($action, $callback); } public function addTexts(string $dir): void { $this->add_texts($dir, true); } public function includeCSS(string $cssFile): void { $skinPath = $this->local_skin_path(); $this->include_stylesheet("$skinPath/$cssFile"); } public function includeJS(string $jsFile, bool $rcInclude = false): void { if ($rcInclude) { $rcube = rcube::get_instance(); /** @psalm-var rcmail_output_html $output */ $output = $rcube->output; $output->include_script($jsFile); } else { $this->include_script($jsFile); } } public function addGuiObject(string $obj, string $id): void { $rcube = rcube::get_instance(); /** @psalm-var rcmail_output_html $output */ $output = $rcube->output; $output->add_gui_object($obj, $id); } public function setPageTitle(string $title): void { $rcube = rcube::get_instance(); /** @psalm-var rcmail_output_html $output */ $output = $rcube->output; $output->set_pagetitle($title); } public function addTemplateObjHandler(string $name, callable $func): void { $rcube = rcube::get_instance(); /** @psalm-var rcmail_output_html $output */ $output = $rcube->output; $output->add_handler($name, $func); } public function setEnv(string $name, $value, bool $addToJs = true): void { $rcube = rcube::get_instance(); $output = $rcube->output; if ($output instanceof rcmail_output_html) { $output->set_env($name, $value, $addToJs); } } public function sendTemplate(string $templ, bool $exit = true): void { $rcube = rcube::get_instance(); /** @psalm-var rcmail_output_html $output */ $output = $rcube->output; $output->send($templ, $exit); } /** * @param array $attrib */ public function requestForm(array $attrib, string $content): string { $rcube = rcube::get_instance(); /** @psalm-var rcmail_output_html $output */ $output = $rcube->output; return $output->request_form($attrib, $content); } /*************************************************************************************** * HOOK FUNCTIONS **************************************************************************************/ public function afterLogin(): void { // this is needed because when carddav::init() was invoked, SESSION['user_id'] was not yet available, and // therefore we delayed the plugin initialization to this point $this->basicInit(); $infra = Config::inst(); $logger = $infra->logger(); $admPrefs = $infra->admPrefs(); $db = $infra->db(); $abMgr = $this->abMgr; // Migrate database schema to the current version if needed try { $logger->debug(__METHOD__); $scriptDir = __DIR__ . "/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()); } // Initialize presets $admPrefs->initPresets($abMgr, $infra); // Perform rediscoveries when rediscover_time has passed // We do this after initPresets() so we do not perform a rediscovery for presets that the admin may have // deleted. For new accounts added by initPresets(), a rediscovery will not be done because rediscover_time is // just now. $accountIds = $abMgr->getAccountIds(); foreach ($accountIds as $accountId) { $accountCfg = $abMgr->getAccountConfig($accountId); // if there is no discovery URL, we cannot perform discovery; this should only happen for presets if (strlen($accountCfg['discovery_url'] ?? '') === 0) { continue; } if (($accountCfg['last_discovered'] + $accountCfg['rediscover_time']) < time()) { try { $abookTmpl = $admPrefs->getAddressbookTemplate($abMgr, $accountId); $logger->info("Performing re-discovery for account $accountId"); $abMgr->discoverAddressbooks($accountCfg, $abookTmpl); } catch (Exception $e) { $logger->error("Rediscovery of account $accountId failed: {$e->getMessage()}"); } } } // now register the addressbooks with roundcube $this->finalizeInit(); } /** * 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 { $infra = Config::inst(); $logger = $infra->logger(); try { $logger->debug(__METHOD__); $abMgr = $this->abMgr; foreach ($abMgr->getAddressbookIds() as $abookId) { $abookCfg = $abMgr->getAddressbookConfig($abookId); $p['sources']["carddav_$abookId"] = [ 'id' => "carddav_$abookId", 'name' => $abookCfg['name'], 'groups' => true, 'autocomplete' => true, 'readonly' => ($abookCfg['readonly'] != '0') ]; } } 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(); $rc = $infra->rc(); $abMgr = $this->abMgr; $abookId = $p['id'] ?? 'null'; try { $logger->debug(__METHOD__ . "($abookId)"); if (preg_match(";^carddav_(\d+)$;", $abookId, $match)) { $abookId = $match[1]; $abook = $abMgr->getAddressbook($abookId); $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) { $msgParam = [ 'name' => $abook->get_name() ]; try { $msgParam['duration'] = (string) $abMgr->resyncAddressbook($abook); $rc->showMessage($rc->locText('AbSync_msg_ok', $msgParam), 'notice', false); } catch (Exception $e) { $msgParam['errormsg'] = $e->getMessage(); $logger->error("Failed to sync addressbook: " . $msgParam['errormsg']); $rc->showMessage($rc->locText('AbSync_msg_fail', $msgParam), 'warning', false); } } } } 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) { if (isset($save_data["_carddav_vcard"])) { $vcf = DataConversion::exportVCard($save_data["_carddav_vcard"], $save_data); $save_data["vcard"] = $vcf; } } return [ "result" => $saveDataSet["result"], "abort" => false ]; } } // vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120