Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F174599
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
79 KB
Referenced Files
None
Subscribers
None
View Options
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 @@
<?php
/**
* A *DAV client.
*
* @author Aleksander Machniak <machniak@apheleia-it.ch>
*
* Copyright (C) 2022, Apheleia IT AG <contact@apheleia-it.ch>
*
* 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 <http://www.gnu.org/licenses/>.
*/
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 = '<?xml version="1.0" encoding="utf-8"?>'
. '<d:propfind xmlns:d="DAV:">'
. '<d:prop>'
. '<d:current-user-principal />'
. '</d:prop>'
. '</d:propfind>';
// 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 = '<?xml version="1.0" encoding="utf-8"?>'
. '<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:' . $ns[$component] . '">'
. '<d:prop>'
. '<c:' . $homes[$component] . ' />'
. '</d:prop>'
. '</d:propfind>';
$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 = '<c:supported-calendar-component-set />'
. '<a:calendar-color />'
. '<k:alarms />';
}
$body = '<?xml version="1.0" encoding="utf-8"?>'
. '<d:propfind ' . $ns . '>'
. '<d:prop>'
. '<d:resourcetype />'
. '<d:displayname />'
// . '<d:sync-token />'
. '<cs:getctag />'
. $props
. '</d:prop>'
. '</d:propfind>';
// 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 = '<?xml version="1.0" encoding="utf-8"?>'
. '<d:propfind xmlns:d="DAV:">'
. '<d:allprop/>'
. '</d:propfind>';
// 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 = '<d:resourcetype><d:collection/><c:addressbook/></d:resourcetype>';
}
else {
$ns .= ' xmlns:c="urn:ietf:params:xml:ns:caldav"';
// Resourcetype property is protected
// $props = '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>';
/*
// Note: These are set by Cyrus automatically for calendars
. '<c:supported-calendar-component-set>'
. '<c:comp name="VEVENT"/>'
. '<c:comp name="VTODO"/>'
. '<c:comp name="VJOURNAL"/>'
. '<c:comp name="VFREEBUSY"/>'
. '<c:comp name="VAVAILABILITY"/>'
. '</c:supported-calendar-component-set>';
*/
}
foreach ($properties as $name => $value) {
if ($name == 'name') {
$props .= '<d:displayname>' . htmlspecialchars($value, ENT_XML1, 'UTF-8') . '</d:displayname>';
}
else if ($name == 'color' && strlen($value)) {
if ($value[0] != '#') {
$value = '#' . $value;
}
$ns .= ' xmlns:a="http://apple.com/ns/ical/"';
$props .= '<a:calendar-color>' . htmlspecialchars($value, ENT_XML1, 'UTF-8') . '</a:calendar-color>';
}
else if ($name == 'alarms') {
if (!strpos($ns, 'Kolab:')) {
$ns .= ' xmlns:k="Kolab:"';
}
$props .= "<k:{$name}>" . ($value ? 'true' : 'false') . "</k:{$name}>";
}
}
if (empty($props)) {
return true;
}
$body = '<?xml version="1.0" encoding="utf-8"?>'
. '<d:propertyupdate ' . $ns . '>'
. '<d:set>'
. '<d:prop>' . $props . '</d:prop>'
. '</d:set>'
. '</d:propertyupdate>';
$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 = '<c:comp-filter name="VCALENDAR">'
. '<c:comp-filter name="' . $component . '" />'
. '</c:comp-filter>';
}
$body = '<?xml version="1.0" encoding="utf-8"?>'
.' <c:' . $queries[$component] . ' xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:' . $ns[$component]. '">'
. '<d:prop>'
. '<d:getetag />'
. '</d:prop>'
. ($filter ? "<c:filter>$filter</c:filter>" : '')
. '</c:' . $queries[$component] . '>';
$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 .= '<d:href>' . $href . '</d: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 = '<?xml version="1.0" encoding="utf-8"?>'
.' <c:' . $queries[$component] . ' xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:' . $ns[$component] . '">'
. '<d:prop>'
. '<d:getetag />'
. '<c:' . $types[$component]. ' />'
. '</d:prop>'
. $body
. '</c:' . $queries[$component] . '>';
$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, '<?xml') === 0) {
if (!$doc->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, '<?xml') === 0) {
$doc = new DOMDocument('1.0', 'UTF-8');
$doc->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 @@
<?php
/**
* Dataset class providing the results of a select operation on a kolab_storage_folder.
*
* Can be used as a normal array as well as an iterator in foreach() loops.
*
* @version @package_version@
* @author Thomas Bruederli <bruederli@kolabsys.com>
*
* Copyright (C) 2014, Kolab Systems AG <contact@kolabsys.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
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 @@
<?php
/**
* Kolab storage cache class providing a local caching layer for Kolab groupware objects.
*
* @author Aleksander Machniak <machniak@apheleia-it.ch>
*
* Copyright (C) 2012-2022, Apheleia IT AG <contact@apheleia-it.ch>
*
* 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 <http://www.gnu.org/licenses/>.
*/
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 <calendar-href>/<uid>.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: ['<colname>', '<comparator>', '<value>']
* @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 @@
<?php
/**
* A class representing a DAV folder object.
*
* @author Aleksander Machniak <machniak@apheleia-it.ch>
*
* Copyright (C) 2014-2022, Apheleia IT AG <contact@apheleia-it.ch>
*
* 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 <http://www.gnu.org/licenses/>.
*/
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'];
}
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sat, Jan 18, 2:40 PM (3 h, 31 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
119957
Default Alt Text
(79 KB)
Attached To
Mode
R14 roundcubemail-plugins-kolab
Attached
Detach File
Event Timeline
Log In to Comment