diff --git a/plugins/kolab_addressbook/drivers/carddav/carddav_contacts.php b/plugins/kolab_addressbook/drivers/carddav/carddav_contacts.php index 4112e9d9..9ec2d117 100644 --- a/plugins/kolab_addressbook/drivers/carddav/carddav_contacts.php +++ b/plugins/kolab_addressbook/drivers/carddav/carddav_contacts.php @@ -1,1241 +1,1269 @@ <?php /** * Backend class for a custom address book using CardDAV service. * * This part of the Roundcube+Kolab integration and connects the * rcube_addressbook interface with the kolab_storage_dav wrapper from libkolab * * @author Thomas Bruederli <bruederli@kolabsys.com> * @author Aleksander Machniak <machniak@apheleia-it.chm> * * Copyright (C) 2011-2022, Kolab Systems AG <contact@apheleia-it.ch> * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program 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 Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. * * @see rcube_addressbook */ class carddav_contacts extends rcube_addressbook { public $primary_key = 'ID'; public $rights = 'lrs'; public $readonly = true; public $undelete = false; - public $groups = false; // TODO + public $groups = true; public $coltypes = [ 'name' => ['limit' => 1], 'firstname' => ['limit' => 1], 'surname' => ['limit' => 1], 'middlename' => ['limit' => 1], 'prefix' => ['limit' => 1], 'suffix' => ['limit' => 1], 'nickname' => ['limit' => 1], 'jobtitle' => ['limit' => 1], 'organization' => ['limit' => 1], 'department' => ['limit' => 1], 'email' => ['subtypes' => ['home','work','other']], 'phone' => [], 'address' => ['subtypes' => ['home','work','office']], 'website' => ['subtypes' => ['homepage','blog']], 'im' => ['subtypes' => null], 'gender' => ['limit' => 1], 'birthday' => ['limit' => 1], 'anniversary' => ['limit' => 1], 'manager' => ['limit' => null], 'assistant' => ['limit' => null], 'spouse' => ['limit' => 1], 'notes' => ['limit' => 1], 'photo' => ['limit' => 1], ]; public $vcard_map = [ // 'profession' => 'X-PROFESSION', // 'officelocation' => 'X-OFFICE-LOCATION', // 'initials' => 'X-INITIALS', // 'children' => 'X-CHILDREN', // 'freebusyurl' => 'X-FREEBUSY-URL', // 'pgppublickey' => 'KEY', 'uid' => 'UID', ]; /** * List of date type fields */ public $date_cols = ['birthday', 'anniversary']; public $fulltext_cols = ['name', 'firstname', 'surname', 'middlename', 'email']; private $gid; private $storage; private $dataset; private $sortindex; private $contacts; private $distlists; private $groupmembers; private $filter; private $result; private $namespace; private $action; // list of fields used for searching in "All fields" mode private $search_fields = [ 'name', 'firstname', 'surname', 'middlename', 'prefix', 'suffix', 'nickname', 'jobtitle', 'organization', 'department', 'email', 'phone', 'address', // 'profession', 'manager', 'assistant', 'spouse', - 'children', +// 'children', 'notes', ]; /** * Object constructor */ public function __construct($dav_folder = null) { $this->storage = $dav_folder; $this->ready = !empty($this->storage); // Set readonly and rights flags according to folder permissions if ($this->ready) { if ($this->storage->get_owner() == $_SESSION['username']) { $this->readonly = false; $this->rights = 'lrswikxtea'; } else { $rights = $this->storage->get_myrights(); if ($rights && !PEAR::isError($rights)) { $this->rights = $rights; if (strpos($rights, 'i') !== false && strpos($rights, 't') !== false) { $this->readonly = false; } } } } $this->action = rcube::get_instance()->action; } /** * Getter for the address book name to be displayed * * @return string Name of this address book */ public function get_name() { return $this->storage->get_name(); } /** * Wrapper for kolab_storage_folder::get_foldername() */ public function get_foldername() { return $this->storage->get_foldername(); } /** * Getter for the folder name * * @return string Name of the folder */ public function get_realname() { return $this->get_name(); } /** * Getter for the name of the namespace to which the IMAP folder belongs * * @return string Name of the namespace (personal, other, shared) */ public function get_namespace() { if ($this->namespace === null && $this->ready) { $this->namespace = $this->storage->get_namespace(); } return $this->namespace; } /** * Getter for parent folder path * * @return string Full path to parent folder */ public function get_parent() { return $this->storage->get_parent(); } /** * Check subscription status of this folder * * @return boolean True if subscribed, false if not */ public function is_subscribed() { return true; } /** * Compose an URL for CardDAV access to this address book (if configured) */ public function get_carddav_url() { /* $rcmail = rcmail::get_instance(); if ($template = $rcmail->config->get('kolab_addressbook_carddav_url', null)) { return strtr($template, [ '%h' => $_SERVER['HTTP_HOST'], '%u' => urlencode($rcmail->get_user_name()), '%i' => urlencode($this->storage->get_uid()), '%n' => urlencode($this->imap_folder), ]); } */ return false; } /** * Setter for the current group */ public function set_group($gid) { $this->gid = $gid; } /** * Save a search string for future listings * * @param mixed Search params to use in listing method, obtained by get_search_set() */ public function set_search_set($filter) { $this->filter = $filter; } /** * Getter for saved search properties * * @return mixed Search properties used by this class */ public function get_search_set() { return $this->filter; } /** * Reset saved results and search parameters */ public function reset() { $this->result = null; $this->filter = null; } /** * List all active contact groups of this source * * @param string Optional search string to match group name * @param int Search mode. Sum of self::SEARCH_* * * @return array Indexed list of contact groups, each a hash array */ function list_groups($search = null, $mode = 0) { $this->_fetch_groups(); $groups = []; foreach ((array)$this->distlists as $group) { if (!$search || strstr(mb_strtolower($group['name']), mb_strtolower($search))) { $groups[$group['ID']] = ['ID' => $group['ID'], 'name' => $group['name']]; } } // sort groups by name uasort($groups, function($a, $b) { return strcoll($a['name'], $b['name']); }); return array_values($groups); } /** * List the current set of contact records * * @param array List of cols to show * @param int Only return this number of records, use negative values for tail * @param bool True to skip the count query (select only) * * @return array Indexed list of contact records, each a hash array */ public function list_records($cols = null, $subset = 0, $nocount = false) { $this->result = new rcube_result_set(0, ($this->list_page-1) * $this->page_size); $fetch_all = false; $fast_mode = !empty($cols) && is_array($cols); // list member of the selected group if ($this->gid) { $this->_fetch_groups(); $this->sortindex = []; $this->contacts = []; $local_sortindex = []; $uids = []; // get members with email specified foreach ((array)$this->distlists[$this->gid]['member'] as $member) { // skip member that don't match the search filter if (!empty($this->filter['ids']) && array_search($member['ID'], $this->filter['ids']) === false) { continue; } if (!empty($member['uid'])) { $uids[] = $member['uid']; } else if (!empty($member['email'])) { $this->contacts[$member['ID']] = $member; $local_sortindex[$member['ID']] = $this->_sort_string($member); $fetch_all = true; } } // get members by UID if (!empty($uids)) { $this->_fetch_contacts($query = [['uid', '=', $uids]], $fetch_all ? false : count($uids), $fast_mode); $this->sortindex = array_merge($this->sortindex, $local_sortindex); } } else if (is_array($this->filter['ids'])) { $ids = $this->filter['ids']; if (count($ids)) { $uids = array_map([$this, 'id2uid'], $this->filter['ids']); $this->_fetch_contacts($query = [['uid', '=', $uids]], count($ids), $fast_mode); } } else { $this->_fetch_contacts($query = 'contact', true, $fast_mode); } if ($fetch_all) { // sort results (index only) asort($this->sortindex, SORT_LOCALE_STRING); $ids = array_keys($this->sortindex); // fill contact data into the current result set $this->result->count = count($ids); $start_row = $subset < 0 ? $this->result->first + $this->page_size + $subset : $this->result->first; $last_row = min($subset != 0 ? $start_row + abs($subset) : $this->result->first + $this->page_size, $this->result->count); for ($i = $start_row; $i < $last_row; $i++) { if (array_key_exists($i, $ids)) { $idx = $ids[$i]; $this->result->add($this->contacts[$idx] ?: $this->_to_rcube_contact($this->dataset[$idx])); } } } else if (!empty($this->dataset)) { // get all records count, skip the query if possible if (!isset($query) || count($this->dataset) < $this->page_size) { $this->result->count = count($this->dataset) + $this->page_size * ($this->list_page - 1); } else { $this->result->count = $this->storage->count($query); } $start_row = $subset < 0 ? $this->page_size + $subset : 0; $last_row = min($subset != 0 ? $start_row + abs($subset) : $this->page_size, $this->result->count); for ($i = $start_row; $i < $last_row; $i++) { $this->result->add($this->_to_rcube_contact($this->dataset[$i])); } } return $this->result; } /** * Search records * * @param mixed $fields The field name of array of field names to search in * @param mixed $value Search value (or array of values when $fields is array) * @param int $mode Matching mode: * 0 - partial (*abc*), * 1 - strict (=), * 2 - prefix (abc*) * 4 - include groups (if supported) * @param bool $select True if results are requested, False if count only * @param bool $nocount True to skip the count query (select only) * @param array $required List of fields that cannot be empty * * @return rcube_result_set List of contact records and 'count' value */ public function search($fields, $value, $mode = 0, $select = true, $nocount = false, $required = []) { // search by ID if ($fields == $this->primary_key) { $ids = !is_array($value) ? explode(',', $value) : $value; $result = new rcube_result_set(); foreach ($ids as $id) { if ($rec = $this->get_record($id, true)) { $result->add($rec); $result->count++; } } return $result; } else if ($fields == '*') { $fields = $this->search_fields; } if (!is_array($fields)) { $fields = [$fields]; } if (!is_array($required) && !empty($required)) { $required = [$required]; } // advanced search if (is_array($value)) { $advanced = true; $value = array_map('mb_strtolower', $value); } else { $value = mb_strtolower($value); } $scount = count($fields); // build key name regexp $regexp = '/^(' . implode('|', $fields) . ')(?:.*)$/'; // pass query to storage if only indexed cols are involved // NOTE: this is only some rough pre-filtering but probably includes false positives $squery = $this->_search_query($fields, $value, $mode); // add magic selector to select contacts with birthday dates only if (in_array('birthday', $required)) { $squery[] = ['tags', '=', 'x-has-birthday']; } $squery[] = ['type', '=', 'contact']; // get all/matching records $this->_fetch_contacts($squery); // save searching conditions $this->filter = ['fields' => $fields, 'value' => $value, 'mode' => $mode, 'ids' => []]; // search by iterating over all records in dataset foreach ($this->dataset as $record) { $contact = $this->_to_rcube_contact($record); $id = $contact['ID']; // check if current contact has required values, otherwise skip it if ($required) { foreach ($required as $f) { // required field might be 'email', but contact might contain 'email:home' if (!($v = rcube_addressbook::get_col_values($f, $contact, true)) || empty($v)) { continue 2; } } } $found = []; $contents = ''; foreach (preg_grep($regexp, array_keys($contact)) as $col) { $pos = strpos($col, ':'); $colname = $pos ? substr($col, 0, $pos) : $col; foreach ((array)$contact[$col] as $val) { if ($advanced) { $found[$colname] = $this->compare_search_value($colname, $val, $value[array_search($colname, $fields)], $mode); } else { $contents .= ' ' . join(' ', (array)$val); } } } // compare matches if (($advanced && count($found) >= $scount) || (!$advanced && rcube_utils::words_match(mb_strtolower($contents), $value))) { $this->filter['ids'][] = $id; } } // dummy result with contacts count if (!$select) { return new rcube_result_set(count($this->filter['ids']), ($this->list_page-1) * $this->page_size); } // list records (now limited by $this->filter) return $this->list_records(); } /** * Refresh saved search results after data has changed */ public function refresh_search() { if ($this->filter) { $this->search($this->filter['fields'], $this->filter['value'], $this->filter['mode']); } return $this->get_search_set(); } /** * Count number of available contacts in database * * @return rcube_result_set Result set with values for 'count' and 'first' */ public function count() { if ($this->gid) { $this->_fetch_groups(); $count = count($this->distlists[$this->gid]['member']); } else if (is_array($this->filter['ids'])) { $count = count($this->filter['ids']); } else { $count = $this->storage->count('contact'); } return new rcube_result_set($count, ($this->list_page-1) * $this->page_size); } /** * Return the last result set * * @return rcube_result_set Current result set or NULL if nothing selected yet */ public function get_result() { return $this->result; } /** * Get a specific contact record * * @param mixed Record identifier(s) * @param bool True to return record as associative array, otherwise a result set is returned * * @return mixed Result object with all record fields or False if not found */ public function get_record($id, $assoc = false) { $rec = null; $uid = $this->id2uid($id); $rev = rcube_utils::get_input_value('_rev', rcube_utils::INPUT_GPC); if (strpos($uid, 'mailto:') === 0) { $this->_fetch_groups(true); $rec = $this->contacts[$id]; $this->readonly = true; // set source to read-only } /* else if (!empty($rev)) { $rcmail = rcube::get_instance(); $plugin = $rcmail->plugins->get_plugin('kolab_addressbook'); if ($plugin && ($object = $plugin->get_revision($id, kolab_storage::id_encode($this->imap_folder), $rev))) { $rec = $this->_to_rcube_contact($object); $rec['rev'] = $rev; } $this->readonly = true; // set source to read-only } */ else if ($object = $this->storage->get_object($uid)) { $rec = $this->_to_rcube_contact($object); } if ($rec) { $this->result = new rcube_result_set(1); $this->result->add($rec); return $assoc ? $rec : $this->result; } return false; } /** * Get group assignments of a specific contact record * * @param mixed Record identifier * * @return array List of assigned groups as ID=>Name pairs */ public function get_record_groups($id) { $out = []; $this->_fetch_groups(); if (!empty($this->groupmembers[$id])) { foreach ((array) $this->groupmembers[$id] as $gid) { if (!empty($this->distlists[$gid])) { $group = $this->distlists[$gid]; $out[$gid] = $group['name']; } } } return $out; } /** * Create a new contact record * * @param array Associative array with save data * Keys: Field name with optional section in the form FIELD:SECTION * Values: Field value. Can be either a string or an array of strings for multiple values * @param bool True to check for duplicates first * * @return mixed The created record ID on success, False on error */ public function insert($save_data, $check = false) { if (!is_array($save_data)) { return false; } $insert_id = $existing = false; // check for existing records by e-mail comparison if ($check) { foreach ($this->get_col_values('email', $save_data, true) as $email) { if (($res = $this->search('email', $email, true, false)) && $res->count) { $existing = true; break; } } } if (!$existing) { // Unset contact ID (e.g. when copying/moving from another addressbook) unset($save_data['ID'], $save_data['uid'], $save_data['_type']); // generate new Kolab contact item $object = $this->_from_rcube_contact($save_data); $saved = $this->storage->save($object, 'contact'); if (!$saved) { rcube::raise_error([ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error saving contact object to CardDAV server" ], true, false); } else { $insert_id = $object['uid']; } } return $insert_id; } /** * Update a specific contact record * * @param mixed Record identifier * @param array Associative array with save data * Keys: Field name with optional section in the form FIELD:SECTION * Values: Field value. Can be either a string or an array of strings for multiple values * * @return bool True on success, False on error */ public function update($id, $save_data) { $updated = false; if ($old = $this->storage->get_object($this->id2uid($id))) { $object = $this->_from_rcube_contact($save_data, $old); if (!$this->storage->save($object, 'contact', $old['uid'])) { rcube::raise_error([ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error saving contact object to CardDAV server" ], true, false ); } else { $updated = true; // TODO: update data in groups this contact is member of } } return $updated; } /** * Mark one or more contact records as deleted * * @param array Record identifiers * @param bool Remove record(s) irreversible (mark as deleted otherwise) * * @return int Number of records deleted */ public function delete($ids, $force = true) { $this->_fetch_groups(); if (!is_array($ids)) { $ids = explode(',', $ids); } $count = 0; foreach ($ids as $id) { if ($uid = $this->id2uid($id)) { $is_mailto = strpos($uid, 'mailto:') === 0; $deleted = $is_mailto || $this->storage->delete($uid, $force); if (!$deleted) { rcube::raise_error([ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error deleting a contact object $uid from the CardDAV server" ], true, false ); } else { // remove from distribution lists foreach ((array) $this->groupmembers[$id] as $gid) { if (!$is_mailto || $gid == $this->gid) { $this->remove_from_group($gid, $id); } } // clear internal cache unset($this->groupmembers[$id]); $count++; } } } return $count; } /** * Undelete one or more contact records. * Only possible just after delete (see 2nd argument of delete() method). * * @param array Record identifiers * * @return int Number of records restored */ public function undelete($ids) { if (!is_array($ids)) { $ids = explode(',', $ids); } $count = 0; foreach ($ids as $id) { $uid = $this->id2uid($id); if ($this->storage->undelete($uid)) { $count++; } else { rcube::raise_error([ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error undeleting a contact object $uid from the CardDav server" ], true, false ); } } return $count; } /** * Remove all records from the database * * @param bool $with_groups Remove also groups */ public function delete_all($with_groups = false) { if ($this->storage->delete_all()) { $this->contacts = []; $this->sortindex = []; $this->dataset = null; $this->result = null; } } /** * Close connection to source * Called on script shutdown */ public function close() { // NOP } /** * Create a contact group with the given name * * @param string The group name * * @return mixed False on error, array with record props in success */ function create_group($name) { $this->_fetch_groups(); - $result = false; + + $rcube = rcube::get_instance(); $list = [ - 'name' => $name, + 'uid' => strtoupper(md5(time() . uniqid(rand())) . '-' . substr(md5($rcube->user->get_username()), 0, 16)), + 'name' => $name, + 'kind' => 'group', 'member' => [], ]; - $saved = $this->storage->save($list, 'distribution-list'); + + $saved = $this->storage->save($list, 'contact'); if (!$saved) { rcube::raise_error([ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, - 'message' => "Error saving distribution-list object to CardDAV server" + 'message' => "Error saving a contact group to CardDAV server" ], true, false ); + return false; } - else { - $id = $this->uid2id($list['uid']); - $this->distlists[$id] = $list; - $result = ['id' => $id, 'name' => $name]; - } - return $result; + $id = $this->uid2id($list['uid']); + + $this->distlists[$id] = $list; + + return ['id' => $id, 'name' => $name]; } /** * Delete the given group and all linked group members * * @param string Group identifier * * @return bool True on success, false if no data was changed */ function delete_group($gid) { $this->_fetch_groups(); - $result = false; - if ($list = $this->distlists[$gid]) { - $deleted = $this->storage->delete($list['uid']); + $list = $this->distlists[$gid]; + + if (!$list) { + return false; } + $deleted = $this->storage->delete($list['uid']); + if (!$deleted) { rcube::raise_error([ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, - 'message' => "Error deleting distribution-list object from the CardDAV server" + 'message' => "Error deleting a contact group from the CardDAV server" ], true, false ); } - else { - $result = true; - } - return $result; + return $deleted; } /** * Rename a specific contact group * * @param string Group identifier * @param string New name to set for this group * @param string New group identifier (if changed, otherwise don't set) * - * @return bool New name on success, false if no data was changed + * @return string|false New name on success, false if no data was changed */ function rename_group($gid, $newname, &$newid) { $this->_fetch_groups(); + $list = $this->distlists[$gid]; - if ($newname != $list['name']) { - $list['name'] = $newname; - $saved = $this->storage->save($list, 'distribution-list', $list['uid']); + if (!$list) { + return false; + } + + if ($newname === $list['name']) { + return $newname; } + $list['name'] = $newname; + $saved = $this->storage->save($list, 'contact', $list['uid']); + if (!$saved) { rcube::raise_error([ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, - 'message' => "Error saving distribution-list object to CardDAV server" + 'message' => "Error saving a contact group to CardDAV server" ], true, false ); + return false; } return $newname; } /** * Add the given contact records the a certain group * * @param string Group identifier * @param array List of contact identifiers to be added - * @return int Number of contacts added + * + * @return int Number of contacts added */ function add_to_group($gid, $ids) { if (!is_array($ids)) { $ids = explode(',', $ids); } $this->_fetch_groups(true); - $list = $this->distlists[$gid]; + $list = $this->distlists[$gid]; + + if (!$list) { + return 0; + } + $added = 0; $uids = []; $exists = []; - foreach ((array)$list['member'] as $member) { + foreach ((array) $list['member'] as $member) { $exists[] = $member['ID']; } // substract existing assignments from list $ids = array_unique(array_diff($ids, $exists)); // add mailto: members foreach ($ids as $contact_id) { $uid = $this->id2uid($contact_id); if (strpos($uid, 'mailto:') === 0 && ($contact = $this->contacts[$contact_id])) { $list['member'][] = [ 'email' => $contact['email'], 'name' => $contact['name'], ]; $this->groupmembers[$contact_id][] = $gid; $added++; } else { $uids[$uid] = $contact_id; } } // add members with UID if (!empty($uids)) { foreach ($uids as $uid => $contact_id) { $list['member'][] = ['uid' => $uid]; $this->groupmembers[$contact_id][] = $gid; $added++; } } if ($added) { - $saved = $this->storage->save($list, 'distribution-list', $list['uid']); + $saved = $this->storage->save($list, 'contact', $list['uid']); } else { $saved = true; } if (!$saved) { rcube::raise_error([ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, - 'message' => "Error saving distribution-list to CardDAV server" + 'message' => "Error saving a contact-group to CardDAV server" ], true, false ); $added = false; $this->set_error(self::ERROR_SAVING, 'errorsaving'); } else { $this->distlists[$gid] = $list; } return $added; } /** * Remove the given contact records from a certain group * * @param string Group identifier * @param array List of contact identifiers to be removed * * @return bool */ function remove_from_group($gid, $ids) { - if (!is_array($ids)) { - $ids = explode(',', $ids); - } - $this->_fetch_groups(); - if (!($list = $this->distlists[$gid])) { + + $list = $this->distlists[$gid]; + + if (!$list) { return false; } + if (!is_array($ids)) { + $ids = explode(',', $ids); + } + $new_member = []; foreach ((array) $list['member'] as $member) { if (!in_array($member['ID'], $ids)) { $new_member[] = $member; } } // write distribution list back to server $list['member'] = $new_member; - $saved = $this->storage->save($list, 'distribution-list', $list['uid']); + $saved = $this->storage->save($list, 'contact', $list['uid']); if (!$saved) { rcube::raise_error([ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, - 'message' => "Error saving distribution-list object to CardDAV server" + 'message' => "Error saving a contact group to CardDAV server" ], true, false ); } else { // remove group assigments in local cache foreach ($ids as $id) { $j = array_search($gid, $this->groupmembers[$id]); unset($this->groupmembers[$id][$j]); } + $this->distlists[$gid] = $list; + return true; } return false; } /** * Check the given data before saving. * If input not valid, the message to display can be fetched using get_error() * * @param array Associative array with contact data to save * @param bool Attempt to fix/complete data automatically * * @return bool True if input is valid, False if not. */ public function validate(&$save_data, $autofix = false) { // validate e-mail addresses $valid = parent::validate($save_data); // require at least one e-mail address if there's no name // (syntax check is already done) if ($valid) { if (!strlen($save_data['name']) && !strlen($save_data['organization']) && !array_filter($this->get_col_values('email', $save_data, true)) ) { $this->set_error('warning', 'kolab_addressbook.noemailnamewarning'); $valid = false; } } return $valid; } /** * Query storage layer and store records in private member var */ private function _fetch_contacts($query = [], $limit = false, $fast_mode = false) { if (!isset($this->dataset) || !empty($query)) { if ($limit) { $size = is_int($limit) && $limit < $this->page_size ? $limit : $this->page_size; $this->storage->set_order_and_limit($this->_sort_columns(), $size, ($this->list_page-1) * $this->page_size); } $this->sortindex = []; $this->dataset = $this->storage->select($query, $fast_mode); foreach ($this->dataset as $idx => $record) { $contact = $this->_to_rcube_contact($record); $this->sortindex[$idx] = $this->_sort_string($contact); } } } /** * Extract a string for sorting from the given contact record */ private function _sort_string($rec) { $str = ''; switch ($this->sort_col) { case 'name': $str = $rec['name'] . $rec['prefix']; case 'firstname': $str .= $rec['firstname'] . $rec['middlename'] . $rec['surname']; break; case 'surname': $str = $rec['surname'] . $rec['firstname'] . $rec['middlename']; break; default: $str = $rec[$this->sort_col]; break; } $str .= is_array($rec['email']) ? $rec['email'][0] : $rec['email']; return mb_strtolower($str); } /** * Return the cache table columns to order by */ private function _sort_columns() { $sortcols = []; switch ($this->sort_col) { case 'name': $sortcols[] = 'name'; case 'firstname': $sortcols[] = 'firstname'; break; case 'surname': $sortcols[] = 'surname'; break; } $sortcols[] = 'email'; return $sortcols; } /** - * Read distribution-lists AKA groups from server + * Read contact groups from server */ private function _fetch_groups($with_contacts = false) { - return; // TODO - if (!isset($this->distlists)) { $this->distlists = $this->groupmembers = []; - foreach ($this->storage->select('distribution-list', true) as $record) { + + // Set order (and LIMIT to skip the count(*) select) + $this->storage->set_order_and_limit(['name'], 200, 0); + + foreach ($this->storage->select('group', true) as $record) { $record['ID'] = $this->uid2id($record['uid']); foreach ((array)$record['member'] as $i => $member) { $mid = $this->uid2id($member['uid'] ? $member['uid'] : 'mailto:' . $member['email']); $record['member'][$i]['ID'] = $mid; $record['member'][$i]['readonly'] = empty($member['uid']); $this->groupmembers[$mid][] = $record['ID']; if ($with_contacts && empty($member['uid'])) { $this->contacts[$mid] = $record['member'][$i]; } } + $this->distlists[$record['ID']] = $record; } + + $this->storage->set_order_and_limit($this->_sort_columns(), null, 0); } } /** * Encode object UID into a safe identifier */ public function uid2id($uid) { return rtrim(strtr(base64_encode($uid), '+/', '-_'), '='); } /** * Convert Roundcube object identifier back into the original UID */ public function id2uid($id) { return base64_decode(str_pad(strtr($id, '-_', '+/'), strlen($id) % 4, '=', STR_PAD_RIGHT)); } /** * Build SQL query for fulltext matches */ private function _search_query($fields, $value, $mode) { $query = []; $cols = []; $cols = array_intersect($fields, $this->fulltext_cols); if (count($cols)) { if ($mode & rcube_addressbook::SEARCH_STRICT) { $prefix = '^'; $suffix = '$'; } else if ($mode & rcube_addressbook::SEARCH_PREFIX) { $prefix = '^'; $suffix = ''; } else { $prefix = ''; $suffix = ''; } $search_string = is_array($value) ? join(' ', $value) : $value; foreach (rcube_utils::normalize_string($search_string, true) as $word) { $query[] = ['words', 'LIKE', $prefix . $word . $suffix]; } } return $query; } /** * Map fields from internal Kolab_Format to Roundcube contact format */ private function _to_rcube_contact($record) { $record['ID'] = $this->uid2id($record['uid']); // remove empty fields $record = array_filter($record); // Set _type for proper icon on the list $record['_type'] = 'person'; return $record; } /** * Map fields from Roundcube format to internal kolab_format_contact properties */ private function _from_rcube_contact($contact, $old = []) { if (empty($contact['uid']) && !empty($contact['ID'])) { $contact['uid'] = $this->id2uid($contact['ID']); } else if (empty($contact['uid']) && !empty($old['uid'])) { $contact['uid'] = $old['uid']; } else if (empty($contact['uid'])) { $rcube = rcube::get_instance(); $contact['uid'] = strtoupper(md5(time() . uniqid(rand())) . '-' . substr(md5($rcube->user->get_username()), 0, 16)); } // When importing contacts 'vcard' data might be added, we don't need it (Bug #1711) unset($contact['vcard']); return $contact; } } diff --git a/plugins/libkolab/lib/kolab_storage_dav.php b/plugins/libkolab/lib/kolab_storage_dav.php index fecff647..1837ff0a 100644 --- a/plugins/libkolab/lib/kolab_storage_dav.php +++ b/plugins/libkolab/lib/kolab_storage_dav.php @@ -1,547 +1,547 @@ <?php /** * Kolab storage class providing access to groupware objects on a *DAV server. * * @author Aleksander Machniak <machniak@apheleia-it.ch> * * Copyright (C) 2022, Apheleia IT AG <contact@apheleia-it.ch> * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program 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 Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ class kolab_storage_dav { const ERROR_DAV_CONN = 1; const ERROR_CACHE_DB = 2; const ERROR_NO_PERMISSION = 3; const ERROR_INVALID_FOLDER = 4; protected $dav; protected $url; /** * Object constructor */ public function __construct($url) { $this->url = $url; $this->setup(); } /** * Setup the environment */ public function setup() { $rcmail = rcube::get_instance(); $this->config = $rcmail->config; $this->dav = new kolab_dav_client($this->url); } /** * Get a list of storage folders for the given data type * - * @param string Data type to list folders for (contact,distribution-list,event,task,note) + * @param string Data type to list folders for (contact,event,task,note) * * @return array List of kolab_storage_dav_folder objects */ public function get_folders($type) { // TODO: This should be cached $folders = $this->dav->listFolders($this->get_dav_type($type)); if (is_array($folders)) { foreach ($folders as $idx => $folder) { // Exclude some special folders if (in_array('schedule-inbox', $folder['resource_type']) || in_array('schedule-outbox', $folder['resource_type'])) { unset($folders[$idx]); continue; } $folders[$idx] = new kolab_storage_dav_folder($this->dav, $folder, $type); } usort($folders, function ($a, $b) { return strcoll($a->get_foldername(), $b->get_foldername()); }); } return $folders ?: []; } /** * Getter for the storage folder for the given type * - * @param string Data type to list folders for (contact,distribution-list,event,task,note) + * @param string Data type to list folders for (contact,event,task,note) * * @return object kolab_storage_dav_folder The folder object */ public function get_default_folder($type) { // TODO: Not used } /** * Getter for a specific storage folder * * @param string $id Folder to access * @param string $type Expected folder type * * @return ?object kolab_storage_folder The folder object */ public function get_folder($id, $type) { foreach ($this->get_folders($type) as $folder) { if ($folder->id == $id) { return $folder; } } } /** * Getter for a single Kolab object, identified by its UID. * This will search all folders storing objects of the given type. * * @param string Object UID * @param string Object type (contact,event,task,journal,file,note,configuration) * * @return array The Kolab object represented as hash array or false if not found */ public function get_object($uid, $type) { // TODO return false; } /** * Execute cross-folder searches with the given query. * * @param array Pseudo-SQL query as list of filter parameter triplets * @param string Folder type (contact,event,task,journal,file,note,configuration) * @param int Expected number of records or limit (for performance reasons) * * @return array List of Kolab data objects (each represented as hash array) */ public function select($query, $type, $limit = null) { $result = []; foreach ($this->get_folders($type) as $folder) { if ($limit) { $folder->set_order_and_limit(null, $limit); } foreach ($folder->select($query) as $object) { $result[] = $object; } } return $result; } /** * Compose an URL to query the free/busy status for the given user * * @param string Email address of the user to get free/busy data for * @param object DateTime Start of the query range (optional) * @param object DateTime End of the query range (optional) * * @return string Fully qualified URL to query free/busy data */ public static function get_freebusy_url($email, $start = null, $end = null) { return kolab_storage::get_freebusy_url($email, $start, $end); } /** * Deletes a folder * * @param string $id Folder ID * @param string $type Folder type (contact,event,task,journal,file,note,configuration) * * @return bool True on success, false on failure */ public function folder_delete($id, $type) { if ($folder = $this->get_folder($id, $type)) { return $this->dav->folderDelete($folder->href); } return false; } /** * Creates a folder * * @param string $name Folder name (UTF7-IMAP) * @param string $type Folder type * @param bool $subscribed Sets folder subscription * @param bool $active Sets folder state (client-side subscription) * * @return bool True on success, false on failure */ public function folder_create($name, $type = null, $subscribed = false, $active = false) { // TODO } /** * Renames DAV folder * * @param string $oldname Old folder name (UTF7-IMAP) * @param string $newname New folder name (UTF7-IMAP) * * @return bool True on success, false on failure */ public function folder_rename($oldname, $newname) { // TODO ?? } /** * Update or Create a new folder. * * Does additional checks for permissions and folder name restrictions * * @param array &$prop Hash array with folder properties and metadata * - name: Folder name * - oldname: Old folder name when changed * - parent: Parent folder to create the new one in * - type: Folder type to create * - subscribed: Subscribed flag (IMAP subscription) * - active: Activation flag (client-side subscription) * * @return string|false New folder ID or False on failure */ public function folder_update(&$prop) { // TODO: Folder hierarchies, active and subscribed state // sanity checks if (!isset($prop['name']) || !is_string($prop['name']) || !strlen($prop['name'])) { self::$last_error = 'cannotbeempty'; return false; } else if (strlen($prop['name']) > 256) { self::$last_error = 'nametoolong'; return false; } if (!empty($prop['id'])) { if ($folder = $this->get_folder($prop['id'], $prop['type'])) { $result = $this->dav->folderUpdate($folder->href, $folder->get_dav_type(), $prop); } else { $result = false; } } else { $rcube = rcube::get_instance(); $uid = rtrim(chunk_split(md5($prop['name'] . $rcube->get_user_name() . uniqid('-', true)), 12, '-'), '-'); $type = $this->get_dav_type($prop['type']); $home = $this->dav->discover($type); if ($home === false) { return false; } $location = unslashify($home) . '/' . $uid; $result = $this->dav->folderCreate($location, $type, $prop); if ($result !== false) { $result = md5($this->dav->url . '/' . $location); } } return $result; } /** * Getter for human-readable name of a folder * * @param string $folder Folder name (UTF7-IMAP) * @param string $folder_ns Will be set to namespace name of the folder * * @return string Name of the folder-object */ public static function object_name($folder, &$folder_ns = null) { // TODO: Shared folders $folder_ns = 'personal'; return $folder; } /** * Creates a SELECT field with folders list * * @param string $type Folder type * @param array $attrs SELECT field attributes (e.g. name) * @param string $current The name of current folder (to skip it) * * @return html_select SELECT object */ public function folder_selector($type, $attrs, $current = '') { // TODO } /** * Returns a list of folder names * * @param string Optional root folder * @param string Optional name pattern * @param string Data type to list folders for (contact,event,task,journal,file,note,mail,configuration) * @param bool Enable to return subscribed folders only (null to use configured subscription mode) * @param array Will be filled with folder-types data * * @return array List of folders */ public function list_folders($root = '', $mbox = '*', $filter = null, $subscribed = null, &$folderdata = array()) { // TODO } /** * Search for shared or otherwise not listed groupware folders the user has access * * @param string Folder type of folders to search for * @param string Search string * @param array Namespace(s) to exclude results from * * @return array List of matching kolab_storage_folder objects */ public function search_folders($type, $query, $exclude_ns = []) { // TODO return []; } /** * Sort the given list of folders by namespace/name * * @param array List of kolab_storage_dav_folder objects * * @return array Sorted list of folders */ public static function sort_folders($folders) { // TODO return $folders; } /** * Returns folder types indexed by folder name * * @param string $prefix Folder prefix (Default '*' for all folders) * * @return array|bool List of folders, False on failure */ public function folders_typedata($prefix = '*') { // TODO: Used by kolab_folders, kolab_activesync, kolab_delegation return []; } /** * Returns type of a DAV folder * * @param string $folder Folder name (UTF7-IMAP) * * @return string Folder type */ public function folder_type($folder) { // TODO: Used by kolab_folders, kolab_activesync, kolab_delegation return ''; } /** * Sets folder content-type. * * @param string $folder Folder name * @param string $type Content type * * @return bool True on success, False otherwise */ public function set_folder_type($folder, $type = 'mail') { // NOP: Used by kolab_folders, kolab_activesync, kolab_delegation return false; } /** * Check subscription status of this folder * * @param string $folder Folder name * @param bool $temp Include temporary/session subscriptions * * @return bool True if subscribed, false if not */ public function folder_is_subscribed($folder, $temp = false) { // NOP return true; } /** * Change subscription status of this folder * * @param string $folder Folder name * @param bool $temp Only subscribe temporarily for the current session * * @return True on success, false on error */ public function folder_subscribe($folder, $temp = false) { // NOP return true; } /** * Change subscription status of this folder * * @param string $folder Folder name * @param bool $temp Only remove temporary subscription * * @return True on success, false on error */ public function folder_unsubscribe($folder, $temp = false) { // NOP return false; } /** * Check activation status of this folder * * @param string $folder Folder name * * @return bool True if active, false if not */ public function folder_is_active($folder) { return true; // TODO } /** * Change activation status of this folder * * @param string $folder Folder name * * @return True on success, false on error */ public function folder_activate($folder) { return true; // TODO } /** * Change activation status of this folder * * @param string $folder Folder name * * @return True on success, false on error */ public function folder_deactivate($folder) { return false; // TODO } /** * Creates default folder of specified type * To be run when none of subscribed folders (of specified type) is found * * @param string $type Folder type * @param string $props Folder properties (color, etc) * * @return string Folder name */ public function create_default_folder($type, $props = []) { // TODO: For kolab_addressbook?? return ''; } /** * Returns a list of IMAP folders shared by the given user * * @param array User entry from LDAP * @param string Data type to list folders for (contact,event,task,journal,file,note,mail,configuration) * @param int 1 - subscribed folders only, 0 - all folders, 2 - all non-active * @param array Will be filled with folder-types data * * @return array List of folders */ public function list_user_folders($user, $type, $subscribed = 0, &$folderdata = []) { // TODO return []; } /** * Get a list of (virtual) top-level folders from the other users namespace * * @param string Data type to list folders for (contact,event,task,journal,file,note,mail,configuration) * @param bool Enable to return subscribed folders only (null to use configured subscription mode) * * @return array List of kolab_storage_folder_user objects */ public function get_user_folders($type, $subscribed) { // TODO return []; } /** * Handler for user_delete plugin hooks * * Remove all cache data from the local database related to the given user. */ public static function delete_user_folders($args) { $db = rcmail::get_instance()->get_dbh(); $table = $db->table_name('kolab_folders', true); $prefix = 'dav://' . urlencode($args['username']) . '@' . $args['host'] . '/%'; $db->query("DELETE FROM $table WHERE `resource` LIKE ?", $prefix); } /** * Get folder METADATA for all supported keys * Do this in one go for better caching performance */ public function folder_metadata($folder) { // TODO ? return []; } /** * Get a folder DAV content type */ public static function get_dav_type($type) { $types = [ 'event' => 'VEVENT', 'task' => 'VTODO', 'contact' => 'VCARD', ]; return $types[$type]; } } diff --git a/plugins/libkolab/lib/kolab_storage_dav_cache_contact.php b/plugins/libkolab/lib/kolab_storage_dav_cache_contact.php index 0ca118dd..ed55e21e 100644 --- a/plugins/libkolab/lib/kolab_storage_dav_cache_contact.php +++ b/plugins/libkolab/lib/kolab_storage_dav_cache_contact.php @@ -1,113 +1,117 @@ <?php /** * Kolab storage cache class for contact objects * * @author Aleksander Machniak <machniak@apcheleia-it.ch> * * Copyright (C) 2013-2022, Apheleia IT AG <contact@apcheleia-it.ch> * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program 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 Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ class kolab_storage_dav_cache_contact extends kolab_storage_dav_cache { protected $extra_cols_max = 255; protected $extra_cols = ['type', 'name', 'firstname', 'surname', 'email']; protected $data_props = ['type', 'name', 'firstname', 'middlename', 'prefix', 'suffix', 'surname', 'email', 'organization', 'member']; protected $fulltext_cols = ['name', 'firstname', 'surname', 'middlename', 'email']; /** * Helper method to convert the given Kolab object into a dataset to be written to cache * * @override */ protected function _serialize($object) { $sql_data = parent::_serialize($object); $sql_data['type'] = $object['_type'] ?: 'contact'; + if ($sql_data['type'] == 'group' || (!empty($object['kind']) && $object['kind'] == 'group')) { + $sql_data['type'] = 'group'; + } + // columns for sorting $sql_data['name'] = rcube_charset::clean($object['name'] . $object['prefix']); $sql_data['firstname'] = rcube_charset::clean($object['firstname'] . $object['middlename'] . $object['surname']); $sql_data['surname'] = rcube_charset::clean($object['surname'] . $object['firstname'] . $object['middlename']); $sql_data['email'] = ''; foreach ($object as $colname => $value) { list($col, $field) = explode(':', $colname); if ($col == 'email' && !empty($value)) { $sql_data['email'] = is_array($value) ? $value[0] : $value; break; } } // use organization if name is empty if (empty($sql_data['name']) && !empty($object['organization'])) { $sql_data['name'] = rcube_charset::clean($object['organization']); } // make sure some data is not longer that database limit (#5291) foreach ($this->extra_cols as $col) { if (strlen($sql_data[$col]) > $this->extra_cols_max) { $sql_data[$col] = rcube_charset::clean(substr($sql_data[$col], 0, $this->extra_cols_max)); } } $sql_data['tags'] = ' ' . join(' ', $this->get_tags($object)) . ' '; // pad with spaces for strict/prefix search $sql_data['words'] = ' ' . join(' ', $this->get_words($object)) . ' '; return $sql_data; } /** * Callback to get words to index for fulltext search * * @return array List of words to save in cache */ public function get_words($object) { $data = ''; foreach ($object as $colname => $value) { list($col, $field) = explode(':', $colname); $val = ''; if (in_array($col, $this->fulltext_cols)) { $val = is_array($value) ? join(' ', $value) : $value; } if (strlen($val)) { $data .= $val . ' '; } } return array_unique(rcube_utils::normalize_string($data, true)); } /** * Callback to get object specific tags to cache * * @return array List of tags to save in cache */ public function get_tags($object) { $tags = []; if (!empty($object['birthday'])) { $tags[] = 'x-has-birthday'; } return $tags; } } diff --git a/plugins/libkolab/lib/kolab_storage_dav_folder.php b/plugins/libkolab/lib/kolab_storage_dav_folder.php index a588b95b..a8ea5794 100644 --- a/plugins/libkolab/lib/kolab_storage_dav_folder.php +++ b/plugins/libkolab/lib/kolab_storage_dav_folder.php @@ -1,587 +1,672 @@ <?php /** * A class representing a DAV folder object. * * @author Aleksander Machniak <machniak@apheleia-it.ch> * * Copyright (C) 2014-2022, Apheleia IT AG <contact@apheleia-it.ch> * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program 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 Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ class kolab_storage_dav_folder extends kolab_storage_folder { public $dav; public $href; public $attributes; /** * Object constructor */ public function __construct($dav, $attributes, $type_annotation = '') { $this->attributes = $attributes; $this->href = $this->attributes['href']; $this->id = md5($this->dav->url . '/' . $this->href); $this->dav = $dav; $this->valid = true; list($this->type, $suffix) = explode('.', $type_annotation); $this->default = $suffix == 'default'; $this->subtype = $this->default ? '' : $suffix; // Init cache $this->cache = kolab_storage_dav_cache::factory($this); } /** * Returns the owner of the folder. * * @param bool Return a fully qualified owner name (i.e. including domain for shared folders) * * @return string The owner of this folder. */ public function get_owner($fully_qualified = false) { // return cached value if (isset($this->owner)) { return $this->owner; } $rcube = rcube::get_instance(); $this->owner = $rcube->get_user_name(); $this->valid = true; // TODO: Support shared folders return $this->owner; } /** * Get a folder Etag identifier */ public function get_ctag() { return $this->attributes['ctag']; } /** * Getter for the name of the namespace to which the folder belongs * * @return string Name of the namespace (personal, other, shared) */ public function get_namespace() { // TODO: Support shared folders return 'personal'; } /** * Get the display name value of this folder * * @return string Folder name */ public function get_name() { return kolab_storage_dav::object_name($this->attributes['name']); } /** * Getter for the top-end folder name (not the entire path) * * @return string Name of this folder */ public function get_foldername() { return $this->attributes['name']; } public function get_folder_info() { return []; // todo ? } /** * Getter for parent folder path * * @return string Full path to parent folder */ public function get_parent() { // TODO return ''; } /** * Compose a unique resource URI for this folder */ public function get_resource_uri() { if (!empty($this->resource_uri)) { return $this->resource_uri; } // compose fully qualified resource uri for this instance $host = preg_replace('|^https?://|', 'dav://' . urlencode($this->get_owner(true)) . '@', $this->dav->url); $path = $this->href[0] == '/' ? $this->href : "/{$this->href}"; $host_path = parse_url($host, PHP_URL_PATH); if ($host_path && strpos($path, $host_path) === 0) { $path = substr($path, strlen($host_path)); } $this->resource_uri = unslashify($host) . $path; return $this->resource_uri; } /** * Getter for the Cyrus mailbox identifier corresponding to this folder * (e.g. user/john.doe/Calendar/Personal@example.org) * * @return string Mailbox ID */ public function get_mailbox_id() { // TODO: This is used with Bonnie related features return ''; } /** * Get the color value stored in metadata * * @param string Default color value to return if not set * * @return mixed Color value from the folder metadata or $default if not set */ public function get_color($default = null) { return !empty($this->attributes['color']) ? $this->attributes['color'] : $default; } /** * Get ACL information for this folder * * @return string Permissions as string */ public function get_myrights() { // TODO return ''; } /** * Helper method to extract folder UID * * @return string Folder's UID */ public function get_uid() { // TODO ??? return ''; } /** * Check activation status of this folder * * @return bool True if enabled, false if not */ public function is_active() { return true; // Unused } /** * Change activation status of this folder * * @param bool The desired subscription status: true = active, false = not active * * @return bool True on success, false on error */ public function activate($active) { return true; // Unused } /** * Check subscription status of this folder * * @return bool True if subscribed, false if not */ public function is_subscribed() { return true; // TODO } /** * Change subscription status of this folder * * @param bool The desired subscription status: true = subscribed, false = not subscribed * * @return True on success, false on error */ public function subscribe($subscribed) { return true; // TODO } /** * Delete the specified object from this folder. * * @param array|string $object The Kolab object to delete or object UID * @param bool $expunge Should the folder be expunged? * * @return bool True if successful, false on error */ public function delete($object, $expunge = true) { if (!$this->valid) { return false; } $uid = is_array($object) ? $object['uid'] : $object; $success = $this->dav->delete($this->object_location($uid)); if ($success) { $this->cache->set($uid, false); } return $success; } /** * Delete all objects in a folder. * * Note: This method is used by kolab_addressbook plugin only * * @return bool True if successful, false on error */ public function delete_all() { if (!$this->valid) { return false; } // TODO: Maybe just deleting and re-creating a folder would be // better, but probably might not always work (ACL) $this->cache->synchronize(); foreach (array_keys($this->cache->folder_index()) as $uid) { $this->dav->delete($this->object_location($uid)); } $this->cache->purge(); return true; } /** * Restore a previously deleted object * * @param string $uid Object UID * * @return mixed Message UID on success, false on error */ public function undelete($uid) { if (!$this->valid) { return false; } // TODO return false; } /** * Move a Kolab object message to another IMAP folder * * @param string Object UID * @param string IMAP folder to move object to * * @return bool True on success, false on failure */ public function move($uid, $target_folder) { if (!$this->valid) { return false; } // TODO return false; } /** * Save an object in this folder. * * @param array $object The array that holds the data of the object. * @param string $type The type of the kolab object. * @param string $uid The UID of the old object if it existed before * * @return mixed False on error or object UID on success */ public function save(&$object, $type = null, $uid = null) { if (!$this->valid || empty($object)) { return false; } if (!$type) { $type = $this->type; } /* // copy attachments from old message $copyfrom = $object['_copyfrom'] ?: $object['_msguid']; if (!empty($copyfrom) && ($old = $this->cache->get($copyfrom, $type, $object['_mailbox']))) { foreach ((array)$old['_attachments'] as $key => $att) { if (!isset($object['_attachments'][$key])) { $object['_attachments'][$key] = $old['_attachments'][$key]; } // unset deleted attachment entries if ($object['_attachments'][$key] == false) { unset($object['_attachments'][$key]); } // load photo.attachment from old Kolab2 format to be directly embedded in xcard block else if ($type == 'contact' && ($key == 'photo.attachment' || $key == 'kolab-picture.png') && $att['id']) { if (!isset($object['photo'])) $object['photo'] = $this->get_attachment($copyfrom, $att['id'], $object['_mailbox']); unset($object['_attachments'][$key]); } } } // process attachments if (is_array($object['_attachments'])) { $numatt = count($object['_attachments']); foreach ($object['_attachments'] as $key => $attachment) { // FIXME: kolab_storage and Roundcube attachment hooks use different fields! if (empty($attachment['content']) && !empty($attachment['data'])) { $attachment['content'] = $attachment['data']; unset($attachment['data'], $object['_attachments'][$key]['data']); } // make sure size is set, so object saved in cache contains this info if (!isset($attachment['size'])) { if (!empty($attachment['content'])) { if (is_resource($attachment['content'])) { // this need to be a seekable resource, otherwise // fstat() failes and we're unable to determine size // here nor in rcube_imap_generic before IMAP APPEND $stat = fstat($attachment['content']); $attachment['size'] = $stat ? $stat['size'] : 0; } else { $attachment['size'] = strlen($attachment['content']); } } else if (!empty($attachment['path'])) { $attachment['size'] = filesize($attachment['path']); } $object['_attachments'][$key] = $attachment; } // generate unique keys (used as content-id) for attachments if (is_numeric($key) && $key < $numatt) { // derrive content-id from attachment file name $ext = preg_match('/(\.[a-z0-9]{1,6})$/i', $attachment['name'], $m) ? $m[1] : null; $basename = preg_replace('/[^a-z0-9_.-]/i', '', basename($attachment['name'], $ext)); // to 7bit ascii if (!$basename) $basename = 'noname'; $cid = $basename . '.' . microtime(true) . $key . $ext; $object['_attachments'][$cid] = $attachment; unset($object['_attachments'][$key]); } } } */ $rcmail = rcube::get_instance(); $result = false; // generate and save object message if ($content = $this->to_dav($object)) { $method = $uid ? 'update' : 'create'; $dav_type = $this->get_dav_type(); $result = $this->dav->{$method}($this->object_location($object['uid']), $content, $dav_type); // Note: $result can be NULL if the request was successful, but ETag wasn't returned if ($result !== false) { // insert/update object in the cache $object['etag'] = $result; $this->cache->save($object, $uid); $result = true; } } return $result; } /** * Fetch the object the DAV server and convert to internal format * * @param string The object UID to fetch * @param string The object type expected (use wildcard '*' to accept all types) * @param string Unused (kept for compat. with the parent class) * * @return mixed Hash array representing the Kolab object, a kolab_format instance or false if not found */ public function read_object($uid, $type = null, $folder = null) { if (!$this->valid) { return false; } $href = $this->object_location($uid); $objects = $this->dav->getData($this->href, $this->get_dav_type(), [$href]); if (!is_array($objects) || count($objects) != 1) { rcube::raise_error([ 'code' => 900, 'message' => "Failed to fetch {$href}" ], true); return false; } return $this->from_dav($objects[0]); } /** * Convert DAV object into PHP array * * @param array Object data in kolab_dav_client::fetchData() format * * @return array Object properties */ public function from_dav($object) { if ($this->type == 'event') { $ical = libcalendaring::get_ical(); $events = $ical->import($object['data']); if (!count($events) || empty($events[0]['uid'])) { return false; } $result = $events[0]; } else if ($this->type == 'contact') { if (stripos($object['data'], 'BEGIN:VCARD') !== 0) { return false; } - $vcard = new rcube_vcard($object['data'], RCUBE_CHARSET, false); + // vCard properties not supported by rcube_vcard + $map = [ + 'uid' => 'UID', + 'kind' => 'KIND', + 'member' => 'MEMBER', + 'x-kind' => 'X-ADDRESSBOOKSERVER-KIND', + 'x-member' => 'X-ADDRESSBOOKSERVER-MEMBER', + ]; + + // TODO: We should probably use Sabre/Vobject to parse the vCard + + $vcard = new rcube_vcard($object['data'], RCUBE_CHARSET, false, $map); if (!empty($vcard->displayname) || !empty($vcard->surname) || !empty($vcard->firstname) || !empty($vcard->email)) { $result = $vcard->get_assoc(); + + // Contact groups + if (!empty($result['x-kind']) && implode($result['x-kind']) == 'group') { + $result['_type'] = 'group'; + $members = isset($result['x-member']) ? $result['x-member'] : []; + unset($result['x-kind'], $result['x-member']); + } + else if (!empty($result['kind']) && implode($result['kind']) == 'group') { + $result['_type'] = 'group'; + $members = isset($result['member']) ? $result['member'] : []; + unset($result['kind'], $result['member']); + } + + if (isset($members)) { + $result['member'] = []; + foreach ($members as $member) { + if (strpos($member, 'urn:uuid:') === 0) { + $result['member'][] = ['uid' => substr($member, 9)]; + } + else if (strpos($member, 'mailto:') === 0) { + $member = reset(rcube_mime::decode_address_list(urldecode(substr($member, 7)))); + if (!empty($member['mailto'])) { + $result['member'][] = ['email' => $member['mailto'], 'name' => $member['name']]; + } + } + } + } + + if (!empty($result['uid'])) { + $result['uid'] = preg_replace('/^urn:uuid:/', '', implode($result['uid'])); + } } else { return false; } } $result['etag'] = $object['etag']; $result['href'] = $object['href']; $result['uid'] = $object['uid'] ?: $result['uid']; return $result; } /** * Convert Kolab object into DAV format (iCalendar) */ public function to_dav($object) { $result = ''; if ($this->type == 'event') { $ical = libcalendaring::get_ical(); if (!empty($object['exceptions'])) { $object['recurrence']['EXCEPTIONS'] = $object['exceptions']; } $result = $ical->export([$object]); } else if ($this->type == 'contact') { // copy values into vcard object - $vcard = new rcube_vcard('', RCUBE_CHARSET, false, ['uid' => 'UID']); + // TODO: We should probably use Sabre/Vobject to create the vCard + + // vCard properties not supported by rcube_vcard + $map = ['uid' => 'UID', 'kind' => 'KIND']; + $vcard = new rcube_vcard('', RCUBE_CHARSET, false, $map); - $vcard->set('groups', null); + if ((!empty($object['_type']) && $object['_type'] == 'group') + || (!empty($object['type']) && $object['type'] == 'group') + ) { + $object['kind'] = 'group'; + } foreach ($object as $key => $values) { list($field, $section) = rcube_utils::explode(':', $key); // avoid casting DateTime objects to array if (is_object($values) && is_a($values, 'DateTime')) { $values = [$values]; } foreach ((array) $values as $value) { if (isset($value)) { $vcard->set($field, $value, $section); } } } $result = $vcard->export(false); + + if (!empty($object['kind']) && $object['kind'] == 'group') { + $members = ''; + foreach ((array) $object['member'] as $member) { + $value = null; + if (!empty($member['uid'])) { + $value = 'urn:uuid:' . $member['uid']; + } + else if (!empty($member['email']) && !empty($member['name'])) { + $value = 'mailto:' . urlencode(sprintf('"%s" <%s>', addcslashes($member['name'], '"'), $member['email'])); + } + else if (!empty($member['email'])) { + $value = 'mailto:' . $member['email']; + } + + if ($value) { + $members .= "MEMBER:{$value}\r\n"; + } + } + + if ($members) { + $result = preg_replace('/\r\nEND:VCARD/', "\r\n{$members}END:VCARD", $result); + } + + /** + Version 4.0 of the vCard format requires Cyrus >= 3.6.0, we'll use Version 3.0 for now + + $result = preg_replace('/\r\nVERSION:3\.0\r\n/', "\r\nVERSION:4.0\r\n", $result); + $result = preg_replace('/\r\nN:[^\r]+/', '', $result); + $result = preg_replace('/\r\nUID:([^\r]+)/', "\r\nUID:urn:uuid:\\1", $result); + */ + + $result = preg_replace('/\r\nMEMBER:([^\r]+)/', "\r\nX-ADDRESSBOOKSERVER-MEMBER:\\1", $result); + $result = preg_replace('/\r\nKIND:([^\r]+)/', "\r\nX-ADDRESSBOOKSERVER-KIND:\\1", $result); + } } if ($result) { // The content must be UTF-8, otherwise if we try to fetch the object // from server XML parsing would fail. $result = rcube_charset::clean($result); } return $result; } protected function object_location($uid) { return unslashify($this->href) . '/' . urlencode($uid) . '.' . $this->get_dav_ext(); } /** * Get a folder DAV content type */ public function get_dav_type() { return kolab_storage_dav::get_dav_type($this->type); } /** * Get a DAV file extension for specified Kolab type */ public function get_dav_ext() { $types = [ 'event' => 'ics', 'task' => 'ics', 'contact' => 'vcf', ]; return $types[$this->type]; } /** * Return folder name as string representation of this object * - * @return string Full IMAP folder name + * @return string Folder display name */ public function __toString() { return $this->attributes['name']; } }