Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F237000
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
26 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 a9dc056f..5fcfb6b2 100644
--- a/plugins/libkolab/lib/kolab_dav_client.php
+++ b/plugins/libkolab/lib/kolab_dav_client.php
@@ -1,820 +1,823 @@
<?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;
}
}
$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('/', 'PROPFIND', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']);
if (empty($response)) {
return false;
}
$elements = $response->getElementsByTagName('response');
foreach ($elements as $element) {
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($homes[$component]) as $prop) {
$root_href = $prop->nodeValue;
break;
}
}
if (empty($root_href)) {
return false;
}
if ($path && strpos($root_href, $path) === 0) {
$root_href = substr($root_href, strlen($path));
}
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 'types' specified
- if (($component == 'VCARD' && in_array('addressbook', $folder['resource_type']))
- || in_array($component, (array) $folder['types'])
- ) {
+ // Filter out the folders of other type
+ if ($component == 'VCARD') {
+ if (in_array('addressbook', $folder['resource_type'])) {
+ $folders[] = $folder;
+ }
+ }
+ else if (in_array('calendar', $folder['resource_type']) && in_array($component, (array) $folder['types'])) {
$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);
return $this->getETagFromResponse($response);
}
/**
* 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;
}
/**
* Move a DAV object
*
* @param string $source Source object location
* @param string $target Target object content
*
* @return false|string|null ETag string (or NULL) on success, False on error
*/
public function move($source, $target)
{
$headers = ['Destination' => $target];
$response = $this->request($source, 'MOVE', '', $headers);
return $this->getETagFromResponse($response);
}
/**
* 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' => 0, 'Prefer' => 'return-minimal']);
if (!empty($response)
&& ($element = $response->getElementsByTagName('response')->item(0))
&& ($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;
}
$components = [];
if ($set_element = $element->getElementsByTagName('supported-calendar-component-set')->item(0)) {
foreach ($set_element->getElementsByTagName('comp') as $comp_element) {
$components[] = $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,
'types' => $components,
'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,
];
}
/**
* Get ETag from a response
*/
protected function getETagFromResponse($response)
{
if ($response !== false) {
// 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 (is_string($etag) && preg_match('|^".*"$|', $etag)) {
$etag = substr($etag, 1, -1);
}
return $etag;
}
return false;
}
/**
* 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);
}
}
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Fri, May 16, 10:02 PM (1 d, 4 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
178371
Default Alt Text
(26 KB)
Attached To
Mode
R14 roundcubemail-plugins-kolab
Attached
Detach File
Event Timeline
Log In to Comment