Compare commits

...

1 Commits

Author SHA1 Message Date
philipp lang 8fcbec95b0 Add Caldav for member birthdays
continuous-integration/drone/push Build is failing Details
2024-05-15 01:36:36 +02:00
7 changed files with 527 additions and 92 deletions

339
app/Dav/CalendarBackend.php Normal file
View File

@ -0,0 +1,339 @@
<?php
namespace App\Dav;
use App\Member\Member;
use App\User;
use Sabre\CalDAV\Backend\AbstractBackend;
use Sabre\DAV\PropPatch;
use Sabre\VObject\Component\VCard;
/**
* @template M as array{lastmodified: int, etag: string, uri: string, id: int, size: int}
*/
class CalendarBackend extends AbstractBackend
{
/**
* Returns a list of calendars for a principal.
*
* Every project is an array with the following keys:
* * id, a unique id that will be used by other functions to modify the
* calendar. This can be the same as the uri or a database key.
* * uri, which is the basename of the uri with which the calendar is
* accessed.
* * principaluri. The owner of the calendar. Almost always the same as
* principalUri passed to this method.
*
* Furthermore it can contain webdav properties in clark notation. A very
* common one is '{DAV:}displayname'.
*
* Many clients also require:
* {urn:ietf:params:xml:ns:caldav}supported-calendar-component-set
* For this property, you can just return an instance of
* Sabre\CalDAV\Property\SupportedCalendarComponentSet.
*
* If you return {http://sabredav.org/ns}read-only and set the value to 1,
* ACL will automatically be put in read-only mode.
*
* @param string $principalUri
*
* @return array<int, array<string, mixed>>
*/
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<string, mixed> $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)
{
}
}

View File

@ -6,7 +6,9 @@ use Illuminate\Support\ServiceProvider as BaseServiceProvider;
use LaravelSabre\Http\Auth\AuthBackend; use LaravelSabre\Http\Auth\AuthBackend;
use LaravelSabre\LaravelSabre; use LaravelSabre\LaravelSabre;
use Sabre\CardDAV\AddressBookRoot; use Sabre\CardDAV\AddressBookRoot;
use Sabre\CalDAV\CalendarRoot;
use Sabre\CardDAV\Plugin as CardDAVPlugin; use Sabre\CardDAV\Plugin as CardDAVPlugin;
use Sabre\CalDAV\Plugin as CalDAVPlugin;
use Sabre\DAV\Auth\Plugin as AuthPlugin; use Sabre\DAV\Auth\Plugin as AuthPlugin;
use Sabre\DAV\Browser\Plugin as BrowserPlugin; use Sabre\DAV\Browser\Plugin as BrowserPlugin;
use Sabre\DAVACL\AbstractPrincipalCollection; use Sabre\DAVACL\AbstractPrincipalCollection;
@ -42,11 +44,12 @@ class ServiceProvider extends BaseServiceProvider
{ {
$principalBackend = new Principal(); $principalBackend = new Principal();
$addressBookBackend = new AddressBookBackend(); $addressBookBackend = new AddressBookBackend();
$calendarBackend = new CalendarBackend();
// Directory tree
return [ return [
new PrincipalCollection($principalBackend), new PrincipalCollection($principalBackend),
new AddressBookRoot($principalBackend, $addressBookBackend), new AddressBookRoot($principalBackend, $addressBookBackend),
new CalendarRoot($principalBackend, $calendarBackend),
]; ];
} }
@ -58,6 +61,7 @@ class ServiceProvider extends BaseServiceProvider
new BrowserPlugin(), new BrowserPlugin(),
new AuthPlugin($authBackend), new AuthPlugin($authBackend),
new CardDAVPlugin(), new CardDAVPlugin(),
new CalDAVPlugin(),
new AclPlugin(), new AclPlugin(),
]; ];
} }

View File

