diff --git a/src/app/Backends/DAV.php b/src/app/Backends/DAV.php --- a/src/app/Backends/DAV.php +++ b/src/app/Backends/DAV.php @@ -249,6 +249,34 @@ 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. * @@ -258,15 +286,10 @@ */ 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); @@ -275,6 +298,61 @@ 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; + } + + /** + * Initialize DAV folders (collections) + * + * @param \App\User $user User object + * @param array $folders Folders list (path, displayname, type, components) + * + * @return bool True on success, False on error + */ + public static function initDefaultFolders(\App\User $user, array $folders) + { + // FIXME: It looks like we'll need a way to authenticate the user, or cyrus admin + $dav = new self($email, $password); + + foreach ($folders as $props) { + $folder = new DAV\Folder(); + $folder->href = "addressbooks/user/{$user->email}/{$props['path']}"; + $folder->name = $props['displayname'] ?? ''; + $folder->types = ['collection', $props['type']]; + $folder->components = $props['components'] ?? []; + + // folder already exists? check the properties and update if needed + if ($existing = $dav->folderInfo($folder->href)) { + if ($existing->name != $folder->name || $existing->components != $folder->components) { + if (!$dav->folderUpdate($folder)) { + \Log::error("Failed to update DAV folder {$folder->href}"); + return false; + } + } + } + + if (!$dav->folderCreate($folder)) { + \Log::error("Failed to create DAV folder {$folder->href}"); + return false; + } + } + + return true; + } + /** * Search DAV objects in a folder. * diff --git a/src/app/Backends/DAV/Folder.php b/src/app/Backends/DAV/Folder.php --- a/src/app/Backends/DAV/Folder.php +++ b/src/app/Backends/DAV/Folder.php @@ -74,4 +74,82 @@ 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 '' + . '' + . '' + // . '' + . '' + // . '' + // . '' + // . '' + . '' + . '' + // . '' + . '' + . ''; + } } diff --git a/src/app/Jobs/User/CreateJob.php b/src/app/Jobs/User/CreateJob.php --- a/src/app/Jobs/User/CreateJob.php +++ b/src/app/Jobs/User/CreateJob.php @@ -104,6 +104,13 @@ $user->status |= \App\User::STATUS_IMAP_READY; } + $folders = \config('services.dav.default_folders'); + if (count($folders)) { + if (!\App\Backends\DAV::initDefaultFolders($user, $folders)) { + throw new \Exception("Failed to initialize DAV folders for user {$this->userId}."); + } + } + // Make user active in non-mandate mode only if ( !($wallet = $user->wallet()) diff --git a/src/config/services.php b/src/config/services.php --- a/src/config/services.php +++ b/src/config/services.php @@ -1,5 +1,32 @@ 'Default', + 'displayname' => 'Calendar', + 'components' => ['VEVENT'], + 'type' => 'calendar', + ], + [ + 'path' => 'Tasks', + 'displayname' => 'Tasks', + 'components' => ['VTODO'], + 'type' => 'calendar', + ], + [ + // FIXME: Same here, should we use 'Contacts'? + 'path' => 'Default', + 'displayname' => 'Contacts', + 'type' => 'addressbook', + ], + ]; +} + return [ /* @@ -58,6 +85,7 @@ 'dav' => [ 'uri' => env('DAV_URI', 'https://proxy/'), + 'default_folders' => $dav_folders, ], 'activesync' => [