aboutsummaryrefslogtreecommitdiffstats
path: root/apps/dav/lib/CardDAV/SyncService.php
diff options
context:
space:
mode:
Diffstat (limited to 'apps/dav/lib/CardDAV/SyncService.php')
-rw-r--r--apps/dav/lib/CardDAV/SyncService.php249
1 files changed, 162 insertions, 87 deletions
diff --git a/apps/dav/lib/CardDAV/SyncService.php b/apps/dav/lib/CardDAV/SyncService.php
index e09cc36c8bb..e6da3ed5923 100644
--- a/apps/dav/lib/CardDAV/SyncService.php
+++ b/apps/dav/lib/CardDAV/SyncService.php
@@ -10,46 +10,44 @@ namespace OCA\DAV\CardDAV;
use OCP\AppFramework\Db\TTransactional;
use OCP\AppFramework\Http;
+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 Psr\Http\Client\ClientExceptionInterface;
use Psr\Log\LoggerInterface;
-use Sabre\DAV\Client;
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 {
use TTransactional;
-
- private CardDavBackend $backend;
- private IUserManager $userManager;
- private IDBConnection $dbConnection;
- private LoggerInterface $logger;
private ?array $localSystemAddressBook = null;
- private Converter $converter;
protected string $certPath;
- public function __construct(CardDavBackend $backend,
- IUserManager $userManager,
- IDBConnection $dbConnection,
- LoggerInterface $logger,
- Converter $converter) {
- $this->backend = $backend;
- $this->userManager = $userManager;
- $this->logger = $logger;
- $this->converter = $converter;
+ 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 = '';
- $this->dbConnection = $dbConnection;
}
/**
+ * @psalm-return list{0: ?string, 1: boolean}
* @throws \Exception
*/
- public function syncRemoteAddressBook(string $url, string $userName, string $addressBookUrl, string $sharedSecret, ?string $syncToken, string $targetBookHash, string $targetPrincipal, array $targetProperties): string {
+ 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, $targetBookHash, $targetProperties);
$addressBookId = $book['id'];
@@ -57,7 +55,7 @@ class SyncService {
// 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);
@@ -74,12 +72,12 @@ class SyncService {
$cardUri = basename($resource);
if (isset($status[200])) {
$vCard = $this->download($url, $userName, $sharedSecret, $resource);
- $this->atomic(function () use ($addressBookId, $cardUri, $vCard) {
+ $this->atomic(function () use ($addressBookId, $cardUri, $vCard): void {
$existingCard = $this->backend->getCard($addressBookId, $cardUri);
if ($existingCard === false) {
- $this->backend->createCard($addressBookId, $cardUri, $vCard['body']);
+ $this->backend->createCard($addressBookId, $cardUri, $vCard);
} else {
- $this->backend->updateCard($addressBookId, $cardUri, $vCard['body']);
+ $this->backend->updateCard($addressBookId, $cardUri, $vCard);
}
}, $this->dbConnection);
} else {
@@ -87,82 +85,134 @@ class SyncService {
}
}
- return $response['token'];
+ return [
+ $response['token'],
+ $response['truncated'],
+ ];
}
/**
* @throws \Sabre\DAV\Exception\BadRequest
*/
public function ensureSystemAddressBookExists(string $principal, string $uri, array $properties): ?array {
- 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);
- }
+ 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);
- /**
- * Check if there is a valid certPath we should use
- */
- protected function getCertPath(): string {
+ 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;
}
- protected function getClient(string $url, string $userName, string $sharedSecret): Client {
- $settings = [
- 'baseUri' => $url . '/',
- 'userName' => $userName,
- 'password' => $sharedSecret,
+ /**
+ * @return array{response: array<string, array<array-key, mixed>>, token: ?string, truncated: bool}
+ * @throws ClientExceptionInterface
+ * @throws ParseException
+ */
+ 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 !== '' && !str_starts_with($url, 'http://')) {
- $client->addCurlSetting(CURLOPT_CAINFO, $this->certPath);
- }
+ $response = $client->request(
+ 'REPORT',
+ $uri,
+ $options
+ );
- return $client;
- }
+ $body = $response->getBody();
+ assert(is_string($body));
- protected function requestSyncReport(string $url, string $userName, string $addressBookUrl, string $sharedSecret, ?string $syncToken): array {
- $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
+ );
- protected function download(string $url, string $userName, string $sharedSecret, string $resourcePath): array {
- $client = $this->getClient($url, $userName, $sharedSecret);
- return $client->request('GET', $resourcePath);
+ return (string)$response->getBody();
}
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');
@@ -176,22 +226,50 @@ 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, '/')
+ );
}
/**
@@ -203,7 +281,7 @@ class SyncService {
$cardId = self::getCardUri($user);
if ($user->isEnabled()) {
- $this->atomic(function () use ($addressBookId, $cardId, $user) {
+ $this->atomic(function () use ($addressBookId, $cardId, $user): void {
$card = $this->backend->getCard($addressBookId, $cardId);
if ($card === false) {
$vCard = $this->converter->createCardFromUser($user);
@@ -240,10 +318,7 @@ 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;
@@ -254,7 +329,7 @@ class SyncService {
*/
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();