Initial service DAV module

This commit is contained in:
Sebastian Krupinski
2026-07-14 18:22:40 -04:00
parent b9302bf965
commit 3b07d6721b
497 changed files with 73793 additions and 0 deletions
+325
View File
@@ -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;
}
}
+370
View File
@@ -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;
}
}
+118
View File
@@ -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);
}
}
+90
View File
@@ -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;
}
}
+56
View File
@@ -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');
}
}
+67
View File
@@ -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);
}
}
+97
View File
@@ -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,
];
}
}