diff --git a/plugins/kolab_addressbook/drivers/kolab/kolab_contacts.php b/plugins/kolab_addressbook/drivers/kolab/kolab_contacts.php index d7538b8a..369309ef 100644 --- a/plugins/kolab_addressbook/drivers/kolab/kolab_contacts.php +++ b/plugins/kolab_addressbook/drivers/kolab/kolab_contacts.php @@ -1,1402 +1,1403 @@ * @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 ($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'])) { + else if (isset($this->filter['ids']) && 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'] ?? null) . ($rec['prefix'] ?? null); + $str = ($rec['name'] ?? '') . ($rec['prefix'] ?? ''); case 'firstname': - $str .= ($rec['firstname'] ?? null) . ($rec['middlename'] ?? null) . ($rec['surname'] ?? null); + $str .= ($rec['firstname'] ?? '') . ($rec['middlename'] ?? '') . ($rec['surname'] ?? ''); break; case 'surname': - $str = ($rec['surname'] ?? null) . ($rec['firstname'] ?? null) . ($rec['middlename'] ?? null); + $str = ($rec['surname'] ?? '') . ($rec['firstname'] ?? '') . ($rec['middlename'] ?? ''); break; default: $str = $rec[$this->sort_col]; break; } - if ($rec['email'] ?? null) { + if (!empty($rec['email'])) { $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] ?? null)) { + if (isset($record[$col]) && is_array($record[$col])) { $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'] ?? null)) { + if (isset($record['address']) && is_array($record['address'])) { $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'] ?? null) && strlen($record['photo']) < 255 && !empty($record['_attachments'][$record['photo']])) { + if (!empty($record['photo']) && 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_auth/kolab_auth.php b/plugins/kolab_auth/kolab_auth.php index bac892ea..6a184afc 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] ?? 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]; + $email = is_array($record[$field] ?? null) ? array_filter($record[$field]) : ($record[$field] ?? null); 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]; + $organization = is_array($record[$field] ?? null) ? $record[$field][0] : ($record[$field] ?? null); 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 7e1b79ee..8a7412db 100644 --- a/plugins/kolab_files/lib/kolab_files_engine.php +++ b/plugins/kolab_files/lib/kolab_files_engine.php @@ -1,1814 +1,1815 @@ * * 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'] ?? false) { + if (!empty($caps['DOCEDIT'])) { $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'] ?? 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 (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'] ?? 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; + $form = ''; + // 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'] ?? 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'] ?? false) || ($_SESSION['kolab_files_caps']['WOPI'] ?? false)) { + if (!empty($_SESSION['kolab_files_caps']['MANTICORE']) || !empty($_SESSION['kolab_files_caps']['WOPI'])) { $_SESSION['kolab_files_caps']['DOCEDIT'] = true; - $_SESSION['kolab_files_caps']['DOCTYPE'] = $_SESSION['kolab_files_caps']['MANTICORE'] ? 'manticore' : 'wopi'; + $_SESSION['kolab_files_caps']['DOCTYPE'] = !empty($_SESSION['kolab_files_caps']['WOPI']) ? 'wopi' : 'manticore'; } 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'), ); $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'); $editor_type = null; $got_editor = null; - if ($this->file_data['viewer']['wopi'] ?? false) { + if (!empty($this->file_data['viewer']['wopi'])) { $editor_type = 'wopi'; $got_editor = ($viewer & 4); } - else if ($this->file_data['viewer']['manticore'] ?? false) { + else if (!empty($this->file_data['viewer']['manticore'])) { $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_notes/kolab_notes.php b/plugins/kolab_notes/kolab_notes.php index 994dcc9a..9528a39f 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'] ?? 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'] ?? null)) { + if (!$this->rc->output->ajax_call && empty($this->rc->output->env['framed'])) { $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 (!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_storage.php b/plugins/libkolab/lib/kolab_storage.php index e0551000..6977a139 100644 --- a/plugins/libkolab/lib/kolab_storage.php +++ b/plugins/libkolab/lib/kolab_storage.php @@ -1,1811 +1,1815 @@ * @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)); + $metadata = self::$imap->get_metadata("*", [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] ?? 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) + public static function object_prettyname($folder, &$folder_ns = null) { self::setup(); $found = false; $namespace = self::$imap->get_namespace(); - $prefix = null; + $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)) + 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) + if ($prefix) { $folder = html::quote($prefix) . ($folder !== '' ? ' ' . $folder : ''); + } - if (!$folder_ns) + if (empty($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] ?? 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'] ?? 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($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 2a1cda0c..1e51f041 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 {$this->href}" + 'message' => "Failed to fetch objects from {$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']; } }