From 76dcfcb3f96a7301ac70b0356c1173cfe69cb074 Mon Sep 17 00:00:00 2001 From: Alexis Saettler Date: Tue, 1 Jan 2019 14:09:13 +0100 Subject: [PATCH] feat: carddav: support sync-token (#2230) --- CHANGELOG | 2 +- .../Controllers/CardDAV/CardDAVController.php | 4 + .../CardDAV/Backends/MonicaCardDAVBackend.php | 206 +++++++++++++++++- app/Models/CardDAV/MonicaAddressBook.php | 16 +- app/Models/User/SyncToken.php | 28 +++ database/factories/ModelFactory.php | 8 + .../2018_12_29_135516_sync_token.php | 26 +++ tests/Api/Carddav/CarddavContactTest.php | 121 ++++++++++ tests/Api/Carddav/CarddavServerTest.php | 178 +++++++++++++++ 9 files changed, 567 insertions(+), 22 deletions(-) create mode 100644 app/Models/User/SyncToken.php create mode 100644 database/migrations/2018_12_29_135516_sync_token.php diff --git a/CHANGELOG b/CHANGELOG index 067becda6..e90c98d4a 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -4,7 +4,7 @@ New features: * Enhancements: -* +* Carddav: support sync-token (rfc6578) Fixes: * Fix exception when user is logged out (again) diff --git a/app/Http/Controllers/CardDAV/CardDAVController.php b/app/Http/Controllers/CardDAV/CardDAVController.php index 366b7eea8..7280681e2 100644 --- a/app/Http/Controllers/CardDAV/CardDAVController.php +++ b/app/Http/Controllers/CardDAV/CardDAVController.php @@ -10,6 +10,7 @@ use Sabre\DAV\Server as SabreServer; use Sabre\DAVACL\Plugin as AclPlugin; use Sabre\DAVACL\PrincipalCollection; use Sabre\DAV\Auth\Plugin as AuthPlugin; +use Sabre\DAV\Sync\Plugin as SyncPlugin; use Barryvdh\Debugbar\Facade as Debugbar; use Sabre\CardDAV\Plugin as CardDAVPlugin; use App\Models\CardDAV\MonicaAddressBookRoot; @@ -103,6 +104,9 @@ class CardDAVController extends Controller // CardDAV plugin $server->addPlugin(new CardDAVPlugin()); + // Sync Plugin - rfc6578 + $server->addPlugin(new SyncPlugin()); + // ACL plugnin $aclPlugin = new AclPlugin(); $aclPlugin->allowUnauthenticatedAccess = false; diff --git a/app/Models/CardDAV/Backends/MonicaCardDAVBackend.php b/app/Models/CardDAV/Backends/MonicaCardDAVBackend.php index 9ec2873a1..1d6756984 100644 --- a/app/Models/CardDAV/Backends/MonicaCardDAVBackend.php +++ b/app/Models/CardDAV/Backends/MonicaCardDAVBackend.php @@ -3,15 +3,18 @@ namespace App\Models\CardDAV\Backends; use Sabre\DAV; +use App\Models\User\SyncToken; use App\Models\Contact\Contact; use Sabre\VObject\Component\VCard; use App\Services\VCard\ExportVCard; use App\Services\VCard\ImportVCard; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Auth; +use Sabre\CardDAV\Backend\SyncSupport; use Sabre\CardDAV\Backend\AbstractBackend; +use Sabre\CardDAV\Plugin as CardDAVPlugin; -class MonicaCardDAVBackend extends AbstractBackend +class MonicaCardDAVBackend extends AbstractBackend implements SyncSupport { /** * Returns the list of addressbooks for a specific user. @@ -32,16 +35,181 @@ class MonicaCardDAVBackend extends AbstractBackend */ public function getAddressBooksForUser($principalUri) { + $name = Auth::user()->name; + $token = $this->getSyncToken(); + return [ [ - 'id' => '0', - 'uri' => 'contacts', - 'principaluri' => MonicaPrincipalBackend::getPrincipalUser(), - '{DAV:}displayname' => Auth::user()->name, + 'id' => '0', + 'uri' => 'contacts', + 'principaluri' => MonicaPrincipalBackend::getPrincipalUser(), + '{DAV:}displayname' => $name, + '{http://sabredav.org/ns}sync-token' => $token->id, + '{DAV:}sync-token' => $token->id, + '{'.CardDAVPlugin::NS_CARDDAV.'}addressbook-description' => $name, ], ]; } + /** + * This method returns a sync-token for this collection. + * + * If null is returned from this function, the plugin assumes there's no + * sync information available. + * + * @return SyncToken + */ + public function getSyncToken() + { + $tokens = SyncToken::where([ + ['account_id', Auth::user()->account_id], + ['user_id', Auth::user()->id], + ]) + ->orderBy('created_at') + ->get(); + + if ($tokens->count() <= 0) { + $token = $this->createSyncToken(); + } else { + $token = $tokens->last(); + + if ($token->timestamp < $this->getLastModified()) { + $token = $this->createSyncToken(); + } + } + + return $token; + } + + /** + * Create a token. + * + * @return SyncToken + */ + private function createSyncToken() + { + $max = $this->getLastModified(); + + return SyncToken::create([ + 'account_id' => Auth::user()->account_id, + 'user_id' => Auth::user()->id, + 'timestamp' => $max, + ]); + } + + /** + * Returns the last modification date. + * + * @return \Carbon\Carbon + */ + public function getLastModified() + { + return $this->getContacts() + ->max('updated_at'); + } + + /** + * The getChanges method returns all the changes that have happened, since + * the specified syncToken in the specified address book. + * + * This function should return an array, such as the following: + * + * [ + * 'syncToken' => 'The current synctoken', + * 'added' => [ + * 'new.txt', + * ], + * 'modified' => [ + * 'modified.txt', + * ], + * 'deleted' => [ + * 'foo.php.bak', + * 'old.txt' + * ] + * ]; + * + * The returned syncToken property should reflect the *current* syncToken + * of the calendar, as reported in the {http://sabredav.org/ns}sync-token + * property. This is needed here too, to ensure the operation is atomic. + * + * If the $syncToken argument is specified as null, this is an initial + * sync, and all members should be reported. + * + * The modified property is an array of nodenames that have changed since + * the last token. + * + * The deleted property is an array with nodenames, that have been deleted + * from collection. + * + * The $syncLevel argument is basically the 'depth' of the report. If it's + * 1, you only have to report changes that happened only directly in + * immediate descendants. If it's 2, it should also include changes from + * the nodes below the child collections. (grandchildren) + * + * The $limit argument allows a client to specify how many results should + * be returned at most. If the limit is not specified, it should be treated + * as infinite. + * + * If the limit (infinite or not) is higher than you're willing to return, + * you should throw a Sabre\DAV\Exception\TooMuchMatches() exception. + * + * If the syncToken is expired (due to data cleanup) or unknown, you must + * return null. + * + * The limit is 'suggestive'. You are free to ignore it. + * + * @param string $addressBookId + * @param string $syncToken + * @param int $syncLevel + * @param int $limit + * @return array + */ + public function getChangesForAddressBook($addressBookId, $syncToken, $syncLevel, $limit = null) + { + $token = null; + $timestamp = null; + if (! empty($syncToken)) { + $token = SyncToken::where([ + 'account_id' => Auth::user()->account_id, + 'user_id' => Auth::user()->id, + ])->find($syncToken); + + if (is_null($token)) { + // syncToken is not recognized + return; + } + + $timestamp = $token->timestamp; + $token = $this->getSyncToken(); + } else { + $token = $this->createSyncToken(); + $timestamp = null; + } + + $contacts = $this->getContacts(); + + $modified = $contacts->filter(function ($contact) use ($timestamp) { + return ! is_null($timestamp) && + $contact->updated_at > $timestamp && + $contact->created_at < $timestamp; + }); + $added = $contacts->filter(function ($contact) use ($timestamp) { + return is_null($timestamp) || + $contact->created_at >= $timestamp; + }); + + return [ + 'syncToken' => $token->id, + 'added' => $added->map(function ($contact) { + return $this->encodeUri($contact); + })->toArray(), + 'modified' => $modified->map(function ($contact) { + return $this->encodeUri($contact); + })->toArray(), + 'deleted' => [], + ]; + } + /** * Updates properties for an address book. * @@ -113,7 +281,7 @@ class MonicaCardDAVBackend extends AbstractBackend 'uri' => $this->encodeUri($contact), 'carddata' => $carddata, 'etag' => '"'.md5($carddata).'"', - 'lastmodified' => $contact->updated_at, + 'lastmodified' => $contact->updated_at->timestamp, ]; } @@ -127,6 +295,12 @@ class MonicaCardDAVBackend extends AbstractBackend return pathinfo(urldecode($uri), PATHINFO_FILENAME); } + /** + * Returns the contact for the specific uri. + * + * @param string $uri + * @return Contact + */ private function getContact($uri) { try { @@ -139,6 +313,20 @@ class MonicaCardDAVBackend extends AbstractBackend } } + /** + * Returns the collection of all active contacts. + * + * @return \Illuminate\Support\Collection + */ + private function getContacts() + { + return Auth::user()->account + ->contacts() + ->real() + ->active() + ->get(); + } + /** * Returns all cards for a specific addressbook id. * @@ -160,11 +348,7 @@ class MonicaCardDAVBackend extends AbstractBackend */ public function getCards($addressbookId) { - $contacts = Auth::user()->account - ->contacts() - ->real() - ->active() - ->get(); + $contacts = $this->getContacts(); return $contacts->map(function ($contact) { return $this->prepareCard($contact); diff --git a/app/Models/CardDAV/MonicaAddressBook.php b/app/Models/CardDAV/MonicaAddressBook.php index 32f998b6d..f905c7826 100644 --- a/app/Models/CardDAV/MonicaAddressBook.php +++ b/app/Models/CardDAV/MonicaAddressBook.php @@ -3,7 +3,7 @@ namespace App\Models\CardDAV; use Sabre\CardDAV\AddressBook; -use Illuminate\Support\Facades\Auth; +use App\Models\CardDAV\Backends\MonicaCardDAVBackend; class MonicaAddressBook extends AddressBook { @@ -48,18 +48,14 @@ class MonicaAddressBook extends AddressBook } /** - * Returns the last modification date as a unix timestamp. + * Returns the last modification date. * - * @return void + * @return \Carbon\Carbon */ public function getLastModified() { - $contacts = Auth::user()->account - ->contacts() - ->real() - ->active() - ->get(); - - return $contacts->max('updated_at'); + if ($this->carddavBackend instanceof MonicaCardDAVBackend) { + return $this->carddavBackend->getLastModified(); + } } } diff --git a/app/Models/User/SyncToken.php b/app/Models/User/SyncToken.php new file mode 100644 index 000000000..22d4d9419 --- /dev/null +++ b/app/Models/User/SyncToken.php @@ -0,0 +1,28 @@ +define(App\Models\Account\Weather::class, function (Faker\Generator $f 'created_at' => now(), ]; }); + +$factory->define(App\Models\User\SyncToken::class, function (Faker\Generator $faker) { + return [ + 'account_id' => factory(App\Models\Account\Account::class)->create()->id, + 'user_id' => factory(App\Models\User\User::class)->create()->id, + 'timestamp' => \App\Helpers\DateHelper::parseDateTime($faker->dateTimeThisCentury()), + ]; +}); diff --git a/database/migrations/2018_12_29_135516_sync_token.php b/database/migrations/2018_12_29_135516_sync_token.php new file mode 100644 index 000000000..f433e884e --- /dev/null +++ b/database/migrations/2018_12_29_135516_sync_token.php @@ -0,0 +1,26 @@ +increments('id'); + $table->unsignedInteger('account_id'); + $table->unsignedInteger('user_id'); + $table->timestamp('timestamp'); + $table->timestamps(); + $table->foreign('account_id')->references('id')->on('accounts')->onDelete('cascade'); + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + }); + } +} diff --git a/tests/Api/Carddav/CarddavContactTest.php b/tests/Api/Carddav/CarddavContactTest.php index be5032e66..4370380fc 100644 --- a/tests/Api/Carddav/CarddavContactTest.php +++ b/tests/Api/Carddav/CarddavContactTest.php @@ -81,6 +81,127 @@ class CarddavContactTest extends ApiTestCase ]); } + /** + * @group carddav + */ + public function test_carddav_update_existing_contact_if_modified() + { + $user = $this->signin(); + $contact = factory(Contact::class)->create([ + 'account_id' => $user->account->id, + ]); + $filename = urlencode($contact->uuid.'.vcf'); + + $response = $this->call('PUT', "/carddav/addressbooks/{$user->email}/contacts/{$filename}", [], [], [], + [ + 'HTTP_If-Modified-Since' => $contact->updated_at->addDays(-1)->toRfc7231String(), + 'content-type' => 'application/xml; charset=utf-8', + ], + "BEGIN:VCARD\nVERSION:4.0\nFN:John Doex\nN:Doex;John;;;\nEND:VCARD" + ); + + $response->assertStatus(204); + $response->assertHeader('X-Sabre-Version'); + $response->assertHeaderMissing('ETag'); + + $this->assertDatabaseHas('contacts', [ + 'account_id' => $user->account->id, + 'first_name' => 'John', + 'last_name' => 'Doex', + ]); + } + + /** + * @group carddav + */ + public function test_carddav_update_existing_contact_if_modified_not_modified() + { + $user = $this->signin(); + $contact = factory(Contact::class)->create([ + 'account_id' => $user->account->id, + ]); + $filename = urlencode($contact->uuid.'.vcf'); + + $response = $this->call('PUT', "/carddav/addressbooks/{$user->email}/contacts/{$filename}", [], [], [], + [ + 'HTTP_If-Modified-Since' => $contact->updated_at->addDays(1)->toRfc7231String(), + 'content-type' => 'application/xml; charset=utf-8', + ], + "BEGIN:VCARD\nVERSION:4.0\nFN:John Doex\nN:Doex;John;;;\nEND:VCARD" + ); + + // Not modified + $response->assertStatus(304); + + $response->assertHeader('X-Sabre-Version'); + + // see http://tools.ietf.org/html/rfc2616#section-10.3.5 + $response->assertHeaderMissing('Last-Modified'); + + $response->assertHeaderMissing('ETag'); + } + + /** + * @group carddav + */ + public function test_carddav_update_existing_contact_if_unmodified() + { + $user = $this->signin(); + $contact = factory(Contact::class)->create([ + 'account_id' => $user->account->id, + ]); + $filename = urlencode($contact->uuid.'.vcf'); + + $response = $this->call('PUT', "/carddav/addressbooks/{$user->email}/contacts/{$filename}", [], [], [], + [ + 'HTTP_If-Unmodified-Since' => $contact->updated_at->addDays(1)->toRfc7231String(), + 'content-type' => 'application/xml; charset=utf-8', + ], + "BEGIN:VCARD\nVERSION:4.0\nFN:John Doex\nN:Doex;John;;;\nEND:VCARD" + ); + + $response->assertStatus(204); + $response->assertHeader('X-Sabre-Version'); + $response->assertHeaderMissing('ETag'); + + $this->assertDatabaseHas('contacts', [ + 'account_id' => $user->account->id, + 'first_name' => 'John', + 'last_name' => 'Doex', + ]); + } + + /** + * @group carddav + */ + public function test_carddav_update_existing_contact_if_unmodified_error() + { + $user = $this->signin(); + $contact = factory(Contact::class)->create([ + 'account_id' => $user->account->id, + ]); + $filename = urlencode($contact->uuid.'.vcf'); + + $response = $this->call('PUT', "/carddav/addressbooks/{$user->email}/contacts/{$filename}", [], [], [], + [ + 'HTTP_If-Unmodified-Since' => $contact->updated_at->addDays(-1)->toRfc7231String(), + 'content-type' => 'application/xml; charset=utf-8', + ], + "BEGIN:VCARD\nVERSION:4.0\nFN:John Doex\nN:Doex;John;;;\nEND:VCARD" + ); + + // PRECONDITION FAILED + $response->assertStatus(412); + + $response->assertHeader('X-Sabre-Version'); + + $sabreversion = \Sabre\DAV\Version::VERSION; + $response->assertSee(" + {$sabreversion} + Sabre\DAV\Exception\PreconditionFailed + An If-Unmodified-Since header was specified, but the entity has been changed since the specified date."); + } + /** * @group carddav */ diff --git a/tests/Api/Carddav/CarddavServerTest.php b/tests/Api/Carddav/CarddavServerTest.php index 8147f35e7..7f7f96f8a 100644 --- a/tests/Api/Carddav/CarddavServerTest.php +++ b/tests/Api/Carddav/CarddavServerTest.php @@ -3,6 +3,7 @@ namespace Tests\Api\Carddav; use Tests\ApiTestCase; +use App\Models\User\SyncToken; use App\Models\Contact\Contact; use Illuminate\Foundation\Testing\DatabaseTransactions; @@ -265,4 +266,181 @@ class CarddavServerTest extends ApiTestCase ''. ''); } + + public function test_carddav_getctag() + { + $user = $this->signin(); + $contact = factory(Contact::class)->create([ + 'account_id' => $user->account->id, + ]); + + $response = $this->call('PROPFIND', "/carddav/addressbooks/{$user->email}/", [], [], [], + [ + 'HTTP_DEPTH' => '1', + 'content-type' => 'application/xml; charset=utf-8', + ], + " + + + + + " + ); + + $response->assertStatus(207); + $response->assertHeader('X-Sabre-Version'); + + $tokens = SyncToken::where([ + ['account_id', $user->account_id], + ['user_id', $user->id], + ])->orderBy('created_at')->get(); + + $this->assertGreaterThan(0, $tokens->count()); + $token = $tokens->last(); + + $response->assertSee(''. + "/carddav/addressbooks/{$user->email}/contacts/". + ''. + ''. + "http://sabre.io/ns/sync/{$token->id}". + "http://sabre.io/ns/sync/{$token->id}". + ''. + 'HTTP/1.1 200 OK'. + ''. + '' + ); + } + + public function test_carddav_getctag_contacts() + { + $user = $this->signin(); + $contact = factory(Contact::class)->create([ + 'account_id' => $user->account->id, + ]); + + $response = $this->call('PROPFIND', "/carddav/addressbooks/{$user->email}/contacts/", [], [], [], + [ + 'HTTP_DEPTH' => '0', + 'content-type' => 'application/xml; charset=utf-8', + ], + " + + + + + " + ); + + $response->assertStatus(207); + $response->assertHeader('X-Sabre-Version'); + + $tokens = SyncToken::where([ + ['account_id', $user->account_id], + ['user_id', $user->id], + ])->orderBy('created_at')->get(); + + $this->assertGreaterThan(0, $tokens->count()); + $token = $tokens->last(); + + $response->assertSee(''. + "/carddav/addressbooks/{$user->email}/contacts/". + ''. + ''. + "http://sabre.io/ns/sync/{$token->id}". + "http://sabre.io/ns/sync/{$token->id}". + ''. + 'HTTP/1.1 200 OK'. + ''. + '' + ); + } + + public function test_carddav_sync_collection_with_token() + { + $user = $this->signin(); + $token = factory(SyncToken::class)->create([ + 'account_id' => $user->account->id, + 'user_id' => $user->id, + 'timestamp' => \App\Helpers\DateHelper::parseDateTime(now()), + ]); + $contact = factory(Contact::class)->create([ + 'account_id' => $user->account->id, + ]); + + $response = $this->call('REPORT', "/carddav/addressbooks/{$user->email}/contacts/", [], [], [], + [ + 'content-type' => 'application/xml; charset=utf-8', + ], + " + http://sabre.io/ns/sync/{$token->id} + 1 + + + + " + ); + + $response->assertStatus(207); + $response->assertHeader('X-Sabre-Version'); + + $response->assertSee(" + + /carddav/addressbooks/{$user->email}/contacts/{$contact->uuid}.vcf + + + ""); + $response->assertSee("" + + HTTP/1.1 200 OK + + + http://sabre.io/ns/sync/{$token->id} +"); + } + + public function test_carddav_sync_collection_init() + { + $user = $this->signin(); + $contact = factory(Contact::class)->create([ + 'account_id' => $user->account->id, + ]); + + $response = $this->call('REPORT', "/carddav/addressbooks/{$user->email}/contacts/", [], [], [], + [ + 'content-type' => 'application/xml; charset=utf-8', + ], + " + + 1 + + + + " + ); + + $response->assertStatus(207); + $response->assertHeader('X-Sabre-Version'); + + $tokens = SyncToken::where([ + ['account_id', $user->account_id], + ['user_id', $user->id], + ])->orderBy('created_at')->get(); + + $this->assertGreaterThan(0, $tokens->count()); + $token = $tokens->last(); + + $response->assertSee(" + + /carddav/addressbooks/{$user->email}/contacts/{$contact->uuid}.vcf + + + ""); + $response->assertSee("" + + HTTP/1.1 200 OK + + + http://sabre.io/ns/sync/{$token->id} +"); + } }