diff --git a/app/Console/Commands/DavClientsUpdate.php b/app/Console/Commands/DavClientsUpdate.php new file mode 100644 index 00000000000..dacb569544c --- /dev/null +++ b/app/Console/Commands/DavClientsUpdate.php @@ -0,0 +1,55 @@ +get(); + + $now = now(); + $subscriptions->filter(function ($subscription) use ($now) { + return $this->isTimeToRunSync($subscription, $now); + })->each(function ($subscription) { + SynchronizeAddressBooks::dispatch($subscription); + }); + } + + /** + * Test if the last synchronized timestamp is older than the subscription's frequency time. + * + * @param AddressBookSubscription $subscription + * @param Carbon $now + * @return bool + */ + private function isTimeToRunSync(AddressBookSubscription $subscription, Carbon $now): bool + { + return is_null($subscription->last_synchronized_at) + || $subscription->last_synchronized_at->addMinutes($subscription->frequency)->lessThan($now); + } +} diff --git a/app/Console/Commands/NewAddressBookSubscription.php b/app/Console/Commands/NewAddressBookSubscription.php new file mode 100644 index 00000000000..3a50a996987 --- /dev/null +++ b/app/Console/Commands/NewAddressBookSubscription.php @@ -0,0 +1,60 @@ +option('email'))->firstOrFail(); + + $url = $this->option('url') ?? $this->ask('url', 'CardDAV url of the address book'); + $login = $this->option('login') ?? $this->ask('login', 'Login name'); + $password = $this->option('password') ?? $this->ask('password', 'User password'); + + try { + $addressBook = app(CreateAddressBookSubscription::class)->execute([ + 'account_id' => $user->account_id, + 'user_id' => $user->id, + 'base_uri' => $url, + 'username' => $login, + 'password' => $password, + ]); + } catch (\Exception $e) { + $this->error($e->getMessage()); + } + + if (! isset($addressBook)) { + $this->error('Could not add subscription'); + } else { + $this->info('Subscription added'); + } + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index fd428da3800..a5fdb81edfa 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -43,6 +43,7 @@ protected function schedule(Schedule $schedule) { $this->scheduleCommand($schedule, 'send:reminders', 'hourly'); $this->scheduleCommand($schedule, 'send:stay_in_touch', 'hourly'); + $this->scheduleCommand($schedule, 'monica:davclients', 'hourly'); $this->scheduleCommand($schedule, 'monica:calculatestatistics', 'daily'); $this->scheduleCommand($schedule, 'monica:ping', 'daily'); $this->scheduleCommand($schedule, 'monica:clean', 'daily'); diff --git a/app/Helpers/SearchHelper.php b/app/Helpers/SearchHelper.php index 2d9aa79e4ab..87dc394f650 100644 --- a/app/Helpers/SearchHelper.php +++ b/app/Helpers/SearchHelper.php @@ -35,7 +35,7 @@ public static function searchContacts(string $needle, string $orderByColumn, str $field_id = is_null($field) ? 0 : $field->id; /** @var Builder */ - $b = Contact::whereHas('contactFields', function ($query) use ($accountId, $field_id, $search_term) { + $builder = Contact::whereHas('contactFields', function ($query) use ($accountId, $field_id, $search_term) { $query->where([ ['account_id', $accountId], ['data', 'like', "$search_term%"], @@ -43,8 +43,8 @@ public static function searchContacts(string $needle, string $orderByColumn, str ]); }); - return $b->addressBook($accountId, $addressBookName) - ->orderBy($orderByColumn, $orderByDirection); + return $builder->addressBook($accountId, $addressBookName) + ->orderBy($orderByColumn, $orderByDirection); } return Contact::search($needle, $accountId, $orderByColumn, $orderByDirection) diff --git a/app/Http/Controllers/DAV/Auth/AuthBackend.php b/app/Http/Controllers/DAV/Auth/AuthBackend.php index ba25c68c760..5df8a8055e0 100644 --- a/app/Http/Controllers/DAV/Auth/AuthBackend.php +++ b/app/Http/Controllers/DAV/Auth/AuthBackend.php @@ -44,7 +44,7 @@ public function check(RequestInterface $request, ResponseInterface $response) return [false, 'User is not authenticated']; } - return [true, PrincipalBackend::getPrincipalUser()]; + return [true, PrincipalBackend::getPrincipalUser(Auth::user())]; } /** diff --git a/app/Http/Controllers/DAV/Backend/CalDAV/AbstractCalDAVBackend.php b/app/Http/Controllers/DAV/Backend/CalDAV/AbstractCalDAVBackend.php index 2095d6c5131..b6d259800a8 100644 --- a/app/Http/Controllers/DAV/Backend/CalDAV/AbstractCalDAVBackend.php +++ b/app/Http/Controllers/DAV/Backend/CalDAV/AbstractCalDAVBackend.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\DAV\Backend\CalDAV; +use App\Models\User\User; use Sabre\DAV\Server as SabreServer; use Sabre\CalDAV\Plugin as CalDAVPlugin; use Sabre\DAV\Sync\Plugin as DAVSyncPlugin; @@ -13,18 +14,40 @@ abstract class AbstractCalDAVBackend implements ICalDAVBackend, IDAVBackend { use SyncDAVBackend; + /** + * Create a new instance of AbstractCalDAVBackend. + * + * @param User $user + */ + public function __construct($user) + { + $this->user = $user; + } + + /** + * Get description array. + * + * @return array + */ public function getDescription() { $token = DAVSyncPlugin::SYNCTOKEN_PREFIX.$this->refreshSyncToken(null)->id; - $des = [ + + return [ 'id' => $this->backendUri(), 'uri' => $this->backendUri(), - 'principaluri' => PrincipalBackend::getPrincipalUser(), + 'principaluri' => PrincipalBackend::getPrincipalUser($this->user), '{DAV:}sync-token' => $token, '{'.SabreServer::NS_SABREDAV.'}sync-token' => $token, '{'.CalDAVPlugin::NS_CALENDARSERVER.'}getctag' => $token, ]; - - return $des; } + + /** + * Get the new exported version of the object. + * + * @param mixed $obj + * @return string + */ + abstract protected function refreshObject($obj): string; } diff --git a/app/Http/Controllers/DAV/Backend/CalDAV/CalDAVBackend.php b/app/Http/Controllers/DAV/Backend/CalDAV/CalDAVBackend.php index 25493c3edbb..e388b7e429a 100644 --- a/app/Http/Controllers/DAV/Backend/CalDAV/CalDAVBackend.php +++ b/app/Http/Controllers/DAV/Backend/CalDAV/CalDAVBackend.php @@ -3,11 +3,27 @@ namespace App\Http\Controllers\DAV\Backend\CalDAV; use Sabre\DAV; +use App\Models\User\User; use Sabre\CalDAV\Backend\SyncSupport; use Sabre\CalDAV\Backend\AbstractBackend; class CalDAVBackend extends AbstractBackend implements SyncSupport { + /** + * @var User + */ + protected $user; + + /** + * Create a new instance of CalDAVBackend. + * + * @param User $user + */ + public function __construct(User $user) + { + $this->user = $user; + } + /** * Set the Calendar backends. * @@ -16,8 +32,8 @@ class CalDAVBackend extends AbstractBackend implements SyncSupport private function getBackends(): array { return [ - new CalDAVBirthdays(), - new CalDAVTasks(), + new CalDAVBirthdays($this->user), + new CalDAVTasks($this->user), ]; } diff --git a/app/Http/Controllers/DAV/Backend/CalDAV/CalDAVBirthdays.php b/app/Http/Controllers/DAV/Backend/CalDAV/CalDAVBirthdays.php index 0ee6d03e8f3..15de2a60e04 100644 --- a/app/Http/Controllers/DAV/Backend/CalDAV/CalDAVBirthdays.php +++ b/app/Http/Controllers/DAV/Backend/CalDAV/CalDAVBirthdays.php @@ -4,7 +4,6 @@ use Illuminate\Support\Facades\Log; use App\Models\Instance\SpecialDate; -use Illuminate\Support\Facades\Auth; use Sabre\DAV\Server as SabreServer; use Sabre\CalDAV\Plugin as CalDAVPlugin; use App\Services\VCalendar\ExportVCalendar; @@ -29,8 +28,8 @@ public function getDescription() + [ '{DAV:}displayname' => trans('app.dav_birthdays'), '{'.SabreServer::NS_SABREDAV.'}read-only' => true, - '{'.CalDAVPlugin::NS_CALDAV.'}calendar-description' => trans('app.dav_birthdays_description', ['name' => Auth::user()->name]), - '{'.CalDAVPlugin::NS_CALDAV.'}calendar-timezone' => Auth::user()->timezone, + '{'.CalDAVPlugin::NS_CALDAV.'}calendar-description' => trans('app.dav_birthdays_description', ['name' => $this->user->name]), + '{'.CalDAVPlugin::NS_CALDAV.'}calendar-timezone' => $this->user->timezone, '{'.CalDAVPlugin::NS_CALDAV.'}supported-calendar-component-set' => new SupportedCalendarComponentSet(['VEVENT']), '{'.CalDAVPlugin::NS_CALDAV.'}schedule-calendar-transp' => new ScheduleCalendarTransp(ScheduleCalendarTransp::TRANSPARENT), ]; @@ -56,13 +55,7 @@ public function prepareData($obj) { if ($obj instanceof SpecialDate) { try { - $vcal = app(ExportVCalendar::class) - ->execute([ - 'account_id' => Auth::user()->account_id, - 'special_date_id' => $obj->id, - ]); - - $calendardata = $vcal->serialize(); + $calendardata = $this->refreshObject($obj); return [ 'id' => $obj->id, @@ -79,6 +72,23 @@ public function prepareData($obj) return []; } + /** + * Get the new exported version of the object. + * + * @param mixed $obj date + * @return string + */ + protected function refreshObject($obj): string + { + $vcal = app(ExportVCalendar::class) + ->execute([ + 'account_id' => $this->user->account_id, + 'special_date_id' => $obj->id, + ]); + + return $vcal->serialize(); + } + private function hasBirthday($contact) { if (! $contact || ! $contact->birthdate) { @@ -102,7 +112,7 @@ private function hasBirthday($contact) public function getObjectUuid($collectionId, $uuid) { return SpecialDate::where([ - 'account_id' => Auth::user()->account_id, + 'account_id' => $this->user->account_id, 'uuid' => $uuid, ])->first(); } @@ -115,7 +125,7 @@ public function getObjectUuid($collectionId, $uuid) public function getObjects($collectionId) { // We only return the birthday of default addressBook - $contacts = Auth::user()->account->contacts() + $contacts = $this->user->account->contacts() ->real() ->active() ->get(); diff --git a/app/Http/Controllers/DAV/Backend/CalDAV/CalDAVTasks.php b/app/Http/Controllers/DAV/Backend/CalDAV/CalDAVTasks.php index 863dffb8d48..1cd43ed1597 100644 --- a/app/Http/Controllers/DAV/Backend/CalDAV/CalDAVTasks.php +++ b/app/Http/Controllers/DAV/Backend/CalDAV/CalDAVTasks.php @@ -6,7 +6,6 @@ use App\Models\Contact\Task; use App\Services\Task\DestroyTask; use Illuminate\Support\Facades\Log; -use Illuminate\Support\Facades\Auth; use App\Services\VCalendar\ExportTask; use App\Services\VCalendar\ImportTask; use Sabre\CalDAV\Plugin as CalDAVPlugin; @@ -30,8 +29,8 @@ public function getDescription() return parent::getDescription() + [ '{DAV:}displayname' => trans('app.dav_tasks'), - '{'.CalDAVPlugin::NS_CALDAV.'}calendar-description' => trans('app.dav_tasks_description', ['name' => Auth::user()->name]), - '{'.CalDAVPlugin::NS_CALDAV.'}calendar-timezone' => Auth::user()->timezone, + '{'.CalDAVPlugin::NS_CALDAV.'}calendar-description' => trans('app.dav_tasks_description', ['name' => $this->user->name]), + '{'.CalDAVPlugin::NS_CALDAV.'}calendar-timezone' => $this->user->timezone, '{'.CalDAVPlugin::NS_CALDAV.'}supported-calendar-component-set' => new SupportedCalendarComponentSet(['VTODO']), '{'.CalDAVPlugin::NS_CALDAV.'}schedule-calendar-transp' => new ScheduleCalendarTransp(ScheduleCalendarTransp::TRANSPARENT), ]; @@ -40,11 +39,12 @@ public function getDescription() /** * Returns the collection of all tasks. * + * @param mixed|null $collectionId * @return \Illuminate\Support\Collection */ public function getObjects($collectionId) { - return Auth::user()->account + return $this->user->account ->tasks() ->get(); } @@ -59,7 +59,7 @@ public function getObjects($collectionId) public function getObjectUuid($collectionId, $uuid) { return Task::where([ - 'account_id' => Auth::user()->account_id, + 'account_id' => $this->user->account_id, 'uuid' => $uuid, ])->first(); } @@ -84,13 +84,7 @@ public function prepareData($obj) { if ($obj instanceof Task) { try { - $vcal = app(ExportTask::class) - ->execute([ - 'account_id' => Auth::user()->account_id, - 'task_id' => $obj->id, - ]); - - $calendardata = $vcal->serialize(); + $calendardata = $this->refreshObject($obj); return [ 'id' => $obj->id, @@ -107,6 +101,23 @@ public function prepareData($obj) return []; } + /** + * Get the new exported version of the object. + * + * @param mixed $obj task + * @return string + */ + protected function refreshObject($obj): string + { + $vcal = app(ExportTask::class) + ->execute([ + 'account_id' => $this->user->account_id, + 'task_id' => $obj->id, + ]); + + return $vcal->serialize(); + } + /** * Updates an existing calendarobject, based on it's uri. * @@ -138,13 +149,13 @@ public function updateOrCreateCalendarObject($calendarId, $objectUri, $calendarD try { $result = app(ImportTask::class) ->execute([ - 'account_id' => Auth::user()->account_id, + 'account_id' => $this->user->account_id, 'task_id' => $task_id, 'entry' => $calendarData, ]); if (! Arr::has($result, 'error')) { - $task = Task::where('account_id', Auth::user()->account_id) + $task = Task::where('account_id', $this->user->account_id) ->find($result['task_id']); $calendar = $this->prepareData($task); @@ -174,7 +185,7 @@ public function deleteCalendarObject($objectUri) try { app(DestroyTask::class) ->execute([ - 'account_id' => Auth::user()->account_id, + 'account_id' => $this->user->account_id, 'task_id' => $task->id, ]); } catch (\Exception $e) { diff --git a/app/Http/Controllers/DAV/Backend/CardDAV/AddressBookHome.php b/app/Http/Controllers/DAV/Backend/CardDAV/AddressBookHome.php index 3858544fc70..bb0ed96a95b 100644 --- a/app/Http/Controllers/DAV/Backend/CardDAV/AddressBookHome.php +++ b/app/Http/Controllers/DAV/Backend/CardDAV/AddressBookHome.php @@ -36,10 +36,10 @@ public function getACL() */ public function getChildren() { - $addressbooks = $this->carddavBackend->getAddressBooksForUser($this->principalUri); + $addressBooks = $this->carddavBackend->getAddressBooksForUser($this->principalUri); - return collect($addressbooks)->map(function ($addressbook) { - return new AddressBook($this->carddavBackend, $addressbook); + return collect($addressBooks)->map(function ($addressBook) { + return new AddressBook($this->carddavBackend, $addressBook); })->toArray(); } } diff --git a/app/Http/Controllers/DAV/Backend/CardDAV/CardDAVBackend.php b/app/Http/Controllers/DAV/Backend/CardDAV/CardDAVBackend.php index 40df059aaf4..3ae97e081be 100644 --- a/app/Http/Controllers/DAV/Backend/CardDAV/CardDAVBackend.php +++ b/app/Http/Controllers/DAV/Backend/CardDAV/CardDAVBackend.php @@ -9,7 +9,6 @@ use App\Services\VCard\ExportVCard; use App\Services\VCard\ImportVCard; use Illuminate\Support\Facades\Log; -use Illuminate\Support\Facades\Auth; use Sabre\DAV\Server as SabreServer; use Sabre\CardDAV\Backend\SyncSupport; use Sabre\CalDAV\Plugin as CalDAVPlugin; @@ -25,6 +24,11 @@ class CardDAVBackend extends AbstractBackend implements SyncSupport, IDAVBackend { use SyncDAVBackend; + public function __construct($user) + { + $this->user = $user; + } + /** * Returns the uri for this backend. * @@ -57,11 +61,11 @@ public function getAddressBooksForUser($principalUri) $result = []; $result[] = $this->getDefaultAddressBook(); - $addressbooks = AddressBook::where('account_id', Auth::user()->account_id) + $addressBooks = AddressBook::where('account_id', $this->user->account_id) ->get(); - foreach ($addressbooks as $addressbook) { - $result[] = $this->getAddressBookDetails($addressbook); + foreach ($addressBooks as $addressBook) { + $result[] = $this->getAddressBookDetails($addressBook); } return $result; @@ -74,24 +78,24 @@ private function getDefaultAddressBook() $me = auth()->user()->me; if ($me) { $des += [ - '{'.CalDAVPlugin::NS_CALENDARSERVER.'}me-card' => '/'.config('laravelsabre.path').'/addressbooks/'.Auth::user()->email.'/contacts/'.$this->encodeUri($me), + '{'.CalDAVPlugin::NS_CALENDARSERVER.'}me-card' => '/'.config('laravelsabre.path').'/addressbooks/'.$this->user->email.'/contacts/'.$this->encodeUri($me), ]; } return $des; } - private function getAddressBookDetails($addressbook) + private function getAddressBookDetails($addressBook) { - $id = $addressbook ? $addressbook->name : $this->backendUri(); - $token = $this->getCurrentSyncToken($addressbook); + $id = $addressBook ? $addressBook->name : $this->backendUri(); + $token = $this->getCurrentSyncToken($addressBook); $des = [ 'id' => $id, 'uri' => $id, - 'principaluri' => PrincipalBackend::getPrincipalUser(), + 'principaluri' => PrincipalBackend::getPrincipalUser($this->user), '{DAV:}displayname' => trans('app.dav_contacts'), - '{'.CardDAVPlugin::NS_CARDDAV.'}addressbook-description' => $addressbook ? $addressbook->description : trans('app.dav_contacts_description', ['name' => Auth::user()->name]), + '{'.CardDAVPlugin::NS_CARDDAV.'}addressbook-description' => $addressBook ? $addressBook->description : trans('app.dav_contacts_description', ['name' => $this->user->name]), ]; if ($token) { $des += [ @@ -168,9 +172,9 @@ public function getExtension() * @param string $syncToken * @param int $syncLevel * @param int $limit - * @return array + * @return array|null */ - public function getChangesForAddressBook($addressBookId, $syncToken, $syncLevel, $limit = null) + public function getChangesForAddressBook($addressBookId, $syncToken, $syncLevel, $limit = null): ?array { return $this->getChanges($addressBookId, $syncToken); } @@ -181,14 +185,14 @@ public function getChangesForAddressBook($addressBookId, $syncToken, $syncLevel, * @param Contact $contact * @return array */ - private function prepareCard($contact): array + public function prepareCard($contact): array { try { $carddata = $contact->vcard; if (empty($carddata)) { $vcard = app(ExportVCard::class) ->execute([ - 'account_id' => Auth::user()->account_id, + 'account_id' => $this->user->account_id, 'contact_id' => $contact->id, ]); @@ -208,6 +212,23 @@ private function prepareCard($contact): array } } + /** + * Get the new exported version of the object. + * + * @param mixed $obj contact + * @return string + */ + protected function refreshObject($obj): string + { + $vcard = app(ExportVCard::class) + ->execute([ + 'account_id' => $this->user->account_id, + 'contact_id' => $obj->id, + ]); + + return $vcard->serialize(); + } + /** * Returns the contact for the specific uuid. * @@ -220,13 +241,13 @@ public function getObjectUuid($collectionId, $uuid) $addressBook = null; if ($collectionId && $collectionId != $this->backendUri()) { $addressBook = AddressBook::where([ - 'account_id' => Auth::user()->account_id, + 'account_id' => $this->user->account_id, 'name' => $collectionId, ])->first(); } return Contact::where([ - 'account_id' => Auth::user()->account_id, + 'account_id' => $this->user->account_id, 'uuid' => $uuid, 'address_book_id' => $addressBook ? $addressBook->id : null, ])->first(); @@ -236,11 +257,11 @@ public function getObjectUuid($collectionId, $uuid) * Returns the collection of all active contacts. * * @param string|null $collectionId - * @return \Illuminate\Support\Collection + * @return \Illuminate\Support\Collection */ public function getObjects($collectionId) { - return Auth::user()->account->contacts($collectionId) + return $this->user->account->contacts($collectionId) ->active() ->get(); } @@ -348,7 +369,7 @@ public function createCard($addressBookId, $cardUri, $cardData) * * @param mixed $addressBookId * @param string $cardUri - * @param string $cardData + * @param string|resource $cardData * @return string|null */ public function updateCard($addressBookId, $cardUri, $cardData): ?string @@ -365,8 +386,8 @@ public function updateCard($addressBookId, $cardUri, $cardData): ?string try { $result = app(ImportVCard::class) ->execute([ - 'account_id' => Auth::user()->account_id, - 'user_id' => Auth::user()->id, + 'account_id' => $this->user->account_id, + 'user_id' => $this->user->id, 'contact_id' => $contact_id, 'entry' => $cardData, 'behaviour' => ImportVCard::BEHAVIOUR_REPLACE, @@ -374,11 +395,10 @@ public function updateCard($addressBookId, $cardUri, $cardData): ?string ]); if (! Arr::has($result, 'error')) { - $contact = Contact::where('account_id', Auth::user()->account_id) + $contact = Contact::where('account_id', $this->user->account_id) ->find($result['contact_id']); - $card = $this->prepareCard($contact); - return $card['etag']; + return '"'.md5($contact->vcard).'"'; } } catch (\Exception $e) { Log::debug(__CLASS__.' updateCard: '.(string) $e); @@ -423,8 +443,8 @@ public function updateAddressBook($addressBookId, DAV\PropPatch $propPatch): ?bo $data = [ 'contact_id' => $contact->id, - 'account_id' => auth()->user()->account_id, - 'user_id' => auth()->user()->id, + 'account_id' => $this->user->account_id, + 'user_id' => $this->user->id, ]; app(SetMeContact::class)->execute($data); diff --git a/app/Http/Controllers/DAV/Backend/SyncDAVBackend.php b/app/Http/Controllers/DAV/Backend/SyncDAVBackend.php index 8c00f8a78e1..eeb1c694435 100644 --- a/app/Http/Controllers/DAV/Backend/SyncDAVBackend.php +++ b/app/Http/Controllers/DAV/Backend/SyncDAVBackend.php @@ -4,10 +4,14 @@ use Illuminate\Support\Str; use App\Models\User\SyncToken; -use Illuminate\Support\Facades\Auth; trait SyncDAVBackend { + /** + * @var \App\Models\User\User + */ + protected $user; + /** * This method returns a sync-token for this collection. * @@ -17,11 +21,11 @@ trait SyncDAVBackend * @param string|null $collectionId * @return SyncToken|null */ - protected function getCurrentSyncToken($collectionId): ?SyncToken + public function getCurrentSyncToken($collectionId): ?SyncToken { $tokens = SyncToken::where([ - 'account_id' => Auth::user()->account_id, - 'user_id' => Auth::user()->id, + 'account_id' => $this->user->account_id, + 'user_id' => $this->user->id, 'name' => $collectionId ?? $this->backendUri(), ]) ->orderBy('created_at') @@ -51,14 +55,15 @@ public function refreshSyncToken($collectionId): SyncToken * Get SyncToken by token id. * * @param string|null $collectionId + * @param string $syncToken * @return SyncToken|null */ protected function getSyncToken($collectionId, $syncToken) { /** @var SyncToken|null */ return SyncToken::where([ - 'account_id' => Auth::user()->account_id, - 'user_id' => Auth::user()->id, + 'account_id' => $this->user->account_id, + 'user_id' => $this->user->id, 'name' => $collectionId ?? $this->backendUri(), ]) ->find($syncToken); @@ -73,8 +78,8 @@ protected function getSyncToken($collectionId, $syncToken) private function createSyncTokenNow($collectionId) { return SyncToken::create([ - 'account_id' => Auth::user()->account_id, - 'user_id' => Auth::user()->id, + 'account_id' => $this->user->account_id, + 'user_id' => $this->user->id, 'name' => $collectionId ?? $this->backendUri(), 'timestamp' => now(), ]); @@ -179,6 +184,8 @@ public function getChanges($calendarId, $syncToken): ?array return $this->encodeUri($obj); })->values()->toArray(), 'modified' => $modified->map(function ($obj) { + $this->refreshObject($obj); + return $this->encodeUri($obj); })->values()->toArray(), 'deleted' => [], @@ -252,4 +259,12 @@ abstract public function getObjectUuid($collectionId, $uuid); abstract public function getObjects($collectionId); abstract public function getExtension(); + + /** + * Get the new exported version of the object. + * + * @param mixed $obj + * @return string + */ + abstract protected function refreshObject($obj): string; } diff --git a/app/Http/Controllers/DAV/DAVACL/PrincipalBackend.php b/app/Http/Controllers/DAV/DAVACL/PrincipalBackend.php index d8c2f43bae7..cf8b55b8628 100644 --- a/app/Http/Controllers/DAV/DAVACL/PrincipalBackend.php +++ b/app/Http/Controllers/DAV/DAVACL/PrincipalBackend.php @@ -4,7 +4,6 @@ use Sabre\DAV; use Illuminate\Support\Str; -use Illuminate\Support\Facades\Auth; use Sabre\DAV\Server as SabreServer; use Sabre\DAVACL\PrincipalBackend\AbstractBackend; @@ -18,22 +17,32 @@ class PrincipalBackend extends AbstractBackend public const PRINCIPAL_PREFIX = 'principals/'; /** - * Get the principal for current user. + * @var \App\Models\User\User + */ + protected $user; + + public function __construct($user) + { + $this->user = $user; + } + + /** + * Get the principal for user. * * @return string */ - public static function getPrincipalUser(): string + public static function getPrincipalUser($user): string { - return static::PRINCIPAL_PREFIX.Auth::user()->email; + return static::PRINCIPAL_PREFIX.$user->email; } protected function getPrincipals() { return [ [ - 'uri' => static::getPrincipalUser(), - '{DAV:}displayname' => Auth::user()->name, - '{'.SabreServer::NS_SABREDAV.'}email-address' => Auth::user()->email, + 'uri' => static::getPrincipalUser($this->user), + '{DAV:}displayname' => $this->user->name, + '{'.SabreServer::NS_SABREDAV.'}email-address' => $this->user->email, ], ]; } @@ -170,7 +179,9 @@ public function getGroupMemberSet($principal) return []; } - return [$principal['uri']]; + return [ + $principal['uri'], + ]; } /** diff --git a/app/Jobs/SynchronizeAddressBooks.php b/app/Jobs/SynchronizeAddressBooks.php new file mode 100644 index 00000000000..970dfed8691 --- /dev/null +++ b/app/Jobs/SynchronizeAddressBooks.php @@ -0,0 +1,53 @@ +subscription = $subscription; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + try { + app(SynchronizeAddressBook::class)->execute([ + 'account_id' => $this->subscription->account_id, + 'user_id' => $this->subscription->user_id, + 'addressbook_subscription_id' => $this->subscription->id, + ]); + } catch (\Exception $e) { + Log::error($e->getMessage(), [$e]); + } + $this->subscription->last_synchronized_at = now(); + $this->subscription->save(); + } +} diff --git a/app/Models/Account/AddressBook.php b/app/Models/Account/AddressBook.php index 5a38dd4e4d5..dadf880a022 100644 --- a/app/Models/Account/AddressBook.php +++ b/app/Models/Account/AddressBook.php @@ -7,9 +7,12 @@ use App\Models\ModelBinding as Model; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Factories\HasFactory; class AddressBook extends Model { + use HasFactory; + protected $table = 'addressbooks'; /** diff --git a/app/Models/Account/AddressBookSubscription.php b/app/Models/Account/AddressBookSubscription.php new file mode 100644 index 00000000000..8354f72ccc1 --- /dev/null +++ b/app/Models/Account/AddressBookSubscription.php @@ -0,0 +1,152 @@ + 'boolean', + 'active' => 'boolean', + 'localSyncToken' => 'integer', + ]; + + /** + * Get the account record associated with the subscription. + * + * @return BelongsTo + */ + public function account() + { + return $this->belongsTo(Account::class); + } + + /** + * Get the user record associated with the subscription. + * + * @return BelongsTo + */ + public function user() + { + return $this->belongsTo(User::class); + } + + /** + * Get the addressbook record associated with the subscription. + * + * @return BelongsTo + */ + public function addressBook() + { + return $this->belongsTo(AddressBook::class); + } + + /** + * Get capabilities. + * + * @param string $value + * @return array + */ + public function getCapabilitiesAttribute($value) + { + return json_decode($value, true); + } + + /** + * Set capabilities. + * + * @param string $value + * @return void + */ + public function setCapabilitiesAttribute($value) + { + $this->attributes['capabilities'] = json_encode($value); + } + + /** + * Get password. + * + * @param string $value + * @return string + */ + public function getPasswordAttribute($value) + { + return decrypt($value); + } + + /** + * Set password. + * + * @param string $value + * @return void + */ + public function setPasswordAttribute($value) + { + $this->attributes['password'] = encrypt($value); + } + + /** + * Scope a query to only include active subscriptions. + * + * @param Builder $query + * @return Builder + */ + public function scopeActive($query) + { + return $query->where('active', 1); + } +} diff --git a/app/Providers/DAVServiceProvider.php b/app/Providers/DAVServiceProvider.php index 91cfa2c45ad..b74de0c7405 100644 --- a/app/Providers/DAVServiceProvider.php +++ b/app/Providers/DAVServiceProvider.php @@ -8,6 +8,7 @@ use Sabre\CalDAV\ICSExportPlugin; use Sabre\CardDAV\VCFExportPlugin; use Illuminate\Support\Facades\App; +use Illuminate\Support\Facades\Auth; use Sabre\DAVACL\Plugin as AclPlugin; use Sabre\DAVACL\PrincipalCollection; use Illuminate\Support\ServiceProvider; @@ -62,10 +63,12 @@ public function boot() */ private function nodes(): array { + $user = Auth::user(); + // Initiate custom backends for link between Sabre and Monica - $principalBackend = new PrincipalBackend(); // User rights - $carddavBackend = new CardDAVBackend(); // Contacts - $caldavBackend = new CalDAVBackend(); // Calendar + $principalBackend = new PrincipalBackend($user); // User rights + $carddavBackend = new CardDAVBackend($user); // Contacts + $caldavBackend = new CalDAVBackend($user); // Calendar return [ new PrincipalCollection($principalBackend), diff --git a/app/Services/DavClient/CreateAddressBookSubscription.php b/app/Services/DavClient/CreateAddressBookSubscription.php new file mode 100644 index 00000000000..0099733b4c4 --- /dev/null +++ b/app/Services/DavClient/CreateAddressBookSubscription.php @@ -0,0 +1,97 @@ + 'required|integer|exists:accounts,id', + 'user_id' => 'required|integer|exists:users,id', + 'base_uri' => 'required|string|url', + 'username' => 'required|string', + 'password' => 'required|string', + ]; + } + + /** + * Add a new Adress Book. + * + * @param array $data + * @param GuzzleClient|null $httpClient + * @return AddressBookSubscription|null + */ + public function execute(array $data, GuzzleClient $httpClient = null): ?AddressBookSubscription + { + $this->validate($data); + + $addressBookData = $this->getAddressBookData($data, $httpClient); + if (! $addressBookData) { + throw new DavClientException(__('Could not get address book data.')); + } + + $lastAddressBook = AddressBook::where('account_id', $data['account_id']) + ->orderBy('id', 'desc') + ->first(); + + $lastId = 0; + if ($lastAddressBook) { + $lastId = intval(preg_replace('/\w+(\d+)/i', '$1', $lastAddressBook->name)); + } + $nextAddressBookName = 'contacts'.($lastId + 1); + + $addressBook = AddressBook::create([ + 'account_id' => $data['account_id'], + 'user_id' => $data['user_id'], + 'name' => $nextAddressBookName, + 'description' => $addressBookData['name'], + ]); + $subscription = AddressBookSubscription::create([ + 'account_id' => $data['account_id'], + 'user_id' => $data['user_id'], + 'username' => $data['username'], + 'address_book_id' => $addressBook->id, + 'uri' => $addressBookData['uri'], + 'capabilities' => $addressBookData['capabilities'], + ]); + $subscription->password = $data['password']; + $subscription->save(); + + return $subscription; + } + + private function getAddressBookData(array $data, ?GuzzleClient $httpClient): ?array + { + $client = $this->getClient($data, $httpClient); + + return app(AddressBookGetter::class) + ->execute($client); + } + + private function getClient(array $data, ?GuzzleClient $client): DavClient + { + $settings = Arr::only($data, [ + 'base_uri', + 'username', + 'password', + ]); + + return new DavClient($settings, $client); + } +} diff --git a/app/Services/DavClient/SynchronizeAddressBook.php b/app/Services/DavClient/SynchronizeAddressBook.php new file mode 100644 index 00000000000..a69a609405b --- /dev/null +++ b/app/Services/DavClient/SynchronizeAddressBook.php @@ -0,0 +1,87 @@ + 'required|integer|exists:accounts,id', + 'user_id' => 'required|integer|exists:users,id', + 'addressbook_subscription_id' => 'required|integer|exists:addressbook_subscriptions,id', + 'force' => 'nullable|boolean', + ]; + } + + /** + * @param array $data + * @return void + */ + public function execute(array $data, GuzzleClient $httpClient = null) + { + $this->validate($data); + + $account = Account::find($data['account_id']); + if (AccountHelper::hasReachedContactLimit($account) + && AccountHelper::hasLimitations($account) + && ! $account->legacy_free_plan_unlimited_contacts) { + abort(402); + } + + $user = User::where('account_id', $data['account_id']) + ->findOrFail($data['user_id']); + + $subscription = AddressBookSubscription::where('account_id', $data['account_id']) + ->findOrFail($data['addressbook_subscription_id']); + + $backend = new CardDAVBackend($user); + + try { + $this->sync($data, $subscription, $backend, $httpClient); + } catch (ClientException $e) { + Log::error(__CLASS__.' execute: '.$e->getMessage(), [$e]); + if ($e->hasResponse()) { + Log::error(__CLASS__.' execute: '.$e->getResponse()->getBody()); + } + } + } + + private function sync(array $data, AddressBookSubscription $subscription, CardDAVBackend $backend, ?GuzzleClient $httpClient) + { + $client = $this->getDavClient($subscription, $httpClient); + $sync = new SyncDto($subscription, $client, $backend); + $force = Arr::get($data, 'force', false); + + app(AddressBookSynchronizer::class) + ->execute($sync, $force); + } + + private function getDavClient(AddressBookSubscription $subscription, ?GuzzleClient $client): DavClient + { + return new DavClient([ + 'base_uri' => $subscription->uri, + 'username' => $subscription->username, + 'password' => $subscription->password, + ], $client); + } +} diff --git a/app/Services/DavClient/Utils/AddressBookContactsPush.php b/app/Services/DavClient/Utils/AddressBookContactsPush.php new file mode 100644 index 00000000000..99435f5e561 --- /dev/null +++ b/app/Services/DavClient/Utils/AddressBookContactsPush.php @@ -0,0 +1,126 @@ + $changes + * @param array|null $localChanges + * @param Collection|null $missed + * @return PromiseInterface + */ + public function execute(SyncDto $sync, Collection $changes, ?array $localChanges, ?Collection $missed = null): PromiseInterface + { + $this->sync = $sync; + + $commands = $this->preparePushChanges($changes, $localChanges, $missed) + ->filter(function ($command) { + return $command !== null; + }); + + $requests = $commands->pluck('request')->toArray(); + + return $this->sync->client->requestPool($requests, [ + 'concurrency' => 25, + 'fulfilled' => function (ResponseInterface $response, int $index) use ($commands) { + /** @var ContactPushDto $command */ + $command = $commands[$index]; + + Log::info(__CLASS__.' pushContacts: PUT '.$command->uri); + + $etags = $response->getHeader('Etag'); + if (! empty($etags) && $etags[0] !== $command->etag) { + Log::warning(__CLASS__.' pushContacts: wrong etag when updating contact. Expected '.$command->etag.', get '.$etags[0]); + } + }, + ]); + } + + /** + * Get list of requests to push contacts that have changed. + * + * @param Collection $changes + * @param array|null $localChanges + * @param Collection|null $missed + * @return Collection + */ + private function preparePushChanges(Collection $changes, ?array $localChanges, ?Collection $missed = null): Collection + { + $requestsChanges = $this->preparePushChangedContacts($changes, Arr::get($localChanges, 'modified', [])); + $requestsAdded = $this->preparePushAddedContacts(Arr::get($localChanges, 'added', [])); + + return $requestsChanges + ->union($requestsAdded) + ->union($missed ?? []); + } + + /** + * Get list of requests to push new contacts. + * + * @param array $contacts + * @return Collection + */ + private function preparePushAddedContacts(array $contacts): Collection + { + // All added contact must be pushed + return collect($contacts) + ->map(function (string $uri): ?ContactPushDto { + $card = $this->sync->backend->getCard($this->sync->addressBookName(), $uri); + + if ($card === false) { + return null; + } + + return new ContactPushDto($uri, $card['etag'], new Request('PUT', $uri, [], $card['carddata'])); + }); + } + + /** + * Get list of requests to push modified contacts. + * + * @param Collection $changes + * @param array $contacts + * @return Collection + */ + private function preparePushChangedContacts(Collection $changes, array $contacts): Collection + { + $refreshIds = $changes->map(function (ContactDto $contact) { + return $this->sync->backend->getUuid($contact->uri); + }); + + // We don't push contact that have just been pulled + return collect($contacts) + ->reject(function (string $uri) use ($refreshIds): bool { + $uuid = $this->sync->backend->getUuid($uri); + + return $refreshIds->contains($uuid); + })->map(function (string $uri): ?ContactPushDto { + $card = $this->sync->backend->getCard($this->sync->addressBookName(), $uri); + + if ($card === false) { + return null; + } + + return new ContactPushDto($uri, $card['etag'], new Request('PUT', $uri, ['If-Match' => $card['etag']], $card['carddata'])); + }); + } +} diff --git a/app/Services/DavClient/Utils/AddressBookContactsPushMissed.php b/app/Services/DavClient/Utils/AddressBookContactsPushMissed.php new file mode 100644 index 00000000000..42bbdcd9a55 --- /dev/null +++ b/app/Services/DavClient/Utils/AddressBookContactsPushMissed.php @@ -0,0 +1,71 @@ +|null $localChanges + * @param Collection $distContacts + * @param Collection $localContacts + * @return PromiseInterface + */ + public function execute(SyncDto $sync, ?array $localChanges, Collection $distContacts, Collection $localContacts): PromiseInterface + { + $this->sync = $sync; + + $missed = $this->preparePushMissedContacts(Arr::get($localChanges, 'added', []), $distContacts, $localContacts); + + return app(AddressBookContactsPush::class) + ->execute($sync, collect(), $localChanges, $missed); + } + + /** + * Get list of requests of missed contacts. + * + * @param array $added + * @param Collection $distContacts + * @param Collection $localContacts + * @return Collection + */ + private function preparePushMissedContacts(array $added, Collection $distContacts, Collection $localContacts): Collection + { + /** @var Collection $distUuids */ + $distUuids = $distContacts->map(function (ContactDto $contact) { + return $this->sync->backend->getUuid($contact->uri); + }); + + /** @var Collection $added */ + $addedUuids = collect($added)->map(function ($uri) { + return $this->sync->backend->getUuid($uri); + }); + + return collect($localContacts) + ->filter(function (Contact $contact) use ($distUuids, $addedUuids) { + return ! $distUuids->contains($contact->uuid) + && ! $addedUuids->contains($contact->uuid); + })->map(function (Contact $contact): ContactPushDto { + $card = $this->sync->backend->prepareCard($contact); + + return new ContactPushDto($card['uri'], $card['etag'], new Request('PUT', $card['uri'], ['If-Match' => '*'], $card['carddata'])); + }) + ->values(); + } +} diff --git a/app/Services/DavClient/Utils/AddressBookContactsUpdater.php b/app/Services/DavClient/Utils/AddressBookContactsUpdater.php new file mode 100644 index 00000000000..e53829d4e4d --- /dev/null +++ b/app/Services/DavClient/Utils/AddressBookContactsUpdater.php @@ -0,0 +1,139 @@ + $refresh + * @return PromiseInterface + */ + public function execute(SyncDto $sync, Collection $refresh): PromiseInterface + { + $this->sync = $sync; + + return $this->hasCapability('addressbookMultiget') + ? $this->refreshMultigetContacts($refresh) + : $this->refreshSimpleGetContacts($refresh); + } + + /** + * Get contacts data with addressbook-multiget request. + * + * @param Collection $refresh + * @return PromiseInterface + */ + private function refreshMultigetContacts(Collection $refresh): PromiseInterface + { + $hrefs = $refresh->pluck('uri'); + + return $this->sync->client->addressbookMultigetAsync('', [ + '{DAV:}getetag', + $this->getAddressDataProperty(), + ], $hrefs) + ->then(function ($datas) { + return collect($datas) + ->filter(function (array $contact): bool { + return isset($contact[200]); + }) + ->map(function (array $contact, $href): ContactUpdateDto { + return new ContactUpdateDto( + $href, + Arr::get($contact, '200.{DAV:}getetag'), + Arr::get($contact, '200.{'.CardDAVPlugin::NS_CARDDAV.'}address-data') + ); + }); + })->then(function (Collection $contacts) { + $contacts->each(function (ContactUpdateDto $contact) { + $this->syncLocalContact($contact); + }); + }); + } + + /** + * Get data for address-data property. + * + * @return array + */ + private function getAddressDataProperty(): array + { + $addressDataAttributes = Arr::get($this->sync->subscription->capabilities, 'addressData', [ + 'content-type' => 'text/vcard', + 'version' => '4.0', + ]); + + return [ + 'name' => '{'.CardDAVPlugin::NS_CARDDAV.'}address-data', + 'value' => null, + 'attributes' => $addressDataAttributes, + ]; + } + + /** + * Get contacts data with request. + * + * @param Collection $requests + * @return PromiseInterface + */ + private function refreshSimpleGetContacts(Collection $requests): PromiseInterface + { + $inputs = $requests->map(function ($contact) { + return new Request('GET', $contact->uri); + })->toArray(); + + return $this->sync->client->requestPool($inputs, [ + 'concurrency' => 25, + 'fulfilled' => function (ResponseInterface $response, $index) use ($requests) { + if ($response->getStatusCode() === 200) { + /** @var \App\Services\DavClient\Utils\Model\ContactDto $request */ + $request = $requests[$index]; + + $this->syncLocalContact(new ContactUpdateDto( + $request->uri, + $request->etag, + $response->getBody()->detach(), + )); + } + }, + ]); + } + + /** + * Save contact to local storage. + * + * @param ContactUpdateDto $contact + */ + private function syncLocalContact(ContactUpdateDto $contact): void + { + if ($contact->card !== null) { + Log::info(__CLASS__.' syncLocalContact: update '.$contact->uri); + + $newtag = $this->sync->backend->updateCard($this->sync->addressBookName(), $contact->uri, $contact->card); + + if ($newtag !== $contact->etag) { + Log::warning(__CLASS__.' syncLocalContact: wrong etag when updating contact. Expected '.$contact->etag.', get '.$newtag); + } + } + } +} diff --git a/app/Services/DavClient/Utils/AddressBookContactsUpdaterMissed.php b/app/Services/DavClient/Utils/AddressBookContactsUpdaterMissed.php new file mode 100644 index 00000000000..063cd1d66f1 --- /dev/null +++ b/app/Services/DavClient/Utils/AddressBookContactsUpdaterMissed.php @@ -0,0 +1,38 @@ + $localContacts + * @param Collection $distContacts + * @return PromiseInterface + */ + public function execute(SyncDto $sync, Collection $localContacts, Collection $distContacts): PromiseInterface + { + $this->sync = $sync; + + $uuids = $localContacts->pluck('uuid'); + + $missed = $distContacts->reject(function (ContactDto $contact) use ($uuids): bool { + return $uuids->contains($this->sync->backend->getUuid($contact->uri)); + }); + + return app(AddressBookContactsUpdater::class) + ->execute($this->sync, $missed); + } +} diff --git a/app/Services/DavClient/Utils/AddressBookGetter.php b/app/Services/DavClient/Utils/AddressBookGetter.php new file mode 100644 index 00000000000..94d66110e06 --- /dev/null +++ b/app/Services/DavClient/Utils/AddressBookGetter.php @@ -0,0 +1,259 @@ +client = $client; + + try { + return $this->getAddressBookData(); + } catch (ClientException $e) { + Log::error(__CLASS__.' getAddressBookBaseUri: '.$e->getMessage(), [$e]); + throw $e; + } + } + + /** + * Get address book data: uri, capabilities, and name. + * + * @return array + */ + 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. + * + * @return string + */ + private function getAddressBookBaseUri(): string + { + $baseUri = $this->client->getServiceUrl(); + if ($baseUri) { + $this->client->setBaseUri($baseUri); + } + + if (! Str::contains($baseUri, 'https://www.googleapis.com')) { + // Google API does not follow rfc2518 section-15 ! + + // Check the OPTIONS of the server + $this->checkOptions(); + } + + // Get the principal of this account + $principal = $this->getCurrentUserPrincipal(); + + // Get the AddressBook of this principal + $addressBook = $this->getAddressBookUrl($principal); + + if ($addressBook === null) { + throw new DavClientException('No address book found'); + } + + return $this->client->getBaseUri($addressBook); + } + + /** + * Check options of the server. + * + * @return void + * + * @see https://datatracker.ietf.org/doc/html/rfc2518#section-15 + * + * @throws DavServerNotCompliantException + */ + private function checkOptions() + { + $options = $this->client->options(); + if (! empty($options)) { + $options = explode(', ', $options[0]); + } + + if (! in_array('1', $options) || ! in_array('3', $options) || ! in_array('addressbook', $options)) { + throw new DavServerNotCompliantException('server is not compliant with rfc2518 section 15.1, or rfc6352 section 6.1'); + } + } + + /** + * Get principal name. + * + * @return string + * + * @see https://datatracker.ietf.org/doc/html/rfc5397#section-3 + * + * @throws DavServerNotCompliantException + */ + private function getCurrentUserPrincipal(): string + { + $prop = $this->client->getProperty('{DAV:}current-user-principal'); + + 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. + * + * @return string + * + * @see https://datatracker.ietf.org/doc/html/rfc6352#section-7.1.1 + * + * @throws DavServerNotCompliantException + */ + private function getAddressBookHome(string $principal): string + { + $prop = $this->client->getProperty('{'.CardDAVPlugin::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 fro address book. + * + * @return string|null + */ + private function getAddressBookUrl(string $principal): ?string + { + $home = $this->getAddressBookHome($principal); + + $books = $this->client->propfind($home, '{DAV:}resourcetype', 1); + + foreach ($books as $book => $properties) { + if ($book == $home) { + continue; + } + + if (($resources = Arr::get($properties, '{DAV:}resourcetype', null)) && + $resources->is('{'.CardDAVPlugin::NS_CARDDAV.'}addressbook')) { + return $book; + } + } + + return null; + } + + /** + * Get capabilities properties. + * + * @return array + */ + private function getCapabilities() + { + return $this->getSupportedReportSet() + + + $this->getSupportedAddressData(); + } + + /** + * Get supported-report-set property. + * + * @return array + */ + private function getSupportedReportSet(): array + { + $supportedReportSet = $this->client->getSupportedReportSet(); + + $addressbookMultiget = in_array('{'.CardDAVPlugin::NS_CARDDAV.'}addressbook-multiget', $supportedReportSet); + $addressbookQuery = in_array('{'.CardDAVPlugin::NS_CARDDAV.'}addressbook-query', $supportedReportSet); + $syncCollection = in_array('{DAV:}sync-collection', $supportedReportSet); + + return [ + 'addressbookMultiget' => $addressbookMultiget, + 'addressbookQuery' => $addressbookQuery, + 'syncCollection' => $syncCollection, + ]; + } + + /** + * Get supported-address-data property. + * + * @return array + */ + private function getSupportedAddressData(): array + { + // get the supported card format + $addressData = collect($this->client->getProperty('{'.CardDAVPlugin::NS_CARDDAV.'}supported-address-data')); + $datas = $addressData->firstWhere('attributes.version', '4.0'); + if (! $datas) { + $datas = $addressData->firstWhere('attributes.version', '3.0'); + } + + if (! $datas) { + // It should not happen ! + $datas = [ + 'attributes' => [ + 'content-type' => 'text/vcard', + 'version' => '4.0', + ], + ]; + } + + return [ + 'addressData' => [ + 'content-type' => Arr::get($datas, 'attributes.content-type'), + 'version' => Arr::get($datas, 'attributes.version'), + ], + ]; + } +} diff --git a/app/Services/DavClient/Utils/AddressBookSynchronizer.php b/app/Services/DavClient/Utils/AddressBookSynchronizer.php new file mode 100644 index 00000000000..6ce84ea0258 --- /dev/null +++ b/app/Services/DavClient/Utils/AddressBookSynchronizer.php @@ -0,0 +1,240 @@ +sync = $sync; + + $force + ? $this->forcesync() + : $this->sync(); + } + + /** + * Sync the address book. + */ + private function sync() + { + // Get changes to sync + $localChanges = $this->sync->backend->getChangesForAddressBook($this->sync->addressBookName(), (string) $this->sync->subscription->localSyncToken, 1); + + // Get distant changes to sync + $promise = $this->getDistantChanges(); + + $chain = []; + $chain[] = $promise->then(function (Collection $changes) { + // Get distant contacts + return app(AddressBookContactsUpdater::class) + ->execute($this->sync, $changes); + }); + if (! $this->sync->subscription->readonly) { + $chain[] = $promise->then(function (Collection $changes) use ($localChanges) { + return app(AddressBookContactsPush::class) + ->execute($this->sync, $changes, $localChanges); + }); + } + + Each::of($chain)->wait(); + + $token = $this->sync->backend->getCurrentSyncToken($this->sync->addressBookName()); + + $this->sync->subscription->localSyncToken = $token->id; + $this->sync->subscription->save(); + } + + /** + * Sync the address book. + */ + private function forcesync() + { + // Get changes to sync + $localChanges = $this->sync->backend->getChangesForAddressBook($this->sync->addressBookName(), (string) $this->sync->subscription->localSyncToken, 1); + + // Get current list of contacts + $localContacts = $this->sync->backend->getObjects($this->sync->addressBookName()); + + // Get distant changes to sync + $promise = $this->getAllContactsEtag(); + + $chain = []; + $chain[] = $promise->then(function (Collection $distContacts) use ($localContacts) { + // Get missed contacts + return app(AddressBookContactsUpdaterMissed::class) + ->execute($this->sync, $localContacts, $distContacts); + }); + + if (! $this->sync->subscription->readonly) { + $chain[] = $promise->then(function ($distContacts) use ($localChanges, $localContacts) { + return app(AddressBookContactsPushMissed::class) + ->execute($this->sync, $localChanges, $distContacts, $localContacts); + }); + } + + Each::of($chain)->wait(); + } + + /** + * Get distant changes to sync. + * + * @return PromiseInterface + */ + private function getDistantChanges(): PromiseInterface + { + return $this->getDistantEtags() + ->then(function ($collection) { + return collect($collection) + ->filter(function ($contact, $href): bool { + return $this->filterDistantContacts($contact, $href); + }) + ->map(function ($contact, $href): ContactDto { + return new ContactDto($href, Arr::get($contact, '200.{DAV:}getetag')); + }); + }); + } + + /** + * Filter contacts to only return vcards type and new contacts or contacts with matching etags. + * + * @param mixed $contact + * @param string $href + * @return bool + */ + private function filterDistantContacts($contact, $href): bool + { + // only return vcards + if (! is_array($contact) || ! Str::contains(Arr::get($contact, '200.{DAV:}getcontenttype'), 'text/vcard')) { + return false; + } + + // only new contact or contact with etag that match + $card = $this->sync->backend->getCard($this->sync->addressBookName(), $href); + + return $card === false || $card['etag'] !== Arr::get($contact, '200.{DAV:}getetag'); + } + + /** + * Get refreshed etags. + * + * @return PromiseInterface + */ + private function getDistantEtags(): PromiseInterface + { + if ($this->hasCapability('syncCollection')) { + // With sync-collection + return $this->callSyncCollectionWhenNeeded(); + } else { + // With PROPFIND + return $this->sync->client->propFindAsync('', [ + '{DAV:}getcontenttype', + '{DAV:}getetag', + ], 1); + } + } + + /** + * Make sync-collection request if sync-token has changed. + * + * @return PromiseInterface + */ + private function callSyncCollectionWhenNeeded(): PromiseInterface + { + // get the current distant syncToken + return $this->sync->client->getPropertyAsync('{DAV:}sync-token') + ->then(function ($distantSyncToken) { + $syncToken = $this->sync->subscription->syncToken ?? ''; + + if ($syncToken === $distantSyncToken) { + // no change at all + return $this->emptyPromise(); + } + + return $this->callSyncCollection(); + }); + } + + /** + * Make sync-collection request. + * + * @return PromiseInterface + */ + private function callSyncCollection(): PromiseInterface + { + $syncToken = $this->sync->subscription->syncToken ?? ''; + + // get sync + return $this->sync->client->syncCollectionAsync('', [ + '{DAV:}getcontenttype', + '{DAV:}getetag', + ], $syncToken)->then(function ($collection) { + // save the new syncToken as current one + if ($newSyncToken = Arr::get($collection, 'synctoken')) { + $this->sync->subscription->syncToken = $newSyncToken; + $this->sync->subscription->save(); + } + + return $collection; + }); + } + + /** + * Get all contacts etag. + * + * @return PromiseInterface + */ + private function getAllContactsEtag(): PromiseInterface + { + if (! $this->hasCapability('addressbookQuery')) { + return $this->emptyPromise(); + } + + return $this->sync->client->addressbookQueryAsync('', '{DAV:}getetag') + ->then(function ($datas) { + return collect($datas) + ->filter(function ($contact) { + return isset($contact[200]); + }) + ->map(function ($contact, $href): ContactDto { + return new ContactDto($href, Arr::get($contact, '200.{DAV:}getetag')); + }); + }); + } + + /** + * Get an empty Promise. + * + * @return PromiseInterface + */ + private function emptyPromise(): PromiseInterface + { + $promise = new Promise(function () use (&$promise) { + $promise->resolve([]); + }); + + return $promise; + } +} diff --git a/app/Services/DavClient/Utils/Dav/DavClient.php b/app/Services/DavClient/Utils/Dav/DavClient.php new file mode 100644 index 00000000000..a65a59e1a10 --- /dev/null +++ b/app/Services/DavClient/Utils/Dav/DavClient.php @@ -0,0 +1,697 @@ +client = is_null($client) ? new GuzzleClient([ + 'base_uri' => $settings['base_uri'], + 'auth' => [ + $settings['username'], + $settings['password'], + ], + 'verify' => ! App::environment('local'), + ]) : $client; + + $this->xml = new Service(); + } + + /** + * Follow rfc6764 to get carddav service url. + * + * @see https://datatracker.ietf.org/doc/html/rfc6764 + */ + public function getServiceUrl() + { + $target = $this->standardServiceUrl(); + + if (! $target) { + // second attempt for non standard server, like Google API + $target = $this->nonStandardServiceUrl(); + } + + if (! $target) { + // Get service name register (section 9.2) + $target = app(ServiceUrlQuery::class)->execute('_carddavs._tcp', true, $this->getBaseUri()); + if (is_null($target)) { + $target = app(ServiceUrlQuery::class)->execute('_carddav._tcp', false, $this->getBaseUri()); + } + } + + return $target; + } + + private function standardServiceUrl(): ?string + { + // Get well-known register (section 9.1) + $wkUri = $this->getBaseUri('/.well-known/carddav'); + + try { + $response = $this->requestAsync('GET', $wkUri, [], null, [ + RequestOptions::ALLOW_REDIRECTS => false, + RequestOptions::SYNCHRONOUS => true, + ])->wait(); + + $code = $response->getStatusCode(); + if (($code === 301 || $code === 302) && $response->hasHeader('Location')) { + return $response->getHeader('Location')[0]; + } + } catch (ClientException $e) { + if ($e->hasResponse()) { + $code = $e->getResponse()->getStatusCode(); + if ($code !== 400 && $code !== 401 && $code !== 404) { + throw $e; + } + } + } + + return null; + } + + private function nonStandardServiceUrl(): ?string + { + $wkUri = $this->getBaseUri('/.well-known/carddav'); + + try { + $response = $this->requestAsync('PROPFIND', $wkUri, [], null, [ + RequestOptions::ALLOW_REDIRECTS => false, + RequestOptions::SYNCHRONOUS => true, + ])->wait(); + + $code = $response->getStatusCode(); + if (($code === 301 || $code === 302) && $response->hasHeader('Location')) { + $location = $response->getHeader('Location')[0]; + + return $this->getBaseUri($location); + } + } catch (ClientException $e) { + // catch exception and return null + } + + return null; + } + + /** + * Get current uri. + * + * @param string|null $path + * @return string + */ + public function getBaseUri(?string $path = null): string + { + $baseUri = $this->client->getConfig('base_uri'); + + return is_null($path) ? $baseUri : $baseUri->withPath($path); + } + + /** + * Set the base uri of client. + * + * @param string $uri + * @return self + */ + public function setBaseUri($uri): self + { + $this->client = new GuzzleClient( + Arr::except($this->client->getConfig(), ['base_uri']) + + + ['base_uri' => $uri] + ); + + return $this; + } + + /** + * 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 string $url + * @param array|string $properties + * @param int $depth + * @return array + */ + public function propFind(string $url, $properties, int $depth = 0): array + { + return $this->propFindAsync($url, $properties, $depth, [ + RequestOptions::SYNCHRONOUS => true, + ])->wait(); + } + + /** + * 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 string $url + * @param array|string $properties + * @param int $depth + * @return PromiseInterface + */ + public function propFindAsync(string $url, $properties, int $depth = 0, array $options = []): PromiseInterface + { + $dom = new \DOMDocument('1.0', 'UTF-8'); + $root = $this->addElementNS($dom, 'DAV:', 'd:propfind'); + $prop = $this->addElement($dom, $root, 'd:prop'); + + $namespaces = [ + 'DAV:' => 'd', + ]; + + $this->fetchProperties($dom, $prop, $properties, $namespaces); + + $body = $dom->saveXML(); + + return $this->requestAsync('PROPFIND', $url, [ + 'Depth' => $depth, + 'Content-Type' => 'application/xml; charset=utf-8', + ], $body, $options)->then(function (ResponseInterface $response) use ($depth): array { + $result = $this->parseMultiStatus((string) $response->getBody()); + + // If depth was 0, we only return the top item value + if ($depth === 0) { + reset($result); + $result = current($result); + + return Arr::get($result, 200, []); + } + + return array_map(function ($statusList) { + return Arr::get($statusList, 200, []); + }, $result); + }); + } + + /** + * Run a REPORT {DAV:}sync-collection. + * + * @param string $url + * @param array|string $properties + * @param string $syncToken + * @return PromiseInterface + * + * @see https://datatracker.ietf.org/doc/html/rfc6578 + */ + public function syncCollectionAsync(string $url, $properties, string $syncToken, array $options = []): PromiseInterface + { + $dom = new \DOMDocument('1.0', 'UTF-8'); + $root = $this->addElementNS($dom, 'DAV:', 'd:sync-collection'); + + $this->addElement($dom, $root, 'd:sync-token', $syncToken); + $this->addElement($dom, $root, 'd:sync-level', '1'); + + $prop = $this->addElement($dom, $root, 'd:prop'); + + $namespaces = [ + 'DAV:' => 'd', + ]; + + $this->fetchProperties($dom, $prop, $properties, $namespaces); + + $body = $dom->saveXML(); + + return $this->requestAsync('REPORT', $url, [ + 'Depth' => '0', + 'Content-Type' => 'application/xml; charset=utf-8', + ], $body, $options)->then(function (ResponseInterface $response) { + return $this->parseMultiStatus((string) $response->getBody()); + }); + } + + /** + * Run a REPORT card:addressbook-multiget. + * + * @param string $url + * @param array|string $properties + * @param iterable $contacts + * @return PromiseInterface + * + * @see https://datatracker.ietf.org/doc/html/rfc6352#section-8.7 + */ + public function addressbookMultigetAsync(string $url, $properties, iterable $contacts, array $options = []): PromiseInterface + { + $dom = new \DOMDocument('1.0', 'UTF-8'); + $root = $this->addElementNS($dom, CardDAVPlugin::NS_CARDDAV, 'card:addressbook-multiget'); + $dom->createAttributeNS('DAV:', 'd:e'); + + $prop = $this->addElement($dom, $root, 'd:prop'); + + $namespaces = [ + 'DAV:' => 'd', + CardDAVPlugin::NS_CARDDAV => 'card', + ]; + + $this->fetchProperties($dom, $prop, $properties, $namespaces); + + foreach ($contacts as $contact) { + $this->addElement($dom, $root, 'd:href', $contact); + } + + $body = $dom->saveXML(); + + return $this->requestAsync('REPORT', $url, [ + 'Depth' => '1', + 'Content-Type' => 'application/xml; charset=utf-8', + ], $body, $options)->then(function (ResponseInterface $response) { + return $this->parseMultiStatus((string) $response->getBody()); + }); + } + + /** + * Run a REPORT card:addressbook-query. + * + * @param string $url + * @param array|string $properties + * @return PromiseInterface + * + * @see https://datatracker.ietf.org/doc/html/rfc6352#section-8.6 + */ + public function addressbookQueryAsync(string $url, $properties, array $options = []): PromiseInterface + { + $dom = new \DOMDocument('1.0', 'UTF-8'); + $root = $this->addElementNS($dom, CardDAVPlugin::NS_CARDDAV, 'card:addressbook-query'); + $dom->createAttributeNS('DAV:', 'd:e'); + + $prop = $this->addElement($dom, $root, 'd:prop'); + + $namespaces = [ + 'DAV:' => 'd', + CardDAVPlugin::NS_CARDDAV => 'card', + ]; + + $this->fetchProperties($dom, $prop, $properties, $namespaces); + + $body = $dom->saveXML(); + + return $this->requestAsync('REPORT', $url, [ + 'Depth' => '1', + 'Content-Type' => 'application/xml; charset=utf-8', + ], $body, $options)->then(function (ResponseInterface $response) { + return $this->parseMultiStatus((string) $response->getBody()); + }); + } + + /** + * 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 \DOMDocument $dom + * @param \DOMNode $prop + * @param array|string $properties + * @param array $namespaces + * @return void + */ + private function fetchProperties(\DOMDocument $dom, \DOMNode $prop, $properties, array $namespaces) + { + 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. + * + * @param string $property + * @param string $url + * @return string|array|null + */ + public function getProperty(string $property, string $url = '') + { + return $this->getPropertyAsync($property, $url, [ + RequestOptions::SYNCHRONOUS => true, + ])->wait(); + } + + /** + * Get a WebDAV property. + * + * @param string $property + * @param string $url + * @return PromiseInterface + */ + public function getPropertyAsync(string $property, string $url = '', array $options = []): PromiseInterface + { + return $this->propfindAsync($url, $property, 0, $options) + ->then(function (array $properties) use ($property) { + 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. + * + * @return array + * + * @see https://datatracker.ietf.org/doc/html/rfc3253#section-3.1.5 + */ + public function getSupportedReportSet(): array + { + return $this->getSupportedReportSetAsync([ + RequestOptions::SYNCHRONOUS => true, + ])->wait(); + } + + /** + * Get a {DAV:}supported-report-set propfind. + * + * @param array $options + * @return PromiseInterface + * + * @see https://datatracker.ietf.org/doc/html/rfc3253#section-3.1.5 + */ + public function getSupportedReportSetAsync(array $options = []): PromiseInterface + { + $propName = '{DAV:}supported-report-set'; + + return $this->propFindAsync('', $propName, 0, $options) + ->then(function (array $properties) use ($propName): array { + if (($prop = Arr::get($properties, $propName)) && is_array($prop)) { + $prop = array_map(function ($supportedReport) { + return $this->iterateOver($supportedReport, '{DAV:}supported-report', function ($report) { + return $this->iterateOver($report, '{DAV:}report', function ($type) { + return Arr::get($type, 'name'); + }); + }); + }, $prop); + } + + return $prop; + }); + } + + /** + * Iterate over the list, if it contains an item name that match with $name. + * + * @param array $list + * @param string $name + * @param callable $callback + * @return mixed + */ + private function iterateOver(array $list, string $name, callable $callback) + { + if (Arr::get($list, 'name') === $name + && ($value = Arr::get($list, 'value'))) { + foreach ($value as $item) { + return $callback($item); + } + } + } + + /** + * 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. + * + * @param string $url + * @param array $properties + * @return PromiseInterface + * + * @see https://datatracker.ietf.org/doc/html/rfc2518#section-12.13 + */ + public function propPatchAsync(string $url, array $properties): PromiseInterface + { + $propPatch = new PropPatch(); + $propPatch->properties = $properties; + $body = $this->xml->write( + '{DAV:}propertyupdate', + $propPatch + ); + + return $this->requestAsync('PROPPATCH', $url, [ + 'Content-Type' => 'application/xml; charset=utf-8', + ], $body)->then(function (ResponseInterface $response): bool { + if ($response->getStatusCode() === 207) { + // If it's a 207, the request could still have failed, but the + // information is hidden in the response body. + $result = $this->parseMultiStatus((string) $response->getBody()); + + $errorProperties = []; + foreach ($result as $statusList) { + foreach ($statusList 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. + * + * @return array + */ + public function options(): array + { + $response = $this->request('OPTIONS'); + + $dav = $response->getHeader('Dav'); + if (! $dav) { + return []; + } + + foreach ($dav as &$v) { + $v = trim($v); + } + + return $dav; + } + + /** + * Performs an actual HTTP request, and returns the result. + * + * @param string $method + * @param string $url + * @param string|null|resource|\Psr\Http\Message\StreamInterface $body + * @param array $headers + * @return ResponseInterface + * + * @throws \GuzzleHttp\Exception\ClientException in case a curl error occurred + */ + public function request(string $method, string $url = '', array $headers = [], $body = null, array $options = []): ResponseInterface + { + return $this->client->send(new Request($method, $url, $headers, $body), $options); + } + + /** + * Performs an actual HTTP request, and returns the result. + * + * @param string $method + * @param string $url + * @param string|null|resource|\Psr\Http\Message\StreamInterface $body + * @param array $headers + * @return PromiseInterface + * + * @throws \GuzzleHttp\Exception\ClientException in case a curl error occurred + */ + public function requestAsync(string $method, string $url = '', array $headers = [], $body = null, array $options = []): PromiseInterface + { + return $this->client->sendAsync(new Request($method, $url, $headers, $body), $options); + } + + /** + * Create multiple request in parallel. + * + * @param array $requests + * @param array $config + * @return PromiseInterface + */ + public function requestPool(array $requests, array $config = []): PromiseInterface + { + return (new Pool($this->client, $requests, $config))->promise(); + } + + /** + * Parses a WebDAV multistatus response body. + * + * This method returns an array with the following structure + * + * [ + * 'url/to/resource' => [ + * '200' => [ + * '{DAV:}property1' => 'value1', + * '{DAV:}property2' => 'value2', + * ], + * '404' => [ + * '{DAV:}property1' => null, + * '{DAV:}property2' => null, + * ], + * ], + * 'url/to/resource2' => [ + * .. etc .. + * ] + * ] + * + * @param string $body xml body + * @return array + * + * @see https://datatracker.ietf.org/doc/html/rfc4918#section-9.2.1 + */ + private function parseMultiStatus(string $body): array + { + $multistatus = $this->xml->expect('{DAV:}multistatus', $body); + + $result = []; + + foreach ($multistatus->getResponses() as $response) { + $result[$response->getHref()] = $response->getResponseProperties(); + } + + $synctoken = $multistatus->getSyncToken(); + if (! empty($synctoken)) { + $result['synctoken'] = $synctoken; + } + + return $result; + } + + /** + * Create a new Element Namespace and add it as document's child. + * + * @param \DOMDocument $dom + * @param string|null $namespace + * @param string $qualifiedName + * @return \DOMNode + */ + private 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. + * + * @param \DOMDocument $dom + * @param \DOMNode $root + * @param string $name + * @param string|null $value + * @return \DOMNode + */ + private function addElement(\DOMDocument $dom, \DOMNode $root, string $name, ?string $value = null): \DOMNode + { + return $root->appendChild($dom->createElement($name, $value)); + } +} diff --git a/app/Services/DavClient/Utils/Dav/DavClientException.php b/app/Services/DavClient/Utils/Dav/DavClientException.php new file mode 100644 index 00000000000..5c0c7858a0d --- /dev/null +++ b/app/Services/DavClient/Utils/Dav/DavClientException.php @@ -0,0 +1,9 @@ +dns_get_record($name.'.'.$host, DNS_SRV); + + if ($entry && $target = Arr::get($entry, '0.target')) { + $uri = (new Uri()) + ->withScheme($https ? 'https' : 'http') + ->withPort(Arr::get($entry, '0.port')) + ->withHost($target); + + return (string) $uri; + } + } catch (\Safe\Exceptions\UrlException $e) { + // catch exception and return null + } catch (\Safe\Exceptions\NetworkException $e) { + // catch exception and return null + } + + return null; + } + + private function dns_get_record(string $hostname, int $type = DNS_ANY, ?array &$authns = null, ?array &$addtl = null, bool $raw = false): array + { + error_clear_last(); + $result = \dns_get_record($hostname, $type, $authns, $addtl, $raw); + if ($result === false) { + throw NetworkException::createFromPhpError(); + } + + return $result; + } +} diff --git a/app/Services/DavClient/Utils/Model/ContactDto.php b/app/Services/DavClient/Utils/Model/ContactDto.php new file mode 100644 index 00000000000..3a1063750a8 --- /dev/null +++ b/app/Services/DavClient/Utils/Model/ContactDto.php @@ -0,0 +1,28 @@ +uri = $uri; + $this->etag = $etag; + } +} diff --git a/app/Services/DavClient/Utils/Model/ContactPushDto.php b/app/Services/DavClient/Utils/Model/ContactPushDto.php new file mode 100644 index 00000000000..3b87f3d1cc4 --- /dev/null +++ b/app/Services/DavClient/Utils/Model/ContactPushDto.php @@ -0,0 +1,27 @@ +uri = $uri; + $this->etag = $etag; + $this->request = $request; + } +} diff --git a/app/Services/DavClient/Utils/Model/ContactUpdateDto.php b/app/Services/DavClient/Utils/Model/ContactUpdateDto.php new file mode 100644 index 00000000000..ab6f83375f9 --- /dev/null +++ b/app/Services/DavClient/Utils/Model/ContactUpdateDto.php @@ -0,0 +1,25 @@ +uri = $uri; + $this->etag = $etag; + $this->card = $card; + } +} diff --git a/app/Services/DavClient/Utils/Model/SyncDto.php b/app/Services/DavClient/Utils/Model/SyncDto.php new file mode 100644 index 00000000000..4b4e9942a08 --- /dev/null +++ b/app/Services/DavClient/Utils/Model/SyncDto.php @@ -0,0 +1,45 @@ +subscription = $subscription; + $this->client = $client; + $this->backend = $backend; + } + + /** + * Get address book name. + * + * @return string + */ + public function addressBookName(): string + { + return $this->subscription->addressbook->name; + } +} diff --git a/app/Services/DavClient/Utils/Traits/HasCapability.php b/app/Services/DavClient/Utils/Traits/HasCapability.php new file mode 100644 index 00000000000..13e2589dbfb --- /dev/null +++ b/app/Services/DavClient/Utils/Traits/HasCapability.php @@ -0,0 +1,19 @@ +sync->subscription->capabilities, $capability, false); + } +} diff --git a/app/Services/VCalendar/ExportTask.php b/app/Services/VCalendar/ExportTask.php index f6bccf04248..5c128cc6663 100644 --- a/app/Services/VCalendar/ExportTask.php +++ b/app/Services/VCalendar/ExportTask.php @@ -46,7 +46,7 @@ public function execute(array $data): VCalendar */ private function export(Task $task): VCalendar { - // The standard for most of these fields can be found on https://tools.ietf.org/html/rfc5545 + // The standard for most of these fields can be found on https://datatracker.ietf.org/doc/html/rfc5545 if (! $task->uuid) { $task->forceFill([ 'uuid' => Str::uuid(), diff --git a/app/Services/VCalendar/ExportVCalendar.php b/app/Services/VCalendar/ExportVCalendar.php index e5383a711de..5550af4d687 100644 --- a/app/Services/VCalendar/ExportVCalendar.php +++ b/app/Services/VCalendar/ExportVCalendar.php @@ -46,7 +46,7 @@ public function execute(array $data): VCalendar */ private function export(SpecialDate $date): VCalendar { - // The standard for most of these fields can be found on https://tools.ietf.org/html/rfc5545 + // The standard for most of these fields can be found on https://datatracker.ietf.org/doc/html/rfc5545 if (! $date->uuid) { $date->forceFill([ 'uuid' => Str::uuid(), diff --git a/app/Services/VCard/ExportVCard.php b/app/Services/VCard/ExportVCard.php index 7e520378a2a..357bc5b9941 100644 --- a/app/Services/VCard/ExportVCard.php +++ b/app/Services/VCard/ExportVCard.php @@ -2,10 +2,12 @@ namespace App\Services\VCard; +use Sabre\VObject\Reader; use Illuminate\Support\Str; use App\Services\BaseService; use App\Models\Contact\Gender; use App\Models\Contact\Contact; +use Sabre\VObject\ParseException; use App\Interfaces\LabelInterface; use Sabre\VObject\Component\VCard; use App\Models\Contact\ContactFieldType; @@ -36,10 +38,17 @@ public function execute(array $data): VCard { $this->validate($data); + /** @var Contact */ $contact = Contact::where('account_id', $data['account_id']) ->findOrFail($data['contact_id']); - return $this->export($contact); + $vcard = $this->export($contact); + + $contact->timestamps = false; + $contact->vcard = $vcard->serialize(); + $contact->save(); + + return $vcard; } private function escape($value): string @@ -53,19 +62,32 @@ private function escape($value): string */ private function export(Contact $contact): VCard { - // The standard for most of these fields can be found on https://tools.ietf.org/html/rfc6350 + // The standard for most of these fields can be found on https://datatracker.ietf.org/doc/html/rfc6350 if (! $contact->uuid) { $contact->forceFill([ 'uuid' => Str::uuid(), ])->save(); } - // Basic information - $vcard = new VCard([ - 'UID' => $contact->uuid, - 'SOURCE' => $contact->getLink(), - 'VERSION' => '4.0', - ]); + if ($contact->vcard) { + try { + /** @var VCard */ + $vcard = Reader::read($contact->vcard, Reader::OPTION_FORGIVING + Reader::OPTION_IGNORE_INVALID_LINES); + if (! $vcard->UID) { + $vcard->UID = $contact->uuid; + } + } catch (ParseException $e) { + // Ignore error + } + } + if (! isset($vcard)) { + // Basic information + $vcard = new VCard([ + 'UID' => $contact->uuid, + 'SOURCE' => $contact->getLink(), + 'VERSION' => '4.0', + ]); + } $this->exportNames($contact, $vcard); $this->exportGender($contact, $vcard); @@ -86,6 +108,10 @@ private function export(Contact $contact): VCard */ private function exportNames(Contact $contact, VCard $vcard) { + $vcard->remove('FN'); + $vcard->remove('N'); + $vcard->remove('NICKNAME'); + $vcard->add('FN', $this->escape($contact->name)); $vcard->add('N', [ @@ -105,6 +131,8 @@ private function exportNames(Contact $contact, VCard $vcard) */ private function exportGender(Contact $contact, VCard $vcard) { + $vcard->remove('GENDER'); + if (is_null($contact->gender)) { return; } @@ -132,6 +160,8 @@ private function exportGender(Contact $contact, VCard $vcard) */ private function exportPhoto(Contact $contact, VCard $vcard) { + $vcard->remove('PHOTO'); + if ($contact->avatar_source == 'photo') { $photo = $contact->avatarPhoto; @@ -151,6 +181,9 @@ private function exportPhoto(Contact $contact, VCard $vcard) */ private function exportWorkInformation(Contact $contact, VCard $vcard) { + $vcard->remove('ORG'); + $vcard->remove('TITLE'); + if (! empty($contact->company)) { $vcard->add('ORG', $this->escape($contact->company)); } @@ -166,6 +199,8 @@ private function exportWorkInformation(Contact $contact, VCard $vcard) */ private function exportBirthday(Contact $contact, VCard $vcard) { + $vcard->remove('BDAY'); + if (! is_null($contact->birthdate)) { if ($contact->birthdate->is_year_unknown) { $date = $contact->birthdate->date->format('--m-d'); @@ -180,10 +215,12 @@ private function exportBirthday(Contact $contact, VCard $vcard) * @param Contact $contact * @param VCard $vcard * - * @see https://tools.ietf.org/html/rfc6350#section-6.3.1 + * @see https://datatracker.ietf.org/doc/html/rfc6350#section-6.3.1 */ private function exportAddress(Contact $contact, VCard $vcard) { + $vcard->remove('ADR'); + foreach ($contact->addresses as $address) { $type = $this->getContactFieldLabel($address); $arguments = []; @@ -210,6 +247,10 @@ private function exportAddress(Contact $contact, VCard $vcard) */ private function exportContactFields(Contact $contact, VCard $vcard) { + $vcard->remove('TEL'); + $vcard->remove('EMAIL'); + $vcard->remove('socialProfile'); + foreach ($contact->contactFields as $contactField) { $type = $this->getContactFieldLabel($contactField); switch ($contactField->contactFieldType->type) { @@ -272,6 +313,7 @@ private function getContactFieldLabel(LabelInterface $labelProvider): ?array */ private function exportTimestamp(Contact $contact, VCard $vcard) { + $vcard->remove('REV'); $vcard->REV = $contact->updated_at->format('Ymd\\THis\\Z'); } @@ -281,6 +323,8 @@ private function exportTimestamp(Contact $contact, VCard $vcard) */ private function exportTags(Contact $contact, VCard $vcard) { + $vcard->remove('CATEGORIES'); + if ($contact->tags->count() > 0) { $vcard->CATEGORIES = $contact->tags->map(function ($tag) { return $tag->name; diff --git a/app/Services/VCard/ImportVCard.php b/app/Services/VCard/ImportVCard.php index ff894ebf51b..83afcb2a6cf 100644 --- a/app/Services/VCard/ImportVCard.php +++ b/app/Services/VCard/ImportVCard.php @@ -16,7 +16,9 @@ use App\Helpers\LocaleHelper; use App\Services\BaseService; use function Safe\preg_split; +use App\Helpers\AccountHelper; use App\Models\Contact\Gender; +use App\Models\Account\Account; use App\Models\Contact\Contact; use Illuminate\Validation\Rule; use App\Helpers\CountriesHelper; @@ -110,8 +112,8 @@ public function rules() 'entry' => [ 'required', function ($attribute, $value, $fail) { - if (! is_string($value) && ! $value instanceof VCard) { - $fail($attribute.' must be a string or a VCard object.'); + if (! is_string($value) && ! is_resource($value) && ! $value instanceof VCard) { + $fail($attribute.' must be a string, a resource, or a VCard object.'); } }, ], @@ -133,6 +135,13 @@ public function execute(array $data): array { $this->validate($data); + $account = Account::find($data['account_id']); + if (AccountHelper::hasReachedContactLimit($account) + && AccountHelper::hasLimitations($account) + && ! $account->legacy_free_plan_unlimited_contacts) { + abort(402); + } + User::where('account_id', $data['account_id']) ->findOrFail($data['user_id']); @@ -422,6 +431,10 @@ private function getExistingContact(VCard $entry, $contact_id = null) $contact = $this->existingContactWithName($entry); } + if ($contact) { + $contact->timestamps = false; + } + return $contact; } diff --git a/composer.json b/composer.json index 4e267a1605c..19ddaab4f49 100644 --- a/composer.json +++ b/composer.json @@ -94,8 +94,7 @@ }, "autoload": { "psr-4": { - "App\\": "app/", - "Tests\\": "tests/" + "App\\": "app/" }, "classmap": [ "database/seeds", @@ -108,7 +107,12 @@ "autoload-dev": { "classmap": [ "tests/TestCase.php" - ] + ], + "psr-4": { + "Tests\\": "tests/", + "Database\\Factories\\": "database/factories/", + "Database\\Seeders\\": "database/seeders/" + } }, "scripts": { "pre-install-cmd": [ diff --git a/database/factories/Account/AddressBookFactory.php b/database/factories/Account/AddressBookFactory.php new file mode 100644 index 00000000000..065587ec3ca --- /dev/null +++ b/database/factories/Account/AddressBookFactory.php @@ -0,0 +1,37 @@ + factory(Account::class)->create(), + 'user_id' => function (array $attributes) { + return factory(User::class)->create([ + 'account_id' => $attributes['account_id'], + ]); + }, + 'name' => 'contacts1', + 'description' => $this->faker->sentence, + ]; + } +} diff --git a/database/factories/Account/AddressBookSubscriptionFactory.php b/database/factories/Account/AddressBookSubscriptionFactory.php new file mode 100644 index 00000000000..c682895d1d1 --- /dev/null +++ b/database/factories/Account/AddressBookSubscriptionFactory.php @@ -0,0 +1,56 @@ + factory(Account::class)->create(), + 'user_id' => function (array $attributes) { + return factory(User::class)->create([ + 'account_id' => $attributes['account_id'], + ]); + }, + 'address_book_id' => function (array $attributes) { + return AddressBook::factory()->create([ + 'account_id' => $attributes['account_id'], + 'user_id' => $attributes['user_id'], + ]); + }, + 'name' => $this->faker->word, + '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', + 'syncToken' => '"test"', + ]; + } +} diff --git a/database/migrations/2020_03_25_065551_add_addressbook_subscription.php b/database/migrations/2020_03_25_065551_add_addressbook_subscription.php new file mode 100644 index 00000000000..eee0bfb4a6c --- /dev/null +++ b/database/migrations/2020_03_25_065551_add_addressbook_subscription.php @@ -0,0 +1,50 @@ +bigIncrements('id'); + $table->unsignedInteger('account_id'); + $table->unsignedInteger('user_id'); + $table->unsignedBigInteger('address_book_id'); + + $table->string('name', 256); + $table->string('uri', 2096); + $table->string('username', 1024); + $table->string('password', 2048); + $table->boolean('readonly'); + $table->boolean('active')->default(true); + $table->string('capabilities', 2048); + $table->string('syncToken', 512)->nullable(); + $table->string('localSyncToken', 1024)->nullable(); + $table->smallInteger('frequency')->default(180); // 3 hours + $table->timestamp('last_synchronized_at', 0)->nullable(); + $table->timestamps(); + + $table->foreign('account_id')->references('id')->on('accounts')->onDelete('cascade'); + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + $table->foreign('address_book_id')->references('id')->on('addressbooks')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('addressbook_subscriptions'); + } +} diff --git a/phpstan.neon b/phpstan.neon index aa527589b61..c8e833af8a7 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -42,3 +42,5 @@ parameters: path: */Helpers/InstanceHelper.php - message: '#Call to an undefined method Illuminate\\Database\\Eloquent\\Relations\\HasMany::recurring\(\)\.#' path: */Traits/Subscription.php + - message: '#Function dns_get_record is unsafe to use\. It can return FALSE instead of throwing an exception\. Please add ''use function Safe\\dns_get_record;'' at the beginning of the file to use the variant provided by the ''thecodingmachine/safe'' library\.#' + path: */Services/DavClient/Utils/Dav/ServiceUrlQuery.php diff --git a/sonar-project.properties b/sonar-project.properties index f33d604b797..e20d0c8df9d 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -8,7 +8,7 @@ sonar.organization=monicahq sonar.sources=app,bootstrap,config,database,public,resources,routes sonar.exclusions=bootstrap/cache/*,public/vendor/**,resources/lang/** sonar.tests=tests -sonar.coverage.exclusions=routes/*.php,config/*.php,bootstrap/**,resources/**/*.php,database/factories/*,database/migrations/*.php,public/*.php,resources/**/*.vue,resources/**/*.js +sonar.coverage.exclusions=routes/*.php,config/*.php,bootstrap/**,resources/**/*.php,database/factories/**/*.php,database/migrations/*.php,public/*.php,resources/**/*.vue,resources/**/*.js sonar.cpd.exclusions=routes/*.php,config/*.php,bootstrap/**,resources/**/*.php,database/**/*.php # Encoding of the source code. Default is default system encoding diff --git a/tests/Api/DAV/CardEtag.php b/tests/Api/DAV/CardEtag.php index 8c5701aa88c..14c453cc3ef 100644 --- a/tests/Api/DAV/CardEtag.php +++ b/tests/Api/DAV/CardEtag.php @@ -9,7 +9,7 @@ trait CardEtag { - protected function getEtag($obj) + protected function getEtag($obj, bool $quotes = false) { $data = ''; if ($obj instanceof Contact) { @@ -20,7 +20,12 @@ protected function getEtag($obj) $data = $this->getVTodo($obj, true); } - return md5($data); + $etag = md5($data); + if ($quotes) { + $etag = '"'.$etag.'"'; + } + + return $etag; } protected function getCard(Contact $contact, bool $realFormat = false): string diff --git a/tests/Commands/CreateAddressBookSubscriptionTest.php b/tests/Commands/CreateAddressBookSubscriptionTest.php new file mode 100644 index 00000000000..a280907cd60 --- /dev/null +++ b/tests/Commands/CreateAddressBookSubscriptionTest.php @@ -0,0 +1,44 @@ +create(); + + $this->mock(CreateAddressBookSubscription::class, function (MockInterface $mock) use ($user) { + $mock->shouldReceive('execute') + ->once() + ->withArgs(function ($data) use ($user) { + $this->assertEquals([ + 'account_id' => $user->account_id, + 'user_id' => $user->id, + 'base_uri' => 'https://test', + 'username' => 'login', + 'password' => 'password', + ], $data); + + return true; + }); + }); + + Artisan::call('monica:newaddressbooksubscription', [ + '--email' => $user->email, + '--url' => 'https://test', + '--login' => 'login', + '--password' => 'password', + ]); + } +} diff --git a/tests/Commands/DavClientsUpdateTest.php b/tests/Commands/DavClientsUpdateTest.php new file mode 100644 index 00000000000..5e0c133ed8f --- /dev/null +++ b/tests/Commands/DavClientsUpdateTest.php @@ -0,0 +1,29 @@ +create(); + + Artisan::call('monica:davclients', []); + + Queue::assertPushed(SynchronizeAddressBooks::class, function ($job) use ($subscription) { + return $job->subscription->id === $subscription->id; + }); + } +} diff --git a/tests/Helpers/DavTester.php b/tests/Helpers/DavTester.php new file mode 100644 index 00000000000..40752c86dc6 --- /dev/null +++ b/tests/Helpers/DavTester.php @@ -0,0 +1,366 @@ +baseUri = $baseUri; + $this->responses = []; + } + + public function getClient() + { + $this->container = []; + $history = Middleware::history($this->container); + + $mock = new MockHandler(array_map(function ($response) { + return $response['response']; + }, $this->responses)); + $handlerStack = HandlerStack::create($mock); + $handlerStack->push($history); + + return new Client(['handler' => $handlerStack, 'base_uri' => $this->baseUri]); + } + + public function assert() + { + $this->assertCount(count($this->responses), $this->container, 'the number of response do not match the number of requests'); + foreach ($this->container as $index => $request) { + $srequest = $request['request']->getMethod().' '.(string) $request['request']->getUri(); + $this->assertEquals($this->responses[$index]['method'], $request['request']->getMethod(), "method for request $srequest differs"); + $this->assertEquals($this->responses[$index]['uri'], (string) $request['request']->getUri(), "uri for request $srequest differs"); + if (isset($this->responses[$index]['body'])) { + $this->assertEquals($this->responses[$index]['body'], (string) $request['request']->getBody(), "body for request $srequest differs"); + } + if (isset($this->responses[$index]['headers'])) { + foreach ($this->responses[$index]['headers'] as $key => $value) { + $this->assertArrayHasKey($key, $request['request']->getHeaders(), "header $key for request $srequest is missing"); + $this->assertEquals($value, $request['request']->getHeaderLine($key), "header $key for request $srequest differs"); + } + } + } + } + + public function addressBookBaseUri() + { + return $this->serviceUrl() + ->optionsOk() + ->userPrincipal() + ->addressbookHome() + ->resourceTypeAddressBook(); + } + + public function capabilities() + { + return $this->supportedReportSet() + ->supportedAddressData(); + } + + public function addResponse(string $uri, Response $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() + { + $this->addResponse('https://test/.well-known/carddav', new Response(301, ['Location' => $this->baseUri.'/dav/']), null, 'GET'); + + return $this; + } + + public function nonStandardServiceUrl() + { + $this->addResponse('https://test/.well-known/carddav', new Response(301, ['Location' => '/dav/'])); + + return $this; + } + + public function optionsOk() + { + $this->addResponse('https://test/dav/', new Response(200, ['Dav' => '1, 3, addressbook']), null, 'OPTIONS'); + + return $this; + } + + public function optionsFail() + { + $this->addResponse('https://test/dav/', new Response(200, ['Dav' => 'bad']), null, 'OPTIONS'); + + return $this; + } + + public function userPrincipal() + { + $this->addResponse('https://test/dav/', new Response(200, [], $this->multistatusHeader(). + ''. + '/dav/'. + ''. + ''. + ''. + '/dav/principals/user@test.com/'. + ''. + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + '')); + + return $this; + } + + public function userPrincipalEmpty() + { + $this->addResponse('https://test/dav/', new Response(200, [], $this->multistatusHeader(). + ''. + '/dav/'. + ''. + ''. + ''. + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + '')); + + return $this; + } + + public function addressbookHome() + { + $this->addResponse('https://test/dav/principals/user@test.com/', new Response(200, [], $this->multistatusHeader(). + ''. + '/dav/principals/user@test.com/'. + ''. + ''. + ''. + '/dav/addressbooks/user@test.com/'. + ''. + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + '')); + + return $this; + } + + public function addressbookEmpty() + { + $this->addResponse('https://test/dav/principals/user@test.com/', new Response(200, [], $this->multistatusHeader(). + ''. + '/dav/principals/user@test.com/'. + ''. + ''. + ''. + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + '')); + + return $this; + } + + public function resourceTypeAddressBook() + { + $this->addResponse('https://test/dav/addressbooks/user@test.com/', new Response(200, [], $this->multistatusHeader(). + ''. + '/dav/addressbooks/user@test.com/contacts/'. + ''. + ''. + ''. + ''. + ''. + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + '')); + + return $this; + } + + public function resourceTypeHomeOnly() + { + $this->addResponse('https://test/dav/addressbooks/user@test.com/', new Response(200, [], $this->multistatusHeader(). + ''. + '/dav/addressbooks/user@test.com/'. + ''. + ''. + ''. + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + '')); + + return $this; + } + + public function resourceTypeEmpty() + { + $this->addResponse('https://test/dav/addressbooks/user@test.com/contacts/', new Response(200, [], $this->multistatusHeader(). + ''. + '/dav/addressbooks/user@test.com/contacts/'. + ''. + ''. + ''. + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + '')); + + return $this; + } + + public function supportedReportSet(array $reportSet = ['card:addressbook-multiget', 'card:addressbook-query', 'd:sync-collection']) + { + $this->addResponse('https://test/dav/addressbooks/user@test.com/contacts/', new Response(200, [], $this->multistatusHeader(). + ''. + '/dav/addressbooks/user@test.com/contacts/'. + ''. + ''. + ''. + implode('', array_map(function ($report) { + return "<$report/>"; + }, $reportSet)). + ''. + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + '')); + + return $this; + } + + public function supportedAddressData(array $list = ['card:address-data-type content-type="text/vcard" version="4.0"']) + { + $this->addResponse('https://test/dav/addressbooks/user@test.com/contacts/', new Response(200, [], $this->multistatusHeader(). + ''. + '/dav/addressbooks/user@test.com/contacts/'. + ''. + ''. + ''. + implode('', array_map(function ($item) { + return "<$item/>"; + }, $list)). + ''. + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + '')); + + return $this; + } + + public function displayName(string $name = 'Test') + { + $this->addResponse('https://test/dav/addressbooks/user@test.com/contacts/', new Response(200, [], $this->multistatusHeader(). + ''. + '/dav/addressbooks/user@test.com/contacts/'. + ''. + ''. + "$name". + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + '')); + + return $this; + } + + public function getSynctoken(string $synctoken = '"test"') + { + $this->addResponse('https://test/dav/addressbooks/user@test.com/contacts/', new Response(200, [], $this->multistatusHeader(). + ''. + '/dav/addressbooks/user@test.com/contacts/'. + ''. + ''. + "$synctoken". + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + '')); + + return $this; + } + + public function getSyncCollection(string $synctoken = 'token', string $etag = '"etag"') + { + return $this->addResponse('https://test/dav/addressbooks/user@test.com/contacts/', new Response(200, [], $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/', new Response(200, [], $this->multistatusHeader(). + ''. + 'https://test/dav/addressbooks/user@test.com/contacts/uuid'. + ''. + ''. + "$etag". + "$card". + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + ''), ''."\n". + ''. + ''. + ''. + ''. + ''. + "$url". + "\n", 'REPORT'); + } + + public function multistatusHeader() + { + return ''; + } +} diff --git a/tests/Unit/Jobs/SynchronizeAddressBooksTest.php b/tests/Unit/Jobs/SynchronizeAddressBooksTest.php new file mode 100644 index 00000000000..82b7b2e5d97 --- /dev/null +++ b/tests/Unit/Jobs/SynchronizeAddressBooksTest.php @@ -0,0 +1,39 @@ +create(); + + $this->mock(SynchronizeAddressBook::class, function ($mock) use ($subscription) { + $mock->shouldReceive('execute') + ->once() + ->with([ + 'account_id' => $subscription->account_id, + 'user_id' => $subscription->user_id, + 'addressbook_subscription_id' => $subscription->id, + ]); + }); + + (new SynchronizeAddressBooks($subscription)) + ->handle(); + + $subscription->refresh(); + $this->assertEquals(Carbon::create(2021, 9, 1, 10, 0, 0), $subscription->last_synchronized_at); + } +} diff --git a/tests/Unit/Models/AddressBookSubscriptionTest.php b/tests/Unit/Models/AddressBookSubscriptionTest.php new file mode 100644 index 00000000000..fd1ed12c4c9 --- /dev/null +++ b/tests/Unit/Models/AddressBookSubscriptionTest.php @@ -0,0 +1,74 @@ +create(); + $user = factory(User::class)->create(['account_id' => $account->id]); + $addressBookSubscription = AddressBookSubscription::factory()->create([ + 'account_id' => $account->id, + 'user_id' => $user->id, + ]); + + $this->assertTrue($addressBookSubscription->account()->exists()); + } + + /** @test */ + public function it_belongs_to_a_user() + { + $user = factory(User::class)->create(); + $addressBookSubscription = AddressBookSubscription::factory()->create([ + 'user_id' => $user->id, + ]); + + $this->assertTrue($addressBookSubscription->user()->exists()); + } + + /** @test */ + public function it_belongs_to_an_addressbook() + { + $addressBook = AddressBook::factory()->create(); + $addressBookSubscription = AddressBookSubscription::factory()->create([ + 'address_book_id' => $addressBook->id, + ]); + + $this->assertTrue($addressBookSubscription->addressBook()->exists()); + } + + /** @test */ + public function it_saves_capabilities() + { + $addressBookSubscription = new AddressBookSubscription(); + + $addressBookSubscription->capabilities = [ + 'test' => true, + ]; + + $this->assertIsArray($addressBookSubscription->capabilities); + $this->assertEquals([ + 'test' => true, + ], $addressBookSubscription->capabilities); + } + + /** @test */ + public function it_saves_password() + { + $addressBookSubscription = new AddressBookSubscription(); + + $addressBookSubscription->password = 'test'; + $this->assertEquals('test', $addressBookSubscription->password); + } +} diff --git a/tests/Unit/Models/AddressBookTest.php b/tests/Unit/Models/AddressBookTest.php new file mode 100644 index 00000000000..e125fc38aa8 --- /dev/null +++ b/tests/Unit/Models/AddressBookTest.php @@ -0,0 +1,38 @@ +create(); + $user = factory(User::class)->create(['account_id' => $account->id]); + $addressBook = AddressBook::factory()->create([ + 'account_id' => $account->id, + 'user_id' => $user->id, + ]); + + $this->assertTrue($addressBook->account()->exists()); + } + + /** @test */ + public function it_belongs_to_a_user() + { + $user = factory(User::class)->create(); + $addressBook = AddressBook::factory()->create([ + 'user_id' => $user->id, + ]); + + $this->assertTrue($addressBook->user()->exists()); + } +} diff --git a/tests/Unit/Services/Account/Company/CreateCompanyTest.php b/tests/Unit/Services/Account/Company/CreateCompanyTest.php index c96ff9d975e..0ca1b9e4d19 100644 --- a/tests/Unit/Services/Account/Company/CreateCompanyTest.php +++ b/tests/Unit/Services/Account/Company/CreateCompanyTest.php @@ -4,6 +4,7 @@ use Tests\TestCase; use App\Models\User\User; +use function Safe\json_encode; use App\Models\Account\Account; use App\Models\Account\Company; use Illuminate\Support\Facades\Queue; diff --git a/tests/Unit/Services/Contact/Contact/CreateContactTest.php b/tests/Unit/Services/Contact/Contact/CreateContactTest.php index 8f0702440b0..fd279565299 100644 --- a/tests/Unit/Services/Contact/Contact/CreateContactTest.php +++ b/tests/Unit/Services/Contact/Contact/CreateContactTest.php @@ -5,6 +5,7 @@ use Tests\TestCase; use App\Models\User\User; use App\Models\Contact\Gender; +use function Safe\json_encode; use App\Models\Account\Account; use App\Models\Contact\Contact; use Illuminate\Support\Facades\Queue; diff --git a/tests/Unit/Services/Contact/Contact/UpdateWorkInformationTest.php b/tests/Unit/Services/Contact/Contact/UpdateWorkInformationTest.php index a53a1eed4ea..40d0534fbeb 100644 --- a/tests/Unit/Services/Contact/Contact/UpdateWorkInformationTest.php +++ b/tests/Unit/Services/Contact/Contact/UpdateWorkInformationTest.php @@ -4,6 +4,7 @@ use Tests\TestCase; use App\Models\User\User; +use function Safe\json_encode; use App\Models\Contact\Contact; use Illuminate\Support\Facades\Queue; use App\Jobs\AuditLog\LogAccountAudit; diff --git a/tests/Unit/Services/Contact/Description/ClearPersonalDescriptionTest.php b/tests/Unit/Services/Contact/Description/ClearPersonalDescriptionTest.php index afce073d596..51e67708acb 100644 --- a/tests/Unit/Services/Contact/Description/ClearPersonalDescriptionTest.php +++ b/tests/Unit/Services/Contact/Description/ClearPersonalDescriptionTest.php @@ -4,6 +4,7 @@ use Tests\TestCase; use App\Models\User\User; +use function Safe\json_encode; use App\Models\Contact\Contact; use Illuminate\Support\Facades\Queue; use App\Jobs\AuditLog\LogAccountAudit; diff --git a/tests/Unit/Services/Contact/Description/SetPersonalDescriptionTest.php b/tests/Unit/Services/Contact/Description/SetPersonalDescriptionTest.php index 720034dba3b..969ff24aa13 100644 --- a/tests/Unit/Services/Contact/Description/SetPersonalDescriptionTest.php +++ b/tests/Unit/Services/Contact/Description/SetPersonalDescriptionTest.php @@ -4,6 +4,7 @@ use Tests\TestCase; use App\Models\User\User; +use function Safe\json_encode; use App\Models\Contact\Contact; use Illuminate\Support\Facades\Queue; use App\Jobs\AuditLog\LogAccountAudit; diff --git a/tests/Unit/Services/DavClient/AddAddressBookTest.php b/tests/Unit/Services/DavClient/AddAddressBookTest.php new file mode 100644 index 00000000000..098f5e05785 --- /dev/null +++ b/tests/Unit/Services/DavClient/AddAddressBookTest.php @@ -0,0 +1,129 @@ +create([]); + + $this->mock(AddressBookGetter::class, function (MockInterface $mock) { + $mock->shouldReceive('execute') + ->once() + ->andReturn($this->mockReturn()); + }); + + $request = [ + 'account_id' => $user->account_id, + 'user_id' => $user->id, + 'base_uri' => 'https://test', + 'username' => 'test', + 'password' => 'test', + ]; + + $addressBookSubscription = (new CreateAddressBookSubscription())->execute($request); + + $this->assertDatabaseHas('addressbooks', [ + 'id' => $addressBookSubscription->address_book_id, + 'account_id' => $user->account_id, + 'user_id' => $user->id, + 'name' => 'contacts1', + ]); + $this->assertDatabaseHas('addressbook_subscriptions', [ + 'id' => $addressBookSubscription->id, + 'account_id' => $user->account_id, + 'user_id' => $user->id, + 'address_book_id' => $addressBookSubscription->address_book_id, + 'capabilities' => json_encode([ + 'addressbookMultiget' => true, + 'addressbookQuery' => true, + 'syncCollection' => true, + 'addressData' => [ + 'content-type' => 'text/vcard', + 'version' => '4.0', + ], + ]), + ]); + + $this->assertInstanceOf( + AddressBookSubscription::class, + $addressBookSubscription + ); + } + + /** @test */ + public function it_creates_next_addressbook() + { + $user = factory(User::class)->create([]); + AddressBook::factory()->create([ + 'account_id' => $user->account_id, + 'user_id' => $user->id, + 'name' => 'contacts5', + ]); + + $this->mock(AddressBookGetter::class, function (MockInterface $mock) { + $mock->shouldReceive('execute') + ->once() + ->andReturn($this->mockReturn()); + }); + + $request = [ + 'account_id' => $user->account_id, + 'user_id' => $user->id, + 'base_uri' => 'https://test', + 'username' => 'test', + 'password' => 'test', + ]; + + $addressBookSubscription = app(CreateAddressBookSubscription::class)->execute($request); + + $this->assertDatabaseHas('addressbooks', [ + 'id' => $addressBookSubscription->address_book_id, + 'account_id' => $user->account_id, + 'user_id' => $user->id, + 'name' => 'contacts6', + ]); + $this->assertDatabaseHas('addressbook_subscriptions', [ + 'id' => $addressBookSubscription->id, + 'account_id' => $user->account_id, + 'user_id' => $user->id, + 'address_book_id' => $addressBookSubscription->address_book_id, + ]); + + $this->assertInstanceOf( + AddressBookSubscription::class, + $addressBookSubscription + ); + } + + 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/Services/DavClient/SynchronizeAddressBookTest.php b/tests/Unit/Services/DavClient/SynchronizeAddressBookTest.php new file mode 100644 index 00000000000..b6c8921127a --- /dev/null +++ b/tests/Unit/Services/DavClient/SynchronizeAddressBookTest.php @@ -0,0 +1,69 @@ +mock(AddressBookSynchronizer::class, function (MockInterface $mock) { + $mock->shouldReceive('execute') + ->once() + ->withArgs(function ($sync, $force) { + $this->assertFalse($force); + + return true; + }); + }); + + $client = new Client(); + + $subscription = AddressBookSubscription::factory()->create(); + + $request = [ + 'account_id' => $subscription->account_id, + 'user_id' => $subscription->user_id, + 'addressbook_subscription_id' => $subscription->id, + ]; + + (new SynchronizeAddressBook())->execute($request, $client); + } + + /** @test */ + public function it_runs_sync_force() + { + $this->mock(AddressBookSynchronizer::class, function (MockInterface $mock) { + $mock->shouldReceive('execute') + ->once() + ->withArgs(function ($sync, $force) { + $this->assertTrue($force); + + return true; + }); + }); + + $client = new Client(); + + $subscription = AddressBookSubscription::factory()->create(); + + $request = [ + 'account_id' => $subscription->account_id, + 'user_id' => $subscription->user_id, + 'addressbook_subscription_id' => $subscription->id, + 'force' => true, + ]; + + (new SynchronizeAddressBook())->execute($request, $client); + } +} diff --git a/tests/Unit/Services/DavClient/Utils/AddressBookContactsPushMissedTest.php b/tests/Unit/Services/DavClient/Utils/AddressBookContactsPushMissedTest.php new file mode 100644 index 00000000000..ed739c4b7b9 --- /dev/null +++ b/tests/Unit/Services/DavClient/Utils/AddressBookContactsPushMissedTest.php @@ -0,0 +1,83 @@ +create(); + $token = factory(SyncToken::class)->create([ + 'account_id' => $subscription->account_id, + 'user_id' => $subscription->user_id, + 'name' => 'contacts1', + 'timestamp' => now()->addDays(-1), + ]); + $subscription->localSyncToken = $token->id; + $subscription->save(); + + $contact = factory(Contact::class)->create([ + 'account_id' => $subscription->account_id, + 'first_name' => 'Test', + 'uuid' => 'affacde9-b2fe-4371-9acb-6612aaee6971', + ]); + $card = $this->getCard($contact); + $etag = $this->getEtag($contact, true); + + /** @var CardDAVBackend */ + $backend = $this->mock(CardDAVBackend::class, function (MockInterface $mock) use ($card, $etag, $contact) { + $mock->shouldReceive('getUuid') + ->once() + ->withArgs(function ($uri) { + $this->assertEquals('https://test/dav/uuid6', $uri); + + return true; + }) + ->andReturn('uuid3'); + $mock->shouldReceive('prepareCard') + ->once() + ->withArgs(function ($c) use ($contact) { + $this->assertEquals($contact, $c); + + return true; + }) + ->andReturn([ + 'carddata' => $card, + 'uri' => 'https://test/dav/uuid3', + 'etag' => $etag, + ]); + }); + + $tester = (new DavTester('https://test/dav/addressbooks/user@test.com/contacts/')); + $tester->addResponse('https://test/dav/uuid3', new Response(200, ['Etag' => $etag]), $card, 'PUT', ['If-Match' => '*']); + + $client = new DavClient([], $tester->getClient()); + + (new AddressBookContactsPushMissed()) + ->execute(new SyncDto($subscription, $client, $backend), [], collect([ + 'https://test/dav/uuid6' => new ContactDto('https://test/dav/uuid6', $etag), + ]), collect([$contact])) + ->wait(); + + $tester->assert(); + } +} diff --git a/tests/Unit/Services/DavClient/Utils/AddressBookContactsPushTest.php b/tests/Unit/Services/DavClient/Utils/AddressBookContactsPushTest.php new file mode 100644 index 00000000000..e9959e759c5 --- /dev/null +++ b/tests/Unit/Services/DavClient/Utils/AddressBookContactsPushTest.php @@ -0,0 +1,143 @@ +create(); + $token = factory(SyncToken::class)->create([ + 'account_id' => $subscription->account_id, + 'user_id' => $subscription->user_id, + 'name' => 'contacts1', + 'timestamp' => now()->addDays(-1), + ]); + $subscription->localSyncToken = $token->id; + $subscription->save(); + + $contact = factory(Contact::class)->create([ + 'account_id' => $subscription->account_id, + 'first_name' => 'Test', + 'uuid' => 'affacde9-b2fe-4371-9acb-6612aaee6971', + ]); + $card = $this->getCard($contact); + $etag = $this->getEtag($contact, true); + + /** @var CardDAVBackend */ + $backend = $this->mock(CardDAVBackend::class, function (MockInterface $mock) use ($card, $etag) { + $mock->shouldReceive('getCard') + ->withArgs(function ($name, $uri) { + $this->assertEquals($uri, 'https://test/dav/uricontact2'); + + return true; + }) + ->andReturn([ + 'carddata' => $card, + 'etag' => $etag, + ]); + $mock->shouldReceive('getUuid') + ->withArgs(function ($uri) { + $this->assertEquals($uri, 'https://test/dav/uricontact1'); + + return true; + }) + ->andReturn('uricontact1'); + }); + + $tester = (new DavTester('https://test/dav/addressbooks/user@test.com/contacts/')); + $tester->addResponse('https://test/dav/uricontact2', new Response(200, ['Etag' => $etag]), $card, 'PUT'); + + $client = new DavClient([], $tester->getClient()); + + (new AddressBookContactsPush()) + ->execute(new SyncDto($subscription, $client, $backend), collect([ + 'https://test/dav/uricontact1' => new ContactDto('https://test/dav/uricontact1', $etag), + ]), [ + 'added' => ['https://test/dav/uricontact2'], + ]) + ->wait(); + + $tester->assert(); + } + + /** @test */ + public function it_push_contacts_modified() + { + $subscription = AddressBookSubscription::factory()->create(); + $token = factory(SyncToken::class)->create([ + 'account_id' => $subscription->account_id, + 'user_id' => $subscription->user_id, + 'name' => 'contacts1', + 'timestamp' => now()->addDays(-1), + ]); + $subscription->localSyncToken = $token->id; + $subscription->save(); + + $contact = factory(Contact::class)->create([ + 'account_id' => $subscription->account_id, + 'first_name' => 'Test', + 'uuid' => 'affacde9-b2fe-4371-9acb-6612aaee6971', + ]); + $card = $this->getCard($contact); + $etag = $this->getEtag($contact, true); + + /** @var CardDAVBackend */ + $backend = $this->mock(CardDAVBackend::class, function (MockInterface $mock) use ($card, $etag) { + $mock->shouldReceive('getUuid') + ->withArgs(function ($uri) { + $this->assertStringStartsWith('https://test/dav/uricontact', $uri); + + return true; + }) + ->andReturnUsing(function ($uri) { + return Str::contains($uri, 'uricontact1') ? 'uricontact1' : 'uricontact2'; + }); + $mock->shouldReceive('getCard') + ->withArgs(function ($name, $uri) { + $this->assertEquals($uri, 'https://test/dav/uricontact2'); + + return true; + }) + ->andReturn([ + 'carddata' => $card, + 'etag' => $etag, + ]); + }); + + $tester = (new DavTester('https://test/dav/addressbooks/user@test.com/contacts/')); + $tester->addResponse('https://test/dav/uricontact2', new Response(200, ['Etag' => $etag]), $card, 'PUT', ['If-Match' => $etag]); + + $client = new DavClient([], $tester->getClient()); + + (new AddressBookContactsPush()) + ->execute(new SyncDto($subscription, $client, $backend), collect([ + 'https://test/dav/uricontact1' => new ContactDto('https://test/dav/uricontact1', $etag), + ]), [ + 'modified' => ['https://test/dav/uricontact2'], + ]) + ->wait(); + + $tester->assert(); + } +} diff --git a/tests/Unit/Services/DavClient/Utils/AddressBookContactsUpdaterMissedTest.php b/tests/Unit/Services/DavClient/Utils/AddressBookContactsUpdaterMissedTest.php new file mode 100644 index 00000000000..136b8e65f9d --- /dev/null +++ b/tests/Unit/Services/DavClient/Utils/AddressBookContactsUpdaterMissedTest.php @@ -0,0 +1,81 @@ +create(); + $token = factory(SyncToken::class)->create([ + 'account_id' => $subscription->account_id, + 'user_id' => $subscription->user_id, + 'name' => 'contacts1', + 'timestamp' => now()->addDays(-1), + ]); + $subscription->localSyncToken = $token->id; + $subscription->save(); + + $contact = new Contact(); + $contact->forceFill([ + 'first_name' => 'Test', + 'uuid' => 'affacde9-b2fe-4371-9acb-6612aaee6971', + 'updated_at' => now(), + ]); + $card = $this->getCard($contact); + $etag = $this->getEtag($contact, true); + + /** @var CardDAVBackend */ + $backend = $this->mock(CardDAVBackend::class, function (MockInterface $mock) use ($card, $etag) { + $mock->shouldReceive('getUuid') + ->withArgs(function ($uri) { + $this->assertEquals($uri, 'https://test/dav/uuid2'); + + return true; + }) + ->andReturn('uuid2'); + $mock->shouldReceive('updateCard') + ->withArgs(function ($addressBookId, $cardUri, $cardData) use ($card) { + $this->assertEquals($card, $cardData); + + return true; + }) + ->andReturn($etag); + }); + + $tester = (new DavTester('https://test/dav/addressbooks/user@test.com/contacts/')); + $tester->addressMultiGet($etag, $card, 'https://test/dav/uuid2'); + + $client = new DavClient([], $tester->getClient()); + + (new AddressBookContactsUpdaterMissed()) + ->execute(new SyncDto($subscription, $client, $backend), collect([ + [ + 'uuid' => 'uuid1', + ], + ]), collect([ + 'https://test/dav/uuid2' => new ContactDto('https://test/dav/uuid2', $etag), + ])) + ->wait(); + + $tester->assert(); + } +} diff --git a/tests/Unit/Services/DavClient/Utils/AddressBookContactsUpdaterTest.php b/tests/Unit/Services/DavClient/Utils/AddressBookContactsUpdaterTest.php new file mode 100644 index 00000000000..58a0fda5961 --- /dev/null +++ b/tests/Unit/Services/DavClient/Utils/AddressBookContactsUpdaterTest.php @@ -0,0 +1,137 @@ +create(); + $token = factory(SyncToken::class)->create([ + 'account_id' => $subscription->account_id, + 'user_id' => $subscription->user_id, + 'name' => 'contacts1', + 'timestamp' => now()->addDays(-1), + ]); + $subscription->localSyncToken = $token->id; + $subscription->save(); + + $contact = new Contact(); + $contact->forceFill([ + 'first_name' => 'Test', + 'uuid' => 'affacde9-b2fe-4371-9acb-6612aaee6971', + 'updated_at' => now(), + ]); + $card = $this->getCard($contact); + $etag = $this->getEtag($contact, true); + + /** @var CardDAVBackend */ + $backend = $this->mock(CardDAVBackend::class, function (MockInterface $mock) use ($card, $etag) { + $mock->shouldReceive('updateCard') + ->withArgs(function ($addressBookId, $cardUri, $cardData) use ($card) { + $this->assertEquals($card, $cardData); + + return true; + }) + ->andReturn($etag); + }); + + $tester = (new DavTester('https://test/dav/addressbooks/user@test.com/contacts/')); + $tester->addressMultiGet($etag, $card, 'https://test/dav/uuid2'); + + $client = new DavClient([], $tester->getClient()); + + (new AddressBookContactsUpdater()) + ->execute(new SyncDto($subscription, $client, $backend), collect([ + 'https://test/dav/uuid2' => new ContactDto('https://test/dav/uuid2', $etag), + ])) + ->wait(); + + $tester->assert(); + } + + /** @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 = factory(SyncToken::class)->create([ + 'account_id' => $subscription->account_id, + 'user_id' => $subscription->user_id, + 'name' => 'contacts1', + 'timestamp' => now()->addDays(-1), + ]); + $subscription->localSyncToken = $token->id; + $subscription->save(); + + $contact = new Contact(); + $contact->forceFill([ + 'first_name' => 'Test', + 'uuid' => 'affacde9-b2fe-4371-9acb-6612aaee6971', + 'updated_at' => now(), + ]); + $card = $this->getCard($contact); + $etag = $this->getEtag($contact, true); + + /** @var CardDAVBackend */ + $backend = $this->mock(CardDAVBackend::class, function (MockInterface $mock) use ($card, $etag) { + $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); + }); + + $tester = (new DavTester('https://test/dav/addressbooks/user@test.com/contacts/')); + $tester->addResponse('https://test/dav/uuid2', new Response(200, [], $card), null, 'GET'); + + $client = new DavClient([], $tester->getClient()); + + (new AddressBookContactsUpdater()) + ->execute(new SyncDto($subscription, $client, $backend), collect([ + 'https://test/dav/uuid2' => new ContactDto('https://test/dav/uuid2', $etag), + ])) + ->wait(); + + $tester->assert(); + } +} diff --git a/tests/Unit/Services/DavClient/Utils/AddressBookGetterTest.php b/tests/Unit/Services/DavClient/Utils/AddressBookGetterTest.php new file mode 100644 index 00000000000..f978fd4d639 --- /dev/null +++ b/tests/Unit/Services/DavClient/Utils/AddressBookGetterTest.php @@ -0,0 +1,103 @@ +addressBookBaseUri() + ->capabilities() + ->displayName(); + $client = new DavClient([], $tester->getClient()); + $result = (new AddressBookGetter()) + ->execute($client); + + $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()) + ->serviceUrl() + ->optionsFail(); + $client = new DavClient([], $tester->getClient()); + + $this->expectException(DavServerNotCompliantException::class); + (new AddressBookGetter()) + ->execute($client); + } + + /** @test */ + public function it_fails_if_no_userprincipal() + { + $tester = (new DavTester()) + ->serviceUrl() + ->optionsOk() + ->userPrincipalEmpty(); + + $client = new DavClient([], $tester->getClient()); + + $this->expectException(DavServerNotCompliantException::class); + (new AddressBookGetter()) + ->execute($client); + } + + /** @test */ + public function it_fails_if_no_addressbook() + { + $tester = (new DavTester()) + ->serviceUrl() + ->optionsOk() + ->userPrincipal() + ->addressbookEmpty(); + + $client = new DavClient([], $tester->getClient()); + + $this->expectException(DavServerNotCompliantException::class); + (new AddressBookGetter()) + ->execute($client); + } + + /** @test */ + public function it_fails_if_no_addressbook_url() + { + $tester = (new DavTester()) + ->serviceUrl() + ->optionsOk() + ->userPrincipal() + ->addressbookHome() + ->resourceTypeHomeOnly(); + $client = new DavClient([], $tester->getClient()); + + $this->expectException(DavClientException::class); + (new AddressBookGetter()) + ->execute($client); + } +} diff --git a/tests/Unit/Services/DavClient/Utils/AddressBookSynchronizerTest.php b/tests/Unit/Services/DavClient/Utils/AddressBookSynchronizerTest.php new file mode 100644 index 00000000000..cd94f771306 --- /dev/null +++ b/tests/Unit/Services/DavClient/Utils/AddressBookSynchronizerTest.php @@ -0,0 +1,191 @@ +mock(AddressBookContactsUpdater::class, function (MockInterface $mock) { + $mock->shouldReceive('execute') + ->once() + ->andReturn(collect()); + }); + + $subscription = $this->getSubscription(); + $backend = new CardDAVBackend($subscription->user); + + $tester = (new DavTester('https://test/dav/addressbooks/user@test.com/contacts/')) + ->getSynctoken($subscription->syncToken); + $client = new DavClient([], $tester->getClient()); + + (new AddressBookSynchronizer()) + ->execute(new SyncDto($subscription, $client, $backend)); + + $tester->assert(); + } + + /** @test */ + public function it_sync_no_changes() + { + $this->mock(AddressBookContactsUpdater::class, function (MockInterface $mock) { + $mock->shouldReceive('execute') + ->once() + ->andReturn(collect()); + }); + + $subscription = $this->getSubscription(); + $backend = new CardDAVBackend($subscription->user); + + $tester = (new DavTester('https://test/dav/addressbooks/user@test.com/contacts/')); + $tester->getSynctoken('"test21"') + ->getSyncCollection('test20'); + + $client = new DavClient([], $tester->getClient()); + + (new AddressBookSynchronizer()) + ->execute(new SyncDto($subscription, $client, $backend)); + + $tester->assert(); + } + + /** @test */ + public function it_sync_changes_added_local_contact() + { + $subscription = $this->getSubscription(); + $backend = new CardDAVBackend($subscription->user); + + $contact = factory(Contact::class)->create([ + 'account_id' => $subscription->account_id, + 'address_book_id' => $subscription->address_book_id, + 'uuid' => 'd403af1c-8492-4e9b-9833-cf18c795dfa9', + ]); + + $tester = (new DavTester('https://test/dav/addressbooks/user@test.com/contacts/')); + $tester->getSynctoken('"token"') + ->getSyncCollection('token', '"test2"'); + + $client = new DavClient([], $tester->getClient()); + + $sync = new SyncDto($subscription, $client, $backend); + $this->mock(AddressBookContactsUpdater::class, function (MockInterface $mock) use ($sync) { + $mock->shouldReceive('execute') + ->once() + ->withArgs(function ($localSync, $contacts) use ($sync) { + $this->assertEquals($sync, $localSync); + $this->assertEquals('https://test/dav/addressbooks/user@test.com/contacts/uuid', $contacts->first()->uri); + $this->assertEquals('"test2"', $contacts->first()->etag); + + return true; + }) + ->andReturn(new Promise(function () { + return true; + })); + }); + + (new AddressBookSynchronizer()) + ->execute($sync); + + $tester->assert(); + } + + /** @test */ + public function it_forcesync_changes_added_local_contact() + { + $subscription = $this->getSubscription(); + $backend = new CardDAVBackend($subscription->user); + + $contact = factory(Contact::class)->create([ + 'account_id' => $subscription->account_id, + 'address_book_id' => $subscription->address_book_id, + 'uuid' => 'd403af1c-8492-4e9b-9833-cf18c795dfa9', + ]); + $etag = $this->getEtag($contact, true); + + $tester = (new DavTester('https://test/dav/addressbooks/user@test.com/contacts/')); + $tester->addResponse('https://test/dav/addressbooks/user@test.com/contacts/', new Response(200, [], $tester->multistatusHeader(). + ''. + 'https://test/dav/uuid1'. + ''. + ''. + "$etag". + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + ''), ''."\n". + ''. + ''. + ''. + ''. + "\n", 'REPORT'); + + $client = new DavClient([], $tester->getClient()); + + $sync = new SyncDto($subscription, $client, $backend); + $this->mock(AddressBookContactsUpdaterMissed::class, function (MockInterface $mock) use ($sync, $contact, $etag) { + $mock->shouldReceive('execute') + ->once() + ->withArgs(function ($localSync, $localContacts, $distContacts) use ($sync, $contact, $etag) { + $this->assertEquals($sync, $localSync); + $this->assertEquals($contact->id, $localContacts->first()->id); + $this->assertEquals('https://test/dav/uuid1', $distContacts->first()->uri); + $this->assertEquals($etag, $distContacts->first()->etag); + + return true; + }) + ->andReturn(new Promise(function () { + return true; + })); + }); + $this->mock(AddressBookContactsPushMissed::class, function (MockInterface $mock) { + $mock->shouldReceive('execute') + ->once() + ->andReturn(new Promise(function () { + return true; + })); + }); + + (new AddressBookSynchronizer()) + ->execute($sync, true); + + $tester->assert(); + } + + private function getSubscription() + { + $subscription = AddressBookSubscription::factory()->create(); + $token = factory(SyncToken::class)->create([ + 'account_id' => $subscription->account_id, + 'user_id' => $subscription->user_id, + 'name' => 'contacts1', + 'timestamp' => now()->addDays(-1), + ]); + $subscription->localSyncToken = $token->id; + $subscription->save(); + + return $subscription; + } +} diff --git a/tests/Unit/Services/DavClient/Utils/Dav/DavClientTest.php b/tests/Unit/Services/DavClient/Utils/Dav/DavClientTest.php new file mode 100644 index 00000000000..af482737c1c --- /dev/null +++ b/tests/Unit/Services/DavClient/Utils/Dav/DavClientTest.php @@ -0,0 +1,483 @@ + 'test', + 'username' => 'user', + 'password' => 'pass', + ]); + + $this->assertInstanceOf(\Sabre\DAV\Xml\Service::class, $client->xml); + } + + /** @test */ + public function it_fails_if_no_baseuri() + { + $this->expectException(\InvalidArgumentException::class); + new DavClient([]); + } + + /** @test */ + public function it_accept_guzzle_client() + { + $client = new DavClient([], new \GuzzleHttp\Client()); + $this->assertInstanceOf(DavClient::class, $client); + } + + /** @test */ + public function it_get_options() + { + $tester = (new DavTester()) + ->addResponse('https://test', new Response(200, []), null, 'OPTIONS') + ->addResponse('https://test', new Response(200, ['Dav' => 'test']), null, 'OPTIONS') + ->addResponse('https://test', new Response(200, ['Dav' => ' test ']), null, 'OPTIONS'); + $client = new DavClient([], $tester->getClient()); + + $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(); + $client = new DavClient([], $tester->getClient()); + + $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', new Response(200), null, 'GET') + ->nonStandardServiceUrl(); + $client = new DavClient([], $tester->getClient()); + + $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', new Response(404), null, 'GET') + ->nonStandardServiceUrl(); + $client = new DavClient([], $tester->getClient()); + + $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', new Response(500), null, 'GET'); + $client = new DavClient([], $tester->getClient()); + + $this->expectException(ServerException::class); + $client->getServiceUrl(); + } + + /** @test */ + public function it_get_base_uri() + { + $tester = (new DavTester()); + $client = new DavClient([], $tester->getClient()); + + $result = $client->getBaseUri(); + + $this->assertEquals('https://test', $result); + + $result = $client->getBaseUri('xxx'); + + $this->assertEquals('https://test/xxx', $result); + } + + /** @test */ + public function it_set_base_uri() + { + $tester = (new DavTester()); + $client = new DavClient([], $tester->getClient()); + + $result = $client->setBaseUri('https://new') + ->getBaseUri(); + + $this->assertEquals('https://new', $result); + } + + /** @test */ + public function it_call_propfind() + { + $tester = (new DavTester()); + + $tester->addResponse('https://test/test', new Response(200, [], $tester->multistatusHeader(). + ''. + 'href'. + ''. + ''. + 'value'. + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + ''), ''."\n". + ''. + ''. + ''. + ''. + "\n", 'PROPFIND'); + + $client = new DavClient([], $tester->getClient()); + + $result = $client->propFind('https://test/test', ['{DAV:}test']); + + $tester->assert(); + $this->assertEquals([ + '{DAV:}test' => 'value', + ], $result); + } + + /** @test */ + public function it_get_property() + { + $tester = (new DavTester()); + + $tester->addResponse('https://test/test', new Response(200, [], $tester->multistatusHeader(). + ''. + 'href'. + ''. + ''. + 'value'. + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + ''), ''."\n". + ''. + ''. + ''. + ''. + "\n", 'PROPFIND'); + + $client = new DavClient([], $tester->getClient()); + + $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')); + + $tester->addResponse('https://test/dav', new Response(200, [], $tester->multistatusHeader(). + ''. + '/dav'. + ''. + ''. + ''. + ''. + ''. + ''. + ''. + ''. + ''. + ''. + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + ''), ''."\n". + ''. + ''. + ''. + ''. + "\n", 'PROPFIND'); + + $client = new DavClient([], $tester->getClient()); + + $result = $client->getSupportedReportSet(); + + $tester->assert(); + $this->assertEquals(['{DAV:}test1', '{DAV:}test2'], $result); + } + + /** @test */ + public function it_sync_collection() + { + $tester = (new DavTester()); + + $tester->addResponse('https://test/test', new Response(200, [], $tester->multistatusHeader(). + ''. + 'href'. + ''. + ''. + '"00001-abcd1"'. + 'value'. + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + '"00001-abcd1"'. + ''), ''."\n". + ''. + ''. + '1'. + ''. + ''. + ''. + "\n", 'REPORT'); + + $client = new DavClient([], $tester->getClient()); + + $result = $client->syncCollectionAsync('https://test/test', ['{DAV:}test'], '') + ->wait(); + + $tester->assert(); + $this->assertEquals([ + 'href' => [ + 200 => [ + '{DAV:}getetag' => '"00001-abcd1"', + '{DAV:}test' => 'value', + ], + ], + 'synctoken' => '"00001-abcd1"', + ], $result); + } + + /** @test */ + public function it_sync_collection_with_synctoken() + { + $tester = (new DavTester()); + + $tester->addResponse('https://test/test', new Response(200, [], $tester->multistatusHeader(). + ''. + 'href'. + ''. + ''. + '"00001-abcd1"'. + 'value'. + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + '"00001-abcd1"'. + ''), ''."\n". + ''. + '"00000-abcd0"'. + '1'. + ''. + ''. + ''. + "\n", 'REPORT'); + + $client = new DavClient([], $tester->getClient()); + + $result = $client->syncCollectionAsync('https://test/test', ['{DAV:}test'], '"00000-abcd0"') + ->wait(); + + $tester->assert(); + $this->assertEquals([ + 'href' => [ + 200 => [ + '{DAV:}getetag' => '"00001-abcd1"', + '{DAV:}test' => 'value', + ], + ], + 'synctoken' => '"00001-abcd1"', + ], $result); + } + + /** @test */ + public function it_run_addressbook_multiget_report() + { + $tester = (new DavTester()); + + $tester->addResponse('https://test/test', new Response(200, [], $tester->multistatusHeader(). + ''. + 'href'. + ''. + ''. + '"00001-abcd1"'. + 'value'. + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + ''), ''."\n". + ''. + ''. + ''. + ''. + 'https://test/contacts/1'. + "\n", 'REPORT'); + + $client = new DavClient([], $tester->getClient()); + + $result = $client->addressbookMultigetAsync('https://test/test', ['{DAV:}test'], [ + 'https://test/contacts/1', + ]) + ->wait(); + + $tester->assert(); + $this->assertEquals([ + 'href' => [ + 200 => [ + '{DAV:}getetag' => '"00001-abcd1"', + '{DAV:}test' => 'value', + ], + ], + ], $result); + } + + /** @test */ + public function it_run_addressbook_query_report() + { + $tester = (new DavTester()); + + $tester->addResponse('https://test/test', new Response(200, [], $tester->multistatusHeader(). + ''. + 'href'. + ''. + ''. + '"00001-abcd1"'. + 'value'. + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + ''), ''."\n". + ''. + ''. + ''. + ''. + "\n", 'REPORT'); + + $client = new DavClient([], $tester->getClient()); + + $result = $client->addressbookQueryAsync('https://test/test', ['{DAV:}test']) + ->wait(); + + $tester->assert(); + $this->assertEquals([ + 'href' => [ + 200 => [ + '{DAV:}getetag' => '"00001-abcd1"', + '{DAV:}test' => 'value', + ], + ], + ], $result); + } + + /** @test */ + public function it_run_proppatch() + { + $tester = (new DavTester()); + + $tester->addResponse('https://test/test', new Response(207, [], $tester->multistatusHeader(). + ''. + 'href'. + ''. + ''. + 'value'. + ''. + 'HTTP/1.1 200 OK'. + ''. + ''. + ''), ''."\n". + ''."\n". + ' '."\n". + ' '."\n". + ' value'."\n". + ' '."\n". + ' '."\n". + "\n", 'PROPPATCH'); + + $client = new DavClient([], $tester->getClient()); + + $result = $client->propPatchAsync('https://test/test', ['{DAV:}test' => 'value']) + ->wait(); + + $tester->assert(); + $this->assertTrue($result); + } + + /** @test */ + public function it_run_proppatch_error() + { + $tester = (new DavTester()); + + $tester->addResponse('https://test/test', new Response(207, [], $tester->multistatusHeader(). + ''. + 'href'. + ''. + ''. + 'x'. + ''. + 'HTTP/1.1 405 OK'. + ''. + ''. + ''. + 'x'. + ''. + 'HTTP/1.1 500 OK'. + ''. + ''. + ''), ''."\n". + ''."\n". + ' '."\n". + ' '."\n". + ' value'."\n". + ' value'."\n". + ' '."\n". + ' '."\n". + "\n", 'PROPPATCH'); + + $client = new DavClient([], $tester->getClient()); + + $this->expectException(DavClientException::class); + $this->expectExceptionMessage('PROPPATCH failed. The following properties errored: {DAV:}test (405), {DAV:}excerpt (500)'); + $result = $client->propPatchAsync('https://test/test', [ + '{DAV:}test' => 'value', + '{DAV:}excerpt' => 'value', + ]) + ->wait(); + } +} diff --git a/tests/Unit/Services/VCard/ImportVCardTest.php b/tests/Unit/Services/VCard/ImportVCardTest.php index a40a47734b4..d63e99d7855 100644 --- a/tests/Unit/Services/VCard/ImportVCardTest.php +++ b/tests/Unit/Services/VCard/ImportVCardTest.php @@ -271,7 +271,7 @@ public function it_creates_a_contact() public function it_creates_a_contact_in_address_book() { $user = factory(User::class)->create([]); - $addressBook = AddressBook::create([ + $addressBook = AddressBook::factory()->create([ 'account_id' => $user->account_id, 'user_id' => $user->id, 'name' => 'contacts',