From 8fcbec95b093be67ee30c6057015884bbecb2f35 Mon Sep 17 00:00:00 2001 From: philipp lang Date: Wed, 15 May 2024 01:36:36 +0200 Subject: [PATCH] Add Caldav for member birthdays --- app/Dav/CalendarBackend.php | 339 +++++++++++++++++++++++++++++ app/Dav/ServiceProvider.php | 6 +- app/Member/Member.php | 20 +- tests/Feature/Member/DavTest.php | 104 ++------- tests/Feature/Member/VcardTest.php | 114 ++++++++++ tests/Lib/TestsDav.php | 34 +++ tests/TestCase.php | 2 + 7 files changed, 527 insertions(+), 92 deletions(-) create mode 100644 app/Dav/CalendarBackend.php create mode 100644 tests/Feature/Member/VcardTest.php create mode 100644 tests/Lib/TestsDav.php diff --git a/app/Dav/CalendarBackend.php b/app/Dav/CalendarBackend.php new file mode 100644 index 00000000..6c790dc2 --- /dev/null +++ b/app/Dav/CalendarBackend.php @@ -0,0 +1,339 @@ +> + */ + public function getCalendarsForUser($principalUri) + { + if (1 !== preg_match('/^principals\/(.*)$/', $principalUri, $matches)) { + return []; + } + + User::where('email', $matches[1])->firstOrFail(); + + return [ + [ + 'id' => 'birthdays', + 'principaluri' => $principalUri, + 'uri' => 'birthdays', + '{DAV:}displayname' => 'Geburtstage', + ], + ]; + } + + /** + * Creates a new calendar for a principal. + * + * If the creation was a success, an id must be returned that can be used to + * reference this calendar in other methods, such as updateCalendar. + * + * The id can be any type, including ints, strings, objects or array. + * + * @param string $principalUri + * @param string $calendarUri + * @param array $properties + * + * @return mixed + */ + public function createCalendar($principalUri, $calendarUri, array $properties) + { + } + + /** + * Updates properties for a calendar. + * + * The list of mutations is stored in a Sabre\DAV\PropPatch object. + * To do the actual updates, you must tell this object which properties + * you're going to process with the handle() method. + * + * Calling the handle method is like telling the PropPatch object "I + * promise I can handle updating this property". + * + * Read the PropPatch documentation for more info and examples. + * + * @param mixed $calendarId + * @return void + */ + public function updateCalendar($calendarId, \Sabre\DAV\PropPatch $propPatch) + { + } + + /** + * Delete a calendar and all its objects. + * + * @param mixed $calendarId + * @return void + */ + public function deleteCalendar($calendarId) + { + } + + /** + * Returns all calendar objects within a calendar. + * + * Every item contains an array with the following keys: + * * calendardata - The iCalendar-compatible calendar data + * * uri - a unique key which will be used to construct the uri. This can + * be any arbitrary string, but making sure it ends with '.ics' is a + * good idea. This is only the basename, or filename, not the full + * path. + * * lastmodified - a timestamp of the last modification time + * * etag - An arbitrary string, surrounded by double-quotes. (e.g.: + * '"abcdef"') + * * size - The size of the calendar objects, in bytes. + * * component - optional, a string containing the type of object, such + * as 'vevent' or 'vtodo'. If specified, this will be used to populate + * the Content-Type header. + * + * Note that the etag is optional, but it's highly encouraged to return for + * speed reasons. + * + * The calendardata is also optional. If it's not returned + * 'getCalendarObject' will be called later, which *is* expected to return + * calendardata. + * + * If neither etag or size are specified, the calendardata will be + * used/fetched to determine these numbers. If both are specified the + * amount of times this is needed is reduced by a great degree. + * + * @param mixed $calendarId + * + * @return array + * @return void + */ + public function getCalendarObjects($calendarId) + { + return Member::whereNotNull('birthday')->get()->map(fn ($member) => $this->calendarObjectMeta($member))->toArray(); + } + + private function calendarObjectMeta(Member $member): array + { + return [ + 'calendardata' => $member->toCalendarObject()->serialize(), + 'uri' => $member->slug . '.ics', + 'lastmodified' => $member->updated_at->timestamp, + 'etag' => '"' . $member->etag . '"', + 'size' => strlen($member->toCalendarObject()->serialize()), + 'component' => 'vevent', + ]; + } + + /** + * Returns information from a single calendar object, based on it's object + * uri. + * + * The object uri is only the basename, or filename and not a full path. + * + * The returned array must have the same keys as getCalendarObjects. The + * 'calendardata' object is required here though, while it's not required + * for getCalendarObjects. + * + * This method must return null if the object did not exist. + * + * @param mixed $calendarId + * @param string $objectUri + * + * @return array|null + */ + public function getCalendarObject($calendarId, $objectUri) + { + $member = Member::where('slug', str($objectUri)->replace('.ics', ''))->first(); + + if (!$member || !$member->toCalendarObject()) { + return null; + } + + return [ + ...$this->calendarObjectMeta($member), + 'calendardata' => $member->toCalendarObject()->serialize(), + ]; + } + + /** + * Returns a list of calendar objects. + * + * This method should work identical to getCalendarObject, but instead + * return all the calendar objects in the list as an array. + * + * If the backend supports this, it may allow for some speed-ups. + * + * @param mixed $calendarId + * + * @return array + */ + public function getMultipleCalendarObjects($calendarId, array $uris) + { + return Member::whereNotNull('birthday')->get()->map(fn ($member) => $this->getCalendarObject($calendarId, $member->slug . '.ics'))->toArray(); + } + + /** + * Creates a new calendar object. + * + * The object uri is only the basename, or filename and not a full path. + * + * It is possible to return an etag from this function, which will be used + * in the response to this PUT request. Note that the ETag must be + * surrounded by double-quotes. + * + * However, you should only really return this ETag if you don't mangle the + * calendar-data. If the result of a subsequent GET to this object is not + * the exact same as this request body, you should omit the ETag. + * + * @param mixed $calendarId + * @param string $objectUri + * @param string $calendarData + * + * @return string|null + */ + public function createCalendarObject($calendarId, $objectUri, $calendarData) + { + } + + /** + * Updates an existing calendarobject, based on it's uri. + * + * The object uri is only the basename, or filename and not a full path. + * + * It is possible return an etag from this function, which will be used in + * the response to this PUT request. Note that the ETag must be surrounded + * by double-quotes. + * + * However, you should only really return this ETag if you don't mangle the + * calendar-data. If the result of a subsequent GET to this object is not + * the exact same as this request body, you should omit the ETag. + * + * @param mixed $calendarId + * @param string $objectUri + * @param string $calendarData + * + * @return string|null + */ + public function updateCalendarObject($calendarId, $objectUri, $calendarData) + { + } + + /** + * Deletes an existing calendar object. + * + * The object uri is only the basename, or filename and not a full path. + * + * @param mixed $calendarId + * @param string $objectUri + */ + public function deleteCalendarObject($calendarId, $objectUri) + { + } + + /** + * Performs a calendar-query on the contents of this calendar. + * + * The calendar-query is defined in RFC4791 : CalDAV. Using the + * calendar-query it is possible for a client to request a specific set of + * object, based on contents of iCalendar properties, date-ranges and + * iCalendar component types (VTODO, VEVENT). + * + * This method should just return a list of (relative) urls that match this + * query. + * + * The list of filters are specified as an array. The exact array is + * documented by Sabre\CalDAV\CalendarQueryParser. + * + * Note that it is extremely likely that getCalendarObject for every path + * returned from this method will be called almost immediately after. You + * may want to anticipate this to speed up these requests. + * + * This method provides a default implementation, which parses *all* the + * iCalendar objects in the specified calendar. + * + * This default may well be good enough for personal use, and calendars + * that aren't very large. But if you anticipate high usage, big calendars + * or high loads, you are strongly adviced to optimize certain paths. + * + * The best way to do so is override this method and to optimize + * specifically for 'common filters'. + * + * Requests that are extremely common are: + * * requests for just VEVENTS + * * requests for just VTODO + * * requests with a time-range-filter on either VEVENT or VTODO. + * + * ..and combinations of these requests. It may not be worth it to try to + * handle every possible situation and just rely on the (relatively + * easy to use) CalendarQueryValidator to handle the rest. + * + * Note that especially time-range-filters may be difficult to parse. A + * time-range filter specified on a VEVENT must for instance also handle + * recurrence rules correctly. + * A good example of how to interprete all these filters can also simply + * be found in Sabre\CalDAV\CalendarQueryFilter. This class is as correct + * as possible, so it gives you a good idea on what type of stuff you need + * to think of. + * + * @param mixed $calendarId + * + * @return array + */ + public function calendarQuery($calendarId, array $filters) + { + } + + /** + * Searches through all of a users calendars and calendar objects to find + * an object with a specific UID. + * + * This method should return the path to this object, relative to the + * calendar home, so this path usually only contains two parts: + * + * calendarpath/objectpath.ics + * + * If the uid is not found, return null. + * + * This method should only consider * objects that the principal owns, so + * any calendars owned by other principals that also appear in this + * collection should be ignored. + * + * @param string $principalUri + * @param string $uid + * + * @return string|null + */ + public function getCalendarObjectByUID($principalUri, $uid) + { + } +} diff --git a/app/Dav/ServiceProvider.php b/app/Dav/ServiceProvider.php index b32ca6af..b0bc10ba 100644 --- a/app/Dav/ServiceProvider.php +++ b/app/Dav/ServiceProvider.php @@ -6,7 +6,9 @@ use Illuminate\Support\ServiceProvider as BaseServiceProvider; use LaravelSabre\Http\Auth\AuthBackend; use LaravelSabre\LaravelSabre; use Sabre\CardDAV\AddressBookRoot; +use Sabre\CalDAV\CalendarRoot; use Sabre\CardDAV\Plugin as CardDAVPlugin; +use Sabre\CalDAV\Plugin as CalDAVPlugin; use Sabre\DAV\Auth\Plugin as AuthPlugin; use Sabre\DAV\Browser\Plugin as BrowserPlugin; use Sabre\DAVACL\AbstractPrincipalCollection; @@ -42,11 +44,12 @@ class ServiceProvider extends BaseServiceProvider { $principalBackend = new Principal(); $addressBookBackend = new AddressBookBackend(); + $calendarBackend = new CalendarBackend(); - // Directory tree return [ new PrincipalCollection($principalBackend), new AddressBookRoot($principalBackend, $addressBookBackend), + new CalendarRoot($principalBackend, $calendarBackend), ]; } @@ -58,6 +61,7 @@ class ServiceProvider extends BaseServiceProvider new BrowserPlugin(), new AuthPlugin($authBackend), new CardDAVPlugin(), + new CalDAVPlugin(), new AclPlugin(), ]; } diff --git a/app/Member/Member.php b/app/Member/Member.php index ad6b3848..bef1675e 100644 --- a/app/Member/Member.php +++ b/app/Member/Member.php @@ -23,8 +23,8 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Notifications\Notifiable; -use Laravel\Scout\Attributes\SearchUsingFullText; use Laravel\Scout\Searchable; +use Sabre\VObject\Component\VCalendar; use Sabre\VObject\Component\VCard; use Sabre\VObject\Reader; use Spatie\LaravelData\Lazy; @@ -437,6 +437,24 @@ class Member extends Model implements Geolocatable return $card; } + public function toCalendarObject(): ?VCalendar + { + if (!$this->birthday) { + return null; + } + + $vcalendar = new VCalendar([ + 'VEVENT' => [ + 'SUMMARY' => 'Geburtstag von ' . $this->fullname, + 'RRULE' => 'FREQ=YEARLY', + ] + ]); + + $vcalendar->VEVENT->add('DTSTART', new \DateTime($this->birthday->format('Y-m-d')), ['VALUE' => 'DATE']); + + return $vcalendar; + } + public function toSender(): Sender { return Sender::from([ diff --git a/tests/Feature/Member/DavTest.php b/tests/Feature/Member/DavTest.php index ffb972b5..a7d849da 100644 --- a/tests/Feature/Member/DavTest.php +++ b/tests/Feature/Member/DavTest.php @@ -2,11 +2,8 @@ namespace Tests\Feature\Member; -use App\Group; use App\Member\Member; -use App\Nationality; -use App\Payment\Subscription; -use App\Setting\NamiSettings; +use Illuminate\Foundation\Testing\DatabaseMigrations; use Illuminate\Foundation\Testing\DatabaseTransactions; use Tests\TestCase; @@ -14,101 +11,28 @@ class DavTest extends TestCase { use DatabaseTransactions; - public function testItCanStoreAMemberFromAVcard(): void + public function testItDisplaysCalendar(): void { - Nationality::factory()->create(['name' => 'englisch']); - $subscription = Subscription::factory()->forFee()->create(['name' => 'Voll']); - $nationality = Nationality::factory()->create(['name' => 'deutsch']); - $group = Group::factory()->create(); - NamiSettings::fake(['default_group_id' => $group->id]); - $cardUri = '97266d2e-36e7-4fb6-8b6c-bbf57a061685.vcf'; - $cardData = <<createDavUser()->withoutExceptionHandling(); -VCARD; - $member = Member::fromVcard($cardUri, $cardData); - - $member->save(); - - $this->assertDatabaseHas('members', [ - 'slug' => '97266d2e-36e7-4fb6-8b6c-bbf57a061685', - 'firstname' => 'given', - 'lastname' => 'familya', - 'address' => 'Itterstr 3', - 'zip' => '42719', - 'location' => 'Solingen', - 'group_id' => $group->id, - 'nationality_id' => $nationality->id, - 'subscription_id' => $subscription->id, - ]); + $this->getDavCalendars()->assertSee('Geburtstage'); } - public function testTheVcardHasTheMembersSlug(): void + public function testItDisplaysBirthdaysOfMembers(): void { - $member = Member::factory()->defaults()->create(['firstname' => 'max', 'lastname' => 'muster']); + $this->createDavUser()->withoutExceptionHandling(); - $card = $member->toVcard(); - - $this->assertEquals('max-muster', $card->UID->getValue()); - } - - public function testItSetsTheNames(): void - { - $member = Member::factory()->defaults()->create(['firstname' => 'Max', 'lastname' => 'Muster']); - - $card = $member->toVcard(); - - $this->assertEquals(['Muster', 'Max', '', '', ''], $card->N->getParts()); - $this->assertEquals('Max Muster', $card->FN->getValue()); - } - - public function testItDoesntNeedBirthday(): void - { - $member = Member::factory()->defaults()->create(['birthday' => null]); - - $card = $member->toVcard(); - - $this->assertNull($card->BDAY); - } - - public function testItSetsTheBirthday(): void - { - $member = Member::factory()->defaults()->create(['birthday' => '1993-05-06']); - - $card = $member->toVcard(); - - $this->assertEquals('19930506', $card->BDAY->getValue()); - } - - public function testItUnsetsMobilePhoneNumber(): void - { $member = Member::factory()->defaults()->create(); - $member->update(['mobile_phone' => '']); + $this->getDavCalendar('birthdays')->assertSee($member->slug . '.ics'); + } - if (!is_null($member->toVcard()->TEL)) { - foreach ($member->toVcard()->TEL as $t) { - if ($t['TYPE'] && 'cell' === $t['TYPE']->getValue()) { - $this->assertFalse(true, 'Phone number found'); - continue; - } - } - } + public function testItGetsObjectsWhenBirthdayIsNull(): void + { + $this->createDavUser()->withoutExceptionHandling(); - $this->assertTrue(true); + $member = Member::factory()->defaults()->create(['birthday' => null]); + + $this->getDavCalendar('birthdays')->assertStatus(207); } } diff --git a/tests/Feature/Member/VcardTest.php b/tests/Feature/Member/VcardTest.php new file mode 100644 index 00000000..29b7d0ee --- /dev/null +++ b/tests/Feature/Member/VcardTest.php @@ -0,0 +1,114 @@ +create(['name' => 'englisch']); + $subscription = Subscription::factory()->forFee()->create(['name' => 'Voll']); + $nationality = Nationality::factory()->create(['name' => 'deutsch']); + $group = Group::factory()->create(); + NamiSettings::fake(['default_group_id' => $group->id]); + $cardUri = '97266d2e-36e7-4fb6-8b6c-bbf57a061685.vcf'; + $cardData = <<save(); + + $this->assertDatabaseHas('members', [ + 'slug' => '97266d2e-36e7-4fb6-8b6c-bbf57a061685', + 'firstname' => 'given', + 'lastname' => 'familya', + 'address' => 'Itterstr 3', + 'zip' => '42719', + 'location' => 'Solingen', + 'group_id' => $group->id, + 'nationality_id' => $nationality->id, + 'subscription_id' => $subscription->id, + ]); + } + + public function testTheVcardHasTheMembersSlug(): void + { + $member = Member::factory()->defaults()->create(['firstname' => 'max', 'lastname' => 'muster']); + + $card = $member->toVcard(); + + $this->assertEquals('max-muster', $card->UID->getValue()); + } + + public function testItSetsTheNames(): void + { + $member = Member::factory()->defaults()->create(['firstname' => 'Max', 'lastname' => 'Muster']); + + $card = $member->toVcard(); + + $this->assertEquals(['Muster', 'Max', '', '', ''], $card->N->getParts()); + $this->assertEquals('Max Muster', $card->FN->getValue()); + } + + public function testItDoesntNeedBirthday(): void + { + $member = Member::factory()->defaults()->create(['birthday' => null]); + + $card = $member->toVcard(); + + $this->assertNull($card->BDAY); + } + + public function testItSetsTheBirthday(): void + { + $member = Member::factory()->defaults()->create(['birthday' => '1993-05-06']); + + $card = $member->toVcard(); + + $this->assertEquals('19930506', $card->BDAY->getValue()); + } + + public function testItUnsetsMobilePhoneNumber(): void + { + $member = Member::factory()->defaults()->create(); + + $member->update(['mobile_phone' => '']); + + if (!is_null($member->toVcard()->TEL)) { + foreach ($member->toVcard()->TEL as $t) { + if ($t['TYPE'] && 'cell' === $t['TYPE']->getValue()) { + $this->assertFalse(true, 'Phone number found'); + continue; + } + } + } + + $this->assertTrue(true); + } +} diff --git a/tests/Lib/TestsDav.php b/tests/Lib/TestsDav.php new file mode 100644 index 00000000..00070985 --- /dev/null +++ b/tests/Lib/TestsDav.php @@ -0,0 +1,34 @@ +withBasicAuth($this->davUserEmail, $this->davUserPassword); + return $this->call('PROPFIND', '/dav/calendars/' . $this->davUserEmail, [], [], [], $this->transformHeadersToServerVars([])); + } + + + public function getDavCalendar(string $calendar): TestResponse + { + $this->withBasicAuth($this->davUserEmail, $this->davUserPassword); + return $this->call('PROPFIND', '/dav/calendars/' . $this->davUserEmail . '/' . $calendar, [], [], [], $this->transformHeadersToServerVars([])); + } + + public function createDavUser(): self + { + $this->me = User::factory()->create(['email' => $this->davUserEmail, 'password' => Hash::make($this->davUserPassword)]); + + return $this; + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index b6eab28a..8f9e3d93 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -15,6 +15,7 @@ use Illuminate\Testing\TestResponse; use Phake; use PHPUnit\Framework\Assert; use Tests\Lib\MakesHttpCalls; +use Tests\Lib\TestsDav; use Tests\Lib\TestsInertia; use Zoomyboy\LaravelNami\Authentication\Auth; @@ -23,6 +24,7 @@ abstract class TestCase extends BaseTestCase use CreatesApplication; use TestsInertia; use MakesHttpCalls; + use TestsDav; protected User $me;