From 663a496a9295f2417b59e8c1fbeb5b4aeefdbde3 Mon Sep 17 00:00:00 2001 From: SebastianKrupinski Date: Mon, 19 Oct 2026 15:16:19 -0400 Subject: [PATCH 1/2] fix(dav): store webcal subscription credentials encrypted Signed-off-by: SebastianKrupinski --- apps/dav/appinfo/info.xml | 2 +- .../composer/composer/autoload_classmap.php | 1 + .../dav/composer/composer/autoload_static.php | 1 + apps/dav/lib/CalDAV/CalDavBackend.php | 69 +++++++++++- .../lib/CalDAV/WebcalCaching/Connection.php | 6 +- .../Version3000Date20261019000000.php | 76 +++++++++++++ ...lDavBackendSubscriptionCredentialsTest.php | 61 ++++++++++ .../tests/unit/CalDAV/CalDavBackendTest.php | 56 ++++++++++ .../CalDAV/WebcalCaching/ConnectionTest.php | 42 +++++++ .../Version3000Date20261019000000Test.php | 104 ++++++++++++++++++ 10 files changed, 411 insertions(+), 7 deletions(-) create mode 100644 apps/dav/lib/Migration/Version3000Date20261019000000.php create mode 100644 apps/dav/tests/unit/CalDAV/CalDavBackendSubscriptionCredentialsTest.php create mode 100644 apps/dav/tests/unit/Migration/Version3000Date20261019000000Test.php diff --git a/apps/dav/appinfo/info.xml b/apps/dav/appinfo/info.xml index 65dcd1b29b30b..a82e389b50792 100644 --- a/apps/dav/appinfo/info.xml +++ b/apps/dav/appinfo/info.xml @@ -10,7 +10,7 @@ WebDAV WebDAV endpoint WebDAV endpoint - 3.0.0-dev.1 + 3.0.0-dev.2 agpl owncloud.org DAV diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index b3b8576f1aec0..bbf9614617022 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -419,6 +419,7 @@ 'OCA\\DAV\\Migration\\Version1039Date20260408000000' => $baseDir . '/../lib/Migration/Version1039Date20260408000000.php', 'OCA\\DAV\\Migration\\Version1040Date20260805000000' => $baseDir . '/../lib/Migration/Version1040Date20260805000000.php', 'OCA\\DAV\\Migration\\Version3000Date20260828000000' => $baseDir . '/../lib/Migration/Version3000Date20260828000000.php', + 'OCA\\DAV\\Migration\\Version3000Date20261019000000' => $baseDir . '/../lib/Migration/Version3000Date20261019000000.php', 'OCA\\DAV\\Model\\ExampleEvent' => $baseDir . '/../lib/Model/ExampleEvent.php', 'OCA\\DAV\\Paginate\\LimitedCopyIterator' => $baseDir . '/../lib/Paginate/LimitedCopyIterator.php', 'OCA\\DAV\\Paginate\\PaginateCache' => $baseDir . '/../lib/Paginate/PaginateCache.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index c52bf9f3acfed..e1f4b38d5eda5 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -434,6 +434,7 @@ class ComposerStaticInitDAV 'OCA\\DAV\\Migration\\Version1039Date20260408000000' => __DIR__ . '/..' . '/../lib/Migration/Version1039Date20260408000000.php', 'OCA\\DAV\\Migration\\Version1040Date20260805000000' => __DIR__ . '/..' . '/../lib/Migration/Version1040Date20260805000000.php', 'OCA\\DAV\\Migration\\Version3000Date20260828000000' => __DIR__ . '/..' . '/../lib/Migration/Version3000Date20260828000000.php', + 'OCA\\DAV\\Migration\\Version3000Date20261019000000' => __DIR__ . '/..' . '/../lib/Migration/Version3000Date20261019000000.php', 'OCA\\DAV\\Model\\ExampleEvent' => __DIR__ . '/..' . '/../lib/Model/ExampleEvent.php', 'OCA\\DAV\\Paginate\\LimitedCopyIterator' => __DIR__ . '/..' . '/../lib/Paginate/LimitedCopyIterator.php', 'OCA\\DAV\\Paginate\\PaginateCache' => __DIR__ . '/..' . '/../lib/Paginate/PaginateCache.php', diff --git a/apps/dav/lib/CalDAV/CalDavBackend.php b/apps/dav/lib/CalDAV/CalDavBackend.php index b4617565677c5..586f586b913d6 100644 --- a/apps/dav/lib/CalDAV/CalDavBackend.php +++ b/apps/dav/lib/CalDAV/CalDavBackend.php @@ -50,7 +50,9 @@ use OCP\IConfig; use OCP\IDBConnection; use OCP\IUserManager; +use OCP\Security\ICrypto; use OCP\Security\ISecureRandom; +use OCP\Server; use Psr\Log\LoggerInterface; use RuntimeException; use Sabre\CalDAV\Backend\AbstractBackend; @@ -2976,6 +2978,7 @@ public function getSubscriptionsForUser($principalUri) { $fields[] = 'principaluri'; $fields[] = 'lastmodified'; $fields[] = 'synctoken'; + $fields[] = 'credentials'; $query = $this->db->getQueryBuilder(); $query->select($fields) @@ -2991,6 +2994,7 @@ public function getSubscriptionsForUser($principalUri) { 'uri' => $row['uri'], 'principaluri' => $row['principaluri'], 'source' => $row['source'], + 'credentials' => $this->decryptSubscriptionCredentials($row['id'], $row['credentials']), 'lastmodified' => $row['lastmodified'], '{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet(['VTODO', 'VEVENT']), @@ -3020,10 +3024,12 @@ public function createSubscription($principalUri, $uri, array $properties) { throw new Forbidden('The {http://calendarserver.org/ns/}source property is required when creating subscriptions'); } + [$source, $credentials] = self::extractSubscriptionCredentials($properties['{http://calendarserver.org/ns/}source']->getHref()); $values = [ 'principaluri' => $principalUri, 'uri' => $uri, - 'source' => $properties['{http://calendarserver.org/ns/}source']->getHref(), + 'source' => $source, + 'credentials' => $credentials, 'lastmodified' => time(), ]; @@ -3085,7 +3091,7 @@ public function updateSubscription($subscriptionId, PropPatch $propPatch) { foreach ($mutations as $propertyName => $propertyValue) { if ($propertyName === '{http://calendarserver.org/ns/}source') { - $newValues['source'] = $propertyValue->getHref(); + [$newValues['source'], $newValues['credentials']] = self::extractSubscriptionCredentials($propertyValue->getHref()); } else { $fieldName = $this->subscriptionPropertyMap[$propertyName][0]; $newValues[$fieldName] = $propertyValue; @@ -3093,6 +3099,14 @@ public function updateSubscription($subscriptionId, PropPatch $propPatch) { } $subscriptionRow = $this->atomic(function () use ($subscriptionId, $newValues) { + // clients only see the source without credentials, so sending it back unchanged keeps them + if (array_key_exists('credentials', $newValues) && $newValues['credentials'] === null) { + $currentSource = $this->getSubscriptionById($subscriptionId)['source'] ?? null; + if ($currentSource === $newValues['source']) { + unset($newValues['credentials']); + } + } + $query = $this->db->getQueryBuilder(); $query->update('calendarsubscriptions') ->set('lastmodified', $query->createNamedParameter(time())); @@ -3111,6 +3125,57 @@ public function updateSubscription($subscriptionId, PropPatch $propPatch) { }); } + private static function encryptSubscriptionCredentials(string $username, string $password): string { + return Server::get(ICrypto::class)->encrypt(json_encode([ + 'username' => $username, + 'password' => $password, + ], JSON_THROW_ON_ERROR)); + } + + /** + * @return array{0: string, 1: string}|null username and password + */ + private function decryptSubscriptionCredentials(int|string $subscriptionId, mixed $credentials): ?array { + if (!is_string($credentials) || $credentials === '') { + return null; + } + + try { + $decoded = json_decode(Server::get(ICrypto::class)->decrypt($credentials), true, 2, JSON_THROW_ON_ERROR); + } catch (\Exception $e) { + $this->logger->error('Could not decrypt the credentials of subscription ' . $subscriptionId, [ + 'exception' => $e, + ]); + return null; + } + + return [(string)$decoded['username'], (string)$decoded['password']]; + } + + /** + * Splits the user info off a subscription source url + * + * The user info is percent-decoded and encrypted. + * + * @return array{0: string, 1: ?string} the source without user info and the encrypted credentials + */ + public static function extractSubscriptionCredentials(string $source): array { + // the greedy match stops at the last '@' of the authority + if (preg_match('/^([a-z][a-z0-9+.\-]*:\/\/)([^\/?#]*)@/i', $source, $matches) !== 1) { + return [$source, null]; + } + + $source = $matches[1] . substr($source, strlen($matches[0])); + [$username, $password] = array_pad(explode(':', $matches[2], 2), 2, ''); + $username = rawurldecode($username); + $password = rawurldecode($password); + if ($username === '' && $password === '') { + return [$source, null]; + } + + return [$source, self::encryptSubscriptionCredentials($username, $password)]; + } + /** * Deletes a subscription. * diff --git a/apps/dav/lib/CalDAV/WebcalCaching/Connection.php b/apps/dav/lib/CalDAV/WebcalCaching/Connection.php index 1728b9c211483..4788f8070413f 100644 --- a/apps/dav/lib/CalDAV/WebcalCaching/Connection.php +++ b/apps/dav/lib/CalDAV/WebcalCaching/Connection.php @@ -59,10 +59,8 @@ public function queryWebcalFeed(array $subscription): ?array { 'stream' => true, ]; - $user = parse_url($subscription['source'], PHP_URL_USER); - $pass = parse_url($subscription['source'], PHP_URL_PASS); - if ($user !== null && $pass !== null) { - $params[RequestOptions::AUTH] = [$user, $pass]; + if (isset($subscription['credentials'])) { + $params[RequestOptions::AUTH] = $subscription['credentials']; } try { diff --git a/apps/dav/lib/Migration/Version3000Date20261019000000.php b/apps/dav/lib/Migration/Version3000Date20261019000000.php new file mode 100644 index 0000000000000..40bcb3e97e989 --- /dev/null +++ b/apps/dav/lib/Migration/Version3000Date20261019000000.php @@ -0,0 +1,76 @@ +getTable('calendarsubscriptions'); + if ($table->hasColumn('credentials')) { + return null; + } + + $table->addColumn('credentials', Types::TEXT, [ + 'notnull' => false, + ]); + + return $schema; + } + + #[Override] + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + $select = $this->connection->getQueryBuilder(); + $select->select('id', 'source') + ->from('calendarsubscriptions') + ->where($select->expr()->like('source', $select->createNamedParameter('%@%'))) + ->andWhere($select->expr()->isNull('credentials')); + $result = $select->executeQuery(); + + $update = $this->connection->getQueryBuilder(); + $update->update('calendarsubscriptions') + ->set('source', $update->createParameter('source')) + ->set('credentials', $update->createParameter('credentials')) + ->where($update->expr()->eq('id', $update->createParameter('id'))); + + while ($row = $result->fetchAssociative()) { + [$source, $credentials] = CalDavBackend::extractSubscriptionCredentials((string)$row['source']); + if ($credentials === null) { + continue; + } + + $update->setParameter('source', $source) + ->setParameter('credentials', $credentials) + ->setParameter('id', (int)$row['id'], IQueryBuilder::PARAM_INT) + ->executeStatement(); + } + $result->closeCursor(); + } +} diff --git a/apps/dav/tests/unit/CalDAV/CalDavBackendSubscriptionCredentialsTest.php b/apps/dav/tests/unit/CalDAV/CalDavBackendSubscriptionCredentialsTest.php new file mode 100644 index 0000000000000..2dcd2b7adba1a --- /dev/null +++ b/apps/dav/tests/unit/CalDAV/CalDavBackendSubscriptionCredentialsTest.php @@ -0,0 +1,61 @@ + ['https://user:pass@example.com/cal.ics', 'https://example.com/cal.ics', 'user', 'pass'], + 'webcal' => ['webcal://user:pass@example.com/cal.ics', 'webcal://example.com/cal.ics', 'user', 'pass'], + 'port, query and fragment' => ['https://user:pass@example.com:8443/cal.ics?a=b@c#frag', 'https://example.com:8443/cal.ics?a=b@c#frag', 'user', 'pass'], + 'percent-encoded' => ['https://us%40er:p%23ss%3Aw%2Frd%25@example.com/cal.ics', 'https://example.com/cal.ics', 'us@er', 'p#ss:w/rd%'], + 'plus is literal' => ['https://user:pa+ss@example.com/cal.ics', 'https://example.com/cal.ics', 'user', 'pa+ss'], + 'utf-8' => ['https://user:p%C3%A4ss@example.com/cal.ics', 'https://example.com/cal.ics', 'user', 'päss'], + 'unencoded colon in password' => ['https://user:pa:ss@example.com/cal.ics', 'https://example.com/cal.ics', 'user', 'pa:ss'], + 'unencoded at sign in username' => ['https://user@domain.tld:pass@example.com/cal.ics', 'https://example.com/cal.ics', 'user@domain.tld', 'pass'], + 'username only' => ['https://user@example.com/cal.ics', 'https://example.com/cal.ics', 'user', ''], + 'password only' => ['https://:pass@example.com/cal.ics', 'https://example.com/cal.ics', '', 'pass'], + ]; + } + + #[DataProvider('sourceWithCredentialsProvider')] + public function testExtractWithCredentials(string $source, string $expectedSource, string $expectedUsername, string $expectedPassword): void { + [$strippedSource, $credentials] = CalDavBackend::extractSubscriptionCredentials($source); + + $this->assertSame($expectedSource, $strippedSource); + $this->assertIsString($credentials); + $this->assertSame( + ['username' => $expectedUsername, 'password' => $expectedPassword], + json_decode(Server::get(ICrypto::class)->decrypt($credentials), true), + ); + } + + public static function sourceWithoutCredentialsProvider(): array { + return [ + 'no user info' => ['https://example.com/cal.ics', 'https://example.com/cal.ics'], + 'at sign in path' => ['https://example.com/user@domain/cal.ics', 'https://example.com/user@domain/cal.ics'], + 'at sign in query' => ['https://example.com/cal.ics?user=a@b', 'https://example.com/cal.ics?user=a@b'], + 'empty user info' => ['https://@example.com/cal.ics', 'https://example.com/cal.ics'], + 'empty username and password' => ['https://:@example.com/cal.ics', 'https://example.com/cal.ics'], + 'no scheme' => ['user:pass@example.com/cal.ics', 'user:pass@example.com/cal.ics'], + ]; + } + + #[DataProvider('sourceWithoutCredentialsProvider')] + public function testExtractWithoutCredentials(string $source, string $expectedSource): void { + $this->assertSame([$expectedSource, null], CalDavBackend::extractSubscriptionCredentials($source)); + } +} diff --git a/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php b/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php index 0694795433756..31d460961192e 100644 --- a/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php +++ b/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php @@ -774,6 +774,62 @@ public function testSubscriptions(): void { $this->assertCount(0, $subscriptions); } + public function testSubscriptionCredentials(): void { + $id = $this->backend->createSubscription(self::UNIT_TEST_USER, 'Subscription', [ + '{http://calendarserver.org/ns/}source' => new Href('webcal://us%40er:p%23ss@example.com/cal.ics'), + ]); + + $this->assertSame('webcal://example.com/cal.ics', $this->backend->getSubscriptionById($id)['source']); + $this->assertSame(['us@er', 'p#ss'], $this->getSubscriptionCredentials($id)); + + // unchanged source without user info keeps the credentials + $patch = new PropPatch([ + '{http://calendarserver.org/ns/}source' => new Href('webcal://example.com/cal.ics'), + ]); + $this->backend->updateSubscription($id, $patch); + $patch->commit(); + $this->assertSame(['us@er', 'p#ss'], $this->getSubscriptionCredentials($id)); + + $patch = new PropPatch([ + '{http://calendarserver.org/ns/}source' => new Href('webcal://other:secret@example.com/cal.ics'), + ]); + $this->backend->updateSubscription($id, $patch); + $patch->commit(); + $this->assertSame('webcal://example.com/cal.ics', $this->backend->getSubscriptionById($id)['source']); + $this->assertSame(['other', 'secret'], $this->getSubscriptionCredentials($id)); + + // changed source without user info drops the credentials + $patch = new PropPatch([ + '{http://calendarserver.org/ns/}source' => new Href('webcal://example.org/cal.ics'), + ]); + $this->backend->updateSubscription($id, $patch); + $patch->commit(); + $this->assertSame('webcal://example.org/cal.ics', $this->backend->getSubscriptionById($id)['source']); + $this->assertNull($this->getSubscriptionCredentials($id)); + + $this->backend->deleteSubscription($id); + } + + private function getSubscriptionCredentials(int|string $id): ?array { + foreach ($this->backend->getSubscriptionsForUser(self::UNIT_TEST_USER) as $subscription) { + if ((string)$subscription['id'] === (string)$id) { + return $subscription['credentials']; + } + } + $this->fail('Subscription ' . $id . ' not found'); + } + + public function testSubscriptionWithoutCredentials(): void { + $id = $this->backend->createSubscription(self::UNIT_TEST_USER, 'Subscription', [ + '{http://calendarserver.org/ns/}source' => new Href('https://example.com/cal.ics'), + ]); + + $this->assertSame('https://example.com/cal.ics', $this->backend->getSubscriptionById($id)['source']); + $this->assertNull($this->getSubscriptionCredentials($id)); + + $this->backend->deleteSubscription($id); + } + public static function providesSchedulingData(): array { $data = << [['us@er', 'p#ss'], ['us@er', 'p#ss']], + 'without credentials' => [null, null], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider(methodName: 'credentialsDataProvider')] + public function testConnectionCredentials(?array $credentials, ?array $expectedAuth): void { + $client = $this->createMock(IClient::class); + $response = $this->createMock(IResponse::class); + $subscription = [ + 'id' => 42, + 'uri' => 'sub123', + 'source' => 'webcal://foo.bar/bla2', + 'credentials' => $credentials, + 'lastmodified' => 0, + ]; + + $this->clientService->expects($this->once()) + ->method('newClient') + ->willReturn($client); + + $this->config->expects($this->once()) + ->method('getValueString') + ->with('dav', 'webcalAllowLocalAccess', 'no') + ->willReturn('no'); + + $client->expects($this->once()) + ->method('get') + ->with('https://foo.bar/bla2', $this->callback(fn (array $params): bool => ($params[RequestOptions::AUTH] ?? null) === $expectedAuth)) + ->willReturn($response); + + $response->method('getHeader') + ->willReturn('text/calendar'); + $response->method('getBody') + ->willReturn('not a resource'); + + $this->connection->queryWebcalFeed($subscription); + } + public function testConnectionReturnsNullWhenBodyIsNotResource(): void { $client = $this->createMock(IClient::class); $response = $this->createMock(IResponse::class); diff --git a/apps/dav/tests/unit/Migration/Version3000Date20261019000000Test.php b/apps/dav/tests/unit/Migration/Version3000Date20261019000000Test.php new file mode 100644 index 0000000000000..f156c037b5512 --- /dev/null +++ b/apps/dav/tests/unit/Migration/Version3000Date20261019000000Test.php @@ -0,0 +1,104 @@ +connection = $this->createMock(IDBConnection::class); + $this->output = $this->createMock(IOutput::class); + + $this->migration = new Version3000Date20261019000000($this->connection); + } + + public function testChangeSchemaAddsColumn(): void { + $table = $this->createMock(ITable::class); + $table->method('hasColumn')->with('credentials')->willReturn(false); + $table->expects($this->once()) + ->method('addColumn') + ->with('credentials', Types::TEXT, ['notnull' => false]); + $schema = $this->createMock(ISchemaWrapper::class); + $schema->method('getTable')->with('calendarsubscriptions')->willReturn($table); + + $this->assertSame($schema, $this->migration->changeSchema($this->output, fn () => $schema, [])); + } + + public function testChangeSchemaColumnExists(): void { + $table = $this->createMock(ITable::class); + $table->method('hasColumn')->with('credentials')->willReturn(true); + $table->expects($this->never())->method('addColumn'); + $schema = $this->createMock(ISchemaWrapper::class); + $schema->method('getTable')->with('calendarsubscriptions')->willReturn($table); + + $this->assertNull($this->migration->changeSchema($this->output, fn () => $schema, [])); + } + + public function testPostSchemaChangeMovesCredentials(): void { + $result = $this->createMock(IResult::class); + $result->method('fetchAssociative')->willReturnOnConsecutiveCalls( + ['id' => '1', 'source' => 'https://user:pass@example.com/cal.ics'], + ['id' => '2', 'source' => 'https://example.com/user@domain/cal.ics'], + false, + ); + + $select = $this->createMock(IQueryBuilder::class); + $select->method('expr')->willReturn($this->createMock(IExpressionBuilder::class)); + $select->method('select')->willReturnSelf(); + $select->method('from')->willReturnSelf(); + $select->method('where')->willReturnSelf(); + $select->method('andWhere')->willReturnSelf(); + $select->method('executeQuery')->willReturn($result); + + $parameters = []; + $update = $this->createMock(IQueryBuilder::class); + $update->method('expr')->willReturn($this->createMock(IExpressionBuilder::class)); + $update->method('update')->willReturnSelf(); + $update->method('set')->willReturnSelf(); + $update->method('where')->willReturnSelf(); + $update->method('setParameter')->willReturnCallback(function (string $key, mixed $value) use (&$parameters, $update) { + $parameters[$key] = $value; + return $update; + }); + $update->expects($this->once()) + ->method('executeStatement') + ->willReturnCallback(function () use (&$parameters) { + $this->assertSame('https://example.com/cal.ics', $parameters['source']); + $this->assertSame(1, $parameters['id']); + $this->assertSame( + ['username' => 'user', 'password' => 'pass'], + json_decode(Server::get(ICrypto::class)->decrypt($parameters['credentials']), true), + ); + return 1; + }); + + $this->connection->method('getQueryBuilder')->willReturnOnConsecutiveCalls($select, $update); + + $this->migration->postSchemaChange($this->output, fn () => null, []); + } +} From 42b48a8afa3ae0d17730a1fca0624189e04487b8 Mon Sep 17 00:00:00 2001 From: SebastianKrupinski Date: Wed, 28 Oct 2026 16:06:33 -0400 Subject: [PATCH 2/2] fixup! fix(dav): store webcal subscription credentials encrypted Signed-off-by: SebastianKrupinski --- 3rdparty | 2 +- .../lib/UserMigration/CalendarMigrator.php | 23 +++++- .../UserMigration/CalendarMigratorTest.php | 77 +++++++++++++++++++ 3 files changed, 100 insertions(+), 2 deletions(-) diff --git a/3rdparty b/3rdparty index 9a181f79a2ad8..4b8c973530ab1 160000 --- a/3rdparty +++ b/3rdparty @@ -1 +1 @@ -Subproject commit 9a181f79a2ad8fa3be4b24a6bfbc0af57ed51e49 +Subproject commit 4b8c973530ab1464bbcb76ed9a59478bbaecc9ca diff --git a/apps/dav/lib/UserMigration/CalendarMigrator.php b/apps/dav/lib/UserMigration/CalendarMigrator.php index 418fc45f59d4a..d8af5c45deda5 100644 --- a/apps/dav/lib/UserMigration/CalendarMigrator.php +++ b/apps/dav/lib/UserMigration/CalendarMigrator.php @@ -219,11 +219,18 @@ private function exportSubscriptions(IUser $user, IExportDestination $exportDest $exportData = []; foreach ($subscriptions as $subscription) { + $credentials = null; + if (isset($subscription['credentials'])) { + [$username, $password] = $subscription['credentials']; + $credentials = ['username' => $username, 'password' => $password]; + } + $exportData[] = [ 'uri' => $subscription[self::DAV_PROPERTY_URI], 'displayname' => $subscription[self::DAV_PROPERTY_DISPLAYNAME] ?? null, 'color' => $subscription[self::DAV_PROPERTY_CALENDAR_COLOR] ?? null, 'source' => $subscription[self::DAV_PROPERTY_SUBSCRIBED_SOURCE] ?? null, + 'credentials' => $credentials, 'striptodos' => $subscription[self::DAV_PROPERTY_SUBSCRIBED_STRIP_TODOS] ?? null, 'stripalarms' => $subscription[self::DAV_PROPERTY_SUBSCRIBED_STRIP_ALARMS] ?? null, 'stripattachments' => $subscription[self::DAV_PROPERTY_SUBSCRIBED_STRIP_ATTACHMENTS] ?? null, @@ -496,7 +503,7 @@ public function importSubscriptions(IUser $user, IImportSource $importSource, Ou $principalUri, $subscription['uri'] ? self::MIGRATED_URI_PREFIX . $subscription['uri'] : self::MIGRATED_URI_PREFIX . bin2hex(random_bytes(16)), [ - '{http://calendarserver.org/ns/}source' => new Href($subscription['source']), + '{http://calendarserver.org/ns/}source' => new Href($this->importSubscriptionSource($subscription)), self::DAV_PROPERTY_DISPLAYNAME => $subscription['displayname'] ?? null, self::DAV_PROPERTY_CALENDAR_COLOR => $subscription['color'] ?? null, self::DAV_PROPERTY_SUBSCRIBED_STRIP_TODOS => $subscription['striptodos'] ?? null, @@ -513,6 +520,20 @@ public function importSubscriptions(IUser $user, IImportSource $importSource, Ou } } + /** + * Returns the source url of an imported subscription with its credentials as user info + */ + private function importSubscriptionSource(array $subscription): string { + $source = (string)$subscription['source']; + $credentials = $subscription['credentials'] ?? null; + if (!is_array($credentials) || !is_string($credentials['username'] ?? null) || !is_string($credentials['password'] ?? null)) { + return $source; + } + + $userInfo = rawurlencode($credentials['username']) . ':' . rawurlencode($credentials['password']) . '@'; + return preg_replace('/^([a-z][a-z0-9+.\-]*:\/\/)/i', '${1}' . $userInfo, $source, 1); + } + /** * @param iterable $stream */ diff --git a/apps/dav/tests/integration/UserMigration/CalendarMigratorTest.php b/apps/dav/tests/integration/UserMigration/CalendarMigratorTest.php index 18c6ca6ee4ec5..4a2df0840055f 100644 --- a/apps/dav/tests/integration/UserMigration/CalendarMigratorTest.php +++ b/apps/dav/tests/integration/UserMigration/CalendarMigratorTest.php @@ -489,12 +489,89 @@ public function testExportSubscriptions(): void { $this->assertEquals('Test Subscription', $exportedSubscription['displayname']); $this->assertEquals('#ff0000', $exportedSubscription['color']); $this->assertEquals('https://example.com/calendar.ics', $exportedSubscription['source']); + $this->assertNull($exportedSubscription['credentials']); $this->assertEquals('1', $exportedSubscription['striptodos']); } finally { $this->deleteUser($user); } } + public function testExportSubscriptionsWithCredentials(): void { + $user = $this->createTestUser(); + + try { + $principalUri = self::USERS_URI_ROOT . $user->getUID(); + $this->calDavBackend->createSubscription( + $principalUri, + 'credentials-subscription', + [ + '{http://calendarserver.org/ns/}source' => new \Sabre\DAV\Xml\Property\Href('https://us%40er:p%2Bss%3Aw@example.com/calendar.ics'), + ] + ); + + $exportDestination = $this->createMock(IExportDestination::class); + + $exportedSubscriptionsJson = null; + + $exportDestination->method('addFileContents') + ->willReturnCallback(function (string $path, string $content) use (&$exportedSubscriptionsJson): void { + if ($path === 'dav/calendars/subscriptions.json') { + $exportedSubscriptionsJson = json_decode($content, true); + } + }); + + $exportDestination->method('addFileAsStream'); + + $this->migrator->export($user, $exportDestination, $this->output); + + $this->assertCount(1, $exportedSubscriptionsJson); + $this->assertEquals('https://example.com/calendar.ics', $exportedSubscriptionsJson[0]['source']); + $this->assertEquals(['username' => 'us@er', 'password' => 'p+ss:w'], $exportedSubscriptionsJson[0]['credentials']); + } finally { + $this->deleteUser($user); + } + } + + public function testImportSubscriptionsWithCredentials(): void { + $user = $this->createTestUser(); + + try { + $importSource = $this->createMock(IImportSource::class); + + $subscriptionsJson = json_encode([[ + 'uri' => 'imported-subscription', + 'displayname' => 'Imported Subscription', + 'source' => 'https://example.com/imported.ics', + 'credentials' => ['username' => 'us@er', 'password' => 'p+ss:w'], + ]]); + + $importSource->method('getMigratorVersion') + ->with('calendar') + ->willReturn(2); + + $importSource->method('pathExists') + ->willReturnCallback(fn (string $path) => $path === 'dav/calendars/subscriptions.json'); + + $importSource->method('getFileContents') + ->willReturnCallback(function (string $path) use ($subscriptionsJson) { + if ($path === 'dav/calendars/subscriptions.json') { + return $subscriptionsJson; + } + throw new \Exception("Unexpected path: $path"); + }); + + $this->migrator->import($user, $importSource, $this->output); + + $principalUri = self::USERS_URI_ROOT . $user->getUID(); + $subscriptions = $this->calDavBackend->getSubscriptionsForUser($principalUri); + $this->assertCount(1, $subscriptions); + $this->assertEquals('https://example.com/imported.ics', $subscriptions[0]['source']); + $this->assertEquals(['us@er', 'p+ss:w'], $subscriptions[0]['credentials']); + } finally { + $this->deleteUser($user); + } + } + public function testImportSubscriptions(): void { $user = $this->createTestUser();