diff --git a/src/app/Backends/DAV.php b/src/app/Backends/DAV.php
new file mode 100644
--- /dev/null
+++ b/src/app/Backends/DAV.php
@@ -0,0 +1,508 @@
+ '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('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;
+ }
+
+ /**
+ * 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 = ''
+ . ''
+ . ''
+ . '';
+
+ // Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it)
+ $headers = ['Depth' => 1, 'Prefer' => 'return-minimal'];
+
+ $response = $this->request($location, 'PROPFIND', $body, $headers);
+
+ if (!empty($response) && ($element = $response->getElementsByTagName('response')->item(0))) {
+ return DAV\Folder::fromDomElement($element);
+ }
+
+ return 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/CommonObject.php b/src/app/Backends/DAV/CommonObject.php
new file mode 100644
--- /dev/null
+++ b/src/app/Backends/DAV/CommonObject.php
@@ -0,0 +1,58 @@
+getElementsByTagName('href')->item(0)) {
+ $object->href = $href->nodeValue;
+
+ // Extract UID from the URL
+ $href_parts = explode('/', $object->href);
+ $object->uid = preg_replace('/\.[a-z]+$/', '', $href_parts[count($href_parts)-1]);
+ }
+
+ if ($etag = $element->getElementsByTagName('getetag')->item(0)) {
+ $object->etag = $etag->nodeValue;
+ if (preg_match('|^".*"$|', $object->etag)) {
+ $object->etag = substr($object->etag, 1, -1);
+ }
+ }
+
+ return $object;
+ }
+
+ /**
+ * Create string representation of the DAV object
+ *
+ * @return string
+ */
+ public function __toString()
+ {
+ return '';
+ }
+}
diff --git a/src/app/Backends/DAV/Folder.php b/src/app/Backends/DAV/Folder.php
new file mode 100644
--- /dev/null
+++ b/src/app/Backends/DAV/Folder.php
@@ -0,0 +1,77 @@
+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;
+ }
+}
diff --git a/src/app/Backends/DAV/Vcard.php b/src/app/Backends/DAV/Vcard.php
new file mode 100644
--- /dev/null
+++ b/src/app/Backends/DAV/Vcard.php
@@ -0,0 +1,49 @@
+getElementsByTagName('address-data')->item(0)) {
+ $object->fromVcard($data->nodeValue);
+ }
+
+ return $object;
+ }
+
+ /**
+ * Set object properties from a vcard
+ *
+ * @param string $vcard vCard string
+ */
+ protected function fromVcard(string $vcard): void
+ {
+ // TODO
+ }
+
+ /**
+ * Create string representation of the DAV object (vcard)
+ *
+ * @return string
+ */
+ public function __toString()
+ {
+ // TODO: This will be needed when we want to create/update objects
+ return '';
+ }
+}
diff --git a/src/app/Backends/DAV/Vevent.php b/src/app/Backends/DAV/Vevent.php
new file mode 100644
--- /dev/null
+++ b/src/app/Backends/DAV/Vevent.php
@@ -0,0 +1,282 @@
+getElementsByTagName('calendar-data')->item(0)) {
+ $object->fromIcal($data->nodeValue);
+ }
+
+ return $object;
+ }
+
+ /**
+ * Set object properties from an iCalendar
+ *
+ * @param string $ical iCalendar string
+ */
+ protected function fromIcal(string $ical): void
+ {
+ $options = VObject\Reader::OPTION_FORGIVING | VObject\Reader::OPTION_IGNORE_INVALID_LINES;
+ $vobject = VObject\Reader::read($ical, $options);
+
+ if ($vobject->name != 'VCALENDAR') {
+ return;
+ }
+
+ $selfType = strtoupper(class_basename(get_class($this)));
+
+ foreach ($vobject->getComponents() as $component) {
+ if ($component->name == $selfType) {
+ $this->fromVObject($component);
+ return;
+ }
+ }
+ }
+
+ /**
+ * Set object properties from a Sabre/VObject component object
+ *
+ * @param VObject\Component $vobject Sabre/VObject component
+ */
+ protected function fromVObject(VObject\Component $vobject): void
+ {
+ $string_properties = [
+ 'COMMENT',
+ 'DESCRIPTION',
+ 'LOCATION',
+ 'SEQUENCE',
+ 'STATUS',
+ 'SUMMARY',
+ 'TRANSP',
+ 'UID',
+ 'URL',
+ ];
+
+ // map string properties
+ foreach ($string_properties as $prop) {
+ if (isset($vobject->{$prop})) {
+ $key = Str::camel(strtolower($prop));
+ $this->{$key} = (string) $vobject->{$prop};
+ }
+ }
+
+ // map other properties
+ foreach ($vobject->children() as $prop) {
+ if (!($prop instanceof VObject\Property)) {
+ continue;
+ }
+
+ switch ($prop->name) {
+ case 'DTSTART':
+ case 'DTEND':
+ case 'DUE':
+ case 'CREATED':
+ case 'LAST-MODIFIED':
+ case 'DTSTAMP':
+ $key = Str::camel(strtolower($prop->name));
+ // These are of type Sabre\VObject\Property\ICalendar\DateTime
+ $this->{$key} = $prop;
+ break;
+
+ case 'RRULE':
+ $params = !empty($this->recurrence) ? $this->recurrence : [];
+
+ foreach ($prop->getParts() as $k => $v) {
+ $params[Str::camel(strtolower($k))] = is_array($v) ? implode(',', $v) : $v;
+ }
+
+ if (!empty($params['until'])) {
+ $params['until'] = new \DateTime($params['until']);
+ }
+
+ if (empty($params['interval'])) {
+ $params['interval'] = 1;
+ }
+
+ $this->recurrence = array_filter($params);
+ break;
+
+ case 'EXDATE':
+ case 'RDATE':
+ $key = strtolower($prop->name);
+ $dates = []; // TODO
+
+ if (!empty($this->recurrence[$key])) {
+ $this->recurrence[$key] = array_merge($this->recurrence[$key], $dates);
+ }
+ else {
+ $this->recurrence[$key] = $dates;
+ }
+
+ break;
+
+ case 'ATTENDEE':
+ case 'ORGANIZER':
+ $attendee = [
+ 'rsvp' => false,
+ 'email' => preg_replace('!^mailto:!i', '', (string) $prop),
+ ];
+
+ $attendeeProps = ['CN', 'PARTSTAT', 'ROLE', 'CUTYPE', 'RSVP', 'DELEGATED-FROM', 'DELEGATED-TO',
+ 'SCHEDULE-STATUS', 'SCHEDULE-AGENT', 'SENT-BY'];
+
+ foreach ($prop->parameters() as $name => $value) {
+ $key = Str::camel(strtolower($name));
+ switch ($name) {
+ case 'RSVP':
+ $params[$key] = strtolower($value) == 'true';
+ break;
+ case 'CN':
+ $params[$key] = str_replace('\,', ',', strval($value));
+ break;
+ default:
+ if (in_array($name, $attendeeProps)) {
+ $params[$key] = strval($value);
+ }
+ break;
+ }
+ }
+
+ if ($prop->name == 'ORGANIZER') {
+ $attendee['role'] = 'ORGANIZER';
+ $attendee['partstat'] = 'ACCEPTED';
+
+ $this->organizer = $attendee;
+ }
+ else if (empty($this->organizer) || $attendee['email'] != $this->organizer['email']) {
+ $this->attendees[] = $attendee;
+ }
+
+ break;
+ }
+ }
+
+ // Check DURATION property if no end date is set
+ /*
+ if (empty($this->dtend) && !empty($this->dtstart) && !empty($vobject->DURATION)) {
+ try {
+ $duration = new \DateInterval((string) $vobject->DURATION);
+ $end = clone $this->dtstart;
+ $end->add($duration);
+ $this->dtend = $end;
+ }
+ catch (\Exception $e) {
+ // TODO: Error?
+ }
+ }
+ */
+
+ // Find alarms
+ foreach ($vobject->select('VALARM') as $valarm) {
+ $action = 'DISPLAY';
+ $trigger = null;
+ $alarm = [];
+
+ foreach ($valarm->children() as $prop) {
+ $value = strval($prop);
+
+ switch ($prop->name) {
+ case 'TRIGGER':
+ foreach ($prop->parameters as $param) {
+ if ($param->name == 'VALUE' && $param->getValue() == 'DATE-TIME') {
+ $trigger = '@' . $prop->getDateTime()->format('U');
+ $alarm['trigger'] = $prop->getDateTime();
+ }
+ else if ($param->name == 'RELATED') {
+ $alarm['related'] = $param->getValue();
+ }
+ }
+/*
+ if (!$trigger && ($values = libcalendaring::parse_alarm_value($value))) {
+ $trigger = $values[2];
+ }
+*/
+ if (empty($alarm['trigger'])) {
+ $alarm['trigger'] = rtrim(preg_replace('/([A-Z])0[WDHMS]/', '\\1', $value), 'T');
+ // if all 0-values have been stripped, assume 'at time'
+ if ($alarm['trigger'] == 'P') {
+ $alarm['trigger'] = 'PT0S';
+ }
+ }
+ break;
+
+ case 'ACTION':
+ $action = $alarm['action'] = strtoupper($value);
+ break;
+
+ case 'SUMMARY':
+ case 'DESCRIPTION':
+ case 'DURATION':
+ $alarm[strtolower($prop->name)] = $value;
+ break;
+
+ case 'REPEAT':
+ $alarm['repeat'] = (int) $value;
+ break;
+
+ case 'ATTENDEE':
+ $alarm['attendees'][] = preg_replace('!^mailto:!i', '', $value);
+ break;
+ }
+ }
+
+ if ($action != 'NONE') {
+ if (!empty($alarm['trigger'])) {
+ $this->valarms[] = $alarm;
+ }
+ }
+ }
+ }
+
+ /**
+ * Create string representation of the DAV object (iCalendar)
+ *
+ * @return string
+ */
+ public function __toString()
+ {
+ // TODO: This will be needed when we want to create/update objects
+ return '';
+ }
+}
diff --git a/src/app/Backends/DAV/Vtodo.php b/src/app/Backends/DAV/Vtodo.php
new file mode 100644
--- /dev/null
+++ b/src/app/Backends/DAV/Vtodo.php
@@ -0,0 +1,7 @@
+line($exception);
+ return false;
+ }
+ }
+
private function checkLDAP()
{
try {
@@ -157,7 +169,7 @@
{
$result = 0;
$steps = [
- 'DB', 'Redis', 'IMAP', 'Roundcube', 'Meet', 'Mollie', 'OpenExchangeRates',
+ 'DB', 'Redis', 'IMAP', 'Roundcube', 'Meet', 'DAV', 'Mollie', 'OpenExchangeRates',
];
if (\config('app.with_ldap')) {
diff --git a/src/composer.json b/src/composer.json
--- a/src/composer.json
+++ b/src/composer.json
@@ -31,6 +31,7 @@
"moontoast/math": "^1.2",
"pear/crypt_gpg": "^1.6.6",
"predis/predis": "^1.1.10",
+ "sabre/vobject": "^4.5",
"spatie/laravel-translatable": "^5.2",
"spomky-labs/otphp": "~10.0.0",
"stripe/stripe-php": "^7.29"
diff --git a/src/config/dav.php b/src/config/dav.php
new file mode 100644
--- /dev/null
+++ b/src/config/dav.php
@@ -0,0 +1,5 @@
+ env('DAV_URI', 'http://kolab/dav'),
+];
diff --git a/src/tests/Unit/Backends/DAV/FolderTest.php b/src/tests/Unit/Backends/DAV/FolderTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Unit/Backends/DAV/FolderTest.php
@@ -0,0 +1,53 @@
+
+
+
+ /dav/calendars/user/alec@aphy.io/Default/
+
+
+
+
+
+
+
+ 1665578572-16
+
+
+
+
+
+ #cccccc
+
+ HTTP/1.1 200 OK
+
+
+
+XML;
+
+ $doc = new \DOMDocument('1.0', 'UTF-8');
+ $doc->loadXML($xml);
+ $folder = Folder::fromDomElement($doc->getElementsByTagName('response')->item(0));
+
+ $this->assertInstanceOf(Folder::class, $folder);
+ $this->assertSame("/dav/calendars/user/alec@aphy.io/Default/", $folder->href);
+ $this->assertSame('1665578572-16', $folder->ctag);
+ $this->assertSame('personal', $folder->name);
+ $this->assertSame('cccccc', $folder->color);
+ $this->assertSame(['collection', 'calendar'], $folder->types);
+ $this->assertSame(['VEVENT', 'VTODO', 'VJOURNAL'], $folder->components);
+ }
+}
diff --git a/src/tests/Unit/Backends/DAV/VcardTest.php b/src/tests/Unit/Backends/DAV/VcardTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Unit/Backends/DAV/VcardTest.php
@@ -0,0 +1,50 @@
+
+
+
+ /dav/addressbooks/user/test@test.com/Default/$uid.vcf
+
+
+ "d27382e0b401384becb0d5b157d6b73a2c2084a2"
+
+
+ HTTP/1.1 200 OK
+
+
+
+XML;
+
+ $doc = new \DOMDocument('1.0', 'UTF-8');
+ $doc->loadXML($vcard);
+ $contact = Vcard::fromDomElement($doc->getElementsByTagName('response')->item(0));
+
+ $this->assertInstanceOf(Vcard::class, $contact);
+ $this->assertSame('d27382e0b401384becb0d5b157d6b73a2c2084a2', $contact->etag);
+ $this->assertSame("/dav/addressbooks/user/test@test.com/Default/{$uid}.vcf", $contact->href);
+ $this->assertSame('text/vcard; charset=utf-8', $contact->contentType);
+ $this->assertSame($uid, $contact->uid);
+
+ // TODO: Test all supported properties in detail
+ }
+}
diff --git a/src/tests/Unit/Backends/DAV/VeventTest.php b/src/tests/Unit/Backends/DAV/VeventTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Unit/Backends/DAV/VeventTest.php
@@ -0,0 +1,81 @@
+
+
+
+ /dav/calendars/user/test@test.com/Default/$uid.ics
+
+
+ "d27382e0b401384becb0d5b157d6b73a2c2084a2"
+
+
+ HTTP/1.1 200 OK
+
+
+
+XML;
+
+ $doc = new \DOMDocument('1.0', 'UTF-8');
+ $doc->loadXML($ical);
+ $event = Vevent::fromDomElement($doc->getElementsByTagName('response')->item(0));
+
+ $this->assertInstanceOf(Vevent::class, $event);
+ $this->assertSame('d27382e0b401384becb0d5b157d6b73a2c2084a2', $event->etag);
+ $this->assertSame("/dav/calendars/user/test@test.com/Default/{$uid}.ics", $event->href);
+ $this->assertSame('text/calendar; charset=utf-8', $event->contentType);
+ $this->assertSame($uid, $event->uid);
+ $this->assertSame('My summary', $event->summary);
+ $this->assertSame('desc', $event->description);
+ $this->assertSame('OPAQUE', $event->transp);
+
+ // TODO: Should we make these Sabre\VObject\Property\ICalendar\DateTime properties
+ $this->assertSame('20221016T103238Z', (string) $event->dtstamp);
+ $this->assertSame('20221013', (string) $event->dtstart);
+
+ $organizer = [
+ 'rsvp' => false,
+ 'email' => 'organizer@test.com',
+ 'role' => 'ORGANIZER',
+ 'partstat' => 'ACCEPTED',
+ ];
+ $this->assertSame($organizer, $event->organizer);
+
+ $recurrence = [
+ 'freq' => 'WEEKLY',
+ 'interval' => 1,
+ ];
+ $this->assertSame($recurrence, $event->recurrence);
+
+ // TODO: Test all supported properties in detail
+ }
+}