diff --git a/plugins/kolab_addressbook/drivers/kolab/kolab_contacts.php b/plugins/kolab_addressbook/drivers/kolab/kolab_contacts.php index 1b02733c..d7538b8a 100644 --- a/plugins/kolab_addressbook/drivers/kolab/kolab_contacts.php +++ b/plugins/kolab_addressbook/drivers/kolab/kolab_contacts.php @@ -1,1400 +1,1402 @@ * @author Aleksander Machniak * * Copyright (C) 2011, Kolab Systems AG * * 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 . * * @see rcube_addressbook */ class kolab_contacts extends rcube_addressbook { public $primary_key = 'ID'; public $rights = 'lrs'; public $readonly = true; public $undelete = true; public $groups = true; public $coltypes = array( 'name' => array('limit' => 1), 'firstname' => array('limit' => 1), 'surname' => array('limit' => 1), 'middlename' => array('limit' => 1), 'prefix' => array('limit' => 1), 'suffix' => array('limit' => 1), 'nickname' => array('limit' => 1), 'jobtitle' => array('limit' => 1), 'organization' => array('limit' => 1), 'department' => array('limit' => 1), 'email' => array('subtypes' => array('home','work','other')), 'phone' => array(), 'address' => array('subtypes' => array('home','work','office')), 'website' => array('subtypes' => array('homepage','blog')), 'im' => array('subtypes' => null), 'gender' => array('limit' => 1), 'birthday' => array('limit' => 1), 'anniversary' => array('limit' => 1), 'profession' => array( 'type' => 'text', 'size' => 40, 'maxlength' => 80, 'limit' => 1, 'label' => 'kolab_addressbook.profession', 'category' => 'personal' ), 'manager' => array('limit' => null), 'assistant' => array('limit' => null), 'spouse' => array('limit' => 1), 'children' => array( 'type' => 'text', 'size' => 40, 'maxlength' => 80, 'limit' => null, 'label' => 'kolab_addressbook.children', 'category' => 'personal' ), 'freebusyurl' => array( 'type' => 'text', 'size' => 40, 'limit' => 1, 'label' => 'kolab_addressbook.freebusyurl' ), 'pgppublickey' => array( 'type' => 'textarea', 'size' => 70, 'rows' => 10, 'limit' => 1, 'label' => 'kolab_addressbook.pgppublickey' ), 'pkcs7publickey' => array( 'type' => 'textarea', 'size' => 70, 'rows' => 10, 'limit' => 1, 'label' => 'kolab_addressbook.pkcs7publickey' ), 'notes' => array('limit' => 1), 'photo' => array('limit' => 1), // TODO: define more Kolab-specific fields such as: language, latitude, longitude, crypto settings ); /** * vCard additional fields mapping */ public $vcard_map = array( 'profession' => 'X-PROFESSION', 'officelocation' => 'X-OFFICE-LOCATION', 'initials' => 'X-INITIALS', 'children' => 'X-CHILDREN', 'freebusyurl' => 'X-FREEBUSY-URL', 'pgppublickey' => 'KEY', ); /** * List of date type fields */ public $date_cols = array('birthday', 'anniversary'); private $gid; private $storagefolder; private $dataset; private $sortindex; private $contacts; private $distlists; private $groupmembers; private $filter; private $result; private $namespace; private $imap_folder = 'INBOX/Contacts'; private $action; // list of fields used for searching in "All fields" mode private $search_fields = array( 'name', 'firstname', 'surname', 'middlename', 'prefix', 'suffix', 'nickname', 'jobtitle', 'organization', 'department', 'email', 'phone', 'address', 'profession', 'manager', 'assistant', 'spouse', 'children', 'notes', ); public function __construct($imap_folder = null) { if ($imap_folder) { $this->imap_folder = $imap_folder; } // extend coltypes configuration $format = kolab_format::factory('contact'); $this->coltypes['phone']['subtypes'] = array_keys($format->phonetypes); $this->coltypes['address']['subtypes'] = array_keys($format->addresstypes); $rcube = rcube::get_instance(); // set localized labels for proprietary cols foreach ($this->coltypes as $col => $prop) { if (!empty($prop['label']) && is_string($prop['label'])) { $this->coltypes[$col]['label'] = $rcube->gettext($prop['label']); } } // fetch objects from the given IMAP folder $this->storagefolder = kolab_storage::get_folder($this->imap_folder); $this->ready = $this->storagefolder && !PEAR::isError($this->storagefolder); // Set readonly and rights flags according to folder permissions if ($this->ready) { if ($this->storagefolder->get_owner() == $_SESSION['username']) { $this->readonly = false; $this->rights = 'lrswikxtea'; } else { $rights = $this->storagefolder->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->storagefolder->get_name(); } /** * Wrapper for kolab_storage_folder::get_foldername() */ public function get_foldername() { return $this->storagefolder->get_foldername(); } /** * Getter for the IMAP folder name * * @return string Name of the IMAP folder */ public function get_realname() { return $this->imap_folder; } /** * 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->storagefolder->get_namespace(); } return $this->namespace; } /** * Getter for parent folder path * * @return string Full path to parent folder */ public function get_parent() { return $this->storagefolder->get_parent(); } /** * Check subscription status of this folder * * @return boolean True if subscribed, false if not */ public function is_subscribed() { return kolab_storage::folder_is_subscribed($this->imap_folder); } /** * 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, array( '%h' => $_SERVER['HTTP_HOST'], '%u' => urlencode($rcmail->get_user_name()), '%i' => urlencode($this->storagefolder->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 = array(); foreach ((array)$this->distlists as $group) { if (!$search || strstr(mb_strtolower($group['name']), mb_strtolower($search))) { $groups[$group['ID']] = array('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 = array(); $this->contacts = array(); $local_sortindex = array(); $uids = array(); // 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 = array(array('uid', '=', $uids)), $fetch_all ? false : count($uids), $fast_mode); $this->sortindex = array_merge($this->sortindex, $local_sortindex); } } - else if (is_array($this->filter['ids'])) { + else if ($this->filter && is_array($this->filter['ids'])) { $ids = $this->filter['ids']; if (count($ids)) { $uids = array_map(array($this, 'id2uid'), $this->filter['ids']); $this->_fetch_contacts($query = array(array('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->storagefolder->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=array()) { // 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 = array($fields); } if (!is_array($required) && !empty($required)) { $required = array($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[] = array('tags', '=', 'x-has-birthday'); } $squery[] = array('type', '=', 'contact'); // get all/matching records $this->_fetch_contacts($squery); // save searching conditions $this->filter = array('fields' => $fields, 'value' => $value, 'mode' => $mode, 'ids' => array()); // 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 = array(); $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->storagefolder->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->storagefolder->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 = array(); $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) { // remove existing id attributes (#1101) unset($save_data['ID'], $save_data['uid']); // generate new Kolab contact item $object = $this->_from_rcube_contact($save_data); $saved = $this->storagefolder->save($object, 'contact'); if (!$saved) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error saving contact object to Kolab server"), true, false); } else { $insert_id = $this->uid2id($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->storagefolder->get_object($this->id2uid($id))) { $object = $this->_from_rcube_contact($save_data, $old); if (!$this->storagefolder->save($object, 'contact', $old['uid'])) { rcube::raise_error(array( 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error saving contact object to Kolab 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->storagefolder->delete($uid, $force); if (!$deleted) { rcube::raise_error(array( 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error deleting a contact object $uid from the Kolab 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->storagefolder->undelete($uid)) { $count++; } else { rcube::raise_error(array( 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error undeleting a contact object $uid from the Kolab 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->storagefolder->delete_all()) { $this->contacts = array(); $this->sortindex = array(); $this->dataset = null; $this->result = null; } } /** * Close connection to source * Called on script shutdown */ public function close() { } /** * 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; $list = array( 'name' => $name, 'member' => array(), ); $saved = $this->storagefolder->save($list, 'distribution-list'); if (!$saved) { rcube::raise_error(array( 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error saving distribution-list object to Kolab server" ), true, false ); return false; } else { $id = $this->uid2id($list['uid']); $this->distlists[$id] = $list; $result = array('id' => $id, 'name' => $name); } return $result; } /** * 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->storagefolder->delete($list['uid']); } if (!$deleted) { rcube::raise_error(array( 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error deleting distribution-list object from the Kolab server" ), true, false ); } else { $result = true; } return $result; } /** * 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 */ function rename_group($gid, $newname, &$newid) { $this->_fetch_groups(); $list = $this->distlists[$gid]; if ($newname != $list['name']) { $list['name'] = $newname; $saved = $this->storagefolder->save($list, 'distribution-list', $list['uid']); } if (!$saved) { rcube::raise_error(array( 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error saving distribution-list object to Kolab 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 */ function add_to_group($gid, $ids) { if (!is_array($ids)) { $ids = explode(',', $ids); } $this->_fetch_groups(true); $list = $this->distlists[$gid]; $added = 0; $uids = array(); $exists = array(); 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'][] = array( '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'][] = array('uid' => $uid); $this->groupmembers[$contact_id][] = $gid; $added++; } } if ($added) { $saved = $this->storagefolder->save($list, 'distribution-list', $list['uid']); } else { $saved = true; } if (!$saved) { rcube::raise_error(array( 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error saving distribution-list to Kolab 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 int Number of deleted group members */ function remove_from_group($gid, $ids) { if (!is_array($ids)) { $ids = explode(',', $ids); } $this->_fetch_groups(); if (!($list = $this->distlists[$gid])) { return false; } $new_member = array(); 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->storagefolder->save($list, 'distribution-list', $list['uid']); if (!$saved) { rcube::raise_error(array( 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error saving distribution-list object to Kolab 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 = array(), $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->storagefolder->set_order_and_limit($this->_sort_columns(), $size, ($this->list_page-1) * $this->page_size); } $this->sortindex = array(); $this->dataset = $this->storagefolder->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']; + $str = ($rec['name'] ?? null) . ($rec['prefix'] ?? null); case 'firstname': - $str .= $rec['firstname'] . $rec['middlename'] . $rec['surname']; + $str .= ($rec['firstname'] ?? null) . ($rec['middlename'] ?? null) . ($rec['surname'] ?? null); break; case 'surname': - $str = $rec['surname'] . $rec['firstname'] . $rec['middlename']; + $str = ($rec['surname'] ?? null) . ($rec['firstname'] ?? null) . ($rec['middlename'] ?? null); break; default: $str = $rec[$this->sort_col]; break; } - $str .= is_array($rec['email']) ? $rec['email'][0] : $rec['email']; + if ($rec['email'] ?? null) { + $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 = array(); 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 */ private function _fetch_groups($with_contacts = false) { if (!isset($this->distlists)) { $this->distlists = $this->groupmembers = array(); foreach ($this->storagefolder->select('distribution-list', 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; } } } /** * 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 = array(); $cols = array(); // $fulltext_cols might contain composite field names e.g. 'email:address' while $fields not foreach (kolab_format_contact::$fulltext_cols as $col) { if ($pos = strpos($col, ':')) { $col = substr($col, 0, $pos); } if (in_array($col, $fields)) { $cols[] = $col; } } if (count($cols) == count($fields)) { 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[] = array('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']); // convert email, website, phone values foreach (array('email'=>'address', 'website'=>'url', 'phone'=>'number') as $col => $propname) { - if (is_array($record[$col])) { + if (is_array($record[$col] ?? null)) { $values = $record[$col]; unset($record[$col]); foreach ((array)$values as $i => $val) { $key = $col . ($val['type'] ? ':' . $val['type'] : ''); $record[$key][] = $val[$propname]; } } } - if (is_array($record['address'])) { + if (is_array($record['address'] ?? null)) { $addresses = $record['address']; unset($record['address']); foreach ($addresses as $i => $adr) { $key = 'address' . ($adr['type'] ? ':' . $adr['type'] : ''); $record[$key][] = array( 'street' => $adr['street'], 'locality' => $adr['locality'], 'zipcode' => $adr['code'], 'region' => $adr['region'], 'country' => $adr['country'], ); } } // photo is stored as separate attachment - if ($record['photo'] && strlen($record['photo']) < 255 && !empty($record['_attachments'][$record['photo']])) { + if (($record['photo'] ?? null) && strlen($record['photo']) < 255 && !empty($record['_attachments'][$record['photo']])) { $att = $record['_attachments'][$record['photo']]; // only fetch photo content if requested if ($this->action == 'photo') { if (!empty($att['content'])) { $record['photo'] = $att['content']; } else { $record['photo'] = $this->storagefolder->get_attachment($record['uid'], $att['id']); } } } // truncate publickey value for display if (!empty($record['pgppublickey']) && $this->action == 'show') { $record['pgppublickey'] = substr($record['pgppublickey'], 0, 140) . '...'; } // remove empty fields $record = array_filter($record); // remove kolab_storage internal data unset($record['_msguid'], $record['_formatobj'], $record['_mailbox'], $record['_type'], $record['_size']); return $record; } /** * Map fields from Roundcube format to internal kolab_format_contact properties */ private function _from_rcube_contact($contact, $old = array()) { 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']; } $contact['im'] = array_filter($this->get_col_values('im', $contact, true)); // convert email, website, phone values foreach (array('email'=>'address', 'website'=>'url', 'phone'=>'number') as $col => $propname) { $col_values = $this->get_col_values($col, $contact); $contact[$col] = array(); foreach ($col_values as $type => $values) { foreach ((array)$values as $val) { if (!empty($val)) { $contact[$col][] = array($propname => $val, 'type' => $type); } } unset($contact[$col.':'.$type]); } } $addresses = array(); foreach ($this->get_col_values('address', $contact) as $type => $values) { foreach ((array)$values as $adr) { // skip empty address $adr = array_filter($adr); if (empty($adr)) { continue; } $addresses[] = array( 'type' => $type, 'street' => $adr['street'], 'locality' => $adr['locality'], 'code' => $adr['zipcode'], 'region' => $adr['region'], 'country' => $adr['country'], ); } unset($contact['address:'.$type]); } $contact['address'] = $addresses; // categories are not supported in the web client but should be preserved (#2608) $contact['categories'] = $old['categories'] ?? null; // copy meta data (starting with _) from old object foreach ((array)$old as $key => $val) { if (!isset($contact[$key]) && $key[0] == '_') { $contact[$key] = $val; } } // convert one-item-array elements into string element // this is needed e.g. to properly import birthday field foreach ($this->coltypes as $type => $col_def) { if (($col_def['limit'] ?? 0) == 1 && !empty($contact[$type]) && is_array($contact[$type])) { $contact[$type] = array_shift(array_filter($contact[$type])); } } // When importing contacts 'vcard' data is added, we don't need it (Bug #1711) unset($contact['vcard']); // add empty values for some fields which can be removed in the UI return array_filter($contact) + array( 'nickname' => '', 'birthday' => '', 'anniversary' => '', 'freebusyurl' => '', 'photo' => $contact['photo'] ); } } diff --git a/plugins/kolab_addressbook/kolab_addressbook.php b/plugins/kolab_addressbook/kolab_addressbook.php index 6e34b973..d5d7bac6 100644 --- a/plugins/kolab_addressbook/kolab_addressbook.php +++ b/plugins/kolab_addressbook/kolab_addressbook.php @@ -1,1142 +1,1142 @@ * @author Aleksander Machniak * * Copyright (C) 2011-2015, Kolab Systems AG * * 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 . */ class kolab_addressbook extends rcube_plugin { public $task = '?(?!logout).*'; public $driver; public $bonnie_api = false; private $sources; private $rc; private $ui; private $recurrent = false; const GLOBAL_FIRST = 0; const PERSONAL_FIRST = 1; const GLOBAL_ONLY = 2; const PERSONAL_ONLY = 3; /** * Startup method of a Roundcube plugin */ public function init() { $this->rc = rcube::get_instance(); // load required plugin $this->require_plugin('libkolab'); $this->load_config(); $driver = $this->rc->config->get('kolab_addressbook_driver') ?: 'kolab'; $driver_class = "{$driver}_contacts_driver"; require_once dirname(__FILE__) . "/drivers/{$driver}/{$driver}_contacts_driver.php"; require_once dirname(__FILE__) . "/drivers/{$driver}/{$driver}_contacts.php"; $this->driver = new $driver_class($this); // register hooks $this->add_hook('addressbooks_list', array($this, 'address_sources')); $this->add_hook('addressbook_get', array($this, 'get_address_book')); $this->add_hook('config_get', array($this, 'config_get')); if ($this->rc->task == 'addressbook') { $this->add_texts('localization'); if ($this->driver instanceof kolab_contacts_driver) { $this->add_hook('contact_form', array($this, 'contact_form')); $this->add_hook('contact_photo', array($this, 'contact_photo')); } $this->add_hook('template_object_directorylist', array($this, 'directorylist_html')); // Plugin actions $this->register_action('plugin.book', array($this, 'book_actions')); $this->register_action('plugin.book-save', array($this, 'book_save')); $this->register_action('plugin.book-search', array($this, 'book_search')); $this->register_action('plugin.book-subscribe', array($this, 'book_subscribe')); $this->register_action('plugin.contact-changelog', array($this, 'contact_changelog')); $this->register_action('plugin.contact-diff', array($this, 'contact_diff')); $this->register_action('plugin.contact-restore', array($this, 'contact_restore')); // get configuration for the Bonnie API $this->bonnie_api = libkolab::get_bonnie_api(); // Load UI elements if ($this->api->output->type == 'html') { require_once $this->home . '/lib/kolab_addressbook_ui.php'; $this->ui = new kolab_addressbook_ui($this); if ($this->bonnie_api) { $this->add_button(array( 'command' => 'contact-history-dialog', 'class' => 'history contact-history disabled', 'classact' => 'history contact-history active', 'innerclass' => 'icon inner', 'label' => 'kolab_addressbook.showhistory', 'type' => 'link-menuitem' ), 'contactmenu'); } } } else if ($this->rc->task == 'settings') { $this->add_texts('localization'); $this->add_hook('preferences_list', array($this, 'prefs_list')); $this->add_hook('preferences_save', array($this, 'prefs_save')); } if ($this->driver instanceof kolab_contacts_driver) { $this->add_hook('folder_delete', array($this, 'prefs_folder_delete')); $this->add_hook('folder_rename', array($this, 'prefs_folder_rename')); $this->add_hook('folder_update', array($this, 'prefs_folder_update')); } } /** * Handler for the addressbooks_list hook. * * This will add all instances of available Kolab-based address books * to the list of address sources of Roundcube. * This will also hide some addressbooks according to kolab_addressbook_prio setting. * * @param array $p Hash array with hook parameters * * @return array Hash array with modified hook parameters */ public function address_sources($p) { $abook_prio = $this->addressbook_prio(); // Disable all global address books // Assumes that all non-kolab_addressbook sources are global if ($abook_prio == self::PERSONAL_ONLY) { $p['sources'] = array(); } $sources = array(); foreach ($this->_list_sources() as $abook_id => $abook) { // register this address source $sources[$abook_id] = $this->driver->abook_prop($abook_id, $abook); // flag folders with 'i' right as writeable if ($this->rc->action == 'add' && strpos($abook->rights, 'i') !== false) { $sources[$abook_id]['readonly'] = false; } } // Add personal address sources to the list if ($abook_prio == self::PERSONAL_FIRST) { // $p['sources'] = array_merge($sources, $p['sources']); // Don't use array_merge(), because if you have folders name // that resolve to numeric identifier it will break output array keys foreach ($p['sources'] as $idx => $value) { $sources[$idx] = $value; } $p['sources'] = $sources; } else { // $p['sources'] = array_merge($p['sources'], $sources); foreach ($sources as $idx => $value) { $p['sources'][$idx] = $value; } } return $p; } /** * */ public function directorylist_html($args) { $out = ''; $spec = ''; $kolab = ''; $jsdata = []; $sources = (array) $this->rc->get_address_sources(); // list all non-kolab sources first (also exclude hidden sources), special folders will go last foreach ($sources as $j => $source) { $id = strval(strlen($source['id']) ? $source['id'] : $j); if (!empty($source['kolab']) || !empty($source['hidden'])) { continue; } // Roundcube >= 1.5, Collected Recipients and Trusted Senders sources will be listed at the end if ((defined('rcube_addressbook::TYPE_RECIPIENT') && $source['id'] == (string) rcube_addressbook::TYPE_RECIPIENT) || (defined('rcube_addressbook::TYPE_TRUSTED_SENDER') && $source['id'] == (string) rcube_addressbook::TYPE_TRUSTED_SENDER) ) { $spec .= $this->addressbook_list_item($id, $source, $jsdata) . ''; } else { $out .= $this->addressbook_list_item($id, $source, $jsdata) . ''; } } // render a hierarchical list of kolab contact folders // TODO: Move this to the drivers if ($this->driver instanceof kolab_contacts_driver) { $folders = kolab_storage::sort_folders(kolab_storage::get_folders('contact')); kolab_storage::folder_hierarchy($folders, $tree); if ($tree && !empty($tree->children)) { $kolab .= $this->folder_tree_html($tree, $sources, $jsdata); } } else { $filter = function($source) { return !empty($source['kolab']) && empty($source['hidden']); }; foreach (array_filter($sources, $filter) as $j => $source) { $id = strval(strlen($source['id']) ? $source['id'] : $j); $kolab .= $this->addressbook_list_item($id, $source, $jsdata) . ''; } } $out .= $kolab . $spec; $this->rc->output->set_env('contactgroups', array_filter($jsdata, function($src){ return isset($src['type']) && $src['type'] == 'group'; })); $this->rc->output->set_env('address_sources', array_filter($jsdata, function($src){ return !isset($src['type']) || $src['type'] != 'group'; })); $args['content'] = html::tag('ul', $args, $out, html::$common_attrib); return $args; } /** * Return html for a structured list
    for the folder tree */ protected function folder_tree_html($node, $data, &$jsdata) { $out = ''; foreach ($node->children as $folder) { $id = $folder->id; $source = $data[$id]; $is_collapsed = strpos($this->rc->config->get('collapsed_abooks',''), '&'.rawurlencode($id).'&') !== false; - if ($folder->virtual) { + if (!empty($folder->virtual)) { $source = $this->driver->abook_prop($folder->id, $folder); } else if (empty($source)) { $this->sources[$id] = new kolab_contacts($folder->name); $source = $this->driver->abook_prop($id, $this->sources[$id]); } $content = $this->addressbook_list_item($id, $source, $jsdata); if (!empty($folder->children)) { $child_html = $this->folder_tree_html($folder, $data, $jsdata); // copy group items... if (preg_match('!]*>(.*)
\n*$!Ums', $content, $m)) { $child_html = $m[1] . $child_html; $content = substr($content, 0, -strlen($m[0]) - 1); } // ... and re-create the subtree if (!empty($child_html)) { $content .= html::tag('ul', array('class' => 'groups', 'style' => ($is_collapsed ? "display:none;" : null)), $child_html); } } $out .= $content . ''; } return $out; } /** * */ protected function addressbook_list_item($id, $source, &$jsdata, $search_mode = false) { $current = rcube_utils::get_input_value('_source', rcube_utils::INPUT_GPC); if (empty($source['virtual'])) { $jsdata[$id] = $source; $jsdata[$id]['name'] = html_entity_decode($source['name'], ENT_NOQUOTES, RCUBE_CHARSET); } // set class name(s) $classes = array('addressbook'); if (!empty($source['group'])) $classes[] = $source['group']; if ($current === $id) $classes[] = 'selected'; if (!empty($source['readonly'])) $classes[] = 'readonly'; if (!empty($source['virtual'])) $classes[] = 'virtual'; if (!empty($source['class_name'])) $classes[] = $source['class_name']; $name = !empty($source['listname']) ? $source['listname'] : (!empty($source['name']) ? $source['name'] : $id); $label_id = 'kabt:' . $id; $inner = (!empty($source['virtual']) ? html::a(array('tabindex' => '0'), $name) : html::a(array( 'href' => $this->rc->url(array('_source' => $id)), 'rel' => $source['id'], 'id' => $label_id, 'onclick' => "return " . rcmail_output::JS_OBJECT_NAME.".command('list','" . rcube::JQ($id) . "',this)", ), $name) ); if ($this->driver instanceof kolab_contacts_driver && isset($source['subscribed'])) { $inner .= html::span(array( 'class' => 'subscribed', 'title' => $this->gettext('foldersubscribe'), 'role' => 'checkbox', 'aria-checked' => $source['subscribed'] ? 'true' : 'false', ), ''); } // don't wrap in
  • but add a checkbox for search results listing if ($search_mode) { $jsdata[$id]['group'] = join(' ', $classes); if (!$source['virtual']) { $inner .= html::tag('input', array( 'type' => 'checkbox', 'name' => '_source[]', 'value' => $id, 'checked' => false, 'aria-labelledby' => $label_id, )); } return html::div(null, $inner); } $out = html::tag('li', array( 'id' => 'rcmli' . rcube_utils::html_identifier($id, true), 'class' => join(' ', $classes), 'noclose' => true, ), html::div(!empty($source['subscribed']) ? 'subscribed' : null, $inner) ); $groupdata = array('out' => '', 'jsdata' => $jsdata, 'source' => $id); if ($source['groups']) { if (function_exists('rcmail_contact_groups')) { $groupdata = rcmail_contact_groups($groupdata); } else { // Roundcube >= 1.5 $groupdata = rcmail_action_contacts_index::contact_groups($groupdata); } } $jsdata = $groupdata['jsdata']; $out .= $groupdata['out']; return $out; } /** * Sets autocomplete_addressbooks option according to * kolab_addressbook_prio setting extending list of address sources * to be used for autocompletion. */ public function config_get($args) { if ($args['name'] != 'autocomplete_addressbooks' || $this->recurrent) { return $args; } $abook_prio = $this->addressbook_prio(); // Get the original setting, use temp flag to prevent from an infinite recursion $this->recurrent = true; $sources = $this->rc->config->get('autocomplete_addressbooks'); $this->recurrent = false; // Disable all global address books // Assumes that all non-kolab_addressbook sources are global if ($abook_prio == self::PERSONAL_ONLY) { $sources = array(); } if (!is_array($sources)) { $sources = array(); } $kolab_sources = array(); foreach (array_keys($this->_list_sources()) as $abook_id) { if (!in_array($abook_id, $sources)) $kolab_sources[] = $abook_id; } // Add personal address sources to the list if (!empty($kolab_sources)) { if ($abook_prio == self::PERSONAL_FIRST) { $sources = array_merge($kolab_sources, $sources); } else { $sources = array_merge($sources, $kolab_sources); } } $args['result'] = $sources; return $args; } /** * Getter for the rcube_addressbook instance * * @param array $p Hash array with hook parameters * * @return array Hash array with modified hook parameters */ public function get_address_book($p) { if ($p['id']) { if ($source = $this->driver->get_address_book($p['id'])) { $p['instance'] = $source; // flag source as writeable if 'i' right is given if ($p['writeable'] && $this->rc->action == 'save' && strpos($p['instance']->rights, 'i') !== false) { $p['instance']->readonly = false; } else if ($this->rc->action == 'delete' && strpos($p['instance']->rights, 't') !== false) { $p['instance']->readonly = false; } } } return $p; } /** * List addressbook sources list */ private function _list_sources() { // already read sources if (isset($this->sources)) { return $this->sources; } $this->sources = []; $abook_prio = $this->addressbook_prio(); // Personal address source(s) disabled? if ($abook_prio == kolab_addressbook::GLOBAL_ONLY) { return $this->sources; } $folders = $this->driver->list_folders(); // get all folders that have "contact" type foreach ($folders as $id => $source) { $this->sources[$id] = $source; } return $this->sources; } /** * Plugin hook called before rendering the contact form or detail view * * @param array $p Hash array with hook parameters * * @return array Hash array with modified hook parameters */ public function contact_form($p) { // none of our business if (empty($GLOBALS['CONTACTS']) || !($GLOBALS['CONTACTS'] instanceof kolab_contacts)) { return $p; } // extend the list of contact fields to be displayed in the 'personal' section if (is_array($p['form']['personal'])) { $p['form']['personal']['content']['profession'] = array('size' => 40); $p['form']['personal']['content']['children'] = array('size' => 40); $p['form']['personal']['content']['freebusyurl'] = array('size' => 40); $p['form']['personal']['content']['pgppublickey'] = array('size' => 70); $p['form']['personal']['content']['pkcs7publickey'] = array('size' => 70); // re-order fields according to the coltypes list $p['form']['contact']['content'] = $this->_sort_form_fields($p['form']['contact']['content'], $GLOBALS['CONTACTS']); $p['form']['personal']['content'] = $this->_sort_form_fields($p['form']['personal']['content'], $GLOBALS['CONTACTS']); /* define a separate section 'settings' $p['form']['settings'] = array( 'name' => $this->gettext('settings'), 'content' => array( 'freebusyurl' => array('size' => 40, 'visible' => true), 'pgppublickey' => array('size' => 70, 'visible' => true), 'pkcs7publickey' => array('size' => 70, 'visible' => false), ) ); */ } if ($this->bonnie_api && $this->rc->action == 'show' && empty($p['record']['rev'])) { $this->rc->output->set_env('kolab_audit_trail', true); } return $p; } /** * Plugin hook for the contact photo image */ public function contact_photo($p) { // add photo data from old revision inline as data url if (!empty($p['record']['rev']) && !empty($p['data'])) { $p['url'] = 'data:image/gif;base64,' . base64_encode($p['data']); } return $p; } /** * Handler for contact audit trail changelog requests */ public function contact_changelog() { if (empty($this->bonnie_api)) { return false; } $contact = rcube_utils::get_input_value('cid', rcube_utils::INPUT_POST, true); $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_POST); list($uid, $mailbox, $msguid) = $this->_resolve_contact_identity($contact, $source); $result = $uid && $mailbox ? $this->bonnie_api->changelog('contact', $uid, $mailbox, $msguid) : null; if (is_array($result) && $result['uid'] == $uid) { if (is_array($result['changes'])) { $rcmail = $this->rc; $dtformat = $this->rc->config->get('date_format') . ' ' . $this->rc->config->get('time_format'); array_walk($result['changes'], function(&$change) use ($rcmail, $dtformat) { if ($change['date']) { $dt = rcube_utils::anytodatetime($change['date']); if ($dt instanceof DateTime) { $change['date'] = $rcmail->format_date($dt, $dtformat); } } }); } $this->rc->output->command('contact_render_changelog', $result['changes']); } else { $this->rc->output->command('contact_render_changelog', false); } $this->rc->output->send(); } /** * Handler for audit trail diff view requests */ public function contact_diff() { if (empty($this->bonnie_api)) { return false; } $contact = rcube_utils::get_input_value('cid', rcube_utils::INPUT_POST, true); $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_POST); $rev1 = rcube_utils::get_input_value('rev1', rcube_utils::INPUT_POST); $rev2 = rcube_utils::get_input_value('rev2', rcube_utils::INPUT_POST); list($uid, $mailbox, $msguid) = $this->_resolve_contact_identity($contact, $source); $result = $this->bonnie_api->diff('contact', $uid, $rev1, $rev2, $mailbox, $msguid); if (is_array($result) && $result['uid'] == $uid) { $result['rev1'] = $rev1; $result['rev2'] = $rev2; $result['cid'] = $contact; // convert some properties, similar to kolab_contacts::_to_rcube_contact() $keymap = array( 'lastmodified-date' => 'changed', 'additional' => 'middlename', 'fn' => 'name', 'tel' => 'phone', 'url' => 'website', 'bday' => 'birthday', 'note' => 'notes', 'role' => 'profession', 'title' => 'jobtitle', ); $propmap = array('email' => 'address', 'website' => 'url', 'phone' => 'number'); $date_format = $this->rc->config->get('date_format', 'Y-m-d'); // map kolab object properties to keys and values the client expects array_walk($result['changes'], function(&$change, $i) use ($keymap, $propmap, $date_format) { if (array_key_exists($change['property'], $keymap)) { $change['property'] = $keymap[$change['property']]; } // format date-time values if ($change['property'] == 'created' || $change['property'] == 'changed') { if ($old_ = rcube_utils::anytodatetime($change['old'])) { $change['old_'] = $this->rc->format_date($old_); } if ($new_ = rcube_utils::anytodatetime($change['new'])) { $change['new_'] = $this->rc->format_date($new_); } } // format dates else if ($change['property'] == 'birthday' || $change['property'] == 'anniversary') { if ($old_ = rcube_utils::anytodatetime($change['old'])) { $change['old_'] = $this->rc->format_date($old_, $date_format); } if ($new_ = rcube_utils::anytodatetime($change['new'])) { $change['new_'] = $this->rc->format_date($new_, $date_format); } } // convert email, website, phone values else if (array_key_exists($change['property'], $propmap)) { $propname = $propmap[$change['property']]; foreach (array('old','new') as $k) { $k_ = $k . '_'; if (!empty($change[$k])) { $change[$k_] = html::quote($change[$k][$propname] ?: '--'); if ($change[$k]['type']) { $change[$k_] .= ' ' . html::span('subtype', $this->get_type_label($change[$k]['type'])); } $change['ishtml'] = true; } } } // serialize address structs if ($change['property'] == 'address') { foreach (array('old','new') as $k) { $k_ = $k . '_'; $change[$k]['zipcode'] = $change[$k]['code']; $template = $this->rc->config->get('address_template', '{'.join('} {', array_keys($change[$k])).'}'); $composite = array(); foreach ($change[$k] as $p => $val) { if (strlen($val)) $composite['{'.$p.'}'] = $val; } $change[$k_] = preg_replace('/\{\w+\}/', '', strtr($template, $composite)); if ($change[$k]['type']) { $change[$k_] .= html::div('subtype', $this->get_type_label($change[$k]['type'])); } $change['ishtml'] = true; } $change['diff_'] = libkolab::html_diff($change['old_'], $change['new_'], true); } // localize gender values else if ($change['property'] == 'gender') { if ($change['old']) $change['old_'] = $this->rc->gettext($change['old']); if ($change['new']) $change['new_'] = $this->rc->gettext($change['new']); } // translate 'key' entries in individual properties else if ($change['property'] == 'key') { $p = $change['old'] ?: $change['new']; $t = $p['type']; $change['property'] = $t . 'publickey'; $change['old'] = $change['old'] ? $change['old']['key'] : ''; $change['new'] = $change['new'] ? $change['new']['key'] : ''; } // compute a nice diff of notes else if ($change['property'] == 'notes') { $change['diff_'] = libkolab::html_diff($change['old'], $change['new'], false); } }); $this->rc->output->command('contact_show_diff', $result); } else { $this->rc->output->command('display_message', $this->gettext('objectdiffnotavailable'), 'error'); } $this->rc->output->send(); } /** * Handler for audit trail revision restore requests */ public function contact_restore() { if (empty($this->bonnie_api)) { return false; } $success = false; $contact = rcube_utils::get_input_value('cid', rcube_utils::INPUT_POST, true); $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_POST); $rev = rcube_utils::get_input_value('rev', rcube_utils::INPUT_POST); list($uid, $mailbox, $msguid) = $this->_resolve_contact_identity($contact, $source, $folder); if ($folder && ($raw_msg = $this->bonnie_api->rawdata('contact', $uid, $rev, $mailbox))) { $imap = $this->rc->get_storage(); // insert $raw_msg as new message if ($imap->save_message($folder->name, $raw_msg, null, false)) { $success = true; // delete old revision from imap and cache $imap->delete_message($msguid, $folder->name); $folder->cache->set($msguid, false); $this->cache = array(); } } if ($success) { $this->rc->output->command('display_message', $this->gettext(array('name' => 'objectrestoresuccess', 'vars' => array('rev' => $rev))), 'confirmation'); $this->rc->output->command('close_contact_history_dialog', $contact); } else { $this->rc->output->command('display_message', $this->gettext('objectrestoreerror'), 'error'); } $this->rc->output->send(); } /** * Get a previous revision of the given contact record from the Bonnie API */ public function get_revision($cid, $source, $rev) { if (empty($this->bonnie_api)) { return false; } list($uid, $mailbox, $msguid) = $this->_resolve_contact_identity($cid, $source); // call Bonnie API $result = $this->bonnie_api->get('contact', $uid, $rev, $mailbox, $msguid); if (is_array($result) && $result['uid'] == $uid && !empty($result['xml'])) { $format = kolab_format::factory('contact'); $format->load($result['xml']); $rec = $format->to_array(); if ($format->is_valid()) { $rec['rev'] = $result['rev']; return $rec; } } return false; } /** * Helper method to resolved the given contact identifier into uid and mailbox * * @return array (uid,mailbox,msguid) tuple */ private function _resolve_contact_identity($id, $abook, &$folder = null) { $mailbox = $msguid = null; $source = $this->get_address_book(array('id' => $abook)); if ($source['instance']) { $uid = $source['instance']->id2uid($id); $list = kolab_storage::id_decode($abook); } else { return array(null, $mailbox, $msguid); } // get resolve message UID and mailbox identifier if ($folder = kolab_storage::get_folder($list)) { $mailbox = $folder->get_mailbox_id(); $msguid = $folder->cache->uid2msguid($uid); } return array($uid, $mailbox, $msguid); } /** * */ private function _sort_form_fields($contents, $source) { $block = []; foreach (array_keys($source->coltypes) as $col) { if (isset($contents[$col])) { $block[$col] = $contents[$col]; } } return $block; } /** * Handler for user preferences form (preferences_list hook) * * @param array $args Hash array with hook parameters * * @return array Hash array with modified hook parameters */ public function prefs_list($args) { if ($args['section'] != 'addressbook') { return $args; } $ldap_public = $this->rc->config->get('ldap_public'); // Hide option if there's no global addressbook if (empty($ldap_public)) { return $args; } // Check that configuration is not disabled $dont_override = (array) $this->rc->config->get('dont_override', array()); $prio = $this->addressbook_prio(); if (!in_array('kolab_addressbook_prio', $dont_override)) { // Load localization $this->add_texts('localization'); $field_id = '_kolab_addressbook_prio'; $select = new html_select(array('name' => $field_id, 'id' => $field_id)); $select->add($this->gettext('globalfirst'), self::GLOBAL_FIRST); $select->add($this->gettext('personalfirst'), self::PERSONAL_FIRST); $select->add($this->gettext('globalonly'), self::GLOBAL_ONLY); $select->add($this->gettext('personalonly'), self::PERSONAL_ONLY); $args['blocks']['main']['options']['kolab_addressbook_prio'] = array( 'title' => html::label($field_id, rcube::Q($this->gettext('addressbookprio'))), 'content' => $select->show($prio), ); } return $args; } /** * Handler for user preferences save (preferences_save hook) * * @param array $args Hash array with hook parameters * * @return array Hash array with modified hook parameters */ public function prefs_save($args) { if ($args['section'] != 'addressbook') { return $args; } // Check that configuration is not disabled $dont_override = (array) $this->rc->config->get('dont_override', array()); $key = 'kolab_addressbook_prio'; if (!in_array('kolab_addressbook_prio', $dont_override) || !isset($_POST['_'.$key])) { $args['prefs'][$key] = (int) rcube_utils::get_input_value('_'.$key, rcube_utils::INPUT_POST); } return $args; } /** * Handler for plugin actions */ public function book_actions() { $action = trim(rcube_utils::get_input_value('_act', rcube_utils::INPUT_GPC)); if ($action == 'create') { $this->ui->book_edit(); } else if ($action == 'edit') { $this->ui->book_edit(); } else if ($action == 'delete') { $this->book_delete(); } } /** * Handler for address book create/edit form submit */ public function book_save() { $this->driver->folder_save(); $this->rc->output->send('iframe'); } /** * */ public function book_search() { $results = []; $query = rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC); $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC); kolab_storage::$encode_ids = true; $search_more_results = false; $this->sources = array(); $this->folders = array(); // find unsubscribed IMAP folders that have "event" type if ($source == 'folders') { foreach ((array)kolab_storage::search_folders('contact', $query, array('other')) as $folder) { $this->folders[$folder->id] = $folder; $this->sources[$folder->id] = new kolab_contacts($folder->name); } } // search other user's namespace via LDAP else if ($source == 'users') { $limit = $this->rc->config->get('autocomplete_max', 15) * 2; // we have slightly more space, so display twice the number foreach (kolab_storage::search_users($query, 0, array(), $limit * 10) as $user) { $folders = array(); // search for contact folders shared by this user foreach (kolab_storage::list_user_folders($user, 'contact', false) as $foldername) { $folders[] = new kolab_storage_folder($foldername, 'contact'); } if (count($folders)) { $userfolder = new kolab_storage_folder_user($user['kolabtargetfolder'], '', $user); $this->folders[$userfolder->id] = $userfolder; $this->sources[$userfolder->id] = $userfolder; foreach ($folders as $folder) { $this->folders[$folder->id] = $folder; $this->sources[$folder->id] = new kolab_contacts($folder->name);; $count++; } } if ($count >= $limit) { $search_more_results = true; break; } } } $delim = $this->rc->get_storage()->get_hierarchy_delimiter(); // build results list foreach ($this->sources as $id => $source) { $folder = $this->folders[$id]; $imap_path = explode($delim, $folder->name); // find parent do { array_pop($imap_path); $parent_id = kolab_storage::folder_id(join($delim, $imap_path)); } while (count($imap_path) > 1 && !$this->folders[$parent_id]); // restore "real" parent ID if ($parent_id && !$this->folders[$parent_id]) { $parent_id = kolab_storage::folder_id($folder->get_parent()); } $prop = $this->driver->abook_prop($id, $source); $prop['parent'] = $parent_id; $html = $this->addressbook_list_item($id, $prop, $jsdata, true); unset($prop['group']); $prop += (array)$jsdata[$id]; $prop['html'] = $html; $results[] = $prop; } // report more results available if ($search_more_results) { $this->rc->output->show_message('autocompletemore', 'notice'); } $this->rc->output->command('multi_thread_http_response', $results, rcube_utils::get_input_value('_reqid', rcube_utils::INPUT_GPC)); } /** * */ public function book_subscribe() { $success = false; $id = rcube_utils::get_input_value('_source', rcube_utils::INPUT_GPC); if ($id && ($folder = kolab_storage::get_folder(kolab_storage::id_decode($id)))) { if (isset($_POST['_permanent'])) $success |= $folder->subscribe(intval($_POST['_permanent'])); if (isset($_POST['_active'])) $success |= $folder->activate(intval($_POST['_active'])); // list groups for this address book if (!empty($_POST['_groups'])) { $abook = new kolab_contacts($folder->name); foreach ((array)$abook->list_groups() as $prop) { $prop['source'] = $id; $prop['id'] = $prop['ID']; unset($prop['ID']); $this->rc->output->command('insert_contact_group', $prop); } } } if ($success) { $this->rc->output->show_message('successfullysaved', 'confirmation'); } else { $this->rc->output->show_message($this->gettext('errorsaving'), 'error'); } $this->rc->output->send(); } /** * Handler for address book delete action (AJAX) */ private function book_delete() { $source = trim(rcube_utils::get_input_value('_source', rcube_utils::INPUT_GPC, true)); if ($source && ($result = $this->driver->folder_delete($source))) { $storage = $this->rc->get_storage(); $delimiter = $storage->get_hierarchy_delimiter(); $this->rc->output->show_message('kolab_addressbook.bookdeleted', 'confirmation'); $this->rc->output->set_env('pagecount', 0); $this->rc->output->command('set_rowcount', $this->rc->gettext('nocontactsfound')); $this->rc->output->command('set_env', 'delimiter', $delimiter); $this->rc->output->command('list_contacts_clear'); $this->rc->output->command('book_delete_done', $source); } else { $this->rc->output->show_message('kolab_addressbook.bookdeleteerror', 'error'); } $this->rc->output->send(); } /** * Returns value of kolab_addressbook_prio setting */ private function addressbook_prio() { $abook_prio = (int) $this->rc->config->get('kolab_addressbook_prio'); // Make sure any global addressbooks are defined if ($abook_prio == 0 || $abook_prio == 2) { $ldap_public = $this->rc->config->get('ldap_public'); if (empty($ldap_public)) { $abook_prio = 1; } } return $abook_prio; } /** * Hook for (contact) folder deletion */ function prefs_folder_delete($args) { // ignore... if ($args['abort'] && !$args['result']) { return $args; } $this->_contact_folder_rename($args['name'], false); } /** * Hook for (contact) folder renaming */ function prefs_folder_rename($args) { // ignore... if ($args['abort'] && !$args['result']) { return $args; } $this->_contact_folder_rename($args['oldname'], $args['newname']); } /** * Hook for (contact) folder updates. Forward to folder_rename handler if name was changed */ function prefs_folder_update($args) { // ignore... if ($args['abort'] && !$args['result']) { return $args; } if ($args['record']['name'] != $args['record']['oldname']) { $this->_contact_folder_rename($args['record']['oldname'], $args['record']['name']); } } /** * Apply folder renaming or deletion to the registered birthday calendar address books */ private function _contact_folder_rename($oldname, $newname = false) { $update = false; $delimiter = $this->rc->get_storage()->get_hierarchy_delimiter(); $bday_addressbooks = (array) $this->rc->config->get('calendar_birthday_adressbooks', []); foreach ($bday_addressbooks as $i => $id) { $folder_name = kolab_storage::id_decode($id); if ($oldname === $folder_name || strpos($folder_name, $oldname.$delimiter) === 0) { if ($newname) { // rename $new_folder = $newname . substr($folder_name, strlen($oldname)); $bday_addressbooks[$i] = kolab_storage::id_encode($new_folder); } else { // delete unset($bday_addressbooks[$i]); } $update = true; } } if ($update) { $this->rc->user->save_prefs(['calendar_birthday_adressbooks' => $bday_addressbooks]); } } /** * Get a localization label for specified field type */ private function get_type_label($type) { // Roundcube < 1.5 if (function_exists('rcmail_get_type_label')) { return rcmail_get_type_label($type); } // Roundcube >= 1.5 return rcmail_action_contacts_index::get_type_label($type); } } diff --git a/plugins/kolab_auth/kolab_auth.php b/plugins/kolab_auth/kolab_auth.php index 0a7ea953..bac892ea 100644 --- a/plugins/kolab_auth/kolab_auth.php +++ b/plugins/kolab_auth/kolab_auth.php @@ -1,890 +1,890 @@ * * Copyright (C) 2011-2013, Kolab Systems AG * * 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 . */ class kolab_auth extends rcube_plugin { static $ldap; private $username; private $data = array(); public function init() { $rcmail = rcube::get_instance(); $this->load_config(); $this->require_plugin('libkolab'); $this->add_hook('authenticate', array($this, 'authenticate')); $this->add_hook('startup', array($this, 'startup')); $this->add_hook('ready', array($this, 'ready')); $this->add_hook('user_create', array($this, 'user_create')); // Hook for password change $this->add_hook('password_ldap_bind', array($this, 'password_ldap_bind')); // Hooks related to "Login As" feature $this->add_hook('template_object_loginform', array($this, 'login_form')); $this->add_hook('storage_connect', array($this, 'imap_connect')); $this->add_hook('managesieve_connect', array($this, 'imap_connect')); $this->add_hook('smtp_connect', array($this, 'smtp_connect')); $this->add_hook('identity_form', array($this, 'identity_form')); // Hook to modify some configuration, e.g. ldap $this->add_hook('config_get', array($this, 'config_get')); // Hook to modify logging directory $this->add_hook('write_log', array($this, 'write_log')); $this->username = $_SESSION['username'] ?? null; // Enable debug logs (per-user), when logged as another user if (!empty($_SESSION['kolab_auth_admin']) && $rcmail->config->get('kolab_auth_auditlog')) { $rcmail->config->set('debug_level', 1); $rcmail->config->set('smtp_log', true); $rcmail->config->set('log_logins', true); $rcmail->config->set('log_session', true); $rcmail->config->set('memcache_debug', true); $rcmail->config->set('imap_debug', true); $rcmail->config->set('ldap_debug', true); $rcmail->config->set('smtp_debug', true); $rcmail->config->set('sql_debug', true); // SQL debug need to be set directly on DB object // setting config variable will not work here because // the object is already initialized/configured if ($db = $rcmail->get_dbh()) { $db->set_debug(true); } } } /** * Ready hook handler */ public function ready($args) { $rcmail = rcube::get_instance(); // Store user unique identifier for freebusy_session_auth feature if (!($uniqueid = $rcmail->config->get('kolab_uniqueid'))) { $uniqueid = $_SESSION['kolab_auth_uniqueid']; if (!$uniqueid) { // Find user record in LDAP if (($ldap = self::ldap()) && $ldap->ready) { if ($record = $ldap->get_user_record($rcmail->get_user_name(), $_SESSION['kolab_host'])) { $uniqueid = $record['uniqueid']; } } } if ($uniqueid) { $uniqueid = md5($uniqueid); $rcmail->user->save_prefs(array('kolab_uniqueid' => $uniqueid)); } } // Set/update freebusy_session_auth entry if ($uniqueid && empty($_SESSION['kolab_auth_admin']) && ($ttl = $rcmail->config->get('freebusy_session_auth')) ) { if ($ttl === true) { $ttl = $rcmail->config->get('session_lifetime', 0) * 60; if (!$ttl) { $ttl = 10 * 60; } } $rcmail->config->set('freebusy_auth_cache', 'db'); $rcmail->config->set('freebusy_auth_cache_ttl', $ttl); if ($cache = $rcmail->get_cache_shared('freebusy_auth', false)) { $key = md5($uniqueid . ':' . rcube_utils::remote_addr() . ':' . $rcmail->get_user_name()); $value = $cache->get($key); $deadline = new DateTime('now', new DateTimeZone('UTC')); // We don't want to do the cache update on every request // do it once in a 1/10 of the ttl if ($value) { $value = new DateTime($value); $value->sub(new DateInterval('PT' . intval($ttl * 9/10) . 'S')); if ($value > $deadline) { return; } } $deadline->add(new DateInterval('PT' . $ttl . 'S')); $cache->set($key, $deadline->format(DateTime::ISO8601)); } } } /** * Startup hook handler */ public function startup($args) { // Check access rights when logged in as another user if (!empty($_SESSION['kolab_auth_admin']) && $args['task'] != 'login' && $args['task'] != 'logout') { // access to specified task is forbidden, // redirect to the first task on the list if (!empty($_SESSION['kolab_auth_allowed_tasks'])) { $tasks = (array)$_SESSION['kolab_auth_allowed_tasks']; if (!in_array($args['task'], $tasks) && !in_array('*', $tasks)) { header('Location: ?_task=' . array_shift($tasks)); die; } // add script that will remove disabled taskbar buttons if (!in_array('*', $tasks)) { $this->add_hook('render_page', array($this, 'render_page')); } } } // load per-user settings $this->load_user_role_plugins_and_settings(); return $args; } /** * Modify some configuration according to LDAP user record */ public function config_get($args) { // Replaces ldap_vars (%dc, etc) in public kolab ldap addressbooks // config based on the users base_dn. (for multi domain support) if ($args['name'] == 'ldap_public' && !empty($args['result'])) { $rcmail = rcube::get_instance(); $kolab_books = (array) $rcmail->config->get('kolab_auth_ldap_addressbooks'); foreach ($args['result'] as $name => $config) { if (in_array($name, $kolab_books) || in_array('*', $kolab_books)) { $args['result'][$name] = $this->patch_ldap_config($config); } } } else if ($args['name'] == 'kolab_users_directory' && !empty($args['result'])) { $args['result'] = $this->patch_ldap_config($args['result']); } return $args; } /** * Helper method to patch the given LDAP directory config with user-specific values */ protected function patch_ldap_config($config) { if (is_array($config)) { $config['base_dn'] = self::parse_ldap_vars($config['base_dn']); $config['search_base_dn'] = self::parse_ldap_vars($config['search_base_dn']); $config['bind_dn'] = str_replace('%dn', $_SESSION['kolab_dn'], $config['bind_dn']); if (!empty($config['groups'])) { $config['groups']['base_dn'] = self::parse_ldap_vars($config['groups']['base_dn']); } } return $config; } /** * Modifies list of plugins and settings according to * specified LDAP roles */ public function load_user_role_plugins_and_settings($startup = false) { if (empty($_SESSION['user_roledns'])) { return; } $rcmail = rcube::get_instance(); // Example 'kolab_auth_role_plugins' = // // Array( // '' => Array('plugin1', 'plugin2'), // ); // // NOTE that may in fact be something like: 'cn=role,%dc' $role_plugins = $rcmail->config->get('kolab_auth_role_plugins'); // Example $rcmail_config['kolab_auth_role_settings'] = // // Array( // '' => Array( // '$setting' => Array( // 'mode' => '(override|merge)', (default: override) // 'value' => <>, // 'allow_override' => (true|false) (default: false) // ), // ), // ); // // NOTE that may in fact be something like: 'cn=role,%dc' $role_settings = $rcmail->config->get('kolab_auth_role_settings'); if (!empty($role_plugins)) { foreach ($role_plugins as $role_dn => $plugins) { $role_dn = self::parse_ldap_vars($role_dn); if (!empty($role_plugins[$role_dn])) { $role_plugins[$role_dn] = array_unique(array_merge((array)$role_plugins[$role_dn], $plugins)); } else { $role_plugins[$role_dn] = $plugins; } } } if (!empty($role_settings)) { foreach ($role_settings as $role_dn => $settings) { $role_dn = self::parse_ldap_vars($role_dn); if (!empty($role_settings[$role_dn])) { $role_settings[$role_dn] = array_merge((array)$role_settings[$role_dn], $settings); } else { $role_settings[$role_dn] = $settings; } } } foreach ($_SESSION['user_roledns'] as $role_dn) { if (!empty($role_settings[$role_dn]) && is_array($role_settings[$role_dn])) { foreach ($role_settings[$role_dn] as $setting_name => $setting) { if (!isset($setting['mode'])) { $setting['mode'] = 'override'; } if ($setting['mode'] == "override") { $rcmail->config->set($setting_name, $setting['value']); } elseif ($setting['mode'] == "merge") { $orig_setting = $rcmail->config->get($setting_name); if (!empty($orig_setting)) { if (is_array($orig_setting)) { $rcmail->config->set($setting_name, array_merge($orig_setting, $setting['value'])); } } else { $rcmail->config->set($setting_name, $setting['value']); } } $dont_override = (array) $rcmail->config->get('dont_override'); if (empty($setting['allow_override'])) { $rcmail->config->set('dont_override', array_merge($dont_override, array($setting_name))); } else { if (in_array($setting_name, $dont_override)) { $_dont_override = array(); foreach ($dont_override as $_setting) { if ($_setting != $setting_name) { $_dont_override[] = $_setting; } } $rcmail->config->set('dont_override', $_dont_override); } } if ($setting_name == 'skin') { if ($rcmail->output->type == 'html') { $rcmail->output->set_skin($setting['value']); $rcmail->output->set_env('skin', $setting['value']); } } } } if (!empty($role_plugins[$role_dn])) { foreach ((array)$role_plugins[$role_dn] as $plugin) { $loaded = $this->api->load_plugin($plugin); // Some plugins e.g. kolab_2fa use 'startup' hook to // register other hooks, but when called on 'authenticate' hook // we're already after 'startup', so we'll call it directly if ($loaded && $startup && $plugin == 'kolab_2fa' && ($plugin = $this->api->get_plugin($plugin)) ) { $plugin->startup(array('task' => $rcmail->task, 'action' => $rcmail->action)); } } } } } /** * Logging method replacement to print debug/errors into * a separate (sub)folder for each user */ public function write_log($args) { $rcmail = rcube::get_instance(); if ($rcmail->config->get('log_driver') == 'syslog') { return $args; } // log_driver == 'file' is assumed here $log_dir = $rcmail->config->get('log_dir', RCUBE_INSTALL_PATH . 'logs'); // Append original username + target username for audit-logging if ($rcmail->config->get('kolab_auth_auditlog') && !empty($_SESSION['kolab_auth_admin'])) { $args['dir'] = $log_dir . '/' . strtolower($_SESSION['kolab_auth_admin']) . '/' . strtolower($this->username); // Attempt to create the directory if (!is_dir($args['dir'])) { @mkdir($args['dir'], 0750, true); } } // Define the user log directory if a username is provided else if ($rcmail->config->get('per_user_logging') && !empty($this->username) && !stripos($log_dir, '/' . $this->username) // maybe already set by syncroton, skip ) { $user_log_dir = $log_dir . '/' . strtolower($this->username); if (is_writable($user_log_dir)) { $args['dir'] = $user_log_dir; } else if (!in_array($args['name'], array('errors', 'userlogins', 'sendmail'))) { $args['abort'] = true; // don't log if unauthenticed or no per-user log dir } } return $args; } /** * Sets defaults for new user. */ public function user_create($args) { if (!empty($this->data['user_email'])) { // addresses list is supported if (array_key_exists('email_list', $args)) { $email_list = array_unique($this->data['user_email']); // add organization to the list if (!empty($this->data['user_organization'])) { foreach ($email_list as $idx => $email) { $email_list[$idx] = array( 'organization' => $this->data['user_organization'], 'email' => $email, ); } } $args['email_list'] = $email_list; } else { $args['user_email'] = $this->data['user_email'][0]; } } if (!empty($this->data['user_name'])) { $args['user_name'] = $this->data['user_name']; } return $args; } /** * Modifies login form adding additional "Login As" field */ public function login_form($args) { $this->add_texts('localization/'); $rcmail = rcube::get_instance(); $admin_login = $rcmail->config->get('kolab_auth_admin_login'); $group = $rcmail->config->get('kolab_auth_group'); $role_attr = $rcmail->config->get('kolab_auth_role'); // Show "Login As" input if (empty($admin_login) || (empty($group) && empty($role_attr))) { return $args; } // Don't add the extra field on 2FA form if (strpos($args['content'], 'plugin.kolab-2fa-login')) { return $args; } $input = new html_inputfield(array('name' => '_loginas', 'id' => 'rcmloginas', 'type' => 'text', 'autocomplete' => 'off')); $row = html::tag('tr', null, html::tag('td', 'title', html::label('rcmloginas', rcube::Q($this->gettext('loginas')))) . html::tag('td', 'input', $input->show(trim(rcube_utils::get_input_value('_loginas', rcube_utils::INPUT_POST)))) ); // add icon style for Elastic $style = html::tag('style', [], '#login-form .input-group .icon.loginas::before { content: "\f508"; } '); $args['content'] = preg_replace('/<\/tbody>/i', $row . '' . $style, $args['content']); return $args; } /** * Find user credentials In LDAP. */ public function authenticate($args) { // get username and host $host = $args['host']; $user = $args['user']; $pass = $args['pass']; $loginas = trim(rcube_utils::get_input_value('_loginas', rcube_utils::INPUT_POST)); if (empty($user) || (empty($pass) && empty($_SERVER['REMOTE_USER']))) { $args['abort'] = true; return $args; } // temporarily set the current username to the one submitted $this->username = $user; $ldap = self::ldap(); if (!$ldap || !$ldap->ready) { self::log_login_error($user, "LDAP not ready"); $args['abort'] = true; $args['kolab_ldap_error'] = true; return $args; } // Find user record in LDAP $record = $ldap->get_user_record($user, $host); if (empty($record)) { self::log_login_error($user, "No user record found"); $args['abort'] = true; return $args; } $rcmail = rcube::get_instance(); $admin_login = $rcmail->config->get('kolab_auth_admin_login'); $admin_pass = $rcmail->config->get('kolab_auth_admin_password'); $login_attr = $rcmail->config->get('kolab_auth_login'); $name_attr = $rcmail->config->get('kolab_auth_name'); $email_attr = $rcmail->config->get('kolab_auth_email'); $org_attr = $rcmail->config->get('kolab_auth_organization'); $role_attr = $rcmail->config->get('kolab_auth_role'); $imap_attr = $rcmail->config->get('kolab_auth_mailhost'); if (!empty($role_attr) && !empty($record[$role_attr])) { $_SESSION['user_roledns'] = (array)($record[$role_attr]); } if (!empty($imap_attr) && !empty($record[$imap_attr])) { $default_host = $rcmail->config->get('default_host'); if (!empty($default_host)) { rcube::write_log("errors", "Both default host and kolab_auth_mailhost set. Incompatible."); } else { $args['host'] = "tls://" . $record[$imap_attr]; } } // Login As... if (!empty($loginas) && $admin_login) { // Authenticate to LDAP $result = $ldap->bind($record['dn'], $pass); if (!$result) { self::log_login_error($user, "Unable to bind with '" . $record['dn'] . "'"); $args['abort'] = true; return $args; } $isadmin = false; $admin_rights = $rcmail->config->get('kolab_auth_admin_rights', array()); // @deprecated: fall-back to the old check if the original user has/belongs to administrative role/group if (empty($admin_rights)) { $group = $rcmail->config->get('kolab_auth_group'); $role_dn = $rcmail->config->get('kolab_auth_role_value'); // check role attribute if (!empty($role_attr) && !empty($role_dn) && !empty($record[$role_attr])) { $role_dn = $ldap->parse_vars($role_dn, $user, $host); if (in_array($role_dn, (array)$record[$role_attr])) { $isadmin = true; } } // check group if (!$isadmin && !empty($group)) { $groups = $ldap->get_user_groups($record['dn'], $user, $host); if (in_array($group, $groups)) { $isadmin = true; } } if ($isadmin) { // user has admin privileges privilage, get "login as" user credentials $target_entry = $ldap->get_user_record($loginas, $host); $allowed_tasks = $rcmail->config->get('kolab_auth_allowed_tasks'); } } else { // get "login as" user credentials $target_entry = $ldap->get_user_record($loginas, $host); if (!empty($target_entry)) { // get effective rights to determine login-as permissions $effective_rights = (array)$ldap->effective_rights($target_entry['dn']); if (!empty($effective_rights)) { // compat with out of date Net_LDAP3 $effective_rights = array_change_key_case($effective_rights, CASE_LOWER); $effective_rights['attrib'] = $effective_rights['attributelevelrights']; $effective_rights['entry'] = $effective_rights['entrylevelrights']; // compare the rights with the permissions mapping $allowed_tasks = array(); foreach ($admin_rights as $task => $perms) { $perms_ = explode(':', $perms); $type = array_shift($perms_); $req = array_pop($perms_); $attrib = array_pop($perms_); if (array_key_exists($type, $effective_rights)) { if ($type == 'entry' && in_array($req, $effective_rights[$type])) { $allowed_tasks[] = $task; } else if ($type == 'attrib' && array_key_exists($attrib, $effective_rights[$type]) && in_array($req, $effective_rights[$type][$attrib])) { $allowed_tasks[] = $task; } } } $isadmin = !empty($allowed_tasks); } } } // Save original user login for log (see below) if ($login_attr) { $origname = is_array($record[$login_attr]) ? $record[$login_attr][0] : $record[$login_attr]; } else { $origname = $user; } if (!$isadmin || empty($target_entry)) { $this->add_texts('localization/'); $args['abort'] = true; $args['error'] = $this->gettext(array( 'name' => 'loginasnotallowed', 'vars' => array('user' => rcube::Q($loginas)), )); self::log_login_error($user, "No privileges to login as '" . $loginas . "'", $loginas); return $args; } // replace $record with target entry $record = $target_entry; $args['user'] = $this->username = $loginas; // Mark session to use SASL proxy for IMAP authentication $_SESSION['kolab_auth_admin'] = strtolower($origname); $_SESSION['kolab_auth_login'] = $rcmail->encrypt($admin_login); $_SESSION['kolab_auth_password'] = $rcmail->encrypt($admin_pass); $_SESSION['kolab_auth_allowed_tasks'] = $allowed_tasks; } // Store UID and DN of logged user in session for use by other plugins $_SESSION['kolab_uid'] = is_array($record['uid']) ? $record['uid'][0] : $record['uid']; $_SESSION['kolab_dn'] = $record['dn']; // Store LDAP replacement variables used for current user // This improves performance of load_user_role_plugins_and_settings() // which is executed on every request (via startup hook) and where // we don't like to use LDAP (connection + bind + search) $_SESSION['kolab_auth_vars'] = $ldap->get_parse_vars(); // Store user unique identifier for freebusy_session_auth feature $_SESSION['kolab_auth_uniqueid'] = is_array($record['uniqueid']) ? $record['uniqueid'][0] : $record['uniqueid']; // Store also host as we need it for get_user_reacod() in 'ready' hook handler $_SESSION['kolab_host'] = $host; // Set user login if ($login_attr) { $this->data['user_login'] = is_array($record[$login_attr]) ? $record[$login_attr][0] : $record[$login_attr]; } if ($this->data['user_login']) { $args['user'] = $this->username = $this->data['user_login']; } // User name for identity (first log in) foreach ((array)$name_attr as $field) { - $name = is_array($record[$field]) ? $record[$field][0] : $record[$field]; + $name = is_array($record[$field] ?? null) ? $record[$field][0] : ($record[$field] ?? null); if (!empty($name)) { $this->data['user_name'] = $name; break; } } // User email(s) for identity (first log in) foreach ((array)$email_attr as $field) { $email = is_array($record[$field]) ? array_filter($record[$field]) : $record[$field]; if (!empty($email)) { $this->data['user_email'] = array_merge((array)($this->data['user_email'] ?? null), (array)$email); } } // Organization name for identity (first log in) foreach ((array)$org_attr as $field) { $organization = is_array($record[$field]) ? $record[$field][0] : $record[$field]; if (!empty($organization)) { $this->data['user_organization'] = $organization; break; } } // Log "Login As" usage if (!empty($origname)) { rcube::write_log('userlogins', sprintf('Admin login for %s by %s from %s', $args['user'], $origname, rcube_utils::remote_ip())); } // load per-user settings/plugins $this->load_user_role_plugins_and_settings(true); return $args; } /** * Set user DN for password change (password plugin with ldap_simple driver) */ public function password_ldap_bind($args) { $args['user_dn'] = $_SESSION['kolab_dn']; $rcmail = rcube::get_instance(); $rcmail->config->set('password_ldap_method', 'user'); return $args; } /** * Sets SASL Proxy login/password for IMAP and Managesieve auth */ public function imap_connect($args) { if (!empty($_SESSION['kolab_auth_admin'])) { $rcmail = rcube::get_instance(); $admin_login = $rcmail->decrypt($_SESSION['kolab_auth_login']); $admin_pass = $rcmail->decrypt($_SESSION['kolab_auth_password']); $args['auth_cid'] = $admin_login; $args['auth_pw'] = $admin_pass; } return $args; } /** * Sets SASL Proxy login/password for SMTP auth */ public function smtp_connect($args) { if (!empty($_SESSION['kolab_auth_admin'])) { $rcmail = rcube::get_instance(); $admin_login = $rcmail->decrypt($_SESSION['kolab_auth_login']); $admin_pass = $rcmail->decrypt($_SESSION['kolab_auth_password']); $args['smtp_auth_cid'] = $admin_login; $args['smtp_auth_pw'] = $admin_pass; } return $args; } /** * Hook to replace the plain text input field for email address by a drop-down list * with all email addresses (including aliases) from this user's LDAP record. */ public function identity_form($args) { $rcmail = rcube::get_instance(); $ident_level = intval($rcmail->config->get('identities_level', 0)); // do nothing if email address modification is disabled if ($ident_level == 1 || $ident_level == 3) { return $args; } $ldap = self::ldap(); if (!$ldap || !$ldap->ready || empty($_SESSION['kolab_dn'])) { return $args; } $emails = array(); $user_record = $ldap->get_record($_SESSION['kolab_dn']); foreach ((array)$rcmail->config->get('kolab_auth_email', array()) as $col) { $values = rcube_addressbook::get_col_values($col, $user_record, true); if (!empty($values)) $emails = array_merge($emails, array_filter($values)); } // kolab_delegation might want to modify this addresses list $plugin = $rcmail->plugins->exec_hook('kolab_auth_emails', array('emails' => $emails)); $emails = $plugin['emails']; if (!empty($emails)) { $args['form']['addressing']['content']['email'] = array( 'type' => 'select', 'options' => array_combine($emails, $emails), ); } return $args; } /** * Action executed before the page is rendered to add an onload script * that will remove all taskbar buttons for disabled tasks */ public function render_page($args) { $rcmail = rcube::get_instance(); $tasks = (array)$_SESSION['kolab_auth_allowed_tasks']; $tasks[] = 'logout'; // disable buttons in taskbar $script = " \$('a').filter(function() { var ev = \$(this).attr('onclick'); return ev && ev.match(/'switch-task','([a-z]+)'/) && \$.inArray(RegExp.\$1, " . json_encode($tasks) . ") < 0; }).remove(); "; $rcmail->output->add_script($script, 'docready'); } /** * Initializes LDAP object and connects to LDAP server */ public static function ldap() { self::$ldap = kolab_storage::ldap('kolab_auth_addressbook'); if (self::$ldap) { self::$ldap->extend_fieldmap(array('uniqueid' => 'nsuniqueid')); } return self::$ldap; } /** * Close LDAP connection */ public static function ldap_close() { if (self::$ldap) { self::$ldap->close(); self::$ldap = null; } } /** * Parses LDAP DN string with replacing supported variables. * See kolab_ldap::parse_vars() * * @param string $str LDAP DN string * * @return string Parsed DN string */ public static function parse_ldap_vars($str) { if (!empty($_SESSION['kolab_auth_vars'])) { $str = strtr($str, $_SESSION['kolab_auth_vars']); } return $str; } /** * Log failed logins * * @param string $username Username/Login * @param string $message Error message (failure reason) * @param string $login_as Username/Login of "login as" user */ public static function log_login_error($username, $message = null, $login_as = null) { $config = rcube::get_instance()->config; if ($config->get('log_logins')) { // don't fill the log with complete input, which could // have been prepared by a hacker if (strlen($username) > 256) { $username = substr($username, 0, 256) . '...'; } if (strlen($login_as) > 256) { $login_as = substr($login_as, 0, 256) . '...'; } if ($login_as) { $username = sprintf('%s (as user %s)', $username, $login_as); } // Don't log full session id for better security $session_id = session_id(); $session_id = $session_id ? substr($session_id, 0, 16) : 'no-session'; $message = sprintf( "Failed login for %s from %s in session %s %s", $username, rcube_utils::remote_ip(), $session_id, $message ? "($message)" : '' ); rcube::write_log('userlogins', $message); // disable log_logins to prevent from duplicate log entries $config->set('log_logins', false); } } } diff --git a/plugins/kolab_files/lib/kolab_files_engine.php b/plugins/kolab_files/lib/kolab_files_engine.php index 311a8695..7e1b79ee 100644 --- a/plugins/kolab_files/lib/kolab_files_engine.php +++ b/plugins/kolab_files/lib/kolab_files_engine.php @@ -1,1810 +1,1814 @@ * * Copyright (C) 2013-2015, Kolab Systems AG * * 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 . */ class kolab_files_engine { private $plugin; private $rc; private $url; private $url_srv; + private $filetypes_style; private $timeout = 600; private $files_sort_cols = array('name', 'mtime', 'size'); private $sessions_sort_cols = array('name'); private $mimetypes = null; const API_VERSION = 4; /** * Class constructor */ public function __construct($plugin, $client_url, $server_url = null) { $this->url = rtrim(rcube_utils::resolve_url($client_url), '/ '); $this->url_srv = $server_url ? rtrim(rcube_utils::resolve_url($server_url), '/ ') : $this->url; $this->plugin = $plugin; $this->rc = $plugin->rc; $this->timeout = $this->rc->config->get('session_lifetime') * 60; } /** * User interface initialization */ public function ui() { $this->plugin->add_texts('localization/'); $templates = array(); $list_widget = false; // set templates of Files UI and widgets if ($this->rc->task == 'mail') { if (in_array($this->rc->action, array('', 'show', 'compose'))) { $templates[] = 'compose_plugin'; } if (in_array($this->rc->action, array('show', 'preview', 'get'))) { $templates[] = 'message_plugin'; if ($this->rc->action == 'get') { // add "Save as" button into attachment toolbar $this->plugin->add_button(array( 'id' => 'saveas', 'name' => 'saveas', 'type' => 'link', 'onclick' => 'kolab_directory_selector_dialog()', 'class' => 'button buttonPas saveas', 'classact' => 'button saveas', 'label' => 'kolab_files.save', 'title' => 'kolab_files.saveto', ), 'toolbar'); } else { // add "Save as" button into attachment menu $this->plugin->add_button(array( 'id' => 'attachmenusaveas', 'name' => 'attachmenusaveas', 'type' => 'link', 'wrapper' => 'li', 'onclick' => 'return false', 'class' => 'icon active saveas', 'classact' => 'icon active saveas', 'innerclass' => 'icon active saveas', 'label' => 'kolab_files.saveto', ), 'attachmentmenu'); } } $list_widget = true; } else if (!$this->rc->action && in_array($this->rc->task, array('calendar', 'tasks'))) { $list_widget = true; $templates[] = 'compose_plugin'; } else if ($this->rc->task == 'files') { $templates[] = 'files'; // get list of external sources $this->get_external_storage_drivers(); // these labels may be needed even if fetching ext sources failed $this->plugin->add_label('folderauthtitle', 'authenticating', 'foldershare', 'saving'); } if ($list_widget) { $this->folder_list_env(); $this->plugin->add_label('save', 'cancel', 'saveto', 'saveall', 'fromcloud', 'attachsel', 'selectfiles', 'attaching', 'collection_audio', 'collection_video', 'collection_image', 'collection_document', 'folderauthtitle', 'authenticating' ); } // add taskbar button if (empty($_REQUEST['framed'])) { $this->plugin->add_button(array( 'command' => 'files', 'class' => 'button-files', 'classsel' => 'button-files button-selected', 'innerclass' => 'button-inner', 'label' => 'kolab_files.files', 'type' => 'link' ), 'taskbar'); } $caps = $this->capabilities(); $this->plugin->include_stylesheet($this->plugin->local_skin_path().'/style.css'); $this->plugin->include_script($this->url . '/js/files_api.js'); $this->plugin->include_script('kolab_files.js'); $this->rc->output->set_env('files_url', $this->url . '/api/'); $this->rc->output->set_env('files_token', $this->get_api_token()); $this->rc->output->set_env('files_caps', $caps); $this->rc->output->set_env('files_api_version', $caps['VERSION'] ?? 3); $this->rc->output->set_env('files_user', $this->rc->get_user_name()); - if ($caps['DOCEDIT']) { + if ($caps['DOCEDIT'] ?? false) { $this->plugin->add_label('declinednotice', 'invitednotice', 'acceptedownernotice', 'declinedownernotice', 'requestednotice', 'acceptednotice', 'declinednotice', 'more', 'accept', 'decline', 'join', 'status', 'when', 'file', 'comment', 'statusaccepted', 'statusinvited', 'statusdeclined', 'statusrequested', 'invitationaccepting', 'invitationdeclining', 'invitationrequesting', 'close', 'invitationtitle', 'sessions', 'saving'); } if (!empty($templates)) { $collapsed_folders = (string) $this->rc->config->get('kolab_files_collapsed_folders'); $this->rc->output->include_script('treelist.js'); $this->rc->output->set_env('kolab_files_collapsed_folders', $collapsed_folders); // register template objects for dialogs (and main interface) $this->rc->output->add_handlers(array( 'folder-create-form' => array($this, 'folder_create_form'), 'folder-edit-form' => array($this, 'folder_edit_form'), 'folder-mount-form' => array($this, 'folder_mount_form'), 'folder-auth-options'=> array($this, 'folder_auth_options'), 'file-search-form' => array($this, 'file_search_form'), 'file-rename-form' => array($this, 'file_rename_form'), 'file-create-form' => array($this, 'file_create_form'), 'file-edit-dialog' => array($this, 'file_edit_dialog'), 'file-session-dialog' => array($this, 'file_session_dialog'), 'filelist' => array($this, 'file_list'), 'sessionslist' => array($this, 'sessions_list'), 'filequotadisplay' => array($this, 'quota_display'), 'document-editors-dialog' => array($this, 'document_editors_dialog'), )); if ($this->rc->task != 'files') { // add dialog(s) content at the end of page body foreach ($templates as $template) { $this->rc->output->add_footer( $this->rc->output->parse('kolab_files.' . $template, false, false)); } } } } /** * Engine actions handler */ public function actions() { if ($this->rc->task == 'files' && $this->rc->action) { $action = $this->rc->action; } else if ($this->rc->task != 'files' && $_POST['act']) { $action = $_POST['act']; } else { $action = 'index'; } $method = 'action_' . str_replace('-', '_', $action); if (method_exists($this, $method)) { $this->plugin->add_texts('localization/'); $this->{$method}(); } } /** * Template object for folder creation form */ public function folder_create_form($attrib) { $attrib['name'] = 'folder-create-form'; if (empty($attrib['id'])) { $attrib['id'] = 'folder-create-form'; } $input_name = new html_inputfield(array('id' => 'folder-name', 'name' => 'name', 'size' => 30)); $select_parent = new html_select(array('id' => 'folder-parent', 'name' => 'parent')); $table = new html_table(array('cols' => 2, 'class' => 'propform')); $table->add('title', html::label('folder-name', rcube::Q($this->plugin->gettext('foldername')))); $table->add(null, $input_name->show()); $table->add('title', html::label('folder-parent', rcube::Q($this->plugin->gettext('folderinside')))); $table->add(null, $select_parent->show()); $out = $table->show(); // add form tag around text field if (empty($attrib['form'])) { $out = $this->rc->output->form_tag($attrib, $out); } $this->plugin->add_label('foldercreating', 'foldercreatenotice', 'create', 'foldercreate', 'cancel', 'addfolder'); $this->rc->output->add_gui_object('folder-create-form', $attrib['id']); return $out; } /** * Template object for folder editing form */ public function folder_edit_form($attrib) { $attrib['name'] = 'folder-edit-form'; if (empty($attrib['id'])) { $attrib['id'] = 'folder-edit-form'; } $input_name = new html_inputfield(array('id' => 'folder-edit-name', 'name' => 'name', 'size' => 30)); $select_parent = new html_select(array('id' => 'folder-edit-parent', 'name' => 'parent')); $table = new html_table(array('cols' => 2, 'class' => 'propform')); $table->add('title', html::label('folder-edit-name', rcube::Q($this->plugin->gettext('foldername')))); $table->add(null, $input_name->show()); $table->add('title', html::label('folder-edit-parent', rcube::Q($this->plugin->gettext('folderinside')))); $table->add(null, $select_parent->show()); $out = $table->show(); // add form tag around text field if (empty($attrib['form'])) { $out = $this->rc->output->form_tag($attrib, $out); } $this->plugin->add_label('folderupdating', 'folderupdatenotice', 'save', 'folderedit', 'cancel'); $this->rc->output->add_gui_object('folder-edit-form', $attrib['id']); return $out; } /** * Template object for folder mounting form */ public function folder_mount_form($attrib) { $sources = $this->rc->output->get_env('external_sources'); if (empty($sources) || !is_array($sources)) { return ''; } $attrib['name'] = 'folder-mount-form'; if (empty($attrib['id'])) { $attrib['id'] = 'folder-mount-form'; } // build form content $table = new html_table(array('cols' => 2, 'class' => 'propform')); $input_name = new html_inputfield(array('id' => 'folder-mount-name', 'name' => 'name', 'size' => 30)); $input_driver = new html_radiobutton(array('name' => 'driver', 'size' => 30)); $table->add('title', html::label('folder-mount-name', rcube::Q($this->plugin->gettext('name')))); $table->add(null, $input_name->show()); foreach ($sources as $key => $source) { $id = 'source-' . $key; $form = new html_table(array('cols' => 2, 'class' => 'propform driverform')); foreach ((array) $source['form'] as $idx => $label) { $iid = $id . '-' . $idx; $type = stripos($idx, 'pass') !== false ? 'html_passwordfield' : 'html_inputfield'; $input = new $type(array('size' => 30)); $form->add('title', html::label($iid, rcube::Q($label))); $form->add(null, $input->show('', array( 'id' => $iid, 'name' => $key . '[' . $idx . ']' ))); } $row = $input_driver->show(null, array('value' => $key)) . html::img(array('src' => $source['image'], 'alt' => $key, 'title' => $source['name'])) . html::div(null, html::span('name', rcube::Q($source['name'])) . html::br() . html::span('description hint', rcube::Q($source['description'])) . $form->show() ); $table->add(array('id' => $id, 'colspan' => 2, 'class' => 'source'), $row); } $out = $table->show() . $this->folder_auth_options(array('suffix' => '-form')); // add form tag around text field if (empty($attrib['form'])) { $out = $this->rc->output->form_tag($attrib, $out); } $this->plugin->add_label('foldermounting', 'foldermountnotice', 'foldermount', 'save', 'cancel', 'folderauthtitle', 'authenticating' ); $this->rc->output->add_gui_object('folder-mount-form', $attrib['id']); return $out; } /** * Template object for folder authentication options */ public function folder_auth_options($attrib) { $checkbox = new html_checkbox(array( 'name' => 'store_passwords', 'value' => '1', 'class' => 'pretty-checkbox', )); return html::div('auth-options', html::label(null, $checkbox->show() . ' ' . $this->plugin->gettext('storepasswords')) . html::p('description hint', $this->plugin->gettext('storepasswordsdesc')) ); } /** * Template object for sharing form */ public function folder_share_form($attrib) { $folder = rcube_utils::get_input_value('_folder', rcube_utils::INPUT_GET, true); $info = $this->get_share_info($folder); if (empty($info) || empty($info['form'])) { $msg = $this->plugin->gettext($info === false ? 'sharepermissionerror' : 'sharestorageerror'); return html::div(array('class' => 'boxerror', 'id' => 'share-notice'), rcube::Q($msg)); } if (empty($attrib['id'])) { $attrib['id'] = 'foldershareform'; } $out = ''; foreach ($info['form'] as $mode => $tab) { $table = new html_table(array( 'cols' => ($tab['list_column'] ? 1 : count($tab['form'])) + 1, 'data-mode' => $mode, 'data-single' => $tab['single'] ? 1 : 0, )); $submit = new html_button(array('class' => 'btn btn-secondary submit')); $delete = new html_button(array('class' => 'btn btn-secondary btn-danger delete')); $fields = array(); // Table header if (!empty($tab['list_column'])) { $table->add_header(null, rcube::Q($tab['list_column_label'])); } else { foreach ($tab['form'] as $field) { $table->add_header(null, rcube::Q($field['title'])); } } $table->add_header(null, ''); // Submit form $record = ''; foreach ($tab['form'] as $index => $field) { $add = ''; if ($field['type'] == 'select') { $ff = new html_select(array('name' => $index)); foreach ($field['options'] as $opt_idx => $opt) { $ff->add($opt, $opt_idx); } } else if ($field['type'] == 'password') { $ff = new html_passwordfield(array( 'name' => $index, 'placeholder' => $this->rc->gettext('password'), )); $add = new html_passwordfield(array( 'name' => $index . 'confirm', 'placeholder' => $this->plugin->gettext('confirmpassword'), )); $add = $add->show(); } else { $ff = new html_inputfield(array( 'name' => $index, 'data-autocomplete' => $field['autocomplete'], 'placeholder' => $field['placeholder'], )); } if (!empty($tab['list_column'])) { $record .= $ff->show() . $add; } else { $table->add(null, $ff->show() . $add); } $fields[$index] = $ff; } if (!empty($tab['list_column'])) { $table->add('form', $record); } $hidden = ''; foreach ((array) $tab['extra_fields'] as $key => $default) { $h = new html_hiddenfield(array('name' => $key, 'value' => $default)); $hidden .= $h->show(); } $table->add(null, $hidden . $submit->show(rcube::Q($tab['label'] ?: $this->plugin->gettext('submit')))); // Existing entries foreach ((array) $info['rights'] as $entry) { if ($entry['mode'] == $mode) { if (!empty($tab['list_column'])) { $table->add(null, html::span(array('title' => $entry['title'], 'class' => 'name'), rcube::Q($entry[$tab['list_column']]))); } else { foreach ($tab['form'] as $index => $field) { if ($fields[$index] instanceof html_select) { $table->add(null, $fields[$index]->show($entry[$index])); } else if ($fields[$index] instanceof html_inputfield) { $table->add(null, html::span(array('title' => $entry['title'], 'class' => 'name'), rcube::Q($entry[$index]))); } } } $hidden = ''; foreach ((array) $tab['extra_fields'] as $key => $default) { if (isset($entry[$key])) { $h = new html_hiddenfield(array('name' => $key, 'value' => $entry[$key])); $hidden .= $h->show(); } } $table->add(null, $hidden . $delete->show(rcube::Q($this->rc->gettext('delete')))); } } $this->rc->output->add_label('kolab_files.updatingfolder' . $mode); $out .= html::tag('fieldset', $mode, html::tag('legend', null, rcube::Q($tab['title'])) . $table->show()) . "\n"; } $this->rc->autocomplete_init(); $this->rc->output->set_env('folder', $folder); $this->rc->output->set_env('form_info', $info['form']); $this->rc->output->add_gui_object('shareform', $attrib['id']); $this->rc->output->add_label('kolab_files.submit', 'kolab_files.passwordconflict', 'delete'); return html::div($attrib, $out); } /** * Template object for file edit dialog/warnings */ public function file_edit_dialog($attrib) { $this->plugin->add_label('select', 'create', 'cancel', 'editfiledialog', 'editfilesessions', 'newsession', 'ownedsession', 'invitedsession', 'joinsession', 'editfilero', 'editfilerotitle', 'newsessionro' ); return '
    '; } /** * Template object for file session dialog */ public function file_session_dialog($attrib) { $this->plugin->add_label('join', 'open', 'close', 'request', 'cancel', 'sessiondialog', 'sessiondialogcontent'); return '
    '; } /** * Template object for dcument editors dialog */ public function document_editors_dialog($attrib) { $table = new html_table($attrib + array('cols' => 3, 'border' => 0, 'cellpadding' => 0)); $table->add_header('username', $this->plugin->gettext('participant')); $table->add_header('status', $this->plugin->gettext('status')); $table->add_header('options', null); $input = new html_inputfield(array('name' => 'participant', 'id' => 'invitation-editor-name', 'size' => 30, 'class' => 'form-control')); $textarea = new html_textarea(array('name' => 'comment', 'id' => 'invitation-comment', 'rows' => 4, 'cols' => 55, 'class' => 'form-control', 'title' => $this->plugin->gettext('invitationtexttitle'))); $button = new html_inputfield(array('type' => 'button', 'class' => 'button', 'id' => 'invitation-editor-add', 'value' => $this->plugin->gettext('addparticipant'))); $this->plugin->add_label('manageeditors', 'statusorganizer', 'addparticipant'); // initialize attendees autocompletion $this->rc->autocomplete_init(); return html::div(null, $table->show() . html::div(null, html::div('form-searchbar', $input->show() . " " . $button->show()) . html::p('attendees-commentbox', html::label(null, $this->plugin->gettext('invitationtextlabel') . $textarea->show()) ) )); } /** * Template object for file_rename form */ public function file_rename_form($attrib) { $attrib['name'] = 'file-rename-form'; if (empty($attrib['id'])) { $attrib['id'] = 'file-rename-form'; } $input_name = new html_inputfield(array('id' => 'file-rename-name', 'name' => 'name', 'size' => 50)); $table = new html_table(array('cols' => 2, 'class' => 'propform')); $table->add('title', html::label('file-rename-name', rcube::Q($this->plugin->gettext('filename')))); $table->add(null, $input_name->show()); $out = $table->show(); // add form tag around text field if (empty($attrib['form'])) { $out = $this->rc->output->form_tag($attrib, $out); } $this->plugin->add_label('save', 'cancel', 'fileupdating', 'renamefile'); $this->rc->output->add_gui_object('file-rename-form', $attrib['id']); return $out; } /** * Template object for file_create form */ public function file_create_form($attrib) { $attrib['name'] = 'file-create-form'; if (empty($attrib['id'])) { $attrib['id'] = 'file-create-form'; } $input_name = new html_inputfield(array('id' => 'file-create-name', 'name' => 'name', 'size' => 30)); $select_parent = new html_select(array('id' => 'file-create-parent', 'name' => 'parent')); $select_type = new html_select(array('id' => 'file-create-type', 'name' => 'type')); $table = new html_table(array('cols' => 2, 'class' => 'propform')); $types = array(); foreach ($this->get_mimetypes('edit') as $type => $mimetype) { $types[$type] = $mimetype['ext']; $select_type->add($mimetype['label'], $type); } $table->add('title', html::label('file-create-name', rcube::Q($this->plugin->gettext('filename')))); $table->add(null, $input_name->show()); $table->add('title', html::label('file-create-type', rcube::Q($this->plugin->gettext('type')))); $table->add(null, $select_type->show()); $table->add('title', html::label('file-create-parent', rcube::Q($this->plugin->gettext('folderinside')))); $table->add(null, $select_parent->show()); $out = $table->show(); // add form tag around text field if (empty($attrib['form'])) { $out = $this->rc->output->form_tag($attrib, $out); } $this->plugin->add_label('create', 'cancel', 'filecreating', 'createfile', 'createandedit', 'copyfile', 'copyandedit'); $this->rc->output->add_gui_object('file-create-form', $attrib['id']); $this->rc->output->set_env('file_extensions', $types); return $out; } /** * Template object for file search form in "From cloud" dialog */ public function file_search_form($attrib) { $attrib += array( 'name' => '_q', 'gui-object' => 'filesearchbox', 'form-name' => 'filesearchform', 'command' => 'files-search', 'reset-command' => 'files-search-reset', ); // add form tag around text field return $this->rc->output->search_form($attrib); } /** * Template object for files list */ public function file_list($attrib) { return $this->list_handler($attrib, 'files'); } /** * Template object for sessions list */ public function sessions_list($attrib) { return $this->list_handler($attrib, 'sessions'); } /** * Creates unified template object for files|sessions list */ protected function list_handler($attrib, $type = 'files') { $prefix = 'kolab_' . $type . '_'; $c_prefix = 'kolab_files' . ($type != 'files' ? '_' . $type : '') . '_'; // define list of cols to be displayed based on parameter or config if (empty($attrib['columns'])) { $list_cols = $this->rc->config->get($c_prefix . 'list_cols'); $dont_override = $this->rc->config->get('dont_override'); $a_show_cols = is_array($list_cols) ? $list_cols : array('name'); $this->rc->output->set_env($type . '_col_movable', !in_array($c_prefix . 'list_cols', (array)$dont_override)); } else { $columns = str_replace(array("'", '"'), '', $attrib['columns']); $a_show_cols = preg_split('/[\s,;]+/', $columns); } // make sure 'name' and 'options' column is present if (!in_array('name', $a_show_cols)) { array_unshift($a_show_cols, 'name'); } if (!in_array('options', $a_show_cols)) { array_unshift($a_show_cols, 'options'); } $attrib['columns'] = $a_show_cols; // save some variables for use in ajax list $_SESSION[$prefix . 'list_attrib'] = $attrib; // For list in dialog(s) remove all option-like columns if ($this->rc->task != 'files') { $a_show_cols = array_intersect($a_show_cols, $this->{$type . '_sort_cols'}); } // set default sort col/order to session if (!isset($_SESSION[$prefix . 'sort_col'])) $_SESSION[$prefix . 'sort_col'] = $this->rc->config->get($c_prefix . 'sort_col') ?: 'name'; if (!isset($_SESSION[$prefix . 'sort_order'])) $_SESSION[$prefix . 'sort_order'] = strtoupper($this->rc->config->get($c_prefix . 'sort_order') ?: 'asc'); // set client env $this->rc->output->add_gui_object($type . 'list', $attrib['id']); $this->rc->output->set_env($type . '_sort_col', $_SESSION[$prefix . 'sort_col']); $this->rc->output->set_env($type . '_sort_order', $_SESSION[$prefix . 'sort_order']); $this->rc->output->set_env($type . '_coltypes', $a_show_cols); $this->rc->output->include_script('list.js'); $this->rc->output->add_label('kolab_files.abort', 'searching'); // attach css rules for mimetype icons if (!$this->filetypes_style) { $this->plugin->include_stylesheet($this->url . '/skins/default/images/mimetypes/style.css'); $this->filetypes_style = true; } $thead = ''; foreach ($this->list_head($attrib, $a_show_cols, $type) as $cell) { $thead .= html::tag('th', array('class' => $cell['className'], 'id' => $cell['id']), $cell['html']); } return html::tag('table', $attrib, html::tag('thead', null, html::tag('tr', null, $thead)) . html::tag('tbody', null, ''), array('style', 'class', 'id', 'cellpadding', 'cellspacing', 'border', 'summary')); } /** * Creates for message list table */ protected function list_head($attrib, $a_show_cols, $type = 'files') { $prefix = 'kolab_' . $type . '_'; $c_prefix = 'kolab_files_' . ($type != 'files' ? $type : '') . '_'; - $skin_path = $_SESSION['skin_path']; + $skin_path = $_SESSION['skin_path'] ?? null; // check to see if we have some settings for sorting $sort_col = $_SESSION[$prefix . 'sort_col']; $sort_order = $_SESSION[$prefix . 'sort_order']; $dont_override = (array)$this->rc->config->get('dont_override'); $disabled_sort = in_array($c_prefix . 'sort_col', $dont_override); $disabled_order = in_array($c_prefix . 'sort_order', $dont_override); $this->rc->output->set_env($prefix . 'disabled_sort_col', $disabled_sort); $this->rc->output->set_env($prefix . 'disabled_sort_order', $disabled_order); // define sortable columns if ($disabled_sort) $a_sort_cols = $sort_col && !$disabled_order ? array($sort_col) : array(); else $a_sort_cols = $this->{$type . '_sort_cols'}; if (!empty($attrib['optionsmenuicon'])) { $onclick = 'return ' . rcmail_output::JS_OBJECT_NAME . ".command('menu-open', '{$type}listmenu', this, event)"; $inner = $this->rc->gettext('listoptions'); if (is_string($attrib['optionsmenuicon']) && $attrib['optionsmenuicon'] != 'true') { $inner = html::img(array('src' => $skin_path . $attrib['optionsmenuicon'], 'alt' => $this->rc->gettext('listoptions'))); } $list_menu = html::a(array( 'href' => '#list-options', 'onclick' => $onclick, 'class' => 'listmenu', 'id' => $type . 'listmenulink', 'title' => $this->rc->gettext('listoptions'), 'tabindex' => '0', ), $inner); } else { $list_menu = ''; } $cells = array(); foreach ($a_show_cols as $col) { // get column name switch ($col) { case 'options': $col_name = $list_menu; break; default: $col_name = rcube::Q($this->plugin->gettext($col)); } // make sort links if (in_array($col, $a_sort_cols)) { $col_name = html::a(array( 'href' => "#sort", 'onclick' => 'return ' . rcmail_output::JS_OBJECT_NAME . ".command('$type-sort','$col',this)", 'title' => $this->plugin->gettext('sortby') ), $col_name); } - else if ($col_name[0] != '<') { + else if (empty($col_name) || $col_name[0] != '<') { $col_name = '' . $col_name . ''; } $sort_class = $col == $sort_col && !$disabled_order ? " sorted$sort_order" : ''; $class_name = $col.$sort_class; // put it all together $cells[] = array('className' => $class_name, 'id' => "rcm$col", 'html' => $col_name); } return $cells; } /** * Update files|sessions list object */ protected function list_update($prefs, $type = 'files') { $prefix = 'kolab_' . $type . '_list_'; $c_prefix = 'kolab_files' . ($type != 'files' ? '_' . $type : '') . '_list_'; $attrib = $_SESSION[$prefix . 'attrib']; if (!empty($prefs[$c_prefix . 'cols'])) { $attrib['columns'] = $prefs[$c_prefix . 'cols']; $_SESSION[$prefix . 'attrib'] = $attrib; } $a_show_cols = $attrib['columns']; $head = ''; foreach ($this->list_head($attrib, $a_show_cols, $type) as $cell) { $head .= html::tag('th', array('class' => $cell['className'], 'id' => $cell['id']), $cell['html']); } $head = html::tag('tr', null, $head); $this->rc->output->set_env($type . '_coltypes', $a_show_cols); $this->rc->output->command($type . '_list_update', $head); } /** * Template object for file info box */ public function file_info_box($attrib) { // print_r($this->file_data, true); $table = new html_table(array('cols' => 2, 'class' => $attrib['class'])); // file name $table->add('title', $this->plugin->gettext('name').':'); $table->add('data filename', $this->file_data['name']); // file type // @TODO: human-readable type name $table->add('title', $this->plugin->gettext('type').':'); $table->add('data filetype', $this->file_data['type']); // file size $table->add('title', $this->plugin->gettext('size').':'); $table->add('data filesize', $this->rc->show_bytes($this->file_data['size'])); // file modification time $table->add('title', $this->plugin->gettext('mtime').':'); $table->add('data filemtime', $this->file_data['mtime']); // @TODO: for images: width, height, color depth, etc. // @TODO: for text files: count of characters, lines, words return $table->show(); } /** * Template object for file preview frame */ public function file_preview_frame($attrib) { if (empty($attrib['id'])) { $attrib['id'] = 'filepreviewframe'; } - if ($frame = $this->file_data['viewer']['frame']) { + if ($frame = ($this->file_data['viewer']['frame'] ?? null)) { return $frame; } if ($href = $this->file_data['viewer']['href']) { // file href attribute must be an absolute URL (Bug #2063) if (!empty($href)) { if (!preg_match('|^https?://|', $href)) { $href = $this->url . '/api/' . $href; } } } else { $token = $this->get_api_token(); $href = $this->url . '/api/?method=file_get' . '&file=' . urlencode($this->file_data['filename']) . '&token=' . urlencode($token); } $this->rc->output->add_gui_object('preview_frame', $attrib['id']); $attrib['allowfullscreen'] = true; $attrib['src'] = $href; $attrib['onload'] = 'kolab_files_frame_load(this)'; + $form = null; // editor requires additional arguments via POST if (!empty($this->file_data['viewer']['post'])) { $attrib['src'] = 'program/resources/blank.gif'; $form_content = new html_hiddenfield(); $form_attrib = array( 'action' => $href, 'id' => $attrib['id'] . '-form', 'target' => $attrib['name'], 'method' => 'post', ); foreach ($this->file_data['viewer']['post'] as $name => $value) { $form_content->add(array('name' => $name, 'value' => $value)); } $form = html::tag('form', $form_attrib, $form_content->show()) . html::script(array(), "\$('#{$attrib['id']}-form').submit()"); } return html::iframe($attrib) . $form; } /** * Template object for quota display */ public function quota_display($attrib) { if (empty($attrib['id'])) { $attrib['id'] = 'rcmquotadisplay'; } $quota_type = !empty($attrib['display']) ? $attrib['display'] : 'text'; $this->rc->output->add_gui_object('quotadisplay', $attrib['id']); $this->rc->output->set_env('quota_type', $quota_type); // get quota $token = $this->get_api_token(); $request = $this->get_request(array('method' => 'quota'), $token); // send request to the API try { $response = $request->send(); $status = $response->getStatus(); $body = @json_decode($response->getBody(), true); if ($status == 200 && $body['status'] == 'OK') { $quota = $body['result']; } else { throw new Exception($body['reason'] ?: "Failed to get quota. Status: $status"); } } catch (Exception $e) { rcube::raise_error($e, true, false); $quota = array('total' => 0, 'percent' => 0); } $quota = rcube_output::json_serialize($quota); $this->rc->output->add_script(rcmail_output::JS_OBJECT_NAME . ".files_set_quota($quota);", 'docready'); return html::span($attrib, ''); } /** * Get API token for current user session, authenticate if needed */ public function get_api_token($configure = true) { - $token = $_SESSION['kolab_files_token']; - $time = $_SESSION['kolab_files_time']; + $token = $_SESSION['kolab_files_token'] ?? null; + $time = $_SESSION['kolab_files_time'] ?? null; if ($token && time() - $this->timeout < $time) { if (time() - $time <= $this->timeout / 2) { return $token; } } $request = $this->get_request(array('method' => 'ping'), $token); try { $url = $request->getUrl(); // Send ping request if ($token) { $url->setQueryVariables(array('method' => 'ping')); $request->setUrl($url); $response = $request->send(); $status = $response->getStatus(); if ($status == 200 && ($body = json_decode($response->getBody(), true))) { if ($body['status'] == 'OK') { $_SESSION['kolab_files_time'] = time(); return $token; } } } // Go with authenticate request $url->setQueryVariables(array('method' => 'authenticate', 'version' => self::API_VERSION)); $request->setUrl($url); $request->setAuth($this->rc->user->get_username(), $this->rc->decrypt($_SESSION['password'])); // Allow plugins (e.g. kolab_sso) to modify the request $this->rc->plugins->exec_hook('chwala_authenticate', array('request' => $request)); $response = $request->send(); $status = $response->getStatus(); if ($status == 200 && ($body = json_decode($response->getBody(), true))) { $token = $body['result']['token']; if ($token) { $_SESSION['kolab_files_token'] = $token; $_SESSION['kolab_files_time'] = time(); $_SESSION['kolab_files_caps'] = $body['result']['capabilities']; } } else { throw new Exception(sprintf("Authenticate error (Status: %d)", $status)); } // Configure session if ($configure && $token) { $this->configure($token); } } catch (Exception $e) { rcube::raise_error($e, true, false); } return $token; } protected function capabilities() { if (empty($_SESSION['kolab_files_caps'])) { $token = $this->get_api_token(); if (empty($_SESSION['kolab_files_caps'])) { $request = $this->get_request(array('method' => 'capabilities'), $token); // send request to the API try { $response = $request->send(); $status = $response->getStatus(); $body = @json_decode($response->getBody(), true); if ($status == 200 && $body['status'] == 'OK') { $_SESSION['kolab_files_caps'] = $body['result']; } else { throw new Exception($body['reason'] ?: "Failed to get capabilities. Status: $status"); } } catch (Exception $e) { rcube::raise_error($e, true, false); return array(); } } } - if ($_SESSION['kolab_files_caps']['MANTICORE'] || $_SESSION['kolab_files_caps']['WOPI']) { + if (($_SESSION['kolab_files_caps']['MANTICORE'] ?? false) || ($_SESSION['kolab_files_caps']['WOPI'] ?? false)) { $_SESSION['kolab_files_caps']['DOCEDIT'] = true; $_SESSION['kolab_files_caps']['DOCTYPE'] = $_SESSION['kolab_files_caps']['MANTICORE'] ? 'manticore' : 'wopi'; } if (!empty($_SESSION['kolab_files_caps']) && !isset($_SESSION['kolab_files_caps']['MOUNTPOINTS'])) { $_SESSION['kolab_files_caps']['MOUNTPOINTS'] = array(); } return $_SESSION['kolab_files_caps']; } /** * Initialize HTTP_Request object */ protected function get_request($get = null, $token = null) { $url = $this->url_srv . '/api/'; if (empty($this->request)) { $config = array( 'store_body' => true, 'follow_redirects' => true, ); $this->request = libkolab::http_request($url, 'GET', $config); } else { // cleanup try { $this->request->setBody(''); $this->request->setUrl($url); $this->request->setMethod(HTTP_Request2::METHOD_GET); } catch (Exception $e) { rcube::raise_error($e, true, true); } } if ($token) { $this->request->setHeader('X-Session-Token', $token); } if (!empty($get)) { $url = $this->request->getUrl(); $url->setQueryVariables($get); $this->request->setUrl($url); } // some HTTP server configurations require this header $this->request->setHeader('accept', "application/json,text/javascript,*/*"); // Localization $this->request->setHeader('accept-language', $_SESSION['language']); // set Referer which is used as an origin for cross-window // communication with document editor iframe $host = $_SERVER['REQUEST_SCHEME'] . '://' . $_SERVER['HTTP_HOST']; $this->request->setHeader('referer', $host); return $this->request; } /** * Configure chwala session */ public function configure($token = null, $prefs = array()) { if (!$token) { $token = $this->get_api_token(false); } try { // Configure session $query = array( 'method' => 'configure', - 'timezone' => $prefs['timezone'] ?: $this->rc->config->get('timezone'), - 'date_format' => $prefs['date_long'] ?: $this->rc->config->get('date_long', 'Y-m-d H:i'), + 'timezone' => $prefs['timezone'] ?? $this->rc->config->get('timezone'), + 'date_format' => $prefs['date_long'] ?? $this->rc->config->get('date_long', 'Y-m-d H:i'), ); $request = $this->get_request($query, $token); $response = $request->send(); $status = $response->getStatus(); if ($status != 200) { throw new Exception(sprintf("Failed to configure chwala session (Status: %d)", $status)); } } catch (Exception $e) { rcube::raise_error($e, true, false); } } /** * Handler for main files interface (Files task) */ protected function action_index() { $this->plugin->add_label( 'uploading', 'attaching', 'uploadsizeerror', 'filedeleting', 'filedeletenotice', 'filedeleteconfirm', 'filemoving', 'filemovenotice', 'filemoveconfirm', 'filecopying', 'filecopynotice', 'fileskip', 'fileskipall', 'fileoverwrite', 'fileoverwriteall' ); $this->folder_list_env(); if ($this->rc->task == 'files') { $this->rc->output->set_env('folder', rcube_utils::get_input_value('folder', rcube_utils::INPUT_GET)); $this->rc->output->set_env('collection', rcube_utils::get_input_value('collection', rcube_utils::INPUT_GET)); } $caps = $this->capabilities(); $this->rc->output->add_label('uploadprogress', 'GB', 'MB', 'KB', 'B'); $this->rc->output->set_pagetitle($this->plugin->gettext('files')); $this->rc->output->set_env('file_mimetypes', $this->get_mimetypes()); $this->rc->output->set_env('files_quota', $caps['QUOTA']); $this->rc->output->set_env('files_max_upload', $caps['MAX_UPLOAD']); $this->rc->output->set_env('files_progress_name', $caps['PROGRESS_NAME'] ?? null); $this->rc->output->set_env('files_progress_time', $caps['PROGRESS_TIME'] ?? null); $this->rc->output->send('kolab_files.files'); } /** * Handler for resetting some session/cached information */ protected function action_reset() { $this->rc->session->remove('kolab_files_caps'); if (($caps = $this->capabilities()) && !empty($caps)) { $this->rc->output->set_env('files_caps', $caps); } } /** * Handler for preferences save action */ protected function action_prefs() { $dont_override = (array)$this->rc->config->get('dont_override'); $prefs = array(); $type = rcube_utils::get_input_value('type', rcube_utils::INPUT_POST); $opts = array( 'kolab_files_sort_col' => true, 'kolab_files_sort_order' => true, 'kolab_files_list_cols' => false, ); foreach ($opts as $o => $sess) { if (isset($_POST[$o])) { $value = rcube_utils::get_input_value($o, rcube_utils::INPUT_POST); $session_key = $o; $config_key = $o; if ($type != 'files') { $config_key = str_replace('files', 'files_' . $type, $config_key); } if (in_array($config_key, $dont_override)) { continue; } if ($o == 'kolab_files_list_cols') { $update_list = true; } $prefs[$config_key] = $value; if ($sess) { $_SESSION[$session_key] = $prefs[$config_key]; } } } // save preference values if (!empty($prefs)) { $this->rc->user->save_prefs($prefs); } if (!empty($update_list)) { $this->list_update($prefs, $type); } $this->rc->output->send(); } /** * Handler for file open action */ protected function action_open() { $this->rc->output->set_env('file_mimetypes', $this->get_mimetypes()); $this->file_opener(intval($_GET['_viewer']) & ~4); } /** * Handler for file open action */ protected function action_edit() { $this->plugin->add_label('sessionterminating', 'unsavedchanges', 'documentinviting', 'documentcancelling', 'removeparticipant', 'sessionterminated', 'sessionterminatedtitle'); $this->file_opener(intval($_GET['_viewer'])); } /** * Handler for folder sharing action */ protected function action_share() { $this->rc->output->add_handler('share-form', array($this, 'folder_share_form')); $this->rc->output->send('kolab_files.share'); } /** * Handler for "save all attachments into cloud" action */ protected function action_save_file() { // $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_POST); $uid = rcube_utils::get_input_value('uid', rcube_utils::INPUT_POST); $dest = rcube_utils::get_input_value('dest', rcube_utils::INPUT_POST); $id = rcube_utils::get_input_value('id', rcube_utils::INPUT_POST); $name = rcube_utils::get_input_value('name', rcube_utils::INPUT_POST); $temp_dir = unslashify($this->rc->config->get('temp_dir')); $message = new rcube_message($uid); $request = $this->get_request(); $url = $request->getUrl(); $files = array(); $errors = array(); $attachments = array(); $request->setMethod(HTTP_Request2::METHOD_POST); $request->setHeader('X-Session-Token', $this->get_api_token()); $url->setQueryVariables(array('method' => 'file_upload', 'folder' => $dest)); $request->setUrl($url); foreach ($message->attachments as $attach_prop) { if (empty($id) || $id == $attach_prop->mime_id) { $filename = strlen($name) ? $name : rcmail_attachment_name($attach_prop, true); $attachments[$filename] = $attach_prop; } } // @TODO: handle error // @TODO: implement file upload using file URI instead of body upload foreach ($attachments as $attach_name => $attach_prop) { $path = tempnam($temp_dir, 'rcmAttmnt'); // save attachment to file if ($fp = fopen($path, 'w+')) { $message->get_part_body($attach_prop->mime_id, false, 0, $fp); } else { $errors[] = true; rcube::raise_error(array( 'code' => 500, 'type' => 'php', 'line' => __LINE__, 'file' => __FILE__, 'message' => "Unable to save attachment into file $path"), true, false); continue; } fclose($fp); // send request to the API try { $request->setBody(''); $request->addUpload('file[]', $path, $attach_name, $attach_prop->mimetype); $response = $request->send(); $status = $response->getStatus(); $body = @json_decode($response->getBody(), true); if ($status == 200 && $body['status'] == 'OK') { $files[] = $attach_name; } else { throw new Exception($body['reason'] ?: "Failed to post file_upload. Status: $status"); } } catch (Exception $e) { unlink($path); $errors[] = $e->getMessage(); rcube::raise_error(array( 'code' => 500, 'type' => 'php', 'line' => __LINE__, 'file' => __FILE__, 'message' => $e->getMessage()), true, false); continue; } // clean up unlink($path); $request->setBody(''); } if ($count = count($files)) { $msg = $this->plugin->gettext(array('name' => 'saveallnotice', 'vars' => array('n' => $count))); $this->rc->output->show_message($msg, 'confirmation'); } if ($count = count($errors)) { $msg = $this->plugin->gettext(array('name' => 'saveallerror', 'vars' => array('n' => $count))); $this->rc->output->show_message($msg, 'error'); } // @TODO: update quota indicator, make this optional in case files aren't stored in IMAP $this->rc->output->send(); } /** * Handler for "add attachments from the cloud" action */ protected function action_attach_file() { $files = rcube_utils::get_input_value('files', rcube_utils::INPUT_POST); $uploadid = rcube_utils::get_input_value('uploadid', rcube_utils::INPUT_POST); $COMPOSE_ID = rcube_utils::get_input_value('id', rcube_utils::INPUT_POST); $COMPOSE = null; $errors = array(); $attachments = array(); if ($this->rc->task == 'mail') { if ($COMPOSE_ID && $_SESSION['compose_data_'.$COMPOSE_ID]) { $COMPOSE =& $_SESSION['compose_data_'.$COMPOSE_ID]; } if (!$COMPOSE) { die("Invalid session var!"); } // attachment upload action if (!is_array($COMPOSE['attachments'])) { $COMPOSE['attachments'] = array(); } } // clear all stored output properties (like scripts and env vars) $this->rc->output->reset(); $temp_dir = unslashify($this->rc->config->get('temp_dir')); $request = $this->get_request(); $url = $request->getUrl(); // Use observer object to store HTTP response into a file require_once $this->plugin->home . DIRECTORY_SEPARATOR . 'lib' . DIRECTORY_SEPARATOR . 'kolab_files_observer.php'; $observer = new kolab_files_observer(); $request->setHeader('X-Session-Token', $this->get_api_token()); // download files from the API and attach them foreach ($files as $file) { // decode filename $file = urldecode($file); // get file information try { $url->setQueryVariables(array('method' => 'file_info', 'file' => $file)); $request->setUrl($url); $response = $request->send(); $status = $response->getStatus(); $body = @json_decode($response->getBody(), true); if ($status == 200 && $body['status'] == 'OK') { $file_params = $body['result']; } else { throw new Exception($body['reason'] ?: "Failed to get file_info. Status: $status"); } } catch (Exception $e) { $errors[] = $e->getMessage(); rcube::raise_error(array( 'code' => 500, 'type' => 'php', 'line' => __LINE__, 'file' => __FILE__, 'message' => $e->getMessage()), true, false); continue; } // set location of downloaded file $path = tempnam($temp_dir, 'rcmAttmnt'); $observer->set_file($path); // download file try { $url->setQueryVariables(array('method' => 'file_get', 'file' => $file)); $request->setUrl($url); $request->attach($observer); $response = $request->send(); $status = $response->getStatus(); $response->getBody(); // returns nothing $request->detach($observer); if ($status != 200 || !file_exists($path)) { throw new Exception("Unable to save file"); } } catch (Exception $e) { $errors[] = $e->getMessage(); rcube::raise_error(array( 'code' => 500, 'type' => 'php', 'line' => __LINE__, 'file' => __FILE__, 'message' => $e->getMessage()), true, false); continue; } $attachment = array( 'path' => $path, 'size' => $file_params['size'], 'name' => $file_params['name'], 'mimetype' => $file_params['type'], 'group' => $COMPOSE_ID, ); if ($this->rc->task != 'mail') { $attachments[] = $attachment; continue; } $attachment = $this->rc->plugins->exec_hook('attachment_save', $attachment); if ($attachment['status'] && !$attachment['abort']) { $this->compose_attach_success($attachment, $COMPOSE, $COMPOSE_ID, $uploadid); } else if ($attachment['error']) { $errors[] = $attachment['error']; } else { $errors[] = $this->plugin->gettext('attacherror'); } } if (!empty($errors)) { $this->rc->output->command('display_message', $this->plugin->gettext('attacherror'), 'error'); $this->rc->output->command('remove_from_attachment_list', $uploadid); } else if ($this->rc->task == 'calendar' || $this->rc->task == 'tasks') { // for uploads in events/tasks we'll use its standard upload handler, // for this we have to fake $_FILES and some other POST args foreach ($attachments as $attach) { $_FILES['_attachments']['tmp_name'][] = $attachment['path']; $_FILES['_attachments']['name'][] = $attachment['name']; $_FILES['_attachments']['size'][] = $attachment['size']; $_FILES['_attachments']['type'][] = $attachment['mimetype']; $_FILES['_attachments']['error'][] = null; } $_GET['_uploadid'] = $uploadid; $_GET['_id'] = $COMPOSE_ID; switch ($this->rc->task) { case 'tasks': $handler = new kolab_attachments_handler(); $handler->attachment_upload(tasklist::SESSION_KEY); break; case 'calendar': $handler = new kolab_attachments_handler(); $handler->attachment_upload(calendar::SESSION_KEY, 'cal-'); break; } } // send html page with JS calls as response $this->rc->output->command('auto_save_start', false); $this->rc->output->send(); } protected function compose_attach_success($attachment, $COMPOSE, $COMPOSE_ID, $uploadid) { $id = $attachment['id']; // store new attachment in session unset($attachment['data'], $attachment['status'], $attachment['abort']); $this->rc->session->append('compose_data_' . $COMPOSE_ID . '.attachments', $id, $attachment); if (($icon = $COMPOSE['deleteicon']) && is_file($icon)) { $button = html::img(array( 'src' => $icon, 'alt' => $this->rc->gettext('delete') )); } else if ($COMPOSE['textbuttons']) { $button = rcube::Q($this->rc->gettext('delete')); } else { $button = ''; } if (version_compare(version_parse(RCMAIL_VERSION), '1.3.0', '>=')) { $link_content = sprintf('%s (%s)', rcube::Q($attachment['name']), $this->rc->show_bytes($attachment['size'])); $content_link = html::a(array( 'href' => "#load", 'class' => 'filename', 'onclick' => sprintf("return %s.command('load-attachment','rcmfile%s', this, event)", rcmail_output::JS_OBJECT_NAME, $id), ), $link_content); $delete_link = html::a(array( 'href' => "#delete", 'onclick' => sprintf("return %s.command('remove-attachment','rcmfile%s', this, event)", rcmail_output::JS_OBJECT_NAME, $id), 'title' => $this->rc->gettext('delete'), 'class' => 'delete', 'aria-label' => $this->rc->gettext('delete') . ' ' . $attachment['name'], ), $button); $content = $COMPOSE['icon_pos'] == 'left' ? $delete_link.$content_link : $content_link.$delete_link; } else { $content = html::a(array( 'href' => "#delete", 'onclick' => sprintf("return %s.command('remove-attachment','rcmfile%s', this)", rcmail_output::JS_OBJECT_NAME, $id), 'title' => $this->rc->gettext('delete'), 'class' => 'delete', ), $button); $content .= rcube::Q($attachment['name']); } $this->rc->output->command('add2attachment_list', "rcmfile$id", array( 'html' => $content, 'name' => $attachment['name'], 'mimetype' => $attachment['mimetype'], 'classname' => rcube_utils::file2class($attachment['mimetype'], $attachment['name']), 'complete' => true), $uploadid); } /** * Handler for file open/edit action */ protected function file_opener($viewer) { $file = rcube_utils::get_input_value('_file', rcube_utils::INPUT_GET); $session = rcube_utils::get_input_value('_session', rcube_utils::INPUT_GET); // get file info $token = $this->get_api_token(); $request = $this->get_request(array( 'method' => 'file_info', 'file' => $file, 'viewer' => $viewer, 'session' => $session, ), $token); // send request to the API try { $response = $request->send(); $status = $response->getStatus(); $body = @json_decode($response->getBody(), true); if ($status == 200 && $body['status'] == 'OK') { $this->file_data = $body['result']; } else { throw new Exception($body['reason'] ?: "Failed to get file_info. Status: $status"); } } catch (Exception $e) { rcube::raise_error(array( 'code' => 500, 'type' => 'php', 'line' => __LINE__, 'file' => __FILE__, 'message' => $e->getMessage()), true, true); } if ($file === null || $file === '') { $file = $this->file_data['file']; } $this->file_data['filename'] = $file; $this->plugin->add_label('filedeleteconfirm', 'filedeleting', 'filedeletenotice', 'terminate'); // register template objects for dialogs (and main interface) $this->rc->output->add_handlers(array( 'fileinfobox' => array($this, 'file_info_box'), 'filepreviewframe' => array($this, 'file_preview_frame'), )); $placeholder = $this->rc->output->asset_url('program/resources/blank.gif'); - if ($this->file_data['viewer']['wopi']) { + $editor_type = null; + $got_editor = null; + if ($this->file_data['viewer']['wopi'] ?? false) { $editor_type = 'wopi'; $got_editor = ($viewer & 4); } - else if ($this->file_data['viewer']['manticore']) { + else if ($this->file_data['viewer']['manticore'] ?? false) { $editor_type = 'manticore'; $got_editor = ($viewer & 4); } // this one is for styling purpose $this->rc->output->set_env('extwin', true); $this->rc->output->set_env('file', $file); $this->rc->output->set_env('file_data', $this->file_data); $this->rc->output->set_env('mimetype', $this->file_data['type']); $this->rc->output->set_env('filename', pathinfo($file, PATHINFO_BASENAME)); $this->rc->output->set_env('editor_type', $editor_type); $this->rc->output->set_env('photo_placeholder', $placeholder); $this->rc->output->set_pagetitle(rcube::Q($file)); $this->rc->output->send('kolab_files.' . ($got_editor ? 'docedit' : 'filepreview')); } /** * Returns mimetypes supported by File API viewers */ protected function get_mimetypes($type = 'view') { $mimetypes = array(); // send request to the API try { if ($this->mimetypes === null) { $this->mimetypes = false; $token = $this->get_api_token(); $caps = $this->capabilities(); $request = $this->get_request(array('method' => 'mimetypes'), $token); $response = $request->send(); $status = $response->getStatus(); $body = @json_decode($response->getBody(), true); if ($status == 200 && $body['status'] == 'OK') { $this->mimetypes = $body['result']; } else { throw new Exception($body['reason'] ?: "Failed to get mimetypes. Status: $status"); } } if (is_array($this->mimetypes)) { if (array_key_exists($type, $this->mimetypes)) { $mimetypes = $this->mimetypes[$type]; } // fallback to static definition if old Chwala is used else if ($type == 'edit') { $mimetypes = array( 'text/plain' => 'txt', 'text/html' => 'html', ); if (!empty($caps['MANTICORE'])) { $mimetypes = array_merge(array('application/vnd.oasis.opendocument.text' => 'odt'), $mimetypes); } foreach (array_keys($mimetypes) as $type) { list ($app, $label) = explode('/', $type); $label = preg_replace('/[^a-z]/', '', $label); $mimetypes[$type] = array( 'ext' => $mimetypes[$type], 'label' => $this->plugin->gettext('type.' . $label), ); } } else { $mimetypes = $this->mimetypes; } } } catch (Exception $e) { rcube::raise_error(array( 'code' => 500, 'type' => 'php', 'line' => __LINE__, 'file' => __FILE__, 'message' => $e->getMessage()), true, false); } return $mimetypes; } /** * Get list of available external storage drivers */ protected function get_external_storage_drivers() { // first get configured sources from Chwala $token = $this->get_api_token(); $request = $this->get_request(array('method' => 'folder_types'), $token); // send request to the API try { $response = $request->send(); $status = $response->getStatus(); $body = @json_decode($response->getBody(), true); if ($status == 200 && $body['status'] == 'OK') { $sources = $body['result']; } else { throw new Exception($body['reason'] ?: "Failed to get folder_types. Status: $status"); } } catch (Exception $e) { rcube::raise_error($e, true, false); return; } $this->rc->output->set_env('external_sources', $sources); } /** * Get folder share dialog data */ protected function get_share_info($folder) { // first get configured sources from Chwala $token = $this->get_api_token(); $request = $this->get_request(array('method' => 'sharing', 'folder' => $folder), $token); // send request to the API try { $response = $request->send(); $status = $response->getStatus(); $body = @json_decode($response->getBody(), true); if ($status == 200 && $body['status'] == 'OK') { $info = $body['result']; } else if ($body['code'] == 530) { return false; } else { throw new Exception($body['reason'] ?: "Failed to get sharing form information. Status: $status"); } } catch (Exception $e) { rcube::raise_error($e, true, false); return; } return $info; } /** * Registers translation labels for folder lists in UI */ protected function folder_list_env() { // folder list and actions $this->plugin->add_label( 'folderdeleting', 'folderdeleteconfirm', 'folderdeletenotice', 'collection_audio', 'collection_video', 'collection_image', 'collection_document', 'additionalfolders', 'listpermanent', 'storageautherror' ); $this->rc->output->add_label('foldersubscribing', 'foldersubscribed', 'folderunsubscribing', 'folderunsubscribed', 'searching' ); } } diff --git a/plugins/kolab_folders/kolab_folders.php b/plugins/kolab_folders/kolab_folders.php index e68c436a..369bf26e 100644 --- a/plugins/kolab_folders/kolab_folders.php +++ b/plugins/kolab_folders/kolab_folders.php @@ -1,853 +1,853 @@ * * Copyright (C) 2011-2017, Kolab Systems AG * * 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 . */ class kolab_folders extends rcube_plugin { public $task = '?(?!login).*'; public $types = array('mail', 'event', 'journal', 'task', 'note', 'contact', 'configuration', 'file', 'freebusy'); public $subtypes = array( 'mail' => array('inbox', 'drafts', 'sentitems', 'outbox', 'wastebasket', 'junkemail'), 'event' => array('default'), 'task' => array('default'), 'journal' => array('default'), 'note' => array('default'), 'contact' => array('default'), 'configuration' => array('default'), 'file' => array('default'), 'freebusy' => array('default'), ); public $act_types = array('event', 'task'); private $rc; private static $instance; private $expire_annotation = '/shared/vendor/cmu/cyrus-imapd/expire'; private $is_processing = false; /** * Plugin initialization. */ function init() { self::$instance = $this; $this->rc = rcube::get_instance(); // load required plugin $this->require_plugin('libkolab'); // Folder listing hooks $this->add_hook('storage_folders', array($this, 'mailboxes_list')); // Folder manager hooks $this->add_hook('folder_form', array($this, 'folder_form')); $this->add_hook('folder_update', array($this, 'folder_save')); $this->add_hook('folder_create', array($this, 'folder_save')); $this->add_hook('folder_delete', array($this, 'folder_save')); $this->add_hook('folder_rename', array($this, 'folder_save')); $this->add_hook('folders_list', array($this, 'folders_list')); // Special folders setting $this->add_hook('preferences_save', array($this, 'prefs_save')); // ACL plugin hooks $this->add_hook('acl_rights_simple', array($this, 'acl_rights_simple')); $this->add_hook('acl_rights_supported', array($this, 'acl_rights_supported')); // Resolving other user folder names $this->add_hook('render_mailboxlist', array($this, 'render_folderlist')); $this->add_hook('render_folder_selector', array($this, 'render_folderlist')); $this->add_hook('folders_list', array($this, 'render_folderlist')); } /** * Handler for mailboxes_list hook. Enables type-aware lists filtering. */ function mailboxes_list($args) { // infinite loop prevention if ($this->is_processing) { return $args; } if (!$this->metadata_support()) { return $args; } $this->is_processing = true; // get folders $folders = kolab_storage::list_folders($args['root'], $args['name'], $args['filter'], $args['mode'] == 'LSUB', $folderdata); $this->is_processing = false; if (!is_array($folders)) { return $args; } // Create default folders if ($args['root'] == '' && $args['name'] = '*') { $this->create_default_folders($folders, $args['filter'], $folderdata, $args['mode'] == 'LSUB'); } $args['folders'] = $folders; return $args; } /** * Handler for folders_list hook. Add css classes to folder rows. */ function folders_list($args) { if (!$this->metadata_support()) { return $args; } // load translations $this->add_texts('localization/', false); // Add javascript script to the client $this->include_script('kolab_folders.js'); $this->add_label('folderctype'); foreach ($this->types as $type) { $this->add_label('foldertype' . $type); } $skip_namespace = $this->rc->config->get('kolab_skip_namespace'); $skip_roots = array(); if (!empty($skip_namespace)) { $storage = $this->rc->get_storage(); foreach ((array)$skip_namespace as $ns) { foreach((array)$storage->get_namespace($ns) as $root) { $skip_roots[] = rtrim($root[0], $root[1]); } } } $this->rc->output->set_env('skip_roots', $skip_roots); $this->rc->output->set_env('foldertypes', $this->types); // get folders types $folderdata = kolab_storage::folders_typedata(); if (!is_array($folderdata)) { return $args; } // Add type-based style for table rows // See kolab_folders::folder_class_name() if (!empty($args['table'])) { $table = $args['table']; for ($i=1, $cnt=$table->size(); $i<=$cnt; $i++) { $attrib = $table->get_row_attribs($i); $folder = $attrib['foldername']; // UTF7-IMAP $type = $folderdata[$folder]; if (!$type) { $type = 'mail'; } $class_name = self::folder_class_name($type); $attrib['class'] = trim($attrib['class'] . ' ' . $class_name); $table->set_row_attribs($attrib, $i); } } // Add type-based class for list items if (!empty($args['list']) && is_array($args['list'])) { foreach ($args['list'] as $k => $item) { $folder = $item['folder_imap']; // UTF7-IMAP $type = $folderdata[$folder] ?? null; if (!$type) { $type = 'mail'; } $class_name = self::folder_class_name($type); $args['list'][$k]['class'] = trim($item['class'] . ' ' . $class_name); } } return $args; } /** * Handler for folder info/edit form (folder_form hook). * Adds folder type selector. */ function folder_form($args) { if (!$this->metadata_support()) { return $args; } // load translations $this->add_texts('localization/', false); // INBOX folder is of type mail.inbox and this cannot be changed if ($args['name'] == 'INBOX') { $args['form']['props']['fieldsets']['settings']['content']['foldertype'] = array( 'label' => $this->gettext('folderctype'), 'value' => sprintf('%s (%s)', $this->gettext('foldertypemail'), $this->gettext('inbox')), ); $this->add_expire_input($args['form'], 'INBOX'); return $args; } if ($args['options']['is_root']) { return $args; } $mbox = strlen($args['name']) ? $args['name'] : $args['parent_name']; if (isset($_POST['_ctype'])) { $new_ctype = trim(rcube_utils::get_input_value('_ctype', rcube_utils::INPUT_POST)); $new_subtype = trim(rcube_utils::get_input_value('_subtype', rcube_utils::INPUT_POST)); } // Get type of the folder or the parent if (strlen($mbox)) { list($ctype, $subtype) = $this->get_folder_type($mbox); if (strlen($args['parent_name']) && $subtype == 'default') $subtype = ''; // there can be only one } if (!$ctype) { $ctype = 'mail'; } $storage = $this->rc->get_storage(); // Don't allow changing type of shared folder, according to ACL if (strlen($mbox)) { $options = $storage->folder_info($mbox); if ($options['namespace'] != 'personal' && !in_array('a', (array)$options['rights'])) { if (in_array($ctype, $this->types)) { $value = $this->gettext('foldertype'.$ctype); } else { $value = $ctype; } if ($subtype) { $value .= ' ('. ($subtype == 'default' ? $this->gettext('default') : $subtype) .')'; } $args['form']['props']['fieldsets']['settings']['content']['foldertype'] = array( 'label' => $this->gettext('folderctype'), 'value' => $value, ); return $args; } } // Add javascript script to the client $this->include_script('kolab_folders.js'); // build type SELECT fields $type_select = new html_select(array('name' => '_ctype', 'id' => '_folderctype', 'onchange' => "\$('[name=\"_expire\"]').attr('disabled', \$(this).val() != 'mail')" )); $sub_select = new html_select(array('name' => '_subtype', 'id' => '_subtype')); $sub_select->add('', ''); foreach ($this->types as $type) { $type_select->add($this->gettext('foldertype'.$type), $type); } // add non-supported type if (!in_array($ctype, $this->types)) { $type_select->add($ctype, $ctype); } $sub_types = array(); foreach ($this->subtypes as $ftype => $subtypes) { $sub_types[$ftype] = array_combine($subtypes, array_map(array($this, 'gettext'), $subtypes)); // fill options for the current folder type if ($ftype == $ctype || (isset($new_ctype) && $ftype == $new_ctype)) { $sub_select->add(array_values($sub_types[$ftype]), $subtypes); } } $args['form']['props']['fieldsets']['settings']['content']['folderctype'] = array( 'label' => $this->gettext('folderctype'), 'value' => html::div('input-group', $type_select->show(isset($new_ctype) ? $new_ctype : $ctype) . $sub_select->show(isset($new_subtype) ? $new_subtype : $subtype) ), ); $this->rc->output->set_env('kolab_folder_subtypes', $sub_types); $this->rc->output->set_env('kolab_folder_subtype', isset($new_subtype) ? $new_subtype : $subtype); $this->add_expire_input($args['form'], $args['name'], $ctype); return $args; } /** * Handler for folder update/create action (folder_update/folder_create hook). */ function folder_save($args) { // Folder actions from folders list if (empty($args['record'])) { return $args; } // Folder create/update with form $ctype = trim(rcube_utils::get_input_value('_ctype', rcube_utils::INPUT_POST)); $subtype = trim(rcube_utils::get_input_value('_subtype', rcube_utils::INPUT_POST)); $mbox = $args['record']['name']; - $old_mbox = $args['record']['oldname']; + $old_mbox = $args['record']['oldname'] ?? null; $subscribe = $args['record']['subscribe']; if (empty($ctype)) { return $args; } // load translations $this->add_texts('localization/', false); // Skip folder creation/rename in core // @TODO: Maybe we should provide folder_create_after and folder_update_after hooks? // Using create_mailbox/rename_mailbox here looks bad $args['abort'] = true; // There can be only one default folder of specified type if ($subtype == 'default') { $default = $this->get_default_folder($ctype); if ($default !== null && $old_mbox != $default) { $args['result'] = false; $args['message'] = $this->gettext('defaultfolderexists'); return $args; } } // Subtype sanity-checks else if ($subtype && (!($subtypes = $this->subtypes[$ctype]) || !in_array($subtype, $subtypes))) { $subtype = ''; } $ctype .= $subtype ? '.'.$subtype : ''; $storage = $this->rc->get_storage(); // Create folder if (!strlen($old_mbox)) { // By default don't subscribe to non-mail folders if ($subscribe) $subscribe = (bool) preg_match('/^mail/', $ctype); $result = $storage->create_folder($mbox, $subscribe); // Set folder type if ($result) { $this->set_folder_type($mbox, $ctype); } } // Rename folder else { if ($old_mbox != $mbox) { $result = $storage->rename_folder($old_mbox, $mbox); } else { $result = true; } if ($result) { list($oldtype, $oldsubtype) = $this->get_folder_type($mbox); $oldtype .= $oldsubtype ? '.'.$oldsubtype : ''; if ($ctype != $oldtype) { $this->set_folder_type($mbox, $ctype); } } } // Set messages expiration in days if ($result && isset($_POST['_expire'])) { $expire = trim(rcube_utils::get_input_value('_expire', rcube_utils::INPUT_POST)); $expire = intval($expire) && preg_match('/^mail/', $ctype) ? intval($expire) : null; $storage->set_metadata($mbox, array($this->expire_annotation => $expire)); } $args['record']['class'] = self::folder_class_name($ctype); $args['record']['subscribe'] = $subscribe; $args['result'] = $result; return $args; } /** * Handler for user preferences save (preferences_save hook) * * @param array $args Hash array with hook parameters * * @return array Hash array with modified hook parameters */ public function prefs_save($args) { if ($args['section'] != 'folders') { return $args; } $dont_override = (array) $this->rc->config->get('dont_override', array()); // map config option name to kolab folder type annotation $opts = array( 'drafts_mbox' => 'mail.drafts', 'sent_mbox' => 'mail.sentitems', 'junk_mbox' => 'mail.junkemail', 'trash_mbox' => 'mail.wastebasket', ); // check if any of special folders has been changed foreach ($opts as $opt_name => $type) { $new = $args['prefs'][$opt_name]; $old = $this->rc->config->get($opt_name); if (!strlen($new) || $new === $old || in_array($opt_name, $dont_override)) { unset($opts[$opt_name]); } } if (empty($opts)) { return $args; } $folderdata = kolab_storage::folders_typedata(); if (!is_array($folderdata)) { return $args; } foreach ($opts as $opt_name => $type) { $foldername = $args['prefs'][$opt_name]; // get all folders of specified type $folders = array_intersect($folderdata, array($type)); // folder already annotated with specified type if (!empty($folders[$foldername])) { continue; } // set type to the new folder $this->set_folder_type($foldername, $type); // unset old folder(s) type annotation list($maintype, $subtype) = explode('.', $type); foreach (array_keys($folders) as $folder) { $this->set_folder_type($folder, $maintype); } } return $args; } /** * Handler for ACL permissions listing (acl_rights_simple hook) * * This shall combine the write and delete permissions into one item for * groupware folders as updating groupware objects is an insert + delete operation. * * @param array $args Hash array with hook parameters * * @return array Hash array with modified hook parameters */ public function acl_rights_simple($args) { if ($args['folder']) { list($type,) = $this->get_folder_type($args['folder']); // we're dealing with a groupware folder here... if ($type && $type !== 'mail') { if ($args['rights']['write'] && $args['rights']['delete']) { $write_perms = $args['rights']['write'] . $args['rights']['delete']; $rw_perms = $write_perms . $args['rights']['read']; $args['rights']['write'] = $write_perms; $args['rights']['other'] = preg_replace("/[$rw_perms]/", '', $args['rights']['other']); // add localized labels and titles for the altered items $args['labels'] = array( 'other' => $this->rc->gettext('shortacla','acl'), ); $args['titles'] = array( 'other' => $this->rc->gettext('longaclother','acl'), ); } } } return $args; } /** * Handler for ACL permissions listing (acl_rights_supported hook) * * @param array $args Hash array with hook parameters * * @return array Hash array with modified hook parameters */ public function acl_rights_supported($args) { if ($args['folder']) { list($type,) = $this->get_folder_type($args['folder']); // we're dealing with a groupware folder here... if ($type && $type !== 'mail') { // remove some irrelevant (for groupware objects) rights $args['rights'] = str_split(preg_replace('/[p]/', '', join('', $args['rights']))); } } return $args; } /** * Checks if IMAP server supports any of METADATA, ANNOTATEMORE, ANNOTATEMORE2 * * @return boolean */ function metadata_support() { $storage = $this->rc->get_storage(); return $storage->get_capability('METADATA') || $storage->get_capability('ANNOTATEMORE') || $storage->get_capability('ANNOTATEMORE2'); } /** * Checks if IMAP server supports any of METADATA, ANNOTATEMORE, ANNOTATEMORE2 * * @param string $folder Folder name * * @return array Folder content-type */ function get_folder_type($folder) { return explode('.', (string)kolab_storage::folder_type($folder)); } /** * Sets folder content-type. * * @param string $folder Folder name * @param string $type Content type * * @return boolean True on success */ function set_folder_type($folder, $type = 'mail') { return kolab_storage::set_folder_type($folder, $type); } /** * Returns the name of default folder * * @param string $type Folder type * * @return string Folder name */ function get_default_folder($type) { $folderdata = kolab_storage::folders_typedata(); if (!is_array($folderdata)) { return null; } // get all folders of specified type $folderdata = array_intersect($folderdata, array($type.'.default')); return key($folderdata); } /** * Returns CSS class name for specified folder type * * @param string $type Folder type * * @return string Class name */ static function folder_class_name($type) { if ($type && strpos($type, '.')) { list($ctype, $subtype) = explode('.', $type); return 'type-' . $ctype . ' subtype-' . $subtype; } return 'type-' . ($type ? $type : 'mail'); } /** * Creates default folders if they doesn't exist */ private function create_default_folders(&$folders, $filter, $folderdata = null, $lsub = false) { $storage = $this->rc->get_storage(); $namespace = $storage->get_namespace(); $defaults = array(); $prefix = ''; // Find personal namespace prefix if (is_array($namespace['personal']) && count($namespace['personal']) == 1) { $prefix = $namespace['personal'][0][0]; } $this->load_config(); // get configured defaults foreach ($this->types as $type) { foreach ((array)$this->subtypes[$type] as $subtype) { $opt_name = 'kolab_folders_' . $type . '_' . $subtype; if ($folder = $this->rc->config->get($opt_name)) { // convert configuration value to UTF7-IMAP charset $folder = rcube_charset::convert($folder, RCUBE_CHARSET, 'UTF7-IMAP'); // and namespace prefix if needed if ($prefix && strpos($folder, $prefix) === false && $folder != 'INBOX') { $folder = $prefix . $folder; } $defaults[$type . '.' . $subtype] = $folder; } } } if (empty($defaults)) { return; } if ($folderdata === null) { $folderdata = kolab_storage::folders_typedata(); } if (!is_array($folderdata)) { return; } // find default folders foreach ($defaults as $type => $foldername) { // get all folders of specified type $_folders = array_intersect($folderdata, array($type)); // default folder found if (!empty($_folders)) { continue; } list($type1, $type2) = explode('.', $type); $activate = in_array($type1, $this->act_types); $exists = false; $result = false; // check if folder exists if (!empty($folderdata[$foldername]) || $foldername == 'INBOX') { $exists = true; } else if ((!$filter || $filter == $type1) && in_array($foldername, $folders)) { // this assumes also that subscribed folder exists $exists = true; } else { $exists = $storage->folder_exists($foldername); } // create folder if (!$exists) { $exists = $storage->create_folder($foldername); } // set type + subscribe + activate if ($exists) { if ($result = kolab_storage::set_folder_type($foldername, $type)) { // check if folder is subscribed if ((!$filter || $filter == $type1) && $lsub && in_array($foldername, $folders)) { // already subscribed $subscribed = true; } else { $subscribed = $storage->subscribe($foldername); } // activate folder if ($activate) { kolab_storage::folder_activate($foldername, true); } } } // add new folder to the result if ($result && (!$filter || $filter == $type1) && (!$lsub || $subscribed)) { $folders[] = $foldername; } } } /** * Static getter for default folder of the given type * * @param string $type Folder type * * @return string Folder name */ public static function default_folder($type) { return self::$instance->get_default_folder($type); } /** * Get /shared/vendor/cmu/cyrus-imapd/expire value * * @param string $folder IMAP folder name * * @return int|false The annotation value or False if not supported */ private function get_expire_annotation($folder) { $storage = $this->rc->get_storage(); if ($storage->get_vendor() != 'cyrus') { return false; } if (!strlen($folder)) { return 0; } $value = $storage->get_metadata($folder, $this->expire_annotation); if (is_array($value)) { return !empty($value[$folder]) ? intval($value[$folder][$this->expire_annotation] ?? 0) : 0; } return false; } /** * Add expiration time input to the form if supported */ private function add_expire_input(&$form, $folder, $type = null) { if (($expire = $this->get_expire_annotation($folder)) !== false) { $post = trim(rcube_utils::get_input_value('_expire', rcube_utils::INPUT_POST)); $is_mail = empty($type) || preg_match('/^mail/i', $type); $label = $this->gettext('xdays'); $input = new html_inputfield(array( 'id' => '_kolabexpire', 'name' => '_expire', 'size' => 3, 'disabled' => !$is_mail )); if ($post && $is_mail) { $expire = (int) $post; } if (strpos($label, '$') === 0) { $label = str_replace('$x', '', $label); $html = $input->show($expire ?: '') . html::span('input-group-append', html::span('input-group-text', rcube::Q($label))); } else { $label = str_replace('$x', '', $label); $html = html::span('input-group-prepend', html::span('input-group-text', rcube::Q($label))) . $input->show($expire ?: ''); } $form['props']['fieldsets']['settings']['content']['kolabexpire'] = array( 'label' => $this->gettext('folderexpire'), 'value' => html::div('input-group', $html), ); } } /** * Handler for various folders list widgets (hooks) * * @param array $args Hash array with hook parameters * * @return array Hash array with modified hook parameters */ public function render_folderlist($args) { $storage = $this->rc->get_storage(); $ns_other = $storage->get_namespace('other'); $is_fl = $this->rc->plugins->is_processing('folders_list'); foreach ((array) $ns_other as $root) { $delim = $root[1]; $prefix = rtrim($root[0], $delim); $length = strlen($prefix); if (!$length) { continue; } // folders_list hook mode if ($is_fl) { foreach ((array) $args['list'] as $folder_name => $folder) { if (strpos($folder_name, $root[0]) === 0 && !substr_count($folder_name, $root[1], $length+1)) { if ($name = kolab_storage::folder_id2user(substr($folder_name, $length+1), true)) { $old = $args['list'][$folder_name]['display']; $content = $args['list'][$folder_name]['content']; $name = rcube::Q($name); $content = str_replace(">$old<", ">$name<", $content); $args['list'][$folder_name]['display'] = $name; $args['list'][$folder_name]['content'] = $content; } } } // TODO: Re-sort the list } // render_* hooks mode else if (!empty($args['list'][$prefix]) && !empty($args['list'][$prefix]['folders'])) { $map = array(); foreach ($args['list'][$prefix]['folders'] as $folder_name => $folder) { if ($name = kolab_storage::folder_id2user($folder_name, true)) { $args['list'][$prefix]['folders'][$folder_name]['name'] = $name; } $map[$folder_name] = $name ?: $args['list'][$prefix]['folders'][$folder_name]['name']; } // Re-sort the list uasort($map, 'strcoll'); $args['list'][$prefix]['folders'] = array_replace($map, $args['list'][$prefix]['folders']); } } return $args; } } diff --git a/plugins/kolab_notes/kolab_notes.php b/plugins/kolab_notes/kolab_notes.php index b0674f86..994dcc9a 100644 --- a/plugins/kolab_notes/kolab_notes.php +++ b/plugins/kolab_notes/kolab_notes.php @@ -1,1485 +1,1485 @@ * * Copyright (C) 2014-2015, Kolab Systems AG * * 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 . */ class kolab_notes extends rcube_plugin { public $task = '?(?!login|logout).*'; public $allowed_prefs = array('kolab_notes_sort_col'); public $rc; private $ui; private $lists; private $folders; private $cache = array(); private $message_notes = array(); private $bonnie_api = false; /** * Required startup method of a Roundcube plugin */ public function init() { $this->require_plugin('libkolab'); $this->rc = rcube::get_instance(); // proceed initialization in startup hook $this->add_hook('startup', array($this, 'startup')); } /** * Startup hook */ public function startup($args) { // the notes module can be enabled/disabled by the kolab_auth plugin if ($this->rc->config->get('kolab_notes_disabled', false) || !$this->rc->config->get('kolab_notes_enabled', true)) { return; } $this->register_task('notes'); // load plugin configuration $this->load_config(); // load localizations $this->add_texts('localization/', $args['task'] == 'notes' && (!$args['action'] || $args['action'] == 'dialog-ui')); $this->rc->load_language($_SESSION['language'], array('notes.notes' => $this->gettext('navtitle'))); // add label for task title if ($args['task'] == 'notes') { $this->add_hook('storage_init', array($this, 'storage_init')); // register task actions $this->register_action('index', array($this, 'notes_view')); $this->register_action('fetch', array($this, 'notes_fetch')); $this->register_action('get', array($this, 'note_record')); $this->register_action('action', array($this, 'note_action')); $this->register_action('list', array($this, 'list_action')); $this->register_action('dialog-ui', array($this, 'dialog_view')); $this->register_action('print', array($this, 'print_note')); if (!$this->rc->output->ajax_call && in_array($args['action'], array('dialog-ui', 'list'))) { $this->load_ui(); } } else if ($args['task'] == 'mail') { $this->add_hook('storage_init', array($this, 'storage_init')); $this->add_hook('message_compose', array($this, 'mail_message_compose')); if (in_array($args['action'], array('show', 'preview', 'print'))) { $this->add_hook('message_load', array($this, 'mail_message_load')); $this->add_hook('template_object_messagebody', array($this, 'mail_messagebody_html')); } // add 'Append note' item to message menu - if ($this->api->output->type == 'html' && $_REQUEST['_rel'] != 'note') { + if ($this->api->output->type == 'html' && ($_REQUEST['_rel'] ?? null) != 'note') { $this->api->add_content(html::tag('li', array('role' => 'menuitem'), $this->api->output->button(array( 'command' => 'append-kolab-note', 'label' => 'kolab_notes.appendnote', 'type' => 'link', 'classact' => 'icon appendnote active', 'class' => 'icon appendnote disabled', 'innerclass' => 'icon note', ))), 'messagemenu'); $this->api->output->add_label('kolab_notes.appendnote', 'kolab_notes.editnote', 'kolab_notes.deletenotesconfirm', 'kolab_notes.entertitle', 'save', 'delete', 'cancel', 'close'); $this->include_script('notes_mail.js'); } } - if (!$this->rc->output->ajax_call && !$this->rc->output->env['framed']) { + if (!$this->rc->output->ajax_call && !($this->rc->output->env['framed'] ?? null)) { $this->load_ui(); } // get configuration for the Bonnie API $this->bonnie_api = libkolab::get_bonnie_api(); // notes use fully encoded identifiers kolab_storage::$encode_ids = true; } /** * Hook into IMAP FETCH HEADER.FIELDS command and request MESSAGE-ID */ public function storage_init($p) { $p['fetch_headers'] = trim($p['fetch_headers'] . ' MESSAGE-ID'); return $p; } /** * Load and initialize UI class */ private function load_ui() { if (!$this->ui) { require_once($this->home . '/kolab_notes_ui.php'); $this->ui = new kolab_notes_ui($this); $this->ui->init(); } } /** * Read available calendars for the current user and store them internally */ private function _read_lists($force = false) { // already read sources if (isset($this->lists) && !$force) return $this->lists; // get all folders that have type "task" $folders = kolab_storage::sort_folders(kolab_storage::get_folders('note')); $this->lists = $this->folders = array(); // find default folder $default_index = 0; foreach ($folders as $i => $folder) { if ($folder->default) $default_index = $i; } // put default folder on top of the list if ($default_index > 0) { $default_folder = $folders[$default_index]; unset($folders[$default_index]); array_unshift($folders, $default_folder); } foreach ($folders as $folder) { $item = $this->folder_props($folder); $this->lists[$item['id']] = $item; $this->folders[$item['id']] = $folder; $this->folders[$folder->name] = $folder; } } /** * Get a list of available folders from this source */ public function get_lists(&$tree = null) { $this->_read_lists(); // attempt to create a default folder for this user if (empty($this->lists)) { $folder = array('name' => 'Notes', 'type' => 'note', 'default' => true, 'subscribed' => true); if (kolab_storage::folder_update($folder)) { $this->_read_lists(true); } } $folders = array(); foreach ($this->lists as $id => $list) { if (!empty($this->folders[$id])) { $folders[] = $this->folders[$id]; } } // include virtual folders for a full folder tree if (!is_null($tree)) { $folders = kolab_storage::folder_hierarchy($folders, $tree); } $delim = $this->rc->get_storage()->get_hierarchy_delimiter(); $lists = array(); foreach ($folders as $folder) { $list_id = $folder->id; $imap_path = explode($delim, $folder->name); // find parent do { array_pop($imap_path); $parent_id = kolab_storage::folder_id(join($delim, $imap_path)); } while (count($imap_path) > 1 && !$this->folders[$parent_id]); // restore "real" parent ID if ($parent_id && !$this->folders[$parent_id]) { $parent_id = kolab_storage::folder_id($folder->get_parent()); } $fullname = $folder->get_name(); $listname = $folder->get_foldername(); // special handling for virtual folders if ($folder instanceof kolab_storage_folder_user) { $lists[$list_id] = array( 'id' => $list_id, 'name' => $fullname, 'listname' => $listname, 'title' => $folder->get_title(), 'virtual' => true, 'editable' => false, 'rights' => 'l', 'group' => 'other virtual', 'class' => 'user', 'parent' => $parent_id, ); } - else if ($folder->virtual) { + else if (!empty($folder->virtual)) { $lists[$list_id] = array( 'id' => $list_id, 'name' => $fullname, 'listname' => $listname, 'virtual' => true, 'editable' => false, 'rights' => 'l', 'group' => $folder->get_namespace(), 'parent' => $parent_id, ); } else { if (!$this->lists[$list_id]) { $this->lists[$list_id] = $this->folder_props($folder); $this->folders[$list_id] = $folder; } $this->lists[$list_id]['parent'] = $parent_id; $lists[$list_id] = $this->lists[$list_id]; } } return $lists; } /** * Search for shared or otherwise not listed folders the user has access * * @param string Search string * @param string Section/source to search * @return array List of notes folders */ protected function search_lists($query, $source) { if (!kolab_storage::setup()) { return array(); } $this->search_more_results = false; $this->lists = $this->folders = array(); // find unsubscribed IMAP folders that have "event" type if ($source == 'folders') { foreach ((array)kolab_storage::search_folders('note', $query, array('other')) as $folder) { $this->folders[$folder->id] = $folder; $this->lists[$folder->id] = $this->folder_props($folder); } } // search other user's namespace via LDAP else if ($source == 'users') { $limit = $this->rc->config->get('autocomplete_max', 15) * 2; // we have slightly more space, so display twice the number foreach (kolab_storage::search_users($query, 0, array(), $limit * 10) as $user) { $folders = array(); // search for note folders shared by this user foreach (kolab_storage::list_user_folders($user, 'note', false) as $foldername) { $folders[] = new kolab_storage_folder($foldername, 'note'); } if (count($folders)) { $userfolder = new kolab_storage_folder_user($user['kolabtargetfolder'], '', $user); $this->folders[$userfolder->id] = $userfolder; $this->lists[$userfolder->id] = $this->folder_props($userfolder); foreach ($folders as $folder) { $this->folders[$folder->id] = $folder; $this->lists[$folder->id] = $this->folder_props($folder); $count++; } } if ($count >= $limit) { $this->search_more_results = true; break; } } } return $this->get_lists(); } /** * Derive list properties from the given kolab_storage_folder object */ protected function folder_props($folder) { if ($folder->get_namespace() == 'personal') { $norename = false; $editable = true; $rights = 'lrswikxtea'; $alarms = true; } else { $alarms = false; $rights = 'lr'; $editable = false; if (($myrights = $folder->get_myrights()) && !PEAR::isError($myrights)) { $rights = $myrights; if (strpos($rights, 't') !== false || strpos($rights, 'd') !== false) $editable = strpos($rights, 'i'); } $info = $folder->get_folder_info(); $norename = $readonly || $info['norename'] || $info['protected']; } $list_id = $folder->id; return array( 'id' => $list_id, 'name' => $folder->get_name(), 'listname' => $folder->get_foldername(), 'editname' => $folder->get_foldername(), 'editable' => $editable, 'rights' => $rights, 'norename' => $norename, 'parentfolder' => $folder->get_parent(), 'subscribed' => (bool)$folder->is_subscribed(), 'default' => $folder->default, 'group' => $folder->default ? 'default' : $folder->get_namespace(), 'class' => trim($folder->get_namespace() . ($folder->default ? ' default' : '')), ); } /** * Get the kolab_calendar instance for the given calendar ID * * @param string List identifier (encoded imap folder name) * @return object kolab_storage_folder Object nor null if list doesn't exist */ public function get_folder($id) { // create list and folder instance if necesary if (!$this->lists[$id]) { $folder = kolab_storage::get_folder(kolab_storage::id_decode($id)); if ($folder->type) { $this->folders[$id] = $folder; $this->lists[$id] = $this->folder_props($folder); } } return $this->folders[$id]; } /******* UI functions ********/ /** * Render main view of the tasklist task */ public function notes_view() { $this->ui->init(); $this->ui->init_templates(); $this->rc->output->set_pagetitle($this->gettext('navtitle')); $this->rc->output->send('kolab_notes.notes'); } /** * Deliver a rediced UI for inline (dialog) */ public function dialog_view() { // resolve message reference if ($msgref = rcube_utils::get_input_value('_msg', rcube_utils::INPUT_GPC, true)) { $storage = $this->rc->get_storage(); list($uid, $folder) = explode('-', $msgref, 2); if ($message = $storage->get_message_headers($msgref)) { $this->rc->output->set_env('kolab_notes_template', array( '_from_mail' => true, 'title' => $message->get('subject'), 'links' => array(kolab_storage_config::get_message_reference( kolab_storage_config::get_message_uri($message, $folder), 'note' )), )); } } $this->ui->init_templates(); $this->rc->output->send('kolab_notes.dialogview'); } /** * Handler to retrieve note records for the given list and/or search query */ public function notes_fetch() { $search = rcube_utils::get_input_value('_q', rcube_utils::INPUT_GPC, true); $list = rcube_utils::get_input_value('_list', rcube_utils::INPUT_GPC); $data = $this->notes_data($this->list_notes($list, $search), $tags); $this->rc->output->command('plugin.data_ready', array( 'list' => $list, 'search' => $search, 'data' => $data, 'tags' => array_values($tags) )); } /** * Convert the given note records for delivery to the client */ protected function notes_data($records, &$tags) { $config = kolab_storage_config::get_instance(); $tags = $config->apply_tags($records); $config->apply_links($records); foreach ($records as $i => $rec) { unset($records[$i]['description']); $this->_client_encode($records[$i]); } return $records; } /** * Read note records for the given list from the storage backend */ protected function list_notes($list_id, $search = null) { $results = array(); // query Kolab storage $query = array(); // full text search (only works with cache enabled) if (strlen($search)) { $words = array_filter(rcube_utils::normalize_string(mb_strtolower($search), true)); foreach ($words as $word) { if (strlen($word) > 2) { // only words > 3 chars are stored in DB $query[] = array('words', '~', $word); } } } $this->_read_lists(); if ($folder = $this->get_folder($list_id)) { foreach ($folder->select($query, empty($query)) as $record) { // post-filter search results if (strlen($search)) { $matches = 0; $contents = mb_strtolower( $record['title'] . ($this->is_html($record) ? strip_tags($record['description']) : $record['description']) ); foreach ($words as $word) { if (mb_strpos($contents, $word) !== false) { $matches++; } } // skip records not matching all search words if ($matches < count($words)) { continue; } } $record['list'] = $list_id; $results[] = $record; } } return $results; } /** * Handler for delivering a full note record to the client */ public function note_record() { $data = $this->get_note(array( 'uid' => rcube_utils::get_input_value('_id', rcube_utils::INPUT_GPC), 'list' => rcube_utils::get_input_value('_list', rcube_utils::INPUT_GPC), )); // encode for client use if (is_array($data)) { $this->_client_encode($data); } $this->rc->output->command('plugin.render_note', $data); } /** * Get the full note record identified by the given UID + Lolder identifier */ public function get_note($note) { if (is_array($note)) { $uid = $note['uid'] ?: $note['id']; $list_id = $note['list']; } else { $uid = $note; } // deliver from in-memory cache $key = $list_id . ':' . $uid; if (!empty($this->cache[$key])) { return $this->cache[$key]; } $result = false; $this->_read_lists(); if ($list_id) { if ($folder = $this->get_folder($list_id)) { $result = $folder->get_object($uid); } } // iterate over all calendar folders and search for the event ID else { foreach ($this->folders as $list_id => $folder) { if ($result = $folder->get_object($uid)) { $result['list'] = $list_id; break; } } } if ($result) { // get note tags $result['tags'] = $this->get_tags($result['uid']); // get note links $result['links'] = $this->get_links($result['uid']); } return $result; } /** * Helper method to encode the given note record for use in the client */ private function _client_encode(&$note) { foreach ($note as $key => $prop) { if ($key[0] == '_' || $key == 'x-custom') { unset($note[$key]); } } foreach (array('created','changed') as $key) { if (is_object($note[$key]) && $note[$key] instanceof DateTime) { $note[$key.'_'] = $note[$key]->format('U'); $note[$key] = $this->rc->format_date($note[$key]); } } // clean HTML contents if (!empty($note['description']) && $this->is_html($note)) { $note['html'] = $this->_wash_html($note['description']); } // convert link URIs references into structs if (array_key_exists('links', $note)) { foreach ((array)$note['links'] as $i => $link) { if (strpos($link, 'imap://') === 0 && ($msgref = kolab_storage_config::get_message_reference($link, 'note'))) { $note['links'][$i] = $msgref; } } } return $note; } /** * Handler for client-initiated actions on a single note record */ public function note_action() { $action = rcube_utils::get_input_value('_do', rcube_utils::INPUT_POST); $note = rcube_utils::get_input_value('_data', rcube_utils::INPUT_POST, true); $success = $silent = false; switch ($action) { case 'new': case 'edit': if ($success = $this->save_note($note)) { $refresh = $this->get_note($note); } break; case 'move': $uids = explode(',', $note['uid']); foreach ($uids as $uid) { $note['uid'] = $uid; if (!($success = $this->move_note($note, $note['to']))) { $refresh = $this->get_note($note); break; } } break; case 'delete': $uids = explode(',', $note['uid']); foreach ($uids as $uid) { $note['uid'] = $uid; if (!($success = $this->delete_note($note))) { $refresh = $this->get_note($note); break; } } break; case 'changelog': $data = $this->get_changelog($note); if (is_array($data) && !empty($data)) { $rcmail = $this->rc; $dtformat = $rcmail->config->get('date_format') . ' ' . $this->rc->config->get('time_format'); array_walk($data, function(&$change) use ($lib, $rcmail, $dtformat) { if ($change['date']) { $dt = rcube_utils::anytodatetime($change['date']); if ($dt instanceof DateTime) { $change['date'] = $rcmail->format_date($dt, $dtformat); } } }); $this->rc->output->command('plugin.note_render_changelog', $data); } else { $this->rc->output->command('plugin.note_render_changelog', false); } $silent = true; break; case 'diff': $silent = true; $data = $this->get_diff($note, $note['rev1'], $note['rev2']); if (is_array($data)) { $this->rc->output->command('plugin.note_show_diff', $data); } else { $this->rc->output->command('display_message', $this->gettext('objectdiffnotavailable'), 'error'); } break; case 'show': if ($rec = $this->get_revison($note, $note['rev'])) { $this->rc->output->command('plugin.note_show_revision', $this->_client_encode($rec)); } else { $this->rc->output->command('display_message', $this->gettext('objectnotfound'), 'error'); } $silent = true; break; case 'restore': if ($this->restore_revision($note, $note['rev'])) { $refresh = $this->get_note($note); $this->rc->output->command('display_message', $this->gettext(array('name' => 'objectrestoresuccess', 'vars' => array('rev' => $note['rev']))), 'confirmation'); $this->rc->output->command('plugin.close_history_dialog'); } else { $this->rc->output->command('display_message', $this->gettext('objectrestoreerror'), 'error'); } $silent = true; break; } // show confirmation/error message if ($success) { $this->rc->output->show_message('successfullysaved', 'confirmation'); } else if (!$silent) { $this->rc->output->show_message('errorsaving', 'error'); } // unlock client $this->rc->output->command('plugin.unlock_saving'); if ($refresh) { $this->rc->output->command('plugin.update_note', $this->_client_encode($refresh)); } } /** * Update an note record with the given data * * @param array Hash array with note properties (id, list) * @return boolean True on success, False on error */ private function save_note(&$note) { $this->_read_lists(); $list_id = $note['list']; if (!$list_id || !($folder = $this->get_folder($list_id))) return false; // moved from another folder if (!empty($note['_fromlist']) && ($fromfolder = $this->get_folder($note['_fromlist']))) { if (!$fromfolder->move($note['uid'], $folder->name)) return false; unset($note['_fromlist']); } // load previous version of this record to merge $old = null; if (!empty($note['uid'])) { $old = $folder->get_object($note['uid']); if (!$old || PEAR::isError($old)) return false; // merge existing properties if the update isn't complete if (!isset($note['title']) || !isset($note['description'])) $note += $old; } // generate new note object from input $object = $this->_write_preprocess($note, $old); // email links and tags are handled separately $links = $object['links'] ?? null; $tags = $object['tags'] ?? null; unset($object['links']); unset($object['tags']); $saved = $folder->save($object, 'note', $note['uid']); if (!$saved) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error saving note object to Kolab server"), true, false); $saved = false; } else { // save links in configuration.relation object $this->save_links($object['uid'], $links); // save tags in configuration.relation object $this->save_tags($object['uid'], $tags); $note = $object; $note['list'] = $list_id; $note['tags'] = (array) $tags; // cache this in memory for later read $key = $list_id . ':' . $note['uid']; $this->cache[$key] = $note; } return $saved; } /** * Move the given note to another folder */ function move_note($note, $list_id) { $this->_read_lists(); $tofolder = $this->get_folder($list_id); $fromfolder = $this->get_folder($note['list']); if ($fromfolder && $tofolder) { return $fromfolder->move($note['uid'], $tofolder->name); } return false; } /** * Remove a single note record from the backend * * @param array Hash array with note properties (id, list) * @param boolean Remove record irreversible (mark as deleted otherwise) * @return boolean True on success, False on error */ public function delete_note($note, $force = true) { $this->_read_lists(); $list_id = $note['list']; if (!$list_id || !($folder = $this->get_folder($list_id))) { return false; } $status = $folder->delete($note['uid'], $force); if ($status) { $this->save_links($note['uid'], null); $this->save_tags($note['uid'], null); } return $status; } /** * Render the template for printing with placeholders */ public function print_note() { $uid = rcube_utils::get_input_value('_uid', rcube_utils::INPUT_GET); $list = rcube_utils::get_input_value('_list', rcube_utils::INPUT_GET); $this->note = $this->get_note(array('uid' => $uid, 'list' => $list)); // encode for client use if (is_array($this->note)) { $this->_client_encode($this->note); } $this->rc->output->set_pagetitle($this->note['title']); $this->rc->output->add_handlers(array( 'noteheader' => array($this, 'print_note_header'), 'notebody' => array($this, 'print_note_body'), )); $this->include_script('notes.js'); $this->rc->output->send('kolab_notes.print'); } public function print_note_header() { $tags = array_map(array('rcube', 'Q'), (array) $this->note['tags']); $tags = implode(' ', $tags); return html::tag('h1', array('id' => 'notetitle'), rcube::Q($this->note['title'])) . html::div(array('id' => 'notetags', 'class' => 'tagline'), $tags) . html::div('dates', html::label(null, rcube::Q($this->gettext('created'))) . html::span(array('id' => 'notecreated'), rcube::Q($this->note['created'])) . html::label(null, rcube::Q($this->gettext('changed'))) . html::span(array('id' => 'notechanged'), rcube::Q($this->note['changed'])) ); } public function print_note_body() { return isset($this->note['html']) ? $this->note['html'] : rcube::Q($this->note['description']); } /** * Provide a list of revisions for the given object * * @param array $note Hash array with note properties * @return array List of changes, each as a hash array */ public function get_changelog($note) { if (empty($this->bonnie_api)) { return false; } list($uid, $mailbox, $msguid) = $this->_resolve_note_identity($note); $result = $uid && $mailbox ? $this->bonnie_api->changelog('note', $uid, $mailbox, $msguid) : null; if (is_array($result) && $result['uid'] == $uid) { return $result['changes']; } return false; } /** * Return full data of a specific revision of a note record * * @param mixed $note UID string or hash array with note properties * @param mixed $rev Revision number * * @return array Note object as hash array */ public function get_revison($note, $rev) { if (empty($this->bonnie_api)) { return false; } list($uid, $mailbox, $msguid) = $this->_resolve_note_identity($note); // call Bonnie API $result = $this->bonnie_api->get('note', $uid, $rev, $mailbox, $msguid); if (is_array($result) && $result['uid'] == $uid && !empty($result['xml'])) { $format = kolab_format::factory('note'); $format->load($result['xml']); $rec = $format->to_array(); if ($format->is_valid()) { $rec['rev'] = $result['rev']; return $rec; } } return false; } /** * Get a list of property changes beteen two revisions of a note object * * @param array $$note Hash array with note properties * @param mixed $rev Revisions: "from:to" * * @return array List of property changes, each as a hash array */ public function get_diff($note, $rev1, $rev2) { if (empty($this->bonnie_api)) { return false; } list($uid, $mailbox, $msguid) = $this->_resolve_note_identity($note); // call Bonnie API $result = $this->bonnie_api->diff('note', $uid, $rev1, $rev2, $mailbox, $msguid); if (is_array($result) && $result['uid'] == $uid) { $result['rev1'] = $rev1; $result['rev2'] = $rev2; // convert some properties, similar to self::_client_encode() $keymap = array( 'summary' => 'title', 'lastmodified-date' => 'changed', ); // map kolab object properties to keys and values the client expects array_walk($result['changes'], function(&$change, $i) use ($keymap) { if (array_key_exists($change['property'], $keymap)) { $change['property'] = $keymap[$change['property']]; } if ($change['property'] == 'created' || $change['property'] == 'changed') { if ($old_ = rcube_utils::anytodatetime($change['old'])) { $change['old_'] = $this->rc->format_date($old_); } if ($new_ = rcube_utils::anytodatetime($change['new'])) { $change['new_'] = $this->rc->format_date($new_); } } // compute a nice diff of note contents if ($change['property'] == 'description') { $change['diff_'] = libkolab::html_diff($change['old'], $change['new']); if (!empty($change['diff_'])) { unset($change['old'], $change['new']); $change['diff_'] = preg_replace(array('!^.*]*>!Uims','!.*$!Uims'), '', $change['diff_']); $change['diff_'] = preg_replace("!\n!", '', $change['diff_']); } } }); return $result; } return false; } /** * Command the backend to restore a certain revision of a note. * This shall replace the current object with an older version. * * @param array $note Hash array with note properties (id, list) * @param mixed $rev Revision number * * @return boolean True on success, False on failure */ public function restore_revision($note, $rev) { if (empty($this->bonnie_api)) { return false; } list($uid, $mailbox, $msguid) = $this->_resolve_note_identity($note); $folder = $this->get_folder($note['list']); $success = false; if ($folder && ($raw_msg = $this->bonnie_api->rawdata('note', $uid, $rev, $mailbox))) { $imap = $this->rc->get_storage(); // insert $raw_msg as new message if ($imap->save_message($folder->name, $raw_msg, null, false)) { $success = true; // delete old revision from imap and cache $imap->delete_message($msguid, $folder->name); $folder->cache->set($msguid, false); $this->cache = array(); } } return $success; } /** * Helper method to resolved the given note identifier into uid and mailbox * * @return array (uid,mailbox,msguid) tuple */ private function _resolve_note_identity($note) { $mailbox = $msguid = null; if (!is_array($note)) { $note = $this->get_note($note); } if (is_array($note)) { $uid = $note['uid'] ?: $note['id']; $list = $note['list']; } else { return array(null, $mailbox, $msguid); } if ($folder = $this->get_folder($list)) { $mailbox = $folder->get_mailbox_id(); // get object from storage in order to get the real object uid an msguid if ($rec = $folder->get_object($uid)) { $msguid = $rec['_msguid']; $uid = $rec['uid']; } } return array($uid, $mailbox, $msguid); } /** * Handler for client requests to list (aka folder) actions */ public function list_action() { $action = rcube_utils::get_input_value('_do', rcube_utils::INPUT_GPC); $list = rcube_utils::get_input_value('_list', rcube_utils::INPUT_GPC, true); $success = $update_cmd = false; if (empty($action)) { $action = rcube_utils::get_input_value('action', rcube_utils::INPUT_GPC); } switch ($action) { case 'form-new': case 'form-edit': $this->_read_lists(); $this->ui->list_editform($action, $this->lists[$list['id']], $this->folders[$list['id']]); exit; case 'new': $list['type'] = 'note'; $list['subscribed'] = true; $folder = kolab_storage::folder_update($list); if ($folder === false) { $save_error = $this->gettext(kolab_storage::$last_error); } else { $success = true; $update_cmd = 'plugin.update_list'; $list['id'] = kolab_storage::folder_id($folder); $list['_reload'] = true; } break; case 'edit': $this->_read_lists(); $oldparent = $this->lists[$list['id']]['parentfolder']; $newfolder = kolab_storage::folder_update($list); if ($newfolder === false) { $save_error = $this->gettext(kolab_storage::$last_error); } else { $success = true; $update_cmd = 'plugin.update_list'; $list['newid'] = kolab_storage::folder_id($newfolder); $list['_reload'] = $list['parent'] != $oldparent; // compose the new display name $delim = $this->rc->get_storage()->get_hierarchy_delimiter(); $path_imap = explode($delim, $newfolder); $list['name'] = kolab_storage::object_name($newfolder); $list['editname'] = rcube_charset::convert(array_pop($path_imap), 'UTF7-IMAP'); $list['listname'] = $list['editname']; } break; case 'delete': $this->_read_lists(); $folder = $this->get_folder($list['id']); if ($folder && kolab_storage::folder_delete($folder->name)) { $success = true; $update_cmd = 'plugin.destroy_list'; } else { $save_error = $this->gettext(kolab_storage::$last_error); } break; case 'search': $this->load_ui(); $results = array(); foreach ((array)$this->search_lists(rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC), rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC)) as $id => $prop) { $editname = $prop['editname']; unset($prop['editname']); // force full name to be displayed // let the UI generate HTML and CSS representation for this calendar $html = $this->ui->folder_list_item($id, $prop, $jsenv, true); $prop += (array)$jsenv[$id]; $prop['editname'] = $editname; $prop['html'] = $html; $results[] = $prop; } // report more results available if ($this->driver->search_more_results) { $this->rc->output->show_message('autocompletemore', 'notice'); } $this->rc->output->command('multi_thread_http_response', $results, rcube_utils::get_input_value('_reqid', rcube_utils::INPUT_GPC)); return; case 'subscribe': $success = false; if ($list['id'] && ($folder = $this->get_folder($list['id']))) { if (isset($list['permanent'])) $success |= $folder->subscribe(intval($list['permanent'])); if (isset($list['active'])) $success |= $folder->activate(intval($list['active'])); // apply to child folders, too if ($list['recursive']) { foreach ((array)kolab_storage::list_folders($folder->name, '*', 'node') as $subfolder) { if (isset($list['permanent'])) ($list['permanent'] ? kolab_storage::folder_subscribe($subfolder) : kolab_storage::folder_unsubscribe($subfolder)); if (isset($list['active'])) ($list['active'] ? kolab_storage::folder_activate($subfolder) : kolab_storage::folder_deactivate($subfolder)); } } } break; } $this->rc->output->command('plugin.unlock_saving'); if ($success) { $this->rc->output->show_message('successfullysaved', 'confirmation'); if ($update_cmd) { $this->rc->output->command($update_cmd, $list); } } else { $error_msg = $this->gettext('errorsaving') . ($save_error ? ': ' . $save_error :''); $this->rc->output->show_message($error_msg, 'error'); } } /** * Hook to add note attachments to message compose if the according parameter is present. * This completes the 'send note by mail' feature. */ public function mail_message_compose($args) { if (!empty($args['param']['with_notes'])) { $uids = explode(',', $args['param']['with_notes']); $list = $args['param']['notes_list']; foreach ($uids as $uid) { if ($note = $this->get_note(array('uid' => $uid, 'list' => $list))) { $data = $this->note2message($note); $args['attachments'][] = array( 'name' => abbreviate_string($note['title'], 50, ''), 'mimetype' => 'message/rfc822', 'data' => $data, 'size' => strlen($data), ); if (empty($args['param']['subject'])) { $args['param']['subject'] = $note['title']; } } } unset($args['param']['with_notes'], $args['param']['notes_list']); } return $args; } /** * Lookup backend storage and find notes associated with the given message */ public function mail_message_load($p) { if (!$p['object']->headers->others['x-kolab-type']) { $this->message_notes = $this->get_message_notes($p['object']->headers, $p['object']->folder); } } /** * Handler for 'messagebody_html' hook */ public function mail_messagebody_html($args) { $html = ''; foreach ($this->message_notes as $note) { $html .= html::a(array( 'href' => $this->rc->url(array('task' => 'notes', '_list' => $note['list'], '_id' => $note['uid'])), 'class' => 'kolabnotesref', 'rel' => $note['uid'] . '@' . $note['list'], 'target' => '_blank', ), rcube::Q($note['title'])); } // prepend note links to message body if ($html) { $this->load_ui(); $args['content'] = html::div('kolabmessagenotes boxinformation', $html) . $args['content']; } return $args; } /** * Determine whether the given note is HTML formatted */ private function is_html($note) { // check for opening and closing or tags return (preg_match('/<(html|body)(\s+[a-z]|>)/', $note['description'], $m) && strpos($note['description'], '') > 0); } /** * Build an RFC 822 message from the given note */ private function note2message($note) { $message = new Mail_mime("\r\n"); $message->setParam('text_encoding', '8bit'); $message->setParam('html_encoding', 'quoted-printable'); $message->setParam('head_encoding', 'quoted-printable'); $message->setParam('head_charset', RCUBE_CHARSET); $message->setParam('html_charset', RCUBE_CHARSET); $message->setParam('text_charset', RCUBE_CHARSET); $message->headers(array( 'Subject' => $note['title'], 'Date' => $note['changed']->format('r'), )); if ($this->is_html($note)) { $message->setHTMLBody($note['description']); // add a plain text version of the note content as an alternative part. $h2t = new rcube_html2text($note['description'], false, true, 0, RCUBE_CHARSET); $plain_part = rcube_mime::wordwrap($h2t->get_text(), $this->rc->config->get('line_length', 72), "\r\n", false, RCUBE_CHARSET); $plain_part = trim(wordwrap($plain_part, 998, "\r\n", true)); // make sure all line endings are CRLF $plain_part = preg_replace('/\r?\n/', "\r\n", $plain_part); $message->setTXTBody($plain_part); } else { $message->setTXTBody($note['description']); } return $message->getMessage(); } private function save_links($uid, $links) { $config = kolab_storage_config::get_instance(); return $config->save_object_links($uid, (array) $links); } /** * Find messages assigned to specified note */ private function get_links($uid) { $config = kolab_storage_config::get_instance(); return $config->get_object_links($uid); } /** * Get note tags */ private function get_tags($uid) { $config = kolab_storage_config::get_instance(); $tags = $config->get_tags($uid); $tags = array_map(function($v) { return $v['name']; }, $tags); return $tags; } /** * Find notes assigned to specified message */ private function get_message_notes($message, $folder) { $config = kolab_storage_config::get_instance(); $result = $config->get_message_relations($message, $folder, 'note'); foreach ($result as $idx => $note) { $result[$idx]['list'] = kolab_storage::folder_id($note['_mailbox']); } return $result; } /** * Update note tags */ private function save_tags($uid, $tags) { $config = kolab_storage_config::get_instance(); $config->save_tags($uid, $tags); } /** * Process the given note data (submitted by the client) before saving it */ private function _write_preprocess($note, $old = array()) { $object = $note; // TODO: handle attachments // convert link references into simple URIs if (array_key_exists('links', $note)) { $object['links'] = array_map(function($link){ return is_array($link) ? $link['uri'] : strval($link); }, $note['links']); } else { if ($old) { $object['links'] = $old['links'] ?? null; } } // clean up HTML content $object['description'] = $this->_wash_html($note['description']); $is_html = true; // try to be smart and convert to plain-text if no real formatting is detected if (preg_match('!<(?:p|pre)>(.*)!Uims', $object['description'], $m)) { if (!preg_match('!<(a|b|i|strong|em|p|span|div|pre|li|img)(\s+[a-z]|>)!im', $m[1], $n) || ($n[1] != 'img' && !strpos($m[1], '')) ) { // $converter = new rcube_html2text($m[1], false, true, 0); // $object['description'] = rtrim($converter->get_text()); $object['description'] = html_entity_decode(preg_replace('!!', "\n", $m[1])); $is_html = false; } } // Add proper HTML header, otherwise Kontact renders it as plain text if ($is_html) { $object['description'] = ''."\n" . str_replace('', '', $object['description']); } // copy meta data (starting with _) from old object foreach ((array)$old as $key => $val) { if (!isset($object[$key]) && $key[0] == '_') $object[$key] = $val; } // make list of categories unique if (!empty($object['tags'])) { $object['tags'] = array_unique(array_filter($object['tags'])); } unset($object['list'], $object['tempid'], $object['created'], $object['changed'], $object['created_'], $object['changed_']); return $object; } /** * Sanity checks/cleanups HTML content */ private function _wash_html($html) { // Add header with charset spec., washtml cannot work without that $html = '' . '' . '' . $html . ''; // clean HTML with washtml by Frederic Motte $wash_opts = array( 'show_washed' => false, 'allow_remote' => 1, 'charset' => RCUBE_CHARSET, 'html_elements' => array('html', 'head', 'meta', 'body', 'link'), 'html_attribs' => array('rel', 'type', 'name', 'http-equiv'), ); // initialize HTML washer $washer = new rcube_washtml($wash_opts); $washer->add_callback('form', array($this, '_washtml_callback')); $washer->add_callback('a', array($this, '_washtml_callback')); // Remove non-UTF8 characters $html = rcube_charset::clean($html); $html = $washer->wash($html); // remove unwanted comments (produced by washtml) $html = preg_replace('//', '', $html); return $html; } /** * Callback function for washtml cleaning class */ public function _washtml_callback($tagname, $attrib, $content, $washtml) { switch ($tagname) { case 'form': $out = html::div('form', $content); break; case 'a': // strip temporary link tags from plain-text markup $attrib = html::parse_attrib_string($attrib); if (!empty($attrib['class']) && strpos($attrib['class'], 'x-templink') !== false) { // remove link entirely if (strpos($attrib['href'], html_entity_decode($content)) !== false) { $out = $content; break; } $attrib['class'] = trim(str_replace('x-templink', '', $attrib['class'])); } $out = html::a($attrib, $content); break; default: $out = ''; } return $out; } } diff --git a/plugins/libkolab/lib/kolab_ldap.php b/plugins/libkolab/lib/kolab_ldap.php index c51fede4..46085b86 100644 --- a/plugins/libkolab/lib/kolab_ldap.php +++ b/plugins/libkolab/lib/kolab_ldap.php @@ -1,613 +1,613 @@ * * Copyright (C) 2011-2019, Kolab Systems AG * * 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 . */ /** * Wrapper class for rcube_ldap_generic */ class kolab_ldap extends rcube_ldap_generic { private $conf = array(); private $fieldmap = array(); private $rcache; function __construct($p) { $rcmail = rcube::get_instance(); $this->conf = $p; $this->conf['kolab_auth_user_displayname'] = $rcmail->config->get('kolab_auth_user_displayname', '{name}'); $this->fieldmap = $p['fieldmap']; $this->fieldmap['uid'] = 'uid'; $p['attributes'] = array_values($this->fieldmap); $p['debug'] = (bool) $rcmail->config->get('ldap_debug'); if ($cache_type = $rcmail->config->get('ldap_cache', 'db')) { $cache_ttl = $rcmail->config->get('ldap_cache_ttl', '10m'); $this->cache = $rcmail->get_cache('LDAP.kolab_cache', $cache_type, $cache_ttl); } // Connect to the server (with bind) parent::__construct($p); $this->_connect(); $rcmail->add_shutdown_function(array($this, 'close')); } /** * Establish a connection to the LDAP server */ private function _connect() { // try to connect + bind for every host configured // with OpenLDAP 2.x ldap_connect() always succeeds but ldap_bind will fail if host isn't reachable // see http://www.php.net/manual/en/function.ldap-connect.php foreach ((array)$this->config['hosts'] as $host) { // skip host if connection failed if (!$this->connect($host)) { continue; } $bind_pass = $this->config['bind_pass'] ?? null; $bind_user = $this->config['bind_user'] ?? null; $bind_dn = $this->config['bind_dn']; $base_dn = $this->config['base_dn']; $groups_base_dn = $this->config['groups']['base_dn'] ?: $base_dn; // User specific access, generate the proper values to use. if ($this->config['user_specific']) { $rcube = rcube::get_instance(); // No password set, use the session password if (empty($bind_pass)) { $bind_pass = $rcube->get_user_password(); } $u = null; // Get the pieces needed for variable replacement. if ($fu = ($rcube->get_user_email() ?: ($this->config['username'] ?? null))) { list($u, $d) = explode('@', $fu); } else { $d = $this->config['mail_domain'] ?? null; } $dc = 'dc=' . strtr($d, array('.' => ',dc=')); // hierarchal domain string // resolve $dc through LDAP if (!empty($this->config['domain_filter']) && !empty($this->config['search_bind_dn'])) { $this->bind($this->config['search_bind_dn'], $this->config['search_bind_pw']); $dc = $this->domain_root_dn($d); } $replaces = array('%dn' => '', '%dc' => $dc, '%d' => $d, '%fu' => $fu, '%u' => $u); // Search for the dn to use to authenticate if (($this->config['search_base_dn'] ?? false) && ($this->config['search_filter'] ?? false) && (strstr($bind_dn, '%dn') || strstr($base_dn, '%dn') || strstr($groups_base_dn, '%dn')) ) { $search_attribs = array('uid'); if ($search_bind_attrib = (array) $this->config['search_bind_attrib']) { foreach ($search_bind_attrib as $r => $attr) { $search_attribs[] = $attr; $replaces[$r] = ''; } } $search_bind_dn = strtr($this->config['search_bind_dn'], $replaces); $search_base_dn = strtr($this->config['search_base_dn'], $replaces); $search_filter = strtr($this->config['search_filter'], $replaces); $cache_key = 'DN.' . md5("$host:$search_bind_dn:$search_base_dn:$search_filter:" . $this->config['search_bind_pw']); if ($this->cache && ($dn = $this->cache->get($cache_key))) { $replaces['%dn'] = $dn; } else { $ldap = $this; if (!empty($search_bind_dn) && !empty($this->config['search_bind_pw'])) { // To protect from "Critical extension is unavailable" error // we need to use a separate LDAP connection if (!empty($this->config['vlv'])) { $ldap = new rcube_ldap_generic($this->config); $ldap->config_set(array('cache' => $this->cache, 'debug' => $this->debug)); if (!$ldap->connect($host)) { continue; } } if (!$ldap->bind($search_bind_dn, $this->config['search_bind_pw'])) { continue; // bind failed, try next host } } $res = $ldap->search($search_base_dn, $search_filter, 'sub', $search_attribs); if ($res) { $res->rewind(); $replaces['%dn'] = key($res->entries(true)); // add more replacements from 'search_bind_attrib' config if ($search_bind_attrib) { $res = $res->current(); foreach ($search_bind_attrib as $r => $attr) { $replaces[$r] = $res[$attr][0]; } } } if ($ldap != $this) { $ldap->close(); } } // DN not found if (empty($replaces['%dn'])) { if (!empty($this->config['search_dn_default'])) $replaces['%dn'] = $this->config['search_dn_default']; else { rcube::raise_error(array( 'code' => 100, 'type' => 'ldap', 'file' => __FILE__, 'line' => __LINE__, 'message' => "DN not found using LDAP search."), true); continue; } } if ($this->cache && !empty($replaces['%dn'])) { $this->cache->set($cache_key, $replaces['%dn']); } } // Replace the bind_dn and base_dn variables. $bind_dn = strtr($bind_dn, $replaces); $base_dn = strtr($base_dn, $replaces); $groups_base_dn = strtr($groups_base_dn, $replaces); // replace placeholders in filter settings if (!empty($this->config['filter'])) { $this->config['filter'] = strtr($this->config['filter'], $replaces); } foreach (array('base_dn', 'filter', 'member_filter') as $k) { if (!empty($this->config['groups'][$k])) { $this->config['groups'][$k] = strtr($this->config['groups'][$k], $replaces); } } if (empty($bind_user)) { $bind_user = $u; } } if (empty($bind_pass)) { $this->ready = true; } else { if (!empty($this->config['auth_cid'])) { $this->ready = $this->sasl_bind($this->config['auth_cid'], $bind_pass, $bind_dn); } else if (!empty($bind_dn)) { $this->ready = $this->bind($bind_dn, $bind_pass); } else { $this->ready = $this->sasl_bind($bind_user, $bind_pass); } } // connection established, we're done here if ($this->ready) { break; } } // end foreach hosts if (!is_resource($this->conn)) { rcube::raise_error(array('code' => 100, 'type' => 'ldap', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Could not connect to any LDAP server, last tried $host"), true); $this->ready = false; } return $this->ready; } /** * Fetches user data from LDAP addressbook */ function get_user_record($user, $host) { $rcmail = rcube::get_instance(); $filter = $rcmail->config->get('kolab_auth_filter'); $filter = $this->parse_vars($filter, $user, $host); $base_dn = $this->parse_vars($this->config['base_dn'], $user, $host); $scope = $this->config['scope']; // @TODO: print error if filter is empty // get record if ($result = parent::search($base_dn, $filter, $scope, $this->attributes)) { if ($result->count() == 1) { $entries = $result->entries(true); $dn = key($entries); $entry = array_pop($entries); $entry = $this->field_mapping($dn, $entry); return $entry; } } } /** * Fetches user data from LDAP addressbook */ function get_user_groups($dn, $user, $host) { if (empty($dn) || empty($this->config['groups'])) { return array(); } $base_dn = $this->parse_vars($this->config['groups']['base_dn'], $user, $host); $name_attr = $this->config['groups']['name_attr'] ? $this->config['groups']['name_attr'] : 'cn'; $member_attr = $this->get_group_member_attr(); $filter = "(member=$dn)(uniqueMember=$dn)"; if ($member_attr != 'member' && $member_attr != 'uniqueMember') $filter .= "($member_attr=$dn)"; $filter = strtr("(|$filter)", array("\\" => "\\\\")); $result = parent::search($base_dn, $filter, 'sub', array('dn', $name_attr)); if (!$result) { return array(); } $groups = array(); foreach ($result as $entry) { $dn = $entry['dn']; $entry = rcube_ldap_generic::normalize_entry($entry); $groups[$dn] = $entry[$name_attr]; } return $groups; } /** * Get a specific LDAP record * * @param string DN * * @return array Record data */ function get_record($dn) { if (!$this->ready) { return; } if ($rec = $this->get_entry($dn, $this->attributes)) { $rec = rcube_ldap_generic::normalize_entry($rec); $rec = $this->field_mapping($dn, $rec); } return $rec; } /** * Replace LDAP record data items * * @param string $dn DN * @param array $entry LDAP entry * * return bool True on success, False on failure */ function replace($dn, $entry) { // fields mapping foreach ($this->fieldmap as $field => $attr) { if (array_key_exists($field, $entry)) { $entry[$attr] = $entry[$field]; if ($attr != $field) { unset($entry[$field]); } } } return $this->mod_replace($dn, $entry); } /** * Search records (simplified version of rcube_ldap::search) * * @param mixed $fields The field name or array of field names to search in * @param string $value Search value * @param int $mode Matching mode: * 0 - partial (*abc*), * 1 - strict (=), * 2 - prefix (abc*) * @param array $required List of fields that cannot be empty * @param int $limit Number of records * @param int $count Returns the number of records found * * @return array List of LDAP records found */ function dosearch($fields, $value, $mode=1, $required = array(), $limit = 0, &$count = 0) { if (empty($fields)) { return array(); } $mode = intval($mode); // try to resolve field names into ldap attributes $fieldmap = $this->fieldmap; $attrs = array_map(function($f) use ($fieldmap) { return array_key_exists($f, $fieldmap) ? $fieldmap[$f] : $f; }, (array)$fields); // compose a full-text-search-like filter if (count($attrs) > 1 || $mode != 1) { $filter = self::fulltext_search_filter($value, $attrs, $mode); } // direct search else { $field = $attrs[0]; $filter = "($field=" . self::quote_string($value) . ")"; } // add required (non empty) fields filter $req_filter = ''; foreach ((array)$required as $field) { $attr = array_key_exists($field, $this->fieldmap) ? $this->fieldmap[$field] : $field; // only add if required field is not already in search filter if (!in_array($attr, $attrs)) { $req_filter .= "($attr=*)"; } } if (!empty($req_filter)) { $filter = '(&' . $req_filter . $filter . ')'; } // avoid double-wildcard if $value is empty $filter = preg_replace('/\*+/', '*', $filter); // add general filter to query if (!empty($this->config['filter'])) { $filter = '(&(' . preg_replace('/^\(|\)$/', '', $this->config['filter']) . ')' . $filter . ')'; } $base_dn = $this->parse_vars($this->config['base_dn']); $scope = $this->config['scope']; $attrs = array_values($this->fieldmap); $list = array(); if ($result = $this->search($base_dn, $filter, $scope, $attrs)) { $count = $result->count(); $i = 0; foreach ($result as $entry) { if ($limit && $limit <= $i) { break; } $dn = $entry['dn']; $entry = rcube_ldap_generic::normalize_entry($entry); $list[$dn] = $this->field_mapping($dn, $entry); $i++; } } return $list; } /** * Set filter used in search() */ function set_filter($filter) { $this->config['filter'] = $filter; } /** * Maps LDAP attributes to defined fields */ protected function field_mapping($dn, $entry) { $entry['dn'] = $dn; // fields mapping foreach ($this->fieldmap as $field => $attr) { // $entry might be indexed by lower-case attribute names $attr_lc = strtolower($attr); if (isset($entry[$attr_lc])) { $entry[$field] = $entry[$attr_lc]; } else if (isset($entry[$attr])) { $entry[$field] = $entry[$attr]; } } // compose display name according to config if (empty($this->fieldmap['displayname'])) { $entry['displayname'] = rcube_addressbook::compose_search_name( $entry, $entry['email'], - $entry['name'], + $entry['name'] ?? null, $this->conf['kolab_auth_user_displayname'] ); } return $entry; } /** * Detects group member attribute name */ private function get_group_member_attr($object_classes = array()) { if (empty($object_classes)) { $object_classes = $this->config['groups']['object_classes']; } if (!empty($object_classes)) { foreach ((array)$object_classes as $oc) { switch (strtolower($oc)) { case 'group': case 'groupofnames': case 'kolabgroupofnames': $member_attr = 'member'; break; case 'groupofuniquenames': case 'kolabgroupofuniquenames': $member_attr = 'uniqueMember'; break; } } } if (!empty($member_attr)) { return $member_attr; } if (!empty($this->config['groups']['member_attr'])) { return $this->config['groups']['member_attr']; } return 'member'; } /** * Prepares filter query for LDAP search */ function parse_vars($str, $user = null, $host = null) { // When authenticating user $user is always set // if not set it means we use this LDAP object for other // purposes, e.g. kolab_delegation, then username with // correct domain is in a session if (!$user) { $user = $_SESSION['username']; } $dc = null; if (isset($this->icache[$user])) { list($user, $dc) = $this->icache[$user]; } else { $orig_user = $user; $rcmail = rcube::get_instance(); // get default domain if ($username_domain = $rcmail->config->get('username_domain')) { if ($host && is_array($username_domain) && isset($username_domain[$host])) { $domain = rcube_utils::parse_host($username_domain[$host], $host); } else if (is_string($username_domain)) { $domain = rcube_utils::parse_host($username_domain, $host); } } // realmed username (with domain) if (strpos($user, '@')) { list($usr, $dom) = explode('@', $user); // unrealm domain, user login can contain a domain alias if ($dom != $domain && ($dc = $this->domain_root_dn($dom))) { // @FIXME: we should replace domain in $user, I suppose } } else if ($domain) { $user .= '@' . $domain; } $this->icache[$orig_user] = array($user, $dc); } // replace variables in filter list($u, $d) = explode('@', $user); // hierarchal domain string if (empty($dc)) { $dc = 'dc=' . strtr($d, array('.' => ',dc=')); } $replaces = array('%dc' => $dc, '%d' => $d, '%fu' => $user, '%u' => $u); $this->parse_replaces = $replaces; return strtr($str, $replaces); } /** * Returns variables used for replacement in (last) parse_vars() call * * @return array Variable-value hash array */ public function get_parse_vars() { return $this->parse_replaces; } /** * Register additional fields */ public function extend_fieldmap($map) { foreach ((array)$map as $name => $attr) { if (!in_array($attr, $this->attributes)) { $this->attributes[] = $attr; $this->fieldmap[$name] = $attr; } } } /** * HTML-safe DN string encoding * * @param string $str DN string * * @return string Encoded HTML identifier string */ static function dn_encode($str) { return rtrim(strtr(base64_encode($str), '+/', '-_'), '='); } /** * Decodes DN string encoded with _dn_encode() * * @param string $str Encoded HTML identifier string * * @return string DN string */ static function dn_decode($str) { $str = str_pad(strtr($str, '-_', '+/'), strlen($str) % 4, '=', STR_PAD_RIGHT); return base64_decode($str); } } diff --git a/plugins/libkolab/lib/kolab_storage.php b/plugins/libkolab/lib/kolab_storage.php index 1ef2138e..e0551000 100644 --- a/plugins/libkolab/lib/kolab_storage.php +++ b/plugins/libkolab/lib/kolab_storage.php @@ -1,1810 +1,1811 @@ * @author Aleksander Machniak * * Copyright (C) 2012-2014, Kolab Systems AG * * 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 . */ class kolab_storage { const CTYPE_KEY = '/shared/vendor/kolab/folder-type'; const CTYPE_KEY_PRIVATE = '/private/vendor/kolab/folder-type'; const COLOR_KEY_SHARED = '/shared/vendor/kolab/color'; const COLOR_KEY_PRIVATE = '/private/vendor/kolab/color'; const NAME_KEY_SHARED = '/shared/vendor/kolab/displayname'; const NAME_KEY_PRIVATE = '/private/vendor/kolab/displayname'; const UID_KEY_SHARED = '/shared/vendor/kolab/uniqueid'; const UID_KEY_CYRUS = '/shared/vendor/cmu/cyrus-imapd/uniqueid'; const ERROR_IMAP_CONN = 1; const ERROR_CACHE_DB = 2; const ERROR_NO_PERMISSION = 3; const ERROR_INVALID_FOLDER = 4; public static $version = '3.0'; public static $last_error; public static $encode_ids = false; private static $ready = false; private static $with_tempsubs = true; private static $subscriptions; private static $ldapcache = array(); private static $ldap = array(); private static $states; private static $config; private static $imap; // Default folder names private static $default_folders = array( 'event' => 'Calendar', 'contact' => 'Contacts', 'task' => 'Tasks', 'note' => 'Notes', 'file' => 'Files', 'configuration' => 'Configuration', 'journal' => 'Journal', 'mail.inbox' => 'INBOX', 'mail.drafts' => 'Drafts', 'mail.sentitems' => 'Sent', 'mail.wastebasket' => 'Trash', 'mail.outbox' => 'Outbox', 'mail.junkemail' => 'Junk', ); /** * Setup the environment needed by the libs */ public static function setup() { if (self::$ready) return true; $rcmail = rcube::get_instance(); self::$config = $rcmail->config; self::$version = strval($rcmail->config->get('kolab_format_version', self::$version)); self::$imap = $rcmail->get_storage(); self::$ready = class_exists('kolabformat') && (self::$imap->get_capability('METADATA') || self::$imap->get_capability('ANNOTATEMORE') || self::$imap->get_capability('ANNOTATEMORE2')); if (self::$ready) { // do nothing } else if (!class_exists('kolabformat')) { rcube::raise_error(array( 'code' => 900, 'type' => 'php', 'message' => "required kolabformat module not found" ), true); } else if (self::$imap->get_error_code()) { rcube::raise_error(array( 'code' => 900, 'type' => 'php', 'message' => "IMAP error" ), true); } // adjust some configurable settings if ($event_scheduling_prop = $rcmail->config->get('kolab_event_scheduling_properties', null)) { kolab_format_event::$scheduling_properties = (array)$event_scheduling_prop; } // adjust some configurable settings if ($task_scheduling_prop = $rcmail->config->get('kolab_task_scheduling_properties', null)) { kolab_format_task::$scheduling_properties = (array)$task_scheduling_prop; } return self::$ready; } /** * Initializes LDAP object to resolve Kolab users * * @param string $name Name of the configuration option with LDAP config */ public static function ldap($name = 'kolab_users_directory') { self::setup(); $config = self::$config->get($name); if (empty($config)) { $name = 'kolab_auth_addressbook'; $config = self::$config->get($name); } if (!empty(self::$ldap[$name])) { return self::$ldap[$name]; } if (!is_array($config)) { $ldap_config = (array)self::$config->get('ldap_public'); $config = $ldap_config[$config]; } if (empty($config)) { return null; } $ldap = new kolab_ldap($config); // overwrite filter option if ($filter = self::$config->get('kolab_users_filter')) { self::$config->set('kolab_auth_filter', $filter); } $user_field = $user_attrib = self::$config->get('kolab_users_id_attrib'); // Fallback to kolab_auth_login, which is not attribute, but field name if (!$user_field && ($user_field = self::$config->get('kolab_auth_login', 'email'))) { $user_attrib = $config['fieldmap'][$user_field]; } if ($user_field && $user_attrib) { $ldap->extend_fieldmap(array($user_field => $user_attrib)); } self::$ldap[$name] = $ldap; return $ldap; } /** * 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 boolean Enable to return subscribed folders only (null to use configured subscription mode) * * @return array List of Kolab_Folder objects (folder names in UTF7-IMAP) */ public static function get_folders($type, $subscribed = null) { $folders = $folderdata = array(); if (self::setup()) { foreach ((array)self::list_folders('', '*', $type, $subscribed, $folderdata) as $foldername) { $folders[$foldername] = new kolab_storage_folder($foldername, $type, $folderdata[$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) * @return object kolab_storage_folder The folder object */ public static function get_default_folder($type) { if (self::setup()) { foreach ((array)self::list_folders('', '*', $type . '.default', false, $folderdata) as $foldername) { return new kolab_storage_folder($foldername, $type, $folderdata[$foldername]); } } return null; } /** * Getter for a specific storage folder * * @param string IMAP folder to access (UTF7-IMAP) * @param string Expected folder type * * @return object kolab_storage_folder The folder object */ public static function get_folder($folder, $type = null) { return self::setup() ? new kolab_storage_folder($folder, $type) : null; } /** * 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 static function get_object($uid, $type) { self::setup(); $folder = null; foreach ((array)self::list_folders('', '*', $type, null, $folderdata) as $foldername) { if (!$folder) $folder = new kolab_storage_folder($foldername, $type, $folderdata[$foldername]); else $folder->set_folder($foldername, $type, $folderdata[$foldername]); if ($object = $folder->get_object($uid)) return $object; } 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) * @see kolab_storage_format::select() */ public static function select($query, $type, $limit = null) { self::setup(); $folder = null; $result = array(); foreach ((array)self::list_folders('', '*', $type, null, $folderdata) as $foldername) { $folder = new kolab_storage_folder($foldername, $type, $folderdata[$foldername]); if ($limit) { $folder->set_order_and_limit(null, $limit); } foreach ($folder->select($query) as $object) { $result[] = $object; } } return $result; } /** * Returns Free-busy server URL */ public static function get_freebusy_server() { $rcmail = rcube::get_instance(); $url = 'https://' . $_SESSION['imap_host'] . '/freebusy'; $url = $rcmail->config->get('kolab_freebusy_server', $url); $url = rcube_utils::resolve_url($url); return unslashify($url); } /** * 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) { $query = ''; $param = array(); $utc = new \DateTimeZone('UTC'); // https://www.calconnect.org/pubdocs/CD0903%20Freebusy%20Read%20URL.pdf if ($start instanceof \DateTime) { $start->setTimezone($utc); $param['start'] = $param['dtstart'] = $start->format('Ymd\THis\Z'); } if ($end instanceof \DateTime) { $end->setTimezone($utc); $param['end'] = $param['dtend'] = $end->format('Ymd\THis\Z'); } if (!empty($param)) { $query = '?' . http_build_query($param); } $url = self::get_freebusy_server(); if (strpos($url, '%u')) { // Expected configured full URL, just replace the %u variable // Note: Cyrus v3 Free-Busy service does not use .ifb extension $url = str_replace('%u', rawurlencode($email), $url); } else { $url .= '/' . $email . '.ifb'; } return $url . $query; } /** * Creates folder ID from folder name * * @param string $folder Folder name (UTF7-IMAP) * @param boolean $enc Use lossless encoding * @return string Folder ID string */ public static function folder_id($folder, $enc = null) { return $enc == true || ($enc === null && self::$encode_ids) ? self::id_encode($folder) : asciiwords(strtr($folder, '/.-', '___')); } /** * Encode the given ID to a safe ascii representation * * @param string $id Arbitrary identifier string * * @return string Ascii representation */ public static function id_encode($id) { return rtrim(strtr(base64_encode($id), '+/', '-_'), '='); } /** * Convert the given identifier back to it's raw value * * @param string $id Ascii identifier * @return string Raw identifier string */ public static function id_decode($id) { return base64_decode(str_pad(strtr($id, '-_', '+/'), strlen($id) % 4, '=', STR_PAD_RIGHT)); } /** * Return the (first) path of the requested IMAP namespace * * @param string Namespace name (personal, shared, other) * @return string IMAP root path for that namespace */ public static function namespace_root($name) { self::setup(); foreach ((array)self::$imap->get_namespace($name) as $paths) { if (strlen($paths[0]) > 1) { return $paths[0]; } } return ''; } /** * Deletes IMAP folder * * @param string $name Folder name (UTF7-IMAP) * * @return bool True on success, false on failure */ public static function folder_delete($name) { // clear cached entries first if ($folder = self::get_folder($name)) $folder->cache->purge(); $rcmail = rcube::get_instance(); $plugin = $rcmail->plugins->exec_hook('folder_delete', array('name' => $name)); $success = self::$imap->delete_folder($name); self::$last_error = self::$imap->get_error_str(); return $success; } /** * Creates IMAP 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 static function folder_create($name, $type = null, $subscribed = false, $active = false) { self::setup(); $rcmail = rcube::get_instance(); $plugin = $rcmail->plugins->exec_hook('folder_create', array('record' => array( 'name' => $name, 'subscribe' => $subscribed, ))); if ($saved = self::$imap->create_folder($name, $subscribed)) { // set metadata for folder type if ($type) { $saved = self::set_folder_type($name, $type); // revert if metadata could not be set if (!$saved) { self::$imap->delete_folder($name); } // activate folder else if ($active) { self::set_state($name, true); } } } if ($saved) { return true; } self::$last_error = self::$imap->get_error_str(); return false; } /** * Renames IMAP 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 static function folder_rename($oldname, $newname) { self::setup(); $rcmail = rcube::get_instance(); $plugin = $rcmail->plugins->exec_hook('folder_rename', array( 'oldname' => $oldname, 'newname' => $newname)); $oldfolder = self::get_folder($oldname); $active = self::folder_is_active($oldname); $success = self::$imap->rename_folder($oldname, $newname); self::$last_error = self::$imap->get_error_str(); // pass active state to new folder name if ($success && $active) { self::set_state($oldname, false); self::set_state($newname, true); } // assign existing cache entries to new resource uri if ($success && $oldfolder) { $oldfolder->cache->rename($newname); } return $success; } /** * Rename or Create a new IMAP 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 name or False on failure * * @see self::set_folder_props() for list of other properties */ public static function folder_update(&$prop) { self::setup(); $folder = rcube_charset::convert($prop['name'], RCUBE_CHARSET, 'UTF7-IMAP'); $oldfolder = $prop['oldname']; // UTF7 $parent = $prop['parent']; // UTF7 $delimiter = self::$imap->get_hierarchy_delimiter(); if (strlen($oldfolder)) { $options = self::$imap->folder_info($oldfolder); } if (!empty($options) && ($options['norename'] || $options['protected'])) { } // sanity checks (from steps/settings/save_folder.inc) else if (!strlen($folder)) { self::$last_error = 'cannotbeempty'; return false; } else if (strlen($folder) > 128) { self::$last_error = 'nametoolong'; return false; } else { // these characters are problematic e.g. when used in LIST/LSUB foreach (array($delimiter, '%', '*') as $char) { if (strpos($folder, $char) !== false) { self::$last_error = 'forbiddencharacter'; return false; } } } if (!empty($options) && ($options['protected'] || $options['norename'])) { $folder = $oldfolder; } else if (strlen($parent)) { $folder = $parent . $delimiter . $folder; } else { // add namespace prefix (when needed) $folder = self::$imap->mod_folder($folder, 'in'); } // Check access rights to the parent folder if (strlen($parent) && (!strlen($oldfolder) || $oldfolder != $folder)) { $parent_opts = self::$imap->folder_info($parent); if ($parent_opts['namespace'] != 'personal' && (empty($parent_opts['rights']) || !preg_match('/[ck]/', implode($parent_opts['rights']))) ) { self::$last_error = 'No permission to create folder'; return false; } } // update the folder name if (strlen($oldfolder)) { if ($oldfolder != $folder) { $result = self::folder_rename($oldfolder, $folder); } else { $result = true; } } // create new folder else { $result = self::folder_create($folder, $prop['type'], $prop['subscribed'], $prop['active']); } if ($result) { self::set_folder_props($folder, $prop); } return $result ? $folder : false; } /** * Getter for human-readable name of Kolab object (folder) * with kolab_custom_display_names support. * See http://wiki.kolab.org/UI-Concepts/Folder-Listing for reference * * @param string $folder IMAP 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) { // find custom display name in folder METADATA if ($name = self::custom_displayname($folder)) { return $name; } return self::object_prettyname($folder, $folder_ns); } /** * Get custom display name (saved in metadata) for the given folder */ public static function custom_displayname($folder) { static $_metadata; // find custom display name in folder METADATA if (self::$config->get('kolab_custom_display_names', true) && self::setup()) { if ($_metadata !== null) { $metadata = $_metadata; } else { // For performance reasons ask for all folders, it will be cached as one cache entry $metadata = self::$imap->get_metadata("*", array(self::NAME_KEY_PRIVATE, self::NAME_KEY_SHARED)); // If cache is disabled store result in memory if (!self::$config->get('imap_cache')) { $_metadata = $metadata; } } - if ($data = $metadata[$folder]) { + if ($data = $metadata[$folder] ?? null) { if (($name = $data[self::NAME_KEY_PRIVATE]) || ($name = $data[self::NAME_KEY_SHARED])) { return $name; } } } return false; } /** * Getter for human-readable name of Kolab object (folder) * See http://wiki.kolab.org/UI-Concepts/Folder-Listing for reference * * @param string $folder IMAP 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_prettyname($folder, &$folder_ns=null) { self::setup(); $found = false; $namespace = self::$imap->get_namespace(); + $prefix = null; if (!empty($namespace['shared'])) { foreach ($namespace['shared'] as $ns) { if (strlen($ns[0]) && strpos($folder, $ns[0]) === 0) { $prefix = ''; $folder = substr($folder, strlen($ns[0])); $delim = $ns[1]; $found = true; $folder_ns = 'shared'; break; } } } if (!$found && !empty($namespace['other'])) { foreach ($namespace['other'] as $ns) { if (strlen($ns[0]) && strpos($folder, $ns[0]) === 0) { // remove namespace prefix and extract username $folder = substr($folder, strlen($ns[0])); $delim = $ns[1]; // get username part and map it to user name $pos = strpos($folder, $delim); $fid = $pos ? substr($folder, 0, $pos) : $folder; if ($user = self::folder_id2user($fid, true)) { $fid = str_replace($delim, '', $user); } $prefix = "($fid)"; $folder = $pos ? substr($folder, $pos + 1) : ''; $found = true; $folder_ns = 'other'; break; } } } if (!$found && !empty($namespace['personal'])) { foreach ($namespace['personal'] as $ns) { if (strlen($ns[0]) && strpos($folder, $ns[0]) === 0) { // remove namespace prefix $folder = substr($folder, strlen($ns[0])); $prefix = ''; $delim = $ns[1]; $found = true; break; } } } if (empty($delim)) $delim = self::$imap->get_hierarchy_delimiter(); $folder = rcube_charset::convert($folder, 'UTF7-IMAP'); $folder = html::quote($folder); $folder = str_replace(html::quote($delim), ' » ', $folder); if ($prefix) $folder = html::quote($prefix) . ($folder !== '' ? ' ' . $folder : ''); if (!$folder_ns) $folder_ns = 'personal'; return $folder; } /** * Helper method to generate a truncated folder name to display. * Note: $origname is a string returned by self::object_name() */ public static function folder_displayname($origname, &$names) { $name = $origname; // find folder prefix to truncate for ($i = count($names)-1; $i >= 0; $i--) { if (strpos($name, $names[$i] . ' » ') === 0) { $length = strlen($names[$i] . ' » '); $prefix = substr($name, 0, $length); $count = count(explode(' » ', $prefix)); $diff = 1; // check if prefix folder is in other users namespace for ($n = count($names)-1; $n >= 0; $n--) { if (strpos($prefix, '(' . $names[$n] . ') ') === 0) { $diff = 0; break; } } $name = str_repeat('   ', $count - $diff) . '» ' . substr($name, $length); break; } // other users namespace and parent folder exists else if (strpos($name, '(' . $names[$i] . ') ') === 0) { $length = strlen('(' . $names[$i] . ') '); $prefix = substr($name, 0, $length); $count = count(explode(' » ', $prefix)); $name = str_repeat('   ', $count) . '» ' . substr($name, $length); break; } } $names[] = $origname; return $name; } /** * 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 static function folder_selector($type, $attrs, $current = '') { // get all folders of specified type (sorted) $folders = self::get_folders($type, true); $delim = self::$imap->get_hierarchy_delimiter(); $names = array(); $len = strlen($current); if ($len && ($rpos = strrpos($current, $delim))) { $parent = substr($current, 0, $rpos); $p_len = strlen($parent); } // Filter folders list foreach ($folders as $c_folder) { $name = $c_folder->name; // skip current folder and it's subfolders if ($len) { if ($name == $current) { // Make sure parent folder is listed (might be skipped e.g. if it's namespace root) if ($p_len && !isset($names[$parent])) { $names[$parent] = self::object_name($parent); } continue; } if (strpos($name, $current.$delim) === 0) { continue; } } // always show the parent of current folder if ($p_len && $name == $parent) { } // skip folders where user have no rights to create subfolders else if ($c_folder->get_owner() != $_SESSION['username']) { $rights = $c_folder->get_myrights(); if (!preg_match('/[ck]/', $rights)) { continue; } } $names[$name] = $c_folder->get_name(); } // Build SELECT field of parent folder $attrs['is_escaped'] = true; $select = new html_select($attrs); $select->add('---', ''); $listnames = array(); foreach (array_keys($names) as $imap_name) { $name = $origname = $names[$imap_name]; // find folder prefix to truncate for ($i = count($listnames)-1; $i >= 0; $i--) { if (strpos($name, $listnames[$i].' » ') === 0) { $length = strlen($listnames[$i].' » '); $prefix = substr($name, 0, $length); $count = count(explode(' » ', $prefix)); $name = str_repeat('  ', $count-1) . '» ' . substr($name, $length); break; } } $listnames[] = $origname; $select->add($name, $imap_name); } return $select; } /** * 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 boolean 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 static function list_folders($root = '', $mbox = '*', $filter = null, $subscribed = null, &$folderdata = array()) { if (!self::setup()) { return null; } // use IMAP subscriptions if ($subscribed === null && self::$config->get('kolab_use_subscriptions')) { $subscribed = true; } if (!$filter) { // Get ALL folders list, standard way if ($subscribed) { $folders = self::_imap_list_subscribed($root, $mbox, $filter); } else { $folders = self::_imap_list_folders($root, $mbox); } return $folders; } $prefix = $root . $mbox; $regexp = '/^' . preg_quote($filter, '/') . '(\..+)?$/'; // get folders types for all folders $folderdata = self::folders_typedata($prefix); if (!is_array($folderdata)) { return array(); } // If we only want groupware folders and don't care about the subscription state, // then the metadata will already contain all folder names and we can avoid the LIST below. if (!$subscribed && $filter != 'mail' && $prefix == '*') { foreach ($folderdata as $folder => $type) { if (!preg_match($regexp, $type)) { unset($folderdata[$folder]); } } return self::$imap->sort_folder_list(array_keys($folderdata), true); } // Get folders list if ($subscribed) { $folders = self::_imap_list_subscribed($root, $mbox, $filter); } else { $folders = self::_imap_list_folders($root, $mbox); } // In case of an error, return empty list (?) if (!is_array($folders)) { return array(); } // Filter folders list foreach ($folders as $idx => $folder) { - $type = $folderdata[$folder]; + $type = $folderdata[$folder] ?? null; if ($filter == 'mail' && empty($type)) { continue; } if (empty($type) || !preg_match($regexp, $type)) { unset($folders[$idx]); } } return $folders; } /** * Wrapper for rcube_imap::list_folders() with optional post-filtering */ protected static function _imap_list_folders($root, $mbox) { $postfilter = null; // compose a post-filter expression for the excluded namespaces if ($root . $mbox == '*' && ($skip_ns = self::$config->get('kolab_skip_namespace'))) { $excludes = array(); foreach ((array)$skip_ns as $ns) { if ($ns_root = self::namespace_root($ns)) { $excludes[] = $ns_root; } } if (count($excludes)) { $postfilter = '!^(' . join(')|(', array_map('preg_quote', $excludes)) . ')!'; } } // use normal LIST command to return all folders, it's fast enough $folders = self::$imap->list_folders($root, $mbox, null, null, !empty($postfilter)); if (!empty($postfilter)) { $folders = array_filter($folders, function($folder) use ($postfilter) { return !preg_match($postfilter, $folder); }); $folders = self::$imap->sort_folder_list($folders); } return $folders; } /** * Wrapper for rcube_imap::list_folders_subscribed() * with support for temporarily subscribed folders */ protected static function _imap_list_subscribed($root, $mbox, $filter = null) { $folders = self::$imap->list_folders_subscribed($root, $mbox); // add temporarily subscribed folders - if ($filter != 'mail' && self::$with_tempsubs && is_array($_SESSION['kolab_subscribed_folders'])) { + if ($filter != 'mail' && self::$with_tempsubs && is_array($_SESSION['kolab_subscribed_folders'] ?? null)) { $folders = array_unique(array_merge($folders, $_SESSION['kolab_subscribed_folders'])); } return $folders; } /** * 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 static function search_folders($type, $query, $exclude_ns = array()) { if (!self::setup()) { return array(); } $folders = array(); $query = str_replace('*', '', $query); // find unsubscribed IMAP folders of the given type foreach ((array)self::list_folders('', '*', $type, false, $folderdata) as $foldername) { // FIXME: only consider the last part of the folder path for searching? $realname = strtolower(rcube_charset::convert($foldername, 'UTF7-IMAP')); if (($query == '' || strpos($realname, $query) !== false) && !self::folder_is_subscribed($foldername, true) && !in_array(self::$imap->folder_namespace($foldername), (array)$exclude_ns) ) { $folders[] = new kolab_storage_folder($foldername, $type, $folderdata[$foldername]); } } return $folders; } /** * Sort the given list of kolab folders by namespace/name * * @param array List of kolab_storage_folder objects * @return array Sorted list of folders */ public static function sort_folders($folders) { $pad = ' '; $out = array(); $nsnames = array('personal' => array(), 'shared' => array(), 'other' => array()); foreach ($folders as $folder) { $_folders[$folder->name] = $folder; $ns = $folder->get_namespace(); $nsnames[$ns][$folder->name] = strtolower(html_entity_decode($folder->get_name(), ENT_COMPAT, RCUBE_CHARSET)) . $pad; // decode » } // $folders is a result of get_folders() we can assume folders were already sorted foreach (array_keys($nsnames) as $ns) { asort($nsnames[$ns], SORT_LOCALE_STRING); foreach (array_keys($nsnames[$ns]) as $utf7name) { $out[] = $_folders[$utf7name]; } } return $out; } /** * Check the folder tree and add the missing parents as virtual folders * * @param array $folders Folders list * @param object $tree Reference to the root node of the folder tree * * @return array Flat folders list */ public static function folder_hierarchy($folders, &$tree = null) { if (!self::setup()) { return array(); } $_folders = array(); $delim = self::$imap->get_hierarchy_delimiter(); $other_ns = rtrim(self::namespace_root('other'), $delim); $tree = new kolab_storage_folder_virtual('', '', ''); // create tree root $refs = array('' => $tree); foreach ($folders as $idx => $folder) { $path = explode($delim, $folder->name); array_pop($path); $folder->parent = join($delim, $path); $folder->children = array(); // reset list // skip top folders or ones with a custom displayname if (count($path) < 1 || kolab_storage::custom_displayname($folder->name)) { $tree->children[] = $folder; } else { $parents = array(); $depth = $folder->get_namespace() == 'personal' ? 1 : 2; while (count($path) >= $depth && ($parent = join($delim, $path))) { array_pop($path); $parent_parent = join($delim, $path); if (!$refs[$parent]) { if ($folder->type && self::folder_type($parent) == $folder->type) { $refs[$parent] = new kolab_storage_folder($parent, $folder->type, $folder->type); $refs[$parent]->parent = $parent_parent; } else if ($parent_parent == $other_ns) { $refs[$parent] = new kolab_storage_folder_user($parent, $parent_parent); } else { $name = kolab_storage::object_name($parent); $refs[$parent] = new kolab_storage_folder_virtual($parent, $name, $folder->get_namespace(), $parent_parent); } $parents[] = $refs[$parent]; } } if (!empty($parents)) { $parents = array_reverse($parents); foreach ($parents as $parent) { $parent_node = $refs[$parent->parent] ?: $tree; $parent_node->children[] = $parent; $_folders[] = $parent; } } $parent_node = $refs[$folder->parent] ?: $tree; $parent_node->children[] = $folder; } $refs[$folder->name] = $folder; $_folders[] = $folder; unset($folders[$idx]); } 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 static function folders_typedata($prefix = '*') { if (!self::setup()) { return false; } $type_keys = array(self::CTYPE_KEY, self::CTYPE_KEY_PRIVATE); // fetch metadata from *some* folders only if (($prefix == '*' || $prefix == '') && ($skip_ns = self::$config->get('kolab_skip_namespace'))) { $delimiter = self::$imap->get_hierarchy_delimiter(); $folderdata = $blacklist = array(); foreach ((array)$skip_ns as $ns) { if ($ns_root = rtrim(self::namespace_root($ns), $delimiter)) { $blacklist[] = $ns_root; } } foreach (array('personal','other','shared') as $ns) { if (!in_array($ns, (array)$skip_ns)) { $ns_root = rtrim(self::namespace_root($ns), $delimiter); // list top-level folders and their childs one by one // GETMETADATA "%" doesn't list shared or other namespace folders but "*" would if ($ns_root == '') { foreach ((array)self::$imap->get_metadata('%', $type_keys) as $folder => $metadata) { if (!in_array($folder, $blacklist)) { $folderdata[$folder] = $metadata; $opts = self::$imap->folder_attributes($folder); if (!in_array('\\HasNoChildren', $opts) && ($data = self::$imap->get_metadata($folder.$delimiter.'*', $type_keys))) { $folderdata += $data; } } } } else if ($data = self::$imap->get_metadata($ns_root.$delimiter.'*', $type_keys)) { $folderdata += $data; } } } } else { $folderdata = self::$imap->get_metadata($prefix, $type_keys); } if (!is_array($folderdata)) { return false; } return array_map(array('kolab_storage', 'folder_select_metadata'), $folderdata); } /** * Callback for array_map to select the correct annotation value */ public static function folder_select_metadata($types) { if (!empty($types[self::CTYPE_KEY_PRIVATE])) { return $types[self::CTYPE_KEY_PRIVATE]; } else if (!empty($types[self::CTYPE_KEY])) { list($ctype, ) = explode('.', $types[self::CTYPE_KEY]); return $ctype; } return null; } /** * Returns type of IMAP folder * * @param string $folder Folder name (UTF7-IMAP) * * @return string Folder type */ public static function folder_type($folder) { self::setup(); $metadata = self::$imap->get_metadata($folder, array(self::CTYPE_KEY, self::CTYPE_KEY_PRIVATE)); if (!is_array($metadata)) { return null; } if (!empty($metadata[$folder])) { return self::folder_select_metadata($metadata[$folder]); } return 'mail'; } /** * Sets folder content-type. * * @param string $folder Folder name * @param string $type Content type * * @return boolean True on success */ public static function set_folder_type($folder, $type='mail') { self::setup(); list($ctype, $subtype) = explode('.', $type); $success = self::$imap->set_metadata($folder, array(self::CTYPE_KEY => $ctype, self::CTYPE_KEY_PRIVATE => $subtype ? $type : null)); if (!$success) // fallback: only set private annotation $success |= self::$imap->set_metadata($folder, array(self::CTYPE_KEY_PRIVATE => $type)); return $success; } /** * Check subscription status of this folder * * @param string $folder Folder name * @param boolean $temp Include temporary/session subscriptions * * @return boolean True if subscribed, false if not */ public static function folder_is_subscribed($folder, $temp = false) { if (self::$subscriptions === null) { self::setup(); self::$with_tempsubs = false; self::$subscriptions = self::$imap->list_folders_subscribed(); self::$with_tempsubs = true; } return in_array($folder, self::$subscriptions) || ($temp && in_array($folder, (array)$_SESSION['kolab_subscribed_folders'])); } /** * Change subscription status of this folder * * @param string $folder Folder name * @param boolean $temp Only subscribe temporarily for the current session * * @return True on success, false on error */ public static function folder_subscribe($folder, $temp = false) { self::setup(); // temporary/session subscription if ($temp) { if (self::folder_is_subscribed($folder)) { return true; } else if (!is_array($_SESSION['kolab_subscribed_folders']) || !in_array($folder, $_SESSION['kolab_subscribed_folders'])) { $_SESSION['kolab_subscribed_folders'][] = $folder; return true; } } else if (self::$imap->subscribe($folder)) { self::$subscriptions = null; return true; } return false; } /** * Change subscription status of this folder * * @param string $folder Folder name * @param boolean $temp Only remove temporary subscription * * @return True on success, false on error */ public static function folder_unsubscribe($folder, $temp = false) { self::setup(); // temporary/session subscription if ($temp) { if (is_array($_SESSION['kolab_subscribed_folders']) && ($i = array_search($folder, $_SESSION['kolab_subscribed_folders'])) !== false) { unset($_SESSION['kolab_subscribed_folders'][$i]); } return true; } else if (self::$imap->unsubscribe($folder)) { self::$subscriptions = null; return true; } return false; } /** * Check activation status of this folder * * @param string $folder Folder name * * @return boolean True if active, false if not */ public static function folder_is_active($folder) { $active_folders = self::get_states(); return in_array($folder, $active_folders); } /** * Change activation status of this folder * * @param string $folder Folder name * * @return True on success, false on error */ public static function folder_activate($folder) { // activation implies temporary subscription self::folder_subscribe($folder, true); return self::set_state($folder, true); } /** * Change activation status of this folder * * @param string $folder Folder name * * @return True on success, false on error */ public static function folder_deactivate($folder) { // remove from temp subscriptions, really? self::folder_unsubscribe($folder, true); return self::set_state($folder, false); } /** * Return list of active folders */ private static function get_states() { if (self::$states !== null) { return self::$states; } $rcube = rcube::get_instance(); $folders = $rcube->config->get('kolab_active_folders'); if ($folders !== null) { self::$states = !empty($folders) ? explode('**', $folders) : array(); } // for backward-compatibility copy server-side subscriptions to activation states else { self::setup(); if (self::$subscriptions === null) { self::$with_tempsubs = false; self::$subscriptions = self::$imap->list_folders_subscribed(); self::$with_tempsubs = true; } self::$states = (array) self::$subscriptions; $folders = implode('**', self::$states); $rcube->user->save_prefs(array('kolab_active_folders' => $folders)); } return self::$states; } /** * Update list of active folders */ private static function set_state($folder, $state) { self::get_states(); // update in-memory list $idx = array_search($folder, self::$states); if ($state && $idx === false) { self::$states[] = $folder; } else if (!$state && $idx !== false) { unset(self::$states[$idx]); } // update user preferences $folders = implode('**', self::$states); return rcube::get_instance()->user->save_prefs(array('kolab_active_folders' => $folders)); } /** * 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 static function create_default_folder($type, $props = array()) { if (!self::setup()) { return; } $folders = self::$imap->get_metadata('*', array(kolab_storage::CTYPE_KEY_PRIVATE)); // from kolab_folders config $folder_type = strpos($type, '.') ? str_replace('.', '_', $type) : $type . '_default'; $default_name = self::$config->get('kolab_folders_' . $folder_type); $folder_type = str_replace('_', '.', $folder_type); // check if we have any folder in personal namespace // folder(s) may exist but not subscribed foreach ((array)$folders as $f => $data) { if (strpos($data[self::CTYPE_KEY_PRIVATE], $type) === 0) { $folder = $f; break; } } if (!$folder) { if (!$default_name) { $default_name = self::$default_folders[$type]; } if (!$default_name) { return; } $folder = rcube_charset::convert($default_name, RCUBE_CHARSET, 'UTF7-IMAP'); $prefix = self::$imap->get_namespace('prefix'); // add personal namespace prefix if needed if ($prefix && strpos($folder, $prefix) !== 0 && $folder != 'INBOX') { $folder = $prefix . $folder; } if (!self::$imap->folder_exists($folder)) { if (!self::$imap->create_folder($folder)) { return; } } self::set_folder_type($folder, $folder_type); } self::folder_subscribe($folder); if ($props['active']) { self::set_state($folder, true); } if (!empty($props)) { self::set_folder_props($folder, $props); } return $folder; } /** * Sets folder metadata properties * * @param string $folder Folder name * @param array &$prop Folder properties (color, displayname) */ public static function set_folder_props($folder, &$prop) { if (!self::setup()) { return; } // TODO: also save 'showalarams' and other properties here $ns = self::$imap->folder_namespace($folder); $supported = array( 'color' => array(self::COLOR_KEY_SHARED, self::COLOR_KEY_PRIVATE), 'displayname' => array(self::NAME_KEY_SHARED, self::NAME_KEY_PRIVATE), ); foreach ($supported as $key => $metakeys) { if (array_key_exists($key, $prop)) { $meta_saved = false; if ($ns == 'personal') // save in shared namespace for personal folders $meta_saved = self::$imap->set_metadata($folder, array($metakeys[0] => $prop[$key])); if (!$meta_saved) // try in private namespace $meta_saved = self::$imap->set_metadata($folder, array($metakeys[1] => $prop[$key])); if ($meta_saved) unset($prop[$key]); // unsetting will prevent fallback to local user prefs } } } /** * Search users in Kolab LDAP storage * * @param mixed $query Search value (or array of field => value pairs) * @param int $mode Matching mode: 0 - partial (*abc*), 1 - strict (=), 2 - prefix (abc*) * @param array $required List of fields that shall ot be empty * @param int $limit Maximum number of records * @param int $count Returns the number of records found * * @return array List of users */ public static function search_users($query, $mode = 1, $required = array(), $limit = 0, &$count = 0) { $query = str_replace('*', '', $query); // requires a working LDAP setup if (!strlen($query) || !($ldap = self::ldap())) { return array(); } $root = self::namespace_root('other'); $user_attrib = self::$config->get('kolab_users_id_attrib', self::$config->get('kolab_auth_login', 'mail')); $search_attrib = self::$config->get('kolab_users_search_attrib', array('cn','mail','alias')); // search users using the configured attributes $results = $ldap->dosearch($search_attrib, $query, $mode, $required, $limit, $count); // exclude myself if ($_SESSION['kolab_dn']) { unset($results[$_SESSION['kolab_dn']]); } // resolve to IMAP folder name array_walk($results, function(&$user, $dn) use ($root, $user_attrib) { list($localpart, ) = explode('@', $user[$user_attrib]); $user['kolabtargetfolder'] = $root . $localpart; }); return $results; } /** * 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 static function list_user_folders($user, $type, $subscribed = 0, &$folderdata = array()) { self::setup(); $folders = array(); // use localpart of user attribute as root for folder listing $user_attrib = self::$config->get('kolab_users_id_attrib', self::$config->get('kolab_auth_login', 'mail')); if (!empty($user[$user_attrib])) { list($mbox) = explode('@', $user[$user_attrib]); $delimiter = self::$imap->get_hierarchy_delimiter(); $other_ns = self::namespace_root('other'); $prefix = $other_ns . $mbox . $delimiter; $subscribed = (int) $subscribed; $subs = $subscribed < 2 ? (bool) $subscribed : false; $folders = self::list_folders($prefix, '*', $type, $subs, $folderdata); if ($subscribed === 2 && !empty($folders)) { $active = self::get_states(); if (!empty($active)) { $folders = array_diff($folders, $active); } } } return $folders; } /** * 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 boolean Enable to return subscribed folders only (null to use configured subscription mode) * * @return array List of kolab_storage_folder_user objects */ public static function get_user_folders($type, $subscribed) { $folders = $folderdata = array(); if (self::setup()) { $delimiter = self::$imap->get_hierarchy_delimiter(); $other_ns = rtrim(self::namespace_root('other'), $delimiter); $path_len = count(explode($delimiter, $other_ns)); foreach ((array)self::list_folders($other_ns . $delimiter, '*', '', $subscribed) as $foldername) { if ($foldername == 'INBOX') // skip INBOX which is added by default continue; $path = explode($delimiter, $foldername); // compare folder type if a subfolder is listed if ($type && count($path) > $path_len + 1 && $type != self::folder_type($foldername)) { continue; } // truncate folder path to top-level folders of the 'other' namespace $foldername = join($delimiter, array_slice($path, 0, $path_len + 1)); if (!$folders[$foldername]) { $folders[$foldername] = new kolab_storage_folder_user($foldername, $other_ns); } } // for every (subscribed) user folder, list all (unsubscribed) subfolders foreach ($folders as $userfolder) { foreach ((array)self::list_folders($userfolder->name . $delimiter, '*', $type, false, $folderdata) as $foldername) { if (!$folders[$foldername]) { $folders[$foldername] = new kolab_storage_folder($foldername, $type, $folderdata[$foldername]); $userfolder->children[] = $folders[$foldername]; } } } } return $folders; } /** * 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(); $prefix = 'imap://' . urlencode($args['username']) . '@' . $args['host'] . '/%'; $db->query("DELETE FROM " . $db->table_name('kolab_folders', true) . " WHERE `resource` LIKE ?", $prefix); } /** * Get folder METADATA for all supported keys * Do this in one go for better caching performance */ public static function folder_metadata($folder) { if (self::setup()) { $keys = array( // For better performance we skip displayname here, see (self::custom_displayname()) // self::NAME_KEY_PRIVATE, // self::NAME_KEY_SHARED, self::CTYPE_KEY, self::CTYPE_KEY_PRIVATE, self::COLOR_KEY_PRIVATE, self::COLOR_KEY_SHARED, self::UID_KEY_SHARED, self::UID_KEY_CYRUS, ); $metadata = self::$imap->get_metadata($folder, $keys); return $metadata[$folder]; } } /** * Get user attributes for specified other user (imap) folder identifier. * * @param string $folder_id Folder name w/o path (imap user identifier) * @param bool $as_string Return configured display name attribute value * * @return array User attributes * @see self::ldap() */ public static function folder_id2user($folder_id, $as_string = false) { static $domain, $cache, $name_attr; $rcube = rcube::get_instance(); if ($domain === null) { list(, $domain) = explode('@', $rcube->get_user_name()); } if ($name_attr === null) { $name_attr = (array) ($rcube->config->get('kolab_users_name_field', $rcube->config->get('kolab_auth_name')) ?: 'name'); } $token = $folder_id; - if ($domain && strpos($find, '@') === false) { + if ($domain && strpos($token, '@') === false) { $token .= '@' . $domain; } if ($cache === null) { $cache = $rcube->get_cache_shared('kolab_users') ?: false; } // use value cached in memory for repeated lookups if (!$cache && array_key_exists($token, self::$ldapcache)) { $user = self::$ldapcache[$token]; } if (empty($user) && $cache) { $user = $cache->get($token); } if (empty($user) && ($ldap = self::ldap())) { $user = $ldap->get_user_record($token, $_SESSION['imap_host']); if (!empty($user)) { $keys = array('displayname', 'name', 'mail'); // supported keys $user = array_intersect_key($user, array_flip($keys)); if (!empty($user)) { if ($cache) { $cache->set($token, $user); } else { self::$ldapcache[$token] = $user; } } } } if (!empty($user)) { if ($as_string) { foreach ($name_attr as $attr) { if ($display = $user[$attr]) { break; } } if (!$display) { $display = $user['displayname'] ?: $user['name']; } if ($display && $display != $folder_id) { $display = "$display ($folder_id)"; } return $display; } return $user; } } /** * Chwala's 'folder_mod' hook handler for mapping other users folder names */ public static function folder_mod($args) { static $roots; if ($roots === null) { self::setup(); $roots = self::$imap->get_namespace('other'); } // Note: We're working with UTF7-IMAP encoding here if ($args['dir'] == 'in') { foreach ((array) $roots as $root) { if (strpos($args['folder'], $root[0]) === 0) { // remove root and explode folder $delim = $root[1]; $folder = explode($delim, substr($args['folder'], strlen($root[0]))); // compare first (user) part with a regexp, it's supposed // to look like this: "Doe, Jane (uid)", so we can extract the uid // and replace the folder with it if (preg_match('~^[^/]+ \(([^)]+)\)$~', $folder[0], $m)) { $folder[0] = $m[1]; $args['folder'] = $root[0] . implode($delim, $folder); } break; } } } else { // dir == 'out' foreach ((array) $roots as $root) { if (strpos($args['folder'], $root[0]) === 0) { // remove root and explode folder $delim = $root[1]; $folder = explode($delim, substr($args['folder'], strlen($root[0]))); // Replace uid with "Doe, Jane (uid)" if ($user = self::folder_id2user($folder[0], true)) { $user = str_replace($delim, '', $user); $folder[0] = rcube_charset::convert($user, RCUBE_CHARSET, 'UTF7-IMAP'); $args['folder'] = $root[0] . implode($delim, $folder); } break; } } } return $args; } } diff --git a/plugins/libkolab/lib/kolab_storage_dav_folder.php b/plugins/libkolab/lib/kolab_storage_dav_folder.php index 6877bdc5..2a1cda0c 100644 --- a/plugins/libkolab/lib/kolab_storage_dav_folder.php +++ b/plugins/libkolab/lib/kolab_storage_dav_folder.php @@ -1,759 +1,759 @@ * * Copyright (C) 2014-2022, Apheleia IT AG * * 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 . */ #[AllowDynamicProperties] class kolab_storage_dav_folder extends kolab_storage_folder { public $dav; public $href; public $attributes; /** * Object constructor */ public function __construct($dav, $attributes, $type = '') { $this->attributes = $attributes; $this->href = $this->attributes['href']; $this->id = kolab_storage_dav::folder_id($dav->url, $this->href); $this->dav = $dav; $this->valid = true; list($this->type, $suffix) = strpos($type, '.') ? explode('.', $type) : [$type, '']; $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 kolab_storage_dav_folder Target folder to move object into * * @return bool True on success, false on failure */ public function move($uid, $target_folder) { if (!$this->valid) { return false; } $source = $this->object_location($uid); $target = $target_folder->object_location($uid); $success = $this->dav->move($source, $target) !== false; if ($success) { $this->cache->set($uid, false); } return $success; } /** * 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; } $result = false; if (empty($uid)) { if (empty($object['created'])) { $object['created'] = new DateTime('now'); } } else { $object['changed'] = new DateTime('now'); } // 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; $object['_raw'] = $content; $this->cache->save($object, $uid); $result = true; unset($object['_raw']); } } 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]); } /** * Fetch multiple objects from the DAV server and convert to internal format * * @param array The object UIDs to fetch * * @return mixed Hash array representing the Kolab objects */ public function read_objects($uids) { if (!$this->valid) { return false; } if (empty($uids)) { return []; } foreach ($uids as $uid) { $hrefs[] = $this->object_location($uid); } $objects = $this->dav->getData($this->href, $this->get_dav_type(), $hrefs); if (!is_array($objects)) { rcube::raise_error([ 'code' => 900, - 'message' => "Failed to fetch {$href}" + 'message' => "Failed to fetch {$this->href}" ], true); return false; } $objects = array_map([$this, 'from_dav'], $objects); foreach ($uids as $idx => $uid) { foreach ($objects as $oidx => $object) { if ($object && $object['uid'] == $uid) { $uids[$idx] = $object; unset($objects[$oidx]); continue 2; } } $uids[$idx] = false; } return $uids; } /** * Convert DAV object into PHP array * * @param array Object data in kolab_dav_client::fetchData() format * * @return array|false Object properties, False on error */ public function from_dav($object) { if (empty($object ) || empty($object['data'])) { return false; } if ($this->type == 'event' || $this->type == 'task') { $ical = libcalendaring::get_ical(); $objects = $ical->import($object['data']); if (!count($objects) || empty($objects[0]['uid'])) { return false; } $result = $objects[0]; $result['_attachments'] = $result['attachments'] ?? []; unset($result['attachments']); } else if ($this->type == 'contact') { if (stripos($object['data'], 'BEGIN:VCARD') !== 0) { return 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'] = !empty($object['href']) ? $object['href'] : null; $result['uid'] = !empty($object['uid']) ? $object['uid'] : $result['uid']; return $result; } /** * Convert Kolab object into DAV format (iCalendar) */ public function to_dav($object) { $result = ''; if ($this->type == 'event' || $this->type == 'task') { $ical = libcalendaring::get_ical(); if (!empty($object['exceptions'])) { $object['recurrence']['EXCEPTIONS'] = $object['exceptions']; } $object['_type'] = $this->type; // pre-process attachments if (isset($object['_attachments']) && is_array($object['_attachments'])) { foreach ($object['_attachments'] as $key => $attachment) { if ($attachment === false) { // Deleted attachment unset($object['_attachments'][$key]); continue; } // make sure size is set if (!isset($attachment['size'])) { if (!empty($attachment['data'])) { if (is_resource($attachment['data'])) { // this need to be a seekable resource, otherwise // fstat() fails and we're unable to determine size // here nor in rcube_imap_generic before IMAP APPEND $stat = fstat($attachment['data']); $attachment['size'] = $stat ? $stat['size'] : 0; } else { $attachment['size'] = strlen($attachment['data']); } } else if (!empty($attachment['path'])) { $attachment['size'] = filesize($attachment['path']); } $object['_attachments'][$key] = $attachment; } } } $object['attachments'] = $object['_attachments'] ?? []; unset($object['_attachments']); $result = $ical->export([$object], null, false, [$this, 'get_attachment']); } else if ($this->type == 'contact') { // copy values into vcard object // 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); 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) && $values instanceof DateTimeInterface) { $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; } public 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 body of an attachment */ public function get_attachment($id, $event, $unused1 = null, $unused2 = false, $unused3 = null, $unused4 = false) { // Note: 'attachments' is defined when saving the data into the DAV server // '_attachments' is defined after fetching the object from the DAV server if (is_int($id) && isset($event['attachments'][$id])) { $attachment = $event['attachments'][$id]; } else if (is_int($id) && isset($event['_attachments'][$id])) { $attachment = $event['_attachments'][$id]; } else if (is_string($id) && !empty($event['attachments'])) { foreach ($event['attachments'] as $att) { if (!empty($att['id']) && $att['id'] === $id) { $attachment = $att; } } } else if (is_string($id) && !empty($event['_attachments'])) { foreach ($event['_attachments'] as $att) { if (!empty($att['id']) && $att['id'] === $id) { $attachment = $att; } } } if (empty($attachment)) { return false; } if (!empty($attachment['path'])) { return file_get_contents($attachment['path']); } return $attachment['data'] ?? null; } /** * 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 Folder display name */ public function __toString() { return $this->attributes['name']; } } diff --git a/plugins/libkolab/lib/kolab_storage_folder_api.php b/plugins/libkolab/lib/kolab_storage_folder_api.php index 1879d6b9..c1cfd5d6 100644 --- a/plugins/libkolab/lib/kolab_storage_folder_api.php +++ b/plugins/libkolab/lib/kolab_storage_folder_api.php @@ -1,379 +1,379 @@ * * Copyright (C) 2014, Kolab Systems AG * * 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 . */ abstract class kolab_storage_folder_api { /** * Folder identifier * @var string */ public $id; /** * The folder name. * @var string */ public $name; /** * The type of this folder. * @var string */ public $type; /** * The subtype of this folder. * @var string */ public $subtype; /** * Is this folder set to be the default for its type * @var boolean */ public $default = false; /** * List of direct child folders * @var array */ public $children = array(); /** * Name of the parent folder * @var string */ public $parent = ''; protected $imap; protected $owner; protected $info; protected $idata; protected $namespace; protected $metadata; /** * Private constructor */ protected function __construct($name) { $this->name = $name; $this->id = kolab_storage::folder_id($name); $this->imap = rcube::get_instance()->get_storage(); } /** * Returns the owner of the folder. * * @param boolean 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; $info = $this->get_folder_info(); $rcmail = rcube::get_instance(); switch ($info['namespace']) { case 'personal': $this->owner = $rcmail->get_user_name(); break; case 'shared': $this->owner = 'anonymous'; break; default: list($prefix, $this->owner) = explode($this->imap->get_hierarchy_delimiter(), $info['name']); $fully_qualified = true; // enforce email addresses (backwards compatibility) break; } if ($fully_qualified && strpos($this->owner, '@') === false) { // extract domain from current user name $domain = strstr($rcmail->get_user_name(), '@'); // fall back to mail_domain config option if (empty($domain) && ($mdomain = $rcmail->config->mail_domain($this->imap->options['host']))) { $domain = '@' . $mdomain; } $this->owner .= $domain; } return $this->owner; } /** * 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 (!isset($this->namespace)) $this->namespace = $this->imap->folder_namespace($this->name); return $this->namespace; } /** * Get the display name value of this folder * * @return string Folder name */ public function get_name() { return kolab_storage::object_name($this->name); } /** * Getter for the top-end folder name (not the entire path) * * @return string Name of this folder */ public function get_foldername() { $parts = explode($this->imap->get_hierarchy_delimiter(), $this->name); return rcube_charset::convert(end($parts), 'UTF7-IMAP'); } /** * Getter for parent folder path * * @return string Full path to parent folder */ public function get_parent() { $delim = $this->imap->get_hierarchy_delimiter(); $path = explode($delim, $this->name); array_pop($path); // don't list top-level namespace folder if (count($path) == 1 && in_array($this->get_namespace(), array('other', 'shared'))) { $path = array(); } return join($delim, $path); } /** * 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() { $info = $this->get_folder_info(); $owner = $this->get_owner(); list($user, $domain) = explode('@', $owner); switch ($info['namespace']) { case 'personal': return sprintf('user/%s/%s@%s', $user, $this->name, $domain); case 'shared': $ns = $this->imap->get_namespace('shared'); $prefix = is_array($ns) ? $ns[0][0] : ''; list(, $domain) = explode('@', rcube::get_instance()->get_user_name()); return substr($this->name, strlen($prefix)) . '@' . $domain; default: $ns = $this->imap->get_namespace('other'); $prefix = is_array($ns) ? $ns[0][0] : ''; list($user, $folder) = explode($this->imap->get_hierarchy_delimiter(), substr($info['name'], strlen($prefix)), 2); if (strpos($user, '@')) { list($user, $domain) = explode('@', $user); } return sprintf('user/%s/%s@%s', $user, $folder, $domain); } } /** * Get the color value stored in metadata * * @param string Default color value to return if not set * @return mixed Color value from IMAP metadata or $default is not set */ public function get_color($default = null) { // color is defined in folder METADATA $metadata = $this->get_metadata(); - if (($color = $metadata[kolab_storage::COLOR_KEY_PRIVATE]) || ($color = $metadata[kolab_storage::COLOR_KEY_SHARED])) { + if (($color = $metadata[kolab_storage::COLOR_KEY_PRIVATE] ?? null) || ($color = $metadata[kolab_storage::COLOR_KEY_SHARED] ?? null)) { return $color; } return $default; } /** * Returns IMAP metadata/annotations (GETMETADATA/GETANNOTATION) * supported by kolab_storage * * @return array Metadata entry-value hash array on success, NULL on error */ public function get_metadata() { if ($this->metadata === null) { $this->metadata = kolab_storage::folder_metadata($this->name); } return $this->metadata; } /** * Sets IMAP metadata/annotations (SETMETADATA/SETANNOTATION) * * @param array $entries Entry-value array (use NULL value as NIL) * @return boolean True on success, False on failure */ public function set_metadata($entries) { $this->metadata = null; return $this->imap->set_metadata($this->name, $entries); } /** * */ public function get_folder_info() { if (!isset($this->info)) $this->info = $this->imap->folder_info($this->name); return $this->info; } /** * Make IMAP folder data available for this folder */ public function get_imap_data() { if (!isset($this->idata)) $this->idata = $this->imap->folder_data($this->name); return $this->idata; } /** * Returns (full) type of IMAP folder * * @return string Folder type */ public function get_type() { $metadata = $this->get_metadata(); if (!empty($metadata)) { return kolab_storage::folder_select_metadata($metadata); } return $this->type; } /** * Get IMAP ACL information for this folder * * @return string Permissions as string */ public function get_myrights() { $rights = $this->info['rights']; if (!is_array($rights)) $rights = $this->imap->my_rights($this->name); return join('', (array)$rights); } /** * Helper method to extract folder UID metadata * * @return string Folder's UID */ public function get_uid() { // To be implemented by extending classes return false; } /** * Check activation status of this folder * * @return boolean True if enabled, false if not */ public function is_active() { return kolab_storage::folder_is_active($this->name); } /** * Change activation status of this folder * * @param boolean The desired subscription status: true = active, false = not active * * @return True on success, false on error */ public function activate($active) { return $active ? kolab_storage::folder_activate($this->name) : kolab_storage::folder_deactivate($this->name); } /** * Check subscription status of this folder * * @return boolean True if subscribed, false if not */ public function is_subscribed() { return kolab_storage::folder_is_subscribed($this->name); } /** * Change subscription status of this folder * * @param boolean The desired subscription status: true = subscribed, false = not subscribed * * @return True on success, false on error */ public function subscribe($subscribed) { return $subscribed ? kolab_storage::folder_subscribe($this->name) : kolab_storage::folder_unsubscribe($this->name); } /** * Return folder name as string representation of this object * * @return string Full IMAP folder name */ public function __toString() { return $this->name; } } diff --git a/plugins/libkolab/lib/kolab_storage_folder_virtual.php b/plugins/libkolab/lib/kolab_storage_folder_virtual.php index bf3ba554..8c6b3319 100644 --- a/plugins/libkolab/lib/kolab_storage_folder_virtual.php +++ b/plugins/libkolab/lib/kolab_storage_folder_virtual.php @@ -1,59 +1,58 @@ * * Copyright (C) 2014, Kolab Systems AG * * 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 . */ class kolab_storage_folder_virtual extends kolab_storage_folder_api { - public $virtual = true; - protected $displayname; public function __construct($name, $dispname, $ns, $parent = '') { parent::__construct($name); $this->namespace = $ns; $this->parent = $parent; $this->displayname = $dispname; + $this->virtual = true; } /** * Get the display name value of this folder * * @return string Folder name */ public function get_name() { return $this->displayname ?: parent::get_name(); } /** * Get the color value stored in metadata * * @param string Default color value to return if not set * @return mixed Color value from IMAP metadata or $default is not set */ public function get_color($default = null) { return $default; } } diff --git a/plugins/libkolab/libkolab.php b/plugins/libkolab/libkolab.php index 53c72bcd..e770586c 100644 --- a/plugins/libkolab/libkolab.php +++ b/plugins/libkolab/libkolab.php @@ -1,392 +1,392 @@ * * Copyright (C) 2012-2015, Kolab Systems AG * * 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 . */ class libkolab extends rcube_plugin { static $http_requests = array(); static $bonnie_api = false; /** * Required startup method of a Roundcube plugin */ public function init() { // load local config $this->load_config(); $this->require_plugin('libcalendaring'); // extend include path to load bundled lib classes $include_path = $this->home . '/lib' . PATH_SEPARATOR . ini_get('include_path'); set_include_path($include_path); $this->add_hook('storage_init', array($this, 'storage_init')); $this->add_hook('storage_connect', array($this, 'storage_connect')); $this->add_hook('user_delete', array('kolab_storage', 'delete_user_folders')); // For Chwala $this->add_hook('folder_mod', array('kolab_storage', 'folder_mod')); $rcmail = rcube::get_instance(); try { kolab_format::$timezone = new DateTimeZone($rcmail->config->get('timezone', 'GMT')); } catch (Exception $e) { rcube::raise_error($e, true); kolab_format::$timezone = new DateTimeZone('GMT'); } $this->add_texts('localization/', false); if (!empty($rcmail->output->type) && $rcmail->output->type == 'html') { $rcmail->output->add_handler('libkolab.folder_search_form', array($this, 'folder_search_form')); $this->include_stylesheet($this->local_skin_path() . '/libkolab.css'); } // embed scripts and templates for email message audit trail if (property_exists($rcmail, 'task') && $rcmail->task == 'mail' && self::get_bonnie_api()) { if ($rcmail->output->type == 'html') { $this->add_hook('render_page', array($this, 'bonnie_render_page')); $this->include_script('libkolab.js'); // add 'Show history' item to message menu $this->api->add_content(html::tag('li', array('role' => 'menuitem'), $this->api->output->button(array( 'command' => 'kolab-mail-history', 'label' => 'libkolab.showhistory', 'type' => 'link', 'classact' => 'icon history active', 'class' => 'icon history disabled', 'innerclass' => 'icon history', ))), 'messagemenu'); } $this->register_action('plugin.message-changelog', array($this, 'message_changelog')); } } /** * Hook into IMAP FETCH HEADER.FIELDS command and request Kolab-specific headers */ function storage_init($p) { $kolab_headers = 'X-KOLAB-TYPE X-KOLAB-MIME-VERSION MESSAGE-ID'; if (!empty($p['fetch_headers'])) { $p['fetch_headers'] .= ' ' . $kolab_headers; } else { $p['fetch_headers'] = $kolab_headers; } return $p; } /** * Hook into IMAP connection to replace client identity */ function storage_connect($p) { $client_name = 'Roundcube/Kolab'; if (empty($p['ident'])) { $p['ident'] = array( 'name' => $client_name, 'version' => RCUBE_VERSION, /* 'php' => PHP_VERSION, 'os' => PHP_OS, 'command' => $_SERVER['REQUEST_URI'], */ ); } else { $p['ident']['name'] = $client_name; } return $p; } /** * Getter for a singleton instance of the Bonnie API * * @return mixed kolab_bonnie_api instance if configured, false otherwise */ public static function get_bonnie_api() { // get configuration for the Bonnie API if (!self::$bonnie_api && ($bonnie_config = rcube::get_instance()->config->get('kolab_bonnie_api', false))) { self::$bonnie_api = new kolab_bonnie_api($bonnie_config); } return self::$bonnie_api; } /** * Hook to append the message history dialog template to the mail view */ function bonnie_render_page($p) { if (($p['template'] === 'mail' || $p['template'] === 'message') && !$p['kolab-audittrail']) { // append a template for the audit trail dialog $this->api->output->add_footer( html::div(array('id' => 'mailmessagehistory', 'class' => 'uidialog', 'aria-hidden' => 'true', 'style' => 'display:none'), self::object_changelog_table(array('class' => 'records-table changelog-table')) ) ); $this->api->output->set_env('kolab_audit_trail', true); $p['kolab-audittrail'] = true; } return $p; } /** * Handler for message audit trail changelog requests */ public function message_changelog() { if (!self::$bonnie_api) { return false; } $rcmail = rcube::get_instance(); $msguid = rcube_utils::get_input_value('_uid', rcube_utils::INPUT_POST, true); $mailbox = rcube_utils::get_input_value('_mbox', rcube_utils::INPUT_POST); $result = $msguid && $mailbox ? self::$bonnie_api->changelog('mail', null, $mailbox, $msguid) : null; if (is_array($result)) { if (is_array($result['changes'])) { $dtformat = $rcmail->config->get('date_format') . ' ' . $rcmail->config->get('time_format'); array_walk($result['changes'], function(&$change) use ($dtformat, $rcmail) { if ($change['date']) { $dt = rcube_utils::anytodatetime($change['date']); if ($dt instanceof DateTimeInterface) { $change['date'] = $rcmail->format_date($dt, $dtformat); } } }); } $this->api->output->command('plugin.message_render_changelog', $result['changes']); } else { $this->api->output->command('plugin.message_render_changelog', false); } $this->api->output->send(); } /** * Wrapper function to load and initalize the HTTP_Request2 Object * * @param string|Net_Url2 Request URL * @param string Request method ('OPTIONS','GET','HEAD','POST','PUT','DELETE','TRACE','CONNECT') * @param array Configuration for this Request instance, that will be merged * with default configuration * * @return HTTP_Request2 Request object */ public static function http_request($url = '', $method = 'GET', $config = array()) { $rcube = rcube::get_instance(); $http_config = (array) $rcube->config->get('kolab_http_request'); // deprecated configuration options if (empty($http_config)) { foreach (array('ssl_verify_peer', 'ssl_verify_host') as $option) { $value = $rcube->config->get('kolab_' . $option, true); if (is_bool($value)) { $http_config[$option] = $value; } } } if (!empty($config)) { $http_config = array_merge($http_config, $config); } // force CURL adapter, this allows to handle correctly // compressed responses with SplObserver registered (kolab_files) (#4507) $http_config['adapter'] = 'HTTP_Request2_Adapter_Curl'; $key = md5(serialize($http_config)); if (!empty(self::$http_requests[$key])) { $request = self::$http_requests[$key]; } else { // load HTTP_Request2 (support both composer-installed and system-installed package) if (!class_exists('HTTP_Request2')) { require_once 'HTTP/Request2.php'; } try { $request = new HTTP_Request2(); $request->setConfig($http_config); } catch (Exception $e) { rcube::raise_error($e, true, true); } // proxy User-Agent string $request->setHeader('user-agent', $_SERVER['HTTP_USER_AGENT']); self::$http_requests[$key] = $request; } // cleanup try { $request->setBody(''); $request->setUrl($url); $request->setMethod($method); } catch (Exception $e) { rcube::raise_error($e, true, true); } return $request; } /** * Table oultine for object changelog display */ public static function object_changelog_table($attrib = array()) { $rcube = rcube::get_instance(); $attrib += array('domain' => 'libkolab'); $table = new html_table(array('cols' => 5, 'border' => 0, 'cellspacing' => 0)); $table->add_header('diff', ''); $table->add_header('revision', $rcube->gettext('revision', $attrib['domain'])); $table->add_header('date', $rcube->gettext('date', $attrib['domain'])); $table->add_header('user', $rcube->gettext('user', $attrib['domain'])); $table->add_header('operation', $rcube->gettext('operation', $attrib['domain'])); $table->add_header('actions', ' '); $rcube->output->add_label( 'libkolab.showrevision', 'libkolab.actionreceive', 'libkolab.actionappend', 'libkolab.actionmove', 'libkolab.actiondelete', 'libkolab.actionread', 'libkolab.actionflagset', 'libkolab.actionflagclear', 'libkolab.objectchangelog', 'libkolab.objectchangelognotavailable', 'close' ); return $table->show($attrib); } /** * Wrapper function for generating a html diff using the FineDiff class by Raymond Hill */ public static function html_diff($from, $to, $is_html = null) { // auto-detect text/html format if ($is_html === null) { $from_html = (preg_match('/<(html|body)(\s+[a-z]|>)/', $from, $m) && strpos($from, '') > 0); $to_html = (preg_match('/<(html|body)(\s+[a-z]|>)/', $to, $m) && strpos($to, '') > 0); $is_html = $from_html || $to_html; // ensure both parts are of the same format if ($is_html && !$from_html) { $converter = new rcube_text2html($from, false, array('wrap' => true)); $from = $converter->get_html(); } if ($is_html && !$to_html) { $converter = new rcube_text2html($to, false, array('wrap' => true)); $to = $converter->get_html(); } } // compute diff from HTML if ($is_html) { include_once __dir__ . '/vendor/Caxy/HtmlDiff/Match.php'; include_once __dir__ . '/vendor/Caxy/HtmlDiff/Operation.php'; include_once __dir__ . '/vendor/Caxy/HtmlDiff/HtmlDiff.php'; // replace data: urls with a transparent image to avoid memory problems $from = preg_replace('/src="data:image[^"]+/', 'src="data:image/gif;base64,R0lGODlhAQABAPAAAOjq6gAAACH/C1hNUCBEYXRhWE1QAT8AIfkEBQAAAAAsAAAAAAEAAQAAAgJEAQA7', $from); $to = preg_replace('/src="data:image[^"]+/', 'src="data:image/gif;base64,R0lGODlhAQABAPAAAOjq6gAAACH/C1hNUCBEYXRhWE1QAT8AIfkEBQAAAAAsAAAAAAEAAQAAAgJEAQA7', $to); $diff = new Caxy\HtmlDiff\HtmlDiff($from, $to); $diffhtml = $diff->build(); // remove empty inserts (from tables) return preg_replace('!\s*!Uims', '', $diffhtml); } else { include_once __dir__ . '/vendor/finediff.php'; $diff = new FineDiff($from, $to, FineDiff::$wordGranularity); return $diff->renderDiffToHTML(); } } /** * Return a date() format string to render identifiers for recurrence instances * * @param array Hash array with event properties * @return string Format string */ public static function recurrence_id_format($event) { return $event['allday'] ? 'Ymd' : 'Ymd\THis'; } /** * Returns HTML code for folder search widget * * @param array $attrib Named parameters * * @return string HTML code for the gui object */ public function folder_search_form($attrib) { $rcmail = rcube::get_instance(); $attrib += array( 'gui-object' => false, 'wrapper' => true, 'form-name' => 'foldersearchform', 'command' => 'non-extsing-command', 'reset-command' => 'non-existing-command', ); - if ($attrib['label-domain'] && !strpos($attrib['buttontitle'], '.')) { + if (($attrib['label-domain'] ?? null) && !strpos($attrib['buttontitle'], '.')) { $attrib['buttontitle'] = $attrib['label-domain'] . '.' . $attrib['buttontitle']; } if ($attrib['buttontitle']) { $attrib['placeholder'] = $rcmail->gettext($attrib['buttontitle']); } return $rcmail->output->search_form($attrib); } } diff --git a/plugins/tasklist/drivers/kolab/tasklist_kolab_driver.php b/plugins/tasklist/drivers/kolab/tasklist_kolab_driver.php index 8f65ffdc..548cf1fe 100644 --- a/plugins/tasklist/drivers/kolab/tasklist_kolab_driver.php +++ b/plugins/tasklist/drivers/kolab/tasklist_kolab_driver.php @@ -1,1739 +1,1739 @@ * * Copyright (C) 2012-2015, Kolab Systems AG * * 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 . */ class tasklist_kolab_driver extends tasklist_driver { // features supported by the backend public $alarms = false; public $attachments = true; public $attendees = true; public $undelete = false; // task undelete action public $alarm_types = array('DISPLAY','AUDIO'); public $search_more_results; private $rc; private $plugin; private $lists; private $folders = array(); private $tasks = array(); private $tags = array(); private $bonnie_api = false; /** * Default constructor */ public function __construct($plugin) { $this->rc = $plugin->rc; $this->plugin = $plugin; if (kolab_storage::$version == '2.0') { $this->alarm_absolute = false; } // tasklist use fully encoded identifiers kolab_storage::$encode_ids = true; // get configuration for the Bonnie API $this->bonnie_api = libkolab::get_bonnie_api(); $this->plugin->register_action('folder-acl', array($this, 'folder_acl')); } /** * Read available calendars for the current user and store them internally */ private function _read_lists($force = false) { // already read sources if (isset($this->lists) && !$force) { return $this->lists; } // get all folders that have type "task" $folders = kolab_storage::sort_folders(kolab_storage::get_folders('task')); $this->lists = $this->folders = array(); $delim = $this->rc->get_storage()->get_hierarchy_delimiter(); // find default folder $default_index = 0; foreach ($folders as $i => $folder) { if ($folder->default && strpos($folder->name, $delim) === false) $default_index = $i; } // put default folder (aka INBOX) on top of the list if ($default_index > 0) { $default_folder = $folders[$default_index]; unset($folders[$default_index]); array_unshift($folders, $default_folder); } $prefs = $this->rc->config->get('kolab_tasklists', array()); foreach ($folders as $folder) { $tasklist = $this->folder_props($folder, $prefs); $this->lists[$tasklist['id']] = $tasklist; $this->folders[$tasklist['id']] = $folder; $this->folders[$folder->name] = $folder; } return $this->lists; } /** * Derive list properties from the given kolab_storage_folder object */ protected function folder_props($folder, $prefs) { if ($folder->get_namespace() == 'personal') { $norename = false; $editable = true; $rights = 'lrswikxtea'; $alarms = true; } else { $alarms = false; $rights = 'lr'; $editable = false; if ($myrights = $folder->get_myrights()) { $rights = $myrights; if (strpos($rights, 't') !== false || strpos($rights, 'd') !== false) $editable = strpos($rights, 'i') !== false; } $info = $folder->get_folder_info(); $norename = $readonly || $info['norename'] || $info['protected']; } $list_id = $folder->id; #kolab_storage::folder_id($folder->name); $old_id = kolab_storage::folder_id($folder->name, false); if (!isset($prefs[$list_id]['showalarms']) && isset($prefs[$old_id]['showalarms'])) { $prefs[$list_id]['showalarms'] = $prefs[$old_id]['showalarms']; } return array( 'id' => $list_id, 'name' => $folder->get_name(), 'listname' => $folder->get_foldername(), 'editname' => $folder->get_foldername(), 'color' => $folder->get_color('0000CC'), 'showalarms' => isset($prefs[$list_id]['showalarms']) ? $prefs[$list_id]['showalarms'] : $alarms, 'editable' => $editable, 'rights' => $rights, 'norename' => $norename, 'active' => $folder->is_active(), 'owner' => $folder->get_owner(), 'parentfolder' => $folder->get_parent(), 'default' => $folder->default, - 'virtual' => $folder->virtual, + 'virtual' => !empty($folder->virtual), 'children' => true, // TODO: determine if that folder indeed has child folders 'subscribed' => (bool)$folder->is_subscribed(), 'removable' => !$folder->default, 'subtype' => $folder->subtype, 'group' => $folder->default ? 'default' : $folder->get_namespace(), 'class' => trim($folder->get_namespace() . ($folder->default ? ' default' : '')), 'caldavuid' => $folder->get_uid(), 'history' => !empty($this->bonnie_api), ); } /** * Get a list of available task lists from this source * * @param integer Bitmask defining filter criterias. * See FILTER_* constants for possible values. */ public function get_lists($filter = 0, &$tree = null) { $this->_read_lists(); // attempt to create a default list for this user if (empty($this->lists) && !isset($this->search_more_results)) { $prop = array('name' => 'Tasks', 'color' => '0000CC', 'default' => true); if ($this->create_list($prop)) $this->_read_lists(true); } $folders = $this->filter_folders($filter); // include virtual folders for a full folder tree if (!is_null($tree)) { $folders = kolab_storage::folder_hierarchy($folders, $tree); } $delim = $this->rc->get_storage()->get_hierarchy_delimiter(); $prefs = $this->rc->config->get('kolab_tasklists', array()); $lists = array(); foreach ($folders as $folder) { $list_id = $folder->id; // kolab_storage::folder_id($folder->name); $imap_path = explode($delim, $folder->name); // find parent do { array_pop($imap_path); $parent_id = kolab_storage::folder_id(join($delim, $imap_path)); } while (count($imap_path) > 1 && !$this->folders[$parent_id]); // restore "real" parent ID if ($parent_id && !$this->folders[$parent_id]) { $parent_id = kolab_storage::folder_id($folder->get_parent()); } $fullname = $folder->get_name(); $listname = $folder->get_foldername(); // special handling for virtual folders if ($folder instanceof kolab_storage_folder_user) { $lists[$list_id] = array( 'id' => $list_id, 'name' => $fullname, 'listname' => $listname, 'title' => $folder->get_title(), 'virtual' => true, 'editable' => false, 'rights' => 'l', 'group' => 'other virtual', 'class' => 'user', 'parent' => $parent_id, ); } - else if ($folder->virtual) { + else if (!empty($folder->virtual)) { $lists[$list_id] = array( 'id' => $list_id, 'name' => $fullname, 'listname' => $listname, 'virtual' => true, 'editable' => false, 'rights' => 'l', 'group' => $folder->get_namespace(), 'class' => 'folder', 'parent' => $parent_id, ); } else { if (!$this->lists[$list_id]) { $this->lists[$list_id] = $this->folder_props($folder, $prefs); $this->folders[$list_id] = $folder; } $this->lists[$list_id]['parent'] = $parent_id; $lists[$list_id] = $this->lists[$list_id]; } } return $lists; } /** * Get list of folders according to specified filters * * @param integer Bitmask defining restrictions. See FILTER_* constants for possible values. * * @return array List of task folders */ protected function filter_folders($filter) { $this->_read_lists(); $folders = array(); foreach ($this->lists as $id => $list) { if (!empty($this->folders[$id])) { $folder = $this->folders[$id]; if ($folder->get_namespace() == 'personal') { $folder->editable = true; } else if ($rights = $folder->get_myrights()) { if (strpos($rights, 't') !== false || strpos($rights, 'd') !== false) { $folder->editable = strpos($rights, 'i') !== false; } } $folders[] = $folder; } } $plugin = $this->rc->plugins->exec_hook('tasklist_list_filter', array( 'list' => $folders, 'filter' => $filter, 'tasklists' => $folders, )); if ($plugin['abort'] || !$filter) { return $plugin['tasklists']; } $personal = $filter & self::FILTER_PERSONAL; $shared = $filter & self::FILTER_SHARED; $tasklists = array(); foreach ($folders as $folder) { if (($filter & self::FILTER_WRITEABLE) && !$folder->editable) { continue; } /* if (($filter & self::FILTER_INSERTABLE) && !$folder->insert) { continue; } if (($filter & self::FILTER_ACTIVE) && !$folder->is_active()) { continue; } if (($filter & self::FILTER_PRIVATE) && $folder->subtype != 'private') { continue; } if (($filter & self::FILTER_CONFIDENTIAL) && $folder->subtype != 'confidential') { continue; } */ if ($personal || $shared) { $ns = $folder->get_namespace(); if (!(($personal && $ns == 'personal') || ($shared && $ns == 'shared'))) { continue; } } $tasklists[$folder->id] = $folder; } return $tasklists; } /** * Get the kolab_calendar instance for the given calendar ID * * @param string List identifier (encoded imap folder name) * @return object kolab_storage_folder Object nor null if list doesn't exist */ protected function get_folder($id) { $this->_read_lists(); // create list and folder instance if necesary if (!$this->lists[$id]) { $folder = kolab_storage::get_folder(kolab_storage::id_decode($id)); if ($folder->type) { $this->folders[$id] = $folder; $this->lists[$id] = $this->folder_props($folder, $this->rc->config->get('kolab_tasklists', array())); } } return $this->folders[$id]; } /** * Create a new list assigned to the current user * * @param array Hash array with list properties * name: List name * color: The color of the list * showalarms: True if alarms are enabled * @return mixed ID of the new list on success, False on error */ public function create_list(&$prop) { $prop['type'] = 'task' . ($prop['default'] ? '.default' : ''); $prop['active'] = true; // activate folder by default $prop['subscribed'] = true; $folder = kolab_storage::folder_update($prop); if ($folder === false) { $this->last_error = kolab_storage::$last_error; return false; } // create ID $id = kolab_storage::folder_id($folder); $prefs['kolab_tasklists'] = $this->rc->config->get('kolab_tasklists', array()); if (isset($prop['showalarms'])) $prefs['kolab_tasklists'][$id]['showalarms'] = $prop['showalarms'] ? true : false; if ($prefs['kolab_tasklists'][$id]) $this->rc->user->save_prefs($prefs); // force page reload to properly render folder hierarchy if (!empty($prop['parent'])) { $prop['_reload'] = true; } else { $folder = kolab_storage::get_folder($folder); $prop += $this->folder_props($folder, array()); } return $id; } /** * Update properties of an existing tasklist * * @param array Hash array with list properties * id: List Identifier * name: List name * color: The color of the list * showalarms: True if alarms are enabled (if supported) * @return boolean True on success, Fales on failure */ public function edit_list(&$prop) { if ($prop['id'] && ($folder = $this->get_folder($prop['id']))) { $prop['oldname'] = $folder->name; $prop['type'] = 'task'; $newfolder = kolab_storage::folder_update($prop); if ($newfolder === false) { $this->last_error = kolab_storage::$last_error; return false; } // create ID $id = kolab_storage::folder_id($newfolder); // fallback to local prefs $prefs['kolab_tasklists'] = $this->rc->config->get('kolab_tasklists', array()); unset($prefs['kolab_tasklists'][$prop['id']]); if (isset($prop['showalarms'])) $prefs['kolab_tasklists'][$id]['showalarms'] = $prop['showalarms'] ? true : false; if ($prefs['kolab_tasklists'][$id]) $this->rc->user->save_prefs($prefs); // force page reload if folder name/hierarchy changed if ($newfolder != $prop['oldname']) $prop['_reload'] = true; return $id; } return false; } /** * Set active/subscribed state of a list * * @param array Hash array with list properties * id: List Identifier * active: True if list is active, false if not * permanent: True if list is to be subscribed permanently * @return boolean True on success, Fales on failure */ public function subscribe_list($prop) { if ($prop['id'] && ($folder = $this->get_folder($prop['id']))) { $ret = false; if (isset($prop['permanent'])) $ret |= $folder->subscribe(intval($prop['permanent'])); if (isset($prop['active'])) $ret |= $folder->activate(intval($prop['active'])); // apply to child folders, too if ($prop['recursive']) { foreach ((array)kolab_storage::list_folders($folder->name, '*', 'task') as $subfolder) { if (isset($prop['permanent'])) ($prop['permanent'] ? kolab_storage::folder_subscribe($subfolder) : kolab_storage::folder_unsubscribe($subfolder)); if (isset($prop['active'])) ($prop['active'] ? kolab_storage::folder_activate($subfolder) : kolab_storage::folder_deactivate($subfolder)); } } return $ret; } return false; } /** * Delete the given list with all its contents * * @param array Hash array with list properties * id: list Identifier * @return boolean True on success, Fales on failure */ public function delete_list($prop) { if ($prop['id'] && ($folder = $this->get_folder($prop['id']))) { if (kolab_storage::folder_delete($folder->name)) { return true; } $this->last_error = kolab_storage::$last_error; } return false; } /** * Search for shared or otherwise not listed tasklists the user has access * * @param string Search string * @param string Section/source to search * @return array List of tasklists */ public function search_lists($query, $source) { if (!kolab_storage::setup()) { return array(); } $this->search_more_results = false; $this->lists = $this->folders = array(); // find unsubscribed IMAP folders that have "event" type if ($source == 'folders') { foreach ((array)kolab_storage::search_folders('task', $query, array('other')) as $folder) { $this->folders[$folder->id] = $folder; $this->lists[$folder->id] = $this->folder_props($folder, array()); } } // search other user's namespace via LDAP else if ($source == 'users') { $limit = $this->rc->config->get('autocomplete_max', 15) * 2; // we have slightly more space, so display twice the number foreach (kolab_storage::search_users($query, 0, array(), $limit * 10) as $user) { $folders = array(); // search for tasks folders shared by this user foreach (kolab_storage::list_user_folders($user, 'task', false) as $foldername) { $folders[] = new kolab_storage_folder($foldername, 'task'); } if (count($folders)) { $userfolder = new kolab_storage_folder_user($user['kolabtargetfolder'], '', $user); $this->folders[$userfolder->id] = $userfolder; $this->lists[$userfolder->id] = $this->folder_props($userfolder, array()); foreach ($folders as $folder) { $this->folders[$folder->id] = $folder; $this->lists[$folder->id] = $this->folder_props($folder, array()); $count++; } } if ($count >= $limit) { $this->search_more_results = true; break; } } } return $this->get_lists(); } /** * Get a list of tags to assign tasks to * * @return array List of tags */ public function get_tags() { $config = kolab_storage_config::get_instance(); $tags = $config->get_tags(); $backend_tags = array_map(function($v) { return $v['name']; }, $tags); return array_values(array_unique(array_merge($this->tags, $backend_tags))); } /** * Get number of tasks matching the given filter * * @param array List of lists to count tasks of * @return array Hash array with counts grouped by status (all|flagged|completed|today|tomorrow|nodate) */ public function count_tasks($lists = null) { if (empty($lists)) { $lists = $this->_read_lists(); $lists = array_keys($lists); } else if (is_string($lists)) { $lists = explode(',', $lists); } $today_date = new DateTime('now', $this->plugin->timezone); $today = $today_date->format('Y-m-d'); $tomorrow_date = new DateTime('now + 1 day', $this->plugin->timezone); $tomorrow = $tomorrow_date->format('Y-m-d'); $counts = array('all' => 0, 'today' => 0, 'tomorrow' => 0, 'later' => 0, 'overdue' => 0); foreach ($lists as $list_id) { if (!$folder = $this->get_folder($list_id)) { continue; } foreach ($folder->select(array(array('tags','!~','x-complete')), true) as $record) { $rec = $this->_to_rcube_task($record, $list_id, false); if ($this->is_complete($rec)) // don't count complete tasks continue; $counts['all']++; if (empty($rec['date'])) $counts['later']++; else if ($rec['date'] == $today) $counts['today']++; else if ($rec['date'] == $tomorrow) $counts['tomorrow']++; else if ($rec['date'] < $today) $counts['overdue']++; else if ($rec['date'] > $tomorrow) $counts['later']++; } } // avoid session race conditions that will loose temporary subscriptions $this->plugin->rc->session->nowrite = true; return $counts; } /** * Get all task records matching the given filter * * @param array Hash array with filter criterias: * - mask: Bitmask representing the filter selection (check against tasklist::FILTER_MASK_* constants) * - from: Date range start as string (Y-m-d) * - to: Date range end as string (Y-m-d) * - search: Search query string * - uid: Task UIDs * @param array List of lists to get tasks from * @return array List of tasks records matchin the criteria */ public function list_tasks($filter, $lists = null) { if (empty($lists)) { $lists = $this->_read_lists(); $lists = array_keys($lists); } else if (is_string($lists)) { $lists = explode(',', $lists); } $config = kolab_storage_config::get_instance(); $results = array(); // query Kolab storage $query = array(); if ($filter['mask'] & tasklist::FILTER_MASK_COMPLETE) $query[] = array('tags','~','x-complete'); else if (empty($filter['since'])) $query[] = array('tags','!~','x-complete'); // full text search (only works with cache enabled) if ($filter['search']) { $search = mb_strtolower($filter['search']); foreach (rcube_utils::normalize_string($search, true) as $word) { $query[] = array('words', '~', $word); } } if (!empty($filter['since'])) { $query[] = array('changed', '>=', $filter['since']); } if (!empty($filter['uid'])) { $query[] = array('uid', '=', (array) $filter['uid']); } foreach ($lists as $list_id) { if (!$folder = $this->get_folder($list_id)) { continue; } foreach ($folder->select($query) as $record) { // TODO: post-filter tasks returned from storage $record['list_id'] = $list_id; $results[] = $record; } } $config->apply_tags($results, true); $config->apply_links($results); foreach (array_keys($results) as $idx) { $results[$idx] = $this->_to_rcube_task($results[$idx], $results[$idx]['list_id']); } // avoid session race conditions that will loose temporary subscriptions $this->plugin->rc->session->nowrite = true; return $results; } /** * Return data of a specific task * * @param mixed Hash array with task properties or task UID * @param integer Bitmask defining filter criterias for folders. * See FILTER_* constants for possible values. * * @return array Hash array with task properties or false if not found */ public function get_task($prop, $filter = 0) { $this->_parse_id($prop); $id = $prop['uid']; $list_id = $prop['list']; $folders = $list_id ? array($list_id => $this->get_folder($list_id)) : $this->get_lists($filter); // find task in the available folders foreach ($folders as $list_id => $folder) { if (is_array($folder)) $folder = $this->folders[$list_id]; if (is_numeric($list_id) || !$folder) continue; if (empty($this->tasks[$id]) && ($object = $folder->get_object($id))) { $this->load_tags($object); $this->tasks[$id] = $this->_to_rcube_task($object, $list_id); break; } } return $this->tasks[$id]; } /** * Get all decendents of the given task record * * @param mixed Hash array with task properties or task UID * @param boolean True if all childrens children should be fetched * @return array List of all child task IDs */ public function get_childs($prop, $recursive = false) { if (is_string($prop)) { $task = $this->get_task($prop); $prop = array('uid' => $task['uid'], 'list' => $task['list']); } else { $this->_parse_id($prop); } $childs = array(); $list_id = $prop['list']; $task_ids = array($prop['uid']); $folder = $this->get_folder($list_id); // query for childs (recursively) while ($folder && !empty($task_ids)) { $query_ids = array(); foreach ($task_ids as $task_id) { $query = array(array('tags','=','x-parent:' . $task_id)); foreach ($folder->select($query) as $record) { // don't rely on kolab_storage_folder filtering if ($record['parent_id'] == $task_id) { $childs[] = $list_id . ':' . $record['uid']; $query_ids[] = $record['uid']; } } } if (!$recursive) break; $task_ids = $query_ids; } return $childs; } /** * Provide a list of revisions for the given task * * @param array $task Hash array with task properties * @return array List of changes, each as a hash array * @see tasklist_driver::get_task_changelog() */ public function get_task_changelog($prop) { if (empty($this->bonnie_api)) { return false; } list($uid, $mailbox, $msguid) = $this->_resolve_task_identity($prop); $result = $uid && $mailbox ? $this->bonnie_api->changelog('task', $uid, $mailbox, $msguid) : null; if (is_array($result) && $result['uid'] == $uid) { return $result['changes']; } return false; } /** * Return full data of a specific revision of an event * * @param mixed $task UID string or hash array with task properties * @param mixed $rev Revision number * * @return array Task object as hash array * @see tasklist_driver::get_task_revision() */ public function get_task_revison($prop, $rev) { if (empty($this->bonnie_api)) { return false; } $this->_parse_id($prop); $uid = $prop['uid']; $list_id = $prop['list']; list($uid, $mailbox, $msguid) = $this->_resolve_task_identity($prop); // call Bonnie API $result = $this->bonnie_api->get('task', $uid, $rev, $mailbox, $msguid); if (is_array($result) && $result['uid'] == $uid && !empty($result['xml'])) { $format = kolab_format::factory('task'); $format->load($result['xml']); $rec = $format->to_array(); $format->get_attachments($rec, true); if ($format->is_valid()) { $rec = self::_to_rcube_task($rec, $list_id, false); $rec['rev'] = $result['rev']; return $rec; } } return false; } /** * Command the backend to restore a certain revision of a task. * This shall replace the current object with an older version. * * @param mixed $task UID string or hash array with task properties * @param mixed $rev Revision number * * @return boolean True on success, False on failure * @see tasklist_driver::restore_task_revision() */ public function restore_task_revision($prop, $rev) { if (empty($this->bonnie_api)) { return false; } $this->_parse_id($prop); $uid = $prop['uid']; $list_id = $prop['list']; list($uid, $mailbox, $msguid) = $this->_resolve_task_identity($prop); $folder = $this->get_folder($list_id); $success = false; if ($folder && ($raw_msg = $this->bonnie_api->rawdata('task', $uid, $rev, $mailbox))) { $imap = $this->rc->get_storage(); // insert $raw_msg as new message if ($imap->save_message($folder->name, $raw_msg, null, false)) { $success = true; // delete old revision from imap and cache $imap->delete_message($msguid, $folder->name); $folder->cache->set($msguid, false); } } return $success; } /** * Get a list of property changes beteen two revisions of a task object * * @param array $task Hash array with task properties * @param mixed $rev Revisions: "from:to" * * @return array List of property changes, each as a hash array * @see tasklist_driver::get_task_diff() */ public function get_task_diff($prop, $rev1, $rev2) { $this->_parse_id($prop); $uid = $prop['uid']; $list_id = $prop['list']; list($uid, $mailbox, $msguid) = $this->_resolve_task_identity($prop); // call Bonnie API $result = $this->bonnie_api->diff('task', $uid, $rev1, $rev2, $mailbox, $msguid, $instance_id); if (is_array($result) && $result['uid'] == $uid) { $result['rev1'] = $rev1; $result['rev2'] = $rev2; $keymap = array( 'start' => 'start', 'due' => 'date', 'dstamp' => 'changed', 'summary' => 'title', 'alarm' => 'alarms', 'attendee' => 'attendees', 'attach' => 'attachments', 'rrule' => 'recurrence', 'related-to' => 'parent_id', 'percent-complete' => 'complete', 'lastmodified-date' => 'changed', ); $prop_keymaps = array( 'attachments' => array('fmttype' => 'mimetype', 'label' => 'name'), 'attendees' => array('partstat' => 'status'), ); $special_changes = array(); // map kolab event properties to keys the client expects array_walk($result['changes'], function(&$change, $i) use ($keymap, $prop_keymaps, $special_changes) { if (array_key_exists($change['property'], $keymap)) { $change['property'] = $keymap[$change['property']]; } if ($change['property'] == 'priority') { $change['property'] = 'flagged'; $change['old'] = $change['old'] == 1 ? $this->plugin->gettext('yes') : null; $change['new'] = $change['new'] == 1 ? $this->plugin->gettext('yes') : null; } // map alarms trigger value if ($change['property'] == 'alarms') { if (is_array($change['old']) && is_array($change['old']['trigger'])) $change['old']['trigger'] = $change['old']['trigger']['value']; if (is_array($change['new']) && is_array($change['new']['trigger'])) $change['new']['trigger'] = $change['new']['trigger']['value']; } // make all property keys uppercase if ($change['property'] == 'recurrence') { $special_changes['recurrence'] = $i; foreach (array('old','new') as $m) { if (is_array($change[$m])) { $props = array(); foreach ($change[$m] as $k => $v) { $props[strtoupper($k)] = $v; } $change[$m] = $props; } } } // map property keys names if (is_array($prop_keymaps[$change['property']])) { foreach ($prop_keymaps[$change['property']] as $k => $dest) { if (is_array($change['old']) && array_key_exists($k, $change['old'])) { $change['old'][$dest] = $change['old'][$k]; unset($change['old'][$k]); } if (is_array($change['new']) && array_key_exists($k, $change['new'])) { $change['new'][$dest] = $change['new'][$k]; unset($change['new'][$k]); } } } if ($change['property'] == 'exdate') { $special_changes['exdate'] = $i; } else if ($change['property'] == 'rdate') { $special_changes['rdate'] = $i; } }); // merge some recurrence changes foreach (array('exdate','rdate') as $prop) { if (array_key_exists($prop, $special_changes)) { $exdate = $result['changes'][$special_changes[$prop]]; if (array_key_exists('recurrence', $special_changes)) { $recurrence = &$result['changes'][$special_changes['recurrence']]; } else { $i = count($result['changes']); $result['changes'][$i] = array('property' => 'recurrence', 'old' => array(), 'new' => array()); $recurrence = &$result['changes'][$i]['recurrence']; } $key = strtoupper($prop); $recurrence['old'][$key] = $exdate['old']; $recurrence['new'][$key] = $exdate['new']; unset($result['changes'][$special_changes[$prop]]); } } return $result; } return false; } /** * Helper method to resolved the given task identifier into uid and folder * * @return array (uid,folder,msguid) tuple */ private function _resolve_task_identity($prop) { $mailbox = $msguid = null; $this->_parse_id($prop); $uid = $prop['uid']; $list_id = $prop['list']; if ($folder = $this->get_folder($list_id)) { $mailbox = $folder->get_mailbox_id(); // get task object from storage in order to get the real object uid an msguid if ($rec = $folder->get_object($uid)) { $msguid = $rec['_msguid']; $uid = $rec['uid']; } } return array($uid, $mailbox, $msguid); } /** * Get a list of pending alarms to be displayed to the user * * @param integer Current time (unix timestamp) * @param mixed List of list IDs to show alarms for (either as array or comma-separated string) * @return array A list of alarms, each encoded as hash array with task properties * @see tasklist_driver::pending_alarms() */ public function pending_alarms($time, $lists = null) { $interval = 300; $time -= $time % 60; $slot = $time; $slot -= $slot % $interval; $last = $time - max(60, $this->rc->config->get('refresh_interval', 0)); $last -= $last % $interval; // only check for alerts once in 5 minutes if ($last == $slot) return array(); if ($lists && is_string($lists)) $lists = explode(',', $lists); $time = $slot + $interval; $candidates = array(); $query = array( array('tags', '=', 'x-has-alarms'), array('tags', '!=', 'x-complete') ); $this->_read_lists(); foreach ($this->lists as $lid => $list) { // skip lists with alarms disabled if (!$list['showalarms'] || ($lists && !in_array($lid, $lists))) continue; $folder = $this->get_folder($lid); foreach ($folder->select($query) as $record) { if (!($record['valarms'] || $record['alarms']) || $record['status'] == 'COMPLETED' || $record['complete'] == 100) // don't trust query :-) continue; $task = $this->_to_rcube_task($record, $lid, false); // add to list if alarm is set $alarm = libcalendaring::get_next_alarm($task, 'task'); if ($alarm && $alarm['time'] && $alarm['time'] <= $time && in_array($alarm['action'], $this->alarm_types)) { $id = $alarm['id']; // use alarm-id as primary identifier $candidates[$id] = array( 'id' => $id, 'title' => $task['title'], 'date' => $task['date'], 'time' => $task['time'], 'notifyat' => $alarm['time'], 'action' => $alarm['action'], ); } } } // get alarm information stored in local database if (!empty($candidates)) { $alarm_ids = array_map(array($this->rc->db, 'quote'), array_keys($candidates)); $result = $this->rc->db->query("SELECT *" . " FROM " . $this->rc->db->table_name('kolab_alarms', true) . " WHERE `alarm_id` IN (" . join(',', $alarm_ids) . ")" . " AND `user_id` = ?", $this->rc->user->ID ); while ($result && ($rec = $this->rc->db->fetch_assoc($result))) { $dbdata[$rec['alarm_id']] = $rec; } } $alarms = array(); foreach ($candidates as $id => $task) { // skip dismissed if ($dbdata[$id]['dismissed']) continue; // snooze function may have shifted alarm time $notifyat = $dbdata[$id]['notifyat'] ? strtotime($dbdata[$id]['notifyat']) : $task['notifyat']; if ($notifyat <= $time) $alarms[] = $task; } return $alarms; } /** * (User) feedback after showing an alarm notification * This should mark the alarm as 'shown' or snooze it for the given amount of time * * @param string Task identifier * @param integer Suspend the alarm for this number of seconds */ public function dismiss_alarm($id, $snooze = 0) { // delete old alarm entry $this->rc->db->query( "DELETE FROM " . $this->rc->db->table_name('kolab_alarms', true) . " WHERE `alarm_id` = ? AND `user_id` = ?", $id, $this->rc->user->ID ); // set new notifyat time or unset if not snoozed $notifyat = $snooze > 0 ? date('Y-m-d H:i:s', time() + $snooze) : null; $query = $this->rc->db->query( "INSERT INTO " . $this->rc->db->table_name('kolab_alarms', true) . " (`alarm_id`, `user_id`, `dismissed`, `notifyat`) VALUES (?, ?, ?, ?)", $id, $this->rc->user->ID, $snooze > 0 ? 0 : 1, $notifyat ); return $this->rc->db->affected_rows($query); } /** * Remove alarm dismissal or snooze state * * @param string Task identifier */ public function clear_alarms($id) { // delete alarm entry $this->rc->db->query( "DELETE FROM " . $this->rc->db->table_name('kolab_alarms', true) . " WHERE `alarm_id` = ? AND `user_id` = ?", $id, $this->rc->user->ID ); return true; } /** * Get task tags */ private function load_tags(&$object) { // this task hasn't been migrated yet if (!empty($object['categories'])) { // OPTIONAL: call kolab_storage_config::apply_tags() to migrate the object $object['tags'] = (array)$object['categories']; if (!empty($object['tags'])) { $this->tags = array_merge($this->tags, $object['tags']); } } else { $config = kolab_storage_config::get_instance(); $tags = $config->get_tags($object['uid']); $object['tags'] = array_map(function($v) { return $v['name']; }, $tags); } } /** * Update task tags */ private function save_tags($uid, $tags) { $config = kolab_storage_config::get_instance(); $config->save_tags($uid, $tags); } /** * Find messages linked with a task record */ private function get_links($uid) { $config = kolab_storage_config::get_instance(); return $config->get_object_links($uid); } /** * */ private function save_links($uid, $links) { $config = kolab_storage_config::get_instance(); return $config->save_object_links($uid, (array) $links); } /** * Extract uid + list identifiers from the given input * * @param mixed array or string with task identifier(s) */ private function _parse_id(&$prop) { $id_ = null; if (is_array($prop)) { // 'uid' + 'list' available, nothing to be done if (!empty($prop['uid']) && !empty($prop['list'])) { return; } // 'id' is given if (!empty($prop['id'])) { if (!empty($prop['list'])) { $list_id = $prop['_fromlist'] ?: $prop['list']; if (strpos($prop['id'], $list_id.':') === 0) { $prop['uid'] = substr($prop['id'], strlen($list_id)+1); } else { $prop['uid'] = $prop['id']; } } else { $id_ = $prop['id']; } } } else { $id_ = strval($prop); $prop = array(); } // split 'id' into list + uid if (!empty($id_)) { list($list, $uid) = explode(':', $id_, 2); if (!empty($uid)) { $prop['uid'] = $uid; $prop['list'] = $list; } else { $prop['uid'] = $id_; } } } /** * Convert from Kolab_Format to internal representation */ private function _to_rcube_task($record, $list_id, $all = true) { $id_prefix = $list_id . ':'; $task = array( 'id' => $id_prefix . $record['uid'], 'uid' => $record['uid'], 'title' => $record['title'] ?? null, // 'location' => $record['location'], 'description' => $record['description'] ?? null, 'flagged' => ($record['priority'] ?? null) == 1, 'complete' => floatval(($record['complete'] ?? null) / 100), 'status' => $record['status'] ?? null, 'parent_id' => ($record['parent_id'] ?? null) ? $id_prefix . $record['parent_id'] : null, 'recurrence' => $record['recurrence'] ?? null, 'attendees' => $record['attendees'] ?? null, 'organizer' => $record['organizer'] ?? null, 'sequence' => $record['sequence'] ?? null, 'tags' => $record['tags'] ?? null, 'list' => $list_id, 'links' => $record['links'] ?? null, ); // we can sometimes skip this expensive operation if ($all && !array_key_exists('links', $task)) { $task['links'] = $this->get_links($task['uid']); } // convert from DateTime to internal date format if (($record['due'] ?? null) instanceof DateTimeInterface) { $due = $this->plugin->lib->adjust_timezone($record['due']); $task['date'] = $due->format('Y-m-d'); if (empty($record['due']->_dateonly)) { $task['time'] = $due->format('H:i'); } } // convert from DateTime to internal date format if (($record['start'] ?? null) instanceof DateTimeInterface) { $start = $this->plugin->lib->adjust_timezone($record['start']); $task['startdate'] = $start->format('Y-m-d'); if (empty($record['start']->_dateonly)) { $task['starttime'] = $start->format('H:i'); } } if (($record['changed'] ?? null) instanceof DateTimeInterface) { $task['changed'] = $record['changed']; } if (($record['created'] ?? null) instanceof DateTimeInterface) { $task['created'] = $record['created']; } if (!empty($record['valarms'])) { $task['valarms'] = $record['valarms']; } else if (!empty($record['alarms'])) { $task['alarms'] = $record['alarms']; } if (!empty($task['attendees'])) { foreach ((array)$task['attendees'] as $i => $attendee) { if (isset($attendee['delegated-from']) && is_array($attendee['delegated-from'])) { $task['attendees'][$i]['delegated-from'] = join(', ', $attendee['delegated-from']); } if (isset($attendee['delegated-to']) && is_array($attendee['delegated-to'])) { $task['attendees'][$i]['delegated-to'] = join(', ', $attendee['delegated-to']); } } } if (!empty($record['_attachments'])) { foreach ($record['_attachments'] as $key => $attachment) { if ($attachment !== false) { if (empty($attachment['name'])) { $attachment['name'] = $key; } $attachments[] = $attachment; } } $task['attachments'] = $attachments; } return $task; } /** * Convert the given task record into a data structure that can be passed to kolab_storage backend for saving * (opposite of self::_to_rcube_event()) */ private function _from_rcube_task($task, $old = []) { $object = $task; $id_prefix = $task['list'] . ':'; $toDT = function($date) { // Convert DateTime into libcalendaring_datetime return libcalendaring_datetime::createFromFormat( 'Y-m-d\\TH:i:s', $date->format('Y-m-d\\TH:i:s'), $date->getTimezone() ); }; if (!empty($task['date'])) { $object['due'] = $toDT(rcube_utils::anytodatetime($task['date'].' '.$task['time'], $this->plugin->timezone)); if (empty($task['time'])) { $object['due']->_dateonly = true; } unset($object['date']); } if (!empty($task['startdate'])) { $object['start'] = $toDT(rcube_utils::anytodatetime($task['startdate'].' '.$task['starttime'], $this->plugin->timezone)); if (empty($task['starttime'])) { $object['start']->_dateonly = true; } unset($object['startdate']); } // as per RFC (and the Kolab schema validation), start and due dates need to be of the same type (#3614) // this should be catched in the client already but just make sure we don't write invalid objects if (!empty($object['start']) && !empty($object['due']) && $object['due']->_dateonly != $object['start']->_dateonly) { $object['start']->_dateonly = true; $object['due']->_dateonly = true; } $object['complete'] = $task['complete'] * 100; if ($task['complete'] == 1.0 && empty($task['complete'])) $object['status'] = 'COMPLETED'; if (!empty($task['flagged'])) $object['priority'] = 1; else $object['priority'] = ($old['priority'] ?? 0) > 1 ? $old['priority'] : 0; // remove list: prefix from parent_id if (!empty($task['parent_id']) && strpos($task['parent_id'], $id_prefix) === 0) { $object['parent_id'] = substr($task['parent_id'], strlen($id_prefix)); } // copy meta data (starting with _) from old object foreach ((array)$old as $key => $val) { if (!isset($object[$key]) && $key[0] == '_') $object[$key] = $val; } // copy recurrence rules if the client didn't submit it (#2713) if (!array_key_exists('recurrence', $object) && $old['recurrence']) { $object['recurrence'] = $old['recurrence']; } unset($task['attachments']); kolab_format::merge_attachments($object, $old); // allow sequence increments if I'm the organizer if ($this->plugin->is_organizer($object) && empty($object['_method'])) { unset($object['sequence']); } else if (isset($old['sequence']) && empty($object['_method'])) { $object['sequence'] = $old['sequence']; } unset($object['tempid'], $object['raw'], $object['list'], $object['flagged'], $object['tags'], $object['created']); return $object; } /** * Add a single task to the database * * @param array Hash array with task properties (see header of tasklist_driver.php) * @return mixed New task ID on success, False on error */ public function create_task($task) { return $this->edit_task($task); } /** * Update an task entry with the given data * * @param array Hash array with task properties (see header of tasklist_driver.php) * @return boolean True on success, False on error */ public function edit_task($task) { $this->_parse_id($task); $list_id = $task['list']; if (!$list_id || !($folder = $this->get_folder($list_id))) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Invalid list identifer to save task: " . print_r($list_id, true)), true, false); return false; } // email links and tags are stored separately $links = $task['links'] ?? null; $tags = $task['tags'] ?? null; unset($task['tags'], $task['links']); // moved from another folder if (($task['_fromlist'] ?? false) && ($fromfolder = $this->get_folder($task['_fromlist']))) { if (!$fromfolder->move($task['uid'], $folder)) return false; unset($task['_fromlist']); } // load previous version of this task to merge $old = null; if (!empty($task['id'])) { $old = $folder->get_object($task['uid']); if (!$old || PEAR::isError($old)) return false; // merge existing properties if the update isn't complete if (!isset($task['title']) || !isset($task['complete'])) $task += $this->_to_rcube_task($old, $list_id); } // generate new task object from RC input $object = $this->_from_rcube_task($task, $old); $saved = $folder->save($object, 'task', $task['uid']); if (!$saved) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error saving task object to Kolab server"), true, false); $saved = false; } else { // save links in configuration.relation object $this->save_links($object['uid'], $links); // save tags in configuration.relation object $this->save_tags($object['uid'], $tags); $task = $this->_to_rcube_task($object, $list_id); $task['tags'] = (array) $tags; $this->tasks[$task['uid']] = $task; } return $saved; } /** * Move a single task to another list * * @param array Hash array with task properties: * @return boolean True on success, False on error * @see tasklist_driver::move_task() */ public function move_task($task) { $this->_parse_id($task); $list_id = $task['list']; if (!$list_id || !($folder = $this->get_folder($list_id))) return false; // execute move command if ($task['_fromlist'] && ($fromfolder = $this->get_folder($task['_fromlist']))) { return $fromfolder->move($task['uid'], $folder); } return false; } /** * Remove a single task from the database * * @param array Hash array with task properties: * id: Task identifier * @param boolean Remove record irreversible (mark as deleted otherwise, if supported by the backend) * @return boolean True on success, False on error */ public function delete_task($task, $force = true) { $this->_parse_id($task); $list_id = $task['list']; if (!$list_id || !($folder = $this->get_folder($list_id))) return false; $status = $folder->delete($task['uid']); if ($status) { // remove tag assignments // @TODO: don't do this when undelete feature will be implemented $this->save_tags($task['uid'], null); } return $status; } /** * Restores a single deleted task (if supported) * * @param array Hash array with task properties: * id: Task identifier * @return boolean True on success, False on error */ public function undelete_task($prop) { // TODO: implement this return false; } /** * Get attachment properties * * @param string $id Attachment identifier * @param array $task Hash array with event properties: * id: Task identifier * list: List identifier * rev: Revision (optional) * * @return array Hash array with attachment properties: * id: Attachment identifier * name: Attachment name * mimetype: MIME content type of the attachment * size: Attachment size */ public function get_attachment($id, $task) { // get old revision of the object if ($task['rev']) { $task = $this->get_task_revison($task, $task['rev']); } else { $task = $this->get_task($task); } if ($task && !empty($task['attachments'])) { foreach ($task['attachments'] as $att) { if ($att['id'] == $id) return $att; } } return null; } /** * Get attachment body * * @param string $id Attachment identifier * @param array $task Hash array with event properties: * id: Task identifier * list: List identifier * rev: Revision (optional) * * @return string Attachment body */ public function get_attachment_body($id, $task) { $this->_parse_id($task); // get old revision of event if ($task['rev']) { if (empty($this->bonnie_api)) { return false; } $cid = substr($id, 4); // call Bonnie API and get the raw mime message list($uid, $mailbox, $msguid) = $this->_resolve_task_identity($task); if ($msg_raw = $this->bonnie_api->rawdata('task', $uid, $task['rev'], $mailbox, $msguid)) { // parse the message and find the part with the matching content-id $message = rcube_mime::parse_message($msg_raw); foreach ((array)$message->parts as $part) { if ($part->headers['content-id'] && trim($part->headers['content-id'], '<>') == $cid) { return $part->body; } } } return false; } if ($storage = $this->get_folder($task['list'])) { return $storage->get_attachment($task['uid'], $id); } return false; } /** * Build a struct representing the given message reference * * @see tasklist_driver::get_message_reference() */ public function get_message_reference($uri_or_headers, $folder = null) { if (is_object($uri_or_headers)) { $uri_or_headers = kolab_storage_config::get_message_uri($uri_or_headers, $folder); } if (is_string($uri_or_headers)) { return kolab_storage_config::get_message_reference($uri_or_headers, 'task'); } return false; } /** * Find tasks assigned to a specified message * * @see tasklist_driver::get_message_related_tasks() */ public function get_message_related_tasks($headers, $folder) { $config = kolab_storage_config::get_instance(); $result = $config->get_message_relations($headers, $folder, 'task'); foreach ($result as $idx => $rec) { $result[$idx] = $this->_to_rcube_task($rec, kolab_storage::folder_id($rec['_mailbox'])); } return $result; } /** * */ public function tasklist_edit_form($action, $list, $fieldprop) { $this->_read_lists(); if ($list['id'] && ($list = $this->lists[$list['id']])) { $folder_name = $this->get_folder($list['id'])->name; // UTF7 } else { $folder_name = ''; } $storage = $this->rc->get_storage(); $delim = $storage->get_hierarchy_delimiter(); $form = array(); if (strlen($folder_name)) { $path_imap = explode($delim, $folder_name); array_pop($path_imap); // pop off name part $path_imap = implode($delim, $path_imap); $options = $storage->folder_info($folder_name); } else { $path_imap = ''; } $hidden_fields[] = array('name' => 'oldname', 'value' => $folder_name); // folder name (default field) $input_name = new html_inputfield(array('name' => 'name', 'id' => 'taskedit-tasklistname', 'size' => 20)); $fieldprop['name']['value'] = $input_name->show($list['editname'], array('disabled' => ($options['norename'] || $options['protected']))); // prevent user from moving folder if (!empty($options) && ($options['norename'] || $options['protected'])) { $hidden_fields[] = array('name' => 'parent', 'value' => $path_imap); } else { $select = kolab_storage::folder_selector('task', array('name' => 'parent', 'id' => 'taskedit-parentfolder'), $folder_name); $fieldprop['parent'] = array( 'id' => 'taskedit-parentfolder', 'label' => $this->plugin->gettext('parentfolder'), 'value' => $select->show($path_imap), ); } // General tab $form['properties'] = array( 'name' => $this->rc->gettext('properties'), 'fields' => array(), ); foreach (array('name','parent','showalarms') as $f) { $form['properties']['fields'][$f] = $fieldprop[$f]; } return kolab_utils::folder_form($form, $folder_name, 'tasklist', $hidden_fields); } }