Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion 3rdparty
2 changes: 1 addition & 1 deletion apps/dav/appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<name>WebDAV</name>
<summary>WebDAV endpoint</summary>
<description>WebDAV endpoint</description>
<version>3.0.0-dev.1</version>
<version>3.0.0-dev.2</version>
<licence>agpl</licence>
<author>owncloud.org</author>
<namespace>DAV</namespace>
Expand Down
1 change: 1 addition & 0 deletions apps/dav/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions apps/dav/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
69 changes: 67 additions & 2 deletions apps/dav/lib/CalDAV/CalDavBackend.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -2976,6 +2978,7 @@ public function getSubscriptionsForUser($principalUri) {
$fields[] = 'principaluri';
$fields[] = 'lastmodified';
$fields[] = 'synctoken';
$fields[] = 'credentials';

$query = $this->db->getQueryBuilder();
$query->select($fields)
Expand All @@ -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']),
Expand Down Expand Up @@ -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(),
];

Expand Down Expand Up @@ -3085,14 +3091,22 @@ 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;
}
}

$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()));
Expand All @@ -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.
*
Expand Down
6 changes: 2 additions & 4 deletions apps/dav/lib/CalDAV/WebcalCaching/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
76 changes: 76 additions & 0 deletions apps/dav/lib/Migration/Version3000Date20261019000000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\DAV\Migration;

use Closure;
use OCA\DAV\CalDAV\CalDavBackend;
use OCP\DB\ISchemaWrapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\DB\Types;
use OCP\IDBConnection;
use OCP\Migration\Attributes\AddColumn;
use OCP\Migration\Attributes\ColumnType;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
use Override;

#[AddColumn(table: 'calendarsubscriptions', name: 'credentials', type: ColumnType::TEXT, description: 'Encrypted credentials extracted from the source url')]
class Version3000Date20261019000000 extends SimpleMigrationStep {
public function __construct(
private readonly IDBConnection $connection,
) {
}

#[Override]
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();

$table = $schema->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()) {
Comment thread
tcitworld marked this conversation as resolved.
[$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();
}
}
23 changes: 22 additions & 1 deletion apps/dav/lib/UserMigration/CalendarMigrator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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<ImportObjectEvent> $stream
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
Loading
Loading