Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F2527914
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Size
45 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/app/Backends/DAV.php b/src/app/Backends/DAV.php
new file mode 100644
index 00000000..6bc36c98
--- /dev/null
+++ b/src/app/Backends/DAV.php
@@ -0,0 +1,508 @@
+<?php
+
+namespace App\Backends;
+
+use Illuminate\Support\Facades\Http;
+
+class DAV
+{
+ public const TYPE_VEVENT = 'VEVENT';
+ public const TYPE_VTODO = 'VTODO';
+ public const TYPE_VCARD = 'VCARD';
+
+ protected const NAMESPACES = [
+ self::TYPE_VEVENT => '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 = '<?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)
+ $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 = '<?xml version="1.0" encoding="utf-8"?>'
+ . '<d:propfind xmlns:d="DAV:" xmlns:c="' . self::NAMESPACES[$component] . '">'
+ . '<d:prop>'
+ . '<c:' . $homes[$component] . ' />'
+ . '</d:prop>'
+ . '</d:propfind>';
+
+ $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<DAV\Folder> 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 = '<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 />'
+ . '<cs:getctag />'
+ . $props
+ . '</d:prop>'
+ . '</d:propfind>';
+
+ // 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 = '<?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)
+ $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 = '<c:comp-filter name="VCALENDAR">'
+ . '<c:comp-filter name="' . $component . '" />'
+ . '</c:comp-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 = '<?xml version="1.0" encoding="utf-8"?>'
+ .' <c:' . $queries[$component] . ' xmlns:d="DAV:" xmlns:c="' . self::NAMESPACES[$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)) {
+ \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 .= '<d:href>' . $href . '</d: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 = '<?xml version="1.0" encoding="utf-8"?>'
+ .' <c:' . $queries[$component] . ' xmlns:d="DAV:" xmlns:c="' . self::NAMESPACES[$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)) {
+ \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, '<?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) {
+ if (is_array($header_value)) {
+ $header_value = implode("\n\t", $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);
+ }
+
+ /**
+ * 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
index 00000000..dfb96aee
--- /dev/null
+++ b/src/app/Backends/DAV/CommonObject.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace App\Backends\DAV;
+
+class CommonObject
+{
+ /** @var string Object content type (of the string representation) */
+ public $contentType = '';
+
+ /** @var ?string Object ETag (getetag property) */
+ public $etag;
+
+ /** @var ?string Object location (href property) */
+ public $href;
+
+ /** @var ?string Object UID */
+ public $uid;
+
+
+ /**
+ * Create DAV object from a DOMElement element
+ *
+ * @param \DOMElement $element DOM element with object properties
+ *
+ * @return CommonObject
+ */
+ public static function fromDomElement(\DOMElement $element)
+ {
+ $object = new static(); // @phpstan-ignore-line
+
+ if ($href = $element->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
index 00000000..0c4ca5d3
--- /dev/null
+++ b/src/app/Backends/DAV/Folder.php
@@ -0,0 +1,77 @@
+<?php
+
+namespace App\Backends\DAV;
+
+class Folder
+{
+ /** @var ?string Folder location (href property) */
+ public $href;
+
+ /** @var ?string Folder name (displayname property) */
+ public $name;
+
+ /** @var ?string Folder CTag (getctag property) */
+ public $ctag;
+
+ /** @var array Supported component set (supported-*-component-set property) */
+ public $components = [];
+
+ /** @var array Supported resource types (resourcetype property) */
+ public $types = [];
+
+ /** @var ?string Folder color (calendar-color property) */
+ public $color;
+
+
+ /**
+ * Create Folder object from a DOMElement element
+ *
+ * @param \DOMElement $element DOM element with folder properties
+ *
+ * @return Folder
+ */
+ public static function fromDomElement(\DOMElement $element)
+ {
+ $folder = new Folder();
+
+ if ($href = $element->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
index 00000000..ab3f2e1d
--- /dev/null
+++ b/src/app/Backends/DAV/Vcard.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace App\Backends\DAV;
+
+class Vcard extends CommonObject
+{
+ /** @var string Object content type (of the string representation) */
+ public $contentType = 'text/vcard; charset=utf-8';
+
+ /**
+ * Create event object from a DOMElement element
+ *
+ * @param \DOMElement $element DOM element with object properties
+ *
+ * @return CommonObject
+ */
+ public static function fromDomElement(\DOMElement $element)
+ {
+ /** @var self $object */
+ $object = parent::fromDomElement($element);
+
+ if ($data = $element->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
index 00000000..c02beec7
--- /dev/null
+++ b/src/app/Backends/DAV/Vevent.php
@@ -0,0 +1,282 @@
+<?php
+
+namespace App\Backends\DAV;
+
+use Illuminate\Support\Str;
+use Sabre\VObject;
+
+class Vevent extends CommonObject
+{
+ /** @var string Object content type (of the string representation) */
+ public $contentType = 'text/calendar; charset=utf-8';
+
+ public $attendees = [];
+ public $comment;
+ public $description;
+ public $location;
+ public $organizer;
+ public $recurrence = [];
+ public $sequence;
+ public $status;
+ public $summary;
+ public $transp;
+ public $url;
+ public $valarms = [];
+
+ public $dtstart;
+ public $dtend;
+ public $due;
+ public $created;
+ public $lastModified;
+ public $dtstamp;
+
+
+ /**
+ * Create event object from a DOMElement element
+ *
+ * @param \DOMElement $element DOM element with object properties
+ *
+ * @return CommonObject
+ */
+ public static function fromDomElement(\DOMElement $element)
+ {
+ /** @var self $object */
+ $object = parent::fromDomElement($element);
+
+ if ($data = $element->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
index 00000000..4584e0ce
--- /dev/null
+++ b/src/app/Backends/DAV/Vtodo.php
@@ -0,0 +1,7 @@
+<?php
+
+namespace App\Backends\DAV;
+
+class Vtodo extends Vevent
+{
+}
diff --git a/src/app/Console/Commands/Status/Health.php b/src/app/Console/Commands/Status/Health.php
index 7a63f63a..c90453d3 100644
--- a/src/app/Console/Commands/Status/Health.php
+++ b/src/app/Console/Commands/Status/Health.php
@@ -1,182 +1,194 @@
<?php
namespace App\Console\Commands\Status;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
-use App\Backends\LDAP;
+use App\Backends\DAV;
use App\Backends\IMAP;
-use App\Backends\Roundcube;
+use App\Backends\LDAP;
use App\Backends\OpenExchangeRates;
+use App\Backends\Roundcube;
use App\Providers\Payment\Mollie;
//TODO stripe
//TODO firebase
class Health extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'status:health';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Check health of backends';
private function checkDB()
{
try {
$result = DB::select("SELECT 1");
return true;
} catch (\Exception $exception) {
$this->line($exception);
return false;
}
}
private function checkOpenExchangeRates()
{
try {
OpenExchangeRates::healthcheck();
return true;
} catch (\Exception $exception) {
$this->line($exception);
return false;
}
}
private function checkMollie()
{
try {
return Mollie::healthcheck();
} catch (\Exception $exception) {
$this->line($exception);
return false;
}
}
+ private function checkDAV()
+ {
+ try {
+ DAV::healthcheck();
+ return true;
+ } catch (\Exception $exception) {
+ $this->line($exception);
+ return false;
+ }
+ }
+
private function checkLDAP()
{
try {
LDAP::healthcheck();
return true;
} catch (\Exception $exception) {
$this->line($exception);
return false;
}
}
private function checkIMAP()
{
try {
IMAP::healthcheck();
return true;
} catch (\Exception $exception) {
$this->line($exception);
return false;
}
}
private function checkRoundcube()
{
try {
//TODO maybe run a select?
Roundcube::dbh();
return true;
} catch (\Exception $exception) {
$this->line($exception);
return false;
}
}
private function checkRedis()
{
try {
Redis::connection();
return true;
} catch (\Exception $exception) {
$this->line($exception);
return false;
}
}
private function checkMeet()
{
try {
$urls = \config('meet.api_urls');
foreach ($urls as $url) {
$this->line("Checking $url");
$client = new \GuzzleHttp\Client(
[
'http_errors' => false, // No exceptions from Guzzle
'base_uri' => $url,
'verify' => \config('meet.api_verify_tls'),
'headers' => [
'X-Auth-Token' => \config('meet.api_token'),
],
'connect_timeout' => 10,
'timeout' => 10,
'on_stats' => function (\GuzzleHttp\TransferStats $stats) {
$threshold = \config('logging.slow_log');
if ($threshold && ($sec = $stats->getTransferTime()) > $threshold) {
$url = $stats->getEffectiveUri();
$method = $stats->getRequest()->getMethod();
\Log::warning(sprintf("[STATS] %s %s: %.4f sec.", $method, $url, $sec));
}
},
]
);
$response = $client->request('GET', "ping");
if ($response->getStatusCode() != 200) {
$this->line("Backend not available: " . var_export($response, true));
return false;
}
}
return true;
} catch (\Exception $exception) {
$this->line($exception);
return false;
}
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$result = 0;
$steps = [
- 'DB', 'Redis', 'IMAP', 'Roundcube', 'Meet', 'Mollie', 'OpenExchangeRates',
+ 'DB', 'Redis', 'IMAP', 'Roundcube', 'Meet', 'DAV', 'Mollie', 'OpenExchangeRates',
];
if (\config('app.with_ldap')) {
array_unshift($steps, 'LDAP');
}
foreach ($steps as $step) {
$func = "check{$step}";
$this->line("Checking {$step}...");
if ($this->{$func}()) {
$this->info("OK");
} else {
$this->error("Not found");
$result = 1;
}
}
return $result;
}
}
diff --git a/src/composer.json b/src/composer.json
index e3c31fc4..37a75951 100644
--- a/src/composer.json
+++ b/src/composer.json
@@ -1,87 +1,88 @@
{
"name": "kolab/kolab4",
"type": "project",
"description": "Kolab 4",
"keywords": [
"framework",
"laravel"
],
"license": "MIT",
"repositories": [
{
"type": "vcs",
"url": "https://git.kolab.org/diffusion/PNL/php-net_ldap3.git"
}
],
"require": {
"php": "^8.0",
"bacon/bacon-qr-code": "^2.0",
"barryvdh/laravel-dompdf": "^2.0.0",
"doctrine/dbal": "^3.3.2",
"dyrynda/laravel-nullable-fields": "^4.2.0",
"guzzlehttp/guzzle": "^7.4.1",
"kolab/net_ldap3": "dev-master",
"laravel/framework": "^9.2",
"laravel/horizon": "^5.9",
"laravel/octane": "^1.2",
"laravel/passport": "^11.3",
"laravel/tinker": "^2.7",
"mlocati/spf-lib": "^3.1",
"mollie/laravel-mollie": "^2.19",
"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"
},
"require-dev": {
"code-lts/doctum": "^5.5.1",
"laravel/dusk": "~6.22.0",
"nunomaduro/larastan": "^2.0",
"phpstan/phpstan": "^1.4",
"phpunit/phpunit": "^9",
"squizlabs/php_codesniffer": "^3.6"
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"autoload": {
"psr-4": {
"App\\": "app/"
},
"classmap": [
"database/seeds",
"include"
]
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"minimum-stability": "stable",
"prefer-stable": true,
"scripts": {
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi"
]
}
}
diff --git a/src/config/dav.php b/src/config/dav.php
new file mode 100644
index 00000000..23792ee0
--- /dev/null
+++ b/src/config/dav.php
@@ -0,0 +1,5 @@
+<?php
+
+return [
+ 'uri' => 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
index 00000000..5d3291f9
--- /dev/null
+++ b/src/tests/Unit/Backends/DAV/FolderTest.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace Tests\Unit\Backends\DAV;
+
+use App\Backends\DAV\Folder;
+use Tests\TestCase;
+
+class FolderTest extends TestCase
+{
+ /**
+ * Test Folder::fromDomElement()
+ */
+ public function testFromDomElement(): void
+ {
+ $xml = <<<XML
+<?xml version="1.0" encoding="utf-8"?>
+<d:multistatus 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:">
+ <d:response>
+ <d:href>/dav/calendars/user/alec@aphy.io/Default/</d:href>
+ <d:propstat>
+ <d:prop>
+ <d:resourcetype>
+ <d:collection/>
+ <c:calendar/>
+ </d:resourcetype>
+ <d:displayname><![CDATA[personal]]></d:displayname>
+ <cs:getctag>1665578572-16</cs:getctag>
+ <c:supported-calendar-component-set>
+ <c:comp name="VEVENT"/>
+ <c:comp name="VTODO"/>
+ <c:comp name="VJOURNAL"/>
+ </c:supported-calendar-component-set>
+ <a:calendar-color>#cccccc</a:calendar-color>
+ </d:prop>
+ <d:status>HTTP/1.1 200 OK</d:status>
+ </d:propstat>
+ </d:response>
+</d:multistatus>
+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
index 00000000..298eef5a
--- /dev/null
+++ b/src/tests/Unit/Backends/DAV/VcardTest.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace Tests\Unit\Backends\DAV;
+
+use App\Backends\DAV\Vcard;
+use Tests\TestCase;
+
+class VcardTest extends TestCase
+{
+ /**
+ * Test Vcard::fromDomElement()
+ */
+ public function testFromDomElement(): void
+ {
+ $uid = 'A8CCF090C66A7D4D805A8B897AE75AFD-8FE68B2E68E1B348';
+ $vcard = <<<XML
+<?xml version="1.0" encoding="utf-8"?>
+<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav">
+ <d:response>
+ <d:href>/dav/addressbooks/user/test@test.com/Default/$uid.vcf</d:href>
+ <d:propstat>
+ <d:prop>
+ <d:getetag>"d27382e0b401384becb0d5b157d6b73a2c2084a2"</d:getetag>
+ <c:address-data><![CDATA[BEGIN:VCARD
+VERSION:3.0
+FN:Test Test
+N:;;;;
+UID:$uid
+END:VCARD
+]]></c:calendar-data>
+ </d:prop>
+ <d:status>HTTP/1.1 200 OK</d:status>
+ </d:propstat>
+ </d:response>
+</d:multistatus>
+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
index 00000000..1548945b
--- /dev/null
+++ b/src/tests/Unit/Backends/DAV/VeventTest.php
@@ -0,0 +1,81 @@
+<?php
+
+namespace Tests\Unit\Backends\DAV;
+
+use App\Backends\DAV\Vevent;
+use Tests\TestCase;
+
+class VeventTest extends TestCase
+{
+ /**
+ * Test Vevent::fromDomElement()
+ */
+ public function testFromDomElement(): void
+ {
+ $uid = 'A8CCF090C66A7D4D805A8B897AE75AFD-8FE68B2E68E1B348';
+ $ical = <<<XML
+<?xml version="1.0" encoding="utf-8"?>
+<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
+ <d:response>
+ <d:href>/dav/calendars/user/test@test.com/Default/$uid.ics</d:href>
+ <d:propstat>
+ <d:prop>
+ <d:getetag>"d27382e0b401384becb0d5b157d6b73a2c2084a2"</d:getetag>
+ <c:calendar-data><![CDATA[BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Test//EN
+CALSCALE:GREGORIAN
+BEGIN:VEVENT
+UID:$uid
+DTSTAMP:20221016T103238Z
+DTSTART;VALUE=DATE:20221013
+DTEND;VALUE=DATE:20221014
+SUMMARY:My summary
+DESCRIPTION:desc
+RRULE:FREQ=WEEKLY
+TRANSP:OPAQUE
+ORGANIZER:mailto:organizer@test.com
+END:VEVENT
+END:VCALENDAR
+]]></c:calendar-data>
+ </d:prop>
+ <d:status>HTTP/1.1 200 OK</d:status>
+ </d:propstat>
+ </d:response>
+</d:multistatus>
+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
+ }
+}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sat, Jan 31, 6:33 PM (1 d, 8 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
426428
Default Alt Text
(45 KB)
Attached To
Mode
R2 kolab
Attached
Detach File
Event Timeline
Log In to Comment