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 @@ -2,6 +2,7 @@ namespace App\Backends; +use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Http; class DAV @@ -343,6 +344,65 @@ return $response !== false; } + /** + * Initialize default DAV folders (collections) + * + * @param \App\User $user User object + * + * @throws \Exception + */ + public static function initDefaultFolders(\App\User $user): void + { + if (!\config('services.dav.uri')) { + return; + } + + $folders = \config('services.dav.default_folders'); + if (!count($folders)) { + return; + } + + // Cyrus DAV does not support proxy authorization via DAV. Even though it has + // the Authorize-As header, it is used only for cummunication with Murder backends. + // We use a one-time token instead. It's valid for 10 seconds, assume it's enough time. + $password = \App\Auth\Utils::tokenCreate((string) $user->id); + + if ($password === null) { + throw new \Exception("Failed to create an authentication token for DAV"); + } + + $dav = new self($user->email, $password); + + foreach ($folders as $props) { + $folder = new DAV\Folder(); + $folder->href = $props['type'] . 's' . '/user/' . $user->email . '/' . $props['path']; + $folder->types = ['collection', $props['type']]; + $folder->name = $props['displayname'] ?? ''; + $folder->components = $props['components'] ?? []; + + $existing = null; + try { + $existing = $dav->folderInfo($folder->href); + } catch (RequestException $e) { + // Cyrus DAV returns 503 Service Unavailable on a non-existing location (?) + if ($e->getCode() != 503 && $e->getCode() != 404) { + throw $e; + } + } + + // folder already exists? check the properties and update if needed + if ($existing) { + if ($existing->name != $folder->name || $existing->components != $folder->components) { + if (!$dav->folderUpdate($folder)) { + throw new \Exception("Failed to update DAV folder {$folder->href}"); + } + } + } elseif (!$dav->folderCreate($folder)) { + throw new \Exception("Failed to create DAV folder {$folder->href}"); + } + } + } + /** * Check server options (and authentication) * 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 @@ -94,10 +94,15 @@ $type = 'calendar'; } - $props .= '' . ($type ? "" : '') . ''; + // Cyrus DAV does not allow resourcetype property change + if ($tag != 'propertyupdate') { + $props .= '' . ($type ? "" : '') . ''; + } if (!empty($this->components)) { - $props .= ''; + // Note: Normally Cyrus DAV does not allow supported-calendar-component-set property update, + // but I found in Cyrus code that the action can be forced with force=yes attribute. + $props .= ''; foreach ($this->components as $component) { $props .= ''; } diff --git a/src/app/Backends/Helper.php b/src/app/Backends/Helper.php new file mode 100644 --- /dev/null +++ b/src/app/Backends/Helper.php @@ -0,0 +1,55 @@ + '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', + ], + ]; + } + + /** + * List of default IMAP folders + */ + public static function defaultImapFolders(): array + { + // TODO: Move the list from config/imap.php + 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 @@ -106,6 +106,9 @@ $user->status |= \App\User::STATUS_IMAP_READY; } + // FIXME: Should we ignore exceptions on this operation or introduce DAV_READY status? + \App\Backends\DAV::initDefaultFolders($user); + // 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 @@ -58,6 +58,7 @@ 'dav' => [ 'uri' => env('DAV_URI', 'https://proxy/'), + 'default_folders' => \App\Backends\Helper::defaultDavFolders(), 'verify' => (bool) env('DAV_VERIFY', true), ], diff --git a/src/phpunit.xml b/src/phpunit.xml --- a/src/phpunit.xml +++ b/src/phpunit.xml @@ -43,5 +43,6 @@ + diff --git a/src/tests/Feature/Backends/DAVTest.php b/src/tests/Feature/Backends/DAVTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Backends/DAVTest.php @@ -0,0 +1,100 @@ +markTestSkipped(); + } + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + if ($this->user) { + $this->deleteTestUser($this->user->email); + } + + parent::tearDown(); + } + + /** + * Test initializing default folders for a user. + * + * @group imap + * @group dav + */ + public function testInitDefaultFolders(): void + { + Queue::fake(); + + $props = ['password' => 'test-pass']; + $this->user = $user = $this->getTestUser('davtest-' . time() . '@' . \config('app.domain'), $props); + + // Create the IMAP mailbox, it is required otherwise DAV requests will fail + \config(['imap.default_folders' => null]); + IMAP::createUser($user); + + $dav_folders = [ + [ + 'path' => 'Default', + 'displayname' => 'Calendar-Test', + 'components' => ['VEVENT'], + 'type' => 'calendar', + ], + [ + 'path' => 'Tasks', + 'displayname' => 'Tasks-Test', + 'components' => ['VTODO'], + 'type' => 'calendar', + ], + [ + 'path' => 'Default', + 'displayname' => 'Contacts-Test', + 'type' => 'addressbook', + ], + ]; + + \config(['services.dav.default_folders' => $dav_folders]); + DAV::initDefaultFolders($user); + + $dav = new DAV($user->email, $props['password']); + + $folders = $dav->listFolders(DAV::TYPE_VCARD); + $this->assertCount(1, $folders); + $this->assertSame('Contacts-Test', $folders[0]->name); + + $folders = $dav->listFolders(DAV::TYPE_VEVENT); + $folders = array_filter($folders, function ($f) { return $f->name != 'Inbox' && $f->name != 'Outbox'; }); + $folders = array_values($folders); + $this->assertCount(1, $folders); + $this->assertSame(['VEVENT'], $folders[0]->components); + $this->assertSame(['collection', 'calendar'], $folders[0]->types); + $this->assertSame('Calendar-Test', $folders[0]->name); + + $folders = $dav->listFolders(DAV::TYPE_VTODO); + $folders = array_filter($folders, function ($f) { return $f->name != 'Inbox' && $f->name != 'Outbox'; }); + $folders = array_values($folders); + $this->assertCount(1, $folders); + $this->assertSame(['VTODO'], $folders[0]->components); + $this->assertSame(['collection', 'calendar'], $folders[0]->types); + $this->assertSame('Tasks-Test', $folders[0]->name); + } +}