@ -23,8 +23,8 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Laravel\Scout\Attributes\SearchUsingFullText;
use Laravel\Scout\Searchable; use Laravel\Scout\Searchable;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Component\VCard; use Sabre\VObject\Component\VCard;
use Sabre\VObject\Reader; use Sabre\VObject\Reader;
use Spatie\LaravelData\Lazy; use Spatie\LaravelData\Lazy;
@ -437,6 +437,24 @@ class Member extends Model implements Geolocatable
return $card; 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 public function toSender(): Sender
{ {
return Sender::from([ return Sender::from([

View File

@ -2,11 +2,8 @@
namespace Tests\Feature\Member; namespace Tests\Feature\Member;
use App\Group;
use App\Member\Member; use App\Member\Member;
use App\Nationality; use Illuminate\Foundation\Testing\DatabaseMigrations;
use App\Payment\Subscription;
use App\Setting\NamiSettings;
use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase; use Tests\TestCase;
@ -14,101 +11,28 @@ class DavTest extends TestCase
{ {
use DatabaseTransactions; use DatabaseTransactions;
public function testItCanStoreAMemberFromAVcard(): void public function testItDisplaysCalendar(): void
{ {
Nationality::factory()->create(['name' => 'englisch']); $this->createDavUser()->withoutExceptionHandling();
$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 = <<<VCARD
BEGIN:VCARD
VERSION:3.0
PRODID:-//Thunderbird.net/NONSGML Thunderbird CardBook V77.0//EN-US
UID:97266d2e-36e7-4fb6-8b6c-bbf57a061685
CATEGORIES:Scoutrobot
FN:given familya Silva
N:familya;given;;;
BDAY:20221003
ORG:Silva
EMAIL:mail@maild.ee
ITEM1.TEL:+49 176 70342420
ITEM1.X-ABLABEL:eltern
ADR:;;Itterstr 3;Solingen;NRW;42719;Germany
REV:2022-10-07T14:17:06Z
END:VCARD
VCARD; $this->getDavCalendars()->assertSee('Geburtstage');
$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,
]);
} }
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 = Member::factory()->defaults()->create();
$member->update(['mobile_phone' => '']); $this->getDavCalendar('birthdays')->assertSee($member->slug . '.ics');
}
if (!is_null($member->toVcard()->TEL)) { public function testItGetsObjectsWhenBirthdayIsNull(): void
foreach ($member->toVcard()->TEL as $t) { {
if ($t['TYPE'] && 'cell' === $t['TYPE']->getValue()) { $this->createDavUser()->withoutExceptionHandling();
$this->assertFalse(true, 'Phone number found');
continue;
}
}
}
$this->assertTrue(true); $member = Member::factory()->defaults()->create(['birthday' => null]);
$this->getDavCalendar('birthdays')->assertStatus(207);
} }
} }

View File

@ -0,0 +1,114 @@
<?php
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\DatabaseTransactions;
use Tests\TestCase;
class VcardTest extends TestCase
{
use DatabaseTransactions;
public function testItCanStoreAMemberFromAVcard(): 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 = <<<VCARD
BEGIN:VCARD
VERSION:3.0
PRODID:-//Thunderbird.net/NONSGML Thunderbird CardBook V77.0//EN-US
UID:97266d2e-36e7-4fb6-8b6c-bbf57a061685
CATEGORIES:Scoutrobot
FN:given familya Silva
N:familya;given;;;
BDAY:20221003
ORG:Silva
EMAIL:mail@maild.ee
ITEM1.TEL:+49 176 70342420
ITEM1.X-ABLABEL:eltern
ADR:;;Itterstr 3;Solingen;NRW;42719;Germany
REV:2022-10-07T14:17:06Z
END:VCARD
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,
]);
}
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);
}
}

34
tests/Lib/TestsDav.php Normal file
View File

@ -0,0 +1,34 @@
<?php
namespace Tests\Lib;
use App\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Testing\TestResponse;
trait TestsDav
{
public $davUserEmail = 'user@example.com';
public $davUserPassword = 'secret';
public function getDavCalendars(): TestResponse
{
$this->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;
}
}

View File

@ -15,6 +15,7 @@ use Illuminate\Testing\TestResponse;
use Phake; use Phake;
use PHPUnit\Framework\Assert; use PHPUnit\Framework\Assert;
use Tests\Lib\MakesHttpCalls; use Tests\Lib\MakesHttpCalls;
use Tests\Lib\TestsDav;
use Tests\Lib\TestsInertia; use Tests\Lib\TestsInertia;
use Zoomyboy\LaravelNami\Authentication\Auth; use Zoomyboy\LaravelNami\Authentication\Auth;
@ -23,6 +24,7 @@ abstract class TestCase extends BaseTestCase
use CreatesApplication; use CreatesApplication;
use TestsInertia; use TestsInertia;
use MakesHttpCalls; use MakesHttpCalls;
use TestsDav;
protected User $me; protected User $me;