generated from Nodarx/template
Initial service DAV module
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ServiceDav\Backends;
|
||||
|
||||
use KTXC\SessionIdentity;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXF\Resource\Identifier\CollectionIdentifier;
|
||||
use KTXF\Resource\Identifier\EntityIdentifier;
|
||||
use KTXF\Resource\Identifier\ResourceIdentifiers;
|
||||
use KTXM\PeopleManager\Manager as PeopleManager;
|
||||
use KTXM\ServiceDav\DavProperties;
|
||||
use KTXM\ServiceDav\DavUriMap;
|
||||
use Sabre\CardDAV\Backend\AbstractBackend;
|
||||
use Sabre\VObject\Component\VCard;
|
||||
use Sabre\VObject\Reader;
|
||||
|
||||
/**
|
||||
* CardDAV backend bridging sabre/dav to the KTXM PeopleManager.
|
||||
*
|
||||
* Address book <=> Collection (within a provider service)
|
||||
* Card <=> Entity (individual / organization / group)
|
||||
*
|
||||
* Address book identifiers use the composite key "{providerId}:{serviceId}:{collectionId}".
|
||||
* Card URIs are client-chosen names (e.g. "contact.vcf") mapped to provider
|
||||
* entity identifiers via DavUriMap.
|
||||
*/
|
||||
class CardDavBackend extends AbstractBackend
|
||||
{
|
||||
private const ID_SEP = ':';
|
||||
|
||||
public function __construct(
|
||||
private readonly PeopleManager $manager,
|
||||
private readonly SessionIdentity $identity,
|
||||
private readonly SessionTenant $tenant,
|
||||
private readonly DavUriMap $uriMap,
|
||||
) {}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Required: address book listing
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function getAddressBooksForUser($principalUri): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
// collectionList returns [providerId][serviceId][collectionId] => CollectionBaseInterface
|
||||
$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();
|
||||
$abId = implode(self::ID_SEP, [(string) $providerId, (string) $serviceId, (string) $collectionId]);
|
||||
|
||||
$result[] = [
|
||||
DavProperties::ID => $abId,
|
||||
DavProperties::URI => $this->slugify($label),
|
||||
DavProperties::DISPLAYNAME => $label,
|
||||
DavProperties::CARDDAV_DESCRIPTION => $props->getDescription() ?? '',
|
||||
DavProperties::CS_CTAG => $collection->signature() ?? '',
|
||||
DavProperties::PRINCIPAL_URI => $principalUri,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function updateAddressBook($addressBookId, \Sabre\DAV\PropPatch $propPatch): void
|
||||
{
|
||||
// not supported
|
||||
}
|
||||
|
||||
public function createAddressBook($principalUri, $url, array $properties): string
|
||||
{
|
||||
throw new \Sabre\DAV\Exception\Forbidden('Address book creation is not supported via DAV');
|
||||
}
|
||||
|
||||
public function deleteAddressBook($addressBookId): void
|
||||
{
|
||||
throw new \Sabre\DAV\Exception\Forbidden('Address book deletion is not supported via DAV');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Required: cards
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function getCards($addressBookId): array
|
||||
{
|
||||
$tenantId = $this->tenant->identifier();
|
||||
$userId = $this->identity->identifier();
|
||||
|
||||
$targets = ResourceIdentifiers::fromArray([$addressBookId]);
|
||||
|
||||
// entityListBulk returns [providerId][serviceId][collectionId] => [entityId => EntityBaseInterface]
|
||||
$entitiesByLocation = $this->manager->entityListBulk($tenantId, $userId, $targets);
|
||||
|
||||
$mapId = $this->mapId($tenantId, $userId, $addressBookId);
|
||||
$cards = [];
|
||||
foreach ($entitiesByLocation as $providerServices) {
|
||||
foreach ($providerServices as $serviceCollections) {
|
||||
foreach ($serviceCollections as $entities) {
|
||||
foreach ($entities as $entity) {
|
||||
if (!is_object($entity)) {
|
||||
continue;
|
||||
}
|
||||
$entityId = (string) $entity->identifier();
|
||||
$objectUri = $this->uriMap->getObjectUri($mapId, $entityId) ?? ($entityId . '.vcf');
|
||||
$data = $this->entityToVcard($entity);
|
||||
|
||||
$cards[] = [
|
||||
DavProperties::ID => $entityId,
|
||||
DavProperties::URI => $objectUri,
|
||||
DavProperties::LAST_MODIFIED => $entity->modified()?->getTimestamp() ?? time(),
|
||||
DavProperties::ETAG => '"' . md5($data) . '"',
|
||||
DavProperties::SIZE => strlen($data),
|
||||
DavProperties::CARD_DATA => null, // lazy
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $cards;
|
||||
}
|
||||
|
||||
public function getCard($addressBookId, $cardUri): ?array
|
||||
{
|
||||
[$providerId, $serviceId, $collectionId] = $this->parseId($addressBookId);
|
||||
$tenantId = $this->tenant->identifier();
|
||||
$userId = $this->identity->identifier();
|
||||
$mapId = $this->mapId($tenantId, $userId, $addressBookId);
|
||||
|
||||
$entityId = $this->uriMap->getEntityId($mapId, $cardUri)
|
||||
?? preg_replace('/\.vcf$/i', '', $cardUri);
|
||||
|
||||
$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->entityToVcard($entity);
|
||||
|
||||
return [
|
||||
DavProperties::ID => $entityId,
|
||||
DavProperties::URI => $cardUri,
|
||||
DavProperties::LAST_MODIFIED => $entity->modified()?->getTimestamp() ?? time(),
|
||||
DavProperties::ETAG => '"' . md5($data) . '"',
|
||||
DavProperties::SIZE => strlen($data),
|
||||
DavProperties::CARD_DATA => $data,
|
||||
];
|
||||
}
|
||||
|
||||
public function getMultipleCards($addressBookId, array $uris): array
|
||||
{
|
||||
return array_values(array_filter(array_map(
|
||||
fn($uri) => $this->getCard($addressBookId, $uri),
|
||||
$uris,
|
||||
)));
|
||||
}
|
||||
|
||||
public function createCard($addressBookId, $cardUri, $cardData): ?string
|
||||
{
|
||||
[$providerId, $serviceId, $collectionId] = $this->parseId($addressBookId);
|
||||
$tenantId = $this->tenant->identifier();
|
||||
$userId = $this->identity->identifier();
|
||||
|
||||
$vcard = Reader::read($cardData);
|
||||
$props = $this->vcardToEntityProps($vcard);
|
||||
$target = new CollectionIdentifier($providerId, $serviceId, $collectionId);
|
||||
$entity = $this->manager->entityCreate($tenantId, $userId, $target, $props);
|
||||
|
||||
$this->uriMap->set(
|
||||
$this->mapId($tenantId, $userId, $addressBookId),
|
||||
$cardUri,
|
||||
(string) $entity->identifier(),
|
||||
);
|
||||
|
||||
return '"' . md5($this->entityToVcard($entity)) . '"';
|
||||
}
|
||||
|
||||
public function updateCard($addressBookId, $cardUri, $cardData): ?string
|
||||
{
|
||||
[$providerId, $serviceId, $collectionId] = $this->parseId($addressBookId);
|
||||
$tenantId = $this->tenant->identifier();
|
||||
$userId = $this->identity->identifier();
|
||||
$mapId = $this->mapId($tenantId, $userId, $addressBookId);
|
||||
|
||||
$entityId = $this->uriMap->getEntityId($mapId, $cardUri)
|
||||
?? preg_replace('/\.vcf$/i', '', $cardUri);
|
||||
|
||||
$vcard = Reader::read($cardData);
|
||||
$props = $this->vcardToEntityProps($vcard);
|
||||
$target = new EntityIdentifier($providerId, $serviceId, $collectionId, $entityId);
|
||||
$entity = $this->manager->entityModify($tenantId, $userId, $target, $props);
|
||||
|
||||
return '"' . md5($this->entityToVcard($entity)) . '"';
|
||||
}
|
||||
|
||||
public function deleteCard($addressBookId, $cardUri): bool
|
||||
{
|
||||
[$providerId, $serviceId, $collectionId] = $this->parseId($addressBookId);
|
||||
$tenantId = $this->tenant->identifier();
|
||||
$userId = $this->identity->identifier();
|
||||
$mapId = $this->mapId($tenantId, $userId, $addressBookId);
|
||||
|
||||
$entityId = $this->uriMap->getEntityId($mapId, $cardUri)
|
||||
?? preg_replace('/\.vcf$/i', '', $cardUri);
|
||||
|
||||
$target = new EntityIdentifier($providerId, $serviceId, $collectionId, $entityId);
|
||||
$outcome = $this->manager->entityDelete($tenantId, $userId, $target);
|
||||
|
||||
// entityDelete returns outcomes keyed by source identifier; a non-error
|
||||
// disposition ('deleted'|'moved') indicates the card no longer exists here.
|
||||
$disposition = $outcome[(string) $target]['disposition'] ?? 'error';
|
||||
$deleted = $disposition !== 'error';
|
||||
if ($deleted) {
|
||||
$this->uriMap->delete($mapId, $cardUri);
|
||||
}
|
||||
return $deleted;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private function parseId(string $id): array
|
||||
{
|
||||
return explode(self::ID_SEP, $id, 3);
|
||||
}
|
||||
|
||||
private function mapId(string $tenantId, string $userId, string $addressBookId): string
|
||||
{
|
||||
return 'card:' . $tenantId . ':' . $userId . ':' . $addressBookId;
|
||||
}
|
||||
|
||||
private function slugify(string $label): string
|
||||
{
|
||||
return strtolower(preg_replace('/[^a-z0-9]+/i', '-', trim($label)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a domain people entity to a vCard 4.0 string.
|
||||
*
|
||||
* The raw data from getDataRaw() is a serialized IndividualObject array with
|
||||
* fields: label, names (given/family/...), phones, emails, organizations, ...
|
||||
*/
|
||||
private function entityToVcard(object $entity): string
|
||||
{
|
||||
$raw = (array) ($entity->getProperties()->getDataRaw() ?? []);
|
||||
$vcard = new VCard(['VERSION' => '4.0', 'UID' => (string) $entity->identifier()]);
|
||||
|
||||
// Full name (FN is required in vCard 4.0)
|
||||
$fn = (string) ($raw['label'] ?? '');
|
||||
$vcard->add('FN', $fn ?: 'Unknown');
|
||||
|
||||
// Structured name: N;family;given;additional;prefix;suffix
|
||||
$names = (array) ($raw['names'] ?? []);
|
||||
$family = (string) ($names['family'] ?? '');
|
||||
$given = (string) ($names['given'] ?? '');
|
||||
if ($family || $given) {
|
||||
$vcard->add('N', [
|
||||
'value' => [
|
||||
$family,
|
||||
$given,
|
||||
(string) ($names['additional'] ?? ''),
|
||||
(string) ($names['prefix'] ?? ''),
|
||||
(string) ($names['suffix'] ?? ''),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// E-mail addresses
|
||||
foreach ((array) ($raw['emails'] ?? []) as $email) {
|
||||
if ($address = (string) ($email['address'] ?? '')) {
|
||||
$vcard->add('EMAIL', $address);
|
||||
}
|
||||
}
|
||||
|
||||
// Phone numbers
|
||||
foreach ((array) ($raw['phones'] ?? []) as $phone) {
|
||||
if ($number = (string) ($phone['number'] ?? '')) {
|
||||
$vcard->add('TEL', $number);
|
||||
}
|
||||
}
|
||||
|
||||
// Organization (first entry only for simplicity)
|
||||
foreach ((array) ($raw['organizations'] ?? []) as $org) {
|
||||
$orgName = (string) ($org['Label'] ?? $org['label'] ?? '');
|
||||
if ($orgName) {
|
||||
$vcard->add('ORG', $orgName);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $vcard->serialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a parsed vCard into an array for entityCreate/entityUpdate.
|
||||
* Field names match IndividualObject property names used in serialization.
|
||||
*/
|
||||
private function vcardToEntityProps(VCard $vcard): array
|
||||
{
|
||||
$props = [];
|
||||
|
||||
if ($fn = (string) ($vcard->FN ?? '')) {
|
||||
$props['label'] = $fn;
|
||||
}
|
||||
|
||||
if ($n = $vcard->N) {
|
||||
$parts = $n->getParts();
|
||||
$names = [];
|
||||
if (!empty($parts[0])) {
|
||||
$names['family'] = $parts[0];
|
||||
}
|
||||
if (!empty($parts[1])) {
|
||||
$names['given'] = $parts[1];
|
||||
}
|
||||
if (!empty($parts[2])) {
|
||||
$names['additional'] = $parts[2];
|
||||
}
|
||||
if (!empty($parts[3])) {
|
||||
$names['prefix'] = $parts[3];
|
||||
}
|
||||
if (!empty($parts[4])) {
|
||||
$names['suffix'] = $parts[4];
|
||||
}
|
||||
if ($names) {
|
||||
$props['names'] = $names;
|
||||
}
|
||||
}
|
||||
|
||||
$emails = [];
|
||||
foreach ($vcard->select('EMAIL') as $email) {
|
||||
$emails[] = ['address' => (string) $email];
|
||||
}
|
||||
if ($emails) {
|
||||
$props['emails'] = $emails;
|
||||
}
|
||||
|
||||
$phones = [];
|
||||
foreach ($vcard->select('TEL') as $tel) {
|
||||
$phones[] = ['number' => (string) $tel];
|
||||
}
|
||||
if ($phones) {
|
||||
$props['phones'] = $phones;
|
||||
}
|
||||
|
||||
$orgs = [];
|
||||
foreach ($vcard->select('ORG') as $org) {
|
||||
$orgs[] = ['Label' => (string) $org];
|
||||
}
|
||||
if ($orgs) {
|
||||
$props['organizations'] = $orgs;
|
||||
}
|
||||
|
||||
return $props;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ServiceDav\Backends;
|
||||
|
||||
use KTXF\Documents\Collection\CollectionBaseInterface;
|
||||
use KTXF\Documents\Service\ServiceBaseInterface;
|
||||
use KTXF\Documents\Service\ServiceCollectionMutableInterface;
|
||||
use KTXF\Documents\Service\ServiceEntityMutableInterface;
|
||||
use KTXF\Resource\Identifier\CollectionIdentifier;
|
||||
use KTXF\Resource\Identifier\EntityIdentifier;
|
||||
use Sabre\DAV\Collection;
|
||||
use Sabre\DAV\Exception\Forbidden;
|
||||
use Sabre\DAV\INode;
|
||||
|
||||
/**
|
||||
* A folder-level WebDAV collection backed by a document collection.
|
||||
*/
|
||||
class FilesCollectionNode extends Collection
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ServiceBaseInterface $service,
|
||||
private readonly CollectionBaseInterface $collection,
|
||||
) {}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->collection->getProperties()->getLabel() ?? (string) $this->collection->identifier();
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifier of the collection backing this node
|
||||
*/
|
||||
private function collectionIdentifier(): CollectionIdentifier
|
||||
{
|
||||
return new CollectionIdentifier(
|
||||
$this->service->provider(),
|
||||
(string) $this->service->identifier(),
|
||||
(string) $this->collection->identifier(),
|
||||
);
|
||||
}
|
||||
|
||||
/** @return INode[] */
|
||||
public function getChildren(): array
|
||||
{
|
||||
$nodes = [];
|
||||
|
||||
// ----- collections (folders) -----
|
||||
$decendents = $this->service->collectionList($this->collection->identifier());
|
||||
foreach ($decendents as $decendent) {
|
||||
$nodes[] = new FilesCollectionNode(
|
||||
$this->service,
|
||||
$decendent,
|
||||
);
|
||||
}
|
||||
|
||||
// ----- entities (files) -----
|
||||
/** @var \KTXF\Documents\Entity\EntityBaseInterface[] $entities */
|
||||
$entities = $this->service->entityListBulk($this->collection->identifier());
|
||||
foreach ($entities as $entity) {
|
||||
if (is_object($entity)) {
|
||||
$nodes[] = new FilesEntityNode(
|
||||
$this->service,
|
||||
$entity,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $nodes;
|
||||
}
|
||||
|
||||
public function createDirectory($name): void
|
||||
{
|
||||
if (!$this->service instanceof ServiceCollectionMutableInterface) {
|
||||
throw new Forbidden('Service does not support collection creation');
|
||||
}
|
||||
|
||||
$properties = $this->service->collectionFresh()->getProperties();
|
||||
$properties->setLabel($name);
|
||||
$this->service->collectionCreate($this->collectionIdentifier(), $properties);
|
||||
}
|
||||
|
||||
public function createFile($name, $data = null): string
|
||||
{
|
||||
if (!$this->service instanceof ServiceEntityMutableInterface) {
|
||||
throw new Forbidden('Service does not support entity creation');
|
||||
}
|
||||
|
||||
$properties = $this->service->entityFresh()->getProperties();
|
||||
$properties->setLabel($name);
|
||||
$entity = $this->service->entityCreate($this->collectionIdentifier(), $properties);
|
||||
|
||||
if ($data !== null) {
|
||||
$content = is_resource($data) ? stream_get_contents($data) : (string) $data;
|
||||
$this->service->entityWrite(
|
||||
new EntityIdentifier(
|
||||
$this->service->provider(),
|
||||
(string) $this->service->identifier(),
|
||||
(string) $entity->collection(),
|
||||
(string) $entity->identifier(),
|
||||
),
|
||||
$content,
|
||||
);
|
||||
}
|
||||
|
||||
return '"' . md5($entity->identifier() . time()) . '"';
|
||||
}
|
||||
|
||||
public function delete(): void
|
||||
{
|
||||
if (!$this->service instanceof ServiceCollectionMutableInterface) {
|
||||
throw new Forbidden('Service does not support collection deletion');
|
||||
}
|
||||
|
||||
$this->service->collectionDelete($this->collectionIdentifier(), true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ServiceDav\Backends;
|
||||
|
||||
use KTXF\Documents\Entity\EntityBaseInterface;
|
||||
use KTXF\Documents\Service\ServiceBaseInterface;
|
||||
use KTXF\Documents\Service\ServiceEntityMutableInterface;
|
||||
use KTXF\Resource\Identifier\EntityIdentifier;
|
||||
use Sabre\DAV\Exception\Forbidden;
|
||||
use Sabre\DAV\File;
|
||||
|
||||
/**
|
||||
* A file node backed by a document entity.
|
||||
*/
|
||||
class FilesEntityNode extends File
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ServiceBaseInterface $service,
|
||||
private readonly EntityBaseInterface $entity,
|
||||
) {}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->entity->getProperties()->getLabel() ?? (string) $this->entity->identifier();
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifier of the entity backing this node
|
||||
*/
|
||||
private function entityIdentifier(): EntityIdentifier
|
||||
{
|
||||
return new EntityIdentifier(
|
||||
$this->service->provider(),
|
||||
(string) $this->service->identifier(),
|
||||
(string) $this->entity->collection(),
|
||||
(string) $this->entity->identifier(),
|
||||
);
|
||||
}
|
||||
|
||||
/** @return resource|string */
|
||||
public function get()
|
||||
{
|
||||
return $this->service->entityRead($this->entityIdentifier());
|
||||
}
|
||||
|
||||
public function put($data): ?string
|
||||
{
|
||||
if (!$this->service instanceof ServiceEntityMutableInterface) {
|
||||
throw new Forbidden('Entity is immutable');
|
||||
}
|
||||
|
||||
$this->service->entityWrite(
|
||||
$this->entityIdentifier(),
|
||||
is_resource($data) ? stream_get_contents($data) : (string) $data,
|
||||
);
|
||||
|
||||
return '"' . md5($this->entity->identifier() . time()) . '"';
|
||||
}
|
||||
|
||||
public function delete(): void
|
||||
{
|
||||
if (!$this->service instanceof ServiceEntityMutableInterface) {
|
||||
throw new Forbidden('Entity is immutable');
|
||||
}
|
||||
|
||||
$this->service->entityDelete($this->entityIdentifier());
|
||||
}
|
||||
|
||||
public function getContentType(): ?string
|
||||
{
|
||||
return $this->entity->getProperties()->getMime();
|
||||
}
|
||||
|
||||
public function getETag(): ?string
|
||||
{
|
||||
return '"' . $this->entity->signature() . '"';
|
||||
}
|
||||
|
||||
public function getSize(): ?int
|
||||
{
|
||||
return $this->entity->getProperties()->size();
|
||||
}
|
||||
|
||||
public function getLastModified(): ?int
|
||||
{
|
||||
return $this->entity->modified()?->getTimestamp() ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ServiceDav\Backends;
|
||||
|
||||
use KTXC\SessionIdentity;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXM\DocumentsManager\Manager as DocumentsManager;
|
||||
use Sabre\DAV\Collection;
|
||||
use Sabre\DAV\Exception\Forbidden;
|
||||
use Sabre\DAV\Exception\NotFound;
|
||||
use Sabre\DAV\INode;
|
||||
|
||||
/**
|
||||
* The root WebDAV collection for a user's files.
|
||||
*
|
||||
* Structure: /dav/files/{providerLabel}/{serviceLabel}/...
|
||||
*
|
||||
* Each provider+service combination becomes a top-level folder.
|
||||
*/
|
||||
class FilesRootNode extends Collection
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DocumentsManager $manager,
|
||||
private readonly SessionIdentity $identity,
|
||||
private readonly SessionTenant $tenant,
|
||||
) {}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'files';
|
||||
}
|
||||
|
||||
/** @return INode[] */
|
||||
public function getChildren(): array
|
||||
{
|
||||
$nodes = [];
|
||||
$services = $this->manager->serviceList($this->tenant->identifier(), $this->identity->identifier());
|
||||
|
||||
foreach ($services as $providerId => $providerServices) {
|
||||
foreach ($providerServices as $serviceId => $service) {
|
||||
$nodes[] = new FilesServiceNode(
|
||||
$service,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $nodes;
|
||||
}
|
||||
|
||||
public function createDirectory($name): void
|
||||
{
|
||||
throw new Forbidden('Cannot create a provider root directory');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ServiceDav\Backends;
|
||||
|
||||
use KTXF\Documents\Service\ServiceBaseInterface;
|
||||
use KTXF\Documents\Service\ServiceCollectionMutableInterface;
|
||||
use Sabre\DAV\Collection;
|
||||
use Sabre\DAV\Exception\Forbidden;
|
||||
use Sabre\DAV\INode;
|
||||
|
||||
/**
|
||||
* A service-level WebDAV collection.
|
||||
*
|
||||
* Maps one (providerId, serviceId) pair → a DAV folder whose children are
|
||||
* the root-level document collections exposed by that service.
|
||||
*/
|
||||
class FilesServiceNode extends Collection
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ServiceBaseInterface $service,
|
||||
) {}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->service->getLabel();
|
||||
}
|
||||
|
||||
/** @return INode[] */
|
||||
public function getChildren(): array
|
||||
{
|
||||
$nodes = [];
|
||||
|
||||
$collections = $this->service->collectionList(null);
|
||||
foreach ($collections as $collection) {
|
||||
$nodes[] = new FilesCollectionNode(
|
||||
$this->service,
|
||||
$collection,
|
||||
);
|
||||
}
|
||||
|
||||
/** @var \KTXF\Documents\Entity\EntityBaseInterface[] $entities */
|
||||
$entities = $this->service->entityListBulk(null);
|
||||
foreach ($entities as $entity) {
|
||||
if (is_object($entity)) {
|
||||
$nodes[] = new FilesEntityNode(
|
||||
$this->service,
|
||||
$entity,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $nodes;
|
||||
}
|
||||
|
||||
public function createDirectory($name): void
|
||||
{
|
||||
if (!$this->service instanceof ServiceCollectionMutableInterface) {
|
||||
throw new Forbidden('Service does not support collection creation');
|
||||
}
|
||||
|
||||
$properties = $this->service->collectionFresh()->getProperties();
|
||||
$properties->setLabel($name);
|
||||
$this->service->collectionCreate(null, $properties);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ServiceDav\Backends;
|
||||
|
||||
use KTXC\SessionIdentity;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXM\ServiceDav\DavProperties;
|
||||
use Sabre\DAVACL\PrincipalBackend\BackendInterface;
|
||||
|
||||
/**
|
||||
* DAVACL principal backend.
|
||||
*/
|
||||
class PrincipalBackend implements BackendInterface
|
||||
{
|
||||
public const PRINCIPAL_PREFIX = 'principals';
|
||||
|
||||
public function __construct(
|
||||
private readonly SessionIdentity $identity,
|
||||
private readonly SessionTenant $tenant,
|
||||
) {}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// BackendInterface
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function getPrincipalsByPrefix($prefixPath): array
|
||||
{
|
||||
if ($prefixPath !== self::PRINCIPAL_PREFIX || $this->identity->identity() === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [$this->buildPrincipal()];
|
||||
}
|
||||
|
||||
public function getPrincipalByPath($path): ?array
|
||||
{
|
||||
if ($this->identity->identity() === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$expected = self::PRINCIPAL_PREFIX . '/' . $this->identity->identifier();
|
||||
if ($path !== $expected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->buildPrincipal();
|
||||
}
|
||||
|
||||
public function getGroupMemberSet($principal): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function getGroupMembership($principal): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function setGroupMemberSet($principal, $members): void
|
||||
{
|
||||
// not supported
|
||||
}
|
||||
|
||||
public function updatePrincipal($path, $propPatch): int
|
||||
{
|
||||
return 403;
|
||||
}
|
||||
|
||||
public function searchPrincipals($prefixPath, $searchProperties, $test = 'allof'): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function findByUri($uri, $principalPrefix): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private function buildPrincipal(): array
|
||||
{
|
||||
$userId = $this->identity->identifier();
|
||||
$label = $this->identity->label() ?? $userId;
|
||||
$email = $this->identity->mailAddress() ?? '';
|
||||
|
||||
return [
|
||||
DavProperties::URI => self::PRINCIPAL_PREFIX . '/' . $userId,
|
||||
DavProperties::DISPLAYNAME => $label,
|
||||
DavProperties::SABRE_EMAIL => $email,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user