feat: support CalDAV for birthdays and tasks (#2304)

This commit is contained in:
Alexis Saettler
2019-01-23 14:39:44 +01:00
committed by GitHub
parent 9b8d5a9c1c
commit ba236dd718
50 changed files with 3524 additions and 881 deletions
@@ -0,0 +1,11 @@
<?php
namespace App\Http\Controllers\DAV\Backend\CalDAV;
use App\Http\Controllers\DAV\Backend\IDAVBackend;
use App\Http\Controllers\DAV\Backend\SyncDAVBackend;
abstract class AbstractCalDAVBackend implements ICalDAVBackend, IDAVBackend
{
use SyncDAVBackend;
}
@@ -0,0 +1,305 @@
<?php
namespace App\Http\Controllers\DAV\Backend\CalDAV;
use Sabre\DAV;
use App\Models\User\SyncToken;
use Sabre\CalDAV\Backend\SyncSupport;
use Sabre\CalDAV\Backend\AbstractBackend;
class CalDAVBackend extends AbstractBackend implements SyncSupport
{
/**
* Set the Calendar backends.
*
* @return array
*/
private function getBackends(): array
{
return [
new CalDAVBirthdays(),
new CalDAVTasks(),
];
}
/**
* Get the backend for this id.
*
* @return AbstractCalDAVBackend
*/
private function getBackend($id): AbstractCalDAVBackend
{
return collect($this->getBackends())->first(function ($backend) use ($id) {
return $backend->backendUri() === $id;
});
}
/**
* 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
*/
public function getCalendarsForUser($principalUri)
{
return array_map(function ($backend) {
return $backend->getDescription()
+ [
'id' => $backend->backendUri(),
'uri' => $backend->backendUri(),
];
}, $this->getBackends());
}
/**
* The getChanges method returns all the changes that have happened, since
* the specified syncToken in the specified calendar.
*
* 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 $calendarId
* @param string $syncToken
* @param int $syncLevel
* @param int $limit
* @return array
*/
public function getChangesForCalendar($calendarId, $syncToken, $syncLevel, $limit = null)
{
$backend = $this->getBackend($calendarId);
if ($backend) {
return $backend->getChanges($syncToken);
}
}
/**
* 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
*/
public function getCalendarObjects($calendarId)
{
$backend = $this->getBackend($calendarId);
if ($backend) {
$objs = $backend->getObjects();
return $objs
->map(function ($date) use ($backend) {
return $backend->prepareData($date);
})
->filter(function ($event) {
return ! is_null($event);
})
->toArray();
}
}
/**
* 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)
{
$backend = $this->getBackend($calendarId);
if ($backend) {
$obj = $backend->getObject($objectUri);
if ($obj) {
return $backend->prepareData($obj);
}
}
}
/**
* 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)
{
return $this->updateCalendarObject($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)
{
$backend = $this->getBackend($calendarId);
if ($backend) {
return $backend->updateOrCreateCalendarObject($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
* @return void
*/
public function deleteCalendarObject($calendarId, $objectUri)
{
$backend = $this->getBackend($calendarId);
if ($backend) {
return $backend->deleteCalendarObject($objectUri);
}
}
/**
* 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)
{
}
/**
* Delete a calendar and all its objects.
*
* @param mixed $calendarId
* @return void
*/
public function deleteCalendar($calendarId)
{
}
}
@@ -0,0 +1,148 @@
<?php
namespace App\Http\Controllers\DAV\Backend\CalDAV;
use Sabre\DAV;
use App\Models\Contact\Contact;
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;
use Sabre\DAV\Sync\Plugin as DAVSyncPlugin;
use App\Http\Controllers\DAV\DAVACL\PrincipalBackend;
class CalDAVBirthdays extends AbstractCalDAVBackend
{
/**
* Returns the uri for this backend.
*
* @return string
*/
public function backendUri()
{
return 'birthdays';
}
public function getDescription()
{
$name = Auth::user()->name;
$token = $this->getCurrentSyncToken();
$des = [
'principaluri' => PrincipalBackend::getPrincipalUser(),
'{DAV:}displayname' => $name,
'{'.SabreServer::NS_SABREDAV.'}read-only' => 1,
'{'.CalDAVPlugin::NS_CALDAV.'}calendar-description' => 'Birthdays',
'{'.CalDAVPlugin::NS_CALDAV.'}calendar-timezone' => Auth::user()->timezone,
];
if ($token) {
$token = DAVSyncPlugin::SYNCTOKEN_PREFIX.$token->id;
$des += [
'{DAV:}sync-token' => $token,
'{'.SabreServer::NS_SABREDAV.'}sync-token' => $token,
'{'.CalDAVPlugin::NS_CALENDARSERVER.'}getctag' => $token,
];
}
return $des;
}
/**
* Extension for Calendar objects.
*
* @var string
*/
public function getExtension()
{
return '.ics';
}
/**
* Datas for this date.
*
* @param mixed $date
* @return array
*/
public function prepareData($date)
{
if ($date instanceof SpecialDate) {
try {
$vcal = (new ExportVCalendar())
->execute([
'account_id' => Auth::user()->account_id,
'special_date_id' => $date->id,
]);
$calendardata = $vcal->serialize();
return [
'id' => $date->id,
'uri' => $this->encodeUri($date),
'calendardata' => $calendardata,
'etag' => '"'.md5($calendardata).'"',
'lastmodified' => $date->updated_at->timestamp,
];
} catch (\Exception $e) {
Log::debug(__CLASS__.' prepareData: '.(string) $e);
}
}
}
private function hasBirthday($contact)
{
if (! $contact || ! $contact->birthdate) {
return false;
}
$birthdayState = $contact->getBirthdayState();
if ($birthdayState != 'almost' && $birthdayState != 'exact') {
return false;
}
return true;
}
/**
* Returns the date for the specific uri.
*
* @param string $uri
* @return mixed
*/
public function getObjectUuid($uuid)
{
return SpecialDate::where([
'account_id' => Auth::user()->account_id,
'uuid' => $uuid,
])->first();
}
/**
* Returns the collection of contact's birthdays.
*
* @return \Illuminate\Support\Collection
*/
public function getObjects()
{
$contacts = Auth::user()->account
->contacts()
->real()
->active()
->get();
return $contacts->filter(function ($contact) {
return $this->hasBirthday($contact);
})
->map(function ($contact) {
return $contact->birthdate;
});
}
public function updateOrCreateCalendarObject($objectUri, $calendarData)
{
}
public function deleteCalendarObject($objectUri)
{
}
}
@@ -0,0 +1,192 @@
<?php
namespace App\Http\Controllers\DAV\Backend\CalDAV;
use Sabre\DAV;
use App\Models\Contact\Task;
use App\Services\Task\DestroyTask;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Auth;
use Sabre\DAV\Server as SabreServer;
use App\Services\VCalendar\ExportTask;
use App\Services\VCalendar\ImportTask;
use Sabre\CalDAV\Plugin as CalDAVPlugin;
use Sabre\DAV\Sync\Plugin as DAVSyncPlugin;
use App\Http\Controllers\DAV\DAVACL\PrincipalBackend;
class CalDAVTasks extends AbstractCalDAVBackend
{
/**
* Returns the uri for this backend.
*
* @return string
*/
public function backendUri()
{
return 'tasks';
}
public function getDescription()
{
$name = Auth::user()->name;
$token = $this->getCurrentSyncToken();
$des = [
'principaluri' => PrincipalBackend::getPrincipalUser(),
'{DAV:}displayname' => $name,
'{'.CalDAVPlugin::NS_CALDAV.'}calendar-description' => 'Tasks',
'{'.CalDAVPlugin::NS_CALDAV.'}calendar-timezone' => Auth::user()->timezone,
];
if ($token) {
$token = DAVSyncPlugin::SYNCTOKEN_PREFIX.$token->id;
$des += [
'{DAV:}sync-token' => $token,
'{'.SabreServer::NS_SABREDAV.'}sync-token' => $token,
'{'.CalDAVPlugin::NS_CALENDARSERVER.'}getctag' => $token,
];
}
return $des;
}
/**
* Returns the collection of all tasks.
*
* @return \Illuminate\Support\Collection
*/
public function getObjects()
{
return Auth::user()->account
->tasks()
->get();
}
/**
* Returns the contact for the specific uri.
*
* @param string $uri
* @return mixed
*/
public function getObjectUuid($uuid)
{
return Task::where([
'account_id' => Auth::user()->account_id,
'uuid' => $uuid,
])->first();
}
/**
* Extension for Calendar objects.
*
* @var string
*/
public function getExtension()
{
return '.ics';
}
/**
* Datas for this task.
*
* @param mixed $task
* @return array
*/
public function prepareData($task)
{
if ($task instanceof Task) {
try {
$vcal = (new ExportTask())
->execute([
'account_id' => Auth::user()->account_id,
'task_id' => $task->id,
]);
$calendardata = $vcal->serialize();
return [
'id' => $task->id,
'uri' => $this->encodeUri($task),
'calendardata' => $calendardata,
'etag' => '"'.md5($calendardata).'"',
'lastmodified' => $task->updated_at->timestamp,
];
} catch (\Exception $e) {
Log::debug(__CLASS__.' prepareData: '.(string) $e);
}
}
}
/**
* 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 string $objectUri
* @param string $calendarData
* @return string|null
*/
public function updateOrCreateCalendarObject($objectUri, $calendarData)
{
$task_id = null;
if ($objectUri) {
$task = $this->getObject($objectUri);
if ($task) {
$task_id = $task->id;
}
}
try {
$result = (new ImportTask())
->execute([
'account_id' => Auth::user()->account_id,
'task_id' => $task_id,
'entry' => $calendarData,
]);
} catch (\Exception $e) {
Log::debug(__CLASS__.' updateOrCreateCalendarObject: '.(string) $e);
}
if (! array_has($result, 'error')) {
$task = Task::where('account_id', Auth::user()->account_id)
->find($result['task_id']);
$calendar = $this->prepareData($task);
return $calendar['etag'];
}
}
/**
* Deletes an existing calendar object.
*
* The object uri is only the basename, or filename and not a full path.
*
* @param string $objectUri
* @return void
*/
public function deleteCalendarObject($objectUri)
{
$task = $this->getObject($objectUri);
if ($task) {
try {
(new DestroyTask)
->execute([
'account_id' => Auth::user()->account_id,
'task_id' => $task->id,
]);
} catch (\Exception $e) {
Log::debug(__CLASS__.' deleteCalendarObject: '.(string) $e);
}
}
}
}
@@ -0,0 +1,98 @@
<?php
namespace App\Http\Controllers\DAV\Backend\CalDAV;
interface ICalDAVBackend
{
/**
* 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
*/
public function getDescription();
/**
* The getChanges method returns all the changes that have happened, since
* the specified syncToken in the specified calendar.
*
* @param string $syncToken
* @return array
*/
public function getChanges($syncToken);
/**
* Returns calendar object.
*
* It returns 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.
*
* @param mixed $obj
* @return array
*/
public function prepareData($obj);
/**
* 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 updateOrCreateCalendarObject($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
* @return void
*/
public function deleteCalendarObject($objectUri);
}
@@ -0,0 +1,70 @@
<?php
namespace App\Http\Controllers\DAV\Backend\CardDAV;
use Sabre\CardDAV\AddressBook as BaseAddressBook;
class AddressBook extends BaseAddressBook
{
/**
* Returns a list of ACE's for this node.
*
* Each ACE has the following properties:
* * 'privilege', a string such as {DAV:}read or {DAV:}write. These are
* currently the only supported privileges
* * 'principal', a url to the principal who owns the node
* * 'protected' (optional), indicating that this ACE is not allowed to
* be updated.
*
* @return array
*/
public function getACL()
{
return [
[
'privilege' => '{DAV:}read',
'principal' => '{DAV:}owner',
'protected' => true,
],
[
'privilege' => '{DAV:}write-content',
'principal' => '{DAV:}owner',
'protected' => true,
],
[
'privilege' => '{DAV:}bind',
'principal' => '{DAV:}owner',
'protected' => true,
],
[
'privilege' => '{DAV:}unbind',
'principal' => '{DAV:}owner',
'protected' => true,
],
];
}
/**
* This method returns the ACL's for card nodes in this address book.
* The result of this method automatically gets passed to the
* card nodes in this address book.
*
* @return array
*/
public function getChildACL()
{
return $this->getACL();
}
/**
* Returns the last modification date.
*
* @return \Carbon\Carbon
*/
public function getLastModified()
{
if ($this->carddavBackend instanceof CardDAVBackend) {
return $this->carddavBackend->getLastModified();
}
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\DAV\Backend\CardDAV;
use Sabre\CardDAV\AddressBookHome as BaseAddressBookHome;
class AddressBookHome extends BaseAddressBookHome
{
/**
* Returns a list of ACE's for this node.
*
* Each ACE has the following properties:
* * 'privilege', a string such as {DAV:}read or {DAV:}write. These are
* currently the only supported privileges
* * 'principal', a url to the principal who owns the node
* * 'protected' (optional), indicating that this ACE is not allowed to
* be updated.
*
* @return array
*/
public function getACL()
{
return [
[
'privilege' => '{DAV:}read',
'principal' => '{DAV:}owner',
'protected' => true,
],
];
}
/**
* Returns a list of addressbooks.
*
* @return array
*/
public function getChildren()
{
$addressbooks = $this->carddavBackend->getAddressBooksForUser($this->principalUri);
return collect($addressbooks)->map(function ($addressbook) {
return new AddressBook($this->carddavBackend, $addressbook);
});
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers\DAV\Backend\CardDAV;
use Sabre\DAVACL\IACL;
use Sabre\DAVACL\ACLTrait;
use Sabre\CardDAV\AddressBookRoot as BaseAddressBookRoot;
class AddressBookRoot extends BaseAddressBookRoot implements IACL
{
use ACLTrait;
/**
* Returns a list of ACE's for this node.
*
* Each ACE has the following properties:
* * 'privilege', a string such as {DAV:}read or {DAV:}write. These are
* currently the only supported privileges
* * 'principal', a url to the principal who owns the node
* * 'protected' (optional), indicating that this ACE is not allowed to
* be updated.
*
* @return array
*/
public function getACL()
{
return [
[
'privilege' => '{DAV:}read',
'principal' => '{DAV:}authenticated',
'protected' => true,
],
];
}
/**
* This method returns a node for a principal.
*
* The passed array contains principal information, and is guaranteed to
* at least contain a uri item. Other properties may or may not be
* supplied by the authentication backend.
*
* @param array $principal
* @return \Sabre\DAV\INode
*/
public function getChildForPrincipal(array $principal)
{
return new AddressBookHome($this->carddavBackend, $principal['uri']);
}
}
@@ -0,0 +1,403 @@
<?php
namespace App\Http\Controllers\DAV\Backend\CardDAV;
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\DAV\Server as SabreServer;
use Sabre\CardDAV\Backend\SyncSupport;
use Sabre\CalDAV\Plugin as CalDAVPlugin;
use Sabre\CardDAV\Backend\AbstractBackend;
use Sabre\CardDAV\Plugin as CardDAVPlugin;
use Sabre\DAV\Sync\Plugin as DAVSyncPlugin;
use App\Http\Controllers\DAV\Backend\IDAVBackend;
use App\Http\Controllers\DAV\Backend\SyncDAVBackend;
use App\Http\Controllers\DAV\DAVACL\PrincipalBackend;
class CardDAVBackend extends AbstractBackend implements SyncSupport, IDAVBackend
{
use SyncDAVBackend;
/**
* Returns the uri for this backend.
*
* @return string
*/
public function backendUri()
{
return 'contacts';
}
/**
* Returns the list of addressbooks for a specific user.
*
* Every addressbook should have the following properties:
* id - an arbitrary unique id
* uri - the 'basename' part of the url
* principaluri - Same as the passed parameter
*
* Any additional clark-notation property may be passed besides this. Some
* common ones are :
* {DAV:}displayname
* {urn:ietf:params:xml:ns:carddav}addressbook-description
* {http://calendarserver.org/ns/}getctag
*
* @param string $principalUri
* @return array
*/
public function getAddressBooksForUser($principalUri)
{
$name = Auth::user()->name;
$token = $this->getCurrentSyncToken();
$des = [
'id' => $this->backendUri(),
'uri' => $this->backendUri(),
'principaluri' => PrincipalBackend::getPrincipalUser(),
'{DAV:}displayname' => $name,
'{'.CardDAVPlugin::NS_CARDDAV.'}addressbook-description' => $name,
];
if ($token) {
$des += [
'{DAV:}sync-token' => $token->id,
'{'.SabreServer::NS_SABREDAV.'}sync-token' => $token->id,
'{'.CalDAVPlugin::NS_CALENDARSERVER.'}getctag' => DAVSyncPlugin::SYNCTOKEN_PREFIX.$token->id,
];
}
return [
$des,
];
}
/**
* Extension for Calendar objects.
*
* @var string
*/
public function getExtension()
{
return '.vcf';
}
/**
* 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)
{
return $this->getChanges($syncToken);
}
/**
* Prepare datas for this contact.
*
* @param Contact $contact
* @return array|null
*/
private function prepareCard($contact)
{
try {
$vcard = (new ExportVCard())
->execute([
'account_id' => Auth::user()->account_id,
'contact_id' => $contact->id,
]);
$carddata = $vcard->serialize();
return [
'id' => $contact->hashID(),
'uri' => $this->encodeUri($contact),
'carddata' => $carddata,
'etag' => '"'.md5($carddata).'"',
'lastmodified' => $contact->updated_at->timestamp,
];
} catch (\Exception $e) {
Log::debug(__CLASS__.' prepareCard: '.(string) $e);
}
}
/**
* Returns the contact for the specific uri.
*
* @param string $uri
* @return Contact
*/
public function getObjectUuid($uuid)
{
return Contact::where([
'account_id' => Auth::user()->account_id,
'uuid' => $uuid,
])->first();
}
/**
* Returns the collection of all active contacts.
*
* @return \Illuminate\Support\Collection
*/
public function getObjects()
{
return Auth::user()->account
->contacts()
->real()
->active()
->get();
}
/**
* Returns all cards for a specific addressbook id.
*
* This method should return the following properties for each card:
* * carddata - raw vcard data
* * uri - Some unique url
* * lastmodified - A unix timestamp
*
* It's recommended to also return the following properties:
* * etag - A unique etag. This must change every time the card changes.
* * size - The size of the card in bytes.
*
* If these last two properties are provided, less time will be spent
* calculating them. If they are specified, you can also ommit carddata.
* This may speed up certain requests, especially with large cards.
*
* @param mixed $addressbookId
* @return array
*/
public function getCards($addressbookId)
{
$contacts = $this->getObjects();
return $contacts->map(function ($contact) {
return $this->prepareCard($contact);
});
}
/**
* Returns a specific card.
*
* The same set of properties must be returned as with getCards. The only
* exception is that 'carddata' is absolutely required.
*
* If the card does not exist, you must return false.
*
* @param mixed $addressBookId
* @param string $cardUri
* @return array|bool
*/
public function getCard($addressBookId, $cardUri)
{
$contact = $this->getObject($cardUri);
if ($contact) {
return $this->prepareCard($contact);
}
return false;
}
/**
* Creates a new card.
*
* The addressbook id will be passed as the first argument. This is the
* same id as it is returned from the getAddressBooksForUser method.
*
* The cardUri is a base uri, and doesn't include the full path. The
* cardData argument is the vcard body, and is passed as a string.
*
* It is possible to return an ETag from this method. This ETag is for the
* newly created resource, and must be enclosed with double quotes (that
* is, the string itself must contain the double quotes).
*
* You should only return the ETag if you store the carddata as-is. If a
* subsequent GET request on the same card does not have the same body,
* byte-by-byte and you did return an ETag here, clients tend to get
* confused.
*
* If you don't return an ETag, you can just return null.
*
* @param mixed $addressBookId
* @param string $cardUri
* @param string $cardData
* @return string|null
*/
public function createCard($addressBookId, $cardUri, $cardData)
{
return $this->updateCard($addressBookId, $cardUri, $cardData);
}
/**
* Updates a card.
*
* The addressbook id will be passed as the first argument. This is the
* same id as it is returned from the getAddressBooksForUser method.
*
* The cardUri is a base uri, and doesn't include the full path. The
* cardData argument is the vcard body, and is passed as a string.
*
* It is possible to return an ETag from this method. This ETag should
* match that of the updated resource, and must be enclosed with double
* quotes (that is: the string itself must contain the actual quotes).
*
* You should only return the ETag if you store the carddata as-is. If a
* subsequent GET request on the same card does not have the same body,
* byte-by-byte and you did return an ETag here, clients tend to get
* confused.
*
* If you don't return an ETag, you can just return null.
*
* @param mixed $addressBookId
* @param string $cardUri
* @param string $cardData
* @return string|null
*/
public function updateCard($addressBookId, $cardUri, $cardData)
{
$contact_id = null;
if ($cardUri) {
$contact = $this->getObject($cardUri);
if ($contact) {
$contact_id = $contact->id;
}
}
try {
$result = (new ImportVCard(Auth::user()->account_id))
->execute([
'contact_id' => $contact_id,
'entry' => $cardData,
'behaviour' => ImportVCard::BEHAVIOUR_REPLACE,
]);
} catch (\Exception $e) {
Log::debug(__CLASS__.' updateCard: '.(string) $e);
}
if (! array_has($result, 'error')) {
$contact = Contact::where('account_id', Auth::user()->account_id)
->find($result['contact_id']);
$card = $this->prepareCard($contact);
return $card['etag'];
}
}
/**
* Deletes a card.
*
* @param mixed $addressBookId
* @param string $cardUri
* @return bool
*/
public function deleteCard($addressBookId, $cardUri)
{
return false;
}
/**
* Updates properties for an address book.
*
* 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 string $addressBookId
* @param \Sabre\DAV\PropPatch $propPatch
* @return void|bool
*/
public function updateAddressBook($addressBookId, DAV\PropPatch $propPatch)
{
return false;
}
/**
* Creates a new address book.
*
* This method should return the id of the new address book. The id can be
* in any format, including ints, strings, arrays or objects.
*
* @param string $principalUri
* @param string $url Just the 'basename' of the url.
* @param array $properties
* @return int|bool
*/
public function createAddressBook($principalUri, $url, array $properties)
{
return false;
}
/**
* Deletes an entire addressbook and all its contents.
*
* @param mixed $addressBookId
* @return void|bool
*/
public function deleteAddressBook($addressBookId)
{
return false;
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Http\Controllers\DAV\Backend;
interface IDAVBackend
{
/**
* Returns the uri for this backend.
*
* @return string
*/
public function backendUri();
/**
* Returns the object for the specific uuid.
*
* @param string $uri
* @return mixed
*/
public function getObjectUuid($uuid);
/**
* Returns the collection of objects.
*
* @return \Illuminate\Support\Collection
*/
public function getObjects();
/**
* Returns the extension for this backend.
*
* @return string
*/
public function getExtension();
}
@@ -0,0 +1,251 @@
<?php
namespace App\Http\Controllers\DAV\Backend;
use Illuminate\Support\Str;
use App\Models\User\SyncToken;
use App\Models\Contact\Contact;
use Illuminate\Support\Facades\Auth;
trait SyncDAVBackend
{
/**
* 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
*/
protected function getCurrentSyncToken()
{
$tokens = SyncToken::where([
'account_id' => Auth::user()->account_id,
'user_id' => Auth::user()->id,
'name' => $this->backendUri(),
])
->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;
}
/**
* Get SyncToken by token id.
*
* @return SyncToken
*/
protected function getSyncToken($syncToken)
{
return SyncToken::where([
'account_id' => Auth::user()->account_id,
'user_id' => Auth::user()->id,
'name' => $this->backendUri(),
])
->find($syncToken);
}
/**
* Create a token.
*
* @return SyncToken|null
*/
private function createSyncToken()
{
$max = $this->getLastModified();
if ($max) {
return SyncToken::create([
'account_id' => Auth::user()->account_id,
'user_id' => Auth::user()->id,
'name' => $this->backendUri(),
'timestamp' => $max,
]);
}
}
/**
* Create a token with now timestamp.
*
* @return SyncToken
*/
private function createSyncTokenNow()
{
return SyncToken::create([
'account_id' => Auth::user()->account_id,
'user_id' => Auth::user()->id,
'name' => $this->backendUri(),
'timestamp' => now(),
]);
}
/**
* Returns the last modification date.
*
* @return \Carbon\Carbon
*/
public function getLastModified()
{
return $this->getObjects()
->max('updated_at');
}
/**
* The getChanges method returns all the changes that have happened, since
* the specified syncToken.
*
* 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
* , 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 $syncToken
* @return array
*/
public function getChanges($syncToken)
{
$token = null;
$timestamp = null;
if (! empty($syncToken)) {
$token = $this->getSyncToken($syncToken);
if (is_null($token)) {
// syncToken is not recognized
return;
}
$timestamp = $token->timestamp;
} else {
$token = $this->createSyncTokenNow();
$timestamp = null;
}
$objs = $this->getObjects();
$modified = $objs->filter(function ($obj) use ($timestamp) {
return ! is_null($timestamp) &&
$obj->updated_at > $timestamp &&
$obj->created_at < $timestamp;
});
$added = $objs->filter(function ($obj) use ($timestamp) {
return is_null($timestamp) ||
$obj->created_at >= $timestamp;
});
return [
'syncToken' => $token->id,
'added' => $added->map(function ($obj) {
return $this->encodeUri($obj);
})->toArray(),
'modified' => $modified->map(function ($obj) {
return $this->encodeUri($obj);
})->toArray(),
'deleted' => [],
];
}
protected function encodeUri($obj)
{
if (empty($obj->uuid)) {
// refresh model from database
$obj->refresh();
if (empty($obj->uuid)) {
// in case uuid is still not set, do it
$obj->forceFill([
'uuid' => Str::uuid(),
])->save();
}
}
return urlencode($obj->uuid.$this->getExtension());
}
private function decodeUri($uri)
{
return pathinfo(urldecode($uri), PATHINFO_FILENAME);
}
/**
* Returns the contact for the specific uri.
*
* @param string $uri
* @return mixed
*/
public function getObject($uri)
{
try {
return $this->getObjectUuid($this->decodeUri($uri));
} catch (\Exception $e) {
// Object not found
}
}
/**
* Returns the object for the specific uuid.
*
* @param string $uri
* @return mixed
*/
abstract public function getObjectUuid($uuid);
/**
* Returns the collection of objects.
*
* @return \Illuminate\Support\Collection
*/
abstract public function getObjects();
abstract public function getExtension();
}