generated from Nodarx/template
3a68d03114
Signed-off-by: Sebastian <krupinski01@gmail.com>
371 lines
14 KiB
PHP
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;
|
|
}
|
|
}
|