Files
service_dav/lib/Backends/CardDavBackend.php
T
2026-07-27 00:50:43 -04:00

371 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace KTXM\ServiceDav\Backends;
use KTXC\Context\IdentityContextInterface;
use KTXC\Context\TenantContextInterface;
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 IdentityContextInterface $identityContext,
private readonly TenantContextInterface $tenantContext,
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->tenantContext->identifier(), $this->identityContext->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->tenantContext->identifier();
$userId = $this->identityContext->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->tenantContext->identifier();
$userId = $this->identityContext->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->tenantContext->identifier();
$userId = $this->identityContext->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->tenantContext->identifier();
$userId = $this->identityContext->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->tenantContext->identifier();
$userId = $this->identityContext->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;
}
}