diff --git a/app/Console/Commands/Local/UpdateAddressBookSubscription.php b/app/Console/Commands/Local/UpdateAddressBookSubscription.php new file mode 100644 index 00000000000..fed51774d67 --- /dev/null +++ b/app/Console/Commands/Local/UpdateAddressBookSubscription.php @@ -0,0 +1,36 @@ +option('subscriptionId')); + + SynchronizeAddressBooks::dispatch($subscription, $this->option('force'))->onQueue('high'); + } +} diff --git a/app/Console/Commands/NewAddressBookSubscription.php b/app/Console/Commands/NewAddressBookSubscription.php new file mode 100644 index 00000000000..aee079f0273 --- /dev/null +++ b/app/Console/Commands/NewAddressBookSubscription.php @@ -0,0 +1,128 @@ +user()) === null) { + return 1; + } + if (($vault = $this->vault()) === null) { + return 2; + } + + if ($user->account_id !== $vault->account_id) { + $this->error('Vault does not belong to this account'); + + return 3; + } + + $pushonly = $this->option('pushonly'); + $getonly = $this->option('getonly'); + if ($pushonly && $getonly) { + $this->error('Cannot set both pushonly and getonly'); + + return 4; + } + + $url = $this->option('url') ?? $this->ask('CardDAV url of the address book'); + $login = $this->option('login') ?? $this->ask('Login name'); + $password = $this->option('password') ?? $this->secret('User password'); + + try { + $subscription = app(CreateAddressBookSubscription::class)->execute([ + 'account_id' => $user->account_id, + 'vault_id' => $vault->id, + 'author_id' => $user->id, + 'base_uri' => $url, + 'username' => $login, + 'password' => $password, + ]); + + if ($pushonly) { + $subscription->sync_way = AddressBookSubscription::WAY_PUSH; + } elseif ($getonly) { + $subscription->sync_way = AddressBookSubscription::WAY_GET; + } + $subscription->save(); + } catch (\Exception $e) { + $this->error('Could not add subscription'); + $this->error($e->getMessage()); + + return -1; + } + + $this->info("Subscription added: {$subscription->id}"); + SynchronizeAddressBooks::dispatch($subscription, true)->onQueue('high'); + + return 0; + } + + private function user(): ?User + { + if (($email = $this->option('email')) === null) { + $this->error('Please provide an email address'); + + return null; + } + + try { + return User::where('email', $email)->firstOrFail(); + } catch (ModelNotFoundException) { + $this->error('Could not find user'); + + return null; + } + } + + private function vault(): ?Vault + { + if (($vaultId = $this->option('vaultId')) === null) { + $this->error('Please provide an vaultId'); + + return null; + } + + try { + return Vault::findOrFail($vaultId); + } catch (ModelNotFoundException) { + $this->error('Could not find vault'); + + return null; + } + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 95b858e92dc..c413defb2db 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -4,6 +4,7 @@ use App\Console\Scheduling\CronEvent; use App\Domains\Contact\Dav\Jobs\CleanSyncToken; +use App\Domains\Contact\DavClient\Jobs\UpdateAddressBooks; use App\Domains\Contact\ManageReminders\Jobs\ProcessScheduledContactReminders; use Illuminate\Console\Scheduling\Schedule; use Illuminate\Foundation\Console\Kernel as ConsoleKernel; @@ -40,6 +41,7 @@ protected function schedule(Schedule $schedule) if (config('telescope.enabled')) { $this->scheduleCommand($schedule, 'telescope:prune', 'daily'); } + $this->scheduleJob($schedule, UpdateAddressBooks::class, 'hourly'); $this->scheduleJob($schedule, ProcessScheduledContactReminders::class, 'minutes', 1); $this->scheduleJob($schedule, CleanSyncToken::class, 'daily'); } diff --git a/app/Domains/Contact/Dav/Web/Backend/CardDAV/CardDAVBackend.php b/app/Domains/Contact/Dav/Web/Backend/CardDAV/CardDAVBackend.php index 34e39d4b3ed..c9d77be5ae5 100644 --- a/app/Domains/Contact/Dav/Web/Backend/CardDAV/CardDAVBackend.php +++ b/app/Domains/Contact/Dav/Web/Backend/CardDAV/CardDAVBackend.php @@ -485,12 +485,12 @@ public function deleteCard($addressBookId, $cardUri): bool return true; } elseif ($obj !== null && $obj instanceof Group) { - (new DestroyGroup)->execute([ + DestroyGroup::dispatch([ 'account_id' => $this->user->account_id, 'author_id' => $this->user->id, 'vault_id' => $obj->vault_id, 'group_id' => $obj->id, - ]); + ])->onQueue('high'); return true; } diff --git a/app/Domains/Contact/DavClient/Jobs/DeleteMultipleVCard.php b/app/Domains/Contact/DavClient/Jobs/DeleteMultipleVCard.php new file mode 100644 index 00000000000..c968649ecfc --- /dev/null +++ b/app/Domains/Contact/DavClient/Jobs/DeleteMultipleVCard.php @@ -0,0 +1,48 @@ +subscription = $subscription->withoutRelations(); + } + + /** + * Update the Last Consulted At field for the given contact. + */ + public function handle(): void + { + if (! $this->batching()) { + return; // @codeCoverageIgnore + } + + $jobs = collect($this->hrefs) + ->map(fn (string $href): DeleteVCard => $this->deleteVCard($href)); + + $this->batch()->add($jobs); + } + + /** + * Delete the contact. + */ + private function deleteVCard(string $href): DeleteVCard + { + return new DeleteVCard($this->subscription, $href); + } +} diff --git a/app/Domains/Contact/DavClient/Jobs/DeleteVCard.php b/app/Domains/Contact/DavClient/Jobs/DeleteVCard.php new file mode 100644 index 00000000000..8a19f19ea16 --- /dev/null +++ b/app/Domains/Contact/DavClient/Jobs/DeleteVCard.php @@ -0,0 +1,34 @@ +subscription = $subscription->withoutRelations(); + } + + /** + * Send Delete contact. + */ + public function handle(): void + { + $this->subscription->getClient() + ->request('DELETE', $this->uri); + } +} diff --git a/app/Domains/Contact/DavClient/Jobs/GetMultipleVCard.php b/app/Domains/Contact/DavClient/Jobs/GetMultipleVCard.php new file mode 100644 index 00000000000..11d1e31a460 --- /dev/null +++ b/app/Domains/Contact/DavClient/Jobs/GetMultipleVCard.php @@ -0,0 +1,96 @@ +subscription = $subscription->withoutRelations(); + } + + /** + * Update the Last Consulted At field for the given contact. + */ + public function handle(): void + { + if (! $this->batching()) { + return; // @codeCoverageIgnore + } + + $data = $this->addressbookMultiget(); + + $jobs = collect($data) + ->filter(fn (array $contact): bool => is_array($contact) && $contact['status'] === '200') + ->map(fn (array $contact, string $href): ?UpdateVCard => $this->updateVCard($contact, $href)) + ->filter(); + + $this->batch()->add($jobs); + } + + /** + * Update the contact. + */ + private function updateVCard(array $contact, string $href): ?UpdateVCard + { + $card = Arr::get($contact, 'properties.200.{'.CardDav::NS_CARDDAV.'}address-data'); + + return $card === null + ? null + : new UpdateVCard([ + 'account_id' => $this->subscription->vault->account_id, + 'author_id' => $this->subscription->user_id, + 'vault_id' => $this->subscription->vault_id, + 'uri' => $href, + 'etag' => Arr::get($contact, 'properties.200.{DAV:}getetag'), + 'card' => $card, + 'external' => true, + ]); + } + + /** + * Get addressbook data. + */ + private function addressbookMultiget(): array + { + return $this->subscription->getClient() + ->addressbookMultiget([ + '{DAV:}getetag', + $this->getAddressDataProperty(), + ], $this->hrefs); + } + + /** + * Get data for address-data property. + */ + private function getAddressDataProperty(): array + { + $addressDataAttributes = Arr::get($this->subscription->capabilities, 'addressData', [ + 'content-type' => 'text/vcard', + 'version' => '4.0', + ]); + + return [ + 'name' => '{'.CardDav::NS_CARDDAV.'}address-data', + 'value' => null, + 'attributes' => $addressDataAttributes, + ]; + } +} diff --git a/app/Domains/Contact/DavClient/Jobs/GetVCard.php b/app/Domains/Contact/DavClient/Jobs/GetVCard.php new file mode 100644 index 00000000000..60bf16a1665 --- /dev/null +++ b/app/Domains/Contact/DavClient/Jobs/GetVCard.php @@ -0,0 +1,57 @@ +subscription = $subscription->withoutRelations(); + } + + /** + * Update the Last Consulted At field for the given contact. + */ + public function handle(): void + { + if (! $this->batching()) { + return; // @codeCoverageIgnore + } + + $response = $this->subscription->getClient() + ->request('GET', $this->contact->uri); + + $job = $this->updateVCard($response->body()); + + $this->batch()->add($job); + } + + private function updateVCard(string $card): UpdateVCard + { + return new UpdateVCard([ + 'account_id' => $this->subscription->vault->account_id, + 'author_id' => $this->subscription->user_id, + 'vault_id' => $this->subscription->vault_id, + 'uri' => $this->contact->uri, + 'etag' => $this->contact->etag, + 'card' => $card, + 'external' => true, + ]); + } +} diff --git a/app/Domains/Contact/DavClient/Jobs/PushVCard.php b/app/Domains/Contact/DavClient/Jobs/PushVCard.php new file mode 100644 index 00000000000..46bc20f6c87 --- /dev/null +++ b/app/Domains/Contact/DavClient/Jobs/PushVCard.php @@ -0,0 +1,115 @@ +subscription = $subscription->withoutRelations(); + $this->card = self::transformCard($card); + } + + /** + * Push VCard data to the distance server. + */ + public function handle(): void + { + $contact = Contact::where('vault_id', $this->subscription->vault_id) + ->findOrFail($this->contactId); + + $etag = $this->pushDistant(); + + if ($contact->distant_uri !== null) { + Contact::withoutTimestamps(function () use ($contact, $etag): void { + + $contact->distant_etag = empty($etag) ? null : $etag; + + $contact->save(); + }); + } + } + + private function pushDistant(int $depth = 1): string + { + try { + $response = $this->subscription->getClient() + ->request('PUT', $this->uri, $this->card, $this->headers()); + + return $response->header('Etag'); + } catch (RequestException $e) { + if ($depth > 0 && $e->response->status() === 412) { + // If the status is 412 (Precondition Failed), then we retry once with a mode match NONE + $this->mode = self::MODE_MATCH_NONE; + + return $this->pushDistant(--$depth); + } else { + Log::error(__CLASS__.' '.__FUNCTION__.': '.$e->getMessage(), [ + 'body' => $e->response->body(), + $e, + ]); + $this->fail($e); + + throw $e; + } + } + } + + /** + * Get the headers for the request. + */ + private function headers(): array + { + $headers = []; + + if ($this->mode === self::MODE_MATCH_ETAG) { + $headers['If-Match'] = $this->etag; + } elseif ($this->mode === self::MODE_MATCH_ANY) { + $headers['If-Match'] = '*'; + } + + return $headers; + } + + /** + * Transform card. + * + * @param string|resource $card + */ + protected static function transformCard(mixed $card): string + { + if (is_resource($card)) { + return tap(stream_get_contents($card), fn () => fclose($card)); + } + + return $card; + } +} diff --git a/app/Domains/Contact/DavClient/Jobs/SynchronizeAddressBooks.php b/app/Domains/Contact/DavClient/Jobs/SynchronizeAddressBooks.php new file mode 100644 index 00000000000..752549db4f4 --- /dev/null +++ b/app/Domains/Contact/DavClient/Jobs/SynchronizeAddressBooks.php @@ -0,0 +1,64 @@ +subscription = $subscription->withoutRelations(); + } + + /** + * Execute the job. + */ + public function handle(): void + { + $this->withLocale($this->subscription->user->preferredLocale(), fn () => $this->synchronize()); + } + + /** + * Run synchronization. + */ + private function synchronize(): void + { + try { + Log::withContext([ + 'addressbook_subscription_id' => $this->subscription->id, + ]); + + $batchId = app(SynchronizeAddressBook::class)->execute([ + 'account_id' => $this->subscription->user->account_id, + 'addressbook_subscription_id' => $this->subscription->id, + 'force' => $this->force, + ]); + + $this->subscription->last_batch = $batchId; + } catch (\Exception $e) { + Log::error(__CLASS__.' '.__FUNCTION__.':'.$e->getMessage(), [$e]); + $this->fail($e); + } finally { + $this->subscription->last_synchronized_at = now(); + $this->subscription->save(); + + Log::withoutContext(); + } + } +} diff --git a/app/Domains/Contact/DavClient/Jobs/UpdateAddressBooks.php b/app/Domains/Contact/DavClient/Jobs/UpdateAddressBooks.php new file mode 100644 index 00000000000..65ba82e79d2 --- /dev/null +++ b/app/Domains/Contact/DavClient/Jobs/UpdateAddressBooks.php @@ -0,0 +1,47 @@ +chunkById(200, fn (Collection $subscriptions) => $this->manageSubscriptions($subscriptions, $now)); + } + + /** + * Manage the subscriptions. + * + * @param Collection $subscriptions + */ + private function manageSubscriptions(Collection $subscriptions, Carbon $now): void + { + $subscriptions + ->filter(fn (AddressBookSubscription $subscription): bool => $this->isTimeToRunSync($subscription, $now)) + ->each(fn (AddressBookSubscription $subscription) => SynchronizeAddressBooks::dispatch($subscription)->onQueue('high')); + } + + /** + * Test if the last synchronized timestamp is older than the subscription's frequency time. + */ + private function isTimeToRunSync($subscription, Carbon $now): bool + { + return $subscription->last_synchronized_at === null + || $subscription->last_synchronized_at->clone()->addMinutes($subscription->frequency)->lessThan($now); + } +} diff --git a/app/Domains/Contact/DavClient/Services/CreateAddressBookSubscription.php b/app/Domains/Contact/DavClient/Services/CreateAddressBookSubscription.php new file mode 100644 index 00000000000..8c00f52fa1a --- /dev/null +++ b/app/Domains/Contact/DavClient/Services/CreateAddressBookSubscription.php @@ -0,0 +1,85 @@ + 'required|uuid|exists:accounts,id', + 'vault_id' => 'required|uuid|exists:vaults,id', + 'author_id' => 'required|uuid|exists:users,id', + 'base_uri' => 'required|string|url', + 'username' => 'required|string', + 'password' => 'required|string', + ]; + } + + /** + * Get the permissions that apply to the user calling the service. + */ + public function permissions(): array + { + return [ + 'author_must_belong_to_account', + 'author_must_be_in_vault', + 'author_must_be_vault_manager', + 'vault_must_belong_to_account', + ]; + } + + /** + * Add a new Adress Book. + * + * @throws DavClientException + */ + public function execute(array $data): AddressBookSubscription + { + $this->validateRules($data); + + $addressBookData = $this->getAddressBookData($data); + if (! $addressBookData) { + throw new DavClientException(trans('Could not get address book data.')); + } + + return $this->createAddressBook($data, $addressBookData); + } + + private function createAddressBook(array $data, array $addressBookData): AddressBookSubscription + { + return AddressBookSubscription::create([ + 'user_id' => $this->author->id, + 'vault_id' => $this->vault->id, + 'username' => $data['username'], + 'password' => $data['password'], + 'uri' => $addressBookData['uri'], + 'capabilities' => $addressBookData['capabilities'], + ]); + } + + private function getAddressBookData(array $data): ?array + { + $client = $this->getClient($data); + + return app(AddressBookGetter::class) + ->withClient($client) + ->execute(); + } + + private function getClient(array $data): DavClient + { + return app(DavClient::class) + ->setBaseUri($data['base_uri']) + ->setCredentials($data['username'], $data['password']); + } +} diff --git a/app/Domains/Contact/DavClient/Services/SynchronizeAddressBook.php b/app/Domains/Contact/DavClient/Services/SynchronizeAddressBook.php new file mode 100644 index 00000000000..35b438c9917 --- /dev/null +++ b/app/Domains/Contact/DavClient/Services/SynchronizeAddressBook.php @@ -0,0 +1,70 @@ + 'required|uuid|exists:accounts,id', + 'addressbook_subscription_id' => 'required|uuid|exists:addressbook_subscriptions,id', + 'force' => 'nullable|boolean', + ]; + } + + public function execute(array $data): ?string + { + $this->validateRules($data); + + $this->validate($data); + + $force = Arr::get($data, 'force', false); + + return $this->synchronize($force); + } + + private function synchronize(bool $force): ?string + { + if (! $this->subscription->active) { + return null; + } + + try { + return app(AddressBookSynchronizer::class) + ->withSubscription($this->subscription) + ->execute($force); + } catch (ClientException $e) { + Log::error(__CLASS__.' '.__FUNCTION__.': '.$e->getMessage(), [ + 'body' => $e->hasResponse() ? $e->getResponse()->getBody() : null, + $e, + ]); + } + + return null; + } + + private function validate(array $data): void + { + $this->subscription = AddressBookSubscription::findOrFail($data['addressbook_subscription_id']); + + if ($this->subscription->user->account_id !== $data['account_id']) { + throw new ModelNotFoundException(); + } + + // TODO: check if account is limited + } +} diff --git a/app/Domains/Contact/DavClient/Services/UpdateSubscriptionLocalSyncToken.php b/app/Domains/Contact/DavClient/Services/UpdateSubscriptionLocalSyncToken.php new file mode 100644 index 00000000000..f19d3efe3ee --- /dev/null +++ b/app/Domains/Contact/DavClient/Services/UpdateSubscriptionLocalSyncToken.php @@ -0,0 +1,45 @@ + 'required|uuid|exists:addressbook_subscriptions,id', + ]; + } + + public function execute(array $data): void + { + $this->validateRules($data); + + $subscription = AddressBookSubscription::findOrFail($data['addressbook_subscription_id']); + + $this->updateSyncToken($subscription); + } + + /** + * Update the synctoken. + */ + private function updateSyncToken(AddressBookSubscription $subscription): void + { + $token = app(CardDAVBackend::class) + ->withUser($subscription->user) + ->getCurrentSyncToken($subscription->vault_id); + + if ($token !== null) { + $subscription->sync_token_id = $token->id; + $subscription->save(); + } + } +} diff --git a/app/Domains/Contact/DavClient/Services/Utils/AddressBookGetter.php b/app/Domains/Contact/DavClient/Services/Utils/AddressBookGetter.php new file mode 100644 index 00000000000..1e1c97e5826 --- /dev/null +++ b/app/Domains/Contact/DavClient/Services/Utils/AddressBookGetter.php @@ -0,0 +1,281 @@ +getAddressBookData(); + } catch (ClientException $e) { + Log::error(__CLASS__.' '.__FUNCTION__.': '.$e->getMessage(), [$e]); + throw $e; + } + } + + /** + * Get address book data: uri, capabilities, and name. + */ + private function getAddressBookData(): array + { + $uri = $this->getAddressBookBaseUri(); + + $this->client->setBaseUri($uri); + + if (Str::startsWith($uri, 'https://www.googleapis.com')) { + // Google API sucks + $capabilities = [ + 'addressbookMultiget' => true, + 'addressbookQuery' => true, + 'syncCollection' => true, + 'addressData' => [ + 'content-type' => 'text/vcard', + 'version' => '3.0', + ], + ]; + } else { + $capabilities = $this->getCapabilities(); + } + + $name = $this->client->getProperty('{DAV:}displayname'); + + return [ + 'uri' => $uri, + 'capabilities' => $capabilities, + 'name' => $name, + ]; + } + + /** + * Calculate address book base uri. + */ + private function getAddressBookBaseUri(): string + { + $addressBookUrl = null; + $path = parse_url($this->client->path(), PHP_URL_PATH); + + while (! empty($path)) { + try { + $addressBookUrl = $this->getAddressBookUrl($path); + } catch (\Illuminate\Http\Client\RequestException $e) { + // Catch error + } + + if ($addressBookUrl !== null) { + break; + } + + $path = (string) Str::beforeLast($path, '/'); + } + + // If no address book found, try to get the principal + if ($addressBookUrl === null) { + try { + $addressBookUrl = $this->getAddressBookForUri($this->client->path()); + } catch (\Illuminate\Http\Client\RequestException $e) { + // Catch error + } + } + + // If no address book found, try with the service url + if ($addressBookUrl === null) { + $serviceUrl = $this->client->getServiceUrl(); + + if ($serviceUrl !== null) { + try { + $addressBookUrl = $this->getAddressBookForUri($serviceUrl); + } catch (\Illuminate\Http\Client\RequestException $e) { + // Catch error + } + } + } + + if ($addressBookUrl === null) { + throw new DavClientException('No address book found'); + } + + $addressBookUrl = $this->client->path(parse_url($addressBookUrl, PHP_URL_PATH)); + + if (! Str::contains($addressBookUrl, 'https://www.googleapis.com')) { + // Check the OPTIONS of the server + $this->checkOptions(true, $addressBookUrl); + } + + return $addressBookUrl; + } + + /** + * Calculate address book base uri. + */ + private function getAddressBookForUri(string $uri = ''): ?string + { + // Get the principal of this account + $principal = $this->getCurrentUserPrincipal($uri); + + $home = $this->getAddressBookHome($principal); + + // Get the AddressBook of this principal + $addressBook = $this->getAddressBookUrl($home, 1); + + return $addressBook !== null + ? $this->client->path($addressBook) + : null; + } + + /** + * Check options of the server. + * + * @see https://datatracker.ietf.org/doc/html/rfc2518#section-15 + * @see https://datatracker.ietf.org/doc/html/rfc6352#section-6.1 + * + * @throws DavServerNotCompliantException + */ + private function checkOptions(bool $addressbook = false, string $url = ''): void + { + $options = $this->client->options($url); + + if (! in_array('1', $options) || ! in_array('3', $options) || ($addressbook && ! in_array('addressbook', $options))) { + throw new DavServerNotCompliantException('server is not compliant with rfc2518 section 15.1, or rfc6352 section 6.1'); + } + } + + /** + * Get principal name. + * + * @see https://datatracker.ietf.org/doc/html/rfc5397#section-3 + * + * @throws DavServerNotCompliantException + */ + private function getCurrentUserPrincipal(string $uri = ''): string + { + $prop = $this->client->getProperty('{DAV:}current-user-principal', $uri); + + if (is_null($prop) || empty($prop)) { + throw new DavServerNotCompliantException('Server does not support rfc 5397 section 3 (DAV:current-user-principal)'); + } elseif (is_string($prop)) { + return $prop; + } + + return $prop[0]['value']; + } + + /** + * Get addressbook url. + * + * @see https://datatracker.ietf.org/doc/html/rfc6352#section-7.1.1 + * + * @throws DavServerNotCompliantException + */ + private function getAddressBookHome(string $principal): string + { + $prop = $this->client->getProperty('{'.CardDav::NS_CARDDAV.'}addressbook-home-set', $principal); + + if (is_null($prop) || empty($prop)) { + throw new DavServerNotCompliantException('Server does not support rfc 6352 section 7.1.1 (CARD:addressbook-home-set)'); + } elseif (is_string($prop)) { + return $prop; + } + + return $prop[0]['value']; + } + + /** + * Get Url for address book. + */ + private function getAddressBookUrl(string $uri, int $depth = 0): ?string + { + $path = parse_url($uri, PHP_URL_PATH); + + $books = $this->client->propfind('{DAV:}resourcetype', depth: $depth, url: $uri); + + foreach ($books as $book => $properties) { + if ($depth !== 0 && $book === $path) { + continue; + } + + $resources = $depth === 0 + ? $properties + : Arr::get($properties, '{DAV:}resourcetype', null); + + if ($resources->is('{'.CardDav::NS_CARDDAV.'}addressbook')) { + return $depth === 0 ? $uri : $book; + } + } + + return null; + } + + /** + * Get capabilities properties. + */ + private function getCapabilities(): array + { + return $this->getSupportedReportSet() + + + $this->getSupportedAddressData(); + } + + /** + * Get supported-report-set property. + */ + private function getSupportedReportSet(): array + { + $supportedReportSet = $this->client->getSupportedReportSet(); + + $addressbookMultiget = in_array('{'.CardDav::NS_CARDDAV.'}addressbook-multiget', $supportedReportSet); + $addressbookQuery = in_array('{'.CardDav::NS_CARDDAV.'}addressbook-query', $supportedReportSet); + $syncCollection = in_array('{DAV:}sync-collection', $supportedReportSet); + + return [ + 'addressbookMultiget' => $addressbookMultiget, + 'addressbookQuery' => $addressbookQuery, + 'syncCollection' => $syncCollection, + ]; + } + + /** + * Get supported-address-data property. + */ + private function getSupportedAddressData(): array + { + // get the supported card format + $addressData = collect($this->client->getProperty('{'.CardDav::NS_CARDDAV.'}supported-address-data')); + $data = $addressData->firstWhere('attributes.version', '4.0'); + if (! $data) { + $data = $addressData->firstWhere('attributes.version', '3.0'); + } + + if (! $data) { + // It should not happen ! + $data = [ + 'attributes' => [ + 'content-type' => 'text/vcard', + 'version' => '4.0', + ], + ]; + } + + return [ + 'addressData' => [ + 'content-type' => Arr::get($data, 'attributes.content-type'), + 'version' => Arr::get($data, 'attributes.version'), + ], + ]; + } +} diff --git a/app/Domains/Contact/DavClient/Services/Utils/AddressBookSynchronizer.php b/app/Domains/Contact/DavClient/Services/Utils/AddressBookSynchronizer.php new file mode 100644 index 00000000000..d2ee508546f --- /dev/null +++ b/app/Domains/Contact/DavClient/Services/Utils/AddressBookSynchronizer.php @@ -0,0 +1,241 @@ +client = $this->subscription->getClient(); + + $updateSyncToken = new UpdateSubscriptionLocalSyncToken([ + 'addressbook_subscription_id' => $this->subscription->id, + ]); + + $jobs = $force + ? $this->forcesync() + : $this->sync(); + + $batch = Bus::batch($jobs); + + if ($this->subscription->isWayPush) { + $batch = $batch->then(fn () => $updateSyncToken->handle()); + } + + $batch = $batch->allowFailures() + ->onQueue('high') + ->dispatch(); + + return $batch->id; + } + + private function getLocalChanges(): Collection + { + $localChanges = $this->backend()->getChangesForAddressBook($this->subscription->vault_id, (string) $this->subscription->sync_token_id, 1); + + return Collection::wrap($localChanges) + ->map(fn ($changes): Collection => Collection::wrap($changes)); + } + + /** + * Sync the address book. + */ + private function sync(): Collection + { + // Get distant changes to sync + $changes = $this->getDistantChanges(); + + // Get distant contacts + $jobs = collect(); + if ($this->subscription->isWayGet) { + $jobs = app(PrepareJobsContactUpdater::class) + ->withSubscription($this->subscription) + ->execute($changes); + } + + if ($this->subscription->isWayPush) { + // Get changes to sync + $localChanges = $this->getLocalChanges(); + + $jobs = $jobs->merge( + app(PrepareJobsContactPush::class) + ->withSubscription($this->subscription) + ->execute($localChanges, $changes) + ); + } + + return $jobs; + } + + /** + * Sync the address book. + */ + private function forcesync(): Collection + { + // Get current list of contacts + $localContacts = $this->backend()->getObjects($this->subscription->vault_id); + $localUuids = $localContacts->pluck('id'); + + // Get distant changes to sync + $distContacts = $this->getAllContactsEtag(); + + // Get missed contacts + $missed = $distContacts->reject(fn (ContactDto $contact): bool => $localUuids->contains($this->backend()->getUuid($contact->uri))); + + $jobs = collect(); + if ($this->subscription->isWayGet) { + $jobs = app(PrepareJobsContactUpdater::class) + ->withSubscription($this->subscription) + ->execute($missed); + } + + if ($this->subscription->isWayPush) { + // Get changes to sync + $localChanges = $this->getLocalChanges(); + + $jobs = $jobs->merge( + app(PrepareJobsContactPushMissed::class) + ->withSubscription($this->subscription) + ->execute($localChanges, $distContacts, $localContacts) + ); + } + + return $jobs; + } + + /** + * Filter contacts to only return vcards type and new contacts or contacts with matching etags. + */ + private function filterDistantContacts(mixed $contact, string $href): bool + { + // only return vcards + if (! is_array($contact) || ! Str::contains(Arr::get($contact, 'properties.200.{DAV:}getcontenttype'), 'text/vcard')) { + return false; + } + + // only new contact or contact with etag that match + $card = $this->backend()->getCard($this->subscription->vault_id, $href); + + return $card === false || $card['etag'] !== Arr::get($contact, 'properties.200.{DAV:}getetag'); + } + + /** + * Get distant changes to sync. + */ + private function getDistantChanges(): Collection + { + $etags = $this->getDistantEtags(); + $data = collect($etags); + + $updated = $data->filter(fn ($contact, $href): bool => $this->filterDistantContacts($contact, $href)) + ->map(fn (array $contact, string $href): ContactDto => new ContactDto($href, Arr::get($contact, 'properties.200.{DAV:}getetag'))); + + $deleted = $data->filter(fn ($contact): bool => is_array($contact) && $contact['status'] === '404') + ->map(fn (array $contact, string $href): ContactDto => new ContactDeleteDto($href)); + + return $updated->merge($deleted); + } + + /** + * Get all contacts etag. + */ + private function getAllContactsEtag(): Collection + { + if (! $this->hasCapability('addressbookQuery')) { + return collect(); + } + + $query = $this->client->addressbookQuery('{DAV:}getetag'); + + $data = collect($query); + + $updated = $data->filter(fn ($contact): bool => is_array($contact) && $contact['status'] === '200') + ->map(fn (array $contact, string $href): ContactDto => new ContactDto($href, Arr::get($contact, 'properties.200.{DAV:}getetag'))); + $deleted = $data->filter(fn ($contact): bool => is_array($contact) && $contact['status'] === '404') + ->map(fn (array $contact, string $href): ContactDto => new ContactDeleteDto($href)); + + return $updated->merge($deleted); + } + + /** + * Get refreshed etags. + */ + private function getDistantEtags(): array + { + return $this->hasCapability('syncCollection') + + // With sync-collection + ? $this->callSyncCollectionWhenNeeded() + + // With PROPFIND + : $this->client->propFind([ + '{DAV:}getcontenttype', + '{DAV:}getetag', + ], 1); + } + + /** + * Make sync-collection request if sync-token has changed. + */ + private function callSyncCollectionWhenNeeded(): array + { + // get the current distant syncToken + $currentSyncToken = $this->client->getProperty('{DAV:}sync-token'); + + if (($this->subscription->distant_sync_token ?? '') === $currentSyncToken) { + // no change at all + return []; + } + + return $this->callSyncCollection(); + } + + /** + * Make sync-collection request. + */ + private function callSyncCollection(): array + { + $syncToken = $this->subscription->distant_sync_token ?? ''; + + // get sync + try { + $collection = $this->client->syncCollection([ + '{DAV:}getcontenttype', + '{DAV:}getetag', + ], $syncToken); + + // save the new syncToken as current one + if ($newSyncToken = Arr::get($collection, 'synctoken')) { + $this->subscription->distant_sync_token = $newSyncToken; + $this->subscription->save(); + } + } catch (RequestException $e) { + Log::error(__CLASS__.' '.__FUNCTION__.':'.$e->getMessage(), [$e]); + $collection = []; + $this->subscription->distant_sync_token = null; + $this->subscription->save(); + } + + return $collection; + } +} diff --git a/app/Domains/Contact/DavClient/Services/Utils/Dav/DavClient.php b/app/Domains/Contact/DavClient/Services/Utils/Dav/DavClient.php new file mode 100644 index 00000000000..f2a3c3a6b11 --- /dev/null +++ b/app/Domains/Contact/DavClient/Services/Utils/Dav/DavClient.php @@ -0,0 +1,564 @@ +baseUri = $uri; + + return $this; + } + + /** + * Set credentials. + */ + public function setCredentials(string $username, string $password): self + { + $this->username = $username; + $this->password = $password; + + return $this; + } + + /** + * Get current uri. + */ + public function path(?string $path = null): string + { + $uri = GuzzleUtils::uriFor($this->baseUri); + + if (is_null($path) || empty($path)) { + return (string) $uri; + } + + if (Str::startsWith($path, '/')) { + return (string) $uri->withPath($path); + } + + $basePath = Str::finish($uri->getPath(), '/'); + + return (string) $uri->withPath("$basePath$path"); + } + + /** + * Get a PendingRequest. + */ + public function getRequest(): PendingRequest + { + $request = Http::withUserAgent('Monica DavClient '.config('monica.app_version').'/Guzzle'); + + if (App::environment('local')) { + $request = $request->withoutVerifying(); + } + + if ($this->username !== null && $this->password !== null) { + $request = $request->withBasicAuth($this->username, $this->password); + } + + return $request; + } + + /** + * Follow rfc6764 to get carddav service url. + * + * @see https://datatracker.ietf.org/doc/html/rfc6764 + */ + public function getServiceUrl(): ?string + { + // first attempt on relative url + $target = $this->standardServiceUrl('.well-known/carddav'); + + if (! $target) { + // second attempt on absolute root url + $target = $this->standardServiceUrl('/.well-known/carddav'); + } + + if (! $target) { + // third attempt for non standard server, like Google API + $target = $this->nonStandardServiceUrl('/.well-known/carddav'); + } + + if (! $target) { + $serviceUrlQuery = app(ServiceUrlQuery::class) + ->withClient($this); + + // Get service name register (section 9.2) + $target = $serviceUrlQuery->execute('_carddavs._tcp', true, $this->path()); + if ($target === null) { + $target = $serviceUrlQuery->execute('_carddav._tcp', false, $this->path()); + } + } + + return $target; + } + + private function standardServiceUrl(string $url): ?string + { + // Get well-known register (section 9.1) + $response = $this->getRequest() + ->withoutRedirecting() + ->get($this->path($url)); + + $code = $response->status(); + if ($code === 301 || $code === 302) { + return $response->header('Location'); + } + + if ($response->serverError()) { + $response->throw(); + } + + return null; + } + + private function nonStandardServiceUrl(?string $url): ?string + { + $response = $this->getRequest() + ->withoutRedirecting() + ->send('PROPFIND', $this->path($url)); + + $code = $response->status(); + if ($code === 301 || $code === 302) { + return $this->path($response->header('Location')); + } + + return null; + } + + /** + * Do a PROPFIND request. + * + * The list of requested properties must be specified as an array, in clark + * notation. + * + * The returned array will contain a list of filenames as keys, and + * properties as values. + * + * The properties array will contain the list of properties. Only properties + * that are actually returned from the server (without error) will be + * returned, anything else is discarded. + * + * Depth should be either 0 or 1. A depth of 1 will cause a request to be + * made to the server to also return all child resources. + * + * @param array|string $properties + */ + public function propFind(mixed $properties, int $depth = 0, array $options = [], string $url = ''): array + { + $dom = new \DOMDocument('1.0', 'UTF-8'); + $root = self::addElementNS($dom, 'DAV:', 'd:propfind'); + $prop = self::addElement($dom, $root, 'd:prop'); + + $namespaces = ['DAV:' => 'd']; + + self::fetchProperties($dom, $prop, $properties, $namespaces); + + $body = $dom->saveXML(); + + $response = $this->request('PROPFIND', $url, $body, ['Depth' => $depth], $options); + + $result = self::parseMultiStatus($response->body()); + + // If depth was 0, we only return the top item value + if ($depth === 0) { + reset($result); + $result = current($result); + + return Arr::get($result, 'properties.200', []); + } + + return array_map(fn ($statusList) => Arr::get($statusList, 'properties.200', []), $result); + } + + /** + * Run a REPORT {DAV:}sync-collection. + * + * @param array|string $properties + * + * @see https://datatracker.ietf.org/doc/html/rfc6578 + */ + public function syncCollection(mixed $properties, string $syncToken, array $options = [], string $url = ''): array + { + $dom = new \DOMDocument('1.0', 'UTF-8'); + $root = self::addElementNS($dom, 'DAV:', 'd:sync-collection'); + + self::addElement($dom, $root, 'd:sync-token', $syncToken); + self::addElement($dom, $root, 'd:sync-level', '1'); + + $prop = self::addElement($dom, $root, 'd:prop'); + + $namespaces = ['DAV:' => 'd']; + + self::fetchProperties($dom, $prop, $properties, $namespaces); + + $body = $dom->saveXML(); + + $response = $this->request('REPORT', $url, $body, ['Depth' => '0'], $options); + + return self::parseMultiStatus($response->body()); + } + + /** + * Run a REPORT card:addressbook-multiget. + * + * @param array|string $properties + * + * @see https://datatracker.ietf.org/doc/html/rfc6352#section-8.7 + */ + public function addressbookMultiget(mixed $properties, iterable $contacts, array $options = [], string $url = ''): array + { + $dom = new \DOMDocument('1.0', 'UTF-8'); + $root = self::addElementNS($dom, CardDav::NS_CARDDAV, 'card:addressbook-multiget'); + $dom->createAttributeNS('DAV:', 'd:e'); + + $prop = self::addElement($dom, $root, 'd:prop'); + + $namespaces = [ + 'DAV:' => 'd', + CardDav::NS_CARDDAV => 'card', + ]; + + self::fetchProperties($dom, $prop, $properties, $namespaces); + + foreach ($contacts as $contact) { + self::addElement($dom, $root, 'd:href', $contact); + } + + $body = $dom->saveXML(); + + $response = $this->request('REPORT', $url, $body, ['Depth' => '1'], $options); + + return self::parseMultiStatus($response->body()); + } + + /** + * Run a REPORT card:addressbook-query. + * + * @param array|string $properties + * + * @see https://datatracker.ietf.org/doc/html/rfc6352#section-8.6 + */ + public function addressbookQuery(mixed $properties, array $options = [], string $url = ''): array + { + $dom = new \DOMDocument('1.0', 'UTF-8'); + $root = self::addElementNS($dom, CardDav::NS_CARDDAV, 'card:addressbook-query'); + $dom->createAttributeNS('DAV:', 'd:e'); + + $prop = self::addElement($dom, $root, 'd:prop'); + + $namespaces = [ + 'DAV:' => 'd', + CardDav::NS_CARDDAV => 'card', + ]; + + self::fetchProperties($dom, $prop, $properties, $namespaces); + + $body = $dom->saveXML(); + + $response = $this->request('REPORT', $url, $body, ['Depth' => '1'], $options); + + return self::parseMultiStatus($response->body()); + } + + /** + * Add properties to the prop object. + * + * Properties must follow: + * - for a simple value + * [ + * '{namespace}value', + * ] + * + * - for a more complex value element + * [ + * [ + * 'name' => '{namespace}value', + * 'value' => 'content element', + * 'attributes' => ['name' => 'value', ...], + * ] + * ] + * + * @param array|string $properties + */ + private static function fetchProperties(\DOMDocument $dom, \DOMNode $prop, mixed $properties, array $namespaces): void + { + if (is_string($properties)) { + $properties = [$properties]; + } + + foreach ($properties as $property) { + if (is_array($property)) { + $propertyExt = $property; + $property = $propertyExt['name']; + } + [$namespace, $elementName] = Service::parseClarkNotation($property); + + $ns = Arr::get($namespaces, $namespace); + $element = $ns !== null + ? $dom->createElement("$ns:$elementName") + : $dom->createElementNS($namespace, "x:$elementName"); + + $child = $prop->appendChild($element); + + if (isset($propertyExt)) { + if (($nodeValue = Arr::get($propertyExt, 'value')) !== null) { + $child->nodeValue = $nodeValue; + } + if (($attributes = Arr::get($propertyExt, 'attributes')) !== null) { + foreach ($attributes as $name => $property) { + $child->appendChild($dom->createAttribute($name))->nodeValue = $property; + } + } + } + } + } + + /** + * Get a WebDAV property. + * + * @return array|string|null + */ + public function getProperty(string $property, string $url = '', array $options = []): mixed + { + $properties = $this->propfind($property, 0, $options, $url); + + if (($prop = Arr::get($properties, $property)) && is_array($prop)) { + $value = $prop[0]; + + if (is_string($value)) { + $prop = $value; + } + } + + return $prop; + } + + /** + * Get a {DAV:}supported-report-set propfind. + * + * @see https://datatracker.ietf.org/doc/html/rfc3253#section-3.1.5 + */ + public function getSupportedReportSet(array $options = []): array + { + $propName = '{DAV:}supported-report-set'; + + $properties = $this->propFind($propName, 0, $options); + + if (($prop = Arr::get($properties, $propName)) && is_array($prop)) { + $prop = array_map(fn ($supportedReport) => $this->iterateOver($supportedReport, '{DAV:}supported-report', fn ($report) => $this->iterateOver($report, '{DAV:}report', fn ($type) => Arr::get($type, 'name') + ) + ), + $prop); + } + + return $prop; + } + + /** + * Iterate over the list, if it contains an item name that match with $name. + */ + private function iterateOver(array $list, string $name, callable $callback): mixed + { + if (Arr::get($list, 'name') === $name + && ($value = Arr::get($list, 'value'))) { + foreach ($value as $item) { + return $callback($item); + } + } + + return null; + } + + /** + * Updates a list of properties on the server. + * + * The list of properties must have clark-notation properties for the keys, + * and the actual (string) value for the value. If the value is null, an + * attempt is made to delete the property. + * + * @see https://datatracker.ietf.org/doc/html/rfc2518#section-12.13 + */ + public function propPatch(array $properties, string $url = ''): bool + { + $propPatch = new PropPatch(); + $propPatch->properties = $properties; + $body = (new Service())->write( + '{DAV:}propertyupdate', + $propPatch + ); + + $response = $this->request('PROPPATCH', $url, $body); + + if ($response->status() === 207) { + // If it's a 207, the request could still have failed, but the + // information is hidden in the response body. + $result = self::parseMultiStatus($response->body()); + + $errorProperties = []; + foreach ($result as $statusList) { + foreach ($statusList['properties'] as $status => $properties) { + if ($status >= 400) { + foreach ($properties as $propName => $propValue) { + $errorProperties[] = $propName.' ('.$status.')'; + } + } + } + } + if (! empty($errorProperties)) { + throw new DavClientException('PROPPATCH failed. The following properties errored: '.implode(', ', $errorProperties)); + } + } + + return true; + } + + /** + * Performs an HTTP options request. + * + * This method returns all the features from the 'DAV:' header as an array. + * If there was no DAV header, or no contents this method will return an + * empty array. + */ + public function options(string $url = ''): array + { + $response = $this->request('OPTIONS', $url); + + $dav = $response->header('Dav'); + if (empty($dav)) { + return []; + } + $davs = explode(', ', $dav); + + return array_map(fn ($header) => trim($header), $davs); + } + + /** + * Performs an actual HTTP request, and returns the result. + * + * @param string|null|resource|\Psr\Http\Message\StreamInterface $body + * + * @throws \Illuminate\Http\Client\RequestException + */ + public function request(string $method, string $url = '', mixed $body = null, array $headers = [], array $options = []): Response + { + $request = $this->getRequest() + ->withHeaders($headers); + + if ($body !== null) { + $request = $request->withBody($body, 'application/xml; charset=utf-8'); + } + + $url = Str::startsWith($url, 'http') ? $url : $this->path($url); + + Log::debug(__CLASS__.' '.__FUNCTION__.'[request]: '.$method.' '.$url, [ + 'body' => $body, + 'headers' => $headers, + 'options' => $options, + ]); + + $response = $request + ->send($method, $url, $options) + ->throw(function (Response $response) use ($method, $url) { + Log::debug(__CLASS__.' '.__FUNCTION__.'[error]: '.$method.' '.$url.' '.$response->status(), [ + 'body' => $response->body(), + 'headers' => $response->headers(), + ]); + }); + + Log::debug(__CLASS__.' '.__FUNCTION__.'[response]: '.$method.' '.$url.' '.$response->status(), [ + 'body' => $response->body(), + 'headers' => $response->headers(), + ]); + + return $response; + } + + /** + * Parses a WebDAV multistatus response body. + * + * This method returns an array with the following structure + * + * [ + * 'url/to/resource' => [ + * 'properties' => [ + * '200' => [ + * '{DAV:}property1' => 'value1', + * '{DAV:}property2' => 'value2', + * ], + * '404' => [ + * '{DAV:}property1' => null, + * '{DAV:}property2' => null, + * ], + * ], + * 'status' => 200, + * ], + * 'url/to/resource2' => [ + * .. etc .. + * ] + * ] + * + * @see https://datatracker.ietf.org/doc/html/rfc4918#section-9.2.1 + */ + private static function parseMultiStatus(string $body): array + { + $multistatus = (new Service()) + ->expect('{DAV:}multistatus', $body); + + $result = []; + + if (is_object($multistatus)) { + foreach ($multistatus->getResponses() as $response) { + $result[$response->getHref()] = [ + 'properties' => $response->getResponseProperties(), + 'status' => $response->getHttpStatus() ?? '200', + ]; + } + + $synctoken = $multistatus->getSyncToken(); + if (! empty($synctoken)) { + $result['synctoken'] = $synctoken; + } + } + + return $result; + } + + /** + * Create a new Element Namespace and add it as document's child. + */ + private static function addElementNS(\DOMDocument $dom, ?string $namespace, string $qualifiedName): \DOMNode + { + return $dom->appendChild($dom->createElementNS($namespace, $qualifiedName)); + } + + /** + * Create a new Element and add it as root's child. + */ + private static function addElement(\DOMDocument $dom, \DOMNode $root, string $name, ?string $value = null): \DOMNode + { + return $root->appendChild($dom->createElement($name, $value)); + } +} diff --git a/app/Domains/Contact/DavClient/Services/Utils/Dav/DavClientException.php b/app/Domains/Contact/DavClient/Services/Utils/Dav/DavClientException.php new file mode 100644 index 00000000000..1aac9a0f7a7 --- /dev/null +++ b/app/Domains/Contact/DavClient/Services/Utils/Dav/DavClientException.php @@ -0,0 +1,9 @@ +parseUrl($baseUri)) !== null) { + $entries = Http::getDnsRecord($name.'.'.$host, DNS_SRV); + + if (optional($entries)->count()) { + $entries = $entries + ->groupBy('pri') + ->sortKeys() + ->sortByDesc('weight') + ->flatten(1); + + foreach ($entries as $entry) { + try { + return $this->getUri($entry, $https); + } catch (RequestException $e) { + // if any exception occurs, it will try the next entry. + } + } + } + } + + return null; + } + + private function parseUrl(string $baseUri): ?string + { + try { + return parse_url($baseUri, PHP_URL_HOST); + } catch (UrlException $e) { + return null; + } + } + + /** + * Get uri from entry. + * + * @throws \Illuminate\Http\Client\RequestException + */ + private function getUri(array $entry, bool $https): string + { + $uri = (new Uri()) + ->withScheme($https ? 'https' : 'http') + ->withPort($entry['port']) + ->withHost($entry['target']); + + // Test connection + $this->client->request('GET', $uri); + + return (string) $uri; + } +} diff --git a/app/Domains/Contact/DavClient/Services/Utils/Model/ContactDeleteDto.php b/app/Domains/Contact/DavClient/Services/Utils/Model/ContactDeleteDto.php new file mode 100644 index 00000000000..679b3fdc178 --- /dev/null +++ b/app/Domains/Contact/DavClient/Services/Utils/Model/ContactDeleteDto.php @@ -0,0 +1,7 @@ +> $localChanges + * @param Collection|null $changes + */ + public function execute(Collection $localChanges, ?Collection $changes = null): Collection + { + $modified = $this->preparePushChangedContacts($localChanges->get('modified', collect()), $changes ?? collect()); + $added = $this->preparePushAddedContacts($localChanges->get('added', collect())); + $deleted = $this->prepareDeletedContacts($localChanges->get('deleted', collect())); + + return $modified + ->merge($added) + ->merge($deleted) + ->filter(); + } + + /** + * Get list of requests to push new contacts. + * + * @param Collection $contacts + */ + private function preparePushAddedContacts(Collection $contacts): Collection + { + // All added contact must be pushed + return $this->filterContacts($contacts) + ->map(function (string $uri): ?PushVCard { + $card = $this->backend()->getCard($this->subscription->vault_id, $uri); + + return $card === false + ? null + : new PushVCard($this->subscription, + $uri, + $card['distant_etag'], + $card['carddata'], + $card['contact_id'] + ); + }); + } + + /** + * Get list of requests to delete contacts. + * + * @param Collection $contacts + */ + private function prepareDeletedContacts(Collection $contacts): Collection + { + // All removed contact must be deleted + return $this->filterContacts($contacts) + ->map(fn (string $uri): DeleteVCard => new DeleteVCard($this->subscription, $uri)); + } + + /** + * Get list of requests to push modified contacts. + * + * @param Collection $contacts + * @param Collection $changes + */ + private function preparePushChangedContacts(Collection $contacts, Collection $changes): Collection + { + $refreshIds = $changes->map(fn (ContactDto $contact): string => $this->backend()->getUuid($contact->uri)); + + // We don't push contact that have just been pulled + return $this->filterContacts($contacts) + ->reject(fn (string $uri): bool => $refreshIds->contains($this->backend()->getUuid($uri))) + ->map(function (string $uri): ?PushVCard { + $card = $this->backend()->getCard($this->subscription->vault_id, $uri); + + if ($card === false) { + return null; + } + + return new PushVCard($this->subscription, + $uri, + $card['distant_etag'], + $card['carddata'], + $card['contact_id'], + $card['distant_etag'] !== null ? PushVCard::MODE_MATCH_ETAG : PushVCard::MODE_MATCH_ANY + ); + }); + } + + /** + * Filter list of contacts. + * + * @param Collection $contacts + */ + private function filterContacts(Collection $contacts): Collection + { + return $contacts->reject(fn (string $uri): bool => $this->getContactInVault() === $this->backend()->getUuid($uri) + ); + } + + private function getContactInVault(): ?string + { + $entry = $this->subscription->user->vaults() + ->wherePivot('vault_id', $this->subscription->vault_id) + ->first(); + + $pivot = optional($entry)->pivot; + + return optional($pivot)->contact_id; + } +} diff --git a/app/Domains/Contact/DavClient/Services/Utils/PrepareJobsContactPushMissed.php b/app/Domains/Contact/DavClient/Services/Utils/PrepareJobsContactPushMissed.php new file mode 100644 index 00000000000..77ec6c9969d --- /dev/null +++ b/app/Domains/Contact/DavClient/Services/Utils/PrepareJobsContactPushMissed.php @@ -0,0 +1,61 @@ +> $localChanges + * @param Collection $distContacts + * @param Collection $localContacts + */ + public function execute(Collection $localChanges, Collection $distContacts, Collection $localContacts): Collection + { + $changes = app(PrepareJobsContactPush::class) + ->withSubscription($this->subscription) + ->execute($localChanges); + + $missings = $this->preparePushMissedContacts($localChanges->get('added', collect()), $distContacts, $localContacts); + + return $changes + ->merge($missings); + } + + /** + * Get list of requests of missed contacts. + * + * @param Collection $added + * @param Collection $distContacts + * @param Collection $localContacts + */ + private function preparePushMissedContacts(Collection $added, Collection $distContacts, Collection $localContacts): Collection + { + $distUuids = $distContacts->map(fn (ContactDto $contact): string => $this->backend()->getUuid($contact->uri)); + $addedUuids = $added->map(fn (string $uri): string => $this->backend()->getUuid($uri)); + + return $localContacts + ->reject(fn (VCardResource $resource): bool => $distUuids->contains($resource->id) || $addedUuids->contains($resource->id) + ) + ->map(function (VCardResource $resource): PushVCard { + $card = $this->backend()->prepareCard($resource); + + return new PushVCard($this->subscription, + $card['uri'], + $resource->distant_etag, + $card['carddata'], + $resource->id, + $resource->distant_etag !== null ? PushVCard::MODE_MATCH_ANY : PushVCard::MODE_MATCH_NONE + ); + }); + } +} diff --git a/app/Domains/Contact/DavClient/Services/Utils/PrepareJobsContactUpdater.php b/app/Domains/Contact/DavClient/Services/Utils/PrepareJobsContactUpdater.php new file mode 100644 index 00000000000..543a34fe1c3 --- /dev/null +++ b/app/Domains/Contact/DavClient/Services/Utils/PrepareJobsContactUpdater.php @@ -0,0 +1,65 @@ + $refresh + */ + public function execute(Collection $refresh): Collection + { + return $this->hasCapability('addressbookMultiget') + ? $this->refreshMultigetContacts($refresh) + : $this->refreshSimpleGetContacts($refresh); + } + + /** + * Get contacts data with addressbook-multiget request. + * + * @param Collection $refresh + */ + private function refreshMultigetContacts(Collection $refresh): Collection + { + $refresh = $refresh->groupBy(fn ($item): string => $item instanceof ContactDeleteDto ? 'deleted' : 'updated') + ->map(fn (Collection $items): array => $items->pluck('uri')->toArray()); + + $jobs = collect(); + if (($updated = $refresh->get('updated')) !== null) { + $jobs->add(new GetMultipleVCard($this->subscription, $updated)); + } + if (($deleted = $refresh->get('deleted')) !== null) { + $jobs->add(new DeleteMultipleVCard($this->subscription, $deleted)); + } + + return $jobs; + } + + /** + * Get contacts data with request. + * + * @param Collection $refresh + */ + private function refreshSimpleGetContacts(Collection $refresh): Collection + { + return $refresh + ->map(fn (ContactDto $contact) => $contact instanceof ContactDeleteDto + ? new DeleteVCard($this->subscription, $contact->uri) + : new GetVCard($this->subscription, $contact) + ); + } +} diff --git a/app/Domains/Contact/DavClient/Services/Utils/Traits/HasCapability.php b/app/Domains/Contact/DavClient/Services/Utils/Traits/HasCapability.php new file mode 100644 index 00000000000..488bf16729c --- /dev/null +++ b/app/Domains/Contact/DavClient/Services/Utils/Traits/HasCapability.php @@ -0,0 +1,19 @@ +subscription; + + return Arr::get($subscription->capabilities, $capability, false); + } +} diff --git a/app/Domains/Contact/DavClient/Services/Utils/Traits/HasClient.php b/app/Domains/Contact/DavClient/Services/Utils/Traits/HasClient.php new file mode 100644 index 00000000000..40836703e67 --- /dev/null +++ b/app/Domains/Contact/DavClient/Services/Utils/Traits/HasClient.php @@ -0,0 +1,21 @@ +client = $client; + + return $this; + } +} diff --git a/app/Domains/Contact/DavClient/Services/Utils/Traits/HasSubscription.php b/app/Domains/Contact/DavClient/Services/Utils/Traits/HasSubscription.php new file mode 100644 index 00000000000..a8a2699f536 --- /dev/null +++ b/app/Domains/Contact/DavClient/Services/Utils/Traits/HasSubscription.php @@ -0,0 +1,32 @@ +subscription = $subscription; + + return $this; + } + + protected ?CardDAVBackend $backend = null; + + /** + * Get carddav backend. + */ + protected function backend(): CardDAVBackend + { + return $this->backend ?? $this->backend = app(CardDAVBackend::class)->withUser($this->subscription->user); + } +} diff --git a/app/Domains/Contact/ManageGroups/Web/Controllers/ContactModuleGroupController.php b/app/Domains/Contact/ManageGroups/Web/Controllers/ContactModuleGroupController.php index 0b6f3dbf2e9..6dc954afed3 100644 --- a/app/Domains/Contact/ManageGroups/Web/Controllers/ContactModuleGroupController.php +++ b/app/Domains/Contact/ManageGroups/Web/Controllers/ContactModuleGroupController.php @@ -72,6 +72,7 @@ public function destroy(Request $request, string $vaultId, string $contactId, in ]; RemoveContactFromGroup::dispatch($data)->onQueue('high'); + $contact = Contact::find($contactId); $group = Group::find($groupId); diff --git a/app/Domains/Contact/ManageRelationships/Web/ViewHelpers/ModuleFamilySummaryViewHelper.php b/app/Domains/Contact/ManageRelationships/Web/ViewHelpers/ModuleFamilySummaryViewHelper.php index b917456041a..f280f7384ec 100644 --- a/app/Domains/Contact/ManageRelationships/Web/ViewHelpers/ModuleFamilySummaryViewHelper.php +++ b/app/Domains/Contact/ManageRelationships/Web/ViewHelpers/ModuleFamilySummaryViewHelper.php @@ -20,8 +20,7 @@ class ModuleFamilySummaryViewHelper public static function data(Contact $contact, User $user): array { $loveRelationshipType = $contact->vault->account->relationshipGroupTypes() - ->where('type', RelationshipGroupType::TYPE_LOVE) - ->first(); + ->firstWhere('type', RelationshipGroupType::TYPE_LOVE); $loveRelationships = $loveRelationshipType->types() ->where('type', RelationshipType::TYPE_LOVE) @@ -30,8 +29,7 @@ public static function data(Contact $contact, User $user): array $loveRelationshipsCollection = self::getRelations($loveRelationships, $contact); $familyRelationshipType = $contact->vault->account->relationshipGroupTypes() - ->where('type', RelationshipGroupType::TYPE_FAMILY) - ->first(); + ->firstWhere('type', RelationshipGroupType::TYPE_FAMILY); $familyRelationships = $familyRelationshipType->types() ->where('type', RelationshipType::TYPE_CHILD) diff --git a/app/Helpers/ContactImportantDateHelper.php b/app/Helpers/ContactImportantDateHelper.php index 1bb3dc7d9d1..1aabdf28c11 100644 --- a/app/Helpers/ContactImportantDateHelper.php +++ b/app/Helpers/ContactImportantDateHelper.php @@ -13,11 +13,10 @@ class ContactImportantDateHelper public static function getImportantDateType(string $type, string $vaultId): ?ContactImportantDateType { return Cache::store('array')->remember("ImportantDateType:{$vaultId}:{$type}", 5, - fn () => ContactImportantDateType::where([ + fn () => ContactImportantDateType::firstWhere([ 'vault_id' => $vaultId, 'internal_type' => $type, ]) - ->first() ); } } diff --git a/app/Models/AddressBookSubscription.php b/app/Models/AddressBookSubscription.php new file mode 100644 index 00000000000..d5392e63723 --- /dev/null +++ b/app/Models/AddressBookSubscription.php @@ -0,0 +1,174 @@ + + */ + protected $fillable = [ + 'user_id', + 'vault_id', + 'uri', + 'capabilities', + 'username', + 'password', + 'sync_way', + 'distant_sync_token', + 'frequency', + 'last_synchronized_at', + 'active', + ]; + + /** + * The attributes that aren't mass assignable. + * + * @var array|bool + */ + protected $guarded = ['id']; + + /** + * The attributes that should be cast to native types. + * + * @var array + */ + protected $casts = [ + 'user_id' => 'string', + 'vault_id' => 'string', + 'last_synchronized_at' => 'datetime', + 'active' => 'boolean', + 'capabilities' => 'array', + ]; + + /** + * Eager load account with every contact. + * + * @var array + */ + protected $with = [ + 'user', + ]; + + /** + * Get the account record associated with the subscription. + */ + public function account(): BelongsTo + { + return $this->belongsTo(Account::class); + } + + /** + * Get the user record associated with the subscription. + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Get the vault record associated with the subscription. + */ + public function vault(): BelongsTo + { + return $this->belongsTo(Vault::class); + } + + /** + * Get the local synctoken. + */ + public function localSyncToken(): BelongsTo + { + return $this->belongsTo(SyncToken::class); + } + + /** + * Get password. + * + * @return Attribute + */ + public function password(): Attribute + { + return Attribute::make( + get: fn (?string $value) => decrypt($value, true), + set: fn (string $value) => encrypt($value) + ); + } + + /** + * Get synchronization way. + * + * @return Attribute + */ + public function isWayPush(): Attribute + { + return Attribute::make( + get: fn (?bool $value, array $attributes) => ($attributes['sync_way'] & self::WAY_PUSH) === self::WAY_PUSH, + ); + } + + /** + * Get synchronization way. + * + * @return Attribute + */ + public function isWayGet(): Attribute + { + return Attribute::make( + get: fn (?bool $value, array $attributes) => ($attributes['sync_way'] & self::WAY_GET) === self::WAY_GET, + ); + } + + /** + * Get synchronization way. + * + * @return Attribute + */ + public function isWayBoth(): Attribute + { + return Attribute::make( + get: fn (?bool $value, array $attributes) => ($attributes['syncWay'] & self::WAY_BOTH) === self::WAY_BOTH, + ); + } + + /** + * Scope a query to only include active subscriptions. + */ + public function scopeActive(Builder $query): Builder + { + return $query->where('active', 1); + } + + /** + * Get a new client. + */ + public function getClient(): DavClient + { + return app(DavClient::class) + ->setBaseUri($this->uri) + ->setCredentials($this->username, $this->password); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 9fe0eea4414..df6091bf750 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -10,6 +10,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Collection; use Illuminate\Support\Facades\App; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; use Illuminate\Support\Str; @@ -62,6 +63,20 @@ public function register() }); } + if (! Http::hasMacro('getDnsRecord')) { + Http::macro('getDnsRecord', function (string $hostname, int $type): ?Collection { + try { + if (($entries = \Safe\dns_get_record($hostname, $type)) !== null) { + return collect($entries); + } + } catch (\Safe\Exceptions\NetworkException) { + // ignore + } + + return null; + }); + } + if (! Collection::hasMacro('sortByCollator')) { Collection::macro('sortByCollator', function (callable|string $callback) { /** @var Collection */ diff --git a/composer.json b/composer.json index 1c339c5285f..8588187fcfd 100644 --- a/composer.json +++ b/composer.json @@ -39,6 +39,7 @@ "socialiteproviders/linkedin": "^4.2", "socialiteproviders/microsoft-azure": "^5.1", "socialiteproviders/twitter": "^4.1", + "thecodingmachine/safe": "^2.5", "tightenco/ziggy": "1.6.0", "uploadcare/uploadcare-php": "^4.1" }, diff --git a/composer.lock b/composer.lock index 996070c3e1a..e200b78fdbc 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "11d4daea54023a8e94b3859fba2cfde5", + "content-hash": "6323d7ef6898c6866a585668036ae8c4", "packages": [ { "name": "asbiin/laravel-webauthn", @@ -14612,9 +14612,10 @@ }, "conflict": { "3f/pygmentize": "<1.2", - "admidio/admidio": "<4.2.9", + "admidio/admidio": "<4.2.11", "adodb/adodb-php": "<=5.20.20|>=5.21,<=5.21.3", - "aheinze/cockpit": "<=2.2.1", + "aheinze/cockpit": "<2.2", + "aimeos/aimeos-typo3": "<19.10.12|>=20,<20.10.5", "akaunting/akaunting": "<2.1.13", "akeneo/pim-community-dev": "<5.0.119|>=6,<6.0.53", "alextselegidis/easyappointments": "<1.5", @@ -14631,12 +14632,17 @@ "appwrite/server-ce": "<=1.2.1", "arc/web": "<3", "area17/twill": "<1.2.5|>=2,<2.5.3", - "asymmetricrypt/asymmetricrypt": ">=0,<9.9.99", + "artesaos/seotools": "<0.17.2", + "asymmetricrypt/asymmetricrypt": "<9.9.99", + "athlon1600/php-proxy": "<=5.1", + "athlon1600/php-proxy-app": "<=3", + "austintoddj/canvas": "<=3.4.2", "automad/automad": "<1.8", "awesome-support/awesome-support": "<=6.0.7", "aws/aws-sdk-php": ">=3,<3.2.1", "azuracast/azuracast": "<0.18.3", "backdrop/backdrop": "<1.24.2", + "backpack/crud": "<3.4.9", "badaso/core": "<2.7", "bagisto/bagisto": "<0.1.5", "barrelstrength/sprout-base-email": "<1.2.7", @@ -14646,7 +14652,7 @@ "baserproject/basercms": "<4.7.5", "bassjobsen/bootstrap-3-typeahead": ">4.0.2", "bigfork/silverstripe-form-capture": ">=3,<3.1.1", - "billz/raspap-webgui": "<2.8.9", + "billz/raspap-webgui": "<=2.9.2", "bk2k/bootstrap-package": ">=7.1,<7.1.2|>=8,<8.0.8|>=9,<9.0.4|>=9.1,<9.1.3|>=10,<10.0.10|>=11,<11.0.3", "bmarshall511/wordpress_zero_spam": "<5.2.13", "bolt/bolt": "<3.7.2", @@ -14660,40 +14666,44 @@ "bugsnag/bugsnag-laravel": ">=2,<2.0.2", "bytefury/crater": "<6.0.2", "cachethq/cachet": "<2.5.1", - "cakephp/cakephp": "<3.10.3|>=4,<4.0.10|>=4.2,<4.2.12|>=4.3,<4.3.11|>=4.4,<4.4.10|= 1.3.7|>=4.1,<4.1.4", + "cakephp/cakephp": "<3.10.3|>=4,<4.0.10|>=4.1,<4.1.4|>=4.2,<4.2.12|>=4.3,<4.3.11|>=4.4,<4.4.10", "cakephp/database": ">=4.2,<4.2.12|>=4.3,<4.3.11|>=4.4,<4.4.10", "cardgate/magento2": "<2.0.33", + "cardgate/woocommerce": "<=3.1.15", "cart2quote/module-quotation": ">=4.1.6,<=4.4.5|>=5,<5.4.4", "cartalyst/sentry": "<=2.1.6", "catfan/medoo": "<1.7.5", - "centreon/centreon": "<22.10-beta.1", + "centreon/centreon": "<22.10.0.0-beta1", "cesnet/simplesamlphp-module-proxystatistics": "<3.1", - "cockpit-hq/cockpit": "<2.4.1", + "chriskacerguis/codeigniter-restserver": "<=2.7.1", + "cockpit-hq/cockpit": "<=2.6.3", "codeception/codeception": "<3.1.3|>=4,<4.1.22", - "codeigniter/framework": "<=3.0.6", + "codeigniter/framework": "<3.1.9", "codeigniter4/framework": "<4.3.5", - "codeigniter4/shield": "<1-beta.4|= 1.0.0-beta", + "codeigniter4/shield": "<1.0.0.0-beta4", "codiad/codiad": "<=2.8.4", - "composer/composer": "<1.10.26|>=2-alpha.1,<2.2.12|>=2.3,<2.3.5", - "concrete5/concrete5": "<9.2|>= 9.0.0RC1, < 9.1.3", + "composer/composer": "<1.10.26|>=2,<2.2.12|>=2.3,<2.3.5", + "concrete5/concrete5": "<9.2", "concrete5/core": "<8.5.8|>=9,<9.1", "contao-components/mediaelement": ">=2.14.2,<2.21.1", "contao/contao": ">=4,<4.4.56|>=4.5,<4.9.40|>=4.10,<4.11.7|>=4.13,<4.13.21|>=5.1,<5.1.4", "contao/core": ">=2,<3.5.39", - "contao/core-bundle": "<4.9.40|>=4.10,<4.11.7|>=4.13,<4.13.21|>=5.1,<5.1.4|= 4.10.0", + "contao/core-bundle": "<4.9.42|>=4.10,<4.13.28|>=5,<5.1.10", "contao/listing-bundle": ">=4,<4.4.8", "contao/managed-edition": "<=1.5", - "craftcms/cms": "<=4.4.9|>= 4.0.0-RC1, < 4.4.12|>= 4.0.0-RC1, <= 4.4.5|>= 4.0.0-RC1, <= 4.4.6|>= 4.0.0-RC1, < 4.4.6|>= 4.0.0-RC1, < 4.3.7|>= 4.0.0-RC1, < 4.2.1", - "croogo/croogo": "<3.0.7", + "cosenary/instagram": "<=2.3", + "craftcms/cms": "<=4.4.14", + "croogo/croogo": "<4", "cuyz/valinor": "<0.12", "czproject/git-php": "<4.0.3", "darylldoyle/safe-svg": "<1.9.10", "datadog/dd-trace": ">=0.30,<0.30.2", "david-garcia/phpwhois": "<=4.3.1", "dbrisinajumi/d2files": "<1", - "dcat/laravel-admin": "<=2.1.3-beta", + "dcat/laravel-admin": "<=2.1.3.0-beta", "derhansen/fe_change_pwd": "<2.0.5|>=3,<3.0.3", "derhansen/sf_event_mgt": "<4.3.1|>=5,<5.1.1", + "desperado/xml-bundle": "<=0.1.7", "directmailteam/direct-mail": "<5.2.4", "doctrine/annotations": ">=1,<1.2.7", "doctrine/cache": ">=1,<1.3.2|>=1.4,<1.4.2", @@ -14704,14 +14714,14 @@ "doctrine/mongodb-odm": ">=1,<1.0.2", "doctrine/mongodb-odm-bundle": ">=2,<3.0.1", "doctrine/orm": ">=2,<2.4.8|>=2.5,<2.5.1|>=2.8.3,<2.8.4", - "dolibarr/dolibarr": "<17.0.1|= 12.0.5|>= 3.3.beta1, < 13.0.2", - "dompdf/dompdf": "<2.0.2|= 2.0.2", + "dolibarr/dolibarr": "<17.0.1", + "dompdf/dompdf": "<2.0.2|==2.0.2", "drupal/core": ">=7,<7.96|>=8,<9.4.14|>=9.5,<9.5.8|>=10,<10.0.8", - "drupal/drupal": ">=7,<7.80|>=8,<8.9.16|>=9,<9.1.12|>=9.2,<9.2.4", + "drupal/drupal": ">=6,<6.38|>=7,<7.80|>=8,<8.9.16|>=9,<9.1.12|>=9.2,<9.2.4", "dweeves/magmi": "<=0.7.24", "ecodev/newsletter": "<=4", "ectouch/ectouch": "<=2.7.2", - "elefant/cms": "<1.3.13", + "elefant/cms": "<2.0.7", "elgg/elgg": "<3.3.24|>=4,<4.0.5", "encore/laravel-admin": "<=1.8.19", "endroid/qr-code-bundle": "<3.4.2", @@ -14719,26 +14729,26 @@ "erusev/parsedown": "<1.7.2", "ether/logs": "<3.0.4", "exceedone/exment": "<4.4.3|>=5,<5.0.3", - "exceedone/laravel-admin": "= 3.0.0|<2.2.3", - "ezsystems/demobundle": ">=5.4,<5.4.6.1", + "exceedone/laravel-admin": "<2.2.3|==3", + "ezsystems/demobundle": ">=5.4,<5.4.6.1-dev", "ezsystems/ez-support-tools": ">=2.2,<2.2.3", - "ezsystems/ezdemo-ls-extension": ">=5.4,<5.4.2.1", - "ezsystems/ezfind-ls": ">=5.3,<5.3.6.1|>=5.4,<5.4.11.1|>=2017.12,<2017.12.0.1", + "ezsystems/ezdemo-ls-extension": ">=5.4,<5.4.2.1-dev", + "ezsystems/ezfind-ls": ">=5.3,<5.3.6.1-dev|>=5.4,<5.4.11.1-dev|>=2017.12,<2017.12.0.1-dev", "ezsystems/ezplatform": "<=1.13.6|>=2,<=2.5.24", "ezsystems/ezplatform-admin-ui": ">=1.3,<1.3.5|>=1.4,<1.4.6|>=1.5,<1.5.29|>=2.3,<2.3.26", "ezsystems/ezplatform-admin-ui-assets": ">=4,<4.2.1|>=5,<5.0.1|>=5.1,<5.1.1", - "ezsystems/ezplatform-graphql": ">=1-rc.1,<1.0.13|>=2-beta.1,<2.3.12", - "ezsystems/ezplatform-kernel": "<1.2.5.1|>=1.3,<1.3.26", + "ezsystems/ezplatform-graphql": ">=1.0.0.0-RC1-dev,<1.0.13|>=2.0.0.0-beta1,<2.3.12", + "ezsystems/ezplatform-kernel": "<1.2.5.1-dev|>=1.3,<1.3.26", "ezsystems/ezplatform-rest": ">=1.2,<=1.2.2|>=1.3,<1.3.8", - "ezsystems/ezplatform-richtext": ">=2.3,<2.3.7.1", + "ezsystems/ezplatform-richtext": ">=2.3,<2.3.7.1-dev", "ezsystems/ezplatform-user": ">=1,<1.0.1", - "ezsystems/ezpublish-kernel": "<6.13.8.2|>=7,<7.5.30", - "ezsystems/ezpublish-legacy": "<=2017.12.7.3|>=2018.6,<=2019.3.5.1", + "ezsystems/ezpublish-kernel": "<6.13.8.2-dev|>=7,<7.5.30", + "ezsystems/ezpublish-legacy": "<=2017.12.7.3|>=2018.06,<=2019.03.5.1", "ezsystems/platform-ui-assets-bundle": ">=4.2,<4.2.3", - "ezsystems/repository-forms": ">=2.3,<2.3.2.1|>=2.5,<2.5.15", + "ezsystems/repository-forms": ">=2.3,<2.3.2.1-dev|>=2.5,<2.5.15", "ezyang/htmlpurifier": "<4.1.1", "facade/ignition": "<1.16.15|>=2,<2.4.2|>=2.5,<2.5.2", - "facturascripts/facturascripts": "<=2022.8", + "facturascripts/facturascripts": "<=2022.08", "feehi/cms": "<=2.1.1", "feehi/feehicms": "<=2.1.1", "fenom/fenom": "<=2.12.1", @@ -14746,12 +14756,13 @@ "firebase/php-jwt": "<6", "fixpunkt/fp-masterquiz": "<2.2.1|>=3,<3.5.2", "fixpunkt/fp-newsletter": "<1.1.1|>=2,<2.1.2|>=2.2,<3.2.6", - "flarum/core": "<1.7", + "flarum/core": "<1.8", + "flarum/framework": "<1.8", "flarum/mentions": "<1.6.3", - "flarum/sticky": ">=0.1-beta.14,<=0.1-beta.15", - "flarum/tags": "<=0.1-beta.13", + "flarum/sticky": ">=0.1.0.0-beta14,<=0.1.0.0-beta15", + "flarum/tags": "<=0.1.0.0-beta13", "fluidtypo3/vhs": "<5.1.1", - "fof/byobu": ">=0.3-beta.2,<1.1.7", + "fof/byobu": ">=0.3.0.0-beta2,<1.1.7", "fof/upload": "<1.2.3", "fooman/tcpdf": "<6.2.22", "forkcms/forkcms": "<5.11.1", @@ -14768,12 +14779,15 @@ "funadmin/funadmin": "<=3.2|>=3.3.2,<=3.3.3", "gaoming13/wechat-php-sdk": "<=1.10.2", "genix/cms": "<=1.1.11", - "getgrav/grav": "<1.7.42", - "getkirby/cms": "= 3.8.0|<3.5.8.2|>=3.6,<3.6.6.2|>=3.7,<3.7.5.1", + "getgrav/grav": "<=1.7.42.1", + "getkirby/cms": "<3.5.8.3-dev|>=3.6,<3.6.6.3-dev|>=3.7,<3.7.5.2-dev|>=3.8,<3.8.4.1-dev|>=3.9,<3.9.6", + "getkirby/kirby": "<=2.5.12", "getkirby/panel": "<2.5.14", "getkirby/starterkit": "<=3.7.0.2", "gilacms/gila": "<=1.11.4", + "gleez/cms": "<=1.2", "globalpayments/php-sdk": "<2", + "gogentooss/samlbase": "<1.2.7", "google/protobuf": "<3.15", "gos/web-socket-bundle": "<1.10.4|>=2,<2.6.1|>=3,<3.3", "gree/jose": "<2.2.1", @@ -14781,8 +14795,9 @@ "grumpydictator/firefly-iii": "<6", "guzzlehttp/guzzle": "<6.5.8|>=7,<7.4.5", "guzzlehttp/psr7": "<1.9.1|>=2,<2.4.5", + "haffner/jh_captcha": "<=2.1.3|>=3,<=3.0.2", "harvesthq/chosen": "<1.8.7", - "helloxz/imgurl": "= 2.31|<=2.31", + "helloxz/imgurl": "<=2.31", "hhxsv5/laravel-s": "<3.7.36", "hillelcoren/invoice-ninja": "<5.3.35", "himiklab/yii2-jqgrid-widget": "<1.0.8", @@ -14802,7 +14817,7 @@ "illuminate/database": "<6.20.26|>=7,<7.30.5|>=8,<8.40", "illuminate/encryption": ">=4,<=4.0.11|>=4.1,<=4.1.31|>=4.2,<=4.2.22|>=5,<=5.0.35|>=5.1,<=5.1.46|>=5.2,<=5.2.45|>=5.3,<=5.3.31|>=5.4,<=5.4.36|>=5.5,<5.5.40|>=5.6,<5.6.15", "illuminate/view": "<6.20.42|>=7,<7.30.6|>=8,<8.75", - "impresscms/impresscms": "<=1.4.3", + "impresscms/impresscms": "<=1.4.5", "in2code/femanager": "<5.5.3|>=6,<6.3.4|>=7,<7.1", "in2code/ipandlanguageredirect": "<5.1.2", "in2code/lux": "<17.6.1|>=18,<24.0.2", @@ -14813,10 +14828,13 @@ "jackalope/jackalope-doctrine-dbal": "<1.7.4", "james-heinrich/getid3": "<1.9.21", "jasig/phpcas": "<1.3.3", + "jcbrand/converse.js": "<3.3.3", "joomla/archive": "<1.1.12|>=2,<2.0.1", "joomla/filesystem": "<1.6.2|>=2,<2.0.1", "joomla/filter": "<1.4.4|>=2,<2.0.1", + "joomla/framework": ">=2.5.4,<=3.8.12", "joomla/input": ">=2,<2.0.2", + "joomla/joomla-cms": "<3.9.12", "joomla/session": "<1.3.1", "joyqi/hyper-down": "<=2.4.27", "jsdecena/laracom": "<2.0.9", @@ -14826,25 +14844,27 @@ "kevinpapst/kimai2": "<1.16.7", "khodakhah/nodcms": "<=3", "kimai/kimai": "<1.1", - "kitodo/presentation": "<3.1.2", + "kitodo/presentation": "<3.2.3|>=3.3,<3.3.4", "klaviyo/magento2-extension": ">=1,<3", "knplabs/knp-snappy": "<1.4.2", + "kohana/core": "<3.3.3", "krayin/laravel-crm": "<1.2.2", "kreait/firebase-php": ">=3.2,<3.8.1", "la-haute-societe/tcpdf": "<6.2.22", - "laminas/laminas-diactoros": "<2.18.1|>=2.24,<2.24.2|>=2.25,<2.25.2|= 2.23.0|= 2.22.0|= 2.21.0|= 2.20.0|= 2.19.0", + "laminas/laminas-diactoros": "<2.18.1|==2.19|==2.20|==2.21|==2.22|==2.23|>=2.24,<2.24.2|>=2.25,<2.25.2", "laminas/laminas-form": "<2.17.1|>=3,<3.0.2|>=3.1,<3.1.1", "laminas/laminas-http": "<2.14.2", "laravel/fortify": "<1.11.1", - "laravel/framework": "<6.20.42|>=7,<7.30.6|>=8,<8.75", + "laravel/framework": "<6.20.44|>=7,<7.30.6|>=8,<8.75", "laravel/socialite": ">=1,<1.0.99|>=2,<2.0.10", "latte/latte": "<2.10.8", "lavalite/cms": "<=9", "lcobucci/jwt": ">=3.4,<3.4.6|>=4,<4.0.4|>=4.1,<4.1.5", "league/commonmark": "<0.18.3", "league/flysystem": "<1.1.4|>=2,<2.1.1", + "league/oauth2-server": ">=8.3.2,<8.4.2|>=8.5,<8.5.3", "lexik/jwt-authentication-bundle": "<2.10.7|>=2.11,<2.11.3", - "librenms/librenms": "<22.10", + "librenms/librenms": "<2017.08.18", "liftkit/database": "<2.13.2", "limesurvey/limesurvey": "<3.27.19", "livehelperchat/livehelperchat": "<=3.91", @@ -14852,16 +14872,16 @@ "lms/routes": "<2.1.1", "localizationteam/l10nmgr": "<7.4|>=8,<8.7|>=9,<9.2", "luyadev/yii-helpers": "<1.2.1", - "magento/community-edition": ">=2,<2.2.10|>=2.3,<2.3.3", - "magento/magento1ce": "<1.9.4.3", - "magento/magento1ee": ">=1,<1.14.4.3", - "magento/product-community-edition": ">=2,<2.2.10|>=2.3,<2.3.2-p.2", + "magento/community-edition": "<=2.4", + "magento/magento1ce": "<1.9.4.3-dev", + "magento/magento1ee": ">=1,<1.14.4.3-dev", + "magento/product-community-edition": ">=2,<2.2.10|>=2.3,<2.3.2.0-patch2", "maikuolan/phpmussel": ">=1,<1.6", "mantisbt/mantisbt": "<=2.25.5", "marcwillmann/turn": "<0.3.3", "matyhtf/framework": "<3.0.6", - "mautic/core": "<4.3|= 2.13.1", - "mediawiki/core": "<=1.39.3", + "mautic/core": "<4.3", + "mediawiki/core": ">=1.27,<1.27.6|>=1.29,<1.29.3|>=1.30,<1.30.2|>=1.31,<1.31.9|>=1.32,<1.32.6|>=1.32.99,<1.33.3|>=1.33.99,<1.34.3|>=1.34.99,<1.35", "mediawiki/matomo": "<2.4.3", "melisplatform/melis-asset-manager": "<5.0.1", "melisplatform/melis-cms": "<5.0.1", @@ -14872,14 +14892,16 @@ "miniorange/miniorange-saml": "<1.4.3", "mittwald/typo3_forum": "<1.2.1", "mobiledetect/mobiledetectlib": "<2.8.32", - "modx/revolution": "<= 2.8.3-pl|<2.8", + "modx/revolution": "<=2.8.3.0-patch", "mojo42/jirafeau": "<4.4", "monolog/monolog": ">=1.8,<1.12", - "moodle/moodle": "<4.2-rc.2|= 4.2.0|= 3.11", + "moodle/moodle": "<4.2.0.0-RC2-dev|==4.2", + "movim/moxl": ">=0.8,<=0.10", + "mpdf/mpdf": "<=7.1.7", "mustache/mustache": ">=2,<2.14.1", "namshi/jose": "<2.2", "neoan3-apps/template": "<1.1.1", - "neorazorx/facturascripts": "<2022.4", + "neorazorx/facturascripts": "<2022.04", "neos/flow": ">=1,<1.0.4|>=1.1,<1.1.1|>=2,<2.0.1|>=2.3,<2.3.16|>=3,<3.0.12|>=3.1,<3.1.10|>=3.2,<3.2.13|>=3.3,<3.3.13|>=4,<4.0.6", "neos/form": ">=1.2,<4.3.3|>=5,<5.0.9|>=5.1,<5.1.3", "neos/neos": ">=1.1,<1.1.3|>=1.2,<1.2.13|>=2,<2.0.4|>=2.3,<2.9.99|>=3,<3.0.20|>=3.1,<3.1.18|>=3.2,<3.2.14|>=3.3,<5.3.10|>=7,<7.0.9|>=7.1,<7.1.7|>=7.2,<7.2.6|>=7.3,<7.3.4|>=8,<8.0.2", @@ -14887,16 +14909,16 @@ "netgen/tagsbundle": ">=3.4,<3.4.11|>=4,<4.0.15", "nette/application": ">=2,<2.0.19|>=2.1,<2.1.13|>=2.2,<2.2.10|>=2.3,<2.3.14|>=2.4,<2.4.16|>=3,<3.0.6", "nette/nette": ">=2,<2.0.19|>=2.1,<2.1.13", - "nilsteampassnet/teampass": "<3.0.9", + "nilsteampassnet/teampass": "<3.0.10", "notrinos/notrinos-erp": "<=0.7", "noumo/easyii": "<=0.9", - "nukeviet/nukeviet": "<4.5.2", + "nukeviet/nukeviet": "<4.5.02", "nyholm/psr7": "<1.6.1", "nystudio107/craft-seomatic": "<3.4.12", "nzo/url-encryptor-bundle": ">=4,<4.3.2|>=5,<5.0.1", "october/backend": "<1.1.2", - "october/cms": "= 1.1.1|= 1.0.471|= 1.0.469|>=1.0.319,<1.0.469", - "october/october": ">=1.0.319,<1.0.466|>=2.1,<2.1.12", + "october/cms": "<1.0.469|==1.0.469|==1.0.471|==1.1.1", + "october/october": "<=3.4.4", "october/rain": "<1.0.472|>=1.1,<1.1.2", "october/system": "<1.0.476|>=1.1,<1.1.12|>=2,<2.2.34|>=3,<3.0.66", "onelogin/php-saml": "<2.10.4", @@ -14905,24 +14927,27 @@ "opencart/opencart": "<=3.0.3.7", "openid/php-openid": "<2.3", "openmage/magento-lts": "<19.4.22|>=20,<20.0.19", - "orchid/platform": ">=9,<9.4.4", + "opensource-workshop/connect-cms": "<1.7.2|>=2,<2.3.2", + "orchid/platform": ">=9,<9.4.4|>=14.0.0.0-alpha4,<14.5", "oro/commerce": ">=4.1,<5.0.6", "oro/crm": ">=1.7,<1.7.4|>=3.1,<4.1.17|>=4.2,<4.2.7", "oro/platform": ">=1.7,<1.7.4|>=3.1,<3.1.29|>=4.1,<4.1.17|>=4.2,<4.2.8", + "oxid-esales/oxideshop-ce": "<4.5", "packbackbooks/lti-1-3-php-library": "<5", "padraic/humbug_get_contents": "<1.1.2", - "pagarme/pagarme-php": ">=0,<3", + "pagarme/pagarme-php": "<3", "pagekit/pagekit": "<=1.0.18", "paragonie/random_compat": "<2", "passbolt/passbolt_api": "<2.11", "paypal/merchant-sdk-php": "<3.12", "pear/archive_tar": "<1.4.14", "pear/crypt_gpg": "<1.6.7", + "pear/pear": "<=1.10.1", "pegasus/google-for-jobs": "<1.5.1|>=2,<2.1.1", "personnummer/personnummer": "<3.0.2", "phanan/koel": "<5.1.4", "php-mod/curl": "<2.3.2", - "phpbb/phpbb": ">=3.2,<3.2.10|>=3.3,<3.3.1", + "phpbb/phpbb": "<3.2.10|>=3.3,<3.3.1", "phpfastcache/phpfastcache": "<6.1.5|>=7,<7.1.2|>=8,<8.0.7", "phpmailer/phpmailer": "<6.5", "phpmussel/phpmussel": ">=1,<1.6", @@ -14931,40 +14956,45 @@ "phpoffice/phpexcel": "<1.8", "phpoffice/phpspreadsheet": "<1.16", "phpseclib/phpseclib": "<2.0.31|>=3,<3.0.19", - "phpservermon/phpservermon": "<=3.5.2", + "phpservermon/phpservermon": "<3.6", "phpsysinfo/phpsysinfo": "<3.2.5", "phpunit/phpunit": ">=4.8.19,<4.8.28|>=5,<5.6.3", "phpwhois/phpwhois": "<=4.2.5", "phpxmlrpc/extras": "<0.6.1", "phpxmlrpc/phpxmlrpc": "<4.9.2", - "pimcore/customer-management-framework-bundle": "<3.3.10", + "pi/pi": "<=2.5", + "pimcore/admin-ui-classic-bundle": "<1.0.3", + "pimcore/customer-management-framework-bundle": "<3.4.2", "pimcore/data-hub": "<1.2.4", "pimcore/perspective-editor": "<1.5.1", - "pimcore/pimcore": "<10.5.23", + "pimcore/pimcore": "<10.6.8", "pixelfed/pixelfed": "<=0.11.4", "pocketmine/bedrock-protocol": "<8.0.2", - "pocketmine/pocketmine-mp": "<4.20.5|>=4.21,<4.21.1|< 4.18.0-ALPHA2|>= 4.0.0-BETA5, < 4.4.2", + "pocketmine/pocketmine-mp": "<4.22.3|>=5,<5.2.1", "pressbooks/pressbooks": "<5.18", "prestashop/autoupgrade": ">=4,<4.10.1", "prestashop/blockwishlist": ">=2,<2.1.1", "prestashop/contactform": ">=1.0.1,<4.3", "prestashop/gamification": "<2.3.2", - "prestashop/prestashop": "<8.0.4", + "prestashop/prestashop": "<=8.1", "prestashop/productcomments": "<5.0.2", "prestashop/ps_emailsubscription": "<2.6.1", "prestashop/ps_facetedsearch": "<3.4.1", "prestashop/ps_linklist": "<3.1", "privatebin/privatebin": "<1.4", "processwire/processwire": "<=3.0.200", - "propel/propel": ">=2-alpha.1,<=2-alpha.7", + "propel/propel": ">=2.0.0.0-alpha1,<=2.0.0.0-alpha7", "propel/propel1": ">=1,<=1.7.1", "pterodactyl/panel": "<1.7", + "ptheofan/yii2-statemachine": ">=2", "ptrofimov/beanstalk_console": "<1.7.14", "pusher/pusher-php-server": "<2.2.1", - "pwweb/laravel-core": "<=0.3.6-beta", + "pwweb/laravel-core": "<=0.3.6.0-beta", "pyrocms/pyrocms": "<=3.9.1", "rainlab/debugbar-plugin": "<3.1", + "rainlab/user-plugin": "<=1.4.5", "rankmath/seo-by-rank-math": "<=1.0.95", + "rap2hpoutre/laravel-log-viewer": "<0.13", "react/http": ">=0.7,<1.9", "really-simple-plugins/complianz-gdpr": "<6.4.2", "remdex/livehelperchat": "<3.99", @@ -14975,10 +15005,11 @@ "s-cart/core": "<6.9", "s-cart/s-cart": "<6.9", "sabberworm/php-css-parser": ">=1,<1.0.1|>=2,<2.0.1|>=3,<3.0.1|>=4,<4.0.1|>=5,<5.0.9|>=5.1,<5.1.3|>=5.2,<5.2.1|>=6,<6.0.2|>=7,<7.0.4|>=8,<8.0.1|>=8.1,<8.1.1|>=8.2,<8.2.1|>=8.3,<8.3.1", - "sabre/dav": ">=1.6,<1.6.99|>=1.7,<1.7.11|>=1.8,<1.8.9", - "scheb/two-factor-bundle": ">=0,<3.26|>=4,<4.11", + "sabre/dav": "<1.7.11|>=1.8,<1.8.9", + "scheb/two-factor-bundle": "<3.26|>=4,<4.11", "sensiolabs/connect": "<4.2.3", "serluck/phpwhois": "<=4.2.6", + "sfroemken/url_redirect": "<=1.2.1", "sheng/yiicms": "<=1.2", "shopware/core": "<=6.4.20", "shopware/platform": "<=6.4.20", @@ -14987,14 +15018,16 @@ "shopware/storefront": "<=6.4.8.1", "shopxo/shopxo": "<2.2.6", "showdoc/showdoc": "<2.10.4", - "silverstripe/admin": "<1.12.7", + "silverstripe-australia/advancedreports": ">=1,<=2", + "silverstripe/admin": "<1.13.6", "silverstripe/assets": ">=1,<1.11.1", "silverstripe/cms": "<4.11.3", "silverstripe/comments": ">=1.3,<1.9.99|>=2,<2.9.99|>=3,<3.1.1", "silverstripe/forum": "<=0.6.1|>=0.7,<=0.7.3", - "silverstripe/framework": "<4.12.5", - "silverstripe/graphql": "<3.5.2|>=4-alpha.1,<4-alpha.2|>=4.1.1,<4.1.2|>=4.2.2,<4.2.3|= 4.0.0-alpha1", + "silverstripe/framework": "<4.13.14|>=5,<5.0.13", + "silverstripe/graphql": "<3.5.2|>=4.0.0.0-alpha1,<4.0.0.0-alpha2|>=4.1.1,<4.1.2|>=4.2.2,<4.2.3", "silverstripe/hybridsessions": ">=1,<2.4.1|>=2.5,<2.5.1", + "silverstripe/recipe-cms": ">=4.5,<4.5.3", "silverstripe/registry": ">=2.1,<2.1.2|>=2.2,<2.2.1", "silverstripe/restfulserver": ">=1,<1.0.9|>=2,<2.0.4", "silverstripe/silverstripe-omnipay": "<2.5.2|>=3,<3.0.2|>=3.1,<3.1.4|>=3.2,<3.2.1", @@ -15003,30 +15036,34 @@ "silverstripe/userforms": "<3", "silverstripe/versioned-admin": ">=1,<1.11.1", "simple-updates/phpwhois": "<=1", - "simplesamlphp/saml2": "<1.10.6|>=2,<2.3.8|>=3,<3.1.4", + "simplesamlphp/saml2": "<1.15.4|>=2,<2.3.8|>=3,<3.1.4", "simplesamlphp/simplesamlphp": "<1.18.6", "simplesamlphp/simplesamlphp-module-infocard": "<1.0.1", "simplesamlphp/simplesamlphp-module-openid": "<1", "simplesamlphp/simplesamlphp-module-openidprovider": "<0.9", "simplito/elliptic-php": "<1.0.6", "sitegeist/fluid-components": "<3.5", + "sjbr/sr-freecap": "<=2.5.2", "slim/psr7": "<1.4.1|>=1.5,<1.5.1|>=1.6,<1.6.1", "slim/slim": "<2.6", + "slub/slub-events": "<3.0.3", "smarty/smarty": "<3.1.48|>=4,<4.3.1", - "snipe/snipe-it": "<=6.0.14|>= 6.0.0-RC-1, <= 6.0.0-RC-5", + "snipe/snipe-it": "<=6.0.14", "socalnick/scn-social-auth": "<1.15.2", "socialiteproviders/steam": "<1.1", "spatie/browsershot": "<3.57.4", "spipu/html2pdf": "<5.2.4", + "spoon/library": "<1.4.1", "spoonity/tcpdf": "<6.2.22", "squizlabs/php_codesniffer": ">=1,<2.8.1|>=3,<3.0.1", - "ssddanbrown/bookstack": "<22.2.3", - "statamic/cms": "<3.2.39|>=3.3,<3.3.2", - "stormpath/sdk": ">=0,<9.9.99", + "ssddanbrown/bookstack": "<22.02.3", + "statamic/cms": "<4.10", + "stormpath/sdk": "<9.9.99", "studio-42/elfinder": "<2.1.62", + "subhh/libconnect": "<7.0.8|>=8,<8.1", "subrion/cms": "<=4.2.1", "sukohi/surpass": "<1", - "sulu/sulu": "= 2.4.0-RC1|<1.6.44|>=2,<2.2.18|>=2.3,<2.3.8", + "sulu/sulu": "<1.6.44|>=2,<2.2.18|>=2.3,<2.3.8|==2.4.0.0-RC1|>=2.5,<2.5.10", "sumocoders/framework-user-bundle": "<1.4", "swag/paypal": "<5.4.4", "swiftmailer/swiftmailer": ">=4,<5.4.5", @@ -15045,7 +15082,7 @@ "symfony/dependency-injection": ">=2,<2.0.17|>=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7", "symfony/error-handler": ">=4.4,<4.4.4|>=5,<5.0.4", "symfony/form": ">=2.3,<2.3.35|>=2.4,<2.6.12|>=2.7,<2.7.50|>=2.8,<2.8.49|>=3,<3.4.20|>=4,<4.0.15|>=4.1,<4.1.9|>=4.2,<4.2.1", - "symfony/framework-bundle": ">=2,<2.3.18|>=2.4,<2.4.8|>=2.5,<2.5.2|>=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7|>=5.3.14,<=5.3.14|>=5.4.3,<=5.4.3|>=6.0.3,<=6.0.3|= 6.0.3|= 5.4.3|= 5.3.14", + "symfony/framework-bundle": ">=2,<2.3.18|>=2.4,<2.4.8|>=2.5,<2.5.2|>=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7|>=5.3.14,<=5.3.14|>=5.4.3,<=5.4.3|>=6.0.3,<=6.0.3", "symfony/http-foundation": ">=2,<2.8.52|>=3,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8|>=4.4,<4.4.7|>=5,<5.0.7", "symfony/http-kernel": ">=2,<4.4.50|>=5,<5.4.20|>=6,<6.0.20|>=6.1,<6.1.12|>=6.2,<6.2.6", "symfony/intl": ">=2.7,<2.7.38|>=2.8,<2.8.31|>=3,<3.2.14|>=3.3,<3.3.13", @@ -15063,27 +15100,28 @@ "symfony/security-guard": ">=2.8,<3.4.48|>=4,<4.4.23|>=5,<5.2.8", "symfony/security-http": ">=2.3,<2.3.41|>=2.4,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.2.12|>=4.3,<4.3.8|>=4.4,<4.4.7|>=5,<5.0.7|>=5.1,<5.2.8|>=5.3,<5.3.2", "symfony/serializer": ">=2,<2.0.11|>=4.1,<4.4.35|>=5,<5.3.12", - "symfony/symfony": ">=2,<4.4.50|>=5,<5.4.20|>=6,<6.0.20|>=6.1,<6.1.12|>=6.2,<6.2.6", + "symfony/symfony": "<4.4.50|>=5,<5.4.20|>=6,<6.0.20|>=6.1,<6.1.12|>=6.2,<6.2.6", "symfony/translation": ">=2,<2.0.17", "symfony/validator": ">=2,<2.0.24|>=2.1,<2.1.12|>=2.2,<2.2.5|>=2.3,<2.3.3", "symfony/var-exporter": ">=4.2,<4.2.12|>=4.3,<4.3.8", "symfony/web-profiler-bundle": ">=2,<2.3.19|>=2.4,<2.4.9|>=2.5,<2.5.4", "symfony/yaml": ">=2,<2.0.22|>=2.1,<2.1.7", - "t3/dce": ">=2.2,<2.6.2", + "t3/dce": "<0.11.5|>=2.2,<2.6.2", "t3g/svg-sanitizer": "<1.0.3", "tastyigniter/tastyigniter": "<3.3", "tcg/voyager": "<=1.4", "tecnickcom/tcpdf": "<6.2.22", "terminal42/contao-tablelookupwizard": "<3.3.5", "thelia/backoffice-default-template": ">=2.1,<2.1.2", - "thelia/thelia": ">=2.1-beta.1,<2.1.3", + "thelia/thelia": ">=2.1,<2.1.3", "theonedemon/phpwhois": "<=4.2.5", "thinkcmf/thinkcmf": "<=5.1.7", - "thorsten/phpmyfaq": "<3.2-beta.2", + "thorsten/phpmyfaq": "<3.2.0.0-beta2", + "tikiwiki/tiki-manager": "<=17.1", "tinymce/tinymce": "<5.10.7|>=6,<6.3.1", "tinymighty/wiki-seo": "<1.2.2", - "titon/framework": ">=0,<9.9.99", - "tobiasbg/tablepress": "<= 2.0-RC1", + "titon/framework": "<9.9.99", + "tobiasbg/tablepress": "<=2.0.0.0-RC1", "topthink/framework": "<6.0.14", "topthink/think": "<=6.1.1", "topthink/thinkphp": "<=3.2.3", @@ -15092,12 +15130,14 @@ "truckersmp/phpwhois": "<=4.3.1", "ttskch/pagination-service-provider": "<1", "twig/twig": "<1.44.7|>=2,<2.15.3|>=3,<3.4.3", - "typo3/cms": "<2.0.5|>=3,<3.0.3|>=6.2,<6.2.30|>=7,<7.6.32|>=8,<8.7.38|>=9,<9.5.29|>=10,<10.4.35|>=11,<11.5.23|>=12,<12.2", + "typo3/cms": "<8.7.38|>=9,<9.5.29|>=10,<10.4.35|>=11,<11.5.23|>=12,<12.2", "typo3/cms-backend": ">=7,<=7.6.50|>=8,<=8.7.39|>=9,<=9.5.24|>=10,<=10.4.13|>=11,<=11.1", - "typo3/cms-core": "<8.7.51|>=9,<9.5.40|>=10,<10.4.36|>=11,<11.5.23|>=12,<12.2", + "typo3/cms-core": "<8.7.51|>=9,<9.5.42|>=10,<10.4.39|>=11,<11.5.30|>=12,<12.4.4", + "typo3/cms-extbase": "<6.2.24|>=7,<7.6.8|==8.1.1", "typo3/cms-form": ">=8,<=8.7.39|>=9,<=9.5.24|>=10,<=10.4.13|>=11,<=11.1", + "typo3/cms-rte-ckeditor": ">=9.5,<9.5.42|>=10,<10.4.39|>=11,<11.5.30", "typo3/flow": ">=1,<1.0.4|>=1.1,<1.1.1|>=2,<2.0.1|>=2.3,<2.3.16|>=3,<3.0.12|>=3.1,<3.1.10|>=3.2,<3.2.13|>=3.3,<3.3.13|>=4,<4.0.6", - "typo3/html-sanitizer": ">=1,<1.5|>=2,<2.1.1", + "typo3/html-sanitizer": ">=1,<1.5.1|>=2,<2.1.2", "typo3/neos": ">=1.1,<1.1.3|>=1.2,<1.2.13|>=2,<2.0.4|>=2.3,<2.3.99|>=3,<3.0.20|>=3.1,<3.1.18|>=3.2,<3.2.14|>=3.3,<3.3.23|>=4,<4.0.17|>=4.1,<4.1.16|>=4.2,<4.2.12|>=4.3,<4.3.3", "typo3/phar-stream-wrapper": ">=1,<2.1.1|>=3,<3.1.1", "typo3/swiftmailer": ">=4.1,<4.1.99|>=5.4,<5.4.5", @@ -15111,8 +15151,9 @@ "verot/class.upload.php": "<=1.0.3|>=2,<=2.0.4", "vova07/yii2-fileapi-widget": "<0.1.9", "vrana/adminer": "<4.8.1", + "waldhacker/hcaptcha": "<2.1.2", "wallabag/tcpdf": "<6.2.22", - "wallabag/wallabag": "<2.5.4", + "wallabag/wallabag": "<=2.6.2", "wanglelecc/laracms": "<=1.0.3", "web-auth/webauthn-framework": ">=3.3,<3.3.4", "webbuilders-group/silverstripe-kapost-bridge": "<0.4", @@ -15120,9 +15161,10 @@ "webklex/laravel-imap": "<5.3", "webklex/php-imap": "<5.3", "webpa/webpa": "<3.1.2", + "wikibase/wikibase": "<=1.39.3", "wikimedia/parsoid": "<0.12.2", "willdurand/js-translation-bundle": "<2.1.1", - "wintercms/winter": "<1.0.475|>=1.1,<1.1.10|>=1.2,<1.2.1", + "wintercms/winter": "<1.2.3", "woocommerce/woocommerce": "<6.6", "wp-cli/wp-cli": "<2.5", "wp-graphql/wp-graphql": "<=1.14.5", @@ -15146,6 +15188,7 @@ "yikesinc/yikes-inc-easy-mailchimp-extender": "<6.8.6", "yoast-seo-for-typo3/yoast_seo": "<7.2.3", "yourls/yourls": "<=1.8.2", + "zencart/zencart": "<=1.5.7.0-beta", "zendesk/zendesk_api_client_php": "<2.2.11", "zendframework/zend-cache": ">=2.4,<2.4.8|>=2.5,<2.5.3", "zendframework/zend-captcha": ">=2,<2.4.9|>=2.5,<2.5.2", @@ -15167,7 +15210,8 @@ "zendframework/zendframework": "<=3", "zendframework/zendframework1": "<1.12.20", "zendframework/zendopenid": ">=2,<2.0.2", - "zendframework/zendxml": ">=1,<1.0.1", + "zendframework/zendxml": "<1.0.1", + "zenstruck/collection": "<0.2.1", "zetacomponents/mail": "<1.8.2", "zf-commons/zfc-user": "<1.2.2", "zfcampus/zf-apigility-doctrine": ">=1,<1.0.3", diff --git a/config/debugbar.php b/config/debugbar.php index 5a75df02670..34c853ebb53 100644 --- a/config/debugbar.php +++ b/config/debugbar.php @@ -19,6 +19,7 @@ 'telescope*', 'horizon*', 'docs*', + 'dav*', ], /* diff --git a/database/factories/AddressBookSubscriptionFactory.php b/database/factories/AddressBookSubscriptionFactory.php new file mode 100644 index 00000000000..4aef0093037 --- /dev/null +++ b/database/factories/AddressBookSubscriptionFactory.php @@ -0,0 +1,64 @@ + + */ +class AddressBookSubscriptionFactory extends Factory +{ + protected $model = AddressBookSubscription::class; + + /** + * Define the model's default state. + */ + public function definition() + { + return [ + 'user_id' => User::factory(), + 'vault_id' => fn ($attributes) => Vault::factory()->create([ + 'account_id' => User::find($attributes['user_id'])->account_id, + ]), + 'uri' => $this->faker->url, + 'capabilities' => [ + 'addressbookMultiget' => true, + 'addressbookQuery' => true, + 'syncCollection' => true, + 'addressData' => [ + 'content-type' => 'text/vcard', + 'version' => '4.0', + ], + ], + 'username' => $this->faker->email, + 'password' => 'password', + 'distant_sync_token' => '"test"', + 'active' => true, + 'sync_way' => AddressBookSubscription::WAY_BOTH, + ]; + } + + /** + * Indicate that the subscription is readonly. + */ + public function readonly() + { + return $this->state(fn () => [ + 'readonly' => true, + ]); + } + + /** + * Indicate that the subscription is not active. + */ + public function inactive() + { + return $this->state(fn () => [ + 'active' => false, + ]); + } +} diff --git a/database/migrations/2023_07_03_230200_create_addressbook_subscription.php b/database/migrations/2023_07_03_230200_create_addressbook_subscription.php new file mode 100644 index 00000000000..c17f20856e2 --- /dev/null +++ b/database/migrations/2023_07_03_230200_create_addressbook_subscription.php @@ -0,0 +1,48 @@ +uuid('id'); + $table->primary('id'); + $table->foreignIdFor(User::class)->constrained()->cascadeOnDelete(); + $table->foreignIdFor(Vault::class)->constrained()->cascadeOnDelete(); + + $table->string('uri', 2096); + $table->string('username', 1024); + $table->string('password', 2048); + $table->boolean('active')->default(true); + $table->unsignedTinyInteger('sync_way')->default(AddressBookSubscription::WAY_BOTH); + $table->string('capabilities', 2048); + $table->string('distant_sync_token', 512)->nullable(); + $table->string('last_batch')->nullable(); + $table->foreignIdFor(SyncToken::class)->nullable()->constrained()->nullOnDelete(); + $table->smallInteger('frequency')->default(180); // 3 hours + $table->timestamp('last_synchronized_at', 0)->nullable(); + $table->timestamps(); + + $table->foreign('last_batch')->references('id')->on('job_batches')->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down() + { + Schema::dropIfExists('addressbook_subscriptions'); + } +}; diff --git a/lang/bn.json b/lang/bn.json index b8f14aa02d5..b3810caeac5 100644 --- a/lang/bn.json +++ b/lang/bn.json @@ -287,6 +287,7 @@ "Copied.": "কপি করা হয়েছে।", "Copy": "কপি", "Copy value into the clipboard": "ক্লিপবোর্ডে মান কপি করুন", + "Could not get address book data.": "ঠিকানা বইয়ের ডেটা পাওয়া যায়নি।", "Country": "দেশ", "Couple": "দম্পতি", "cousin": "কাজিন", diff --git a/lang/ca.json b/lang/ca.json index 6dbe212ecfa..7ae7c7b28a0 100644 --- a/lang/ca.json +++ b/lang/ca.json @@ -287,6 +287,7 @@ "Copied.": "Copiat.", "Copy": "Còpia", "Copy value into the clipboard": "Copieu el valor al porta-retalls", + "Could not get address book data.": "No s'han pogut obtenir les dades de la llibreta d'adreces.", "Country": "País", "Couple": "Parella", "cousin": "cosí", diff --git a/lang/da.json b/lang/da.json index 329c8fbb3b7..96ce933e0c3 100644 --- a/lang/da.json +++ b/lang/da.json @@ -287,6 +287,7 @@ "Copied.": "Kopieret.", "Copy": "Kopi", "Copy value into the clipboard": "Kopier værdi til udklipsholderen", + "Could not get address book data.": "Adressebogsdata kunne ikke hentes.", "Country": "Land", "Couple": "Par", "cousin": "fætter", diff --git a/lang/de.json b/lang/de.json index 4a84f1098cc..42fcbdc1a1d 100644 --- a/lang/de.json +++ b/lang/de.json @@ -287,6 +287,7 @@ "Copied.": "Kopiert.", "Copy": "Kopieren", "Copy value into the clipboard": "Wert in die Zwischenablage kopieren", + "Could not get address book data.": "Adressbuchdaten konnten nicht abgerufen werden.", "Country": "Land", "Couple": "Paar", "cousin": "Cousin", diff --git a/lang/el.json b/lang/el.json index 97437d3c5b6..69b22d0311b 100644 --- a/lang/el.json +++ b/lang/el.json @@ -287,6 +287,7 @@ "Copied.": "Αντιγράφηκε.", "Copy": "αντίγραφο", "Copy value into the clipboard": "Αντιγράψτε την τιμή στο πρόχειρο", + "Could not get address book data.": "Δεν ήταν δυνατή η λήψη δεδομένων βιβλίου διευθύνσεων.", "Country": "Χώρα", "Couple": "Ζευγάρι", "cousin": "ξαδερφος ξαδερφη", diff --git a/lang/es.json b/lang/es.json index 039b0a65b93..ef714489f15 100644 --- a/lang/es.json +++ b/lang/es.json @@ -287,6 +287,7 @@ "Copied.": "Copiado.", "Copy": "Copiar", "Copy value into the clipboard": "Copiar valor en el portapapeles", + "Could not get address book data.": "No se pudieron obtener los datos de la libreta de direcciones.", "Country": "País", "Couple": "Pareja", "cousin": "primo", diff --git a/lang/fr.json b/lang/fr.json index b86c04f0b64..525da2f7abc 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -287,6 +287,7 @@ "Copied.": "Copié.", "Copy": "Copier", "Copy value into the clipboard": "Copier la valeur dans le presse-papiers", + "Could not get address book data.": "Impossible d'obtenir les données du carnet d'adresses.", "Country": "Pays", "Couple": "Couple", "cousin": "cousin", diff --git a/lang/he.json b/lang/he.json index dcf2b318ddd..5bf42af3b31 100644 --- a/lang/he.json +++ b/lang/he.json @@ -287,6 +287,7 @@ "Copied.": "מוּעֲתָק.", "Copy": "עותק", "Copy value into the clipboard": "העתק ערך ללוח", + "Could not get address book data.": "לא ניתן היה לקבל נתוני פנקס כתובות.", "Country": "מדינה", "Couple": "זוּג", "cousin": "בת דודה", diff --git a/lang/hi.json b/lang/hi.json index bb6d54b15cc..9f75649408c 100644 --- a/lang/hi.json +++ b/lang/hi.json @@ -287,6 +287,7 @@ "Copied.": "कॉपी किया गया।", "Copy": "प्रतिलिपि", "Copy value into the clipboard": "क्लिपबोर्ड में मूल्य कॉपी करें", + "Could not get address book data.": "पता पुस्तिका डेटा नहीं मिल सका.", "Country": "देश", "Couple": "जोड़ा", "cousin": "चचेरा", diff --git a/lang/it.json b/lang/it.json index a7c457fb820..90b0d193288 100644 --- a/lang/it.json +++ b/lang/it.json @@ -287,6 +287,7 @@ "Copied.": "Copiato.", "Copy": "Copia", "Copy value into the clipboard": "Copia il valore negli appunti", + "Could not get address book data.": "Impossibile ottenere i dati della rubrica.", "Country": "Paese", "Couple": "Coppia", "cousin": "cugino", diff --git a/lang/ja.json b/lang/ja.json index 07c39d86fd8..a71285b959a 100644 --- a/lang/ja.json +++ b/lang/ja.json @@ -287,6 +287,7 @@ "Copied.": "コピーしました。", "Copy": "コピー", "Copy value into the clipboard": "値をクリップボードにコピー", + "Could not get address book data.": "アドレス帳データを取得できませんでした。", "Country": "国", "Couple": "カップル", "cousin": "いとこ", diff --git a/lang/ml.json b/lang/ml.json index 73278599d8d..dd8900417a2 100644 --- a/lang/ml.json +++ b/lang/ml.json @@ -287,6 +287,7 @@ "Copied.": "പകർത്തി.", "Copy": "പകർത്തുക", "Copy value into the clipboard": "ക്ലിപ്പ്ബോർഡിലേക്ക് മൂല്യം പകർത്തുക", + "Could not get address book data.": "വിലാസ പുസ്തക ഡാറ്റ നേടാനായില്ല.", "Country": "രാജ്യം", "Couple": "ദമ്പതികൾ", "cousin": "ബന്ധു", diff --git a/lang/nl.json b/lang/nl.json index 214c90faf60..50be64945bb 100644 --- a/lang/nl.json +++ b/lang/nl.json @@ -287,6 +287,7 @@ "Copied.": "Gekopieerd.", "Copy": "Kopiëren", "Copy value into the clipboard": "Kopieer de waarde naar het klembord", + "Could not get address book data.": "Kan adresboekgegevens niet ophalen.", "Country": "Land", "Couple": "Stel", "cousin": "neef", diff --git a/lang/no.json b/lang/no.json index 984b128482c..f061ffb6cbc 100644 --- a/lang/no.json +++ b/lang/no.json @@ -287,6 +287,7 @@ "Copied.": "Kopiert.", "Copy": "Kopiere", "Copy value into the clipboard": "Kopier verdi til utklippstavlen", + "Could not get address book data.": "Kunne ikke hente adressebokdata.", "Country": "Land", "Couple": "Par", "cousin": "fetter", diff --git a/lang/pa.json b/lang/pa.json index 099047cbbd8..a3ffd44b98d 100644 --- a/lang/pa.json +++ b/lang/pa.json @@ -287,6 +287,7 @@ "Copied.": "ਕਾਪੀ ਕੀਤਾ।", "Copy": "ਕਾਪੀ ਕਰੋ", "Copy value into the clipboard": "ਕਲਿੱਪਬੋਰਡ ਵਿੱਚ ਮੁੱਲ ਦੀ ਨਕਲ ਕਰੋ", + "Could not get address book data.": "ਐਡਰੈੱਸ ਬੁੱਕ ਡਾਟਾ ਪ੍ਰਾਪਤ ਨਹੀਂ ਕੀਤਾ ਜਾ ਸਕਿਆ।", "Country": "ਦੇਸ਼", "Couple": "ਜੋੜਾ", "cousin": "ਚਚੇਰੇ ਭਰਾ", diff --git a/lang/pl.json b/lang/pl.json index 0814fa406a7..2b7183f6d71 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -287,6 +287,7 @@ "Copied.": "Skopiowane.", "Copy": "Kopiuj", "Copy value into the clipboard": "Skopiuj wartość do schowka", + "Could not get address book data.": "Nie można pobrać danych książki adresowej.", "Country": "Kraj", "Couple": "Para", "cousin": "kuzyn", diff --git a/lang/pt.json b/lang/pt.json index f09133fcfc3..aad4b9f7df2 100644 --- a/lang/pt.json +++ b/lang/pt.json @@ -287,6 +287,7 @@ "Copied.": "Copiado.", "Copy": "Cópia de", "Copy value into the clipboard": "Copiar valor para a área de transferência", + "Could not get address book data.": "Não foi possível obter os dados do catálogo de endereços.", "Country": "País", "Couple": "Casal", "cousin": "primo\/prima", diff --git a/lang/ro.json b/lang/ro.json index 89222b1127c..67f362343ba 100644 --- a/lang/ro.json +++ b/lang/ro.json @@ -287,6 +287,7 @@ "Copied.": "Copiat.", "Copy": "Copie", "Copy value into the clipboard": "Copiați valoarea în clipboard", + "Could not get address book data.": "Nu s-au putut obține datele din agendă.", "Country": "Țară", "Couple": "Cuplu", "cousin": "văr", diff --git a/lang/ru.json b/lang/ru.json index bc5aaab9e83..f6f90d7f9f9 100644 --- a/lang/ru.json +++ b/lang/ru.json @@ -287,6 +287,7 @@ "Copied.": "Скопировано.", "Copy": "Копировать", "Copy value into the clipboard": "Скопировать значение в буфер обмена", + "Could not get address book data.": "Не удалось получить данные адресной книги.", "Country": "Страна", "Couple": "Пара", "cousin": "двоюродный брат", diff --git a/lang/sv.json b/lang/sv.json index d5eb33bd1ca..6aaaf69ad3e 100644 --- a/lang/sv.json +++ b/lang/sv.json @@ -287,6 +287,7 @@ "Copied.": "Kopierade.", "Copy": "Kopiera", "Copy value into the clipboard": "Kopiera värde till urklipp", + "Could not get address book data.": "Kunde inte hämta adressboksdata.", "Country": "Land", "Couple": "Par", "cousin": "kusin", diff --git a/lang/te.json b/lang/te.json index 53cf4998eb0..96a6c257169 100644 --- a/lang/te.json +++ b/lang/te.json @@ -287,6 +287,7 @@ "Copied.": "కాపీ చేయబడింది.", "Copy": "కాపీ చేయండి", "Copy value into the clipboard": "విలువను క్లిప్‌బోర్డ్‌లోకి కాపీ చేయండి", + "Could not get address book data.": "చిరునామా పుస్తక డేటాను పొందడం సాధ్యపడలేదు.", "Country": "దేశం", "Couple": "జంట", "cousin": "బంధువు", diff --git a/lang/tr.json b/lang/tr.json index a8afe44ecb9..163ae2d0c6f 100644 --- a/lang/tr.json +++ b/lang/tr.json @@ -287,6 +287,7 @@ "Copied.": "kopyalandı.", "Copy": "Kopyala", "Copy value into the clipboard": "Değeri panoya kopyala", + "Could not get address book data.": "Adres defteri verileri alınamadı.", "Country": "Ülke", "Couple": "Çift", "cousin": "kuzen", diff --git a/lang/ur.json b/lang/ur.json index 3b4a3377774..2e76d745ead 100644 --- a/lang/ur.json +++ b/lang/ur.json @@ -287,6 +287,7 @@ "Copied.": "کاپی", "Copy": "کاپی", "Copy value into the clipboard": "کلپ بورڈ میں قیمت کاپی کریں۔", + "Could not get address book data.": "ایڈریس بک کا ڈیٹا حاصل نہیں ہو سکا۔", "Country": "ملک", "Couple": "جوڑے", "cousin": "کزن", diff --git a/lang/vi.json b/lang/vi.json index 341ad11c2ce..f930d05c0b3 100644 --- a/lang/vi.json +++ b/lang/vi.json @@ -287,6 +287,7 @@ "Copied.": "Đã sao chép.", "Copy": "Sao chép", "Copy value into the clipboard": "Sao chép giá trị vào khay nhớ tạm", + "Could not get address book data.": "Không thể lấy dữ liệu sổ địa chỉ.", "Country": "Quốc gia", "Couple": "Cặp đôi", "cousin": "anh em họ", diff --git a/lang/zh.json b/lang/zh.json index b1f8462738d..7999084bc2f 100644 --- a/lang/zh.json +++ b/lang/zh.json @@ -287,6 +287,7 @@ "Copied.": "复制。", "Copy": "复制", "Copy value into the clipboard": "将值复制到剪贴板", + "Could not get address book data.": "无法获取地址簿数据。", "Country": "国家", "Couple": "夫妻", "cousin": "表哥", diff --git a/phpstan.neon b/phpstan.neon index 2effc63720e..fc2066c98b3 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -36,3 +36,9 @@ parameters: # larastan needs to manage ->pivot properties - '#Access to an undefined property App\\Models\\[^:]*::\$pivot\.#' + + # Attributes + - message: '#Access to an undefined property App\\Models\\AddressBookSubscription::\$isWayPush\.#' + path: */app/Domains/Contact/DavClient/Services/Utils/AddressBookSynchronizer.php + - message: '#Access to an undefined property App\\Models\\AddressBookSubscription::\$isWayGet\.#' + path: */app/Domains/Contact/DavClient/Services/Utils/AddressBookSynchronizer.php diff --git a/tests/Helpers/DavTester.php b/tests/Helpers/DavTester.php new file mode 100644 index 00000000000..86a4a8b5ee7 --- /dev/null +++ b/tests/Helpers/DavTester.php @@ -0,0 +1,334 @@ +baseUri = $baseUri; + } + + public function client(): DavClient + { + return (new DavClient())->setBaseUri($this->baseUri); + } + + public function fake() + { + $this->http = Http::fake(function ($request) { + return $this->responses[$this->current++]['response']; + }); + + return $this; + } + + public function assert() + { + Http::assertSentInOrder(array_map(function ($data) { + return function (Request $request, Response $response) use ($data) { + $srequest = $request->method().' '.$request->url(); + $this->assertEquals($data['method'], $request->method(), "method for request $srequest differs"); + $this->assertEquals($data['uri'], $request->url(), "uri for request $srequest differs"); + if (isset($data['body'])) { + $this->assertEquals($data['body'], $request->body(), "body for request $srequest differs"); + } + if (isset($data['headers'])) { + foreach ($data['headers'] as $key => $value) { + $this->assertArrayHasKey($key, $request->headers(), "header $key for request $srequest is missing"); + $this->assertEquals($value, $request->header($key), "header $key for request $srequest differs"); + } + } + + return true; + }; + }, $this->responses)); + } + + public function addressBookBaseUri() + { + return $this->userPrincipal('https://test') + ->addressbookHome() + ->resourceTypeAddressBook() + ->optionsOk('https://test/dav/addressbooks/user@test.com/contacts/'); + } + + public function capabilities() + { + return $this->supportedReportSet() + ->supportedAddressData(); + } + + public function addResponse(string $uri, PromiseInterface $response, string $body = null, string $method = 'PROPFIND', array $headers = null) + { + $this->responses[] = [ + 'uri' => $uri, + 'response' => $response, + 'method' => $method, + 'body' => $body, + 'headers' => $headers, + ]; + + return $this; + } + + public function serviceUrl() + { + return $this->addResponse('https://test/.well-known/carddav', Http::response(null, 301, ['Location' => $this->baseUri.'/dav/']), null, 'GET'); + } + + public function nonStandardServiceUrl() + { + return $this->addResponse('https://test/.well-known/carddav', Http::response(null, 301, ['Location' => '/dav/']), null, 'PROPFIND'); + } + + public function optionsOk(string $url = 'https://test/dav/') + { + return $this->addResponse($url, Http::response(null, 200, ['Dav' => '1, 3, addressbook']), null, 'OPTIONS'); + } + + public function optionsFail() + { + return $this->addResponse('https://test/dav/', Http::response(null, 200, ['Dav' => 'bad']), null, 'OPTIONS'); + } + + public function userPrincipal(string $url = 'https://test/dav/') + { + return $this->addResponse($url, Http::response($this->multistatusHeader(). + ''. + '/dav/'. + ''. + ''. + ''. + '/dav/principals/user@test.com/'. + ''. + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + '')); + } + + public function userPrincipalEmpty() + { + return $this->addResponse('https://test/dav/', Http::response($this->multistatusHeader(). + ''. + '/dav/'. + ''. + ''. + ''. + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + '')); + } + + public function addressbookHome() + { + return $this->addResponse('https://test/dav/principals/user@test.com/', Http::response($this->multistatusHeader(). + ''. + '/dav/principals/user@test.com/'. + ''. + ''. + ''. + '/dav/addressbooks/user@test.com/'. + ''. + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + '')); + } + + public function addressbookEmpty() + { + return $this->addResponse('https://test/dav/principals/user@test.com/', Http::response($this->multistatusHeader(). + ''. + '/dav/principals/user@test.com/'. + ''. + ''. + ''. + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + '')); + } + + public function resourceTypeAddressBook(string $uri = 'https://test/dav/addressbooks/user@test.com/') + { + return $this->addResponse($uri, Http::response($this->multistatusHeader(). + ''. + '/dav/addressbooks/user@test.com/contacts/'. + ''. + ''. + ''. + ''. + ''. + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + '')); + } + + public function resourceTypeHomeOnly() + { + return $this->addResponse('https://test/dav/addressbooks/user@test.com/', Http::response($this->multistatusHeader(). + ''. + '/dav/addressbooks/user@test.com/'. + ''. + ''. + ''. + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + '')); + } + + public function resourceTypeEmpty() + { + return $this->addResponse('https://test/dav/addressbooks/user@test.com/contacts/', Http::response($this->multistatusHeader(). + ''. + '/dav/addressbooks/user@test.com/contacts/'. + ''. + ''. + ''. + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + '')); + } + + public function supportedReportSet(array $reportSet = ['card:addressbook-multiget', 'card:addressbook-query', 'd:sync-collection']) + { + return $this->addResponse('https://test/dav/addressbooks/user@test.com/contacts/', Http::response($this->multistatusHeader(). + ''. + '/dav/addressbooks/user@test.com/contacts/'. + ''. + ''. + ''. + implode('', array_map(function ($report) { + return "<$report/>"; + }, $reportSet)). + ''. + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + '')); + } + + public function supportedAddressData(array $list = ['card:address-data-type content-type="text/vcard" version="4.0"']) + { + return $this->addResponse('https://test/dav/addressbooks/user@test.com/contacts/', Http::response($this->multistatusHeader(). + ''. + '/dav/addressbooks/user@test.com/contacts/'. + ''. + ''. + ''. + implode('', array_map(function ($item) { + return "<$item/>"; + }, $list)). + ''. + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + '')); + } + + public function displayName(string $name = 'Test') + { + return $this->addResponse('https://test/dav/addressbooks/user@test.com/contacts/', Http::response($this->multistatusHeader(). + ''. + '/dav/addressbooks/user@test.com/contacts/'. + ''. + ''. + "$name". + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + '')); + } + + public function getSynctoken(string $synctoken = '"test"') + { + return $this->addResponse('https://test/dav/addressbooks/user@test.com/contacts/', Http::response($this->multistatusHeader(). + ''. + '/dav/addressbooks/user@test.com/contacts/'. + ''. + ''. + "$synctoken". + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + '')); + } + + public function getSyncCollection(string $synctoken = '"token"', string $etag = '"etag"', string $uuid = 'uuid') + { + return $this->addResponse('https://test/dav/addressbooks/user@test.com/contacts/', Http::response($this->multistatusHeader(). + ''. + "https://test/dav/addressbooks/user@test.com/contacts/$uuid". + ''. + ''. + "$etag". + 'text/vcard'. + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + "$synctoken". + ''), null, 'REPORT'); + } + + public function addressMultiGet($etag, $card, $url) + { + return $this->addResponse('https://test/dav/addressbooks/user@test.com/contacts/', Http::response($this->multistatusHeader(). + ''. + 'https://test/dav/addressbooks/user@test.com/contacts/uuid'. + ''. + ''. + "$etag". + "$card". + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + ''), ''."\n". + ''. + ''. + ''. + ''. + ''. + "$url". + "\n", 'REPORT'); + } + + public static function multistatusHeader() + { + return ''; + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index e0cc57b5386..952a4d09b38 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -102,4 +102,16 @@ public function setPrivateValue(object &$object, string $propertyName, mixed $va $property->setValue($object, $value); } + + /** + * Get protected/private property of a class. + */ + public function getPrivateValue(object &$object, string $propertyName): mixed + { + $reflection = new \ReflectionClass(get_class($object)); + $property = $reflection->getProperty($propertyName); + $property->setAccessible(true); + + return $property->getValue($object); + } } diff --git a/tests/Unit/Domains/Contact/DavClient/Jobs/DeleteMultipleVCardTest.php b/tests/Unit/Domains/Contact/DavClient/Jobs/DeleteMultipleVCardTest.php new file mode 100644 index 00000000000..b582988764f --- /dev/null +++ b/tests/Unit/Domains/Contact/DavClient/Jobs/DeleteMultipleVCardTest.php @@ -0,0 +1,47 @@ +create(); + + $pendingBatch = $fake->batch([ + $job = new DeleteMultipleVCard($addressBookSubscription, ['https://test/dav/uri']), + ]); + $batch = $pendingBatch->dispatch(); + + $fake->assertBatched(function (PendingBatch $pendingBatch) { + $this->assertCount(1, $pendingBatch->jobs); + $this->assertInstanceOf(DeleteMultipleVCard::class, $pendingBatch->jobs->first()); + + return true; + }); + + $batch = app(DatabaseBatchRepository::class)->store($pendingBatch); + $job->withBatchId($batch->id)->handle(); + + $fake->assertDispatched(function (DeleteVCard $updateVCard) { + $uri = $this->getPrivateValue($updateVCard, 'uri'); + $this->assertEquals('https://test/dav/uri', $uri); + + return true; + }); + } +} diff --git a/tests/Unit/Domains/Contact/DavClient/Jobs/DeleteVCardTest.php b/tests/Unit/Domains/Contact/DavClient/Jobs/DeleteVCardTest.php new file mode 100644 index 00000000000..68e5dd057f1 --- /dev/null +++ b/tests/Unit/Domains/Contact/DavClient/Jobs/DeleteVCardTest.php @@ -0,0 +1,48 @@ +create(); + + Http::fake(function (Request $request) { + $this->assertEquals('https://test/dav/uri', $request->url()); + $this->assertEquals('DELETE', $request->method()); + + return Http::response(null, 204); + }); + + $pendingBatch = $fake->batch([ + $job = new DeleteVCard($addressBookSubscription, 'https://test/dav/uri'), + ]); + $batch = $pendingBatch->dispatch(); + + $fake->assertBatched(function (PendingBatch $pendingBatch) { + $this->assertCount(1, $pendingBatch->jobs); + $this->assertInstanceOf(DeleteVCard::class, $pendingBatch->jobs->first()); + + return true; + }); + + $batch = app(DatabaseBatchRepository::class)->store($pendingBatch); + $job->withBatchId($batch->id)->handle(); + } +} diff --git a/tests/Unit/Domains/Contact/DavClient/Jobs/GetMultipleVCardTest.php b/tests/Unit/Domains/Contact/DavClient/Jobs/GetMultipleVCardTest.php new file mode 100644 index 00000000000..e8e1578ac9f --- /dev/null +++ b/tests/Unit/Domains/Contact/DavClient/Jobs/GetMultipleVCardTest.php @@ -0,0 +1,186 @@ +create(); + $this->setPermissionInVault($subscription->user, Vault::PERMISSION_EDIT, $subscription->vault); + + $contact = Contact::factory()->create([ + 'vault_id' => $subscription->vault_id, + 'first_name' => 'Test', + 'id' => 'affacde9-b2fe-4371-9acb-6612aaee6971', + 'updated_at' => now(), + ]); + $card = $this->getCard($contact); + $etag = $this->getEtag($contact, true); + + $this->mock(DavClient::class, function (MockInterface $mock) use ($card, $etag) { + $mock->shouldReceive('setBaseUri')->once()->andReturnSelf(); + $mock->shouldReceive('setCredentials')->once()->andReturnSelf(); + $mock->shouldReceive('addressbookMultiget') + ->once() + ->withArgs(function ($properties, $contacts) { + $this->assertEquals([ + '{DAV:}getetag', + [ + 'name' => '{'.CardDav::NS_CARDDAV.'}address-data', + 'value' => null, + 'attributes' => [ + 'content-type' => 'text/vcard', + 'version' => '4.0', + ], + ], + ], $properties); + $this->assertEquals(['https://test/dav/uri'], $contacts); + + return true; + }) + ->andReturn([ + 'https://test/dav/uri' => [ + 'properties' => [ + 200 => [ + '{'.CardDav::NS_CARDDAV.'}address-data' => $card, + '{DAV:}getetag' => $etag, + ], + ], + 'status' => '200', + ], + ]); + }); + + $pendingBatch = $fake->batch([ + $job = new GetMultipleVCard($subscription, ['https://test/dav/uri']), + ]); + $batch = $pendingBatch->dispatch(); + + $fake->assertBatched(function (PendingBatch $pendingBatch) { + $this->assertCount(1, $pendingBatch->jobs); + $this->assertInstanceOf(GetMultipleVCard::class, $pendingBatch->jobs->first()); + + return true; + }); + + $batch = app(DatabaseBatchRepository::class)->store($pendingBatch); + $job->withBatchId($batch->id)->handle(); + + $fake->assertDispatched(function (UpdateVCard $updateVCard) use ($subscription, $etag, $card) { + $this->assertEquals([ + 'account_id' => $subscription->vault->account_id, + 'author_id' => $subscription->user_id, + 'vault_id' => $subscription->vault_id, + 'uri' => 'https://test/dav/uri', + 'etag' => $etag, + 'card' => $card, + 'external' => true, + ], $updateVCard->data); + + return true; + }); + } + + /** @test */ + public function it_get_cards_mock_http() + { + $fake = Bus::fake(); + + $subscription = AddressBookSubscription::factory()->create(); + $this->setPermissionInVault($subscription->user, Vault::PERMISSION_EDIT, $subscription->vault); + + $contact = Contact::factory()->create([ + 'vault_id' => $subscription->vault_id, + 'first_name' => 'Test', + 'id' => 'affacde9-b2fe-4371-9acb-6612aaee6971', + 'updated_at' => now(), + ]); + $card = $this->getCard($contact); + $etag = $this->getEtag($contact, true); + + $this->mock(DavClient::class, function (MockInterface $mock) use ($card, $etag) { + $mock->shouldReceive('setBaseUri')->once()->andReturnSelf(); + $mock->shouldReceive('setCredentials')->once()->andReturnSelf(); + $mock->shouldReceive('addressbookMultiget') + ->once() + ->withArgs(function ($properties, $contacts) { + $this->assertEquals([ + '{DAV:}getetag', + [ + 'name' => '{'.CardDav::NS_CARDDAV.'}address-data', + 'value' => null, + 'attributes' => [ + 'content-type' => 'text/vcard', + 'version' => '4.0', + ], + ], + ], $properties); + $this->assertEquals(['https://test/dav/uri'], $contacts); + + return true; + }) + ->andReturn([ + 'https://test/dav/uri' => [ + 'properties' => [ + 200 => [ + '{'.CardDav::NS_CARDDAV.'}address-data' => $card, + '{DAV:}getetag' => $etag, + ], + ], + 'status' => '200', + ], + ]); + }); + + $pendingBatch = $fake->batch([ + $job = new GetMultipleVCard($subscription, ['https://test/dav/uri']), + ]); + $batch = $pendingBatch->dispatch(); + + $fake->assertBatched(function (PendingBatch $pendingBatch) { + $this->assertCount(1, $pendingBatch->jobs); + $this->assertInstanceOf(GetMultipleVCard::class, $pendingBatch->jobs->first()); + + return true; + }); + + $batch = app(DatabaseBatchRepository::class)->store($pendingBatch); + $job->withBatchId($batch->id)->handle(); + + $fake->assertDispatched(function (UpdateVCard $updateVCard) use ($subscription, $etag, $card) { + $this->assertEquals([ + 'account_id' => $subscription->vault->account_id, + 'author_id' => $subscription->user_id, + 'vault_id' => $subscription->vault_id, + 'uri' => 'https://test/dav/uri', + 'etag' => $etag, + 'card' => $card, + 'external' => true, + ], $updateVCard->data); + + return true; + }); + } +} diff --git a/tests/Unit/Domains/Contact/DavClient/Jobs/GetVCardTest.php b/tests/Unit/Domains/Contact/DavClient/Jobs/GetVCardTest.php new file mode 100644 index 00000000000..8dddc044e60 --- /dev/null +++ b/tests/Unit/Domains/Contact/DavClient/Jobs/GetVCardTest.php @@ -0,0 +1,74 @@ +create(); + $this->setPermissionInVault($subscription->user, Vault::PERMISSION_EDIT, $subscription->vault); + + $contact = Contact::factory()->create([ + 'vault_id' => $subscription->vault_id, + 'first_name' => 'Test', + 'id' => 'affacde9-b2fe-4371-9acb-6612aaee6971', + 'updated_at' => now(), + ]); + $card = $this->getCard($contact); + $etag = $this->getEtag($contact, true); + + Http::fake([ + 'https://test/dav/uri' => Http::response($card, 200), + ]); + + $pendingBatch = $fake->batch([ + $job = new GetVCard($subscription, new ContactDto('https://test/dav/uri', $etag)), + ]); + $batch = $pendingBatch->dispatch(); + + $fake->assertBatched(function (PendingBatch $pendingBatch) { + $this->assertCount(1, $pendingBatch->jobs); + $this->assertInstanceOf(GetVCard::class, $pendingBatch->jobs->first()); + + return true; + }); + + $batch = app(DatabaseBatchRepository::class)->store($pendingBatch); + $job->withBatchId($batch->id)->handle(); + + $fake->assertDispatched(function (UpdateVCard $updateVCard) use ($subscription, $etag, $card) { + $this->assertEquals([ + 'account_id' => $subscription->vault->account_id, + 'author_id' => $subscription->user_id, + 'vault_id' => $subscription->vault_id, + 'uri' => 'https://test/dav/uri', + 'etag' => $etag, + 'card' => $card, + 'external' => true, + ], $updateVCard->data); + + return true; + }); + } +} diff --git a/tests/Unit/Domains/Contact/DavClient/Jobs/PushVCardTest.php b/tests/Unit/Domains/Contact/DavClient/Jobs/PushVCardTest.php new file mode 100644 index 00000000000..583e2d76938 --- /dev/null +++ b/tests/Unit/Domains/Contact/DavClient/Jobs/PushVCardTest.php @@ -0,0 +1,104 @@ +create(); + $dto = new PushVCard($subscription, 'uri', 'etag', 'card', 'id'); + $this->assertEquals('uri', $dto->uri); + $this->assertEquals('etag', $dto->etag); + $this->assertEquals('id', $dto->contactId); + $this->assertEquals('card', $dto->card); + } + + /** @test */ + public function it_create_dto_resource() + { + $subscription = AddressBookSubscription::factory()->create(); + $resource = fopen(__DIR__.'/stub.vcf', 'r'); + $dto = new PushVCard($subscription, 'uri', 'etag', $resource, 'id'); + $this->assertEquals('uri', $dto->uri); + $this->assertEquals('etag', $dto->etag); + $this->assertEquals('id', $dto->contactId); + $this->assertEquals('card', $dto->card); + } + + /** + * @test + * + * @dataProvider modes + */ + public function it_push_card($mode, $ifmatch) + { + $fake = Bus::fake(); + + $subscription = AddressBookSubscription::factory()->create([ + 'uri' => 'https://test/dav', + ]); + + $contact = Contact::factory()->create([ + 'vault_id' => $subscription->vault_id, + 'first_name' => 'John', + 'last_name' => 'Doe', + 'id' => 'affacde9-b2fe-4371-9acb-6612aaee6971', + 'updated_at' => '2021-09-01', + ]); + $card = $this->getCard($contact); + $etag = $this->getEtag($contact, true); + + if ($ifmatch == ['etag']) { + $ifmatch = [$etag]; + } + + Http::fake(function (Request $request, $options) use ($card, $ifmatch) { + $this->assertEquals('https://test/dav/uri', $request->url()); + $this->assertEquals('PUT', $request->method()); + $this->assertEquals($ifmatch, $request->header('If-Match')); + + return Http::response($card, 200); + }); + + $pendingBatch = $fake->batch([ + $job = new PushVCard($subscription, 'https://test/dav/uri', $etag, $card, $contact->id, $mode), + ]); + $batch = $pendingBatch->dispatch(); + + $fake->assertBatched(function (PendingBatch $pendingBatch) { + $this->assertCount(1, $pendingBatch->jobs); + $this->assertInstanceOf(PushVCard::class, $pendingBatch->jobs->first()); + + return true; + }); + + $batch = app(DatabaseBatchRepository::class)->store($pendingBatch); + $job->withBatchId($batch->id)->handle(); + } + + public static function modes(): array + { + return [ + [0, []], + [1, ['etag']], + [2, ['*']], + ]; + } +} diff --git a/tests/Unit/Domains/Contact/DavClient/Jobs/SynchronizeAddressBooksTest.php b/tests/Unit/Domains/Contact/DavClient/Jobs/SynchronizeAddressBooksTest.php new file mode 100644 index 00000000000..5ea42c1e98a --- /dev/null +++ b/tests/Unit/Domains/Contact/DavClient/Jobs/SynchronizeAddressBooksTest.php @@ -0,0 +1,37 @@ +create([ + 'last_synchronized_at' => null, + ]); + + $this->mock(SynchronizeAddressBook::class, function ($mock) use ($subscription) { + $mock->shouldReceive('execute')->once()->with([ + 'account_id' => $subscription->user->account_id, + 'addressbook_subscription_id' => $subscription->id, + 'force' => false, + ]); + }); + + (new SynchronizeAddressBooks($subscription, false))->handle(); + + $this->assertEquals('2021-01-01 00:00:00', $subscription->fresh()->last_synchronized_at); + } +} diff --git a/tests/Unit/Domains/Contact/DavClient/Jobs/UpdateAddressBooksTest.php b/tests/Unit/Domains/Contact/DavClient/Jobs/UpdateAddressBooksTest.php new file mode 100644 index 00000000000..4864ac1cca8 --- /dev/null +++ b/tests/Unit/Domains/Contact/DavClient/Jobs/UpdateAddressBooksTest.php @@ -0,0 +1,76 @@ +create(); + + (new UpdateAddressBooks())->handle(); + + Queue::assertPushed(SynchronizeAddressBooks::class, fn ($job) => $job->subscription->id === $subscription->id + ); + } + + /** @test */ + public function it_does_not_dispatch_inactive_subscription() + { + Queue::fake(); + + $subscription = AddressBookSubscription::factory()->inactive()->create(); + + (new UpdateAddressBooks())->handle(); + + Queue::assertNotPushed(SynchronizeAddressBooks::class, fn ($job) => $job->subscription->id === $subscription->id + ); + } + + /** @test */ + public function it_dispatch_subscription_at_time_update() + { + Queue::fake(); + Carbon::setTestNow(Carbon::parse('2020-01-01 01:01:00')); + + $subscription = AddressBookSubscription::factory([ + 'last_synchronized_at' => Carbon::parse('2020-01-01 00:00:00'), + 'frequency' => 60, + ])->create(); + + (new UpdateAddressBooks())->handle(); + + Queue::assertPushed(SynchronizeAddressBooks::class, fn ($job) => $job->subscription->id === $subscription->id + ); + } + + /** @test */ + public function it_does_not_dispatch_subscription_at_time_update() + { + Queue::fake(); + Carbon::setTestNow(Carbon::parse('2020-01-01 01:00:00')); + + $subscription = AddressBookSubscription::factory([ + 'last_synchronized_at' => Carbon::parse('2020-01-01 00:00:00'), + 'frequency' => 60, + ])->create(); + + (new UpdateAddressBooks())->handle(); + + Queue::assertNotPushed(SynchronizeAddressBooks::class, fn ($job) => $job->subscription->id === $subscription->id + ); + } +} diff --git a/tests/Unit/Domains/Contact/DavClient/Jobs/stub.vcf b/tests/Unit/Domains/Contact/DavClient/Jobs/stub.vcf new file mode 100644 index 00000000000..8c7c2820552 --- /dev/null +++ b/tests/Unit/Domains/Contact/DavClient/Jobs/stub.vcf @@ -0,0 +1 @@ +card \ No newline at end of file diff --git a/tests/Unit/Domains/Contact/DavClient/Services/AddAddressBookTest.php b/tests/Unit/Domains/Contact/DavClient/Services/AddAddressBookTest.php new file mode 100644 index 00000000000..683b071d89b --- /dev/null +++ b/tests/Unit/Domains/Contact/DavClient/Services/AddAddressBookTest.php @@ -0,0 +1,91 @@ +create(); + $vault = $this->createVaultUser($user, Vault::PERMISSION_MANAGE); + $vault->update([ + 'name' => 'contacts1', + ]); + + $this->mock(AddressBookGetter::class, function (MockInterface $mock) { + $mock->shouldReceive('withClient')->andReturnSelf(); + $mock->shouldReceive('execute') + ->once() + ->andReturn($this->mockReturn()); + }); + + $request = [ + 'account_id' => $user->account_id, + 'vault_id' => $vault->id, + 'author_id' => $user->id, + 'base_uri' => 'https://test', + 'username' => 'test', + 'password' => 'test', + ]; + + $subscription = (new CreateAddressBookSubscription())->execute($request); + + $this->assertDatabaseHas('addressbook_subscriptions', [ + 'id' => $subscription->id, + 'user_id' => $user->id, + 'vault_id' => $vault->id, + 'username' => 'test', + 'capabilities' => json_encode([ + 'addressbookMultiget' => true, + 'addressbookQuery' => true, + 'syncCollection' => true, + 'addressData' => [ + 'content-type' => 'text/vcard', + 'version' => '4.0', + ], + ]), + ]); + + $addressBookPassword = DB::table('addressbook_subscriptions') + ->where('id', $subscription->id) + ->select('password') + ->get(); + $this->assertEquals('test', decrypt($addressBookPassword[0]->password)); + + $this->assertInstanceOf( + AddressBookSubscription::class, + $subscription + ); + } + + private function mockReturn(): array + { + return [ + 'uri' => 'https://test/dav', + 'capabilities' => [ + 'addressbookMultiget' => true, + 'addressbookQuery' => true, + 'syncCollection' => true, + 'addressData' => [ + 'content-type' => 'text/vcard', + 'version' => '4.0', + ], + ], + 'name' => 'Test', + ]; + } +} diff --git a/tests/Unit/Domains/Contact/DavClient/Services/SynchronizeAddressBookTest.php b/tests/Unit/Domains/Contact/DavClient/Services/SynchronizeAddressBookTest.php new file mode 100644 index 00000000000..a6255b0976c --- /dev/null +++ b/tests/Unit/Domains/Contact/DavClient/Services/SynchronizeAddressBookTest.php @@ -0,0 +1,64 @@ +mock(AddressBookSynchronizer::class, function (MockInterface $mock) { + $mock->shouldReceive('withSubscription')->once()->andReturnSelf(); + $mock->shouldReceive('execute') + ->once() + ->withArgs(function ($force) { + $this->assertFalse($force); + + return true; + }); + }); + + $subscription = AddressBookSubscription::factory()->create(); + + $request = [ + 'account_id' => $subscription->user->account_id, + 'addressbook_subscription_id' => $subscription->id, + ]; + + (new SynchronizeAddressBook())->execute($request); + } + + /** @test */ + public function it_runs_sync_force() + { + $this->mock(AddressBookSynchronizer::class, function (MockInterface $mock) { + $mock->shouldReceive('withSubscription')->once()->andReturnSelf(); + $mock->shouldReceive('execute') + ->once() + ->withArgs(function ($force) { + $this->assertTrue($force); + + return true; + }); + }); + + $subscription = AddressBookSubscription::factory()->create(); + + $request = [ + 'account_id' => $subscription->user->account_id, + 'addressbook_subscription_id' => $subscription->id, + 'force' => true, + ]; + + (new SynchronizeAddressBook())->execute($request); + } +} diff --git a/tests/Unit/Domains/Contact/DavClient/Services/UpdateSubscriptionLocalSyncTokenTest.php b/tests/Unit/Domains/Contact/DavClient/Services/UpdateSubscriptionLocalSyncTokenTest.php new file mode 100644 index 00000000000..7d010efa53a --- /dev/null +++ b/tests/Unit/Domains/Contact/DavClient/Services/UpdateSubscriptionLocalSyncTokenTest.php @@ -0,0 +1,74 @@ +create(); + $token = SyncToken::factory()->create([ + 'account_id' => $subscription->user->account_id, + 'user_id' => $subscription->user_id, + 'name' => 'contacts1', + 'timestamp' => now()->addDays(-1), + ]); + + $this->mock(CardDAVBackend::class, function (MockInterface $mock) use ($token, $subscription) { + $mock->shouldReceive('withUser')->andReturnSelf(); + $mock->shouldReceive('getCurrentSyncToken') + ->withArgs(function ($id) use ($subscription) { + $this->assertEquals($id, $subscription->vault_id); + + return true; + }) + ->andReturn($token); + }); + + (new UpdateSubscriptionLocalSyncToken())->execute([ + 'account_id' => $subscription->user->account_id, + 'addressbook_subscription_id' => $subscription->id, + ]); + + $subscription->refresh(); + + $this->assertEquals($token->id, $subscription->sync_token_id); + } + + /** @test */ + public function it_wont_update_null_token() + { + $subscription = AddressBookSubscription::factory()->create(); + + $this->mock(CardDAVBackend::class, function (MockInterface $mock) use ($subscription) { + $mock->shouldReceive('withUser')->andReturnSelf(); + $mock->shouldReceive('getCurrentSyncToken') + ->withArgs(function ($id) use ($subscription) { + $this->assertEquals($id, $subscription->vault_id); + + return true; + }) + ->andReturn(null); + }); + + (new UpdateSubscriptionLocalSyncToken())->execute([ + 'account_id' => $subscription->user->account_id, + 'addressbook_subscription_id' => $subscription->id, + ]); + + $subscription->refresh(); + + $this->assertNull($subscription->sync_token_id); + } +} diff --git a/tests/Unit/Domains/Contact/DavClient/Services/Utils/AddressBookGetterTest.php b/tests/Unit/Domains/Contact/DavClient/Services/Utils/AddressBookGetterTest.php new file mode 100644 index 00000000000..a81855c8d7d --- /dev/null +++ b/tests/Unit/Domains/Contact/DavClient/Services/Utils/AddressBookGetterTest.php @@ -0,0 +1,145 @@ +addressBookBaseUri() + ->capabilities() + ->displayName() + ->fake(); + $client = $tester->client(); + $result = (new AddressBookGetter()) + ->withClient($client) + ->execute(); + + $tester->assert(); + $this->assertEquals([ + 'uri' => 'https://test/dav/addressbooks/user@test.com/contacts/', + 'capabilities' => [ + 'addressbookMultiget' => true, + 'addressbookQuery' => true, + 'syncCollection' => true, + 'addressData' => [ + 'content-type' => 'text/vcard', + 'version' => '4.0', + ], + ], + 'name' => 'Test', + ], $result); + } + + /** @test */ + public function it_get_address_book_data_direct() + { + $tester = (new DavTester('https://test/dav/addressbooks/user@test.com/contacts/')) + ->resourceTypeAddressBook('https://test/dav/addressbooks/user@test.com/contacts/') + ->optionsOk('https://test/dav/addressbooks/user@test.com/contacts/') + ->capabilities() + ->displayName() + ->fake(); + $client = $tester->client(); + $result = (new AddressBookGetter()) + ->withClient($client) + ->execute(); + + $tester->assert(); + $this->assertEquals([ + 'uri' => 'https://test/dav/addressbooks/user@test.com/contacts/', + 'capabilities' => [ + 'addressbookMultiget' => true, + 'addressbookQuery' => true, + 'syncCollection' => true, + 'addressData' => [ + 'content-type' => 'text/vcard', + 'version' => '4.0', + ], + ], + 'name' => 'Test', + ], $result); + } + + /** @test */ + public function it_fails_on_server_not_compliant() + { + $tester = (new DavTester()) + ->userPrincipalEmpty() + ->serviceUrl() + ->optionsFail() + ->fake(); + $client = $tester->client(); + + $this->expectException(DavServerNotCompliantException::class); + (new AddressBookGetter()) + ->withClient($client) + ->execute(); + } + + /** @test */ + public function it_fails_if_no_userprincipal() + { + $tester = (new DavTester()) + ->userPrincipalEmpty() + ->serviceUrl() + ->optionsOk() + ->userPrincipalEmpty() + ->fake(); + $client = $tester->client(); + + $this->expectException(DavServerNotCompliantException::class); + (new AddressBookGetter()) + ->withClient($client) + ->execute(); + } + + /** @test */ + public function it_fails_if_no_addressbook() + { + $tester = (new DavTester()) + ->userPrincipalEmpty() + ->serviceUrl() + ->optionsOk() + ->userPrincipal() + ->addressbookEmpty() + ->fake(); + $client = $tester->client(); + + $this->expectException(DavServerNotCompliantException::class); + (new AddressBookGetter()) + ->withClient($client) + ->execute(); + } + + /** @test */ + public function it_fails_if_no_addressbook_url() + { + $tester = (new DavTester()) + ->userPrincipalEmpty() + ->serviceUrl() + ->optionsOk() + ->userPrincipal() + ->addressbookHome() + ->resourceTypeHomeOnly() + ->optionsOk() + ->fake(); + $client = $tester->client(); + + $this->expectException(DavClientException::class); + (new AddressBookGetter()) + ->withClient($client) + ->execute(); + } +} diff --git a/tests/Unit/Domains/Contact/DavClient/Services/Utils/AddressBookSynchronizerTest.php b/tests/Unit/Domains/Contact/DavClient/Services/Utils/AddressBookSynchronizerTest.php new file mode 100644 index 00000000000..ab6e2c7c475 --- /dev/null +++ b/tests/Unit/Domains/Contact/DavClient/Services/Utils/AddressBookSynchronizerTest.php @@ -0,0 +1,412 @@ +partialMock(PrepareJobsContactUpdater::class, function (MockInterface $mock) { + $mock->shouldReceive('withSubscription')->once()->andReturnSelf(); + $mock->shouldReceive('execute') + ->once() + ->andReturn(collect()); + }); + $this->partialMock(PrepareJobsContactPush::class, function (MockInterface $mock) { + $mock->shouldReceive('withSubscription')->once()->andReturnSelf(); + $mock->shouldReceive('execute') + ->once() + ->andReturn(collect()); + }); + + $subscription = $this->getSubscription(); + + $tester = (new DavTester($subscription->uri)) + ->getSynctoken($subscription->distant_sync_token) + ->fake(); + + (new AddressBookSynchronizer) + ->withSubscription($subscription) + ->execute(); + + $tester->assert(); + } + + #[Test] + public function it_sync_no_changes() + { + Bus::fake(); + + $this->mock(PrepareJobsContactUpdater::class, function (MockInterface $mock) { + $mock->shouldReceive('withSubscription')->once()->andReturnSelf(); + $mock->shouldReceive('execute') + ->once() + ->andReturn(collect()); + }); + $this->partialMock(PrepareJobsContactPush::class, function (MockInterface $mock) { + $mock->shouldReceive('withSubscription')->once()->andReturnSelf(); + $mock->shouldReceive('execute') + ->once() + ->andReturn(collect()); + }); + + $subscription = $this->getSubscription(); + + $tester = (new DavTester($subscription->uri)) + ->getSynctoken('"test21"') + ->getSyncCollection('"test20"', uuid: 'd403af1c-8492-4e9b-9833-cf18c795dfa9') + ->fake(); + + (new AddressBookSynchronizer) + ->withSubscription($subscription) + ->execute(); + + $tester->assert(); + } + + #[Test] + public function it_sync_changes_added_local_contact() + { + Bus::fake(); + + $subscription = $this->getSubscription(); + + $contact = Contact::factory()->create([ + 'vault_id' => $subscription->vault_id, + 'id' => 'd403af1c-8492-4e9b-9833-cf18c795dfa9', + ]); + + $tester = (new DavTester($subscription->uri)) + ->getSynctoken('"token"') + ->getSyncCollection('"token"', '"test2"', uuid: $contact->id) + ->fake(); + + $this->mock(PrepareJobsContactUpdater::class, function (MockInterface $mock) use ($contact) { + $mock->shouldReceive('withSubscription')->once()->andReturnSelf(); + $mock->shouldReceive('execute') + ->once() + ->withArgs(function ($contacts) use ($contact) { + $this->assertEquals("https://test/dav/addressbooks/user@test.com/contacts/{$contact->id}", $contacts->first()->uri); + $this->assertEquals('"test2"', $contacts->first()->etag); + + return true; + }) + ->andReturn(collect()); + }); + $this->partialMock(PrepareJobsContactPush::class, function (MockInterface $mock) { + $mock->shouldReceive('withSubscription')->once()->andReturnSelf(); + $mock->shouldReceive('execute') + ->once() + ->withArgs(function ($localChanges, $changes) { + $this->assertEquals('"test2"', $changes->first()->etag); + + return true; + }) + ->andReturn(collect()); + }); + + (new AddressBookSynchronizer) + ->withSubscription($subscription) + ->execute(); + + $tester->assert(); + } + + #[Test] + public function it_sync_changes_added_local_contact_batched() + { + Bus::fake(); + + $subscription = $this->getSubscription(); + + $contact = Contact::factory()->create([ + 'vault_id' => $subscription->vault_id, + 'id' => 'd403af1c-8492-4e9b-9833-cf18c795dfa9', + ]); + + $tester = (new DavTester($subscription->uri)) + ->getSynctoken('"token"') + ->getSyncCollection('"token"', '"test2"', uuid: $contact->id) + ->fake(); + + (new AddressBookSynchronizer) + ->withSubscription($subscription) + ->execute(); + + $tester->assert(); + + Bus::assertBatched(function (PendingBatch $batch) use ($contact) { + $this->assertCount(2, $batch->jobs); + $job = $batch->jobs[0]; + $this->assertInstanceOf(GetMultipleVCard::class, $job); + $this->assertEquals(["https://test/dav/addressbooks/user@test.com/contacts/{$contact->id}"], $this->getPrivateValue($job, 'hrefs')); + + return true; + }); + } + + #[Test] + public function it_sync_changes_deleted_contact_batched() + { + Bus::fake(); + + $subscription = $this->getSubscription(); + + $tester = (new DavTester($subscription->uri)) + ->getSynctoken('"token"') + ->addResponse('https://test/dav/addressbooks/user@test.com/contacts/', Http::response(DavTester::multistatusHeader(). + ''. + 'HTTP/1.1 404 Not Found'. + 'https://test/dav/addressbooks/user@test.com/contacts/uuid'. + ''. + ''. + 'HTTP/1.1 418 I\'m a teapot'. + ''. + ''. + 'token'. + ''), null, 'REPORT') + ->fake(); + + (new AddressBookSynchronizer) + ->withSubscription($subscription) + ->execute(); + + $tester->assert(); + + Bus::assertBatched(function (PendingBatch $batch) { + $this->assertCount(1, $batch->jobs); + $job = $batch->jobs[0]; + $this->assertInstanceOf(DeleteMultipleVCard::class, $job); + $this->assertEquals(['https://test/dav/addressbooks/user@test.com/contacts/uuid'], $this->getPrivateValue($job, 'hrefs')); + + return true; + }); + } + + #[Test] + public function it_forcesync_changes_added_local_contact() + { + Bus::fake(); + + $subscription = $this->getSubscription(); + + $contact = Contact::factory()->create([ + 'vault_id' => $subscription->vault_id, + 'id' => 'd403af1c-8492-4e9b-9833-cf18c795dfa9', + ]); + $etag = $this->getEtag($contact, true); + + $tester = (new DavTester($subscription->uri)) + ->fake() + ->addResponse('https://test/dav/addressbooks/user@test.com/contacts/', Http::response(DavTester::multistatusHeader(). + ''. + 'https://test/dav/uuid1'. + ''. + ''. + "$etag". + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + ''), ''."\n". + ''. + ''. + ''. + ''. + "\n", 'REPORT'); + + $this->mock(PrepareJobsContactUpdater::class, function (MockInterface $mock) { + $mock->shouldReceive('withSubscription')->once()->andReturnSelf(); + $mock->shouldReceive('execute') + ->once() + ->andReturn(collect()); + }); + + $this->mock(PrepareJobsContactPushMissed::class, function (MockInterface $mock) use ($contact, $etag) { + $mock->shouldReceive('withSubscription')->once()->andReturnSelf(); + $mock->shouldReceive('execute') + ->once() + ->withArgs(function ($localChanges, $distContacts, $localContacts) use ($contact, $etag) { + $this->assertContains($contact->id, $localContacts->pluck('id')); + $this->assertEquals('https://test/dav/uuid1', $distContacts->first()->uri); + $this->assertEquals($etag, $distContacts->first()->etag); + + return true; + }) + ->andReturn(collect()); + }); + + (new AddressBookSynchronizer) + ->withSubscription($subscription) + ->execute(true); + + $tester->assert(); + } + + #[Test] + public function it_forcesync_changes_added_local_contact_batched() + { + Bus::fake(); + + $subscription = $this->getSubscription(); + + $contact1 = $subscription->vault->contacts->first(); + $contact2 = Contact::factory()->create([ + 'first_name' => 'Ryan', + 'vault_id' => $subscription->vault_id, + 'id' => 'd403af1c-8492-4e9b-9833-cf18c795dfa9', + ]); + + $tester = (new DavTester($subscription->uri)) + ->fake() + ->addResponse('https://test/dav/addressbooks/user@test.com/contacts/', Http::response(DavTester::multistatusHeader(). + ''. + 'https://test/dav/uuid1'. + ''. + ''. + "{$this->getEtag($contact1, true)}". + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + ''. + 'https://test/dav/uuid1'. + ''. + ''. + "{$this->getEtag($contact2, true)}". + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + ''), ''."\n". + ''. + ''. + ''. + ''. + "\n", 'REPORT'); + + (new AddressBookSynchronizer) + ->withSubscription($subscription) + ->execute(true); + + $tester->assert(); + + Bus::assertBatched(function (PendingBatch $batch) use ($contact1, $contact2) { + $this->assertCount(3, $batch->jobs); + + $job = $batch->jobs[0]; + $this->assertInstanceOf(GetMultipleVCard::class, $job); + $this->assertEquals(['https://test/dav/uuid1'], $this->getPrivateValue($job, 'hrefs')); + + $job = $batch->jobs[1]; + $this->assertInstanceOf(PushVCard::class, $job); + $this->assertEquals($contact2->id, $job->contactId); + + $job = $batch->jobs[2]; + $this->assertInstanceOf(PushVCard::class, $job); + $this->assertEquals($contact1->id, $job->contactId); + + return true; + }); + } + + #[Test] + public function it_forcesync_changes_deleted_contact_batched() + { + Bus::fake(); + + $subscription = $this->getSubscription(); + + $tester = (new DavTester($subscription->uri)) + ->fake() + ->addResponse('https://test/dav/addressbooks/user@test.com/contacts/', Http::response(DavTester::multistatusHeader(). + ''. + 'HTTP/1.1 404 Not Found'. + 'https://test/dav/uuid1'. + ''. + ''. + 'HTTP/1.1 418 I\'m a teapot'. + ''. + ''. + ''), ''."\n". + ''. + ''. + ''. + ''. + "\n", 'REPORT'); + + (new AddressBookSynchronizer) + ->withSubscription($subscription) + ->execute(true); + + $tester->assert(); + + Bus::assertBatched(function (PendingBatch $batch) { + $this->assertCount(2, $batch->jobs); + $job = $batch->jobs[0]; + $this->assertInstanceOf(DeleteMultipleVCard::class, $job); + $this->assertEquals(['https://test/dav/uuid1'], $this->getPrivateValue($job, 'hrefs')); + + return true; + }); + } + + private function getSubscription(): AddressBookSubscription + { + Carbon::setTestNow(Carbon::create(2023, 1, 1, 0, 0, 0)); + + $subscription = AddressBookSubscription::factory()->create([ + 'uri' => 'https://test/dav/addressbooks/user@test.com/contacts/', + ]); + $this->setPermissionInVault($subscription->user, Vault::PERMISSION_VIEW, $subscription->vault); + + Carbon::setTestNow(Carbon::create(2023, 1, 3, 0, 0, 0)); + + $token = SyncToken::factory()->create([ + 'account_id' => $subscription->user->account_id, + 'user_id' => $subscription->user_id, + 'name' => "contacts-{$subscription->vault_id}", + 'timestamp' => now(), + ]); + + $subscription->sync_token_id = $token->id; + $subscription->save(); + + $this->assertDatabaseHas('addressbook_subscriptions', [ + 'id' => $subscription->id, + 'sync_token_id' => $token->id, + ]); + + Carbon::setTestNow(Carbon::create(2023, 1, 4, 0, 0, 0)); + + return $subscription; + } +} diff --git a/tests/Unit/Domains/Contact/DavClient/Services/Utils/Dav/DavClientTest.php b/tests/Unit/Domains/Contact/DavClient/Services/Utils/Dav/DavClientTest.php new file mode 100644 index 00000000000..764cbc94e53 --- /dev/null +++ b/tests/Unit/Domains/Contact/DavClient/Services/Utils/Dav/DavClientTest.php @@ -0,0 +1,501 @@ +addResponse('https://test', Http::response(), null, 'OPTIONS') + ->addResponse('https://test', Http::response(null, 200, ['Dav' => 'test']), null, 'OPTIONS') + ->addResponse('https://test', Http::response(null, 200, ['Dav' => ' test ']), null, 'OPTIONS') + ->fake(); + $client = $tester->client(); + + $result = $client->options(); + $this->assertEquals([], $result); + + $result = $client->options(); + $this->assertEquals(['test'], $result); + + $result = $client->options(); + $this->assertEquals(['test'], $result); + + $tester->assert(); + } + + /** @test */ + public function it_get_serviceurl() + { + $tester = (new DavTester()) + ->serviceUrl() + ->fake(); + $client = $tester->client(); + + $result = $client->getServiceUrl(); + + $tester->assert(); + $this->assertEquals('https://test/dav/', $result); + } + + /** @test */ + public function it_get_non_standard_serviceurl() + { + $tester = (new DavTester()) + ->addResponse('https://test/.well-known/carddav', Http::response(), null, 'GET') + ->addResponse('https://test/.well-known/carddav', Http::response(), null, 'GET') + ->nonStandardServiceUrl() + ->fake(); + $client = $tester->client(); + + $result = $client->getServiceUrl(); + + $tester->assert(); + $this->assertEquals('https://test/dav/', $result); + } + + /** @test */ + public function it_get_serviceurl_name() + { + $this->mock(ServiceUrlQuery::class, function ($mock) { + $mock->shouldReceive('withClient')->once()->andReturnSelf(); + $mock->shouldReceive('execute') + ->once() + ->withArgs(function (string $name, bool $https, string $baseUri) { + $this->assertEquals('_carddavs._tcp', $name); + $this->assertTrue($https); + $this->assertEquals('https://test', $baseUri); + + return true; + }) + ->andReturn('https://test/dav/'); + }); + + $tester = (new DavTester()) + ->addResponse('https://test/.well-known/carddav', Http::response(), null, 'GET') + ->addResponse('https://test/.well-known/carddav', Http::response(), null, 'GET') + ->addResponse('https://test/.well-known/carddav', Http::response(), null, 'PROPFIND') + ->fake(); + $client = $tester->client(); + + $result = $client->getServiceUrl(); + + $tester->assert(); + $this->assertEquals('https://test/dav/', $result); + } + + /** @test */ + public function it_get_non_standard_serviceurl2() + { + $tester = (new DavTester()) + ->addResponse('https://test/.well-known/carddav', Http::response(null, 404), null, 'GET') + ->addResponse('https://test/.well-known/carddav', Http::response(null, 404), null, 'GET') + ->nonStandardServiceUrl() + ->fake(); + $client = $tester->client(); + + $result = $client->getServiceUrl(); + + $tester->assert(); + $this->assertEquals('https://test/dav/', $result); + } + + /** @test */ + public function it_fail_non_standard() + { + $tester = (new DavTester()) + ->addResponse('https://test/.well-known/carddav', Http::response(null, 500), null, 'GET') + ->fake(); + $client = $tester->client(); + + $this->expectException(RequestException::class); + $client->getServiceUrl(); + } + + /** @test */ + public function it_get_base_uri() + { + $tester = (new DavTester()) + ->fake(); + $client = $tester->client(); + + $result = $client->path(); + + $this->assertEquals('https://test', $result); + + $result = $client->path('xxx'); + + $this->assertEquals('https://test/xxx', $result); + } + + /** @test */ + public function it_set_base_uri() + { + $tester = (new DavTester()) + ->fake(); + $client = $tester->client(); + + $result = $client->setBaseUri('https://new') + ->path(); + + $this->assertEquals('https://new', $result); + } + + /** @test */ + public function it_call_propfind() + { + $tester = (new DavTester()) + ->addResponse('https://test', Http::response(DavTester::multistatusHeader(). + ''. + 'href'. + ''. + ''. + 'value'. + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + ''), ''."\n". + ''. + ''. + ''. + ''. + "\n", 'PROPFIND') + ->fake(); + + $client = $tester->client(); + + $result = $client->propFind(['{DAV:}test']); + + $tester->assert(); + $this->assertEquals([ + '{DAV:}test' => 'value', + ], $result); + } + + /** @test */ + public function it_get_property() + { + $tester = (new DavTester()) + ->addResponse('https://test/test', Http::response(DavTester::multistatusHeader(). + ''. + 'href'. + ''. + ''. + 'value'. + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + ''), ''."\n". + ''. + ''. + ''. + ''. + "\n", 'PROPFIND') + ->fake(); + + $client = $tester->client(); + + $result = $client->getProperty('{DAV:}test', 'https://test/test'); + + $tester->assert(); + $this->assertEquals('value', $result); + } + + /** @test */ + public function it_get_supported_report() + { + $tester = (new DavTester('https://test/dav')) + ->addResponse('https://test/dav', Http::response(DavTester::multistatusHeader(). + ''. + '/dav'. + ''. + ''. + ''. + ''. + ''. + ''. + ''. + ''. + ''. + ''. + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + ''), ''."\n". + ''. + ''. + ''. + ''. + "\n", 'PROPFIND') + ->fake(); + + $client = $tester->client(); + + $result = $client->getSupportedReportSet(); + + $tester->assert(); + $this->assertEquals(['{DAV:}test1', '{DAV:}test2'], $result); + } + + /** @test */ + public function it_sync_collection() + { + $tester = (new DavTester()) + ->addResponse('https://test', Http::response(DavTester::multistatusHeader(). + ''. + 'href'. + ''. + ''. + '"00001-abcd1"'. + 'value'. + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + '"00001-abcd1"'. + ''), ''."\n". + ''. + ''. + '1'. + ''. + ''. + ''. + "\n", 'REPORT') + ->fake(); + + $client = $tester->client(); + + $result = $client->syncCollection(['{DAV:}test'], ''); + + $tester->assert(); + $this->assertEquals([ + 'href' => [ + 'properties' => [ + 200 => [ + '{DAV:}getetag' => '"00001-abcd1"', + '{DAV:}test' => 'value', + ], + ], + 'status' => '200', + ], + 'synctoken' => '"00001-abcd1"', + ], $result); + } + + /** @test */ + public function it_sync_collection_with_synctoken() + { + $tester = (new DavTester()) + ->addResponse('https://test', Http::response(DavTester::multistatusHeader(). + ''. + 'href'. + ''. + ''. + '"00001-abcd1"'. + 'value'. + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + '"00001-abcd1"'. + ''), ''."\n". + ''. + '"00000-abcd0"'. + '1'. + ''. + ''. + ''. + "\n", 'REPORT') + ->fake(); + + $client = $tester->client(); + + $result = $client->syncCollection(['{DAV:}test'], '"00000-abcd0"'); + + $tester->assert(); + $this->assertEquals([ + 'href' => [ + 'properties' => [ + 200 => [ + '{DAV:}getetag' => '"00001-abcd1"', + '{DAV:}test' => 'value', + ], + ], + 'status' => '200', + ], + 'synctoken' => '"00001-abcd1"', + ], $result); + } + + /** @test */ + public function it_run_addressbook_multiget_report() + { + $tester = (new DavTester()) + ->addResponse('https://test', Http::response(DavTester::multistatusHeader(). + ''. + 'href'. + ''. + ''. + '"00001-abcd1"'. + 'value'. + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + ''), ''."\n". + ''. + ''. + ''. + ''. + 'https://test/contacts/1'. + "\n", 'REPORT') + ->fake(); + + $client = $tester->client(); + + $result = $client->addressbookMultiget(['{DAV:}test'], ['https://test/contacts/1']); + + $this->assertEquals([ + 'href' => [ + 'properties' => [ + 200 => [ + '{DAV:}getetag' => '"00001-abcd1"', + '{DAV:}test' => 'value', + ], + ], + 'status' => '200', + ], + ], $result); + + $tester->assert(); + } + + /** @test */ + public function it_run_addressbook_query_report() + { + $tester = (new DavTester()) + ->addResponse('https://test', Http::response(DavTester::multistatusHeader(). + ''. + 'href'. + ''. + ''. + '"00001-abcd1"'. + 'value'. + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + ''), ''."\n". + ''. + ''. + ''. + ''. + "\n", 'REPORT') + ->fake(); + + $client = $tester->client(); + + $result = $client->addressbookQuery(['{DAV:}test']); + + $tester->assert(); + $this->assertEquals([ + 'href' => [ + 'properties' => [ + 200 => [ + '{DAV:}getetag' => '"00001-abcd1"', + '{DAV:}test' => 'value', + ], + ], + 'status' => '200', + ], + ], $result); + } + + /** @test */ + public function it_run_proppatch() + { + $tester = (new DavTester()) + ->addResponse('https://test', Http::response(DavTester::multistatusHeader(). + ''. + 'href'. + ''. + ''. + 'value'. + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + '', 207), ''."\n". + ''."\n". + ' '."\n". + ' '."\n". + ' value'."\n". + ' '."\n". + ' '."\n". + "\n", 'PROPPATCH') + ->fake(); + + $client = $tester->client(); + + $result = $client->propPatch(['{DAV:}test' => 'value']); + + $tester->assert(); + $this->assertTrue($result); + } + + /** @test */ + public function it_run_proppatch_error() + { + $tester = (new DavTester()) + ->addResponse('https://test', Http::response(DavTester::multistatusHeader(). + ''. + 'href'. + ''. + ''. + 'x'. + ''. + 'HTTP/1.1 405 OK'. + ''. + ''. + ''. + 'x'. + ''. + 'HTTP/1.1 500 OK'. + ''. + ''. + '', 207), ''."\n". + ''."\n". + ' '."\n". + ' '."\n". + ' value'."\n". + ' value'."\n". + ' '."\n". + ' '."\n". + "\n", 'PROPPATCH') + ->fake(); + + $client = $tester->client(); + + $this->expectException(DavClientException::class); + $this->expectExceptionMessage('PROPPATCH failed. The following properties errored: {DAV:}test (405), {DAV:}excerpt (500)'); + $client->propPatch([ + '{DAV:}test' => 'value', + '{DAV:}excerpt' => 'value', + ]); + } +} diff --git a/tests/Unit/Domains/Contact/DavClient/Services/Utils/Dav/ServiceUrlQueryTest.php b/tests/Unit/Domains/Contact/DavClient/Services/Utils/Dav/ServiceUrlQueryTest.php new file mode 100644 index 00000000000..6fe76774a3e --- /dev/null +++ b/tests/Unit/Domains/Contact/DavClient/Services/Utils/Dav/ServiceUrlQueryTest.php @@ -0,0 +1,146 @@ +addResponse('https://carddav.test.com', Http::response(), null, 'GET') + ->fake(); + + $t = $this; + + $tester->http->macro('getDnsRecord', function (string $hostname, int $type) use ($t): ?Collection { + $t->assertEquals('srv.test.com', $hostname); + $t->assertEquals(DNS_SRV, $type); + + return collect([ + [ + 'type' => 'SRV', + 'pri' => 0, + 'weight' => 1, + 'port' => 443, + 'target' => 'carddav.test.com', + ], + ]); + }); + + (new ServiceUrlQuery) + ->withClient($tester->client()) + ->execute('srv', true, 'https://test.com'); + + $tester->assert(); + } + + /** @test */ + public function it_get_service_url_by_weight() + { + $tester = (new DavTester('https://test.com')) + ->addResponse('https://bad.test.com', Http::response(status: 404), method: 'GET') + ->addResponse('https://carddav.test.com', Http::response(), method: 'GET') + ->fake(); + + $t = $this; + + $tester->http->macro('getDnsRecord', function (string $hostname, int $type) use ($t): ?Collection { + $t->assertEquals('srv.test.com', $hostname); + $t->assertEquals(DNS_SRV, $type); + + return collect([ + [ + 'type' => 'SRV', + 'pri' => 0, + 'weight' => 10, + 'port' => 443, + 'target' => 'bad.test.com', + ], + [ + 'type' => 'SRV', + 'pri' => 0, + 'weight' => 1, + 'port' => 443, + 'target' => 'carddav.test.com', + ], + ]); + }); + + (new ServiceUrlQuery) + ->withClient($tester->client()) + ->execute('srv', true, 'https://test.com'); + + $tester->assert(); + } + + /** @test */ + public function it_get_service_url_by_pri() + { + $tester = (new DavTester('https://test.com')) + ->addResponse('https://bad.test.com', Http::response(status: 404), method: 'GET') + ->addResponse('https://carddav.test.com', Http::response(), method: 'GET') + ->fake(); + + $t = $this; + + $tester->http->macro('getDnsRecord', function (string $hostname, int $type) use ($t): ?Collection { + $t->assertEquals('srv.test.com', $hostname); + $t->assertEquals(DNS_SRV, $type); + + return collect([ + [ + 'type' => 'SRV', + 'pri' => 0, + 'weight' => 1, + 'port' => 443, + 'target' => 'bad.test.com', + ], + [ + 'type' => 'SRV', + 'pri' => 1, + 'weight' => 1, + 'port' => 443, + 'target' => 'carddav.test.com', + ], + ]); + }); + + (new ServiceUrlQuery) + ->withClient($tester->client()) + ->execute('srv', true, 'https://test.com'); + + $tester->assert(); + } + + /** @test */ + public function it_get_service_null() + { + $tester = (new DavTester('https://test.com')) + ->fake(); + + $t = $this; + + $tester->http->macro('getDnsRecord', function (string $hostname, int $type) use ($t): ?Collection { + $t->assertEquals('srv.test.com', $hostname); + $t->assertEquals(DNS_SRV, $type); + + return null; + }); + + (new ServiceUrlQuery) + ->withClient($tester->client()) + ->execute('srv', true, 'https://test.com'); + + $tester->assert(); + } +} diff --git a/tests/Unit/Domains/Contact/DavClient/Services/Utils/PrepareJobsContactPushMissedTest.php b/tests/Unit/Domains/Contact/DavClient/Services/Utils/PrepareJobsContactPushMissedTest.php new file mode 100644 index 00000000000..1d14ae7e6a8 --- /dev/null +++ b/tests/Unit/Domains/Contact/DavClient/Services/Utils/PrepareJobsContactPushMissedTest.php @@ -0,0 +1,83 @@ +create(); + $this->setPermissionInVault($subscription->user, Vault::PERMISSION_MANAGE, $subscription->vault); + $token = SyncToken::factory()->create([ + 'account_id' => $subscription->user->account_id, + 'user_id' => $subscription->user_id, + 'name' => 'contacts1', + 'timestamp' => now()->addDays(-1), + ]); + $subscription->sync_token_id = $token->id; + $subscription->save(); + + $contact = Contact::factory()->create([ + 'vault_id' => $subscription->vault_id, + 'first_name' => 'Test', + 'id' => 'affacde9-b2fe-4371-9acb-6612aaee6971', + ]); + $card = $this->getCard($contact); + $etag = $this->getEtag($contact, true); + + $this->mock(CardDAVBackend::class, function (MockInterface $mock) use ($card, $etag, $contact) { + $mock->shouldReceive('withUser')->andReturnSelf(); + $mock->shouldReceive('getUuid') + ->once() + ->withArgs(function ($uri) { + $this->assertEquals('uuid6', $uri); + + return true; + }) + ->andReturn('uuid3'); + $mock->shouldReceive('prepareCard') + ->once() + ->withArgs(function ($c) use ($contact) { + $this->assertEquals($contact, $c); + + return true; + }) + ->andReturn([ + 'account_id' => $contact->account_id, + 'contact_id' => $contact->id, + 'carddata' => $card, + 'uri' => 'uuid3', + 'etag' => $etag, + ]); + }); + + $batchs = (new PrepareJobsContactPushMissed) + ->withSubscription($subscription) + ->execute(collect(), collect([ + 'uuid6' => new ContactDto('uuid6', $etag), + ]), collect([$contact])); + + $this->assertCount(1, $batchs); + $batch = $batchs->first(); + $this->assertInstanceOf(PushVCard::class, $batch); + $this->assertEquals('uuid3', $batch->uri); + $this->assertEquals(PushVCard::MODE_MATCH_NONE, $batch->mode); + } +} diff --git a/tests/Unit/Domains/Contact/DavClient/Services/Utils/PrepareJobsContactPushTest.php b/tests/Unit/Domains/Contact/DavClient/Services/Utils/PrepareJobsContactPushTest.php new file mode 100644 index 00000000000..b38e5c0bcd1 --- /dev/null +++ b/tests/Unit/Domains/Contact/DavClient/Services/Utils/PrepareJobsContactPushTest.php @@ -0,0 +1,167 @@ +create(); + $token = SyncToken::factory()->create([ + 'account_id' => $subscription->user->account_id, + 'user_id' => $subscription->user_id, + 'name' => 'contacts1', + 'timestamp' => now()->addDays(-1), + ]); + $subscription->sync_token_id = $token->id; + $subscription->save(); + + $contact = Contact::factory()->create([ + 'vault_id' => $subscription->vault_id, + 'first_name' => 'Test', + 'id' => 'affacde9-b2fe-4371-9acb-6612aaee6971', + ]); + $card = $this->getCard($contact); + $etag = $this->getEtag($contact, true); + + $this->mock(CardDAVBackend::class, function (MockInterface $mock) use ($contact, $card, $etag) { + $mock->shouldReceive('withUser')->andReturnSelf(); + $mock->shouldReceive('getCard') + ->withArgs(function ($name, $uri) { + $this->assertEquals($uri, 'uricontact2'); + + return true; + }) + ->andReturn([ + 'contact_id' => $contact->id, + 'carddata' => $card, + 'etag' => $etag, + 'distant_etag' => $etag, + ]); + $mock->shouldReceive('getUuid') + ->withArgs(function ($uri) { + if ($uri !== 'uricontact2' && $uri !== 'https://test/dav/uricontact1') { + $this->fail("Invalid uri: $uri"); + + return false; + } + + return true; + }) + ->andReturnUsing(function ($uri) { + return Str::contains($uri, 'uricontact1') ? 'uricontact1' : 'uricontact2'; + }); + }); + + $batchs = (new PrepareJobsContactPush) + ->withSubscription($subscription) + ->execute(collect([ + 'added' => collect(['uricontact2']), + ]), collect([ + 'https://test/dav/uricontact1' => new ContactDto('https://test/dav/uricontact1', $etag), + ])); + + $this->assertCount(1, $batchs); + $batch = $batchs->first(); + $this->assertInstanceOf(PushVCard::class, $batch); + $this->assertEquals('uricontact2', $batch->uri); + $this->assertEquals(PushVCard::MODE_MATCH_NONE, $batch->mode); + } + + /** @test */ + public function it_push_contacts_modified() + { + $subscription = AddressBookSubscription::factory()->create(); + $token = SyncToken::factory()->create([ + 'account_id' => $subscription->user->account_id, + 'user_id' => $subscription->user_id, + 'name' => 'contacts1', + 'timestamp' => now()->addDays(-1), + ]); + $subscription->sync_token_id = $token->id; + $subscription->save(); + + $contact = Contact::factory()->create([ + 'vault_id' => $subscription->vault_id, + 'first_name' => 'Test', + 'id' => 'affacde9-b2fe-4371-9acb-6612aaee6971', + ]); + $card = $this->getCard($contact); + $etag = $this->getEtag($contact, true); + + $this->mock(CardDAVBackend::class, function (MockInterface $mock) use ($contact, $card, $etag) { + $mock->shouldReceive('withUser')->andReturnSelf(); + $mock->shouldReceive('getUuid') + ->withArgs(function ($uri) { + $this->assertStringContainsString('uricontact', $uri); + + return true; + }) + ->andReturnUsing(function ($uri) { + return Str::contains($uri, 'uricontact1') ? 'uricontact1' : 'uricontact2'; + }); + $mock->shouldReceive('getCard') + ->withArgs(function ($name, $uri) { + $this->assertEquals($uri, 'uricontact2'); + + return true; + }) + ->andReturn([ + 'contact_id' => $contact->id, + 'carddata' => $card, + 'etag' => $etag, + 'distant_etag' => $etag, + ]); + }); + + $batchs = (new PrepareJobsContactPush) + ->withSubscription($subscription) + ->execute(collect([ + 'modified' => collect(['uricontact2']), + ]), collect([ + 'https://test/dav/uricontact1' => new ContactDto('https://test/dav/uricontact1', $etag), + ])); + + $this->assertCount(1, $batchs); + $batch = $batchs->first(); + $this->assertInstanceOf(PushVCard::class, $batch); + $this->assertEquals('uricontact2', $batch->uri); + $this->assertEquals(1, $batch->mode); + } + + /** @test */ + public function it_delete_contacts_removed() + { + $subscription = AddressBookSubscription::factory()->create(); + + $batchs = (new PrepareJobsContactPush) + ->withSubscription($subscription) + ->execute(collect([ + 'deleted' => collect(['uricontact2']), + ])); + + $this->assertCount(1, $batchs); + $batch = $batchs->first(); + $this->assertInstanceOf(DeleteVCard::class, $batch); + $uri = $this->getPrivateValue($batch, 'uri'); + $this->assertEquals('uricontact2', $uri); + } +} diff --git a/tests/Unit/Domains/Contact/DavClient/Services/Utils/PrepareJobsContactUpdaterTest.php b/tests/Unit/Domains/Contact/DavClient/Services/Utils/PrepareJobsContactUpdaterTest.php new file mode 100644 index 00000000000..89747b2dc25 --- /dev/null +++ b/tests/Unit/Domains/Contact/DavClient/Services/Utils/PrepareJobsContactUpdaterTest.php @@ -0,0 +1,199 @@ +create(); + $token = SyncToken::factory()->create([ + 'account_id' => $subscription->user->account_id, + 'user_id' => $subscription->user_id, + 'name' => 'contacts1', + 'timestamp' => now()->addDays(-1), + ]); + $subscription->sync_token_id = $token->id; + $subscription->save(); + + $contact = Contact::factory()->create([ + 'vault_id' => $subscription->vault_id, + 'first_name' => 'Test', + 'id' => 'affacde9-b2fe-4371-9acb-6612aaee6971', + 'updated_at' => now(), + ]); + $card = $this->getCard($contact); + $etag = $this->getEtag($contact, true); + + $this->mock(CardDAVBackend::class, function (MockInterface $mock) use ($card, $etag) { + $mock->shouldReceive('withUser')->andReturnSelf(); + $mock->shouldReceive('updateCard') + ->withArgs(function ($addressBookId, $cardUri, $cardData) use ($card) { + $this->assertEquals($card, $cardData); + + return true; + }) + ->andReturn($etag); + }); + + $batchs = (new PrepareJobsContactUpdater) + ->withSubscription($subscription) + ->execute(collect([ + 'https://test/dav/uuid2' => new ContactDto('https://test/dav/uuid2', $etag), + ])); + + $this->assertCount(1, $batchs); + $batch = $batchs->first(); + $this->assertInstanceOf(GetMultipleVCard::class, $batch); + $hrefs = $this->getPrivateValue($batch, 'hrefs'); + $this->assertEquals(['https://test/dav/uuid2'], $hrefs); + } + + /** @test */ + public function it_sync_deleted_multiget() + { + $subscription = AddressBookSubscription::factory()->create(); + $token = SyncToken::factory()->create([ + 'account_id' => $subscription->user->account_id, + 'user_id' => $subscription->user_id, + 'name' => 'contacts1', + 'timestamp' => now()->addDays(-1), + ]); + $subscription->sync_token_id = $token->id; + $subscription->save(); + + $batchs = (new PrepareJobsContactUpdater) + ->withSubscription($subscription) + ->execute(collect([ + 'https://test/dav/uuid2' => new ContactDeleteDto('https://test/dav/uuid2'), + ])); + + $this->assertCount(1, $batchs); + $batch = $batchs->first(); + $this->assertInstanceOf(DeleteMultipleVCard::class, $batch); + $hrefs = $this->getPrivateValue($batch, 'hrefs'); + $this->assertEquals(['https://test/dav/uuid2'], $hrefs); + } + + /** @test */ + public function it_sync_changes_simple() + { + $subscription = AddressBookSubscription::factory()->create([ + 'capabilities' => [ + 'addressbookMultiget' => false, + 'addressbookQuery' => true, + 'syncCollection' => true, + 'addressData' => [ + 'content-type' => 'text/vcard', + 'version' => '4.0', + ], + ], + ]); + $token = SyncToken::factory()->create([ + 'account_id' => $subscription->user->account_id, + 'user_id' => $subscription->user_id, + 'name' => 'contacts1', + 'timestamp' => now()->addDays(-1), + ]); + $subscription->sync_token_id = $token->id; + $subscription->save(); + + $contact = Contact::factory()->create([ + 'vault_id' => $subscription->vault_id, + 'first_name' => 'Test', + 'id' => 'affacde9-b2fe-4371-9acb-6612aaee6971', + 'updated_at' => now(), + ]); + $card = $this->getCard($contact); + $etag = $this->getEtag($contact, true); + + $this->mock(CardDAVBackend::class, function (MockInterface $mock) use ($card, $etag) { + $mock->shouldReceive('withUser')->andReturnSelf(); + $mock->shouldReceive('updateCard') + ->withArgs(function ($addressBookId, $cardUri, $cardData) use ($card) { + $this->assertTrue(is_resource($cardData)); + + $data = ''; + while (! feof($cardData)) { + $data .= fgets($cardData); + } + + fclose($cardData); + + $this->assertEquals($card, $data); + + return true; + }) + ->andReturn($etag); + }); + + $batchs = (new PrepareJobsContactUpdater) + ->withSubscription($subscription) + ->execute(collect([ + 'https://test/dav/uuid2' => new ContactDto('https://test/dav/uuid2', $etag), + ])); + + $this->assertCount(1, $batchs); + $batch = $batchs->first(); + $this->assertInstanceOf(GetVCard::class, $batch); + $dto = $this->getPrivateValue($batch, 'contact'); + $this->assertInstanceOf(ContactDto::class, $dto); + $this->assertEquals('https://test/dav/uuid2', $dto->uri); + } + + /** @test */ + public function it_sync_deleted_simple() + { + $subscription = AddressBookSubscription::factory()->create([ + 'capabilities' => [ + 'addressbookMultiget' => false, + 'addressbookQuery' => true, + 'syncCollection' => true, + 'addressData' => [ + 'content-type' => 'text/vcard', + 'version' => '4.0', + ], + ], + ]); + $token = SyncToken::factory()->create([ + 'account_id' => $subscription->user->account_id, + 'user_id' => $subscription->user_id, + 'name' => 'contacts1', + 'timestamp' => now()->addDays(-1), + ]); + $subscription->sync_token_id = $token->id; + $subscription->save(); + + $batchs = (new PrepareJobsContactUpdater) + ->withSubscription($subscription) + ->execute(collect([ + 'https://test/dav/uuid2' => new ContactDeleteDto('https://test/dav/uuid2'), + ])); + + $this->assertCount(1, $batchs); + $batch = $batchs->first(); + $this->assertInstanceOf(DeleteVCard::class, $batch); + $uri = $this->getPrivateValue($batch, 'uri'); + $this->assertEquals('https://test/dav/uuid2', $uri); + } +} diff --git a/tests/Unit/Domains/Contact/ManageGroups/Dav/ImportMembersTest.php b/tests/Unit/Domains/Contact/ManageGroups/Dav/ImportMembersTest.php index 1faf354af59..de882bdf09d 100644 --- a/tests/Unit/Domains/Contact/ManageGroups/Dav/ImportMembersTest.php +++ b/tests/Unit/Domains/Contact/ManageGroups/Dav/ImportMembersTest.php @@ -143,9 +143,8 @@ public function it_keeps_existing_members_and_add_new() $this->invokePrivateMethod($importGroup, 'updateGroupMembers', [$group, collect($members)]); $group = $group->refresh(); - $contacts = collect($group->contacts->all())->map(fn ($c) => $c->id)->toArray(); - - $this->assertEquals($members, $contacts); + collect($group->contacts->all())->each(fn ($contact) => $this->assertContains($contact->id, $members) + ); } /** @test */ @@ -181,8 +180,7 @@ public function it_removes_old_members() $this->invokePrivateMethod($importGroup, 'updateGroupMembers', [$group, collect($members)]); $group = $group->refresh(); - $contacts = collect($group->contacts->all())->map(fn ($c) => $c->id)->toArray(); - - $this->assertEquals($members, $contacts); + collect($group->contacts->all())->each(fn ($contact) => $this->assertContains($contact->id, $members) + ); } }