, * Michael Stilkerich * * This file is part of RCMCardDAV. * * RCMCardDAV is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 2 of the License, or * (at your option) any later version. * * RCMCardDAV is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with RCMCardDAV. If not, see . */ /** * Synchronization handler that stores changes to roundcube database. */ declare(strict_types=1); namespace MStilkerich\RCMCardDAV; use Psr\Log\LoggerInterface; use Sabre\VObject\Component\VCard; use Sabre\VObject; use MStilkerich\CardDavClient\AddressbookCollection; use MStilkerich\CardDavClient\Services\SyncHandler; use MStilkerich\RCMCardDAV\Db\AbstractDatabase; use carddav; /** * @psalm-type ContactDbInfo = array{id: string, etag: string, uri: string, cuid: string} DB data on a contact * @psalm-type GroupDbInfo = array{id: string, uri: ?string, etag: ?string} DB data on a group * @psalm-type CardGroupDbInfo = array{id: string, uri: string, etag: string} DB data on a VCard-type group * @psalm-type ReceivedGroupInfo = array{vcard: VCard, etag: string, uri: string} Data of a retrieved group card */ class SyncHandlerRoundcube implements SyncHandler { /** @var bool hadErrors If true, errors that did not cause the sync to be aborted occurred. */ public $hadErrors = false; /** @var Addressbook Object of the addressbook that is being synchronized */ private $rcAbook; /** @var array Maps UIDs of locally available contact cards to (database) id */ private $localCardsByUID = []; /** @var array Maps URIs to the contact info */ private $localCards = []; /** @var array Maps URIs of KIND=group cards to the group info */ private $localGrpCards = []; /** @var list List of DB ids of CATEGORIES-type groups at the time the sync is started. * Note: If a contact's existing memberships to such groups are determined, this is * sufficient and we do not have to add new CATEGORIES-type groups created during the * sync to this list. */ private $localCatGrpIds = []; /** @var list records VCard-type groups that need to be updated */ private $grpCardsToUpdate = []; /** @var list a list of group IDs that may be cleared from the DB if empty and CATEGORIES-type */ private $clearGroupCandidates = []; /** @var DataConversion $dataConverter to convert between VCard and roundcube's representation of contacts. */ private $dataConverter; /** @var AddressbookCollection $davAbook */ private $davAbook; public function __construct( Addressbook $rcAbook, DataConversion $dataConverter, AddressbookCollection $davAbook ) { $this->rcAbook = $rcAbook; $this->dataConverter = $dataConverter; $this->davAbook = $davAbook; $db = Config::inst()->db(); $abookId = $this->rcAbook->getId(); /** * determine existing local contact URIs and ETAGs * @var list $contacts */ $contacts = $db->get(['abook_id' => $abookId], ['id', 'etag', 'uri', 'cuid']); foreach ($contacts as $contact) { $this->localCards[$contact['uri']] = $contact; $this->localCardsByUID[$contact['cuid']] = $contact['id']; } /** * determine existing local group URIs and ETAGs * @var list $groups */ $groups = $db->get(['abook_id' => $abookId], ['id', 'uri', 'etag'], 'groups'); foreach ($groups as $group) { if (isset($group['uri'])) { // these are groups defined by a KIND=group VCard /** @var CardGroupDbInfo $group */ $this->localGrpCards[$group['uri']] = $group; } else { // these are groups derived from CATEGORIES in the contact VCards $this->localCatGrpIds[] = $group['id']; } } } /** * Process a card reported as changed by the server (includes new cards). * * Cards of individuals are processed immediately, updating the database. Cards of KIND=group are recorded and * processed after all individuals have been processed in finalizeSync(). This is because these group cards may * reference individuals, and we need to have all of them in the local DB before processing the groups. * * @param string $uri URI of the card * @param string $etag ETag of the card as given * @param ?VCard $card The card as a Sabre VCard object. Null if the address data could not be retrieved/parsed. */ public function addressObjectChanged(string $uri, string $etag, ?VCard $card): void { $logger = Config::inst()->logger(); // in case a card has an error, we continue syncing the rest if (!isset($card)) { $this->hadErrors = true; $logger->error("Card $uri changed, but error in retrieving address data (card ignored)"); return; } if (strcasecmp((string) $card->{"X-ADDRESSBOOKSERVER-KIND"}, "group") === 0) { $this->grpCardsToUpdate[] = [ "vcard" => $card, "etag" => $etag, "uri" => $uri ]; } else { $this->updateContactCard($uri, $etag, $card); } } /** * Process a card reported as deleted the server. * * This function immediately updates the state in the database for both contact and group cards, deleting all * membership relations between contacts/groups if either side is deleted. If a CATEGORIES-type groups loses a * member during this process, we record it as a candidate that is deleted by finalizeSync() in case the group is * empty at the end of the sync. * * It is quite common for servers to report cards as deleted that were never seen by this client, for example when a * card was added and deleted again between two syncs. Thus, we must not react hard on such situations (we log a * notice). * * It is also possible that a URI is both reported as deleted and changed. This can happen if a URI was deleted and * a new object was created at the same URI. The Sync service will report all deleted objects first for this reason, * so we don't have to care about it here. However, we must clean up the local state before the * addressObjectChanged() function is called with a URI that was deleted, so it does not wrongfully assume a * connection between the deleted and the new card (and try to update the deleted card that no longer exists in the * DB). * * @param string $uri URI of the card */ public function addressObjectDeleted(string $uri): void { $infra = Config::inst(); $logger = $infra->logger(); $db = $infra->db(); $logger->info("Deleted card $uri"); if (isset($this->localCards[$uri]["id"])) { // delete contact $dbid = $this->localCards[$uri]["id"]; $cardUID = $this->localCards[$uri]["cuid"]; // CATEGORIES-type groups may become empty as a user is deleted and should then be deleted as well. Record // what groups the user belonged to. if (!empty($this->localCatGrpIds)) { /** @var list */ $group_ids = array_column( $db->get(["contact_id" => $dbid, "group_id" => $this->localCatGrpIds], ["group_id"], "group_user"), "group_id" ); $this->clearGroupCandidates = array_merge($this->clearGroupCandidates, $group_ids); $db->delete(["contact_id" => $dbid], "group_user"); } $db->delete($dbid); // important: URI may be reported as deleted and then reused for new card. unset($this->localCardsByUID[$cardUID]); unset($this->localCards[$uri]); } elseif (isset($this->localGrpCards[$uri]["id"])) { // delete VCard-type group $dbid = $this->localGrpCards[$uri]["id"]; $db->delete(["group_id" => $dbid], "group_user"); $db->delete($dbid, "groups"); // important: URI may be reported as deleted and then reused for new card. unset($this->localGrpCards[$uri]); } else { $logger->notice("Server reported deleted card $uri for that no DB entry exists"); } } /** * Provides the current local cards and ETags to the Sync service. * * This is only requested by the Sync service in case it has to fall back to PROPFIND-based synchronization, * i.e. if sync-collection REPORT is not supported by the server or did not work. * * @return array Maps card URIs to ETags */ public function getExistingVCardETags(): array { return array_column( array_merge($this->localCards, $this->localGrpCards), "etag", "uri" ); } /** * Finalize the synchronization process. * * This function is called last by the Sync service after all changes have been reported. We use it to perform * delayed actions, namely the processing of changed group vcards and deletion of CATEGORIES-type groups that became * empty during this sync. */ public function finalizeSync(): void { $infra = Config::inst(); $logger = $infra->logger(); $db = $infra->db(); $abookId = $this->rcAbook->getId(); // Now process all KIND=group type VCards that the server reported as changed foreach ($this->grpCardsToUpdate as $g) { $this->updateGroupCard($g["uri"], $g["etag"], $g["vcard"]); } // Delete all CATEGORIES-TYPE groups that had their last contacts deleted during this sync $group_ids = array_unique($this->clearGroupCandidates); if (!empty($group_ids)) { try { $db->startTransaction(false); $group_ids_nonempty = array_column( $db->get(["group_id" => $group_ids], ["group_id"], "group_user"), "group_id" ); $group_ids_empty = array_diff($group_ids, $group_ids_nonempty); if (!empty($group_ids_empty)) { $logger->info("Delete empty CATEGORIES-type groups: " . implode(",", $group_ids_empty)); $db->delete(["id" => $group_ids_empty, "uri" => null, "abook_id" => $abookId], "groups"); } $db->endTransaction(); } catch (\Exception $e) { $this->hadErrors = true; $logger->error("Failed to delete emptied CATEGORIES-type groups: " . $e->getMessage()); $db->rollbackTransaction(); } } } /** * This function determines the group IDs of CATEGORIES-type groups the individual of the * given VCard belongs to. Groups are created if needed. * * @param VCard $card The VCard that describes the individual, including the CATEGORIES attribute * @return list An array of DB ids of the CATEGORIES-type groups the user belongs to. */ private function getCategoryTypeGroupsForUser(VCard $card): array { $abookId = $this->rcAbook->getId(); $infra = Config::inst(); $logger = $infra->logger(); $db = $infra->db(); // Determine CATEGORIES-type group ID (and create if needed) of the user $categories = []; if (isset($card->CATEGORIES)) { /** @var list */ $categories = $card->CATEGORIES->getParts(); // remove all whitespace categories Addressbook::stringsAddRemove($categories); } if (empty($categories)) { return []; } try { $db->startTransaction(false); /** @var array $group_ids_by_name */ $group_ids_by_name = array_column( $db->get(["abook_id" => $abookId, "uri" => null, "name" => $categories], ["id", "name"], "groups"), "id", "name" ); foreach ($categories as $category) { if (!isset($group_ids_by_name[$category])) { $gsave_data = [ 'name' => $category, 'kind' => 'group' ]; $dbid = $db->storeGroup($abookId, $gsave_data); $group_ids_by_name[$category] = $dbid; } } $db->endTransaction(); return array_values($group_ids_by_name); } catch (\Exception $e) { $this->hadErrors = true; $logger->error("Failed to determine CATEGORIES-type groups for contact: " . $e->getMessage()); $db->rollbackTransaction(); } return []; } /** * Updates a KIND=individual VCard in the local DB. * * @param string $uri URI of the card * @param string $etag ETag of the card as given * @param VCard $card The card as a Sabre VCard object. */ private function updateContactCard(string $uri, string $etag, VCard $card): void { $abookId = $this->rcAbook->getId(); $infra = Config::inst(); $logger = $infra->logger(); $db = $infra->db(); $save_data = $this->dataConverter->toRoundcube($card, $this->davAbook); $logger->info("Changed Individual $uri"); $dbid = $this->localCards[$uri]["id"] ?? null; try { $db->startTransaction(false); $dbid = $db->storeContact($abookId, $etag, $uri, $card->serialize(), $save_data, $dbid); $db->endTransaction(); // remember in the local cache - might be needed in finalizeSync to map UID to DB ID without DB query if (!isset($save_data["cuid"])) { throw new \Exception("VCard $uri has not UID property"); } if (!isset($this->localCardsByUID[$save_data["cuid"]])) { $this->localCardsByUID[$save_data["cuid"]] = $dbid; } // determine current assignments to CATGEGORIES-type groups $cur_group_ids = $this->getCategoryTypeGroupsForUser($card); $db->startTransaction(false); // Update membership to CATEGORIES-type groups /** @var list $old_group_ids */ $old_group_ids = empty($this->localCatGrpIds) ? [] : array_column( $db->get(["contact_id" => $dbid, "group_id" => $this->localCatGrpIds ], ["group_id"], "group_user"), "group_id" ); $del_group_ids = array_diff($old_group_ids, $cur_group_ids); if (!empty($del_group_ids)) { // CATEGORIES-type groups may become empty when members are removed. Record those the user belonged to. $this->clearGroupCandidates = array_merge($this->clearGroupCandidates, $del_group_ids); // remove contact from CATEGORIES-type groups he no longer belongs to $db->delete(["contact_id" => $dbid, "group_id" => $del_group_ids], 'group_user'); } // add contact to CATEGORIES-type groups he newly belongs to $add_group_ids = array_values(array_diff($cur_group_ids, $old_group_ids)); if (!empty($add_group_ids)) { $db->insert( "group_user", ["contact_id", "group_id"], array_map( function (string $groupId) use ($dbid) { return [$dbid, $groupId]; }, $add_group_ids ) ); } $db->endTransaction(); } catch (\Exception $e) { $this->hadErrors = true; $logger->error("Failed to process changed card $uri: " . $e->getMessage()); $db->rollbackTransaction(); } } /** * Updates a KIND=group VCard in the local DB. * * @param string $uri URI of the card * @param string $etag ETag of the card as given * @param VCard $card The card as a Sabre VCard object. */ private function updateGroupCard(string $uri, string $etag, VCard $card): void { $infra = Config::inst(); $logger = $infra->logger(); $db = $infra->db(); $abookId = $this->rcAbook->getId(); $save_data = $this->dataConverter->toRoundcube($card, $this->davAbook); $dbid = $this->localGrpCards[$uri]["id"] ?? null; $logger->info("Changed Group $uri " . $save_data['name']); // X-ADDRESSBOOKSERVER-MEMBER:urn:uuid:51A7211B-358B-4996-90AD-016D25E77A6E $members = $card->{'X-ADDRESSBOOKSERVER-MEMBER'} ?? []; $memberIds = []; $logger->debug("Group $uri has " . count($members) . " members"); /** @var VObject\Property $mbr */ foreach ($members as $mbr) { $mbrc = explode(':', (string) $mbr); if (count($mbrc) != 3 || $mbrc[0] !== 'urn' || $mbrc[1] !== 'uuid') { $logger->warning("don't know how to interpret group membership: $mbr"); } else { $memberId = $this->localCardsByUID[$mbrc[2]] ?? null; if (isset($memberId)) { if (in_array($memberId, $memberIds)) { // remove duplicate from vcard $card->remove($mbr); } else { $memberIds[] = $memberId; } } else { $logger->warning("cannot find DB ID for group member: $mbrc[2]"); } } } try { $db->startTransaction(false); // store group card $dbid = $db->storeGroup($abookId, $save_data, $dbid, $etag, $uri, $card->serialize()); // delete current group members (will be reinserted if needed below) $db->delete(["group_id" => $dbid], 'group_user'); // Update member assignments if (count($memberIds) > 0) { $db->insert( 'group_user', ['group_id', 'contact_id'], array_map( function (string $contactId) use ($dbid): array { return [ $dbid, $contactId ]; }, $memberIds ) ); $logger->debug("Added " . count($memberIds) . " contacts to group $dbid"); } $db->endTransaction(); } catch (\Exception $e) { $this->hadErrors = true; $logger->error("Failed to update group $dbid: " . $e->getMessage()); $db->rollbackTransaction(); } } } // vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120