Files
service_dav/lib/Backends/CalDavBackend.php
T
2026-07-14 18:24:31 -04:00

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;
}
}