diff --git a/plugins/libkolab/lib/kolab_storage_dataset.php b/plugins/libkolab/lib/kolab_storage_dataset.php index 771e321f..819b8d8b 100644 --- a/plugins/libkolab/lib/kolab_storage_dataset.php +++ b/plugins/libkolab/lib/kolab_storage_dataset.php @@ -1,152 +1,186 @@ * * Copyright (C) 2014, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_storage_dataset implements Iterator, ArrayAccess, Countable { + const CHUNK_SIZE = 25; + private $cache; // kolab_storage_cache instance to use for fetching data private $memlimit = 0; private $buffer = false; - private $index = array(); - private $data = array(); + private $index = []; + private $data = []; private $iteratorkey = 0; private $error = null; + private $chunk = []; /** * Default constructor * * @param object kolab_storage_cache instance to be used for fetching objects upon access */ public function __construct($cache) { $this->cache = $cache; // enable in-memory buffering up until 1/5 of the available memory if (function_exists('memory_get_usage')) { $this->memlimit = parse_bytes(ini_get('memory_limit')) / 5; $this->buffer = true; } } /** * Return error state */ public function is_error() { return !empty($this->error); } /** * Set error state */ public function set_error($err) { $this->error = $err; } /*** Implement PHP Countable interface ***/ public function count() { return count($this->index); } /*** Implement PHP ArrayAccess interface ***/ public function offsetSet($offset, $value) { - $uid = !empty($value['_msguid']) ? $value['_msguid'] : $value['uid']; + if (is_string($value)) { + $uid = $value; + } + else { + $uid = !empty($value['_msguid']) ? $value['_msguid'] : $value['uid']; + } if (is_null($offset)) { $offset = count($this->index); } $this->index[$offset] = $uid; // keep full payload data in memory if possible if ($this->memlimit && $this->buffer) { $this->data[$offset] = $value; // check memory usage and stop buffering if ($offset % 10 == 0) { $this->buffer = memory_get_usage() < $this->memlimit; } } } public function offsetExists($offset) { return isset($this->index[$offset]); } public function offsetUnset($offset) { unset($this->index[$offset]); } public function offsetGet($offset) { + if (isset($this->chunk[$offset])) { + return $this->chunk[$offset] ?: null; + } + + // The item is a string (object's UID), use multiget method to pre-fetch + // multiple objects from the server in one request + if (isset($this->data[$offset]) && is_string($this->data[$offset]) && method_exists($this->cache, 'multiget')) { + $idx = $offset; + $uids = []; + + while (isset($this->index[$idx]) && count($uids) < self::CHUNK_SIZE) { + $uids[$idx] = $this->index[$idx]; + $idx++; + } + + if (!empty($uids)) { + $this->chunk = $this->cache->multiget($uids); + } + + if (isset($this->chunk[$offset])) { + return $this->chunk[$offset] ?: null; + } + + return null; + } + if (isset($this->data[$offset])) { return $this->data[$offset]; } if ($uid = $this->index[$offset]) { return $this->cache->get($uid); } return null; } /*** Implement PHP Iterator interface ***/ public function current() { return $this->offsetGet($this->iteratorkey); } public function key() { return $this->iteratorkey; } public function next() { $this->iteratorkey++; return $this->valid(); } public function rewind() { $this->iteratorkey = 0; } public function valid() { return !empty($this->index[$this->iteratorkey]); } } diff --git a/plugins/libkolab/lib/kolab_storage_dav_cache.php b/plugins/libkolab/lib/kolab_storage_dav_cache.php index 783978e5..28c6e4c8 100644 --- a/plugins/libkolab/lib/kolab_storage_dav_cache.php +++ b/plugins/libkolab/lib/kolab_storage_dav_cache.php @@ -1,624 +1,644 @@ * * 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) */ public function folder_index() { $this->_read_folder_data(); // 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) + * + * @return null|array An array of objects, NULL if not found */ 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; } + /** + * Read multiple entries from the server directly + * + * @param array Object UIDs + * + * @return false|array An array of objects, False on error + */ + public function multiget($uids) + { + return $this->folder->read_objects($uids); + } + /** * 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; } + else if (($object = $this->_unserialize($sql_arr, true))) { + $result[] = $object; + } + else { + $result[] = $sql_arr['uid']; + } } 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]; } } /** * 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) + protected function _unserialize($sql_arr, $noread = false) { 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 if ($noread) { + return null; + } else { - // TODO: Fetching objects one-by-one from DAV server is slow + // Fetch a complete object from the server $object = $this->folder->read_object($sql_arr['uid'], '*'); } return $object; } } diff --git a/plugins/libkolab/lib/kolab_storage_dav_folder.php b/plugins/libkolab/lib/kolab_storage_dav_folder.php index a8ea5794..118c7b91 100644 --- a/plugins/libkolab/lib/kolab_storage_dav_folder.php +++ b/plugins/libkolab/lib/kolab_storage_dav_folder.php @@ -1,672 +1,720 @@ * * 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->dav->url . '/' . $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 resource uri for this instance $host = preg_replace('|^https?://|', 'dav://' . urlencode($this->get_owner(true)) . '@', $this->dav->url); $path = $this->href[0] == '/' ? $this->href : "/{$this->href}"; $host_path = parse_url($host, PHP_URL_PATH); if ($host_path && strpos($path, $host_path) === 0) { $path = substr($path, strlen($host_path)); } $this->resource_uri = unslashify($host) . $path; return $this->resource_uri; } /** * Getter for the Cyrus mailbox identifier corresponding to this folder * (e.g. user/john.doe/Calendar/Personal@example.org) * * @return string Mailbox ID */ public function get_mailbox_id() { // TODO: This is used with Bonnie related features return ''; } /** * Get the color value stored in metadata * * @param string Default color value to return if not set * * @return mixed Color value from the folder metadata or $default if not set */ public function get_color($default = null) { return !empty($this->attributes['color']) ? $this->attributes['color'] : $default; } /** * Get ACL information for this folder * * @return string Permissions as string */ public function get_myrights() { // TODO return ''; } /** * Helper method to extract folder UID * * @return string Folder's UID */ public function get_uid() { // TODO ??? return ''; } /** * Check activation status of this folder * * @return bool True if enabled, false if not */ public function is_active() { return true; // Unused } /** * Change activation status of this folder * * @param bool The desired subscription status: true = active, false = not active * * @return bool True on success, false on error */ public function activate($active) { return true; // Unused } /** * Check subscription status of this folder * * @return bool True if subscribed, false if not */ public function is_subscribed() { return true; // TODO } /** * Change subscription status of this folder * * @param bool The desired subscription status: true = subscribed, false = not subscribed * * @return True on success, false on error */ public function subscribe($subscribed) { return true; // TODO } /** * Delete the specified object from this folder. * * @param array|string $object The Kolab object to delete or object UID * @param bool $expunge Should the folder be expunged? * * @return bool True if successful, false on error */ public function delete($object, $expunge = true) { if (!$this->valid) { return false; } $uid = is_array($object) ? $object['uid'] : $object; $success = $this->dav->delete($this->object_location($uid)); if ($success) { $this->cache->set($uid, false); } return $success; } /** * Delete all objects in a folder. * * Note: This method is used by kolab_addressbook plugin only * * @return bool True if successful, false on error */ public function delete_all() { if (!$this->valid) { return false; } // TODO: Maybe just deleting and re-creating a folder would be // better, but probably might not always work (ACL) $this->cache->synchronize(); foreach (array_keys($this->cache->folder_index()) as $uid) { $this->dav->delete($this->object_location($uid)); } $this->cache->purge(); return true; } /** * Restore a previously deleted object * * @param string $uid Object UID * * @return mixed Message UID on success, false on error */ public function undelete($uid) { if (!$this->valid) { return false; } // TODO return false; } /** * Move a Kolab object message to another IMAP folder * * @param string Object UID * @param 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]); } + /** + * Fetch multiple objects from the DAV server and convert to internal format + * + * @param array The object UIDs to fetch + * + * @return mixed Hash array representing the Kolab objects + */ + public function read_objects($uids) + { + if (!$this->valid) { + return false; + } + + if (empty($uids)) { + return []; + } + + foreach ($uids as $uid) { + $hrefs[] = $this->object_location($uid); + } + + $objects = $this->dav->getData($this->href, $this->get_dav_type(), $hrefs); + + if (!is_array($objects)) { + rcube::raise_error([ + 'code' => 900, + 'message' => "Failed to fetch {$href}" + ], true); + return false; + } + + $objects = array_map([$this, 'from_dav'], $objects); + + foreach ($uids as $idx => $uid) { + foreach ($objects as $oidx => $object) { + if ($object && $object['uid'] == $uid) { + $uids[$idx] = $object; + unset($objects[$oidx]); + continue 2; + } + } + + $uids[$idx] = false; + } + + return $uids; + } + /** * Convert DAV object into PHP array * * @param array Object data in kolab_dav_client::fetchData() format * * @return array 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 properties not supported by rcube_vcard $map = [ 'uid' => 'UID', 'kind' => 'KIND', 'member' => 'MEMBER', 'x-kind' => 'X-ADDRESSBOOKSERVER-KIND', 'x-member' => 'X-ADDRESSBOOKSERVER-MEMBER', ]; // TODO: We should probably use Sabre/Vobject to parse the vCard $vcard = new rcube_vcard($object['data'], RCUBE_CHARSET, false, $map); if (!empty($vcard->displayname) || !empty($vcard->surname) || !empty($vcard->firstname) || !empty($vcard->email)) { $result = $vcard->get_assoc(); // Contact groups if (!empty($result['x-kind']) && implode($result['x-kind']) == 'group') { $result['_type'] = 'group'; $members = isset($result['x-member']) ? $result['x-member'] : []; unset($result['x-kind'], $result['x-member']); } else if (!empty($result['kind']) && implode($result['kind']) == 'group') { $result['_type'] = 'group'; $members = isset($result['member']) ? $result['member'] : []; unset($result['kind'], $result['member']); } if (isset($members)) { $result['member'] = []; foreach ($members as $member) { if (strpos($member, 'urn:uuid:') === 0) { $result['member'][] = ['uid' => substr($member, 9)]; } else if (strpos($member, 'mailto:') === 0) { $member = reset(rcube_mime::decode_address_list(urldecode(substr($member, 7)))); if (!empty($member['mailto'])) { $result['member'][] = ['email' => $member['mailto'], 'name' => $member['name']]; } } } } if (!empty($result['uid'])) { $result['uid'] = preg_replace('/^urn:uuid:/', '', implode($result['uid'])); } } else { return false; } } $result['etag'] = $object['etag']; $result['href'] = $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 // TODO: We should probably use Sabre/Vobject to create the vCard // vCard properties not supported by rcube_vcard $map = ['uid' => 'UID', 'kind' => 'KIND']; $vcard = new rcube_vcard('', RCUBE_CHARSET, false, $map); if ((!empty($object['_type']) && $object['_type'] == 'group') || (!empty($object['type']) && $object['type'] == 'group') ) { $object['kind'] = 'group'; } foreach ($object as $key => $values) { list($field, $section) = rcube_utils::explode(':', $key); // avoid casting DateTime objects to array if (is_object($values) && is_a($values, 'DateTime')) { $values = [$values]; } foreach ((array) $values as $value) { if (isset($value)) { $vcard->set($field, $value, $section); } } } $result = $vcard->export(false); if (!empty($object['kind']) && $object['kind'] == 'group') { $members = ''; foreach ((array) $object['member'] as $member) { $value = null; if (!empty($member['uid'])) { $value = 'urn:uuid:' . $member['uid']; } else if (!empty($member['email']) && !empty($member['name'])) { $value = 'mailto:' . urlencode(sprintf('"%s" <%s>', addcslashes($member['name'], '"'), $member['email'])); } else if (!empty($member['email'])) { $value = 'mailto:' . $member['email']; } if ($value) { $members .= "MEMBER:{$value}\r\n"; } } if ($members) { $result = preg_replace('/\r\nEND:VCARD/', "\r\n{$members}END:VCARD", $result); } /** Version 4.0 of the vCard format requires Cyrus >= 3.6.0, we'll use Version 3.0 for now $result = preg_replace('/\r\nVERSION:3\.0\r\n/', "\r\nVERSION:4.0\r\n", $result); $result = preg_replace('/\r\nN:[^\r]+/', '', $result); $result = preg_replace('/\r\nUID:([^\r]+)/', "\r\nUID:urn:uuid:\\1", $result); */ $result = preg_replace('/\r\nMEMBER:([^\r]+)/', "\r\nX-ADDRESSBOOKSERVER-MEMBER:\\1", $result); $result = preg_replace('/\r\nKIND:([^\r]+)/', "\r\nX-ADDRESSBOOKSERVER-KIND:\\1", $result); } } if ($result) { // The content must be UTF-8, otherwise if we try to fetch the object // from server XML parsing would fail. $result = rcube_charset::clean($result); } return $result; } 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() { return kolab_storage_dav::get_dav_type($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 Folder display name */ public function __toString() { return $this->attributes['name']; } }