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