diff --git a/plugins/libkolab/lib/kolab_storage.php b/plugins/libkolab/lib/kolab_storage.php index 381afe54..b6f773f9 100644 --- a/plugins/libkolab/lib/kolab_storage.php +++ b/plugins/libkolab/lib/kolab_storage.php @@ -1,1795 +1,1795 @@ * @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 (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() { self::setup(); $url = 'https://' . $_SESSION['imap_host'] . '/freebusy'; $url = self::$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'); if ($start instanceof \DateTime) { $start->setTimezone($utc); $param['dtstart'] = $start->format('Ymd\THis\Z'); } if ($end instanceof \DateTime) { $end->setTimezone($utc); $param['dtend'] = $end->format('Ymd\THis\Z'); } if (!empty($param)) { $query = '?' . http_build_query($param); } return self::get_freebusy_server() . '/' . $email . '.ifb' . $query; } /** * Creates folder ID from folder name * * @param string $folder Folder name (UTF7-IMAP) * @param boolean $enc Use lossless encoding * @return string Folder ID string */ public static function folder_id($folder, $enc = null) { return $enc == true || ($enc === null && self::$encode_ids) ? self::id_encode($folder) : asciiwords(strtr($folder, '/.-', '___')); } /** * Encode the given ID to a safe ascii representation * * @param string $id Arbitrary identifier string * * @return string Ascii representation */ public static function id_encode($id) { return rtrim(strtr(base64_encode($id), '+/', '-_'), '='); } /** * Convert the given identifier back to it's raw value * * @param string $id Ascii identifier * @return string Raw identifier string */ public static function id_decode($id) { return base64_decode(str_pad(strtr($id, '-_', '+/'), strlen($id) % 4, '=', STR_PAD_RIGHT)); } /** * Return the (first) path of the requested IMAP namespace * * @param string Namespace name (personal, shared, other) * @return string IMAP root path for that namespace */ public static function namespace_root($name) { self::setup(); foreach ((array)self::$imap->get_namespace($name) as $paths) { if (strlen($paths[0]) > 1) { return $paths[0]; } } return ''; } /** * Deletes IMAP folder * * @param string $name Folder name (UTF7-IMAP) * * @return bool True on success, false on failure */ public static function folder_delete($name) { // clear cached entries first if ($folder = self::get_folder($name)) $folder->cache->purge(); $rcmail = rcube::get_instance(); $plugin = $rcmail->plugins->exec_hook('folder_delete', array('name' => $name)); $success = self::$imap->delete_folder($name); self::$last_error = self::$imap->get_error_str(); return $success; } /** * Creates IMAP folder * * @param string $name Folder name (UTF7-IMAP) * @param string $type Folder type * @param bool $subscribed Sets folder subscription * @param bool $active Sets folder state (client-side subscription) * * @return bool True on success, false on failure */ public static function folder_create($name, $type = null, $subscribed = false, $active = false) { self::setup(); $rcmail = rcube::get_instance(); $plugin = $rcmail->plugins->exec_hook('folder_create', array('record' => array( 'name' => $name, 'subscribe' => $subscribed, ))); if ($saved = self::$imap->create_folder($name, $subscribed)) { // set metadata for folder type if ($type) { $saved = self::set_folder_type($name, $type); // revert if metadata could not be set if (!$saved) { self::$imap->delete_folder($name); } // activate folder else if ($active) { self::set_state($name, true); } } } if ($saved) { return true; } self::$last_error = self::$imap->get_error_str(); return false; } /** * Renames IMAP folder * * @param string $oldname Old folder name (UTF7-IMAP) * @param string $newname New folder name (UTF7-IMAP) * * @return bool True on success, false on failure */ public static function folder_rename($oldname, $newname) { self::setup(); $rcmail = rcube::get_instance(); $plugin = $rcmail->plugins->exec_hook('folder_rename', array( 'oldname' => $oldname, 'newname' => $newname)); $oldfolder = self::get_folder($oldname); $active = self::folder_is_active($oldname); $success = self::$imap->rename_folder($oldname, $newname); self::$last_error = self::$imap->get_error_str(); // pass active state to new folder name if ($success && $active) { self::set_state($oldname, false); self::set_state($newname, true); } // assign existing cache entries to new resource uri if ($success && $oldfolder) { $oldfolder->cache->rename($newname); } return $success; } /** * Rename or Create a new IMAP folder. * * Does additional checks for permissions and folder name restrictions * * @param array &$prop Hash array with folder properties and metadata * - name: Folder name * - oldname: Old folder name when changed * - parent: Parent folder to create the new one in * - type: Folder type to create * - subscribed: Subscribed flag (IMAP subscription) * - active: Activation flag (client-side subscription) * * @return string|false New folder name or False on failure * * @see self::set_folder_props() for list of other properties */ public static function folder_update(&$prop) { self::setup(); $folder = rcube_charset::convert($prop['name'], RCUBE_CHARSET, 'UTF7-IMAP'); $oldfolder = $prop['oldname']; // UTF7 $parent = $prop['parent']; // UTF7 $delimiter = self::$imap->get_hierarchy_delimiter(); if (strlen($oldfolder)) { $options = self::$imap->folder_info($oldfolder); } if (!empty($options) && ($options['norename'] || $options['protected'])) { } // sanity checks (from steps/settings/save_folder.inc) else if (!strlen($folder)) { self::$last_error = 'cannotbeempty'; return false; } else if (strlen($folder) > 128) { self::$last_error = 'nametoolong'; return false; } else { // these characters are problematic e.g. when used in LIST/LSUB foreach (array($delimiter, '%', '*') as $char) { if (strpos($folder, $char) !== false) { self::$last_error = 'forbiddencharacter'; return false; } } } if (!empty($options) && ($options['protected'] || $options['norename'])) { $folder = $oldfolder; } else if (strlen($parent)) { $folder = $parent . $delimiter . $folder; } else { // add namespace prefix (when needed) $folder = self::$imap->mod_folder($folder, 'in'); } // Check access rights to the parent folder if (strlen($parent) && (!strlen($oldfolder) || $oldfolder != $folder)) { $parent_opts = self::$imap->folder_info($parent); if ($parent_opts['namespace'] != 'personal' && (empty($parent_opts['rights']) || !preg_match('/[ck]/', implode($parent_opts['rights']))) ) { self::$last_error = 'No permission to create folder'; return false; } } // update the folder name if (strlen($oldfolder)) { if ($oldfolder != $folder) { $result = self::folder_rename($oldfolder, $folder); } else { $result = true; } } // create new folder else { $result = self::folder_create($folder, $prop['type'], $prop['subscribed'], $prop['active']); } if ($result) { self::set_folder_props($folder, $prop); } return $result ? $folder : false; } /** * Getter for human-readable name of Kolab object (folder) * with kolab_custom_display_names support. * See http://wiki.kolab.org/UI-Concepts/Folder-Listing for reference * * @param string $folder IMAP folder name (UTF7-IMAP) * @param string $folder_ns Will be set to namespace name of the folder * * @return string Name of the folder-object */ public static function object_name($folder, &$folder_ns=null) { // find custom display name in folder METADATA if ($name = self::custom_displayname($folder)) { return $name; } return self::object_prettyname($folder, $folder_ns); } /** * Get custom display name (saved in metadata) for the given folder */ public static function custom_displayname($folder) { static $_metadata; // find custom display name in folder METADATA if (self::$config->get('kolab_custom_display_names', true) && self::setup()) { if ($_metadata !== null) { $metadata = $_metadata; } else { // For performance reasons ask for all folders, it will be cached as one cache entry $metadata = self::$imap->get_metadata("*", array(self::NAME_KEY_PRIVATE, self::NAME_KEY_SHARED)); // If cache is disabled store result in memory if (!self::$config->get('imap_cache')) { $_metadata = $metadata; } } if ($data = $metadata[$folder]) { if (($name = $data[self::NAME_KEY_PRIVATE]) || ($name = $data[self::NAME_KEY_SHARED])) { return $name; } } } return false; } /** * Getter for human-readable name of Kolab object (folder) * See http://wiki.kolab.org/UI-Concepts/Folder-Listing for reference * * @param string $folder IMAP folder name (UTF7-IMAP) * @param string $folder_ns Will be set to namespace name of the folder * * @return string Name of the folder-object */ public static function object_prettyname($folder, &$folder_ns=null) { self::setup(); $found = false; $namespace = self::$imap->get_namespace(); if (!empty($namespace['shared'])) { foreach ($namespace['shared'] as $ns) { if (strlen($ns[0]) && strpos($folder, $ns[0]) === 0) { $prefix = ''; $folder = substr($folder, strlen($ns[0])); $delim = $ns[1]; $found = true; $folder_ns = 'shared'; break; } } } if (!$found && !empty($namespace['other'])) { foreach ($namespace['other'] as $ns) { if (strlen($ns[0]) && strpos($folder, $ns[0]) === 0) { // remove namespace prefix and extract username $folder = substr($folder, strlen($ns[0])); $delim = $ns[1]; // get username part and map it to user name $pos = strpos($folder, $delim); $fid = $pos ? substr($folder, 0, $pos) : $folder; if ($user = self::folder_id2user($fid, true)) { $fid = str_replace($delim, '', $user); } $prefix = "($fid)"; $folder = $pos ? substr($folder, $pos + 1) : ''; $found = true; $folder_ns = 'other'; break; } } } if (!$found && !empty($namespace['personal'])) { foreach ($namespace['personal'] as $ns) { if (strlen($ns[0]) && strpos($folder, $ns[0]) === 0) { // remove namespace prefix $folder = substr($folder, strlen($ns[0])); $prefix = ''; $delim = $ns[1]; $found = true; break; } } } if (empty($delim)) $delim = self::$imap->get_hierarchy_delimiter(); $folder = rcube_charset::convert($folder, 'UTF7-IMAP'); $folder = html::quote($folder); $folder = str_replace(html::quote($delim), ' » ', $folder); if ($prefix) $folder = html::quote($prefix) . ($folder !== '' ? ' ' . $folder : ''); if (!$folder_ns) $folder_ns = 'personal'; return $folder; } /** * Helper method to generate a truncated folder name to display. * Note: $origname is a string returned by self::object_name() */ public static function folder_displayname($origname, &$names) { $name = $origname; // find folder prefix to truncate for ($i = count($names)-1; $i >= 0; $i--) { if (strpos($name, $names[$i] . ' » ') === 0) { $length = strlen($names[$i] . ' » '); $prefix = substr($name, 0, $length); $count = count(explode(' » ', $prefix)); $diff = 1; // check if prefix folder is in other users namespace for ($n = count($names)-1; $n >= 0; $n--) { if (strpos($prefix, '(' . $names[$n] . ') ') === 0) { $diff = 0; break; } } $name = str_repeat('   ', $count - $diff) . '» ' . substr($name, $length); break; } // other users namespace and parent folder exists else if (strpos($name, '(' . $names[$i] . ') ') === 0) { $length = strlen('(' . $names[$i] . ') '); $prefix = substr($name, 0, $length); $count = count(explode(' » ', $prefix)); $name = str_repeat('   ', $count) . '» ' . substr($name, $length); break; } } $names[] = $origname; return $name; } /** * Creates a SELECT field with folders list * * @param string $type Folder type * @param array $attrs SELECT field attributes (e.g. name) * @param string $current The name of current folder (to skip it) * * @return html_select SELECT object */ public static function folder_selector($type, $attrs, $current = '') { // get all folders of specified type (sorted) $folders = self::get_folders($type, true); $delim = self::$imap->get_hierarchy_delimiter(); $names = array(); $len = strlen($current); if ($len && ($rpos = strrpos($current, $delim))) { $parent = substr($current, 0, $rpos); $p_len = strlen($parent); } // Filter folders list foreach ($folders as $c_folder) { $name = $c_folder->name; // skip current folder and it's subfolders if ($len) { if ($name == $current) { // Make sure parent folder is listed (might be skipped e.g. if it's namespace root) if ($p_len && !isset($names[$parent])) { $names[$parent] = self::object_name($parent); } continue; } if (strpos($name, $current.$delim) === 0) { continue; } } // always show the parent of current folder if ($p_len && $name == $parent) { } // skip folders where user have no rights to create subfolders else if ($c_folder->get_owner() != $_SESSION['username']) { $rights = $c_folder->get_myrights(); if (!preg_match('/[ck]/', $rights)) { continue; } } $names[$name] = $c_folder->get_name(); } // Build SELECT field of parent folder $attrs['is_escaped'] = true; $select = new html_select($attrs); $select->add('---', ''); $listnames = array(); foreach (array_keys($names) as $imap_name) { $name = $origname = $names[$imap_name]; // find folder prefix to truncate for ($i = count($listnames)-1; $i >= 0; $i--) { if (strpos($name, $listnames[$i].' » ') === 0) { $length = strlen($listnames[$i].' » '); $prefix = substr($name, 0, $length); $count = count(explode(' » ', $prefix)); $name = str_repeat('  ', $count-1) . '» ' . substr($name, $length); break; } } $listnames[] = $origname; $select->add($name, $imap_name); } return $select; } /** * Returns a list of folder names * * @param string Optional root folder * @param string Optional name pattern * @param string Data type to list folders for (contact,event,task,journal,file,note,mail,configuration) * @param boolean Enable to return subscribed folders only (null to use configured subscription mode) * @param array Will be filled with folder-types data * * @return array List of folders */ public static function list_folders($root = '', $mbox = '*', $filter = null, $subscribed = null, &$folderdata = array()) { if (!self::setup()) { return null; } // use IMAP subscriptions if ($subscribed === null && self::$config->get('kolab_use_subscriptions')) { $subscribed = true; } if (!$filter) { // Get ALL folders list, standard way if ($subscribed) { $folders = self::_imap_list_subscribed($root, $mbox); } 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); } 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]; 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) + protected static function _imap_list_subscribed($root, $mbox) { $folders = self::$imap->list_folders_subscribed($root, $mbox); // add temporarily subscribed folders if (self::$with_tempsubs && is_array($_SESSION['kolab_subscribed_folders'])) { $folders = array_unique(array_merge($folders, $_SESSION['kolab_subscribed_folders'])); } return $folders; } /** * Search for shared or otherwise not listed groupware folders the user has access * * @param string Folder type of folders to search for * @param string Search string * @param array Namespace(s) to exclude results from * * @return array List of matching kolab_storage_folder objects */ public static function search_folders($type, $query, $exclude_ns = array()) { if (!self::setup()) { return array(); } $folders = array(); $query = str_replace('*', '', $query); // find unsubscribed IMAP folders of the given type foreach ((array)self::list_folders('', '*', $type, false, $folderdata) as $foldername) { // FIXME: only consider the last part of the folder path for searching? $realname = strtolower(rcube_charset::convert($foldername, 'UTF7-IMAP')); if (($query == '' || strpos($realname, $query) !== false) && !self::folder_is_subscribed($foldername, true) && !in_array(self::$imap->folder_namespace($foldername), (array)$exclude_ns) ) { $folders[] = new kolab_storage_folder($foldername, $type, $folderdata[$foldername]); } } return $folders; } /** * Sort the given list of kolab folders by namespace/name * * @param array List of kolab_storage_folder objects * @return array Sorted list of folders */ public static function sort_folders($folders) { $pad = ' '; $out = array(); $nsnames = array('personal' => array(), 'shared' => array(), 'other' => array()); foreach ($folders as $folder) { $_folders[$folder->name] = $folder; $ns = $folder->get_namespace(); $nsnames[$ns][$folder->name] = strtolower(html_entity_decode($folder->get_name(), ENT_COMPAT, RCUBE_CHARSET)) . $pad; // decode » } // $folders is a result of get_folders() we can assume folders were already sorted foreach (array_keys($nsnames) as $ns) { asort($nsnames[$ns], SORT_LOCALE_STRING); foreach (array_keys($nsnames[$ns]) as $utf7name) { $out[] = $_folders[$utf7name]; } } return $out; } /** * Check the folder tree and add the missing parents as virtual folders * * @param array $folders Folders list * @param object $tree Reference to the root node of the folder tree * * @return array Flat folders list */ public static function folder_hierarchy($folders, &$tree = null) { if (!self::setup()) { return array(); } $_folders = array(); $delim = self::$imap->get_hierarchy_delimiter(); $other_ns = rtrim(self::namespace_root('other'), $delim); $tree = new kolab_storage_folder_virtual('', '', ''); // create tree root $refs = array('' => $tree); foreach ($folders as $idx => $folder) { $path = explode($delim, $folder->name); array_pop($path); $folder->parent = join($delim, $path); $folder->children = array(); // reset list // skip top folders or ones with a custom displayname if (count($path) < 1 || kolab_storage::custom_displayname($folder->name)) { $tree->children[] = $folder; } else { $parents = array(); $depth = $folder->get_namespace() == 'personal' ? 1 : 2; while (count($path) >= $depth && ($parent = join($delim, $path))) { array_pop($path); $parent_parent = join($delim, $path); if (!$refs[$parent]) { if ($folder->type && self::folder_type($parent) == $folder->type) { $refs[$parent] = new kolab_storage_folder($parent, $folder->type, $folder->type); $refs[$parent]->parent = $parent_parent; } else if ($parent_parent == $other_ns) { $refs[$parent] = new kolab_storage_folder_user($parent, $parent_parent); } else { $name = kolab_storage::object_name($parent); $refs[$parent] = new kolab_storage_folder_virtual($parent, $name, $folder->get_namespace(), $parent_parent); } $parents[] = $refs[$parent]; } } if (!empty($parents)) { $parents = array_reverse($parents); foreach ($parents as $parent) { $parent_node = $refs[$parent->parent] ?: $tree; $parent_node->children[] = $parent; $_folders[] = $parent; } } $parent_node = $refs[$folder->parent] ?: $tree; $parent_node->children[] = $folder; } $refs[$folder->name] = $folder; $_folders[] = $folder; unset($folders[$idx]); } return $_folders; } /** * Returns folder types indexed by folder name * * @param string $prefix Folder prefix (Default '*' for all folders) * * @return array|bool List of folders, False on failure */ public static function folders_typedata($prefix = '*') { if (!self::setup()) { return false; } $type_keys = array(self::CTYPE_KEY, self::CTYPE_KEY_PRIVATE); // fetch metadata from *some* folders only if (($prefix == '*' || $prefix == '') && ($skip_ns = self::$config->get('kolab_skip_namespace'))) { $delimiter = self::$imap->get_hierarchy_delimiter(); $folderdata = $blacklist = array(); foreach ((array)$skip_ns as $ns) { if ($ns_root = rtrim(self::namespace_root($ns), $delimiter)) { $blacklist[] = $ns_root; } } foreach (array('personal','other','shared') as $ns) { if (!in_array($ns, (array)$skip_ns)) { $ns_root = rtrim(self::namespace_root($ns), $delimiter); // list top-level folders and their childs one by one // GETMETADATA "%" doesn't list shared or other namespace folders but "*" would if ($ns_root == '') { foreach ((array)self::$imap->get_metadata('%', $type_keys) as $folder => $metadata) { if (!in_array($folder, $blacklist)) { $folderdata[$folder] = $metadata; $opts = self::$imap->folder_attributes($folder); if (!in_array('\\HasNoChildren', $opts) && ($data = self::$imap->get_metadata($folder.$delimiter.'*', $type_keys))) { $folderdata += $data; } } } } else if ($data = self::$imap->get_metadata($ns_root.$delimiter.'*', $type_keys)) { $folderdata += $data; } } } } else { $folderdata = self::$imap->get_metadata($prefix, $type_keys); } if (!is_array($folderdata)) { return false; } return array_map(array('kolab_storage', 'folder_select_metadata'), $folderdata); } /** * Callback for array_map to select the correct annotation value */ public static function folder_select_metadata($types) { if (!empty($types[self::CTYPE_KEY_PRIVATE])) { return $types[self::CTYPE_KEY_PRIVATE]; } else if (!empty($types[self::CTYPE_KEY])) { list($ctype, ) = explode('.', $types[self::CTYPE_KEY]); return $ctype; } return null; } /** * Returns type of IMAP folder * * @param string $folder Folder name (UTF7-IMAP) * * @return string Folder type */ public static function folder_type($folder) { self::setup(); $metadata = self::$imap->get_metadata($folder, array(self::CTYPE_KEY, self::CTYPE_KEY_PRIVATE)); if (!is_array($metadata)) { return null; } if (!empty($metadata[$folder])) { return self::folder_select_metadata($metadata[$folder]); } return 'mail'; } /** * Sets folder content-type. * * @param string $folder Folder name * @param string $type Content type * * @return boolean True on success */ public static function set_folder_type($folder, $type='mail') { self::setup(); list($ctype, $subtype) = explode('.', $type); $success = self::$imap->set_metadata($folder, array(self::CTYPE_KEY => $ctype, self::CTYPE_KEY_PRIVATE => $subtype ? $type : null)); if (!$success) // fallback: only set private annotation $success |= self::$imap->set_metadata($folder, array(self::CTYPE_KEY_PRIVATE => $type)); return $success; } /** * Check subscription status of this folder * * @param string $folder Folder name * @param boolean $temp Include temporary/session subscriptions * * @return boolean True if subscribed, false if not */ public static function folder_is_subscribed($folder, $temp = false) { if (self::$subscriptions === null) { self::setup(); self::$with_tempsubs = false; self::$subscriptions = self::$imap->list_folders_subscribed(); self::$with_tempsubs = true; } return in_array($folder, self::$subscriptions) || ($temp && in_array($folder, (array)$_SESSION['kolab_subscribed_folders'])); } /** * Change subscription status of this folder * * @param string $folder Folder name * @param boolean $temp Only subscribe temporarily for the current session * * @return True on success, false on error */ public static function folder_subscribe($folder, $temp = false) { self::setup(); // temporary/session subscription if ($temp) { if (self::folder_is_subscribed($folder)) { return true; } else if (!is_array($_SESSION['kolab_subscribed_folders']) || !in_array($folder, $_SESSION['kolab_subscribed_folders'])) { $_SESSION['kolab_subscribed_folders'][] = $folder; return true; } } else if (self::$imap->subscribe($folder)) { self::$subscriptions = null; return true; } return false; } /** * Change subscription status of this folder * * @param string $folder Folder name * @param boolean $temp Only remove temporary subscription * * @return True on success, false on error */ public static function folder_unsubscribe($folder, $temp = false) { self::setup(); // temporary/session subscription if ($temp) { if (is_array($_SESSION['kolab_subscribed_folders']) && ($i = array_search($folder, $_SESSION['kolab_subscribed_folders'])) !== false) { unset($_SESSION['kolab_subscribed_folders'][$i]); } return true; } else if (self::$imap->unsubscribe($folder)) { self::$subscriptions = null; return true; } return false; } /** * Check activation status of this folder * * @param string $folder Folder name * * @return boolean True if active, false if not */ public static function folder_is_active($folder) { $active_folders = self::get_states(); return in_array($folder, $active_folders); } /** * Change activation status of this folder * * @param string $folder Folder name * * @return True on success, false on error */ public static function folder_activate($folder) { // activation implies temporary subscription self::folder_subscribe($folder, true); return self::set_state($folder, true); } /** * Change activation status of this folder * * @param string $folder Folder name * * @return True on success, false on error */ public static function folder_deactivate($folder) { // remove from temp subscriptions, really? self::folder_unsubscribe($folder, true); return self::set_state($folder, false); } /** * Return list of active folders */ private static function get_states() { if (self::$states !== null) { return self::$states; } $rcube = rcube::get_instance(); $folders = $rcube->config->get('kolab_active_folders'); if ($folders !== null) { self::$states = !empty($folders) ? explode('**', $folders) : array(); } // for backward-compatibility copy server-side subscriptions to activation states else { self::setup(); if (self::$subscriptions === null) { self::$with_tempsubs = false; self::$subscriptions = self::$imap->list_folders_subscribed(); self::$with_tempsubs = true; } self::$states = (array) self::$subscriptions; $folders = implode('**', self::$states); $rcube->user->save_prefs(array('kolab_active_folders' => $folders)); } return self::$states; } /** * Update list of active folders */ private static function set_state($folder, $state) { self::get_states(); // update in-memory list $idx = array_search($folder, self::$states); if ($state && $idx === false) { self::$states[] = $folder; } else if (!$state && $idx !== false) { unset(self::$states[$idx]); } // update user preferences $folders = implode('**', self::$states); return rcube::get_instance()->user->save_prefs(array('kolab_active_folders' => $folders)); } /** * Creates default folder of specified type * To be run when none of subscribed folders (of specified type) is found * * @param string $type Folder type * @param string $props Folder properties (color, etc) * * @return string Folder name */ public static function create_default_folder($type, $props = array()) { if (!self::setup()) { return; } $folders = self::$imap->get_metadata('*', array(kolab_storage::CTYPE_KEY_PRIVATE)); // from kolab_folders config $folder_type = strpos($type, '.') ? str_replace('.', '_', $type) : $type . '_default'; $default_name = self::$config->get('kolab_folders_' . $folder_type); $folder_type = str_replace('_', '.', $folder_type); // check if we have any folder in personal namespace // folder(s) may exist but not subscribed foreach ((array)$folders as $f => $data) { if (strpos($data[self::CTYPE_KEY_PRIVATE], $type) === 0) { $folder = $f; break; } } if (!$folder) { if (!$default_name) { $default_name = self::$default_folders[$type]; } if (!$default_name) { return; } $folder = rcube_charset::convert($default_name, RCUBE_CHARSET, 'UTF7-IMAP'); $prefix = self::$imap->get_namespace('prefix'); // add personal namespace prefix if needed if ($prefix && strpos($folder, $prefix) !== 0 && $folder != 'INBOX') { $folder = $prefix . $folder; } if (!self::$imap->folder_exists($folder)) { if (!self::$imap->create_folder($folder)) { return; } } self::set_folder_type($folder, $folder_type); } self::folder_subscribe($folder); if ($props['active']) { self::set_state($folder, true); } if (!empty($props)) { self::set_folder_props($folder, $props); } return $folder; } /** * Sets folder metadata properties * * @param string $folder Folder name * @param array &$prop Folder properties (color, displayname) */ public static function set_folder_props($folder, &$prop) { if (!self::setup()) { return; } // TODO: also save 'showalarams' and other properties here $ns = self::$imap->folder_namespace($folder); $supported = array( 'color' => array(self::COLOR_KEY_SHARED, self::COLOR_KEY_PRIVATE), 'displayname' => array(self::NAME_KEY_SHARED, self::NAME_KEY_PRIVATE), ); foreach ($supported as $key => $metakeys) { if (array_key_exists($key, $prop)) { $meta_saved = false; if ($ns == 'personal') // save in shared namespace for personal folders $meta_saved = self::$imap->set_metadata($folder, array($metakeys[0] => $prop[$key])); if (!$meta_saved) // try in private namespace $meta_saved = self::$imap->set_metadata($folder, array($metakeys[1] => $prop[$key])); if ($meta_saved) unset($prop[$key]); // unsetting will prevent fallback to local user prefs } } } /** * Search users in Kolab LDAP storage * * @param mixed $query Search value (or array of field => value pairs) * @param int $mode Matching mode: 0 - partial (*abc*), 1 - strict (=), 2 - prefix (abc*) * @param array $required List of fields that shall ot be empty * @param int $limit Maximum number of records * @param int $count Returns the number of records found * * @return array List of users */ public static function search_users($query, $mode = 1, $required = array(), $limit = 0, &$count = 0) { $query = str_replace('*', '', $query); // requires a working LDAP setup if (!strlen($query) || !($ldap = self::ldap())) { return array(); } $root = self::namespace_root('other'); $user_attrib = self::$config->get('kolab_users_id_attrib', self::$config->get('kolab_auth_login', 'mail')); $search_attrib = self::$config->get('kolab_users_search_attrib', array('cn','mail','alias')); // search users using the configured attributes $results = $ldap->dosearch($search_attrib, $query, $mode, $required, $limit, $count); // exclude myself if ($_SESSION['kolab_dn']) { unset($results[$_SESSION['kolab_dn']]); } // resolve to IMAP folder name array_walk($results, function(&$user, $dn) use ($root, $user_attrib) { list($localpart, ) = explode('@', $user[$user_attrib]); $user['kolabtargetfolder'] = $root . $localpart; }); return $results; } /** * Returns a list of IMAP folders shared by the given user * * @param array User entry from LDAP * @param string Data type to list folders for (contact,event,task,journal,file,note,mail,configuration) * @param int 1 - subscribed folders only, 0 - all folders, 2 - all non-active * @param array Will be filled with folder-types data * * @return array List of folders */ public static function list_user_folders($user, $type, $subscribed = 0, &$folderdata = array()) { self::setup(); $folders = array(); // use localpart of user attribute as root for folder listing $user_attrib = self::$config->get('kolab_users_id_attrib', self::$config->get('kolab_auth_login', 'mail')); if (!empty($user[$user_attrib])) { list($mbox) = explode('@', $user[$user_attrib]); $delimiter = self::$imap->get_hierarchy_delimiter(); $other_ns = self::namespace_root('other'); $prefix = $other_ns . $mbox . $delimiter; $subscribed = (int) $subscribed; $subs = $subscribed < 2 ? (bool) $subscribed : false; $folders = self::list_folders($prefix, '*', $type, $subs, $folderdata); if ($subscribed === 2 && !empty($folders)) { $active = self::get_states(); if (!empty($active)) { $folders = array_diff($folders, $active); } } } return $folders; } /** * Get a list of (virtual) top-level folders from the other users namespace * * @param string Data type to list folders for (contact,event,task,journal,file,note,mail,configuration) * @param boolean Enable to return subscribed folders only (null to use configured subscription mode) * * @return array List of kolab_storage_folder_user objects */ public static function get_user_folders($type, $subscribed) { $folders = $folderdata = array(); if (self::setup()) { $delimiter = self::$imap->get_hierarchy_delimiter(); $other_ns = rtrim(self::namespace_root('other'), $delimiter); $path_len = count(explode($delimiter, $other_ns)); foreach ((array)self::list_folders($other_ns . $delimiter, '*', '', $subscribed) as $foldername) { if ($foldername == 'INBOX') // skip INBOX which is added by default continue; $path = explode($delimiter, $foldername); // compare folder type if a subfolder is listed if ($type && count($path) > $path_len + 1 && $type != self::folder_type($foldername)) { continue; } // truncate folder path to top-level folders of the 'other' namespace $foldername = join($delimiter, array_slice($path, 0, $path_len + 1)); if (!$folders[$foldername]) { $folders[$foldername] = new kolab_storage_folder_user($foldername, $other_ns); } } // for every (subscribed) user folder, list all (unsubscribed) subfolders foreach ($folders as $userfolder) { foreach ((array)self::list_folders($userfolder->name . $delimiter, '*', $type, false, $folderdata) as $foldername) { if (!$folders[$foldername]) { $folders[$foldername] = new kolab_storage_folder($foldername, $type, $folderdata[$foldername]); $userfolder->children[] = $folders[$foldername]; } } } } return $folders; } /** * Handler for user_delete plugin hooks * * Remove all cache data from the local database related to the given user. */ public static function delete_user_folders($args) { $db = rcmail::get_instance()->get_dbh(); $prefix = 'imap://' . urlencode($args['username']) . '@' . $args['host'] . '/%'; $db->query("DELETE FROM " . $db->table_name('kolab_folders', true) . " WHERE `resource` LIKE ?", $prefix); } /** * Get folder METADATA for all supported keys * Do this in one go for better caching performance */ public static function folder_metadata($folder) { if (self::setup()) { $keys = array( // For better performance we skip displayname here, see (self::custom_displayname()) // self::NAME_KEY_PRIVATE, // self::NAME_KEY_SHARED, self::CTYPE_KEY, self::CTYPE_KEY_PRIVATE, self::COLOR_KEY_PRIVATE, self::COLOR_KEY_SHARED, self::UID_KEY_SHARED, self::UID_KEY_CYRUS, ); $metadata = self::$imap->get_metadata($folder, $keys); return $metadata[$folder]; } } /** * Get user attributes for specified other user (imap) folder identifier. * * @param string $folder_id Folder name w/o path (imap user identifier) * @param bool $as_string Return configured display name attribute value * * @return array User attributes * @see self::ldap() */ public static function folder_id2user($folder_id, $as_string = false) { static $domain, $cache, $name_attr; $rcube = rcube::get_instance(); if ($domain === null) { list(, $domain) = explode('@', $rcube->get_user_name()); } if ($name_attr === null) { $name_attr = (array) ($rcube->config->get('kolab_users_name_field', $rcube->config->get('kolab_auth_name')) ?: 'name'); } $token = $folder_id; if ($domain && strpos($find, '@') === false) { $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.php b/plugins/libkolab/lib/kolab_storage_dav.php index 15295cfb..6ba3a791 100644 --- a/plugins/libkolab/lib/kolab_storage_dav.php +++ b/plugins/libkolab/lib/kolab_storage_dav.php @@ -1,492 +1,492 @@ * * Copyright (C) 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 . */ class kolab_storage_dav { const ERROR_DAV_CONN = 1; const ERROR_CACHE_DB = 2; const ERROR_NO_PERMISSION = 3; const ERROR_INVALID_FOLDER = 4; protected $dav; protected $url; /** * Object constructor */ public function __construct($url) { $this->url = $url; $this->setup(); } /** * Setup the environment */ public function setup() { $rcmail = rcube::get_instance(); $this->config = $rcmail->config; $this->dav = new kolab_dav_client($this->url); } /** * Get a list of storage folders for the given data type * * @param string Data type to list folders for (contact,distribution-list,event,task,note) * * @return array List of kolab_storage_dav_folder objects */ public function get_folders($type) { $davTypes = [ 'event' => 'VEVENT', 'task' => 'VTODO', 'contact' => 'VCARD', ]; // TODO: This should be cached $folders = $this->dav->discover($davTypes[$type]); if (is_array($folders)) { foreach ($folders as $idx => $folder) { // Exclude some special folders if (in_array('schedule-inbox', $folder['resource_type']) || in_array('schedule-outbox', $folder['resource_type'])) { unset($folders[$idx]); continue; } $folders[$idx] = new kolab_storage_dav_folder($this->dav, $folder, $type); } } 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_dav_folder The folder object */ public function get_default_folder($type) { // TODO: Not used } /** * Getter for a specific storage folder * * @param string $id Folder to access * @param string $type Expected folder type * * @return ?object kolab_storage_folder The folder object */ public function get_folder($id, $type = null) { foreach ($this->get_folders($type) as $folder) { if ($folder->id == $id) { return $folder; } } } /** * Getter for a single Kolab object, identified by its UID. * This will search all folders storing objects of the given type. * * @param string Object UID * @param string Object type (contact,event,task,journal,file,note,configuration) * * @return array The Kolab object represented as hash array or false if not found */ public function get_object($uid, $type) { // TODO return false; } /** * Execute cross-folder searches with the given query. * * @param array Pseudo-SQL query as list of filter parameter triplets * @param string Folder type (contact,event,task,journal,file,note,configuration) * @param int Expected number of records or limit (for performance reasons) * * @return array List of Kolab data objects (each represented as hash array) */ public function select($query, $type, $limit = null) { $result = []; foreach ($this->get_folders($type) as $folder) { if ($limit) { $folder->set_order_and_limit(null, $limit); } foreach ($folder->select($query) as $object) { $result[] = $object; } } return $result; } /** * Compose an URL to query the free/busy status for the given user * * @param string Email address of the user to get free/busy data for * @param object DateTime Start of the query range (optional) * @param object DateTime End of the query range (optional) * * @return string Fully qualified URL to query free/busy data */ public static function get_freebusy_url($email, $start = null, $end = null) { return kolab_storage::get_freebusy_url($email, $start, $end); } /** * Deletes a folder * * @param string $name Folder name * * @return bool True on success, false on failure */ public function folder_delete($name) { // TODO } /** * Creates a folder * * @param string $name Folder name (UTF7-IMAP) * @param string $type Folder type * @param bool $subscribed Sets folder subscription * @param bool $active Sets folder state (client-side subscription) * * @return bool True on success, false on failure */ public function folder_create($name, $type = null, $subscribed = false, $active = false) { // TODO } /** * Renames DAV folder * * @param string $oldname Old folder name (UTF7-IMAP) * @param string $newname New folder name (UTF7-IMAP) * * @return bool True on success, false on failure */ public function folder_rename($oldname, $newname) { // TODO } /** * Rename or Create a new folder. * * Does additional checks for permissions and folder name restrictions * * @param array &$prop Hash array with folder properties and metadata * - name: Folder name * - oldname: Old folder name when changed * - parent: Parent folder to create the new one in * - type: Folder type to create * - subscribed: Subscribed flag (IMAP subscription) * - active: Activation flag (client-side subscription) * * @return string|false New folder name or False on failure */ public function folder_update(&$prop) { // TODO } /** * Getter for human-readable name of a folder * * @param string $folder Folder name (UTF7-IMAP) * @param string $folder_ns Will be set to namespace name of the folder * * @return string Name of the folder-object */ public static function object_name($folder, &$folder_ns = null) { // TODO: Shared folders $folder_ns = 'personal'; return $folder; } /** * Creates a SELECT field with folders list * * @param string $type Folder type * @param array $attrs SELECT field attributes (e.g. name) * @param string $current The name of current folder (to skip it) * * @return html_select SELECT object */ public function folder_selector($type, $attrs, $current = '') { // TODO } /** * Returns a list of folder names * * @param string Optional root folder * @param string Optional name pattern * @param string Data type to list folders for (contact,event,task,journal,file,note,mail,configuration) * @param bool Enable to return subscribed folders only (null to use configured subscription mode) * @param array Will be filled with folder-types data * * @return array List of folders */ public function list_folders($root = '', $mbox = '*', $filter = null, $subscribed = null, &$folderdata = array()) { // TODO } /** * Search for shared or otherwise not listed groupware folders the user has access * * @param string Folder type of folders to search for * @param string Search string * @param array Namespace(s) to exclude results from * * @return array List of matching kolab_storage_folder objects */ public function search_folders($type, $query, $exclude_ns = []) { // TODO return []; } /** * Sort the given list of folders by namespace/name * * @param array List of kolab_storage_dav_folder objects * * @return array Sorted list of folders */ public static function sort_folders($folders) { // TODO return $folders; } /** * Returns folder types indexed by folder name * * @param string $prefix Folder prefix (Default '*' for all folders) * * @return array|bool List of folders, False on failure */ public function folders_typedata($prefix = '*') { // TODO: Used by kolab_folders, kolab_activesync, kolab_delegation return []; } /** * Returns type of a DAV folder * * @param string $folder Folder name (UTF7-IMAP) * * @return string Folder type */ public function folder_type($folder) { // TODO: Used by kolab_folders, kolab_activesync, kolab_delegation - return 'event'; + return ''; } /** * Sets folder content-type. * * @param string $folder Folder name * @param string $type Content type * * @return bool True on success, False otherwise */ public function set_folder_type($folder, $type = 'mail') { // NOP: Used by kolab_folders, kolab_activesync, kolab_delegation return false; } /** * Check subscription status of this folder * * @param string $folder Folder name * @param bool $temp Include temporary/session subscriptions * * @return bool True if subscribed, false if not */ public function folder_is_subscribed($folder, $temp = false) { // NOP return true; } /** * Change subscription status of this folder * * @param string $folder Folder name * @param bool $temp Only subscribe temporarily for the current session * * @return True on success, false on error */ public function folder_subscribe($folder, $temp = false) { // NOP return true; } /** * Change subscription status of this folder * * @param string $folder Folder name * @param bool $temp Only remove temporary subscription * * @return True on success, false on error */ public function folder_unsubscribe($folder, $temp = false) { // NOP return false; } /** * Check activation status of this folder * * @param string $folder Folder name * * @return bool True if active, false if not */ public function folder_is_active($folder) { // TODO return true; } /** * Change activation status of this folder * * @param string $folder Folder name * * @return True on success, false on error */ public function folder_activate($folder) { return true; } /** * Change activation status of this folder * * @param string $folder Folder name * * @return True on success, false on error */ public function folder_deactivate($folder) { return false; } /** * Creates default folder of specified type * To be run when none of subscribed folders (of specified type) is found * * @param string $type Folder type * @param string $props Folder properties (color, etc) * * @return string Folder name */ public function create_default_folder($type, $props = []) { // TODO: For kolab_addressbook?? return ''; } /** * Returns a list of IMAP folders shared by the given user * * @param array User entry from LDAP * @param string Data type to list folders for (contact,event,task,journal,file,note,mail,configuration) * @param int 1 - subscribed folders only, 0 - all folders, 2 - all non-active * @param array Will be filled with folder-types data * * @return array List of folders */ public function list_user_folders($user, $type, $subscribed = 0, &$folderdata = []) { // TODO return []; } /** * Get a list of (virtual) top-level folders from the other users namespace * * @param string Data type to list folders for (contact,event,task,journal,file,note,mail,configuration) * @param bool Enable to return subscribed folders only (null to use configured subscription mode) * * @return array List of kolab_storage_folder_user objects */ public function get_user_folders($type, $subscribed) { // TODO return []; } /** * Handler for user_delete plugin hooks * * Remove all cache data from the local database related to the given user. */ public static function delete_user_folders($args) { $db = rcmail::get_instance()->get_dbh(); $table = $db->table_name('kolab_folders', true); $prefix = 'dav://' . urlencode($args['username']) . '@' . $args['host'] . '/%'; $db->query("DELETE FROM $table WHERE `resource` LIKE ?", $prefix); } /** * Get folder METADATA for all supported keys * Do this in one go for better caching performance */ public function folder_metadata($folder) { // TODO ? return []; } } diff --git a/plugins/libkolab/lib/kolab_storage_dav_cache.php b/plugins/libkolab/lib/kolab_storage_dav_cache.php index b089b170..c97bf275 100644 --- a/plugins/libkolab/lib/kolab_storage_dav_cache.php +++ b/plugins/libkolab/lib/kolab_storage_dav_cache.php @@ -1,632 +1,622 @@ * @author Aleksander Machniak * - * Copyright (C) 2012-2013, Kolab Systems AG - * Copyright (C) 2022, Apheleia IT AG + * Copyright (C) 2012-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 . */ class kolab_storage_dav_cache extends kolab_storage_cache { /** * Factory constructor */ public static function factory(kolab_storage_folder $storage_folder) { $subclass = 'kolab_storage_dav_cache_' . $storage_folder->type; if (class_exists($subclass)) { return new $subclass($storage_folder); } rcube::raise_error( ['code' => 900, 'message' => "No {$subclass} class found for folder '{$storage_folder->name}'"], true ); return new kolab_storage_dav_cache($storage_folder); } /** * Connect cache with a storage folder * * @param kolab_storage_folder The storage folder instance to connect with */ public function set_folder(kolab_storage_folder $storage_folder) { $this->folder = $storage_folder; if (!$this->folder->valid) { $this->ready = false; return; } // compose fully qualified ressource uri for this instance $this->resource_uri = $this->folder->get_resource_uri(); $this->cache_table = $this->db->table_name('kolab_cache_dav_' . $this->folder->type); $this->ready = true; } /** * Synchronize local cache data with remote */ public function synchronize() { // only sync once per request cycle if ($this->synched) { return; } $this->sync_start = time(); // read cached folder metadata $this->_read_folder_data(); $ctag = $this->folder->get_ctag(); // check cache status ($this->metadata is set in _read_folder_data()) if ( empty($this->metadata['ctag']) || empty($this->metadata['changed']) || $this->metadata['ctag'] !== $ctag ) { // lock synchronization for this folder and wait if already locked $this->_sync_lock(); $result = $this->synchronize_worker(); // update ctag value (will be written to database in _sync_unlock()) if ($result) { $this->metadata['ctag'] = $ctag; $this->metadata['changed'] = date(self::DB_DATE_FORMAT, time()); } // remove lock $this->_sync_unlock(); } $this->synched = time(); } /** * Perform cache synchronization */ protected function synchronize_worker() { // get effective time limit we have for synchronization (~70% of the execution time) $time_limit = $this->_max_sync_lock_time() * 0.7; if (time() - $this->sync_start > $time_limit) { return false; } // TODO: Implement synchronization with use of WebDAV-Sync (RFC 6578) // Get the objects from the DAV server $dav_index = $this->folder->dav->getIndex($this->folder->href, $this->folder->get_dav_type()); if (!is_array($dav_index)) { rcube::raise_error([ 'code' => 900, 'message' => "Failed to sync the kolab cache for {$this->folder->href}" ], true); return false; } // WARNING: For now we assume object's href is /.ics, // which would mean there are no duplicates (objects with the same uid). // With DAV protocol we can't get UID without fetching the whole object. // Also the folder_id + uid is a unique index in the database. // In the future we maybe should store the href in database. // Determine objects to fetch or delete $new_index = []; $update_index = []; $old_index = $this->folder_index(); // uid -> etag $chunk_size = 20; // max numer of objects per DAV request foreach ($dav_index as $object) { $uid = $object['uid']; if (isset($old_index[$uid])) { $old_etag = $old_index[$uid]; $old_index[$uid] = null; if ($old_etag === $object['etag']) { // the object didn't change continue; } $update_index[$uid] = $object['href']; } else { $new_index[$uid] = $object['href']; } } // Fetch new objects and store in DB if (!empty($new_index)) { foreach (array_chunk($new_index, $chunk_size, true) as $chunk) { $objects = $this->folder->dav->getData($this->folder->href, $this->folder->get_dav_type(), $chunk); if (!is_array($objects)) { rcube::raise_error([ 'code' => 900, 'message' => "Failed to sync the kolab cache for {$this->folder->href}" ], true); return false; } foreach ($objects as $object) { if ($object = $this->folder->from_dav($object)) { $this->_extended_insert(false, $object); } } $this->_extended_insert(true, null); // check time limit and abort sync if running too long if (++$i % 25 == 0 && time() - $this->sync_start > $time_limit) { return false; } } } // Fetch updated objects and store in DB if (!empty($update_index)) { foreach (array_chunk($update_index, $chunk_size, true) as $chunk) { $objects = $this->folder->dav->getData($this->folder->href, $this->folder->get_dav_type(), $chunk); if (!is_array($objects)) { rcube::raise_error([ 'code' => 900, 'message' => "Failed to sync the kolab cache for {$this->folder->href}" ], true); return false; } foreach ($objects as $object) { if ($object = $this->folder->from_dav($object)) { $this->save($object, $object['uid']); } } // check time limit and abort sync if running too long if (++$i % 25 == 0 && time() - $this->sync_start > $time_limit) { return false; } } } // Remove deleted objects $old_index = array_filter($old_index); if (!empty($old_index)) { $quoted_uids = join(',', array_map(array($this->db, 'quote'), $old_index)); $this->db->query( "DELETE FROM `{$this->cache_table}` WHERE `folder_id` = ? AND `uid` IN ($quoted_uids)", $this->folder_id ); } return true; } /** * Return current folder index (uid -> etag) */ protected function folder_index() { // read cache index $sql_result = $this->db->query( "SELECT `uid`, `etag` FROM `{$this->cache_table}` WHERE `folder_id` = ?", $this->folder_id ); $index = []; while ($sql_arr = $this->db->fetch_assoc($sql_result)) { $index[$sql_arr['uid']] = $sql_arr['etag']; } return $index; } /** * Read a single entry from cache or from server directly * * @param string Object UID * @param string Object type to read * @param string Unused (kept for compat. with the parent class) */ public function get($uid, $type = null, $unused = null) { if ($this->ready) { $this->_read_folder_data(); $sql_result = $this->db->query( "SELECT * FROM `{$this->cache_table}` WHERE `folder_id` = ? AND `uid` = ?", $this->folder_id, $uid ); if ($sql_arr = $this->db->fetch_assoc($sql_result)) { $object = $this->_unserialize($sql_arr); } } // fetch from DAV if not present in cache if (empty($object)) { if ($object = $this->folder->read_object($uid, $type ?: '*')) { $this->save($object); } } return $object ?: null; } /** * Insert/Update a cache entry * * @param string Object UID * @param array|false Hash array with object properties to save or false to delete the cache entry * @param string Unused (kept for compat. with the parent class) */ public function set($uid, $object, $unused = null) { // remove old entry if ($this->ready) { $this->_read_folder_data(); $this->db->query( "DELETE FROM `{$this->cache_table}` WHERE `folder_id` = ? AND `uid` = ?", $this->folder_id, $uid ); } if ($object) { $this->save($object); } } /** * Insert (or update) a cache entry * * @param mixed Hash array with object properties to save or false to delete the cache entry * @param string Optional old message UID (for update) * @param string Unused (kept for compat. with the parent class) */ public function save($object, $olduid = null, $unused = null) { // write to cache if ($this->ready) { $this->_read_folder_data(); $sql_data = $this->_serialize($object); $sql_data['folder_id'] = $this->folder_id; $sql_data['uid'] = rcube_charset::clean($object['uid']); $sql_data['etag'] = rcube_charset::clean($object['etag']); $args = []; $cols = ['folder_id', 'uid', 'etag', 'changed', 'data', 'tags', 'words']; $cols = array_merge($cols, $this->extra_cols); foreach ($cols as $idx => $col) { $cols[$idx] = $this->db->quote_identifier($col); $args[] = $sql_data[$col]; } if ($olduid) { foreach ($cols as $idx => $col) { $cols[$idx] = "$col = ?"; } $query = "UPDATE `{$this->cache_table}` SET " . implode(', ', $cols) . " WHERE `folder_id` = ? AND `uid` = ?"; $args[] = $this->folder_id; $args[] = $olduid; } else { $query = "INSERT INTO `{$this->cache_table}` (`created`, " . implode(', ', $cols) . ") VALUES (" . $this->db->now() . str_repeat(', ?', count($cols)) . ")"; } $result = $this->db->query($query, $args); if (!$this->db->affected_rows($result)) { rcube::raise_error([ 'code' => 900, 'message' => "Failed to write to kolab cache" ], true); } } } /** * Move an existing cache entry to a new resource * * @param string Entry's UID * @param kolab_storage_folder Target storage folder instance * @param string Unused (kept for compat. with the parent class) * @param string Unused (kept for compat. with the parent class) */ public function move($uid, $target, $unused1 = null, $unused2 = null) { // TODO } /** * Update resource URI for existing folder * * @param string Target DAV folder to move it to */ public function rename($new_folder) { // TODO } /** * Select Kolab objects filtered by the given query * * @param array Pseudo-SQL query as list of filter parameter triplets * triplet: ['', '', ''] * @param bool Set true to only return UIDs instead of complete objects * @param bool Use fast mode to fetch only minimal set of information * (no xml fetching and parsing, etc.) * * @return array|null|kolab_storage_dataset List of Kolab data objects (each represented as hash array) or UIDs */ public function select($query = [], $uids = false, $fast = false) { $result = $uids ? [] : new kolab_storage_dataset($this); $this->_read_folder_data(); // fetch full object data on one query if a small result set is expected $fetchall = !$uids && ($this->limit ? $this->limit[0] : ($count = $this->count($query))) < self::MAX_RECORDS; // skip SELECT if we know it will return nothing if ($count === 0) { return $result; } $sql_query = "SELECT " . ($fetchall ? '*' : "`uid`") . " FROM `{$this->cache_table}` WHERE `folder_id` = ?" . $this->_sql_where($query) . (!empty($this->order_by) ? " ORDER BY " . $this->order_by : ''); $sql_result = $this->limit ? $this->db->limitquery($sql_query, $this->limit[1], $this->limit[0], $this->folder_id) : $this->db->query($sql_query, $this->folder_id); if ($this->db->is_error($sql_result)) { if ($uids) { return null; } $result->set_error(true); return $result; } while ($sql_arr = $this->db->fetch_assoc($sql_result)) { if ($fast) { $sql_arr['fast-mode'] = true; } if ($uids) { $result[] = $sql_arr['uid']; } else if ($fetchall && ($object = $this->_unserialize($sql_arr))) { $result[] = $object; } else if (!$fetchall) { $result[] = $sql_arr; } } return $result; } /** * Get number of objects mathing the given query * * @param array $query Pseudo-SQL query as list of filter parameter triplets * * @return int The number of objects of the given type */ public function count($query = []) { // read from local cache DB (assume it to be synchronized) $this->_read_folder_data(); $sql_result = $this->db->query( "SELECT COUNT(*) AS `numrows` FROM `{$this->cache_table}` ". "WHERE `folder_id` = ?" . $this->_sql_where($query), $this->folder_id ); if ($this->db->is_error($sql_result)) { return null; } $sql_arr = $this->db->fetch_assoc($sql_result); $count = intval($sql_arr['numrows']); return $count; } /** * Getter for a single Kolab object identified by its UID * * @param string $uid Object UID * * @return array|null The Kolab object represented as hash array */ public function get_by_uid($uid) { $old_limit = $this->limit; // set limit to skip count query $this->limit = [1, 0]; $list = $this->select([['uid', '=', $uid]]); // set the limit back to defined value $this->limit = $old_limit; if (!empty($list) && !empty($list[0])) { return $list[0]; } } - /** - * Check DAV connection error state - */ - protected function check_error() - { - // TODO ? - } - /** * Write records into cache using extended inserts to reduce the number of queries to be executed * * @param bool Set to false to commit buffered insert, true to force an insert * @param array Kolab object to cache */ protected function _extended_insert($force, $object) { static $buffer = ''; $line = ''; $cols = ['folder_id', 'uid', 'etag', 'created', 'changed', 'data', 'tags', 'words']; if ($this->extra_cols) { $cols = array_merge($cols, $this->extra_cols); } if ($object) { $sql_data = $this->_serialize($object); // Skip multi-folder insert for all databases but MySQL // In Oracle we can't put long data inline, others we don't support yet if (strpos($this->db->db_provider, 'mysql') !== 0) { $extra_args = []; $params = [ $this->folder_id, rcube_charset::clean($object['uid']), rcube_charset::clean($object['etag']), $sql_data['changed'], $sql_data['data'], $sql_data['tags'], $sql_data['words'] ]; foreach ($this->extra_cols as $col) { $params[] = $sql_data[$col]; $extra_args[] = '?'; } $cols = implode(', ', array_map(function($n) { return "`{$n}`"; }, $cols)); $extra_args = count($extra_args) ? ', ' . implode(', ', $extra_args) : ''; $result = $this->db->query( "INSERT INTO `{$this->cache_table}` ($cols)" . " VALUES (?, ?, " . $this->db->now() . ", ?, ?, ?, ?$extra_args)", $params ); if (!$this->db->affected_rows($result)) { rcube::raise_error(array( 'code' => 900, 'message' => "Failed to write to kolab cache" ), true); } return; } $values = array( $this->db->quote($this->folder_id), $this->db->quote(rcube_charset::clean($object['uid'])), $this->db->quote(rcube_charset::clean($object['etag'])), $this->db->now(), $this->db->quote($sql_data['changed']), $this->db->quote($sql_data['data']), $this->db->quote($sql_data['tags']), $this->db->quote($sql_data['words']), ); foreach ($this->extra_cols as $col) { $values[] = $this->db->quote($sql_data[$col]); } $line = '(' . join(',', $values) . ')'; } if ($buffer && ($force || (strlen($buffer) + strlen($line) > $this->max_sql_packet()))) { $columns = implode(', ', array_map(function($n) { return "`{$n}`"; }, $cols)); $update = implode(', ', array_map(function($i) { return "`{$i}` = VALUES(`{$i}`)"; }, array_slice($cols, 2))); $result = $this->db->query( "INSERT INTO `{$this->cache_table}` ($columns) VALUES $buffer" . " ON DUPLICATE KEY UPDATE $update" ); if (!$this->db->affected_rows($result)) { rcube::raise_error(array( 'code' => 900, 'message' => "Failed to write to kolab cache" ), true); } $buffer = ''; } $buffer .= ($buffer ? ',' : '') . $line; } /** * Helper method to turn stored cache data into a valid storage object */ protected function _unserialize($sql_arr) { if ($sql_arr['fast-mode'] && !empty($sql_arr['data']) && ($object = json_decode($sql_arr['data'], true))) { foreach ($this->data_props as $prop) { if (isset($object[$prop]) && is_array($object[$prop]) && $object[$prop]['cl'] == 'DateTime') { $object[$prop] = new DateTime($object[$prop]['dt'], new DateTimeZone($object[$prop]['tz'])); } else if (!isset($object[$prop]) && isset($sql_arr[$prop])) { $object[$prop] = $sql_arr[$prop]; } } if ($sql_arr['created'] && empty($object['created'])) { $object['created'] = new DateTime($sql_arr['created']); } if ($sql_arr['changed'] && empty($object['changed'])) { $object['changed'] = new DateTime($sql_arr['changed']); } $object['_type'] = $sql_arr['type'] ?: $this->folder->type; $object['uid'] = $sql_arr['uid']; $object['etag'] = $sql_arr['etag']; } // Fetch a complete object from the server else { // TODO: Fetching objects one-by-one from DAV server is slow $object = $this->folder->read_object($sql_arr['uid'], '*'); } return $object; } } diff --git a/plugins/libkolab/lib/kolab_storage_dav_cache_event.php b/plugins/libkolab/lib/kolab_storage_dav_cache_event.php index 4d4f0363..00d395f5 100644 --- a/plugins/libkolab/lib/kolab_storage_dav_cache_event.php +++ b/plugins/libkolab/lib/kolab_storage_dav_cache_event.php @@ -1,152 +1,152 @@ + * @author Aleksander Machniak * - * Copyright (C) 2013, Kolab Systems AG + * Copyright (C) 2013-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 . */ class kolab_storage_dav_cache_event extends kolab_storage_dav_cache { protected $extra_cols = ['dtstart','dtend']; protected $data_props = ['categories', 'status', 'attendees']; protected $fulltext_cols = ['title', 'description', 'location', 'attendees:name', 'attendees:email', 'categories']; /** * Helper method to convert the given Kolab object into a dataset to be written to cache * * @override */ protected function _serialize($object) { $sql_data = parent::_serialize($object); $sql_data['dtstart'] = $this->_convert_datetime($object['start']); $sql_data['dtend'] = $this->_convert_datetime($object['end']); // extend date range for recurring events if (!empty($object['recurrence'])) { if (empty($object['_formatobj'])) { $event_xml = new kolab_format_event(); $event_xml->set($object); $object['_formatobj'] = $event_xml; } $recurrence = new kolab_date_recurrence($object['_formatobj']); $dtend = $recurrence->end() ?: new DateTime('now +100 years'); $sql_data['dtend'] = $this->_convert_datetime($dtend); } // extend start/end dates to spawn all exceptions if (is_array($object['exceptions'])) { foreach ($object['exceptions'] as $exception) { if (is_a($exception['start'], 'DateTime')) { $exstart = $this->_convert_datetime($exception['start']); if ($exstart < $sql_data['dtstart']) { $sql_data['dtstart'] = $exstart; } } if (is_a($exception['end'], 'DateTime')) { $exend = $this->_convert_datetime($exception['end']); if ($exend > $sql_data['dtend']) { $sql_data['dtend'] = $exend; } } } } $sql_data['tags'] = ' ' . join(' ', $this->get_tags($object)) . ' '; // pad with spaces for strict/prefix search $sql_data['words'] = ' ' . join(' ', $this->get_words($object)) . ' '; return $sql_data; } /** * Callback to get words to index for fulltext search * * @return array List of words to save in cache */ public function get_words($object = []) { $data = ''; foreach ($this->fulltext_cols as $colname) { list($col, $field) = explode(':', $colname); if ($field) { $a = []; foreach ((array) $object[$col] as $attr) { $a[] = $attr[$field]; } $val = join(' ', $a); } else { $val = is_array($object[$col]) ? join(' ', $object[$col]) : $object[$col]; } if (strlen($val)) $data .= $val . ' '; } $words = rcube_utils::normalize_string($data, true); // collect words from recurrence exceptions if (is_array($object['exceptions'])) { foreach ($object['exceptions'] as $exception) { $words = array_merge($words, $this->get_words($exception)); } } return array_unique($words); } /** * Callback to get object specific tags to cache * * @return array List of tags to save in cache */ public function get_tags($object) { $tags = []; if (!empty($object['valarms'])) { $tags[] = 'x-has-alarms'; } // create tags reflecting participant status if (is_array($object['attendees'])) { foreach ($object['attendees'] as $attendee) { if (!empty($attendee['email']) && !empty($attendee['status'])) $tags[] = 'x-partstat:' . $attendee['email'] . ':' . strtolower($attendee['status']); } } // collect tags from recurrence exceptions if (is_array($object['exceptions'])) { foreach ($object['exceptions'] as $exception) { $tags = array_merge($tags, $this->get_tags($exception)); } } if (!empty($object['status'])) { $tags[] = 'x-status:' . strtolower($object['status']); } return array_unique($tags); } } diff --git a/plugins/libkolab/lib/kolab_storage_dav_folder.php b/plugins/libkolab/lib/kolab_storage_dav_folder.php index c7a46f47..48425181 100644 --- a/plugins/libkolab/lib/kolab_storage_dav_folder.php +++ b/plugins/libkolab/lib/kolab_storage_dav_folder.php @@ -1,587 +1,586 @@ * * 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 . */ class kolab_storage_dav_folder extends kolab_storage_folder { public $dav; public $href; public $attributes; /** * Object constructor */ public function __construct($dav, $attributes, $type_annotation = '') { $this->attributes = $attributes; $this->href = $this->attributes['href']; $this->id = md5($this->href); $this->dav = $dav; $this->valid = true; list($this->type, $suffix) = explode('.', $type_annotation); $this->default = $suffix == 'default'; $this->subtype = $this->default ? '' : $suffix; // Init cache $this->cache = kolab_storage_dav_cache::factory($this); } /** * Returns the owner of the folder. * * @param bool Return a fully qualified owner name (i.e. including domain for shared folders) * * @return string The owner of this folder. */ public function get_owner($fully_qualified = false) { // return cached value if (isset($this->owner)) { return $this->owner; } $rcube = rcube::get_instance(); $this->owner = $rcube->get_user_name(); $this->valid = true; // TODO: Support shared folders return $this->owner; } /** * Get a folder Etag identifier */ public function get_ctag() { return $this->attributes['ctag']; } /** * Getter for the name of the namespace to which the folder belongs * * @return string Name of the namespace (personal, other, shared) */ public function get_namespace() { // TODO: Support shared folders return 'personal'; } /** * Get the display name value of this folder * * @return string Folder name */ public function get_name() { return kolab_storage_dav::object_name($this->attributes['name']); } /** * Getter for the top-end folder name (not the entire path) * * @return string Name of this folder */ public function get_foldername() { return $this->attributes['name']; } public function get_folder_info() { return []; // todo ? } /** * Getter for parent folder path * * @return string Full path to parent folder */ public function get_parent() { // TODO return ''; } /** * Compose a unique resource URI for this folder */ public function get_resource_uri() { if (!empty($this->resource_uri)) { return $this->resource_uri; } - // compose fully qualified ressource uri for this instance + // 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() { // TODO return true; } /** * 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) { // TODO return true; } /** * Check subscription status of this folder * * @return bool True if subscribed, false if not */ public function is_subscribed() { // TODO return true; } /** * 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) { // TODO return true; } /** * 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), $content); if ($success) { $this->cache->set($uid, false); } return $success; } /** * */ public function delete_all() { if (!$this->valid) { return false; } // TODO: This method is used by kolab_addressbook plugin only - - $this->cache->purge(); + // $this->cache->purge(); return false; } /** * Restore a previously deleted object * * @param string $uid Object UID * * @return mixed Message UID on success, false on error */ public function undelete($uid) { if (!$this->valid) { return false; } // TODO return false; } /** * Move a Kolab object message to another IMAP folder * * @param string Object UID * @param string IMAP folder to move object to * * @return bool True on success, false on failure */ public function move($uid, $target_folder) { if (!$this->valid) { return false; } // TODO return false; } /** * Save an object in this folder. * * @param array $object The array that holds the data of the object. * @param string $type The type of the kolab object. * @param string $uid The UID of the old object if it existed before * * @return mixed False on error or object UID on success */ public function save(&$object, $type = null, $uid = null) { if (!$this->valid || empty($object)) { return false; } if (!$type) { $type = $this->type; } /* // copy attachments from old message $copyfrom = $object['_copyfrom'] ?: $object['_msguid']; if (!empty($copyfrom) && ($old = $this->cache->get($copyfrom, $type, $object['_mailbox']))) { foreach ((array)$old['_attachments'] as $key => $att) { if (!isset($object['_attachments'][$key])) { $object['_attachments'][$key] = $old['_attachments'][$key]; } // unset deleted attachment entries if ($object['_attachments'][$key] == false) { unset($object['_attachments'][$key]); } // load photo.attachment from old Kolab2 format to be directly embedded in xcard block else if ($type == 'contact' && ($key == 'photo.attachment' || $key == 'kolab-picture.png') && $att['id']) { if (!isset($object['photo'])) $object['photo'] = $this->get_attachment($copyfrom, $att['id'], $object['_mailbox']); unset($object['_attachments'][$key]); } } } // process attachments if (is_array($object['_attachments'])) { $numatt = count($object['_attachments']); foreach ($object['_attachments'] as $key => $attachment) { // FIXME: kolab_storage and Roundcube attachment hooks use different fields! if (empty($attachment['content']) && !empty($attachment['data'])) { $attachment['content'] = $attachment['data']; unset($attachment['data'], $object['_attachments'][$key]['data']); } // make sure size is set, so object saved in cache contains this info if (!isset($attachment['size'])) { if (!empty($attachment['content'])) { if (is_resource($attachment['content'])) { // this need to be a seekable resource, otherwise // fstat() failes and we're unable to determine size // here nor in rcube_imap_generic before IMAP APPEND $stat = fstat($attachment['content']); $attachment['size'] = $stat ? $stat['size'] : 0; } else { $attachment['size'] = strlen($attachment['content']); } } else if (!empty($attachment['path'])) { $attachment['size'] = filesize($attachment['path']); } $object['_attachments'][$key] = $attachment; } // generate unique keys (used as content-id) for attachments if (is_numeric($key) && $key < $numatt) { // derrive content-id from attachment file name $ext = preg_match('/(\.[a-z0-9]{1,6})$/i', $attachment['name'], $m) ? $m[1] : null; $basename = preg_replace('/[^a-z0-9_.-]/i', '', basename($attachment['name'], $ext)); // to 7bit ascii if (!$basename) $basename = 'noname'; $cid = $basename . '.' . microtime(true) . $key . $ext; $object['_attachments'][$cid] = $attachment; unset($object['_attachments'][$key]); } } } */ $rcmail = rcube::get_instance(); $result = false; // generate and save object message if ($content = $this->to_dav($object)) { $method = $uid ? 'update' : 'create'; $dav_type = $this->get_dav_type(); $result = $this->dav->{$method}($this->object_location($object['uid']), $content, $dav_type); // Note: $result can be NULL if the request was successful, but ETag wasn't returned if ($result !== false) { // insert/update object in the cache $object['etag'] = $result; $this->cache->save($object, $uid); $result = true; } } return $result; } /** * Fetch the object the DAV server and convert to internal format * * @param string The object UID to fetch * @param string The object type expected (use wildcard '*' to accept all types) * @param string Unused (kept for compat. with the parent class) * * @return mixed Hash array representing the Kolab object, a kolab_format instance or false if not found */ public function read_object($uid, $type = null, $folder = null) { if (!$this->valid) { return false; } $href = $this->object_location($uid); $objects = $this->dav->getData($this->href, $this->get_dav_type(), [$href]); if (!is_array($objects) || count($objects) != 1) { rcube::raise_error([ 'code' => 900, 'message' => "Failed to fetch {$href}" ], true); return false; } return $this->from_dav($objects[0]); } /** * Convert DAV object into PHP array * * @param array Object data in kolab_dav_client::fetchData() format * * @return array Object properties */ public function from_dav($object) { if ($this->type == 'event') { $ical = libcalendaring::get_ical(); $events = $ical->import($object['data']); if (!count($events) || empty($events[0]['uid'])) { return false; } $result = $events[0]; } else if ($this->type == 'contact') { if (stripos($object['data'], 'BEGIN:VCARD') !== 0) { return false; } $vcard = new rcube_vcard($object['data'], RCUBE_CHARSET, false); if (!empty($vcard->displayname) || !empty($vcard->surname) || !empty($vcard->firstname) || !empty($vcard->email)) { $result = $vcard->get_assoc(); } else { return false; } } $result['etag'] = $object['etag']; $result['href'] = $object['href']; $result['uid'] = $object['uid'] ?: $result['uid']; return $result; } /** * Convert Kolab object into DAV format (iCalendar) */ public function to_dav($object) { $result = ''; if ($this->type == 'event') { $ical = libcalendaring::get_ical(); if (!empty($object['exceptions'])) { $object['recurrence']['EXCEPTIONS'] = $object['exceptions']; } $result = $ical->export([$object]); } else if ($this->type == 'contact') { // copy values into vcard object $vcard = new rcube_vcard('', RCUBE_CHARSET, false, ['uid' => 'UID']); $vcard->set('groups', null); foreach ($object as $key => $values) { list($field, $section) = rcube_utils::explode(':', $key); // avoid casting DateTime objects to array if (is_object($values) && is_a($values, 'DateTime')) { $values = [$values]; } foreach ((array) $values as $value) { if (isset($value)) { $vcard->set($field, $value, $section); } } } $result = $vcard->export(false); } if ($result) { // The content must be UTF-8, otherwise if we try to fetch the object // from server XML parsing would fail. $result = rcube_charset::clean($result); } return $result; } protected function object_location($uid) { return unslashify($this->href) . '/' . urlencode($uid) . '.' . $this->get_dav_ext(); } /** * Get a folder DAV content type */ public function get_dav_type() { $types = [ 'event' => 'VEVENT', 'task' => 'VTODO', 'contact' => 'VCARD', ]; return $types[$this->type]; } /** * Get a DAV file extension for specified Kolab type */ public function get_dav_ext() { $types = [ 'event' => 'ics', 'task' => 'ics', 'contact' => 'vcf', ]; return $types[$this->type]; } /** * Return folder name as string representation of this object * * @return string Full IMAP folder name */ public function __toString() { return $this->attributes['name']; } }