diff --git a/src/app/Backends/DAV.php b/src/app/Backends/DAV.php
index 0028e38f..42730fb0 100644
--- a/src/app/Backends/DAV.php
+++ b/src/app/Backends/DAV.php
@@ -1,509 +1,548 @@
'urn:ietf:params:xml:ns:caldav',
self::TYPE_VTODO => 'urn:ietf:params:xml:ns:caldav',
self::TYPE_VCARD => 'urn:ietf:params:xml:ns:carddav',
];
protected $url;
protected $user;
protected $password;
protected $responseHeaders = [];
/**
* Object constructor
*/
public function __construct($user, $password)
{
$this->url = \config('services.dav.uri');
$this->user = $user;
$this->password = $password;
}
/**
* Discover DAV home (root) collection of a 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(string $component = self::TYPE_VEVENT)
{
$roots = [
self::TYPE_VEVENT => 'calendars',
self::TYPE_VTODO => 'calendars',
self::TYPE_VCARD => 'addressbooks',
];
$homes = [
self::TYPE_VEVENT => 'calendar-home-set',
self::TYPE_VTODO => 'calendar-home-set',
self::TYPE_VCARD => 'addressbook-home-set',
];
$path = parse_url($this->url, PHP_URL_PATH);
$body = ''
. ''
. ''
. ''
. ''
. '';
// Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it)
$headers = ['Depth' => 1, 'Prefer' => 'return-minimal'];
$response = $this->request('/' . $roots[$component], 'PROPFIND', $body, $headers);
if (empty($response)) {
\Log::error("Failed to get current-user-principal for {$component} from the DAV server.");
return false;
}
$elements = $response->getElementsByTagName('response');
foreach ($elements as $element) {
foreach ($element->getElementsByTagName('prop') as $prop) {
$principal_href = $prop->nodeValue;
break;
}
}
if (empty($principal_href)) {
\Log::error("No principal on the DAV server.");
return false;
}
if ($path && strpos($principal_href, $path) === 0) {
$principal_href = substr($principal_href, strlen($path));
}
$body = ''
. ''
. ''
. ''
. ''
. '';
$response = $this->request($principal_href, 'PROPFIND', $body);
if (empty($response)) {
\Log::error("Failed to get homes for {$component} from the DAV server.");
return false;
}
$root_href = false;
$elements = $response->getElementsByTagName('response');
foreach ($elements as $element) {
foreach ($element->getElementsByTagName('prop') 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));
}
}
return $root_href;
}
/**
* Check if we can connect to the DAV server
*
* @return bool True on success, False otherwise
*/
public static function healthcheck(): bool
{
// TODO
return true;
}
/**
* 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(string $component)
{
$root_href = $this->discover($component);
if ($root_href === false) {
return false;
}
$ns = 'xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/"';
$props = '';
if ($component != self::TYPE_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)
$headers = ['Depth' => 1, 'Prefer' => 'return-minimal'];
$response = $this->request($root_href, 'PROPFIND', $body, $headers);
if (empty($response)) {
\Log::error("Failed to get folders list from the DAV server.");
return false;
}
$folders = [];
foreach ($response->getElementsByTagName('response') as $element) {
$folder = DAV\Folder::fromDomElement($element);
// Note: Addressbooks don't have 'type' specified
if (
($component == self::TYPE_VCARD && in_array('addressbook', $folder->types))
|| in_array($component, $folder->components)
) {
$folders[] = $folder;
}
}
return $folders;
}
/**
* Create a DAV object in a folder
*
* @param DAV\CommonObject $object Object
*
* @return false|DAV\CommonObject Object on success, False on error
*/
public function create(DAV\CommonObject $object)
{
$headers = ['Content-Type' => $object->contentType];
$response = $this->request($object->href, 'PUT', $object, $headers);
if ($response !== false) {
if ($etag = $this->responseHeaders['etag']) {
if (preg_match('|^".*"$|', $etag)) {
$etag = substr($etag, 1, -1);
}
$object->etag = $etag;
}
return $object;
}
return false;
}
/**
* Update a DAV object in a folder
*
* @param DAV\CommonObject $object Object
*
* @return false|DAV\CommonObject Object on success, False on error
*/
public function update(DAV\CommonObject $object)
{
return $this->create($object);
}
/**
* Delete a DAV object from a folder
*
* @param string $location Object location
*
* @return bool True on success, False on error
*/
public function delete(string $location)
{
$response = $this->request($location, 'DELETE', '', ['Depth' => 1, 'Prefer' => 'return-minimal']);
return $response !== false;
}
+ /**
+ * Create a DAV folder (collection)
+ *
+ * @param DAV\Folder $folder Folder object
+ *
+ * @return bool True on success, False on error
+ */
+ public function folderCreate(DAV\Folder $folder)
+ {
+ $response = $this->request($folder->href, 'MKCOL', $folder->toXML('mkcol'));
+
+ return $response !== false;
+ }
+
+ /**
+ * Delete a DAV folder (collection)
+ *
+ * @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;
+ }
+
/**
* Get all properties of a folder.
*
* @param string $location Object location
*
* @return false|DAV\Folder Folder metadata or False on error
*/
public function folderInfo(string $location)
{
- $body = ''
- . ''
- . ''
- . '';
+ $body = DAV\Folder::propfindXML();
// Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it)
- $headers = ['Depth' => 1, 'Prefer' => 'return-minimal'];
-
- $response = $this->request($location, 'PROPFIND', $body, $headers);
+ $response = $this->request($location, 'PROPFIND', $body, ['Depth' => 0, 'Prefer' => 'return-minimal']);
if (!empty($response) && ($element = $response->getElementsByTagName('response')->item(0))) {
return DAV\Folder::fromDomElement($element);
}
return false;
}
+ /**
+ * Update a DAV folder (collection)
+ *
+ * @param DAV\Folder $folder Folder object
+ *
+ * @return bool True on success, False on error
+ */
+ public function folderUpdate(DAV\Folder $folder)
+ {
+ // Note: Changing resourcetype property is forbidden (at least by Cyrus)
+
+ $response = $this->request($folder->href, 'PROPPATCH', $folder->toXML('propertyupdate'));
+
+ return $response !== false;
+ }
+
/**
* Search DAV objects in 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 search(string $location, string $component)
{
$queries = [
self::TYPE_VEVENT => 'calendar-query',
self::TYPE_VTODO => 'calendar-query',
self::TYPE_VCARD => 'addressbook-query',
];
$filter = '';
if ($component != self::TYPE_VCARD) {
$filter = ''
. ''
. '';
}
// TODO: Make filter an argument of this function to build all kind of queries.
// It probably should be a separate object e.g. DAV\Filter.
// TODO: List of object props to return should also be an argument, so we not only
// could fetch "an index" but also any of object's data.
$body = ''
. ' '
. ''
. ''
. ''
. ($filter ? "$filter" : '')
. '';
$response = $this->request($location, 'REPORT', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']);
if (empty($response)) {
\Log::error("Failed to get objects from the DAV server.");
return false;
}
$objects = [];
foreach ($response->getElementsByTagName('response') as $element) {
$objects[] = $this->objectFromElement($element, $component);
}
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 getObjects(string $location, string $component, array $hrefs = [])
{
if (empty($hrefs)) {
return [];
}
$body = '';
foreach ($hrefs as $href) {
$body .= '' . $href . '';
}
$queries = [
self::TYPE_VEVENT => 'calendar-multiget',
self::TYPE_VTODO => 'calendar-multiget',
self::TYPE_VCARD => 'addressbook-multiget',
];
$types = [
self::TYPE_VEVENT => 'calendar-data',
self::TYPE_VTODO => 'calendar-data',
self::TYPE_VCARD => 'address-data',
];
$body = ''
. ' '
. ''
. ''
. ''
. ''
. $body
. '';
$response = $this->request($location, 'REPORT', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']);
if (empty($response)) {
\Log::error("Failed to get objects from the DAV server.");
return false;
}
$objects = [];
foreach ($response->getElementsByTagName('response') as $element) {
$objects[] = $this->objectFromElement($element, $component);
}
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) {
if (is_array($header_value)) {
$header_value = implode("\n\t", $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);
}
/**
* Create DAV\CommonObject from a DOMElement
*/
protected function objectFromElement($element, $component)
{
switch ($component) {
case self::TYPE_VEVENT:
$object = DAV\Vevent::fromDomElement($element);
break;
case self::TYPE_VTODO:
$object = DAV\Vtodo::fromDomElement($element);
break;
case self::TYPE_VCARD:
$object = DAV\Vcard::fromDomElement($element);
break;
default:
throw new \Exception("Unknown component: {$component}");
}
return $object;
}
/**
* Execute HTTP request to a DAV server
*/
protected function request($path, $method, $body = '', $headers = [])
{
$debug = \config('app.debug');
$url = $this->url;
$this->responseHeaders = [];
if ($path && ($rootPath = parse_url($url, PHP_URL_PATH)) && strpos($path, $rootPath) === 0) {
$path = substr($path, strlen($rootPath));
}
$url .= $path;
$client = Http::withBasicAuth($this->user, $this->password);
// $client = Http::withToken($token); // Bearer token
if ($body) {
if (!isset($headers['Content-Type'])) {
$headers['Content-Type'] = 'application/xml; charset=utf-8';
}
$client->withBody($body, $headers['Content-Type']);
}
if (!empty($headers)) {
$client->withHeaders($headers);
}
if ($debug) {
\Log::debug("C: {$method}: {$url}\n" . $this->debugBody($body, $headers));
}
$response = $client->send($method, $url);
$body = $response->body();
$code = $response->status();
if ($debug) {
\Log::debug("S: [{$code}]\n" . $this->debugBody($body, $response->headers()));
}
// Throw an exception if a client or server error occurred...
$response->throw();
$this->responseHeaders = $response->headers();
return $this->parseXML($body);
}
}
diff --git a/src/app/Backends/DAV/Folder.php b/src/app/Backends/DAV/Folder.php
index 0c4ca5d3..c86c6f08 100644
--- a/src/app/Backends/DAV/Folder.php
+++ b/src/app/Backends/DAV/Folder.php
@@ -1,77 +1,155 @@
getElementsByTagName('href')->item(0)) {
$folder->href = $href->nodeValue;
}
if ($color = $element->getElementsByTagName('calendar-color')->item(0)) {
if (preg_match('/^#[0-9a-fA-F]{6,8}$/', $color->nodeValue)) {
$folder->color = substr($color->nodeValue, 1);
}
}
if ($name = $element->getElementsByTagName('displayname')->item(0)) {
$folder->name = $name->nodeValue;
}
if ($ctag = $element->getElementsByTagName('getctag')->item(0)) {
$folder->ctag = $ctag->nodeValue;
}
$components = [];
if ($set_element = $element->getElementsByTagName('supported-calendar-component-set')->item(0)) {
foreach ($set_element->getElementsByTagName('comp') as $comp) {
$components[] = $comp->attributes->getNamedItem('name')->nodeValue;
}
}
$types = [];
if ($type_element = $element->getElementsByTagName('resourcetype')->item(0)) {
foreach ($type_element->childNodes as $node) {
if ($node->nodeType == XML_ELEMENT_NODE) {
$_type = explode(':', $node->nodeName);
$types[] = count($_type) > 1 ? $_type[1] : $_type[0];
}
}
}
$folder->types = $types;
$folder->components = $components;
return $folder;
}
+
+ /**
+ * Parse folder properties input into XML string to use in a request
+ *
+ * @return string
+ */
+ public function toXML($tag)
+ {
+ $ns = 'xmlns:d="DAV:"';
+ $props = '';
+ $type = null;
+
+ if (in_array('addressbook', $this->types)) {
+ $ns .= ' xmlns:c="urn:ietf:params:xml:ns:carddav"';
+ $type = 'addressbook';
+ } elseif (in_array('calendar', $this->types)) {
+ $ns .= ' xmlns:c="urn:ietf:params:xml:ns:caldav"';
+ $type = 'calendar';
+ }
+
+ $props .= '' . ($type ? "" : '') . '';
+
+ if (!empty($this->components)) {
+ $props .= '';
+ foreach ($this->components as $component) {
+ $props .= '';
+ }
+ $props .= '';
+ }
+
+ if ($this->name !== null) {
+ $props .= '' . htmlspecialchars($this->name, ENT_XML1, 'UTF-8') . '';
+ }
+
+ if ($this->color !== null) {
+ $color = $this->color;
+ if (strlen($color) && $color[0] != '#') {
+ $color = '#' . $color;
+ }
+
+ $ns .= ' xmlns:a="http://apple.com/ns/ical/"';
+ $props .= '' . htmlspecialchars($color, ENT_XML1, 'UTF-8') . '';
+ }
+
+ return ''
+ . "{$props}";
+ }
+
+ /**
+ * Get XML string for PROPFIND query on a folder
+ *
+ * @return string
+ */
+ public static function propfindXML()
+ {
+ $ns = implode(' ', [
+ 'xmlns:d="DAV:"',
+ // 'xmlns:cs="http://calendarserver.org/ns/"',
+ 'xmlns:c="urn:ietf:params:xml:ns:caldav"',
+ // 'xmlns:a="http://apple.com/ns/ical/"',
+ // 'xmlns:k="Kolab:"'
+ ]);
+
+ // Note: does not include some of the properties we're interested in
+ return ''
+ . ''
+ . ''
+ // . ''
+ . ''
+ // . ''
+ // . ''
+ // . ''
+ . ''
+ . ''
+ // . ''
+ . ''
+ . '';
+ }
}