diff --git a/plugins/libkolab/lib/kolab_storage_config.php b/plugins/libkolab/lib/kolab_storage_config.php index dcc2d138..e5bdf451 100644 --- a/plugins/libkolab/lib/kolab_storage_config.php +++ b/plugins/libkolab/lib/kolab_storage_config.php @@ -1,1009 +1,1009 @@ * @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_config { const FOLDER_TYPE = 'configuration'; const MAX_RELATIONS = 499; /** * Singleton instace of kolab_storage_config * * @var kolab_storage_config */ static protected $instance; private $folders; private $default; private $enabled; private $tags; /** * This implements the 'singleton' design pattern * * @return kolab_storage_config The one and only instance */ static function get_instance() { if (!self::$instance) { self::$instance = new kolab_storage_config(); } return self::$instance; } /** * Private constructor (finds default configuration folder as a config source) */ private function _init() { if ($this->enabled !== null) { return $this->enabled; } // get all configuration folders $this->folders = kolab_storage::get_folders(self::FOLDER_TYPE, false); foreach ($this->folders as $folder) { if ($folder->default) { $this->default = $folder; break; } } // if no folder is set as default, choose the first one if (!$this->default) { $this->default = reset($this->folders); } // attempt to create a default folder if it does not exist if (!$this->default) { $folder_name = 'Configuration'; $folder_type = self::FOLDER_TYPE . '.default'; if (kolab_storage::folder_create($folder_name, $folder_type, true)) { $this->default = new kolab_storage_folder($folder_name, $folder_type); } } // check if configuration folder exist return $this->enabled = $this->default && $this->default->name; } /** * Check wether any configuration storage (folder) exists * * @return bool */ public function is_enabled() { return $this->_init(); } /** * Get configuration objects * * @param array $filter Search filter * @param bool $default Enable to get objects only from default folder * @param int $limit Max. number of records (per-folder) * * @return array List of objects */ public function get_objects($filter = array(), $default = false, $limit = 0) { $list = array(); if (!$this->is_enabled()) { return $list; } foreach ($this->folders as $folder) { // we only want to read from default folder if ($default && !$folder->default) { continue; } // for better performance it's good to assume max. number of records if ($limit) { $folder->set_order_and_limit(null, $limit); } foreach ($folder->select($filter, true) as $object) { unset($object['_formatobj']); $list[] = $object; } } return $list; } /** * Get configuration object * * @param string $uid Object UID * @param bool $default Enable to get objects only from default folder * * @return array Object data */ public function get_object($uid, $default = false) { if (!$this->is_enabled()) { return; } foreach ($this->folders as $folder) { // we only want to read from default folder if ($default && !$folder->default) { continue; } if ($object = $folder->get_object($uid)) { return $object; } } } /** * Create/update configuration object * * @param array $object Object data * @param string $type Object type * * @return bool True on success, False on failure */ public function save(&$object, $type) { if (!$this->is_enabled()) { return false; } $folder = $this->find_folder($object); if ($type) { $object['type'] = $type; } $status = $folder->save($object, self::FOLDER_TYPE . '.' . ($object['type'] ?? null), $object['uid'] ?? null); // on success, update cached tags list if ($status && ($object['category'] ?? null) == 'tag' && is_array($this->tags)) { $found = false; unset($object['_formatobj']); // we don't need it anymore foreach ($this->tags as $idx => $tag) { if ($tag['uid'] == $object['uid']) { $found = true; $this->tags[$idx] = $object; } } if (!$found) { $this->tags[] = $object; } } return !empty($status); } /** * Remove configuration object * * @param string|array $object Object array or its UID * * @return bool True on success, False on failure */ public function delete($object) { if (!$this->is_enabled()) { return false; } // fetch the object to find folder if (!is_array($object)) { $object = $this->get_object($object); } if (!$object) { return false; } $folder = $this->find_folder($object); $status = $folder->delete($object); // on success, update cached tags list if ($status && is_array($this->tags)) { foreach ($this->tags as $idx => $tag) { if ($tag['uid'] == $object['uid']) { unset($this->tags[$idx]); break; } } } return $status; } /** * Find folder */ public function find_folder($object = array()) { if (!$this->is_enabled()) { return; } // find folder object if (!empty($object['_mailbox'])) { foreach ($this->folders as $folder) { if ($folder->name == $object['_mailbox']) { break; } } } else { $folder = $this->default; } return $folder; } /** * Builds relation member URI * * @param string|array Object UUID or Message folder, UID, Search headers (Message-Id, Date) * * @return string $url Member URI */ public static function build_member_url($params) { // param is object UUID if (is_string($params) && !empty($params)) { return 'urn:uuid:' . $params; } if (empty($params) || !strlen($params['folder'])) { return null; } $rcube = rcube::get_instance(); $storage = $rcube->get_storage(); list($username, $domain) = explode('@', $rcube->get_user_name()); if (strlen($domain)) { $domain = '@' . $domain; } // modify folder spec. according to namespace $folder = $params['folder']; $ns = $storage->folder_namespace($folder); if ($ns == 'shared') { // Note: this assumes there's only one shared namespace root if ($ns = $storage->get_namespace('shared')) { if ($prefix = $ns[0][0]) { $folder = substr($folder, strlen($prefix)); } } } else { if ($ns == 'other') { // Note: this assumes there's only one other users namespace root if ($ns = $storage->get_namespace('other')) { if ($prefix = $ns[0][0]) { - list($otheruser, $path) = explode('/', substr($folder, strlen($prefix)), 2); + list($otheruser, $path) = array_pad(explode('/', substr($folder, strlen($prefix)), 2), 2, ''); $folder = 'user/' . $otheruser . $domain . '/' . $path; } } } else { $folder = 'user/' . $username . $domain . '/' . $folder; } } $folder = implode('/', array_map('rawurlencode', explode('/', $folder))); // build URI $url = 'imap:///' . $folder; // UID is optional here because sometimes we want // to build just a member uri prefix - if ($params['uid']) { + if (!empty($params['uid'])) { $url .= '/' . $params['uid']; } unset($params['folder']); unset($params['uid']); if (!empty($params)) { $url .= '?' . http_build_query($params, '', '&'); } return $url; } /** * Parses relation member string * * @param string $url Member URI * * @return array Message folder, UID, Search headers (Message-Id, Date) */ public static function parse_member_url($url) { // Look for IMAP URI: // imap:///(user/username@domain|shared)//? if (strpos($url, 'imap:///') === 0) { $rcube = rcube::get_instance(); $storage = $rcube->get_storage(); // parse_url does not work with imap:/// prefix $url = parse_url(substr($url, 8)); $path = explode('/', $url['path']); parse_str($url['query'], $params); $uid = array_pop($path); $ns = array_shift($path); $path = array_map('rawurldecode', $path); // resolve folder name if ($ns == 'user') { $username = array_shift($path); $folder = implode('/', $path); if ($username != $rcube->get_user_name()) { list($user, $domain) = explode('@', $username); // Note: this assumes there's only one other users namespace root if ($ns = $storage->get_namespace('other')) { if ($prefix = $ns[0][0]) { $folder = $prefix . $user . '/' . $folder; } } } else if (!strlen($folder)) { $folder = 'INBOX'; } } else { $folder = $ns . '/' . implode('/', $path); // Note: this assumes there's only one shared namespace root if ($ns = $storage->get_namespace('shared')) { if ($prefix = $ns[0][0]) { $folder = $prefix . $folder; } } } return array( 'folder' => $folder, 'uid' => $uid, 'params' => $params, ); } return false; } /** * Build array of member URIs from set of messages * * @param string $folder Folder name * @param array $messages Array of rcube_message objects * * @return array List of members (IMAP URIs) */ public static function build_members($folder, $messages) { $members = array(); foreach ((array) $messages as $msg) { $params = array( 'folder' => $folder, 'uid' => $msg->uid, ); // add search parameters: // we don't want to build "invalid" searches e.g. that // will return false positives (more or wrong messages) if (($messageid = $msg->get('message-id', false)) && ($date = $msg->get('date', false))) { $params['message-id'] = $messageid; $params['date'] = $date; if ($subject = $msg->get('subject', false)) { $params['subject'] = substr($subject, 0, 256); } } $members[] = self::build_member_url($params); } return $members; } /** * Resolve/validate/update members (which are IMAP URIs) of relation object. * * @param array $tag Tag object * @param bool $force Force members list update * * @return array Folder/UIDs list */ public static function resolve_members(&$tag, $force = true) { $result = array(); foreach ((array) $tag['members'] as $member) { // IMAP URI members if ($url = self::parse_member_url($member)) { $folder = $url['folder']; if (!$force) { $result[$folder][] = $url['uid']; } else { $result[$folder]['uid'][] = $url['uid']; $result[$folder]['params'][] = $url['params']; $result[$folder]['member'][] = $member; } } } if (empty($result) || !$force) { return $result; } $rcube = rcube::get_instance(); $storage = $rcube->get_storage(); $search = array(); $missing = array(); // first we search messages by Folder+UID foreach ($result as $folder => $data) { // @FIXME: maybe better use index() which is cached? // @TODO: consider skip_deleted option $index = $storage->search_once($folder, 'UID ' . rcube_imap_generic::compressMessageSet($data['uid'])); $uids = $index->get(); // messages that were not found need to be searched by search parameters $not_found = array_diff($data['uid'], $uids); if (!empty($not_found)) { foreach ($not_found as $uid) { $idx = array_search($uid, $data['uid']); if ($p = $data['params'][$idx]) { $search[] = $p; } $missing[] = $result[$folder]['member'][$idx]; unset($result[$folder]['uid'][$idx]); unset($result[$folder]['params'][$idx]); unset($result[$folder]['member'][$idx]); } } $result[$folder] = $uids; } // search in all subscribed mail folders using search parameters if (!empty($search)) { // remove not found members from the members list $tag['members'] = array_diff($tag['members'], $missing); // get subscribed folders $folders = $storage->list_folders_subscribed('', '*', 'mail', null, true); // @TODO: do this search in chunks (for e.g. 10 messages)? $search_str = ''; foreach ($search as $p) { $search_params = array(); foreach ($p as $key => $val) { $key = strtoupper($key); // don't search by subject, we don't want false-positives if ($key != 'SUBJECT') { $search_params[] = 'HEADER ' . $key . ' ' . rcube_imap_generic::escape($val); } } $search_str .= ' (' . implode(' ', $search_params) . ')'; } $search_str = trim(str_repeat(' OR', count($search)-1) . $search_str); // search $search = $storage->search_once($folders, $search_str); // handle search result $folders = (array) $search->get_parameters('MAILBOX'); foreach ($folders as $folder) { $set = $search->get_set($folder); $uids = $set->get(); if (!empty($uids)) { $msgs = $storage->fetch_headers($folder, $uids, false); $members = self::build_members($folder, $msgs); // merge new members into the tag members list $tag['members'] = array_merge($tag['members'], $members); // add UIDs into the result $result[$folder] = array_unique(array_merge((array)$result[$folder], $uids)); } } // update tag object with new members list $tag['members'] = array_unique($tag['members']); kolab_storage_config::get_instance()->save($tag, 'relation', false); } return $result; } /** * Assign tags to kolab objects * * @param array $records List of kolab objects * @param bool $no_return Don't return anything * * @return array List of tags */ public function apply_tags(&$records, $no_return = false) { if (empty($records) && $no_return) { return; } // first convert categories into tags foreach ($records as $i => $rec) { if (!empty($rec['categories'])) { $folder = new kolab_storage_folder($rec['_mailbox']); if ($object = $folder->get_object($rec['uid'])) { $tags = $rec['categories']; unset($object['categories']); unset($records[$i]['categories']); $this->save_tags($rec['uid'], $tags); $folder->save($object, $rec['_type'], $rec['uid']); } } } $tags = array(); // assign tags to objects foreach ($this->get_tags() as $tag) { foreach ($records as $idx => $rec) { $uid = self::build_member_url($rec['uid']); if (in_array($uid, (array) $tag['members'])) { $records[$idx]['tags'][] = $tag['name']; } } $tags[] = $tag['name']; } $tags = $no_return ? null : array_unique($tags); return $tags; } /** * Assign links (relations) to kolab objects * * @param array $records List of kolab objects */ public function apply_links(&$records) { $links = array(); $uids = array(); $ids = array(); $limit = 25; // get list of object UIDs and UIRs map foreach ($records as $i => $rec) { $uids[] = $rec['uid']; // there can be many objects with the same uid (recurring events) $ids[self::build_member_url($rec['uid'])][] = $i; $records[$i]['links'] = array(); } if (!empty($uids)) { $uids = array_unique($uids); } // The whole story here is to not do SELECT for every object. // We'll build one SELECT for many (limit above) objects at once while (!empty($uids)) { $chunk = array_splice($uids, 0, $limit); $chunk = array_map(function($v) { return array('member', '=', $v); }, $chunk); $filter = array( array('type', '=', 'relation'), array('category', '=', 'generic'), array($chunk, 'OR'), ); $relations = $this->get_objects($filter, true, self::MAX_RELATIONS); foreach ($relations as $relation) { $links[$relation['uid']] = $relation; } } if (empty($links)) { return; } // assign links of related messages foreach ($links as $relation) { // make relation members up-to-date kolab_storage_config::resolve_members($relation); $members = array(); foreach ((array) $relation['members'] as $member) { if (strpos($member, 'imap://') === 0) { $members[$member] = $member; } } $members = array_values($members); // assign links to objects foreach ((array) $relation['members'] as $member) { if (!empty($ids[$member])) { foreach ($ids[$member] as $i) { $records[$i]['links'] = array_unique(array_merge($records[$i]['links'] ?? [], $members)); } } } } } /** * Update object tags * * @param string $uid Kolab object UID * @param array $tags List of tag names */ public function save_tags($uid, $tags) { $url = self::build_member_url($uid); $relations = $this->get_tags(); foreach ($relations as $idx => $relation) { $selected = !empty($tags) && in_array($relation['name'], $tags); $found = !empty($relation['members']) && in_array($url, $relation['members']); $update = false; // remove member from the relation if ($found && !$selected) { $relation['members'] = array_diff($relation['members'], (array) $url); $update = true; } // add member to the relation else if (!$found && $selected) { $relation['members'][] = $url; $update = true; } if ($update) { $this->save($relation, 'relation'); } if ($selected) { $tags = array_diff($tags, array($relation['name'])); } } // create new relations if (!empty($tags)) { foreach ($tags as $tag) { $relation = array( 'name' => $tag, 'members' => (array) $url, 'category' => 'tag', ); $this->save($relation, 'relation'); } } } /** * Get tags (all or referring to specified object) * * @param string $member Optional object UID or mail message-id * * @return array List of Relation objects */ public function get_tags($member = '*') { if (!isset($this->tags)) { $default = true; $filter = array( array('type', '=', 'relation'), array('category', '=', 'tag') ); // use faster method if ($member && $member != '*') { $filter[] = array('member', '=', $member); $tags = $this->get_objects($filter, $default, self::MAX_RELATIONS); } else { $this->tags = $tags = $this->get_objects($filter, $default, self::MAX_RELATIONS); } } else { $tags = $this->tags; } if ($member === '*') { return $tags; } $result = array(); if ($member[0] == '<') { $search_msg = urlencode($member); } else { $search_uid = self::build_member_url($member); } foreach ($tags as $tag) { if (!empty($search_uid) && in_array($search_uid, (array) $tag['members'])) { $result[] = $tag; } else if (!empty($search_msg)) { foreach ($tag['members'] as $m) { if (strpos($m, $search_msg) !== false) { $result[] = $tag; break; } } } } return $result; } /** * Find objects linked with the given groupware object through a relation * * @param string Object UUID * * @return array List of related URIs */ public function get_object_links($uid) { $links = array(); $object_uri = self::build_member_url($uid); foreach ($this->get_relations_for_member($uid) as $relation) { if (in_array($object_uri, (array) $relation['members'])) { // make relation members up-to-date kolab_storage_config::resolve_members($relation); foreach ($relation['members'] as $member) { if ($member != $object_uri) { $links[] = $member; } } } } return array_unique($links); } /** * Save relations of an object. * Note, that we already support only one-to-one relations. * So, all relations to the object that are not provided in $links * argument will be removed. * * @param string $uid Object UUID * @param array $links List of related-object URIs * * @return bool True on success, False on failure */ public function save_object_links($uid, $links) { $object_uri = self::build_member_url($uid); $relations = $this->get_relations_for_member($uid); $done = false; foreach ($relations as $relation) { // make relation members up-to-date kolab_storage_config::resolve_members($relation); // remove and add links $members = array($object_uri); $members = array_unique(array_merge($members, $links)); // remove relation if no other members remain if (count($members) <= 1) { $done = $this->delete($relation); } // update relation object if members changed else if (count(array_diff($members, $relation['members'])) || count(array_diff($relation['members'], $members))) { $relation['members'] = $members; $done = $this->save($relation, 'relation'); $links = array(); } // no changes, we're happy else { $done = true; $links = array(); } } // create a new relation if (!$done && !empty($links)) { $relation = array( 'members' => array_merge($links, array($object_uri)), 'category' => 'generic', ); $done = $this->save($relation, 'relation'); } return $done; } /** * Find relation objects referring to specified note */ public function get_relations_for_member($uid, $reltype = 'generic') { $default = true; $filter = array( array('type', '=', 'relation'), array('category', '=', $reltype), array('member', '=', $uid), ); return $this->get_objects($filter, $default, self::MAX_RELATIONS); } /** * Find kolab objects assigned to specified e-mail message * * @param rcube_message $message E-mail message * @param string $folder Folder name * @param string $type Result objects type * * @return array List of kolab objects */ public function get_message_relations($message, $folder, $type) { static $_cache = array(); $result = array(); $uids = array(); $default = true; $uri = self::get_message_uri($message, $folder); $filter = array( array('type', '=', 'relation'), array('category', '=', 'generic'), ); // query by message-id $member_id = $message->get('message-id', false); if (empty($member_id)) { // derive message identifier from URI $member_id = md5($uri); } $filter[] = array('member', '=', $member_id); if (!isset($_cache[$uri])) { // get UIDs of related groupware objects foreach ($this->get_objects($filter, $default) as $relation) { // we don't need to update members if the URI is found if (!in_array($uri, $relation['members'])) { // update members... $messages = kolab_storage_config::resolve_members($relation); // ...and check again if (empty($messages[$folder]) || !in_array($message->uid, $messages[$folder])) { continue; } } // find groupware object UID(s) foreach ($relation['members'] as $member) { if (strpos($member, 'urn:uuid:') === 0) { $uids[] = substr($member, 9); } } } // remember this lookup $_cache[$uri] = $uids; } else { $uids = $_cache[$uri]; } // get kolab objects of specified type if (!empty($uids)) { $query = array(array('uid', '=', array_unique($uids))); $result = kolab_storage::select($query, $type, count($uids)); } return $result; } /** * Build a URI representing the given message reference */ public static function get_message_uri($headers, $folder) { $params = array( 'folder' => $headers->folder ?: $folder, 'uid' => $headers->uid, ); if (($messageid = $headers->get('message-id', false)) && ($date = $headers->get('date', false))) { $params['message-id'] = $messageid; $params['date'] = $date; if ($subject = $headers->get('subject')) { $params['subject'] = $subject; } } return self::build_member_url($params); } /** * Resolve the email message reference from the given URI */ public static function get_message_reference($uri, $rel = null) { if ($linkref = self::parse_member_url($uri)) { $linkref['subject'] = $linkref['params']['subject']; $linkref['uri'] = $uri; $rcmail = rcube::get_instance(); if (method_exists($rcmail, 'url')) { $linkref['mailurl'] = $rcmail->url(array( 'task' => 'mail', 'action' => 'show', 'mbox' => $linkref['folder'], 'uid' => $linkref['uid'], 'rel' => $rel, )); } unset($linkref['params']); } return $linkref; } }