diff --git a/plugins/libkolab/lib/kolab_dav_client.php b/plugins/libkolab/lib/kolab_dav_client.php index 1659372b..c5d3fb29 100644 --- a/plugins/libkolab/lib/kolab_dav_client.php +++ b/plugins/libkolab/lib/kolab_dav_client.php @@ -1,802 +1,803 @@ * * 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_dav_client { public $url; protected $user; protected $password; protected $rc; protected $responseHeaders = []; /** * Object constructor */ public function __construct($url) { $this->rc = rcube::get_instance(); $parsedUrl = parse_url($url); if (!empty($parsedUrl['user']) && !empty($parsedUrl['pass'])) { $this->user = rawurldecode($parsedUrl['user']); $this->password = rawurldecode($parsedUrl['pass']); $url = str_replace(rawurlencode($this->user) . ':' . rawurlencode($this->password) . '@', '', $url); } else { $this->user = $this->rc->user->get_username(); $this->password = $this->rc->decrypt($_SESSION['password']); } $this->url = $url; } /** * Execute HTTP request to a DAV server */ protected function request($path, $method, $body = '', $headers = []) { $rcube = rcube::get_instance(); $debug = (array) $rcube->config->get('dav_debug'); $request_config = [ 'store_body' => true, 'follow_redirects' => true, ]; $this->responseHeaders = []; if ($path && ($rootPath = parse_url($this->url, PHP_URL_PATH)) && strpos($path, $rootPath) === 0) { $path = substr($path, strlen($rootPath)); } try { $request = $this->initRequest($this->url . $path, $method, $request_config); $request->setAuth($this->user, $this->password); if ($body) { $request->setBody($body); $request->setHeader(['Content-Type' => 'application/xml; charset=utf-8']); } if (!empty($headers)) { $request->setHeader($headers); } if ($debug) { rcube::write_log('dav', "C: {$method}: " . (string) $request->getUrl() . "\n" . $this->debugBody($body, $request->getHeaders())); } $response = $request->send(); $body = $response->getBody(); $code = $response->getStatus(); if ($debug) { rcube::write_log('dav', "S: [{$code}]\n" . $this->debugBody($body, $response->getHeader())); } if ($code >= 300) { throw new Exception("DAV Error ($code):\n{$body}"); } $this->responseHeaders = $response->getHeader(); return $this->parseXML($body); } catch (Exception $e) { rcube::raise_error($e, true, false); return false; } } /** * Discover DAV home (root) collection of specified type. * * @param string $component Component to filter by (VEVENT, VTODO, VCARD) * * @return string|false Home collection location or False on error */ public function discover($component = 'VEVENT') { if ($cache = $this->get_cache()) { $cache_key = "discover.{$component}." . md5($this->url); if ($response = $cache->get($cache_key)) { return $response; } } $roots = [ 'VEVENT' => 'calendars', 'VTODO' => 'calendars', 'VCARD' => 'addressbooks', ]; $path = parse_url($this->url, PHP_URL_PATH); $body = '' . '' . '' . '' . '' . ''; // Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it) $response = $this->request('/' . $roots[$component], 'PROPFIND', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']); if (empty($response)) { return false; } $elements = $response->getElementsByTagName('response'); foreach ($elements as $element) { - foreach ($element->getElementsByTagName('prop') as $prop) { + foreach ($element->getElementsByTagName('current-user-principal') as $prop) { $principal_href = $prop->nodeValue; break; } } if ($path && strpos($principal_href, $path) === 0) { $principal_href = substr($principal_href, strlen($path)); } $homes = [ 'VEVENT' => 'calendar-home-set', 'VTODO' => 'calendar-home-set', 'VCARD' => 'addressbook-home-set', ]; $ns = [ 'VEVENT' => 'caldav', 'VTODO' => 'caldav', 'VCARD' => 'carddav', ]; $body = '' . '' . '' . '' . '' . ''; $response = $this->request($principal_href, 'PROPFIND', $body); if (empty($response)) { return false; } $elements = $response->getElementsByTagName('response'); foreach ($elements as $element) { - foreach ($element->getElementsByTagName('prop') as $prop) { + foreach ($element->getElementsByTagName($homes[$component]) as $prop) { $root_href = $prop->nodeValue; break; } } if (!empty($root_href)) { if ($path && strpos($root_href, $path) === 0) { $root_href = substr($root_href, strlen($path)); } } else { // Kolab iRony's calendar root $root_href = '/' . $roots[$component] . '/' . rawurlencode($this->user); } if ($cache) { $cache->set($cache_key, $root_href); } return $root_href; } /** * Get list of folders of specified type. * * @param string $component Component to filter by (VEVENT, VTODO, VCARD) * * @return false|array List of folders' metadata or False on error */ public function listFolders($component = 'VEVENT') { $root_href = $this->discover($component); if ($root_href === false) { return false; } $ns = 'xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/"'; $props = ''; if ($component != 'VCARD') { $ns .= ' xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:a="http://apple.com/ns/ical/" xmlns:k="Kolab:"'; $props = '' . '' . ''; } $body = '' . '' . '' . '' . '' // . '' . '' . $props . '' . ''; // Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it) $response = $this->request($root_href, 'PROPFIND', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']); if (empty($response)) { return false; } $folders = []; foreach ($response->getElementsByTagName('response') as $element) { $folder = $this->getFolderPropertiesFromResponse($element); // Note: Addressbooks don't have 'type' specified if (($component == 'VCARD' && in_array('addressbook', $folder['resource_type'])) || $folder['type'] === $component ) { $folders[] = $folder; } } return $folders; } /** * Create a DAV object in a folder * * @param string $location Object location * @param string $content Object content * @param string $component Content type (VEVENT, VTODO, VCARD) * * @return false|string|null ETag string (or NULL) on success, False on error */ public function create($location, $content, $component = 'VEVENT') { $ctype = [ 'VEVENT' => 'text/calendar', 'VTODO' => 'text/calendar', 'VCARD' => 'text/vcard', ]; $headers = ['Content-Type' => $ctype[$component] . '; charset=utf-8']; $response = $this->request($location, 'PUT', $content, $headers); if ($response !== false) { - $etag = $this->responseHeaders['etag']; + // Note: ETag is not always returned, e.g. https://github.com/cyrusimap/cyrus-imapd/issues/2456 + $etag = isset($this->responseHeaders['etag']) ? $this->responseHeaders['etag'] : null; - if (preg_match('|^".*"$|', $etag)) { + if (is_string($etag) && preg_match('|^".*"$|', $etag)) { $etag = substr($etag, 1, -1); } return $etag; } return false; } /** * Update a DAV object in a folder * * @param string $location Object location * @param string $content Object content * @param string $component Content type (VEVENT, VTODO, VCARD) * * @return false|string|null ETag string (or NULL) on success, False on error */ public function update($location, $content, $component = 'VEVENT') { return $this->create($location, $content, $component); } /** * Delete a DAV object from a folder * * @param string $location Object location * * @return bool True on success, False on error */ public function delete($location) { $response = $this->request($location, 'DELETE', '', ['Depth' => 1, 'Prefer' => 'return-minimal']); return $response !== false; } /** * Get folder properties. * * @param string $location Object location * * @return false|array Folder metadata or False on error */ public function folderInfo($location) { $body = '' . '' . '' . ''; // Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it) $response = $this->request($location, 'PROPFIND', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']); if (!empty($response) && ($element = $response->getElementsByTagName('response')) && ($folder = $this->getFolderPropertiesFromResponse($element)) ) { return $folder; } return false; } /** * Create a DAV folder * * @param string $location Object location (relative to the user home) * @param string $component Content type (VEVENT, VTODO, VCARD) * @param array $properties Object content * * @return bool True on success, False on error */ public function folderCreate($location, $component, $properties = []) { // Create the collection $response = $this->request($location, 'MKCOL'); if (empty($response)) { return false; } // Update collection properties return $this->folderUpdate($location, $component, $properties); } /** * Delete a DAV folder * * @param string $location Folder location * * @return bool True on success, False on error */ public function folderDelete($location) { $response = $this->request($location, 'DELETE'); return $response !== false; } /** * Update a DAV folder * * @param string $location Object location * @param string $component Content type (VEVENT, VTODO, VCARD) * @param array $properties Object content * * @return bool True on success, False on error */ public function folderUpdate($location, $component, $properties = []) { $ns = 'xmlns:d="DAV:"'; $props = ''; if ($component == 'VCARD') { $ns .= ' xmlns:c="urn:ietf:params:xml:ns:carddav"'; // Resourcetype property is protected // $props = ''; } else { $ns .= ' xmlns:c="urn:ietf:params:xml:ns:caldav"'; // Resourcetype property is protected // $props = ''; /* // Note: These are set by Cyrus automatically for calendars . '' . '' . '' . '' . '' . '' . ''; */ } foreach ($properties as $name => $value) { if ($name == 'name') { $props .= '' . htmlspecialchars($value, ENT_XML1, 'UTF-8') . ''; } else if ($name == 'color' && strlen($value)) { if ($value[0] != '#') { $value = '#' . $value; } $ns .= ' xmlns:a="http://apple.com/ns/ical/"'; $props .= '' . htmlspecialchars($value, ENT_XML1, 'UTF-8') . ''; } else if ($name == 'alarms') { if (!strpos($ns, 'Kolab:')) { $ns .= ' xmlns:k="Kolab:"'; } $props .= "" . ($value ? 'true' : 'false') . ""; } } if (empty($props)) { return true; } $body = '' . '' . '' . '' . $props . '' . '' . ''; $response = $this->request($location, 'PROPPATCH', $body); // TODO: Should we make sure "200 OK" status is set for all requested properties? return $response !== false; } /** * Fetch DAV objects metadata (ETag, href) a folder * * @param string $location Folder location * @param string $component Object type (VEVENT, VTODO, VCARD) * * @return false|array Objects metadata on success, False on error */ public function getIndex($location, $component = 'VEVENT') { $queries = [ 'VEVENT' => 'calendar-query', 'VTODO' => 'calendar-query', 'VCARD' => 'addressbook-query', ]; $ns = [ 'VEVENT' => 'caldav', 'VTODO' => 'caldav', 'VCARD' => 'carddav', ]; $filter = ''; if ($component != 'VCARD') { $filter = '' . '' . ''; } $body = '' .' ' . '' . '' . '' . ($filter ? "$filter" : '') . ''; $response = $this->request($location, 'REPORT', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']); if (empty($response)) { return false; } $objects = []; foreach ($response->getElementsByTagName('response') as $element) { $objects[] = $this->getObjectPropertiesFromResponse($element); } return $objects; } /** * Fetch DAV objects data from a folder * * @param string $location Folder location * @param string $component Object type (VEVENT, VTODO, VCARD) * @param array $hrefs List of objects' locations to fetch (empty for all objects) * * @return false|array Objects metadata on success, False on error */ public function getData($location, $component = 'VEVENT', $hrefs = []) { if (empty($hrefs)) { return []; } $body = ''; foreach ($hrefs as $href) { $body .= '' . $href . ''; } $queries = [ 'VEVENT' => 'calendar-multiget', 'VTODO' => 'calendar-multiget', 'VCARD' => 'addressbook-multiget', ]; $ns = [ 'VEVENT' => 'caldav', 'VTODO' => 'caldav', 'VCARD' => 'carddav', ]; $types = [ 'VEVENT' => 'calendar-data', 'VTODO' => 'calendar-data', 'VCARD' => 'address-data', ]; $body = '' .' ' . '' . '' . '' . '' . $body . ''; $response = $this->request($location, 'REPORT', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']); if (empty($response)) { return false; } $objects = []; foreach ($response->getElementsByTagName('response') as $element) { $objects[] = $this->getObjectPropertiesFromResponse($element); } return $objects; } /** * Parse XML content */ protected function parseXML($xml) { $doc = new DOMDocument('1.0', 'UTF-8'); if (stripos($xml, 'loadXML($xml)) { throw new Exception("Failed to parse XML"); } $doc->formatOutput = true; } return $doc; } /** * Parse request/response body for debug purposes */ protected function debugBody($body, $headers) { $head = ''; foreach ($headers as $header_name => $header_value) { $head .= "{$header_name}: {$header_value}\n"; } if (stripos($body, 'formatOutput = true; $doc->preserveWhiteSpace = false; if (!$doc->loadXML($body)) { throw new Exception("Failed to parse XML"); } $body = $doc->saveXML(); } return $head . "\n" . rtrim($body); } /** * Extract folder properties from a server 'response' element */ protected function getFolderPropertiesFromResponse(DOMNode $element) { if ($href = $element->getElementsByTagName('href')->item(0)) { $href = $href->nodeValue; /* $path = parse_url($this->url, PHP_URL_PATH); if ($path && strpos($href, $path) === 0) { $href = substr($href, strlen($path)); } */ } if ($color = $element->getElementsByTagName('calendar-color')->item(0)) { if (preg_match('/^#[0-9a-fA-F]{6,8}$/', $color->nodeValue)) { $color = substr($color->nodeValue, 1); } else { $color = null; } } if ($name = $element->getElementsByTagName('displayname')->item(0)) { $name = $name->nodeValue; } if ($ctag = $element->getElementsByTagName('getctag')->item(0)) { $ctag = $ctag->nodeValue; } $component = null; if ($set_element = $element->getElementsByTagName('supported-calendar-component-set')->item(0)) { if ($comp_element = $set_element->getElementsByTagName('comp')->item(0)) { $component = $comp_element->attributes->getNamedItem('name')->nodeValue; } } $types = []; if ($type_element = $element->getElementsByTagName('resourcetype')->item(0)) { foreach ($type_element->childNodes as $node) { $_type = explode(':', $node->nodeName); $types[] = count($_type) > 1 ? $_type[1] : $_type[0]; } } $result = [ 'href' => $href, 'name' => $name, 'ctag' => $ctag, 'color' => $color, 'type' => $component, 'resource_type' => $types, ]; foreach (['alarms'] as $tag) { if ($el = $element->getElementsByTagName($tag)->item(0)) { if (strlen($el->nodeValue) > 0) { $result[$tag] = strtolower($el->nodeValue) === 'true'; } } } return $result; } /** * Extract object properties from a server 'response' element */ protected function getObjectPropertiesFromResponse(DOMNode $element) { $uid = null; if ($href = $element->getElementsByTagName('href')->item(0)) { $href = $href->nodeValue; /* $path = parse_url($this->url, PHP_URL_PATH); if ($path && strpos($href, $path) === 0) { $href = substr($href, strlen($path)); } */ // Extract UID from the URL $href_parts = explode('/', $href); $uid = preg_replace('/\.[a-z]+$/', '', $href_parts[count($href_parts)-1]); } if ($data = $element->getElementsByTagName('calendar-data')->item(0)) { $data = $data->nodeValue; } else if ($data = $element->getElementsByTagName('address-data')->item(0)) { $data = $data->nodeValue; } if ($etag = $element->getElementsByTagName('getetag')->item(0)) { $etag = $etag->nodeValue; if (preg_match('|^".*"$|', $etag)) { $etag = substr($etag, 1, -1); } } return [ 'href' => $href, 'data' => $data, 'etag' => $etag, 'uid' => $uid, ]; } /** * Initialize HTTP request object */ protected function initRequest($url = '', $method = 'GET', $config = array()) { $rcube = rcube::get_instance(); $http_config = (array) $rcube->config->get('kolab_http_request'); // deprecated configuration options if (empty($http_config)) { foreach (array('ssl_verify_peer', 'ssl_verify_host') as $option) { $value = $rcube->config->get('kolab_' . $option, true); if (is_bool($value)) { $http_config[$option] = $value; } } } if (!empty($config)) { $http_config = array_merge($http_config, $config); } // load HTTP_Request2 (support both composer-installed and system-installed package) if (!class_exists('HTTP_Request2')) { require_once 'HTTP/Request2.php'; } try { $request = new HTTP_Request2(); $request->setConfig($http_config); // proxy User-Agent string $request->setHeader('user-agent', $_SERVER['HTTP_USER_AGENT']); // cleanup $request->setBody(''); $request->setUrl($url); $request->setMethod($method); return $request; } catch (Exception $e) { rcube::raise_error($e, true, true); } } /** * Return caching object if enabled */ protected function get_cache() { $rcube = rcube::get_instance(); if ($cache_type = $rcube->config->get('dav_cache', 'db')) { $cache_ttl = $rcube->config->get('dav_cache_ttl', '10m'); $cache_name = 'DAV'; return $rcube->get_cache($cache_name, $cache_type, $cache_ttl); } } } diff --git a/plugins/libkolab/lib/kolab_storage_dataset.php b/plugins/libkolab/lib/kolab_storage_dataset.php index 819b8d8b..97717e2a 100644 --- a/plugins/libkolab/lib/kolab_storage_dataset.php +++ b/plugins/libkolab/lib/kolab_storage_dataset.php @@ -1,186 +1,191 @@ * * 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 = []; 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) { 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) { + if (isset($this->data[$idx]) && !is_string($this->data[$idx])) { + // skip objects that had the raw content in the cache (are not empty) + continue; + } + $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 28c6e4c8..e22518f9 100644 --- a/plugins/libkolab/lib/kolab_storage_dav_cache.php +++ b/plugins/libkolab/lib/kolab_storage_dav_cache.php @@ -1,644 +1,700 @@ * * 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)) { + foreach ($objects as $dav_object) { + if ($object = $this->folder->from_dav($dav_object)) { + $object['_raw'] = $dav_object['data']; $this->_extended_insert(false, $object); + unset($object['_raw']); } } $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)) { + foreach ($objects as $dav_object) { + if ($object = $this->folder->from_dav($dav_object)) { + $object['_raw'] = $dav_object['data']; $this->save($object, $object['uid']); + unset($object['_raw']); } } // 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) { $result[] = $sql_arr; } - else if (($object = $this->_unserialize($sql_arr, true))) { + else if (($object = $this->_unserialize($sql_arr, true, $fast))) { $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 convert the given Kolab object into a dataset to be written to cache + */ + protected function _serialize($object) + { + static $threshold; + + if ($threshold === null) { + $rcube = rcube::get_instance(); + $threshold = parse_bytes(rcube::get_instance()->config->get('dav_cache_threshold', 0)); + } + + $data = []; + $sql_data = ['changed' => null, 'tags' => '', 'words' => '']; + + if (!empty($object['changed'])) { + $sql_data['changed'] = date(self::DB_DATE_FORMAT, is_object($object['changed']) ? $object['changed']->format('U') : $object['changed']); + } + + // Store only minimal set of object properties + foreach ($this->data_props as $prop) { + if (isset($object[$prop])) { + $data[$prop] = $object[$prop]; + if ($data[$prop] instanceof DateTime) { + $data[$prop] = array( + 'cl' => 'DateTime', + 'dt' => $data[$prop]->format('Y-m-d H:i:s'), + 'tz' => $data[$prop]->getTimezone()->getName(), + ); + } + } + } + + if (!empty($object['_raw']) && $threshold > 0 && strlen($object['_raw']) <= $threshold) { + $data['_raw'] = $object['_raw']; + } + + $sql_data['data'] = json_encode(rcube_charset::clean($data)); + + return $sql_data; + } + /** * Helper method to turn stored cache data into a valid storage object */ - protected function _unserialize($sql_arr, $noread = false) + protected function _unserialize($sql_arr, $noread = false, $fast_mode = false) { - if ($sql_arr['fast-mode'] && !empty($sql_arr['data']) && ($object = json_decode($sql_arr['data'], true))) { + if (!empty($sql_arr['data'])) { + if ($object = json_decode($sql_arr['data'], true)) { + $object['_type'] = $sql_arr['type'] ?: $this->folder->type; + $object['uid'] = $sql_arr['uid']; + $object['etag'] = $sql_arr['etag']; + } + } + + if (!empty($fast_mode) && !empty($object)) { 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']; + unset($object['_raw']); } else if ($noread) { + // We have the raw content already, parse it + if (!empty($object['_raw'])) { + $object['data'] = $object['_raw']; + if ($object = $this->folder->from_dav($object)) { + return $object; + } + } + return null; } else { // 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 118c7b91..e73a6882 100644 --- a/plugins/libkolab/lib/kolab_storage_dav_folder.php +++ b/plugins/libkolab/lib/kolab_storage_dav_folder.php @@ -1,720 +1,722 @@ * * 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; + $object['_raw'] = $content; $this->cache->save($object, $uid); $result = true; + unset($object['_raw']); } } return $result; } /** * Fetch the object the DAV server and convert to internal format * * @param string The object UID to fetch * @param string The object type expected (use wildcard '*' to accept all types) * @param string Unused (kept for compat. with the parent class) * * @return mixed Hash array representing the Kolab object, a kolab_format instance or false if not found */ public function read_object($uid, $type = null, $folder = null) { if (!$this->valid) { return false; } $href = $this->object_location($uid); $objects = $this->dav->getData($this->href, $this->get_dav_type(), [$href]); if (!is_array($objects) || count($objects) != 1) { rcube::raise_error([ 'code' => 900, 'message' => "Failed to fetch {$href}" ], true); return false; } return $this->from_dav($objects[0]); } /** * Fetch multiple objects from the DAV server and convert to internal format * * @param array The object UIDs to fetch * * @return mixed Hash array representing the Kolab objects */ public function read_objects($uids) { if (!$this->valid) { return false; } if (empty($uids)) { return []; } foreach ($uids as $uid) { $hrefs[] = $this->object_location($uid); } $objects = $this->dav->getData($this->href, $this->get_dav_type(), $hrefs); if (!is_array($objects)) { rcube::raise_error([ 'code' => 900, 'message' => "Failed to fetch {$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']; + $result['href'] = !empty($object['href']) ? $object['href'] : null; + $result['uid'] = !empty($object['uid']) ? $object['uid'] : $result['uid']; return $result; } /** * Convert Kolab object into DAV format (iCalendar) */ public function to_dav($object) { $result = ''; if ($this->type == 'event') { $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']; } }