generated from Nodarx/template
326 lines
13 KiB
PHP
326 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace KTXM\ServiceDav\Backends;
|
|
|
|
use KTXC\SessionIdentity;
|
|
use KTXC\SessionTenant;
|
|
use KTXM\ChronoManager\Manager as ChronoManager;
|
|
use KTXM\ServiceDav\DavProperties;
|
|
use KTXM\ServiceDav\DavUriMap;
|
|
use KTXF\Resource\Identifier\CollectionIdentifier;
|
|
use KTXF\Resource\Identifier\EntityIdentifier;
|
|
use KTXF\Resource\Identifier\ResourceIdentifiers;
|
|
use Sabre\CalDAV\Backend\AbstractBackend;
|
|
use Sabre\CalDAV\Xml\Property\SupportedCalendarComponentSet;
|
|
use Sabre\VObject\Component\VCalendar;
|
|
use Sabre\VObject\Reader;
|
|
|
|
/**
|
|
* CalDAV backend bridging sabre/dav to the KTXM ChronoManager.
|
|
*
|
|
* Calendar <=> Collection (within a provider service)
|
|
* Calendar object <=> Entity (VEVENT / VTODO)
|
|
*
|
|
* Calendar identifiers use the composite key "{providerId}:{serviceId}:{collectionId}".
|
|
* Object URIs are client-chosen names (e.g. "event-uid.ics") mapped to provider
|
|
* entity identifiers via DavUriMap.
|
|
*/
|
|
class CalDavBackend extends AbstractBackend
|
|
{
|
|
private const ID_SEP = ':';
|
|
|
|
public function __construct(
|
|
private readonly ChronoManager $manager,
|
|
private readonly SessionIdentity $identity,
|
|
private readonly SessionTenant $tenant,
|
|
private readonly DavUriMap $uriMap,
|
|
) {}
|
|
|
|
public function getCalendarsForUser($principalUri): array
|
|
{
|
|
$result = [];
|
|
|
|
$allCollections = $this->manager->collectionList($this->tenant->identifier(), $this->identity->identifier());
|
|
|
|
foreach ($allCollections as $providerId => $providerServices) {
|
|
foreach ($providerServices as $serviceId => $collections) {
|
|
foreach ($collections as $collectionId => $collection) {
|
|
if (!is_object($collection)) {
|
|
continue;
|
|
}
|
|
$props = $collection->getProperties();
|
|
$label = $props->getLabel();
|
|
$calId = implode(self::ID_SEP, [(string) $providerId, (string) $serviceId, (string) $collectionId]);
|
|
|
|
$result[] = [
|
|
DavProperties::ID => $calId,
|
|
DavProperties::URI => $this->slugify($label),
|
|
DavProperties::DISPLAYNAME => $label,
|
|
DavProperties::CALDAV_DESCRIPTION => $props->getDescription() ?? '',
|
|
DavProperties::APPLE_COLOR => $props->getColor() ?? '#3a87ad',
|
|
DavProperties::CALDAV_COMPONENTS => new SupportedCalendarComponentSet(['VEVENT', 'VTODO']),
|
|
DavProperties::CS_CTAG => md5($calId . (string) ($collection->modified()?->getTimestamp() ?? 0)),
|
|
DavProperties::SYNC_TOKEN => '1',
|
|
DavProperties::PRINCIPAL_URI => $principalUri,
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
public function createCalendar($principalUri, $calendarUri, array $properties): string
|
|
{
|
|
throw new \Sabre\DAV\Exception\Forbidden('Calendar creation is not supported via DAV');
|
|
}
|
|
|
|
public function deleteCalendar($calendarId): void
|
|
{
|
|
throw new \Sabre\DAV\Exception\Forbidden('Calendar deletion is not supported via DAV');
|
|
}
|
|
|
|
public function getCalendarObjects($calendarId): array
|
|
{
|
|
[$providerId, $serviceId, $collectionId] = $this->parseId($calendarId);
|
|
$tenantId = $this->tenant->identifier();
|
|
$userId = $this->identity->identifier();
|
|
|
|
$targets = ResourceIdentifiers::fromArray([$calendarId]);
|
|
$all = $this->manager->entityListBulk($tenantId, $userId, $targets);
|
|
$entities = $all[(string) $providerId][(string) $serviceId][(string) $collectionId] ?? [];
|
|
|
|
$mapId = $this->mapId($tenantId, $userId, $calendarId);
|
|
$objects = [];
|
|
foreach ($entities as $entity) {
|
|
if (!is_object($entity)) {
|
|
continue;
|
|
}
|
|
$entityId = (string) $entity->identifier();
|
|
$objectUri = $this->uriMap->getObjectUri($mapId, $entityId) ?? ($entityId . '.ics');
|
|
$data = $this->entityToIcal($entity);
|
|
|
|
$objects[] = [
|
|
DavProperties::ID => $entityId,
|
|
DavProperties::URI => $objectUri,
|
|
DavProperties::LAST_MODIFIED => $entity->modified()?->getTimestamp() ?? time(),
|
|
DavProperties::ETAG => '"' . md5($data) . '"',
|
|
DavProperties::SIZE => strlen($data),
|
|
DavProperties::COMPONENT => 'VEVENT',
|
|
DavProperties::CALENDAR_DATA => null, // lazy - fetched on demand
|
|
];
|
|
}
|
|
|
|
return $objects;
|
|
}
|
|
|
|
public function getCalendarObject($calendarId, $objectUri): ?array
|
|
{
|
|
[$providerId, $serviceId, $collectionId] = $this->parseId($calendarId);
|
|
$tenantId = $this->tenant->identifier();
|
|
$userId = $this->identity->identifier();
|
|
$mapId = $this->mapId($tenantId, $userId, $calendarId);
|
|
|
|
// Resolve URI => entityId via the map; fall back to treating stripped URI as entityId
|
|
$entityId = $this->uriMap->getEntityId($mapId, $objectUri)
|
|
?? preg_replace('/\.ics$/i', '', $objectUri);
|
|
|
|
$identifier = new EntityIdentifier($providerId, $serviceId, $collectionId, $entityId);
|
|
$fetched = $this->manager->entityFetchBulk($tenantId, $userId, $identifier);
|
|
$entity = reset($fetched) ?: null;
|
|
|
|
if ($entity === null) {
|
|
return null;
|
|
}
|
|
|
|
$data = $this->entityToIcal($entity);
|
|
|
|
return [
|
|
DavProperties::ID => $entityId,
|
|
DavProperties::URI => $objectUri,
|
|
DavProperties::LAST_MODIFIED => $entity->modified()?->getTimestamp() ?? time(),
|
|
DavProperties::ETAG => '"' . md5($data) . '"',
|
|
DavProperties::SIZE => strlen($data),
|
|
DavProperties::COMPONENT => 'VEVENT',
|
|
DavProperties::CALENDAR_DATA => $data,
|
|
];
|
|
}
|
|
|
|
public function getMultipleCalendarObjects($calendarId, array $uris): array
|
|
{
|
|
return array_values(array_filter(array_map(
|
|
fn($uri) => $this->getCalendarObject($calendarId, $uri),
|
|
$uris,
|
|
)));
|
|
}
|
|
|
|
public function createCalendarObject($calendarId, $objectUri, $calendarData): ?string
|
|
{
|
|
[$providerId, $serviceId, $collectionId] = $this->parseId($calendarId);
|
|
$tenantId = $this->tenant->identifier();
|
|
$userId = $this->identity->identifier();
|
|
|
|
$vcal = Reader::read($calendarData);
|
|
$props = $this->icalToEntityProps($vcal);
|
|
$target = new CollectionIdentifier($providerId, $serviceId, $collectionId);
|
|
$entity = $this->manager->entityCreate($tenantId, $userId, $target, $props);
|
|
|
|
// Persist the client-chosen URI => provider entityId mapping
|
|
$this->uriMap->set(
|
|
$this->mapId($tenantId, $userId, $calendarId),
|
|
$objectUri,
|
|
(string) $entity->identifier(),
|
|
);
|
|
|
|
return '"' . md5($this->entityToIcal($entity)) . '"';
|
|
}
|
|
|
|
public function updateCalendarObject($calendarId, $objectUri, $calendarData): ?string
|
|
{
|
|
[$providerId, $serviceId, $collectionId] = $this->parseId($calendarId);
|
|
$tenantId = $this->tenant->identifier();
|
|
$userId = $this->identity->identifier();
|
|
$mapId = $this->mapId($tenantId, $userId, $calendarId);
|
|
|
|
$entityId = $this->uriMap->getEntityId($mapId, $objectUri)
|
|
?? preg_replace('/\.ics$/i', '', $objectUri);
|
|
|
|
$vcal = Reader::read($calendarData);
|
|
$props = $this->icalToEntityProps($vcal);
|
|
$target = new EntityIdentifier($providerId, $serviceId, $collectionId, $entityId);
|
|
$entity = $this->manager->entityModify($tenantId, $userId, $target, $props);
|
|
|
|
return '"' . md5($this->entityToIcal($entity)) . '"';
|
|
}
|
|
|
|
public function deleteCalendarObject($calendarId, $objectUri): void
|
|
{
|
|
[$providerId, $serviceId, $collectionId] = $this->parseId($calendarId);
|
|
$tenantId = $this->tenant->identifier();
|
|
$userId = $this->identity->identifier();
|
|
$mapId = $this->mapId($tenantId, $userId, $calendarId);
|
|
|
|
$entityId = $this->uriMap->getEntityId($mapId, $objectUri)
|
|
?? preg_replace('/\.ics$/i', '', $objectUri);
|
|
|
|
$target = new EntityIdentifier($providerId, $serviceId, $collectionId, $entityId);
|
|
$this->manager->entityDelete($tenantId, $userId, $target);
|
|
$this->uriMap->delete($mapId, $objectUri);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Helpers
|
|
// -------------------------------------------------------------------------
|
|
|
|
private function parseId(string $calendarId): array
|
|
{
|
|
return explode(self::ID_SEP, $calendarId, 3);
|
|
}
|
|
|
|
private function mapId(string $tenantId, string $userId, string $calendarId): string
|
|
{
|
|
return 'cal:' . $tenantId . ':' . $userId . ':' . $calendarId;
|
|
}
|
|
|
|
private function slugify(string $label): string
|
|
{
|
|
return strtolower(preg_replace('/[^a-z0-9]+/i', '-', trim($label)));
|
|
}
|
|
|
|
/**
|
|
* Serialize a domain entity to an iCalendar string.
|
|
*
|
|
* The raw data from getDataRaw() is a serialized EventObject array
|
|
* with fields matching EventCommonObject property names (label, startsOn, endsOn, ...).
|
|
*/
|
|
private function entityToIcal(object $entity): string
|
|
{
|
|
$raw = (array) ($entity->getProperties()->getDataRaw() ?? []);
|
|
$utc = new \DateTimeZone('UTC');
|
|
$vcal = new VCalendar();
|
|
|
|
// Create the VEVENT without sabre's auto-generated UID/DTSTAMP defaults,
|
|
// otherwise they appear twice (we set our own below), which is invalid.
|
|
/** @var \Sabre\VObject\Component $vevent */
|
|
$vevent = $vcal->add('VEVENT', [], false);
|
|
$vevent->add('UID', (string) $entity->identifier());
|
|
// Always emit date-times in UTC ("...Z"). A DateTime carrying a fixed
|
|
// numeric offset (e.g. from an RFC3339 string) would otherwise serialize
|
|
// as TZID=-04:00 with no matching VTIMEZONE, which clients reject.
|
|
// DTSTAMP participates in the serialized data used for the ETag. Using
|
|
// the current time here made an unchanged entity produce a different
|
|
// ETag on every PROPFIND/REPORT and triggered perpetual client syncs.
|
|
$stamp = $entity->modified() ?? $entity->created();
|
|
$vevent->add(
|
|
'DTSTAMP',
|
|
$stamp !== null
|
|
? \DateTime::createFromImmutable($stamp)->setTimezone($utc)
|
|
: (new \DateTime('@0'))->setTimezone($utc),
|
|
);
|
|
|
|
if ($created = $entity->created()) {
|
|
$vevent->add('CREATED', \DateTime::createFromImmutable($created)->setTimezone($utc));
|
|
}
|
|
if ($modified = $entity->modified()) {
|
|
$vevent->add('LAST-MODIFIED', \DateTime::createFromImmutable($modified)->setTimezone($utc));
|
|
}
|
|
if (!empty($raw['label'])) {
|
|
$vevent->add('SUMMARY', (string) $raw['label']);
|
|
}
|
|
if (!empty($raw['description'])) {
|
|
$vevent->add('DESCRIPTION', (string) $raw['description']);
|
|
}
|
|
if (!empty($raw['startsOn'])) {
|
|
$vevent->add('DTSTART', (new \DateTime((string) $raw['startsOn']))->setTimezone($utc));
|
|
}
|
|
if (!empty($raw['endsOn'])) {
|
|
$vevent->add('DTEND', (new \DateTime((string) $raw['endsOn']))->setTimezone($utc));
|
|
}
|
|
// iCal supports one LOCATION property; use first physical location
|
|
if (!empty($raw['locationsPhysical']) && is_array($raw['locationsPhysical'])) {
|
|
foreach ($raw['locationsPhysical'] as $loc) {
|
|
$addr = $loc['address'] ?? $loc['name'] ?? $loc['label'] ?? null;
|
|
if ($addr) {
|
|
$vevent->add('LOCATION', (string) $addr);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $vcal->serialize();
|
|
}
|
|
|
|
/**
|
|
* Convert a parsed VCALENDAR to an array for entityCreate/entityUpdate.
|
|
* Field names match EventCommonObject property names used in serialization.
|
|
*/
|
|
private function icalToEntityProps(VCalendar $vcal): array
|
|
{
|
|
$comp = $vcal->VEVENT ?? $vcal->VTODO ?? $vcal->VJOURNAL ?? null;
|
|
if ($comp === null) {
|
|
return [];
|
|
}
|
|
|
|
$props = [];
|
|
if ($summary = (string) ($comp->SUMMARY ?? '')) {
|
|
$props['label'] = $summary;
|
|
}
|
|
if ($description = (string) ($comp->DESCRIPTION ?? '')) {
|
|
$props['description'] = $description;
|
|
}
|
|
if ($dtstart = $comp->DTSTART ?? null) {
|
|
$props['startsOn'] = $dtstart->getDateTime()->format(\DateTime::RFC3339);
|
|
}
|
|
if ($dtend = $comp->DTEND ?? $comp->DUE ?? null) {
|
|
$props['endsOn'] = $dtend->getDateTime()->format(\DateTime::RFC3339);
|
|
}
|
|
if ($location = (string) ($comp->LOCATION ?? '')) {
|
|
$props['locationsPhysical'] = [['label' => $location]];
|
|
}
|
|
|
|
return $props;
|
|
}
|
|
}
|