diff --git a/plugins/libkolab/lib/kolab_storage_config.php b/plugins/libkolab/lib/kolab_storage_config.php index 036b8270..0097741a 100644 --- a/plugins/libkolab/lib/kolab_storage_config.php +++ b/plugins/libkolab/lib/kolab_storage_config.php @@ -1,866 +1,874 @@ * @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'; /** * Singleton instace of kolab_storage_config * * @var kolab_storage_config */ static protected $instance; private $folders; private $default; private $enabled; /** * 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 __construct() { // 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 if ($this->default && $this->default->name) { $this->enabled = true; } } /** * Check wether any configuration storage (folder) exists * * @return bool */ public function is_enabled() { return $this->enabled; } /** * 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(); 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) 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) { 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->enabled) { return false; } $folder = $this->find_folder($object); if ($type) { $object['type'] = $type; } return $folder->save($object, self::FOLDER_TYPE . '.' . $object['type'], $object['uid']); } /** * Remove configuration object * * @param string $uid Object UID * * @return bool True on success, False on failure */ public function delete($uid) { if (!$this->enabled) { return false; } // fetch the object to find folder $object = $this->get_object($uid); if (!$object) { return false; } $folder = $this->find_folder($object); return $folder->delete($uid); } /** * Find folder */ public function find_folder($object = array()) { // find folder object if ($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 = 'shared' . substr($folder, strlen($prefix)); + $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('shared')) { + if ($ns = $storage->get_namespace('other')) { if ($prefix = $ns[0][0]) { - $folder = 'user' . substr($folder, strlen($prefix)); + list($otheruser, $path) = explode('/', substr($folder, strlen($prefix)), 2); + $folder = 'user/' . $otheruser . $domain . '/' . $path; } } } else { - $folder = 'user' . '/' . $rcube->get_user_name() . '/' . $folder; + $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']) { $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 == 'shared') { $folder = 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; + $folder = $prefix . 'shared/' . $folder; } } } else 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 . '/' . $username . '/' . $folder; + $folder = $prefix . $user . '/' . $folder; } } } else if (!strlen($folder)) { $folder = 'INBOX'; } } else { return; } 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 * * @return array List of tags */ public function apply_tags(&$records) { // 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 = array_unique($tags); return $tags; } /** * 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) { if ($this->save($relation, 'relation')) { $this->tags[$idx] = $relation; // update in-memory cache } } 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', ); if ($this->save($relation, 'relation')) { $this->tags[] = $relation; // update in-memory cache } } } } /** * Get tags (all or referring to specified object) * * @param string $uid Optional object UID * * @return array List of Relation objects */ public function get_tags($uid = '*') { if (!isset($this->tags)) { $default = true; $filter = array( array('type', '=', 'relation'), array('category', '=', 'tag') ); // use faster method if ($uid && $uid != '*') { $filter[] = array('member', '=', $uid); $tags = $this->get_objects($filter, $default); } else { $this->tags = $tags = $this->get_objects($filter, $default); } } else { $tags = $this->tags; } if ($uid === '*') { return $tags; } $result = array(); $search = self::build_member_url($uid); foreach ($tags as $tag) { if (in_array($search, (array) $tag['members'])) { $result[] = $tag; } } return $result; } /** * Find objects linked with the given groupware object through a relation * * @param string Object UUID * @param 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); } /** * */ public function save_object_links($uid, $links, $remove = array()) { $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_diff($relation['members'], (array)$remove); $members = array_unique(array_merge($members, $links)); // make sure the object_uri is still a member if (!in_array($object_uri, $members)) { $members[$object_uri]; } // remove relation if no other members remain if (count($members) <= 1) { $done = $this->delete($relation['uid']); } // 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', ); $ret = $this->save($relation, 'relation'); } return $ret; } /** * 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, 100); } /** * 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); } 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 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; } } diff --git a/plugins/libkolab/tests/kolab_storage_config.php b/plugins/libkolab/tests/kolab_storage_config.php new file mode 100644 index 00000000..e27c0827 --- /dev/null +++ b/plugins/libkolab/tests/kolab_storage_config.php @@ -0,0 +1,114 @@ + 'Archive', + 'uid' => '9', + 'message-id' => '<1225270@example.org>', + 'date' => 'Mon, 20 Apr 2015 15:30:30 UTC', + 'subject' => 'Archived', + ); + private $url_personal = 'imap:///user/john.doe%40example.org/Archive/9?message-id=%3C1225270%40example.org%3E&date=Mon%2C+20+Apr+2015+15%3A30%3A30+UTC&subject=Archived'; + + private $params_shared = array( + 'folder' => 'Shared Folders/shared/Collected', + 'uid' => '4', + 'message-id' => '<5270122@example.org>', + 'date' => 'Mon, 20 Apr 2015 16:33:03 +0200', + 'subject' => 'Catch me if you can', + ); + private $url_shared = 'imap:///shared/Collected/4?message-id=%3C5270122%40example.org%3E&date=Mon%2C+20+Apr+2015+16%3A33%3A03+%2B0200&subject=Shared'; + + private $params_other = array( + 'folder' => 'Other Users/lucy.white/Mailings', + 'uid' => '378', + 'message-id' => '<22448899@example.org>', + 'date' => 'Tue, 14 Apr 2015 14:14:30 +0200', + 'subject' => 'Happy Holidays', + ); + private $url_other = 'imap:///user/lucy.white%40example.org/Mailings/378?message-id=%3C22448899%40example.org%3E&date=Tue%2C+14+Apr+2015+14%3A14%3A30+%2B0200&subject=Happy+Holidays'; + + + public static function setUpBeforeClass() + { + require_once __DIR__ . '/../../libkolab/libkolab.php'; + + $rcube = rcube::get_instance(); + $rcube->user = null; + + $lib = new libkolab($rcube->plugins); + $lib->init(); + + // fake some session data to make storage work without an actual IMAP connection + $_SESSION['username'] = 'john.doe@example.org'; + $_SESSION['imap_delimiter'] = '/'; + $_SESSION['imap_namespace'] = array( + 'personal' => array(array('','/')), + 'other' => array(array('Other Users/','/')), + 'shared' => array(array('Shared Folders/','/')), + 'prefix' => '', + ); + } + + function test_001_build_member_url() + { + $rcube = rcube::get_instance(); + $this->assertEquals('john.doe@example.org', $rcube->get_user_name()); + + // personal namespace + $url = kolab_storage_config::build_member_url($this->params_personal); + $this->assertEquals('imap:///user/john.doe%40example.org/Archive/9?message-id=%3C1225270%40example.org%3E&date=Mon%2C+20+Apr+2015+15%3A30%3A30+UTC&subject=Archived', $url); + + // shared namespace + $url = kolab_storage_config::build_member_url($this->params_shared); + $this->assertEquals('imap:///shared/Collected/4?message-id=%3C5270122%40example.org%3E&date=Mon%2C+20+Apr+2015+16%3A33%3A03+%2B0200&subject=Catch+me+if+you+can', $url); + + // other users namespace + $url = kolab_storage_config::build_member_url($this->params_other); + $this->assertEquals('imap:///user/lucy.white%40example.org/Mailings/378?message-id=%3C22448899%40example.org%3E&date=Tue%2C+14+Apr+2015+14%3A14%3A30+%2B0200&subject=Happy+Holidays', $url); + } + + function test_002_parse_member_url() + { + // personal namespace + $params = kolab_storage_config::parse_member_url($this->url_personal); + $this->assertEquals($this->params_personal['uid'], $params['uid']); + $this->assertEquals($this->params_personal['folder'], $params['folder']); + $this->assertEquals($this->params_personal['subject'], $params['params']['subject']); + $this->assertEquals($this->params_personal['message-id'], $params['params']['message-id']); + + // shared namespace + $params = kolab_storage_config::parse_member_url($this->url_shared); + $this->assertEquals($this->params_shared['uid'], $params['uid']); + $this->assertEquals($this->params_shared['folder'], $params['folder']); + + // other users namespace + $params = kolab_storage_config::parse_member_url($this->url_other); + $this->assertEquals($this->params_other['uid'], $params['uid']); + $this->assertEquals($this->params_other['folder'], $params['folder']); + } + + function test_003_build_parse_member_url() + { + // personal namespace + $params = $this->params_personal; + $params_ = kolab_storage_config::parse_member_url(kolab_storage_config::build_member_url($params)); + $this->assertEquals($params['uid'], $params_['uid']); + $this->assertEquals($params['folder'], $params_['folder']); + + // shared namespace + $params = $this->params_shared; + $params_ = kolab_storage_config::parse_member_url(kolab_storage_config::build_member_url($params)); + $this->assertEquals($params['uid'], $params_['uid']); + $this->assertEquals($params['folder'], $params_['folder']); + + // other users namespace + $params = $this->params_other; + $params_ = kolab_storage_config::parse_member_url(kolab_storage_config::build_member_url($params)); + $this->assertEquals($params['uid'], $params_['uid']); + $this->assertEquals($params['folder'], $params_['folder']); + } +} + + \ No newline at end of file