diff options
Diffstat (limited to 'apps/dav/lib/CardDAV/SyncService.php')
-rw-r--r-- | apps/dav/lib/CardDAV/SyncService.php | 409 |
1 files changed, 213 insertions, 196 deletions
diff --git a/apps/dav/lib/CardDAV/SyncService.php b/apps/dav/lib/CardDAV/SyncService.php index bcb20409524..e6da3ed5923 100644 --- a/apps/dav/lib/CardDAV/SyncService.php +++ b/apps/dav/lib/CardDAV/SyncService.php @@ -1,108 +1,69 @@ <?php + + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author Björn Schießle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Thomas Citharel <nextcloud@tcit.fr> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - namespace OCA\DAV\CardDAV; -use OC\Accounts\AccountManager; +use OCP\AppFramework\Db\TTransactional; use OCP\AppFramework\Http; -use OCP\ILogger; +use OCP\DB\Exception; +use OCP\Http\Client\IClient; +use OCP\Http\Client\IClientService; +use OCP\IConfig; +use OCP\IDBConnection; use OCP\IUser; use OCP\IUserManager; -use Sabre\DAV\Client; +use Psr\Http\Client\ClientExceptionInterface; +use Psr\Log\LoggerInterface; use Sabre\DAV\Xml\Response\MultiStatus; use Sabre\DAV\Xml\Service; -use Sabre\HTTP\ClientHttpException; use Sabre\VObject\Reader; +use Sabre\Xml\ParseException; +use function is_null; class SyncService { - /** @var CardDavBackend */ - private $backend; - - /** @var IUserManager */ - private $userManager; - - /** @var ILogger */ - private $logger; - - /** @var array */ - private $localSystemAddressBook; - - /** @var AccountManager */ - private $accountManager; - - /** @var string */ - protected $certPath; - - /** - * SyncService constructor. - * - * @param CardDavBackend $backend - * @param IUserManager $userManager - * @param ILogger $logger - * @param AccountManager $accountManager - */ - public function __construct(CardDavBackend $backend, IUserManager $userManager, ILogger $logger, AccountManager $accountManager) { - $this->backend = $backend; - $this->userManager = $userManager; - $this->logger = $logger; - $this->accountManager = $accountManager; + use TTransactional; + private ?array $localSystemAddressBook = null; + protected string $certPath; + + public function __construct( + private CardDavBackend $backend, + private IUserManager $userManager, + private IDBConnection $dbConnection, + private LoggerInterface $logger, + private Converter $converter, + private IClientService $clientService, + private IConfig $config, + ) { $this->certPath = ''; } /** - * @param string $url - * @param string $userName - * @param string $addressBookUrl - * @param string $sharedSecret - * @param string $syncToken - * @param int $targetBookId - * @param string $targetPrincipal - * @param array $targetProperties - * @return string + * @psalm-return list{0: ?string, 1: boolean} * @throws \Exception */ - public function syncRemoteAddressBook($url, $userName, $addressBookUrl, $sharedSecret, $syncToken, $targetBookId, $targetPrincipal, $targetProperties) { + public function syncRemoteAddressBook(string $url, string $userName, string $addressBookUrl, string $sharedSecret, ?string $syncToken, string $targetBookHash, string $targetPrincipal, array $targetProperties): array { // 1. create addressbook - $book = $this->ensureSystemAddressBookExists($targetPrincipal, $targetBookId, $targetProperties); + $book = $this->ensureSystemAddressBookExists($targetPrincipal, $targetBookHash, $targetProperties); $addressBookId = $book['id']; // 2. query changes try { $response = $this->requestSyncReport($url, $userName, $addressBookUrl, $sharedSecret, $syncToken); - } catch (ClientHttpException $ex) { + } catch (ClientExceptionInterface $ex) { if ($ex->getCode() === Http::STATUS_UNAUTHORIZED) { // remote server revoked access to the address book, remove it $this->backend->deleteAddressBook($addressBookId); - $this->logger->info('Authorization failed, remove address book: ' . $url, ['app' => 'dav']); + $this->logger->error('Authorization failed, remove address book: ' . $url, ['app' => 'dav']); throw $ex; } + $this->logger->error('Client exception:', ['app' => 'dav', 'exception' => $ex]); + throw $ex; } // 3. apply changes @@ -111,123 +72,147 @@ class SyncService { $cardUri = basename($resource); if (isset($status[200])) { $vCard = $this->download($url, $userName, $sharedSecret, $resource); - $existingCard = $this->backend->getCard($addressBookId, $cardUri); - if ($existingCard === false) { - $this->backend->createCard($addressBookId, $cardUri, $vCard['body']); - } else { - $this->backend->updateCard($addressBookId, $cardUri, $vCard['body']); - } + $this->atomic(function () use ($addressBookId, $cardUri, $vCard): void { + $existingCard = $this->backend->getCard($addressBookId, $cardUri); + if ($existingCard === false) { + $this->backend->createCard($addressBookId, $cardUri, $vCard); + } else { + $this->backend->updateCard($addressBookId, $cardUri, $vCard); + } + }, $this->dbConnection); } else { $this->backend->deleteCard($addressBookId, $cardUri); } } - return $response['token']; + return [ + $response['token'], + $response['truncated'], + ]; } /** - * @param string $principal - * @param string $id - * @param array $properties - * @return array|null * @throws \Sabre\DAV\Exception\BadRequest */ - public function ensureSystemAddressBookExists($principal, $id, $properties) { - $book = $this->backend->getAddressBooksByUri($principal, $id); - if (!is_null($book)) { - return $book; - } - $this->backend->createAddressBook($principal, $id, $properties); - - return $this->backend->getAddressBooksByUri($principal, $id); - } - - /** - * Check if there is a valid certPath we should use - * - * @return string - */ - protected function getCertPath() { + public function ensureSystemAddressBookExists(string $principal, string $uri, array $properties): ?array { + try { + return $this->atomic(function () use ($principal, $uri, $properties) { + $book = $this->backend->getAddressBooksByUri($principal, $uri); + if (!is_null($book)) { + return $book; + } + $this->backend->createAddressBook($principal, $uri, $properties); + + return $this->backend->getAddressBooksByUri($principal, $uri); + }, $this->dbConnection); + } catch (Exception $e) { + // READ COMMITTED doesn't prevent a nonrepeatable read above, so + // two processes might create an address book here. Ignore our + // failure and continue loading the entry written by the other process + if ($e->getReason() !== Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) { + throw $e; + } - // we already have a valid certPath - if ($this->certPath !== '') { - return $this->certPath; + // If this fails we might have hit a replication node that does not + // have the row written in the other process. + // TODO: find an elegant way to handle this + $ab = $this->backend->getAddressBooksByUri($principal, $uri); + if ($ab === null) { + throw new Exception('Could not create system address book', $e->getCode(), $e); + } + return $ab; } + } - $certManager = \OC::$server->getCertificateManager(); - $certPath = $certManager->getAbsoluteBundlePath(); - if (file_exists($certPath)) { - $this->certPath = $certPath; - } + public function ensureLocalSystemAddressBookExists(): ?array { + return $this->ensureSystemAddressBookExists('principals/system/system', 'system', [ + '{' . Plugin::NS_CARDDAV . '}addressbook-description' => 'System addressbook which holds all users of this instance' + ]); + } - return $this->certPath; + private function prepareUri(string $host, string $path): string { + /* + * The trailing slash is important for merging the uris. + * + * $host is stored in oc_trusted_servers.url and usually without a trailing slash. + * + * Example for a report request + * + * $host = 'https://server.internal/cloud' + * $path = 'remote.php/dav/addressbooks/system/system/system' + * + * Without the trailing slash, the webroot is missing: + * https://server.internal/remote.php/dav/addressbooks/system/system/system + * + * Example for a download request + * + * $host = 'https://server.internal/cloud' + * $path = '/cloud/remote.php/dav/addressbooks/system/system/system/Database:alice.vcf' + * + * The response from the remote usually contains the webroot already and must be normalized to: + * https://server.internal/cloud/remote.php/dav/addressbooks/system/system/system/Database:alice.vcf + */ + $host = rtrim($host, '/') . '/'; + + $uri = \GuzzleHttp\Psr7\UriResolver::resolve( + \GuzzleHttp\Psr7\Utils::uriFor($host), + \GuzzleHttp\Psr7\Utils::uriFor($path) + ); + + return (string)$uri; } /** - * @param string $url - * @param string $userName - * @param string $addressBookUrl - * @param string $sharedSecret - * @return Client + * @return array{response: array<string, array<array-key, mixed>>, token: ?string, truncated: bool} + * @throws ClientExceptionInterface + * @throws ParseException */ - protected function getClient($url, $userName, $sharedSecret) { - $settings = [ - 'baseUri' => $url . '/', - 'userName' => $userName, - 'password' => $sharedSecret, + protected function requestSyncReport(string $url, string $userName, string $addressBookUrl, string $sharedSecret, ?string $syncToken): array { + $client = $this->clientService->newClient(); + $uri = $this->prepareUri($url, $addressBookUrl); + + $options = [ + 'auth' => [$userName, $sharedSecret], + 'body' => $this->buildSyncCollectionRequestBody($syncToken), + 'headers' => ['Content-Type' => 'application/xml'], + 'timeout' => $this->config->getSystemValueInt('carddav_sync_request_timeout', IClient::DEFAULT_REQUEST_TIMEOUT), + 'verify' => !$this->config->getSystemValue('sharing.federation.allowSelfSignedCertificates', false), ]; - $client = new Client($settings); - $certPath = $this->getCertPath(); - $client->setThrowExceptions(true); - if ($certPath !== '' && strpos($url, 'http://') !== 0) { - $client->addCurlSetting(CURLOPT_CAINFO, $this->certPath); - } + $response = $client->request( + 'REPORT', + $uri, + $options + ); - return $client; - } + $body = $response->getBody(); + assert(is_string($body)); - /** - * @param string $url - * @param string $userName - * @param string $addressBookUrl - * @param string $sharedSecret - * @param string $syncToken - * @return array - */ - protected function requestSyncReport($url, $userName, $addressBookUrl, $sharedSecret, $syncToken) { - $client = $this->getClient($url, $userName, $sharedSecret); + return $this->parseMultiStatus($body, $addressBookUrl); + } - $body = $this->buildSyncCollectionRequestBody($syncToken); + protected function download(string $url, string $userName, string $sharedSecret, string $resourcePath): string { + $client = $this->clientService->newClient(); + $uri = $this->prepareUri($url, $resourcePath); - $response = $client->request('REPORT', $addressBookUrl, $body, [ - 'Content-Type' => 'application/xml' - ]); + $options = [ + 'auth' => [$userName, $sharedSecret], + 'verify' => !$this->config->getSystemValue('sharing.federation.allowSelfSignedCertificates', false), + ]; - return $this->parseMultiStatus($response['body']); - } + $response = $client->get( + $uri, + $options + ); - /** - * @param string $url - * @param string $userName - * @param string $sharedSecret - * @param string $resourcePath - * @return array - */ - protected function download($url, $userName, $sharedSecret, $resourcePath) { - $client = $this->getClient($url, $userName, $sharedSecret); - return $client->request('GET', $resourcePath); + return (string)$response->getBody(); } - /** - * @param string|null $syncToken - * @return string - */ - private function buildSyncCollectionRequestBody($syncToken) { + private function buildSyncCollectionRequestBody(?string $syncToken): string { $dom = new \DOMDocument('1.0', 'UTF-8'); $dom->formatOutput = true; $root = $dom->createElementNS('DAV:', 'd:sync-collection'); - $sync = $dom->createElement('d:sync-token', $syncToken); + $sync = $dom->createElement('d:sync-token', $syncToken ?? ''); $prop = $dom->createElement('d:prop'); $cont = $dom->createElement('d:getcontenttype'); $etag = $dom->createElement('d:getetag'); @@ -241,50 +226,77 @@ class SyncService { } /** - * @param string $body - * @return array - * @throws \Sabre\Xml\ParseException + * @return array{response: array<string, array<array-key, mixed>>, token: ?string, truncated: bool} + * @throws ParseException */ - private function parseMultiStatus($body) { - $xml = new Service(); - + private function parseMultiStatus(string $body, string $addressBookUrl): array { /** @var MultiStatus $multiStatus */ - $multiStatus = $xml->expect('{DAV:}multistatus', $body); + $multiStatus = (new Service())->expect('{DAV:}multistatus', $body); $result = []; + $truncated = false; + foreach ($multiStatus->getResponses() as $response) { - $result[$response->getHref()] = $response->getResponseProperties(); + $href = $response->getHref(); + if ($response->getHttpStatus() === '507' && $this->isResponseForRequestUri($href, $addressBookUrl)) { + $truncated = true; + } else { + $result[$response->getHref()] = $response->getResponseProperties(); + } } - return ['response' => $result, 'token' => $multiStatus->getSyncToken()]; + return ['response' => $result, 'token' => $multiStatus->getSyncToken(), 'truncated' => $truncated]; + } + + /** + * Determines whether the provided response URI corresponds to the given request URI. + */ + private function isResponseForRequestUri(string $responseUri, string $requestUri): bool { + /* + * Example response uri: + * + * /remote.php/dav/addressbooks/system/system/system/ + * /cloud/remote.php/dav/addressbooks/system/system/system/ (when installed in a subdirectory) + * + * Example request uri: + * + * remote.php/dav/addressbooks/system/system/system + * + * References: + * https://github.com/nextcloud/3rdparty/blob/e0a509739b13820f0a62ff9cad5d0fede00e76ee/sabre/dav/lib/DAV/Sync/Plugin.php#L172-L174 + * https://github.com/nextcloud/server/blob/b40acb34a39592070d8455eb91c5364c07928c50/apps/federation/lib/SyncFederationAddressBooks.php#L41 + */ + return str_ends_with( + rtrim($responseUri, '/'), + rtrim($requestUri, '/') + ); } /** * @param IUser $user */ - public function updateUser(IUser $user) { + public function updateUser(IUser $user): void { $systemAddressBook = $this->getLocalSystemAddressBook(); $addressBookId = $systemAddressBook['id']; - $converter = new Converter($this->accountManager); - $name = $user->getBackendClassName(); - $userId = $user->getUID(); - $cardId = "$name:$userId.vcf"; - $card = $this->backend->getCard($addressBookId, $cardId); + $cardId = self::getCardUri($user); if ($user->isEnabled()) { - if ($card === false) { - $vCard = $converter->createCardFromUser($user); - if ($vCard !== null) { - $this->backend->createCard($addressBookId, $cardId, $vCard->serialize()); - } - } else { - $vCard = $converter->createCardFromUser($user); - if (is_null($vCard)) { - $this->backend->deleteCard($addressBookId, $cardId); + $this->atomic(function () use ($addressBookId, $cardId, $user): void { + $card = $this->backend->getCard($addressBookId, $cardId); + if ($card === false) { + $vCard = $this->converter->createCardFromUser($user); + if ($vCard !== null) { + $this->backend->createCard($addressBookId, $cardId, $vCard->serialize(), false); + } } else { - $this->backend->updateCard($addressBookId, $cardId, $vCard->serialize()); + $vCard = $this->converter->createCardFromUser($user); + if (is_null($vCard)) { + $this->backend->deleteCard($addressBookId, $cardId); + } else { + $this->backend->updateCard($addressBookId, $cardId, $vCard->serialize()); + } } - } + }, $this->dbConnection); } else { $this->backend->deleteCard($addressBookId, $cardId); } @@ -296,10 +308,7 @@ class SyncService { public function deleteUser($userOrCardId) { $systemAddressBook = $this->getLocalSystemAddressBook(); if ($userOrCardId instanceof IUser) { - $name = $userOrCardId->getBackendClassName(); - $userId = $userOrCardId->getUID(); - - $userOrCardId = "$name:$userId.vcf"; + $userOrCardId = self::getCardUri($userOrCardId); } $this->backend->deleteCard($systemAddressBook['id'], $userOrCardId); } @@ -309,18 +318,18 @@ class SyncService { */ public function getLocalSystemAddressBook() { if (is_null($this->localSystemAddressBook)) { - $systemPrincipal = "principals/system/system"; - $this->localSystemAddressBook = $this->ensureSystemAddressBookExists($systemPrincipal, 'system', [ - '{' . Plugin::NS_CARDDAV . '}addressbook-description' => 'System addressbook which holds all users of this instance' - ]); + $this->localSystemAddressBook = $this->ensureLocalSystemAddressBookExists(); } return $this->localSystemAddressBook; } - public function syncInstance(\Closure $progressCallback = null) { + /** + * @return void + */ + public function syncInstance(?\Closure $progressCallback = null) { $systemAddressBook = $this->getLocalSystemAddressBook(); - $this->userManager->callForAllUsers(function ($user) use ($systemAddressBook, $progressCallback) { + $this->userManager->callForAllUsers(function ($user) use ($systemAddressBook, $progressCallback): void { $this->updateUser($user); if (!is_null($progressCallback)) { $progressCallback(); @@ -338,4 +347,12 @@ class SyncService { } } } + + /** + * @param IUser $user + * @return string + */ + public static function getCardUri(IUser $user): string { + return $user->getBackendClassName() . ':' . $user->getUID() . '.vcf'; + } } |