diff --git a/docs/SQL/mysql.initial.sql b/docs/SQL/mysql.initial.sql index 0028923..ac433c8 100644 --- a/docs/SQL/mysql.initial.sql +++ b/docs/SQL/mysql.initial.sql @@ -1,127 +1,118 @@ CREATE TABLE IF NOT EXISTS `syncroton_policy` ( `id` varchar(40) NOT NULL, `name` varchar(255) NOT NULL, `description` varchar(255) DEFAULT NULL, `policy_key` varchar(64) NOT NULL, `json_policy` blob NOT NULL, PRIMARY KEY (`id`) ) /*!40000 ENGINE=INNODB */ /*!40101 CHARACTER SET utf8 COLLATE utf8_general_ci */; CREATE TABLE IF NOT EXISTS `syncroton_device` ( `id` varchar(40) NOT NULL, `deviceid` varchar(64) NOT NULL, `devicetype` varchar(64) NOT NULL, `owner_id` varchar(40) NOT NULL, `acsversion` varchar(40) NOT NULL, `policykey` varchar(64) DEFAULT NULL, `policy_id` varchar(40) DEFAULT NULL, `useragent` varchar(255) DEFAULT NULL, `imei` varchar(255) DEFAULT NULL, `model` varchar(255) DEFAULT NULL, `friendlyname` varchar(255) DEFAULT NULL, `os` varchar(255) DEFAULT NULL, `oslanguage` varchar(255) DEFAULT NULL, `phonenumber` varchar(255) DEFAULT NULL, `pinglifetime` int(11) DEFAULT NULL, `remotewipe` int(11) DEFAULT '0', `pingfolder` longblob, `lastsynccollection` longblob DEFAULT NULL, `lastping` datetime DEFAULT NULL, `contactsfilter_id` varchar(40) DEFAULT NULL, `calendarfilter_id` varchar(40) DEFAULT NULL, `tasksfilter_id` varchar(40) DEFAULT NULL, `emailfilter_id` varchar(40) DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `owner_id--deviceid` (`owner_id`, `deviceid`) ) /*!40000 ENGINE=INNODB */ /*!40101 CHARACTER SET utf8 COLLATE utf8_general_ci */; CREATE TABLE IF NOT EXISTS `syncroton_folder` ( `id` varchar(40) NOT NULL, `device_id` varchar(40) NOT NULL, `class` varchar(64) NOT NULL, `folderid` varchar(254) NOT NULL, `parentid` varchar(254) DEFAULT NULL, `displayname` varchar(254) NOT NULL, `type` int(11) NOT NULL, `creation_time` datetime NOT NULL, `lastfiltertype` int(11) DEFAULT NULL, `supportedfields` longblob DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `device_id--class--folderid` (`device_id`(40),`class`(40),`folderid`(40)), KEY `folderstates::device_id--devices::id` (`device_id`), CONSTRAINT `folderstates::device_id--devices::id` FOREIGN KEY (`device_id`) REFERENCES `syncroton_device` (`id`) ON DELETE CASCADE ON UPDATE CASCADE ) /*!40000 ENGINE=INNODB */ /*!40101 CHARACTER SET utf8 COLLATE utf8_general_ci */; CREATE TABLE IF NOT EXISTS `syncroton_synckey` ( `id` varchar(40) NOT NULL, `device_id` varchar(40) NOT NULL DEFAULT '', `type` varchar(64) NOT NULL DEFAULT '', `counter` int(11) NOT NULL DEFAULT '0', `lastsync` datetime DEFAULT NULL, `pendingdata` longblob, `client_id_map` longblob DEFAULT NULL, + `extra_data` longblob DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `device_id--type--counter` (`device_id`,`type`,`counter`), CONSTRAINT `syncroton_synckey::device_id--syncroton_device::id` FOREIGN KEY (`device_id`) REFERENCES `syncroton_device` (`id`) ON DELETE CASCADE ON UPDATE CASCADE ) /*!40000 ENGINE=INNODB */ /*!40101 CHARACTER SET utf8 COLLATE utf8_general_ci */; CREATE TABLE IF NOT EXISTS `syncroton_content` ( `id` varchar(40) NOT NULL, `device_id` varchar(40) NOT NULL, `folder_id` varchar(40) NOT NULL, `contentid` varchar(128) NOT NULL, `creation_time` datetime DEFAULT NULL, `creation_synckey` int(11) NOT NULL, `is_deleted` tinyint(1) DEFAULT '0', PRIMARY KEY (`id`), UNIQUE KEY `device_id--folder_id--contentid` (`device_id`(40),`folder_id`(40),`contentid`(128)), KEY `syncroton_contents::device_id` (`device_id`), CONSTRAINT `syncroton_contents::device_id--syncroton_device::id` FOREIGN KEY (`device_id`) REFERENCES `syncroton_device` (`id`) ON DELETE CASCADE ON UPDATE CASCADE ) /*!40000 ENGINE=INNODB */ /*!40101 CHARACTER SET utf8 COLLATE utf8_general_ci */; CREATE TABLE IF NOT EXISTS `syncroton_data` ( `id` varchar(40) NOT NULL, `class` varchar(40) NOT NULL, `folder_id` varchar(40) NOT NULL, `data` longblob, PRIMARY KEY (`id`) ) /*!40000 ENGINE=INNODB */ /*!40101 CHARACTER SET utf8 COLLATE utf8_general_ci */; CREATE TABLE IF NOT EXISTS `syncroton_data_folder` ( `id` varchar(40) NOT NULL, `type` int(11) NOT NULL, `name` varchar(255) NOT NULL, `owner_id` varchar(40) NOT NULL, `parent_id` varchar(40) DEFAULT NULL, PRIMARY KEY (`id`) ) /*!40000 ENGINE=INNODB */ /*!40101 CHARACTER SET utf8 COLLATE utf8_general_ci */; -CREATE TABLE IF NOT EXISTS `syncroton_modseq` ( - `device_id` varchar(40) NOT NULL, - `folder_id` varchar(40) NOT NULL, - `synctime` datetime NOT NULL, - `data` longblob, - PRIMARY KEY (`device_id`,`folder_id`,`synctime`), - KEY `syncroton_modseq::device_id` (`device_id`), - CONSTRAINT `syncroton_modseq::device_id--syncroton_device::id` FOREIGN KEY (`device_id`) REFERENCES `syncroton_device` (`id`) ON DELETE CASCADE ON UPDATE CASCADE -) /*!40000 ENGINE=INNODB */ /*!40101 CHARACTER SET utf8 COLLATE utf8_general_ci */; - CREATE TABLE IF NOT EXISTS `syncroton_relations_state` ( `device_id` varchar(40) NOT NULL, `folder_id` varchar(40) NOT NULL, `synctime` datetime NOT NULL, `data` longblob, PRIMARY KEY (`device_id`,`folder_id`,`synctime`), KEY `syncroton_relations_state::device_id` (`device_id`), CONSTRAINT `syncroton_relations_state::device_id--syncroton_device::id` FOREIGN KEY (`device_id`) REFERENCES `syncroton_device` (`id`) ON DELETE CASCADE ON UPDATE CASCADE ) /*!40000 ENGINE=INNODB */ /*!40101 CHARACTER SET utf8 COLLATE utf8_general_ci */; -- Roundcube core table should exist if we're using the same database CREATE TABLE IF NOT EXISTS `system` ( `name` varchar(64) NOT NULL, `value` mediumtext, PRIMARY KEY(`name`) ) /*!40000 ENGINE=INNODB */ /*!40101 CHARACTER SET utf8 COLLATE utf8_general_ci */; -INSERT INTO `system` (`name`, `value`) VALUES ('syncroton-version', '2023100500'); +INSERT INTO `system` (`name`, `value`) VALUES ('syncroton-version', '2024031100'); diff --git a/docs/SQL/mysql/2024031100.sql b/docs/SQL/mysql/2024031100.sql new file mode 100644 index 0000000..946f010 --- /dev/null +++ b/docs/SQL/mysql/2024031100.sql @@ -0,0 +1,3 @@ + +ALTER TABLE `syncroton_synckey` ADD `extra_data` longblob DEFAULT NULL; +DROP TABLE `syncroton_modseq`; diff --git a/lib/ext/Syncroton/Command/Sync.php b/lib/ext/Syncroton/Command/Sync.php index 3ede6df..f548160 100644 --- a/lib/ext/Syncroton/Command/Sync.php +++ b/lib/ext/Syncroton/Command/Sync.php @@ -1,1245 +1,1247 @@ */ /** * class to handle ActiveSync Sync command * * @package Syncroton * @subpackage Command */ class Syncroton_Command_Sync extends Syncroton_Command_Wbxml { const STATUS_SUCCESS = 1; const STATUS_PROTOCOL_VERSION_MISMATCH = 2; const STATUS_INVALID_SYNC_KEY = 3; const STATUS_PROTOCOL_ERROR = 4; const STATUS_SERVER_ERROR = 5; const STATUS_ERROR_IN_CLIENT_SERVER_CONVERSION = 6; const STATUS_CONFLICT_MATCHING_THE_CLIENT_AND_SERVER_OBJECT = 7; const STATUS_OBJECT_NOT_FOUND = 8; const STATUS_USER_ACCOUNT_MAYBE_OUT_OF_DISK_SPACE = 9; const STATUS_ERROR_SETTING_NOTIFICATION_GUID = 10; const STATUS_DEVICE_NOT_PROVISIONED_FOR_NOTIFICATIONS = 11; const STATUS_FOLDER_HIERARCHY_HAS_CHANGED = 12; const STATUS_RESEND_FULL_XML = 13; const STATUS_WAIT_INTERVAL_OUT_OF_RANGE = 14; const STATUS_TOO_MANY_COLLECTIONS = 15; const CONFLICT_OVERWRITE_SERVER = 0; const CONFLICT_OVERWRITE_PIM = 1; const MIMESUPPORT_DONT_SEND_MIME = 0; const MIMESUPPORT_SMIME_ONLY = 1; const MIMESUPPORT_SEND_MIME = 2; const BODY_TYPE_PLAIN_TEXT = 1; const BODY_TYPE_HTML = 2; const BODY_TYPE_RTF = 3; const BODY_TYPE_MIME = 4; /** * truncate types */ const TRUNCATE_ALL = 0; const TRUNCATE_4096 = 1; const TRUNCATE_5120 = 2; const TRUNCATE_7168 = 3; const TRUNCATE_10240 = 4; const TRUNCATE_20480 = 5; const TRUNCATE_51200 = 6; const TRUNCATE_102400 = 7; const TRUNCATE_NOTHING = 8; /** * filter types */ const FILTER_NOTHING = 0; const FILTER_1_DAY_BACK = 1; const FILTER_3_DAYS_BACK = 2; const FILTER_1_WEEK_BACK = 3; const FILTER_2_WEEKS_BACK = 4; const FILTER_1_MONTH_BACK = 5; const FILTER_3_MONTHS_BACK = 6; const FILTER_6_MONTHS_BACK = 7; const FILTER_INCOMPLETE = 8; protected $_defaultNameSpace = 'uri:AirSync'; protected $_documentElement = 'Sync'; /** * list of collections * * @var array */ protected $_collections = array(); protected $_modifications = array(); /** * the global WindowSize * * @var integer */ protected $_globalWindowSize; /** * there are more entries than WindowSize available * the MoreAvailable tag hot added to the xml output * * @var boolean */ protected $_moreAvailable = false; protected $_maxWindowSize = 100; protected $_heartbeatInterval = null; /** * process the XML file and add, change, delete or fetches data */ public function handle() { // input xml $requestXML = simplexml_import_dom($this->_mergeSyncRequest($this->_requestBody, $this->_device)); if (! isset($requestXML->Collections)) { $this->_outputDom->documentElement->appendChild( $this->_outputDom->createElementNS('uri:AirSync', 'Status', self::STATUS_RESEND_FULL_XML) ); return $this->_outputDom; } if (isset($requestXML->HeartbeatInterval)) { $intervalDiv = 1; $this->_heartbeatInterval = (int)$requestXML->HeartbeatInterval; } else if (isset($requestXML->Wait)) { $intervalDiv = 60; $this->_heartbeatInterval = (int)$requestXML->Wait * $intervalDiv; } $maxInterval = Syncroton_Registry::getPingInterval(); if ($maxInterval <= 0 || $maxInterval > Syncroton_Server::MAX_HEARTBEAT_INTERVAL) { $maxInterval = Syncroton_Server::MAX_HEARTBEAT_INTERVAL; } if ($this->_heartbeatInterval && $this->_heartbeatInterval > $maxInterval) { $sync = $this->_outputDom->documentElement; $sync->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Status', self::STATUS_WAIT_INTERVAL_OUT_OF_RANGE)); $sync->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Limit', floor($maxInterval/$intervalDiv))); $this->_heartbeatInterval = null; return; } $this->_globalWindowSize = isset($requestXML->WindowSize) ? (int)$requestXML->WindowSize : 100; if (!$this->_globalWindowSize || $this->_globalWindowSize > 512) { $this->_globalWindowSize = 512; } if ($this->_globalWindowSize > $this->_maxWindowSize) { $this->_globalWindowSize = $this->_maxWindowSize; } // load options from lastsynccollection $lastSyncCollection = array('options' => array()); if (!empty($this->_device->lastsynccollection)) { $lastSyncCollection = Zend_Json::decode($this->_device->lastsynccollection); if (!array_key_exists('options', $lastSyncCollection) || !is_array($lastSyncCollection['options'])) { $lastSyncCollection['options'] = array(); } } $maxCollections = Syncroton_Registry::getMaxCollections(); if ($maxCollections && count($requestXML->Collections->Collection) > $maxCollections) { $sync = $this->_outputDom->documentElement; $sync->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Status', self::STATUS_TOO_MANY_COLLECTIONS)); $sync->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Limit', $maxCollections)); return; } $collections = array(); foreach ($requestXML->Collections->Collection as $xmlCollection) { $collectionId = (string)$xmlCollection->CollectionId; $collections[$collectionId] = new Syncroton_Model_SyncCollection($xmlCollection); // do we have to reuse the options from the previous request? if (!isset($xmlCollection->Options) && array_key_exists($collectionId, $lastSyncCollection['options'])) { $collections[$collectionId]->options = $lastSyncCollection['options'][$collectionId]; if ($this->_logger instanceof Zend_Log) $this->_logger->debug(__METHOD__ . '::' . __LINE__ . " restored options to " . print_r($collections[$collectionId]->options, TRUE)); } // store current options for next Sync command request (sticky options) $lastSyncCollection['options'][$collectionId] = $collections[$collectionId]->options; } $this->_device->lastsynccollection = Zend_Json::encode($lastSyncCollection); if ($this->_device->isDirty()) { Syncroton_Registry::getDeviceBackend()->update($this->_device); } foreach ($collections as $collectionData) { // has the folder been synchronised to the device already try { $collectionData->folder = $this->_folderBackend->getFolder($this->_device, $collectionData->collectionId); } catch (Syncroton_Exception_NotFound $senf) { if ($this->_logger instanceof Zend_Log) $this->_logger->warn(__METHOD__ . '::' . __LINE__ . " folder {$collectionData->collectionId} not found"); // trigger INVALID_SYNCKEY instead of OBJECT_NOTFOUND when synckey is higher than 0 // to avoid a syncloop for the iPhone if ($collectionData->syncKey > 0) { $collectionData->folder = new Syncroton_Model_Folder(array( 'deviceId' => $this->_device, 'serverId' => $collectionData->collectionId )); } $this->_collections[$collectionData->collectionId] = $collectionData; continue; } if ($this->_logger instanceof Zend_Log) $this->_logger->info(__METHOD__ . '::' . __LINE__ . " SyncKey is {$collectionData->syncKey} Class: {$collectionData->folder->class} CollectionId: {$collectionData->collectionId}"); // initial synckey if($collectionData->syncKey === 0) { if ($this->_logger instanceof Zend_Log) $this->_logger->info(__METHOD__ . '::' . __LINE__ . " initial client synckey 0 provided"); // reset sync state for this folder $this->_syncStateBackend->resetState($this->_device, $collectionData->folder); $this->_contentStateBackend->resetState($this->_device, $collectionData->folder); $collectionData->syncState = new Syncroton_Model_SyncState(array( 'device_id' => $this->_device, 'counter' => 0, 'type' => $collectionData->folder, 'lastsync' => $this->_syncTimeStamp )); $this->_collections[$collectionData->collectionId] = $collectionData; continue; } $syncKeyReused = $this->_syncStateBackend->haveNext($this->_device, $collectionData->folder, $collectionData->syncKey); if ($syncKeyReused) { if ($this->_logger instanceof Zend_Log) $this->_logger->warn(__METHOD__ . '::' . __LINE__ . " already known synckey {$collectionData->syncKey} provided"); } // check for invalid synckey if(($collectionData->syncState = $this->_syncStateBackend->validate($this->_device, $collectionData->folder, $collectionData->syncKey)) === false) { if ($this->_logger instanceof Zend_Log) $this->_logger->warn(__METHOD__ . '::' . __LINE__ . " invalid synckey {$collectionData->syncKey} provided"); // reset sync state for this folder $this->_syncStateBackend->resetState($this->_device, $collectionData->folder); $this->_contentStateBackend->resetState($this->_device, $collectionData->folder); $this->_collections[$collectionData->collectionId] = $collectionData; continue; } $dataController = Syncroton_Data_Factory::factory($collectionData->folder->class, $this->_device, $this->_syncTimeStamp); switch($collectionData->folder->class) { case Syncroton_Data_Factory::CLASS_CALENDAR: $dataClass = 'Syncroton_Model_Event'; break; case Syncroton_Data_Factory::CLASS_CONTACTS: $dataClass = 'Syncroton_Model_Contact'; break; case Syncroton_Data_Factory::CLASS_EMAIL: $dataClass = 'Syncroton_Model_Email'; break; case Syncroton_Data_Factory::CLASS_NOTES: $dataClass = 'Syncroton_Model_Note'; break; case Syncroton_Data_Factory::CLASS_TASKS: $dataClass = 'Syncroton_Model_Task'; break; default: throw new Syncroton_Exception_UnexpectedValue('invalid class provided'); break; } $clientModifications = array( 'added' => array(), 'changed' => array(), 'deleted' => array(), 'forceAdd' => array(), 'forceChange' => array(), 'toBeFetched' => array(), ); // handle incoming data if($collectionData->hasClientAdds()) { $adds = $collectionData->getClientAdds(); if ($this->_logger instanceof Zend_Log) $this->_logger->info(__METHOD__ . '::' . __LINE__ . " found " . count($adds) . " entries to be added to server"); $clientIdMap = []; if ($syncKeyReused && $collectionData->syncState->clientIdMap) { $clientIdMap = Zend_Json::decode($collectionData->syncState->clientIdMap); } foreach ($adds as $add) { if ($this->_logger instanceof Zend_Log) $this->_logger->debug(__METHOD__ . '::' . __LINE__ . " add entry with clientId " . (string) $add->ClientId); try { if ($this->_logger instanceof Zend_Log) $this->_logger->info(__METHOD__ . '::' . __LINE__ . " adding entry as new"); $clientId = (string)$add->ClientId; // If the sync key was reused, but we don't have a $clientId mapping, // this means the client sent a new item with the same sync_key. if ($syncKeyReused && array_key_exists($clientId, $clientIdMap)) { // We don't normally store the clientId, so if a command with Add's is resent, // we have to look-up the corresponding serverId using a cached clientId => serverId mapping, // otherwise we would duplicate all added items on resend. $serverId = $clientIdMap[$clientId]; $clientModifications['added'][$serverId] = array( 'clientId' => (string)$add->ClientId, 'serverId' => $serverId, 'status' => self::STATUS_SUCCESS, 'contentState' => null ); } else { $serverId = $dataController->createEntry($collectionData->collectionId, new $dataClass($add->ApplicationData)); $clientModifications['added'][$serverId] = array( 'clientId' => (string)$add->ClientId, 'serverId' => $serverId, 'status' => self::STATUS_SUCCESS, 'contentState' => $this->_contentStateBackend->create(new Syncroton_Model_Content(array( 'device_id' => $this->_device, 'folder_id' => $collectionData->folder, 'contentid' => $serverId, 'creation_time' => $this->_syncTimeStamp, 'creation_synckey' => $collectionData->syncKey + 1 ))) ); } } catch (Exception $e) { if ($this->_logger instanceof Zend_Log) $this->_logger->warn(__METHOD__ . '::' . __LINE__ . " failed to add entry " . $e->getMessage()); $clientModifications['added'][] = array( 'clientId' => (string)$add->ClientId, 'status' => self::STATUS_SERVER_ERROR ); } } } // handle changes, but only if not first sync if(!$syncKeyReused && $collectionData->syncKey > 1 && $collectionData->hasClientChanges()) { $changes = $collectionData->getClientChanges(); if ($this->_logger instanceof Zend_Log) $this->_logger->info(__METHOD__ . '::' . __LINE__ . " found " . count($changes) . " entries to be updated on server"); foreach ($changes as $change) { $serverId = (string)$change->ServerId; try { $dataController->updateEntry($collectionData->collectionId, $serverId, new $dataClass($change->ApplicationData)); $clientModifications['changed'][$serverId] = self::STATUS_SUCCESS; } catch (Syncroton_Exception_AccessDenied $e) { $clientModifications['changed'][$serverId] = self::STATUS_CONFLICT_MATCHING_THE_CLIENT_AND_SERVER_OBJECT; $clientModifications['forceChange'][$serverId] = $serverId; } catch (Syncroton_Exception_NotFound $e) { // entry does not exist anymore, will get deleted automaticaly $clientModifications['changed'][$serverId] = self::STATUS_OBJECT_NOT_FOUND; } catch (Exception $e) { if ($this->_logger instanceof Zend_Log) $this->_logger->warn(__METHOD__ . '::' . __LINE__ . " failed to update entry " . $e); // something went wrong while trying to update the entry $clientModifications['changed'][$serverId] = self::STATUS_SERVER_ERROR; } } } // handle deletes, but only if not first sync if(!$syncKeyReused && $collectionData->hasClientDeletes()) { $deletes = $collectionData->getClientDeletes(); if ($this->_logger instanceof Zend_Log) $this->_logger->info(__METHOD__ . '::' . __LINE__ . " found " . count($deletes) . " entries to be deleted on server"); foreach ($deletes as $delete) { $serverId = (string)$delete->ServerId; try { // check if we have sent this entry to the phone $state = $this->_contentStateBackend->getContentState($this->_device, $collectionData->folder, $serverId); try { $dataController->deleteEntry($collectionData->collectionId, $serverId, $collectionData); } catch(Syncroton_Exception_NotFound $e) { if ($this->_logger instanceof Zend_Log) $this->_logger->crit(__METHOD__ . '::' . __LINE__ . ' tried to delete entry ' . $serverId . ' but entry was not found'); } catch (Syncroton_Exception $e) { if ($this->_logger instanceof Zend_Log) $this->_logger->info(__METHOD__ . '::' . __LINE__ . ' tried to delete entry ' . $serverId . ' but a error occured: ' . $e->getMessage()); $clientModifications['forceAdd'][$serverId] = $serverId; } $this->_contentStateBackend->delete($state); } catch (Syncroton_Exception_NotFound $senf) { if ($this->_logger instanceof Zend_Log) $this->_logger->info(__METHOD__ . '::' . __LINE__ . ' ' . $serverId . ' should have been removed from client already'); // should we send a special status??? //$collectionData->deleted[$serverId] = self::STATUS_SUCCESS; } $clientModifications['deleted'][$serverId] = self::STATUS_SUCCESS; } } // handle fetches, but only if not first sync if($collectionData->syncKey > 1 && $collectionData->hasClientFetches()) { // the default value for GetChanges is 1. If the phone don't want the changes it must set GetChanges to 0 // some prevoius versions of iOS did not set GetChanges to 0 for fetches. Let's enforce getChanges to false here. $collectionData->getChanges = false; $fetches = $collectionData->getClientFetches(); if ($this->_logger instanceof Zend_Log) $this->_logger->info(__METHOD__ . '::' . __LINE__ . " found " . count($fetches) . " entries to be fetched from server"); $toBeFetched = array(); foreach ($fetches as $fetch) { $serverId = (string)$fetch->ServerId; $toBeFetched[$serverId] = $serverId; } $collectionData->toBeFetched = $toBeFetched; } $this->_collections[$collectionData->collectionId] = $collectionData; $this->_modifications[$collectionData->collectionId] = $clientModifications; } } /** * (non-PHPdoc) * @see Syncroton_Command_Wbxml::getResponse() */ public function getResponse() { $sync = $this->_outputDom->documentElement; $collections = $this->_outputDom->createElementNS('uri:AirSync', 'Collections'); $totalChanges = 0; // Detect devices that do not support empty Sync reponse $emptySyncSupported = !preg_match('/(meego|nokian800)/i', $this->_device->useragent); // continue only if there are changes or no time is left if ($this->_heartbeatInterval > 0) { $intervalStart = time(); $sleepCallback = Syncroton_Registry::getSleepCallback(); $wakeupCallback = Syncroton_Registry::getWakeupCallback(); do { // take a break to save battery lifetime $sleepCallback(); sleep(Syncroton_Registry::getPingTimeout()); // make sure the connection is still alive, abort otherwise if (connection_aborted()) { if ($this->_logger instanceof Zend_Log) $this->_logger->debug(__METHOD__ . '::' . __LINE__ . " Exiting on aborted connection"); exit; } $wakeupCallback(); $now = new DateTime('now', new DateTimeZone('UTC')); foreach($this->_collections as $collectionData) { // continue immediately if folder does not exist if (! ($collectionData->folder instanceof Syncroton_Model_IFolder)) { break 2; // countinue immediately if syncstate is invalid } elseif (! ($collectionData->syncState instanceof Syncroton_Model_ISyncState)) { break 2; } else { if ($collectionData->getChanges !== true) { continue; } try { // just check if the folder still exists $this->_folderBackend->get($collectionData->folder); } catch (Syncroton_Exception_NotFound $senf) { if ($this->_logger instanceof Zend_Log) $this->_logger->debug(__METHOD__ . '::' . __LINE__ . " collection does not exist anymore: " . $collectionData->collectionId); $collectionData->getChanges = false; // make sure this is the last while loop // no break 2 here, as we like to check the other folders too $intervalStart -= $this->_heartbeatInterval; } // check that the syncstate still exists and is still valid try { $syncState = $this->_syncStateBackend->getSyncState($this->_device, $collectionData->folder); // another process synchronized data of this folder already. let's skip it if ($syncState->id !== $collectionData->syncState->id) { if ($this->_logger instanceof Zend_Log) $this->_logger->debug(__METHOD__ . '::' . __LINE__ . " syncstate changed during heartbeat interval for collection: " . $collectionData->folder->serverId); $collectionData->getChanges = false; // make sure this is the last while loop // no break 2 here, as we like to check the other folders too $intervalStart -= $this->_heartbeatInterval; } } catch (Syncroton_Exception_NotFound $senf) { if ($this->_logger instanceof Zend_Log) $this->_logger->debug(__METHOD__ . '::' . __LINE__ . " no syncstate found anymore for collection: " . $collectionData->folder->serverId); $collectionData->syncState = null; // make sure this is the last while loop // no break 2 here, as we like to check the other folders too $intervalStart -= $this->_heartbeatInterval; } // safe battery time by skipping folders which got synchronied less than Syncroton_Command_Ping::$quietTime seconds ago if ( ! $collectionData->syncState instanceof Syncroton_Model_SyncState || ($now->getTimestamp() - $collectionData->syncState->lastsync->getTimestamp()) < Syncroton_Registry::getQuietTime()) { continue; } $dataController = Syncroton_Data_Factory::factory($collectionData->folder->class , $this->_device, $this->_syncTimeStamp); // countinue immediately if there are any changes available if($dataController->hasChanges($this->_contentStateBackend, $collectionData->folder, $collectionData->syncState)) { break 2; } } } // See: http://www.tine20.org/forum/viewtopic.php?f=12&t=12146 // // break if there are less than PingTimeout + 10 seconds left for the next loop // otherwise the response will be returned after the client has finished his Ping // request already maybe } while (Syncroton_Server::validateSession() && time() - $intervalStart < $this->_heartbeatInterval - (Syncroton_Registry::getPingTimeout() + 10)); } // First check for folders hierarchy changes foreach ($this->_collections as $collectionData) { if (! ($collectionData->folder instanceof Syncroton_Model_IFolder)) { if ($this->_logger instanceof Zend_Log) $this->_logger->warn(__METHOD__ . '::' . __LINE__ . " Detected a folder hierarchy change on {$collectionData->collectionId}."); $sync->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Status', self::STATUS_FOLDER_HIERARCHY_HAS_CHANGED)); return $this->_outputDom; } } foreach($this->_collections as $collectionData) { $collectionChanges = 0; /** * keep track of entries added on server side */ $newContentStates = array(); /** * keep track of entries deleted on server side */ $deletedContentStates = array(); // invalid synckey provided if (! ($collectionData->syncState instanceof Syncroton_Model_ISyncState)) { // set synckey to 0 $collection = $collections->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Collection')); $collection->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'SyncKey', 0)); $collection->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'CollectionId', $collectionData->collectionId)); $collection->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Status', self::STATUS_INVALID_SYNC_KEY)); // initial sync } elseif ($collectionData->syncState->counter === 0) { $collectionData->syncState->counter++; // initial sync // send back a new SyncKey only $collection = $collections->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Collection')); if (!empty($collectionData->folder->class)) { $collection->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Class', $collectionData->folder->class)); } $collection->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'SyncKey', $collectionData->syncState->counter)); $collection->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'CollectionId', $collectionData->collectionId)); $collection->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Status', self::STATUS_SUCCESS)); } else { $dataController = Syncroton_Data_Factory::factory($collectionData->folder->class , $this->_device, $this->_syncTimeStamp); $clientModifications = $this->_modifications[$collectionData->collectionId]; $serverModifications = array( 'added' => array(), 'changed' => array(), 'deleted' => array(), ); $status = self::STATUS_SUCCESS; $hasChanges = 0; if($collectionData->getChanges === true) { // continue sync session? if(is_array($collectionData->syncState->pendingdata)) { if ($this->_logger instanceof Zend_Log) $this->_logger->info(__METHOD__ . '::' . __LINE__ . " restored from sync state "); $serverModifications = $collectionData->syncState->pendingdata; } else { try { $hasChanges = $dataController->hasChanges($this->_contentStateBackend, $collectionData->folder, $collectionData->syncState); } catch (Syncroton_Exception_NotFound $e) { if ($this->_logger instanceof Zend_Log) $this->_logger->debug(__METHOD__ . '::' . __LINE__ . " Folder changes checking failed (not found): " . $e->getTraceAsString()); $status = self::STATUS_FOLDER_HIERARCHY_HAS_CHANGED; } catch (Exception $e) { if ($this->_logger instanceof Zend_Log) $this->_logger->crit(__METHOD__ . '::' . __LINE__ . " Folder changes checking failed: " . $e->getMessage()); // Prevent from removing client entries when getServerEntries() fails // @todo: should we break the loop here? $status = self::STATUS_SERVER_ERROR; } } if ($hasChanges) { // update _syncTimeStamp as $dataController->hasChanges might have spent some time $this->_syncTimeStamp = new DateTime('now', new DateTimeZone('UTC')); try { // fetch entries added since last sync $allClientEntries = $this->_contentStateBackend->getFolderState( $this->_device, $collectionData->folder, $collectionData->syncState->counter ); // fetch entries changed since last sync $allChangedEntries = $dataController->getChangedEntries( $collectionData->collectionId, - $collectionData->syncState->lastsync, - $this->_syncTimeStamp, + $collectionData->syncState, $collectionData->options['filterType'] ); // fetch all entries $allServerEntries = $dataController->getServerEntries( $collectionData->collectionId, $collectionData->options['filterType'] ); // add entries $serverDiff = array_diff($allServerEntries, $allClientEntries); // add entries which produced problems during delete from client $serverModifications['added'] = $clientModifications['forceAdd']; // add entries not yet sent to client $serverModifications['added'] = array_unique(array_merge($serverModifications['added'], $serverDiff)); // @todo still needed? foreach($serverModifications['added'] as $id => $serverId) { // skip entries added by client during this sync session if(isset($clientModifications['added'][$serverId]) && !isset($clientModifications['forceAdd'][$serverId])) { if ($this->_logger instanceof Zend_Log) $this->_logger->info(__METHOD__ . '::' . __LINE__ . " skipped added entry: " . $serverId); unset($serverModifications['added'][$id]); } } // entries to be deleted $serverModifications['deleted'] = array_diff($allClientEntries, $allServerEntries); // entries changed since last sync $serverModifications['changed'] = array_merge($allChangedEntries, $clientModifications['forceChange']); foreach($serverModifications['changed'] as $id => $serverId) { // skip entry, if it got changed by client during current sync if(isset($clientModifications['changed'][$serverId]) && !isset($clientModifications['forceChange'][$serverId])) { if ($this->_logger instanceof Zend_Log) $this->_logger->info(__METHOD__ . '::' . __LINE__ . " skipped changed entry: " . $serverId); unset($serverModifications['changed'][$id]); } // skip entry, make sure we don't sent entries already added by client in this request else if (isset($clientModifications['added'][$serverId]) && !isset($clientModifications['forceAdd'][$serverId])) { if ($this->_logger instanceof Zend_Log) $this->_logger->info(__METHOD__ . '::' . __LINE__ . " skipped change for added entry: " . $serverId); unset($serverModifications['changed'][$id]); } } // entries comeing in scope are already in $serverModifications['added'] and do not need to // be send with $serverCanges $serverModifications['changed'] = array_diff($serverModifications['changed'], $serverModifications['added']); } catch (Exception $e) { if ($this->_logger instanceof Zend_Log) $this->_logger->crit(__METHOD__ . '::' . __LINE__ . " Folder state checking failed: " . $e->getMessage()); if ($this->_logger instanceof Zend_Log) $this->_logger->debug(__METHOD__ . '::' . __LINE__ . " Folder state checking failed: " . $e->getTraceAsString()); // Prevent from removing client entries when getServerEntries() fails // @todo: should we break the loop here? $status = self::STATUS_SERVER_ERROR; } if ($this->_logger instanceof Zend_Log) $this->_logger->info(__METHOD__ . '::' . __LINE__ . " found (added/changed/deleted) " . count($serverModifications['added']) . '/' . count($serverModifications['changed']) . '/' . count($serverModifications['deleted']) . ' entries for sync from server to client'); } } // collection header $collection = $this->_outputDom->createElementNS('uri:AirSync', 'Collection'); if (!empty($collectionData->folder->class)) { $collection->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Class', $collectionData->folder->class)); } $syncKeyElement = $collection->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'SyncKey')); $collection->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'CollectionId', $collectionData->collectionId)); $collection->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Status', $status)); $responses = $this->_outputDom->createElementNS('uri:AirSync', 'Responses'); // send reponse for newly added entries if(!empty($clientModifications['added'])) { foreach($clientModifications['added'] as $entryData) { $add = $responses->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Add')); $add->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'ClientId', $entryData['clientId'])); // we have no serverId if the add failed if(isset($entryData['serverId'])) { $add->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'ServerId', $entryData['serverId'])); } $add->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Status', $entryData['status'])); } } // send reponse for changed entries if(!empty($clientModifications['changed'])) { foreach($clientModifications['changed'] as $serverId => $status) { if ($status !== Syncroton_Command_Sync::STATUS_SUCCESS) { $change = $responses->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Change')); $change->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'ServerId', $serverId)); $change->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Status', $status)); } } } // send response for to be fetched entries if(!empty($collectionData->toBeFetched)) { // unset all truncation settings as entries are not allowed to be truncated during fetch $fetchCollectionData = clone $collectionData; // unset truncationSize if (isset($fetchCollectionData->options['bodyPreferences']) && is_array($fetchCollectionData->options['bodyPreferences'])) { foreach($fetchCollectionData->options['bodyPreferences'] as $key => $bodyPreference) { unset($fetchCollectionData->options['bodyPreferences'][$key]['truncationSize']); } } $fetchCollectionData->options['mimeTruncation'] = Syncroton_Command_Sync::TRUNCATE_NOTHING; foreach($collectionData->toBeFetched as $serverId) { $fetch = $responses->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Fetch')); $fetch->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'ServerId', $serverId)); try { $applicationData = $this->_outputDom->createElementNS('uri:AirSync', 'ApplicationData'); $dataController ->getEntry($fetchCollectionData, $serverId) ->appendXML($applicationData, $this->_device); $fetch->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Status', self::STATUS_SUCCESS)); $fetch->appendChild($applicationData); } catch (Exception $e) { if ($this->_logger instanceof Zend_Log) $this->_logger->warn(__METHOD__ . '::' . __LINE__ . " unable to convert entry to xml: " . $e->getMessage()); if ($this->_logger instanceof Zend_Log) $this->_logger->debug(__METHOD__ . '::' . __LINE__ . " unable to convert entry to xml: " . $e->getTraceAsString()); $fetch->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Status', self::STATUS_OBJECT_NOT_FOUND)); } } } if ($responses->hasChildNodes() === true) { $collection->appendChild($responses); } $commands = $this->_outputDom->createElementNS('uri:AirSync', 'Commands'); foreach($serverModifications['added'] as $id => $serverId) { if($collectionChanges == $collectionData->windowSize || $totalChanges + $collectionChanges >= $this->_globalWindowSize) { break; } #/** # * somewhere is a problem in the logic for handling moreAvailable # * # * it can happen, that we have a contentstate (which means we sent the entry to the client # * and that this entry is yet in $collectionData->syncState->pendingdata['serverAdds'] # * I have no idea how this can happen, but the next lines of code work around this problem # */ #try { # $this->_contentStateBackend->getContentState($this->_device, $collectionData->folder, $serverId); # # if ($this->_logger instanceof Zend_Log) # $this->_logger->info(__METHOD__ . '::' . __LINE__ . " skipped an entry($serverId) which is already on the client"); # # unset($serverModifications['added'][$id]); # continue; # #} catch (Syncroton_Exception_NotFound $senf) { # // do nothing => content state should not exist yet #} try { $add = $this->_outputDom->createElementNS('uri:AirSync', 'Add'); $add->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'ServerId', $serverId)); $applicationData = $add->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'ApplicationData')); $dataController ->getEntry($collectionData, $serverId) ->appendXML($applicationData, $this->_device); $commands->appendChild($add); $newContentStates[] = new Syncroton_Model_Content(array( 'device_id' => $this->_device, 'folder_id' => $collectionData->folder, 'contentid' => $serverId, 'creation_time' => $this->_syncTimeStamp, 'creation_synckey' => $collectionData->syncState->counter + 1 )); $collectionChanges++; } catch (Syncroton_Exception_MemoryExhausted $seme) { // continue to next entry, as there is not enough memory left for the current entry // this will lead to MoreAvailable at the end and the entry will be synced during the next Sync command if ($this->_logger instanceof Zend_Log) $this->_logger->warn(__METHOD__ . '::' . __LINE__ . " memory exhausted for entry: " . $serverId); break; } catch (Exception $e) { if ($this->_logger instanceof Zend_Log) $this->_logger->warn(__METHOD__ . '::' . __LINE__ . " unable to convert entry to xml: " . $e->getMessage()); if ($this->_logger instanceof Zend_Log) $this->_logger->debug(__METHOD__ . '::' . __LINE__ . " unable to convert entry to xml: " . $e->getTraceAsString()); // We bump collectionChanges anyways to make sure the windowSize still applies. $collectionChanges++; } // mark as sent to the client, even the conversion to xml might have failed unset($serverModifications['added'][$id]); } /** * process entries changed on server side */ foreach($serverModifications['changed'] as $id => $serverId) { if($collectionChanges == $collectionData->windowSize || $totalChanges + $collectionChanges >= $this->_globalWindowSize) { break; } try { $change = $this->_outputDom->createElementNS('uri:AirSync', 'Change'); $change->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'ServerId', $serverId)); $applicationData = $change->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'ApplicationData')); $dataController ->getEntry($collectionData, $serverId) ->appendXML($applicationData, $this->_device); $commands->appendChild($change); $collectionChanges++; } catch (Syncroton_Exception_MemoryExhausted $seme) { // continue to next entry, as there is not enough memory left for the current entry // this will lead to MoreAvailable at the end and the entry will be synced during the next Sync command if ($this->_logger instanceof Zend_Log) $this->_logger->warn(__METHOD__ . '::' . __LINE__ . " memory exhausted for entry: " . $serverId); break; } catch (Exception $e) { if ($this->_logger instanceof Zend_Log) $this->_logger->warn(__METHOD__ . '::' . __LINE__ . " unable to convert entry to xml: " . $e->getMessage()); // We bump collectionChanges anyways to make sure the windowSize still applies. $collectionChanges++; } unset($serverModifications['changed'][$id]); } foreach($serverModifications['deleted'] as $id => $serverId) { if($collectionChanges == $collectionData->windowSize || $totalChanges + $collectionChanges >= $this->_globalWindowSize) { break; } try { // check if we have sent this entry to the phone $state = $this->_contentStateBackend->getContentState($this->_device, $collectionData->folder, $serverId); $delete = $this->_outputDom->createElementNS('uri:AirSync', 'Delete'); $delete->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'ServerId', $serverId)); $deletedContentStates[] = $state; $commands->appendChild($delete); $collectionChanges++; } catch (Exception $e) { if ($this->_logger instanceof Zend_Log) $this->_logger->warn(__METHOD__ . '::' . __LINE__ . " unable to convert entry to xml: " . $e->getMessage()); // We bump collectionChanges anyways to make sure the windowSize still applies. $collectionChanges++; } unset($serverModifications['deleted'][$id]); } $countOfPendingChanges = (count($serverModifications['added']) + count($serverModifications['changed']) + count($serverModifications['deleted'])); if ($countOfPendingChanges > 0) { if ($this->_logger instanceof Zend_Log) $this->_logger->info(__METHOD__ . '::' . __LINE__ . " there are ". $countOfPendingChanges . " more items available"); $collection->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'MoreAvailable')); } else { if ($this->_logger instanceof Zend_Log) $this->_logger->info(__METHOD__ . '::' . __LINE__ . " there are no more items available"); $serverModifications = null; } if ($commands->hasChildNodes() === true) { $collection->appendChild($commands); } $totalChanges += $collectionChanges; // If the client resent an old sync-key, we should still respond with the latest sync key if (isset($collectionData->syncState->counterNext)) { //TODO we're not resending the changes in between, but I'm not sure we have to. $collectionData->syncState->counter = $collectionData->syncState->counterNext; } // increase SyncKey if needed if (( // sent the clients updates... ? !empty($clientModifications['added']) || !empty($clientModifications['changed']) || !empty($clientModifications['deleted']) ) || ( // is the server sending updates to the client... ? $commands->hasChildNodes() === true ) || ( // changed the pending data... ? $collectionData->syncState->pendingdata != $serverModifications ) ) { // ...then increase SyncKey $collectionData->syncState->counter++; } $syncKeyElement->appendChild($this->_outputDom->createTextNode($collectionData->syncState->counter)); if ($this->_logger instanceof Zend_Log) $this->_logger->info(__METHOD__ . '::' . __LINE__ . " current synckey is ". $collectionData->syncState->counter); if (!$emptySyncSupported || $collection->childNodes->length > 4 || $collectionData->syncState->counter != $collectionData->syncKey) { $collections->appendChild($collection); } + + //Store next + $collectionData->syncState->extraData = $dataController->getExtraData($collectionData->folder); } if (isset($collectionData->syncState) && $collectionData->syncState instanceof Syncroton_Model_ISyncState && $collectionData->syncState->counter != $collectionData->syncKey ) { if ($this->_logger instanceof Zend_Log) $this->_logger->debug(__METHOD__ . '::' . __LINE__ . " update syncState for collection: " . $collectionData->collectionId); // store pending data in sync state when needed if(isset($countOfPendingChanges) && $countOfPendingChanges > 0) { $collectionData->syncState->pendingdata = array( 'added' => (array)$serverModifications['added'], 'changed' => (array)$serverModifications['changed'], 'deleted' => (array)$serverModifications['deleted'] ); } else { $collectionData->syncState->pendingdata = null; } $collectionData->syncState->lastsync = clone $this->_syncTimeStamp; // increment sync timestamp by 1 second $collectionData->syncState->lastsync->modify('+1 sec'); if (!empty($clientModifications['added'])) { // Store a client id mapping in case we encounter a reused sync_key in a future request. $newClientIdMap = []; foreach($clientModifications['added'] as $entryData) { // No serverId if we failed to add if ($entryData['status'] == self::STATUS_SUCCESS) { $newClientIdMap[$entryData['clientId']] = $entryData['serverId']; } } $collectionData->syncState->clientIdMap = Zend_Json::encode($newClientIdMap); } //Retry in case of deadlock $retryCounter = 0; while (True) { try { $transactionId = Syncroton_Registry::getTransactionManager()->startTransaction(Syncroton_Registry::getDatabase()); // store new synckey $this->_syncStateBackend->create($collectionData->syncState, true); // store contentstates for new entries added to client foreach($newContentStates as $state) { try { //This can happen if we rerun a previous sync-key $state = $this->_contentStateBackend->getContentState($state->device_id, $state->folder_id, $state->contentid); $this->_contentStateBackend->update($state); } catch(Exception $zdse) { $this->_contentStateBackend->create($state); } } // remove contentstates for entries to be deleted on client foreach($deletedContentStates as $state) { $this->_contentStateBackend->delete($state); } Syncroton_Registry::getTransactionManager()->commitTransaction($transactionId); break; } catch (Syncroton_Exception_DeadlockDetected $zdse) { $retryCounter++; if ($retryCounter > 60) { if ($this->_logger instanceof Zend_Log) $this->_logger->warn(__METHOD__ . '::' . __LINE__ . ' exception while storing new synckey. Aborting after 5 retries.'); // something went wrong // maybe another parallel request added a new synckey // we must remove data added from client if (!empty($clientModifications['added'])) { foreach ($clientModifications['added'] as $added) { $this->_contentStateBackend->delete($added['contentState']); $dataController->deleteEntry($collectionData->collectionId, $added['serverId']); } } Syncroton_Registry::getTransactionManager()->rollBack(); throw $zdse; } Syncroton_Registry::getTransactionManager()->rollBack(); // Give the other transactions some time before we try again sleep(1); if ($this->_logger instanceof Zend_Log) $this->_logger->warn(__METHOD__ . '::' . __LINE__ . ' error during transaction, trying again.'); } catch (Exception $zdse) { if ($this->_logger instanceof Zend_Log) $this->_logger->warn(__METHOD__ . '::' . __LINE__ . ' exception while storing new synckey.'); // something went wrong // maybe another parallel request added a new synckey // we must remove data added from client if (!empty($clientModifications['added'])) { foreach ($clientModifications['added'] as $added) { $this->_contentStateBackend->delete($added['contentState']); $dataController->deleteEntry($collectionData->collectionId, $added['serverId']); } } Syncroton_Registry::getTransactionManager()->rollBack(); throw $zdse; } } } // store current filter type try { $folderState = $this->_folderBackend->get($collectionData->folder); $folderState->lastfiltertype = $collectionData->options['filterType']; if ($folderState->isDirty()) { $this->_folderBackend->update($folderState); } } catch (Syncroton_Exception_NotFound $senf) { // failed to get folderstate => should not happen but is also no problem in this state if ($this->_logger instanceof Zend_Log) $this->_logger->warn(__METHOD__ . '::' . __LINE__ . ' failed to get folder state for: ' . $collectionData->collectionId); } } if ($collections->hasChildNodes() === true) { $sync->appendChild($collections); } if ($sync->hasChildNodes()) { return $this->_outputDom; } return null; } /** * remove Commands and Supported from collections XML tree * * @param DOMDocument $document * @return DOMDocument */ protected function _cleanUpXML(DOMDocument $document) { $cleanedDocument = clone $document; $xpath = new DomXPath($cleanedDocument); $xpath->registerNamespace('AirSync', 'uri:AirSync'); $collections = $xpath->query("//AirSync:Sync/AirSync:Collections/AirSync:Collection"); // remove Commands and Supported elements foreach ($collections as $collection) { foreach (array('Commands', 'Supported') as $element) { $childrenToRemove = $collection->getElementsByTagName($element); foreach ($childrenToRemove as $childToRemove) { $collection->removeChild($childToRemove); } } } return $cleanedDocument; } /** * merge a partial XML document with the XML document from the previous request * * @param DOMDocument|null $requestBody * @return SimpleXMLElement */ protected function _mergeSyncRequest($requestBody, Syncroton_Model_Device $device) { $lastSyncCollection = array(); if (!empty($device->lastsynccollection)) { $lastSyncCollection = Zend_Json::decode($device->lastsynccollection); if (!empty($lastSyncCollection['lastXML'])) { $lastXML = new DOMDocument(); $lastXML->loadXML($lastSyncCollection['lastXML']); } } if (! $requestBody instanceof DOMDocument && isset($lastXML) && $lastXML instanceof DOMDocument) { $requestBody = $lastXML; } elseif (! $requestBody instanceof DOMDocument) { throw new Syncroton_Exception_UnexpectedValue('no xml body found'); } if ($requestBody->getElementsByTagName('Partial')->length > 0) { $partialBody = clone $requestBody; $requestBody = $lastXML; $xpath = new DomXPath($requestBody); $xpath->registerNamespace('AirSync', 'uri:AirSync'); foreach ($partialBody->documentElement->childNodes as $child) { if (! $child instanceof DOMElement) { continue; } if ($child->tagName == 'Partial') { continue; } if ($child->tagName == 'Collections') { foreach ($child->getElementsByTagName('Collection') as $updatedCollection) { $collectionId = $updatedCollection->getElementsByTagName('CollectionId')->item(0)->nodeValue; $existingCollections = $xpath->query("//AirSync:Sync/AirSync:Collections/AirSync:Collection[AirSync:CollectionId='$collectionId']"); if ($existingCollections->length > 0) { $existingCollection = $existingCollections->item(0); foreach ($updatedCollection->childNodes as $updatedCollectionChild) { if (! $updatedCollectionChild instanceof DOMElement) { continue; } $duplicateChild = $existingCollection->getElementsByTagName($updatedCollectionChild->tagName); if ($duplicateChild->length > 0) { $existingCollection->replaceChild($requestBody->importNode($updatedCollectionChild, TRUE), $duplicateChild->item(0)); } else { $existingCollection->appendChild($requestBody->importNode($updatedCollectionChild, TRUE)); } } } else { $importedCollection = $requestBody->importNode($updatedCollection, TRUE); } } } else { $duplicateChild = $xpath->query("//AirSync:Sync/AirSync:{$child->tagName}"); if ($duplicateChild->length > 0) { $requestBody->documentElement->replaceChild($requestBody->importNode($child, TRUE), $duplicateChild->item(0)); } else { $requestBody->documentElement->appendChild($requestBody->importNode($child, TRUE)); } } } } $lastSyncCollection['lastXML'] = $this->_cleanUpXML($requestBody)->saveXML(); $device->lastsynccollection = Zend_Json::encode($lastSyncCollection); return $requestBody; } } diff --git a/lib/ext/Syncroton/Data/AData.php b/lib/ext/Syncroton/Data/AData.php index 7867237..b38a56c 100644 --- a/lib/ext/Syncroton/Data/AData.php +++ b/lib/ext/Syncroton/Data/AData.php @@ -1,366 +1,368 @@ */ /** * class to handle ActiveSync Sync command * * @package Syncroton * @subpackage Data */ abstract class Syncroton_Data_AData implements Syncroton_Data_IData { const LONGID_DELIMITER = "\xe2\x87\x94"; # UTF-8 character ⇔ /** * @var DateTime */ protected $_timeStamp; /** * the constructor * * @param Syncroton_Model_IDevice $_device * @param DateTime $_timeStamp */ public function __construct(Syncroton_Model_IDevice $_device, DateTime $_timeStamp) { $this->_device = $_device; $this->_timeStamp = $_timeStamp; $this->_db = Syncroton_Registry::getDatabase(); $this->_tablePrefix = 'Syncroton_'; $this->_ownerId = '1234'; } /** * return one folder identified by id * * @param string $id * @throws Syncroton_Exception_NotFound * @return Syncroton_Model_Folder */ public function getFolder($id) { $select = $this->_db->select() ->from($this->_tablePrefix . 'data_folder') ->where('owner_id = ?', $this->_ownerId) ->where('id = ?', $id); $stmt = $this->_db->query($select); $folder = $stmt->fetch(); $stmt = null; # see https://bugs.php.net/bug.php?id=44081 if ($folder === false) { throw new Syncroton_Exception_NotFound("folder $id not found"); } return new Syncroton_Model_Folder(array( 'serverId' => $folder['id'], 'displayName' => $folder['name'], 'type' => $folder['type'], 'parentId' => !empty($folder['parent_id']) ? $folder['parent_id'] : null )); } /** * (non-PHPdoc) * @see Syncroton_Data_IData::createFolder() */ public function createFolder(Syncroton_Model_IFolder $folder) { if (!in_array($folder->type, $this->_supportedFolderTypes)) { throw new Syncroton_Exception_UnexpectedValue(); } $id = !empty($folder->serverId) ? $folder->serverId : sha1(mt_rand(). microtime()); $this->_db->insert($this->_tablePrefix . 'data_folder', array( 'id' => $id, 'type' => $folder->type, 'name' => $folder->displayName, 'owner_id' => $this->_ownerId, 'parent_id' => $folder->parentId, 'creation_time' => $this->_timeStamp->format("Y-m-d H:i:s") )); return $this->getFolder($id); } /** * (non-PHPdoc) * @see Syncroton_Data_IData::createEntry() */ public function createEntry($_folderId, Syncroton_Model_IEntry $_entry) { $id = sha1(mt_rand(). microtime()); $this->_db->insert($this->_tablePrefix . 'data', array( 'id' => $id, 'class' => get_class($_entry), 'folder_id' => $_folderId, 'creation_time' => $this->_timeStamp->format("Y-m-d H:i:s"), 'data' => serialize($_entry) )); return $id; } /** * (non-PHPdoc) * @see Syncroton_Data_IData::deleteEntry() */ public function deleteEntry($_folderId, $_serverId, $_collectionData) { $folderId = $_folderId instanceof Syncroton_Model_IFolder ? $_folderId->serverId : $_folderId; $result = $this->_db->delete($this->_tablePrefix . 'data', array('id = ?' => $_serverId)); return (bool) $result; } /** * (non-PHPdoc) * @see Syncroton_Data_IData::deleteFolder() */ public function deleteFolder($_folderId) { $folderId = $_folderId instanceof Syncroton_Model_IFolder ? $_folderId->serverId : $_folderId; $result = $this->_db->delete($this->_tablePrefix . 'data', array('folder_id = ?' => $folderId)); $result = $this->_db->delete($this->_tablePrefix . 'data_folder', array('id = ?' => $folderId)); return (bool) $result; } /** * (non-PHPdoc) * @see Syncroton_Data_IData::emptyFolderContents() */ public function emptyFolderContents($folderId, $options) { return true; } /** * (non-PHPdoc) * @see Syncroton_Data_IData::getAllFolders() */ public function getAllFolders() { $select = $this->_db->select() ->from($this->_tablePrefix . 'data_folder') ->where('type IN (?)', $this->_supportedFolderTypes) ->where('owner_id = ?', $this->_ownerId); $stmt = $this->_db->query($select); $folders = $stmt->fetchAll(); $stmt = null; # see https://bugs.php.net/bug.php?id=44081 $result = array(); foreach ((array) $folders as $folder) { $result[$folder['id']] = new Syncroton_Model_Folder(array( 'serverId' => $folder['id'], 'displayName' => $folder['name'], 'type' => $folder['type'], 'parentId' => $folder['parent_id'] )); } return $result; } /** * (non-PHPdoc) * @see Syncroton_Data_IData::getChangedEntries() */ - public function getChangedEntries($_folderId, DateTime $_startTimeStamp, DateTime $_endTimeStamp = NULL, $filterType = NULL) + public function getChangedEntries($_folderId, Syncroton_Model_ISyncState $syncState, $filterType = NULL) { + $_startTimeStamp = $syncState->lastSync; + $_endTimeStamp = null; $folderId = $_folderId instanceof Syncroton_Model_IFolder ? $_folderId->id : $_folderId; $select = $this->_db->select() ->from($this->_tablePrefix . 'data', array('id')) ->where('folder_id = ?', $_folderId) ->where('last_modified_time > ?', $_startTimeStamp->format("Y-m-d H:i:s")); if ($_endTimeStamp instanceof DateTime) { $select->where('last_modified_time < ?', $_endTimeStamp->format("Y-m-d H:i:s")); } $ids = array(); $stmt = $this->_db->query($select); while ($id = $stmt->fetchColumn()) { $ids[] = $id; } return $ids; } /** * retrieve folders which were modified since last sync * * @param DateTime $startTimeStamp * @param DateTime $endTimeStamp * @return array list of Syncroton_Model_Folder */ public function getChangedFolders(DateTime $startTimeStamp, DateTime $endTimeStamp) { $select = $this->_db->select() ->from($this->_tablePrefix . 'data_folder') ->where('type IN (?)', $this->_supportedFolderTypes) ->where('owner_id = ?', $this->_ownerId) ->where('last_modified_time > ?', $startTimeStamp->format('Y-m-d H:i:s')) ->where('last_modified_time <= ?', $endTimeStamp->format('Y-m-d H:i:s')); $stmt = $this->_db->query($select); $folders = $stmt->fetchAll(); $stmt = null; # see https://bugs.php.net/bug.php?id=44081 $result = array(); foreach ((array) $folders as $folder) { $result[$folder['id']] = new Syncroton_Model_Folder(array( 'serverId' => $folder['id'], 'displayName' => $folder['name'], 'type' => $folder['type'], 'parentId' => $folder['parent_id'] )); } return $result; } /** * @param Syncroton_Model_IFolder|string $_folderId * @param string $_filter * @return array */ public function getServerEntries($_folderId, $_filter) { $folderId = $_folderId instanceof Syncroton_Model_IFolder ? $_folderId->id : $_folderId; $select = $this->_db->select() ->from($this->_tablePrefix . 'data', array('id')) ->where('folder_id = ?', $_folderId); $ids = array(); $stmt = $this->_db->query($select); while ($id = $stmt->fetchColumn()) { $ids[] = $id; } return $ids; } /** * (non-PHPdoc) * @see Syncroton_Data_IData::getCountOfChanges() */ public function getCountOfChanges(Syncroton_Backend_IContent $contentBackend, Syncroton_Model_IFolder $folder, Syncroton_Model_ISyncState $syncState) { $allClientEntries = $contentBackend->getFolderState($this->_device, $folder); $allServerEntries = $this->getServerEntries($folder->serverId, $folder->lastfiltertype); $addedEntries = array_diff($allServerEntries, $allClientEntries); $deletedEntries = array_diff($allClientEntries, $allServerEntries); - $changedEntries = $this->getChangedEntries($folder->serverId, $syncState->lastsync, null, $folder->lastfiltertype); + $changedEntries = $this->getChangedEntries($folder->serverId, $syncState, $folder->lastfiltertype); return count($addedEntries) + count($deletedEntries) + count($changedEntries); } /** * (non-PHPdoc) * @see Syncroton_Data_IData::getFileReference() */ public function getFileReference($fileReference) { throw new Syncroton_Exception_NotFound('filereference not found'); } /** * (non-PHPdoc) * @see Syncroton_Data_IData::getEntry() */ public function getEntry(Syncroton_Model_SyncCollection $collection, $serverId) { $select = $this->_db->select() ->from($this->_tablePrefix . 'data', array('data')) ->where('id = ?', $serverId); $stmt = $this->_db->query($select); $entry = $stmt->fetchColumn(); if ($entry === false) { throw new Syncroton_Exception_NotFound("entry $serverId not found in folder {$collection->collectionId}"); } return unserialize($entry); } /** * (non-PHPdoc) * @see Syncroton_Data_IData::hasChanges() */ public function hasChanges(Syncroton_Backend_IContent $contentBackend, Syncroton_Model_IFolder $folder, Syncroton_Model_ISyncState $syncState) { return !!$this->getCountOfChanges($contentBackend, $folder, $syncState); } /** * (non-PHPdoc) * @see Syncroton_Data_IData::moveItem() */ public function moveItem($_srcFolderId, $_serverId, $_dstFolderId) { $this->_db->update($this->_tablePrefix . 'data', array( 'folder_id' => $_dstFolderId, ), array( 'id = ?' => $_serverId )); return $_serverId; } /** * (non-PHPdoc) * @see Syncroton_Data_IData::updateEntry() */ public function updateEntry($_folderId, $_serverId, Syncroton_Model_IEntry $_entry) { $this->_db->update($this->_tablePrefix . 'data', array( 'folder_id' => $_folderId, 'last_modified_time' => $this->_timeStamp->format("Y-m-d H:i:s"), 'data' => serialize($_entry) ), array( 'id = ?' => $_serverId )); } /** * (non-PHPdoc) * @see Syncroton_Data_IData::updateFolder() */ public function updateFolder(Syncroton_Model_IFolder $folder) { $this->_db->update($this->_tablePrefix . 'data_folder', array( 'name' => $folder->displayName, 'parent_id' => $folder->parentId, 'last_modified_time' => $this->_timeStamp->format("Y-m-d H:i:s"), ), array( 'id = ?' => $folder->serverId, 'owner_id = ?' => $this->_ownerId )); return $this->getFolder($folder->serverId); } } diff --git a/lib/ext/Syncroton/Data/IData.php b/lib/ext/Syncroton/Data/IData.php index c7e3cbe..8ea1f4c 100644 --- a/lib/ext/Syncroton/Data/IData.php +++ b/lib/ext/Syncroton/Data/IData.php @@ -1,127 +1,134 @@ */ /** * class to handle ActiveSync Sync command * * @package Syncroton * @subpackage Data */ interface Syncroton_Data_IData { /** * create new entry * * @param string $folderId * @param Syncroton_Model_IEntry $entry * @return string id of created entry */ public function createEntry($folderId, Syncroton_Model_IEntry $entry); /** * create a new folder in backend * * @param Syncroton_Model_IFolder $folder * @return Syncroton_Model_IFolder */ public function createFolder(Syncroton_Model_IFolder $folder); /** * delete entry in backend * * @param string $_folderId * @param string $_serverId * @param ?Syncroton_Model_SyncCollection $_collectionData */ public function deleteEntry($_folderId, $_serverId, $_collectionData = null); /** * delete folder * * @param string $folderId */ public function deleteFolder($folderId); /** * empty folder * * @param string $folderId * @param array $options */ public function emptyFolderContents($folderId, $options); /** * return list off all folders * @return array of Syncroton_Model_IFolder */ public function getAllFolders(); - public function getChangedEntries($folderId, DateTime $startTimeStamp, DateTime $endTimeStamp = NULL, $filterType = NULL); + public function getChangedEntries($folderId, Syncroton_Model_ISyncState $syncState, $filterType = NULL); + + /** + * Retrieve extra data that is stored with the sync key + * @return string|null + **/ + public function getExtraData(Syncroton_Model_IFolder $folder); + /** * retrieve folders which were modified since last sync * * @param DateTime $startTimeStamp * @param DateTime $endTimeStamp */ public function getChangedFolders(DateTime $startTimeStamp, DateTime $endTimeStamp); public function getCountOfChanges(Syncroton_Backend_IContent $contentBackend, Syncroton_Model_IFolder $folder, Syncroton_Model_ISyncState $syncState); /** * * @param Syncroton_Model_SyncCollection $collection * @param string $serverId * @return Syncroton_Model_IEntry */ public function getEntry(Syncroton_Model_SyncCollection $collection, $serverId); /** * * @param string $fileReference * @return Syncroton_Model_FileReference */ public function getFileReference($fileReference); /** * return array of all id's stored in folder * * @param Syncroton_Model_IFolder|string $folderId * @param string $filter * @return array */ public function getServerEntries($folderId, $filter); /** * return true if any data got modified in the backend * * @param Syncroton_Backend_IContent $contentBackend * @param Syncroton_Model_IFolder $folder * @param Syncroton_Model_ISyncState $syncState * @return bool */ public function hasChanges(Syncroton_Backend_IContent $contentBackend, Syncroton_Model_IFolder $folder, Syncroton_Model_ISyncState $syncState); public function moveItem($srcFolderId, $serverId, $dstFolderId); /** * update existing entry * * @param string $folderId * @param string $serverId * @param Syncroton_Model_IEntry $entry * @return string id of updated entry */ public function updateEntry($folderId, $serverId, Syncroton_Model_IEntry $entry); public function updateFolder(Syncroton_Model_IFolder $folder); } diff --git a/lib/ext/Syncroton/Model/ISyncState.php b/lib/ext/Syncroton/Model/ISyncState.php index a2ffbff..2f50b7c 100644 --- a/lib/ext/Syncroton/Model/ISyncState.php +++ b/lib/ext/Syncroton/Model/ISyncState.php @@ -1,27 +1,28 @@ */ /** * class to handle ActiveSync Sync command * * @package Syncroton * @subpackage Model * @property string device_id * @property string type * @property string counter * @property DateTime lastsync * @property string pendingdata * @property string client_id_map + * @property string extraData */ interface Syncroton_Model_ISyncState { } diff --git a/lib/kolab_sync_data.php b/lib/kolab_sync_data.php index f2a6d47..ac75fee 100644 --- a/lib/kolab_sync_data.php +++ b/lib/kolab_sync_data.php @@ -1,1567 +1,1565 @@ | | | | This program is free software: you can redistribute it and/or modify | | it under the terms of the GNU Affero General Public License as published | | by the Free Software Foundation, either version 3 of the License, or | | (at your option) any later version. | | | | This program 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 Affero General Public License for more details. | | | | You should have received a copy of the GNU Affero General Public License | | along with this program. If not, see | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ /** * Base class for Syncroton data backends */ abstract class kolab_sync_data implements Syncroton_Data_IData { /** * ActiveSync protocol version * * @var float */ protected $asversion = 0; /** * The storage backend * * @var kolab_sync_storage */ protected $backend; /** * information about the current device * * @var Syncroton_Model_IDevice */ protected $device; /** * timestamp to use for all sync requests * * @var DateTime */ protected $syncTimeStamp; /** * name of model to use * * @var string */ protected $modelName; /** * type of the default folder * * @var int */ protected $defaultFolderType; /** * default container for new entries * * @var string */ protected $defaultFolder; /** * default root folder * * @var string */ protected $defaultRootFolder; /** * type of user created folders * * @var int */ protected $folderType; /** * Internal cache for storage folders list * * @var array */ protected $folders = []; /** * Logger instance. * * @var kolab_sync_logger */ protected $logger; /** * Timezone * * @var string */ protected $timezone; /** * List of device types with multiple folders support * * @var array */ protected $ext_devices = [ 'iphone', 'ipad', 'thundertine', 'windowsphone', 'wp', 'wp8', 'playbook', ]; protected $lastsync_folder = null; protected $lastsync_time = null; public const RESULT_OBJECT = 0; public const RESULT_UID = 1; public const RESULT_COUNT = 2; /** * Recurrence types */ public const RECUR_TYPE_DAILY = 0; // Recurs daily. public const RECUR_TYPE_WEEKLY = 1; // Recurs weekly public const RECUR_TYPE_MONTHLY = 2; // Recurs monthly public const RECUR_TYPE_MONTHLY_DAYN = 3; // Recurs monthly on the nth day public const RECUR_TYPE_YEARLY = 5; // Recurs yearly public const RECUR_TYPE_YEARLY_DAYN = 6; // Recurs yearly on the nth day /** * Day of week constants */ public const RECUR_DOW_SUNDAY = 1; public const RECUR_DOW_MONDAY = 2; public const RECUR_DOW_TUESDAY = 4; public const RECUR_DOW_WEDNESDAY = 8; public const RECUR_DOW_THURSDAY = 16; public const RECUR_DOW_FRIDAY = 32; public const RECUR_DOW_SATURDAY = 64; public const RECUR_DOW_LAST = 127; // The last day of the month. Used as a special value in monthly or yearly recurrences. /** * Mapping of recurrence types * * @var array */ protected $recurTypeMap = [ self::RECUR_TYPE_DAILY => 'DAILY', self::RECUR_TYPE_WEEKLY => 'WEEKLY', self::RECUR_TYPE_MONTHLY => 'MONTHLY', self::RECUR_TYPE_MONTHLY_DAYN => 'MONTHLY', self::RECUR_TYPE_YEARLY => 'YEARLY', self::RECUR_TYPE_YEARLY_DAYN => 'YEARLY', ]; /** * Mapping of weekdays * NOTE: ActiveSync uses a bitmask * * @var array */ protected $recurDayMap = [ 'SU' => self::RECUR_DOW_SUNDAY, 'MO' => self::RECUR_DOW_MONDAY, 'TU' => self::RECUR_DOW_TUESDAY, 'WE' => self::RECUR_DOW_WEDNESDAY, 'TH' => self::RECUR_DOW_THURSDAY, 'FR' => self::RECUR_DOW_FRIDAY, 'SA' => self::RECUR_DOW_SATURDAY, ]; /** * the constructor * * @param Syncroton_Model_IDevice $device * @param DateTime $syncTimeStamp */ public function __construct(Syncroton_Model_IDevice $device, DateTime $syncTimeStamp) { $this->backend = kolab_sync::storage(); $this->device = $device; $this->asversion = floatval($device->acsversion); $this->syncTimeStamp = $this->backend->syncTimeStamp = $syncTimeStamp; $this->logger = Syncroton_Registry::get(Syncroton_Registry::LOGGERBACKEND); $this->defaultRootFolder = $this->defaultFolder . '::Syncroton'; // set internal timezone of kolab_format to user timezone try { $this->timezone = rcube::get_instance()->config->get('timezone', 'GMT'); kolab_format::$timezone = new DateTimeZone($this->timezone); } catch (Exception $e) { //rcube::raise_error($e, true); $this->timezone = 'GMT'; kolab_format::$timezone = new DateTimeZone('GMT'); } } /** * return list of supported folders for this backend * * @return array */ public function getAllFolders() { $list = []; // device supports multiple folders ? if ($this->isMultiFolder()) { // get the folders the user has access to $list = $this->listFolders(); } elseif ($default = $this->getDefaultFolder()) { $list = [$default['serverId'] => $default]; } // getAllFolders() is called only in FolderSync // throw Syncroton_Exception_Status_FolderSync exception if (!is_array($list)) { throw new Syncroton_Exception_Status_FolderSync(Syncroton_Exception_Status_FolderSync::FOLDER_SERVER_ERROR); } foreach ($list as $idx => $folder) { $list[$idx] = new Syncroton_Model_Folder($folder); } return $list; } /** * Retrieve folders which were modified since last sync * * @param DateTime $startTimeStamp * @param DateTime $endTimeStamp * * @return array List of folders */ public function getChangedFolders(DateTime $startTimeStamp, DateTime $endTimeStamp) { // FIXME/TODO: Can we get mtime of a DAV folder? // Without this, we have a problem if folder ID does not change on rename return []; } /** * Returns true if the device supports multiple folders or it was configured so */ protected function isMultiFolder() { $config = rcube::get_instance()->config; $blacklist = $config->get('activesync_multifolder_blacklist_' . $this->modelName); if (!is_array($blacklist)) { $blacklist = $config->get('activesync_multifolder_blacklist'); } if (is_array($blacklist)) { return !$this->deviceTypeFilter($blacklist); } return in_array_nocase($this->device->devicetype, $this->ext_devices); } /** * Returns default folder for current class type. */ protected function getDefaultFolder() { // Check if there's any folder configured for sync $folders = $this->listFolders(); if (empty($folders)) { return $folders; } foreach ($folders as $folder) { if ($folder['type'] == $this->defaultFolderType) { $default = $folder; break; } } // Return first on the list if there's no default if (empty($default)) { $default = array_first($folders); // make sure the type is default here $default['type'] = $this->defaultFolderType; } // Remember real folder ID and set ID/name to root folder $default['realid'] = $default['serverId']; $default['serverId'] = $this->defaultRootFolder; $default['displayName'] = $this->defaultFolder; return $default; } /** * Creates a folder */ public function createFolder(Syncroton_Model_IFolder $folder) { $result = $this->backend->folder_create($folder->displayName, $folder->type, $this->device->deviceid, $folder->parentId); if ($result) { $folder->serverId = $result; return $folder; } // Note: Looks like Outlook 2013 ignores any errors on FolderCreate command throw new Syncroton_Exception_Status_FolderCreate(Syncroton_Exception_Status_FolderCreate::UNKNOWN_ERROR); } /** * Updates a folder */ public function updateFolder(Syncroton_Model_IFolder $folder) { $result = $this->backend->folder_rename($folder->serverId, $this->device->deviceid, $folder->displayName, $folder->parentId); if ($result) { return $folder; } // @TODO: throw exception } /** * Deletes a folder */ public function deleteFolder($folder) { if ($folder instanceof Syncroton_Model_IFolder) { $folder = $folder->serverId; } // @TODO: throw exception return $this->backend->folder_delete($folder, $this->device->deviceid); } /** * Empty folder (remove all entries and optionally subfolders) * * @param string $folderid Folder identifier * @param array $options Options */ public function emptyFolderContents($folderid, $options) { // ActiveSync spec.: Clients use EmptyFolderContents to empty the Deleted Items folder. // The client can clear out all items in the Deleted Items folder when the user runs out of storage quota // (indicated by the return of an MailboxQuotaExceeded (113) status code from the server. // FIXME: Does that mean we don't need this to work on any other folder? // TODO: Respond with MailboxQuotaExceeded status. Where exactly? foreach ($this->extractFolders($folderid) as $folderid) { if (!$this->backend->folder_empty($folderid, $this->device->deviceid, !empty($options['deleteSubFolders']))) { throw new Syncroton_Exception_Status_ItemOperations(Syncroton_Exception_Status_ItemOperations::ITEM_SERVER_ERROR); } } } /** * Moves object into another location (folder) * * @param string $srcFolderId Source folder identifier * @param string $serverId Object identifier * @param string $dstFolderId Destination folder identifier * * @throws Syncroton_Exception_Status * @return string New object identifier */ public function moveItem($srcFolderId, $serverId, $dstFolderId) { // TODO: Optimize, we just need to find the folder ID and UID, we do not need to "fetch" it. $item = $this->getObject($srcFolderId, $serverId); if (!$item) { throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_SOURCE); } $uid = $this->backend->moveItem($item['folderId'], $this->device->deviceid, $this->modelName, $item['uid'], $dstFolderId); return $this->serverId($uid, $dstFolderId); } /** * Add entry * * @param string $folderId Folder identifier * @param Syncroton_Model_IEntry $entry Entry object * * @return string ID of the created entry */ public function createEntry($folderId, Syncroton_Model_IEntry $entry) { $entry = $this->toKolab($entry, $folderId); if ($folderId == $this->defaultRootFolder) { $default = $this->getDefaultFolder(); if (!is_array($default)) { throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR); } $folderId = $default['realid'] ?? $default['serverId']; } $uid = $this->backend->createItem($folderId, $this->device->deviceid, $this->modelName, $entry); if (empty($uid)) { throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR); } return $this->serverId($uid, $folderId); } /** * update existing entry * * @param string $folderId * @param string $serverId * @param Syncroton_Model_IEntry $entry * * @return string ID of the updated entry */ public function updateEntry($folderId, $serverId, Syncroton_Model_IEntry $entry) { $oldEntry = $this->getObject($folderId, $serverId); if (empty($oldEntry)) { throw new Syncroton_Exception_NotFound('entry not found'); } $entry = $this->toKolab($entry, $folderId, $oldEntry); $uid = $this->backend->updateItem($oldEntry['folderId'], $this->device->deviceid, $this->modelName, $oldEntry['uid'], $entry); if (empty($uid)) { throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR); } return $this->serverId($uid, $oldEntry['folderId']); } /** * Delete entry * * @param string $folderId * @param string $serverId * @param ?Syncroton_Model_SyncCollection $collectionData */ public function deleteEntry($folderId, $serverId, $collectionData = null) { // TODO: Optimize, we just need to find the folder ID and UID, we do not need to "fetch" it. $object = $this->getObject($folderId, $serverId); if ($object) { $deleted = $this->backend->deleteItem($object['folderId'], $this->device->deviceid, $this->modelName, $object['uid']); if (!$deleted) { throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR); } } } /** * Get attachment data from the server. * * @param string $fileReference * * @return Syncroton_Model_FileReference */ public function getFileReference($fileReference) { // to be implemented by Email data class throw new Syncroton_Exception_NotFound('File references not supported'); } /** * Search for existing entries * * @param string $folderid Folder identifier * @param array $filter Search filter * @param int $result_type Type of the result (see RESULT_* constants) * * @return array|int Search result as count or array of uids/objects */ - protected function searchEntries($folderid, $filter = [], $result_type = self::RESULT_UID) + protected function searchEntries($folderid, $filter = [], $result_type = self::RESULT_UID, $extraData = null) { $result = $result_type == self::RESULT_COUNT ? 0 : []; $ts = time(); $force = $this->lastsync_folder != $folderid || $this->lastsync_time <= $ts - Syncroton_Registry::getPingTimeout(); $found = false; foreach ($this->extractFolders($folderid) as $fid) { - $search = $this->backend->searchEntries($fid, $this->device->deviceid, $this->device->id, $this->modelName, $filter, $result_type, $force); + $search = $this->backend->searchEntries($fid, $this->device->deviceid, $this->modelName, $filter, $result_type, $force, $extraData); $found = true; switch ($result_type) { case self::RESULT_COUNT: $result += $search; break; case self::RESULT_UID: foreach ($search as $idx => $uid) { $search[$idx] = $this->serverId($uid, $fid); } $result = array_unique(array_merge($result, $search)); break; } } if (!$found) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } $this->lastsync_folder = $folderid; $this->lastsync_time = $ts; return $result; } /** * Returns filter query array according to specified ActiveSync FilterType * * @param int $filter_type Filter type * * @return array Filter query */ protected function filter($filter_type = 0) { // overwrite by child class according to specified type return []; } /** * get all entries changed between two dates * * @param string $folderId - * @param DateTime $start - * @param DateTime $end + * @param Syncroton_Model_ISyncState $syncState * @param int $filter_type * * @return array */ - public function getChangedEntries($folderId, DateTime $start, DateTime $end = null, $filter_type = null) + public function getChangedEntries($folderId, Syncroton_Model_ISyncState $syncState, $filter_type = null) { + $start = $syncState->lastsync; $filter = $this->filter($filter_type); $filter[] = ['changed', '>', $start]; - if ($end) { - $filter[] = ['changed', '<=', $end]; - } - - return $this->searchEntries($folderId, $filter, self::RESULT_UID); + return $this->searchEntries($folderId, $filter, self::RESULT_UID, $syncState->extraData); } /** * Get count of entries changed between two dates * * @param string $folderId - * @param DateTime $start - * @param DateTime $end + * @param Syncroton_Model_ISyncState $syncState * @param int $filter_type * * @return int */ - public function getChangedEntriesCount($folderId, DateTime $start, DateTime $end = null, $filter_type = null) + private function getChangedEntriesCount($folderId, Syncroton_Model_ISyncState $syncState, $filter_type = null) { + $start = $syncState->lastsync; $filter = $this->filter($filter_type); $filter[] = ['changed', '>', $start]; - if ($end) { - $filter[] = ['changed', '<=', $end]; - } + return $this->searchEntries($folderId, $filter, self::RESULT_COUNT, $syncState->extraData); + } - return $this->searchEntries($folderId, $filter, self::RESULT_COUNT); + + public function getExtraData(Syncroton_Model_IFolder $folder) + { + return $this->backend->getExtraData($folder->serverId, $this->device->deviceid); } /** * get id's of all entries available on the server * * @param string $folder_id * @param string $filter_type * * @return array */ public function getServerEntries($folder_id, $filter_type) { $filter = $this->filter($filter_type); $result = $this->searchEntries($folder_id, $filter, self::RESULT_UID); return $result; } /** * get count of all entries available on the server * * @param string $folder_id * @param string $filter_type * * @return int */ public function getServerEntriesCount($folder_id, $filter_type) { $filter = $this->filter($filter_type); $result = $this->searchEntries($folder_id, $filter, self::RESULT_COUNT); return $result; } /** * Returns number of changed objects in the backend folder * * @param Syncroton_Backend_IContent $contentBackend * @param Syncroton_Model_IFolder $folder * @param Syncroton_Model_ISyncState $syncState * * @return int */ public function getCountOfChanges(Syncroton_Backend_IContent $contentBackend, Syncroton_Model_IFolder $folder, Syncroton_Model_ISyncState $syncState) { // @phpstan-ignore-next-line $allClientEntries = $contentBackend->getFolderState($this->device, $folder, $syncState->counter); $allServerEntries = $this->getServerEntries($folder->serverId, $folder->lastfiltertype); - $changedEntries = $this->getChangedEntriesCount($folder->serverId, $syncState->lastsync, null, $folder->lastfiltertype); + $changedEntries = $this->getChangedEntriesCount($folder->serverId, $syncState, $folder->lastfiltertype); $addedEntries = array_diff($allServerEntries, $allClientEntries); $deletedEntries = array_diff($allClientEntries, $allServerEntries); return count($addedEntries) + count($deletedEntries) + $changedEntries; } /** * Returns true if any data got modified in the backend folder * * @param Syncroton_Backend_IContent $contentBackend * @param Syncroton_Model_IFolder $folder * @param Syncroton_Model_ISyncState $syncState * * @return bool */ public function hasChanges(Syncroton_Backend_IContent $contentBackend, Syncroton_Model_IFolder $folder, Syncroton_Model_ISyncState $syncState) { try { - if ($this->getChangedEntriesCount($folder->serverId, $syncState->lastsync, null, $folder->lastfiltertype)) { + if ($this->getChangedEntriesCount($folder->serverId, $syncState, $folder->lastfiltertype)) { return true; } // @phpstan-ignore-next-line $allClientEntries = $contentBackend->getFolderState($this->device, $folder, $syncState->counter); // @TODO: Consider looping over all folders here, not in getServerEntries() and // getChangedEntriesCount(). This way we could break the loop and not check all folders // or at least skip redundant cache sync of the same folder $allServerEntries = $this->getServerEntries($folder->serverId, $folder->lastfiltertype); $addedEntries = array_diff($allServerEntries, $allClientEntries); $deletedEntries = array_diff($allClientEntries, $allServerEntries); return count($addedEntries) > 0 || count($deletedEntries) > 0; } catch (Exception $e) { // return "no changes" if something failed return false; } } /** * Fetches the entry from the backend */ protected function getObject($folderid, $entryid) { foreach ($this->extractFolders($folderid) as $fid) { $crc = null; $uid = $entryid; // See self::serverId() for full explanation // Use (slower) UID prefix matching... if (preg_match('/^CRC([0-9A-Fa-f]{8})(.+)$/', $uid, $matches)) { $crc = $matches[1]; $uid = $matches[2]; if (strlen($entryid) >= 64) { $objects = $this->backend->getItemsByUidPrefix($fid, $this->device->deviceid, $this->modelName, $uid); foreach ($objects as $object) { if (($object['uid'] === $uid || strpos($object['uid'], $uid) === 0) && $crc == $this->objectCRC($object['uid'], $fid) ) { $object['folderId'] = $fid; return $object; } } continue; } } // Or (faster) strict UID matching... $object = $this->backend->getItem($fid, $this->device->deviceid, $this->modelName, $uid); if (!empty($object) && ($crc === null || $crc == $this->objectCRC($object['uid'], $fid))) { $object['folderId'] = $fid; return $object; } } } /** * Returns internal folder IDs * * @param string $folderid Folder identifier * * @return array List of folder identifiers */ protected function extractFolders($folderid) { if ($folderid instanceof Syncroton_Model_IFolder) { $folderid = $folderid->serverId; } if ($folderid === $this->defaultRootFolder) { $folders = $this->listFolders(); if (!is_array($folders)) { throw new Syncroton_Exception_NotFound('Folder not found'); } $folders = array_keys($folders); } else { $folders = [$folderid]; } return $folders; } /** * List of all IMAP folders (or subtree) * * @param string $parentid Parent folder identifier * * @return array List of folder identifiers */ protected function listFolders($parentid = null) { if (empty($this->folders)) { $this->folders = $this->backend->folders_list( $this->device->deviceid, $this->modelName, $this->isMultiFolder() ); } if ($parentid === null || !is_array($this->folders)) { return $this->folders; } $folders = []; $parents = [$parentid]; foreach ($this->folders as $folder_id => $folder) { if ($folder['parentId'] && in_array($folder['parentId'], $parents)) { $folders[$folder_id] = $folder; $parents[] = $folder_id; } } return $folders; } /** * Returns ActiveSync settings of specified folder * * @param string $folderid Folder identifier * * @return array Folder settings */ protected function getFolderConfig($folderid) { if ($folderid == $this->defaultRootFolder) { $default = $this->getDefaultFolder(); if (!is_array($default)) { return []; } $folderid = $default['realid'] ?? $default['serverId']; } return $this->backend->getFolderConfig($folderid, $this->device->deviceid, $this->modelName); } /** * Convert contact from xml to kolab format * * @param mixed $data Contact data * @param string $folderId Folder identifier * @param array $entry Old Contact data for merge * * @return array */ abstract public function toKolab($data, $folderId, $entry = null); /** * Extracts data from kolab data array */ protected function getKolabDataItem($data, $name) { $name_items = explode('.', $name); $count = count($name_items); // multi-level array (e.g. address, phone) if ($count == 3) { $name = $name_items[0]; $type = $name_items[1]; $key_name = $name_items[2]; if (!empty($data[$name]) && is_array($data[$name])) { foreach ($data[$name] as $element) { if ($element['type'] == $type) { return $element[$key_name]; } } } return null; } // custom properties if ($count == 2 && $name_items[0] == 'x-custom') { $value = null; if (!empty($data['x-custom']) && is_array($data['x-custom'])) { foreach ($data['x-custom'] as $val) { if (is_array($val) && $val[0] == $name_items[1]) { $value = $val[1]; break; } } } return $value; } $name_items = explode(':', $name); $name = $name_items[0]; if (empty($data[$name])) { return null; } // simple array (e.g. email) if (count($name_items) == 2) { return $data[$name][$name_items[1]]; } return $data[$name]; } /** * Saves data in kolab data array */ protected function setKolabDataItem(&$data, $name, $value) { if (empty($value)) { return $this->unsetKolabDataItem($data, $name); } $name_items = explode('.', $name); $count = count($name_items); // multi-level array (e.g. address, phone) if ($count == 3) { $name = $name_items[0]; $type = $name_items[1]; $key_name = $name_items[2]; if (!isset($data[$name])) { $data[$name] = []; } foreach ($data[$name] as $idx => $element) { if ($element['type'] == $type) { $found = $idx; break; } } if (!isset($found)) { $data[$name] = array_values($data[$name]); $found = count($data[$name]); $data[$name][$found] = ['type' => $type]; } $data[$name][$found][$key_name] = $value; return; } // custom properties if ($count == 2 && $name_items[0] == 'x-custom') { $data['x-custom'] = isset($data['x-custom']) ? ((array) $data['x-custom']) : []; foreach ($data['x-custom'] as $idx => $val) { if (is_array($val) && $val[0] == $name_items[1]) { $data['x-custom'][$idx][1] = $value; return; } } $data['x-custom'][] = [$name_items[1], $value]; return; } $name_items = explode(':', $name); $name = $name_items[0]; // simple array (e.g. email) if (count($name_items) == 2) { $data[$name][$name_items[1]] = $value; return; } $data[$name] = $value; } /** * Unsets data item in kolab data array */ protected function unsetKolabDataItem(&$data, $name) { $name_items = explode('.', $name); $count = count($name_items); // multi-level array (e.g. address, phone) if ($count == 3) { $name = $name_items[0]; $type = $name_items[1]; $key_name = $name_items[2]; if (!isset($data[$name])) { return; } foreach ($data[$name] as $idx => $element) { if ($element['type'] == $type) { $found = $idx; break; } } if (!isset($found)) { return; } unset($data[$name][$found][$key_name]); // if there's only one element and it's 'type', remove it if (count($data[$name][$found]) == 1 && isset($data[$name][$found]['type'])) { unset($data[$name][$found]['type']); } if (empty($data[$name][$found])) { unset($data[$name][$found]); } if (empty($data[$name])) { unset($data[$name]); } return; } // custom properties if ($count == 2 && $name_items[0] == 'x-custom') { foreach ((array) $data['x-custom'] as $idx => $val) { if (is_array($val) && $val[0] == $name_items[1]) { unset($data['x-custom'][$idx]); } } } $name_items = explode(':', $name); $name = $name_items[0]; // simple array (e.g. email) if (count($name_items) == 2) { unset($data[$name][$name_items[1]]); if (empty($data[$name])) { unset($data[$name]); } return; } unset($data[$name]); } /** * Setter for Body attribute according to client version * * @param string $value Body * @param array $params Body parameters * * @reurn Syncroton_Model_EmailBody Body element */ protected function setBody($value, $params = []) { if (empty($value) && empty($params)) { return; } // Old protocol version doesn't support AirSyncBase:Body, it's eg. WindowsCE if ($this->asversion < 12) { return; } if (!empty($value)) { // cast to string to workaround issue described in Bug #1635 $params['data'] = (string) $value; } if (!isset($params['type'])) { $params['type'] = Syncroton_Model_EmailBody::TYPE_PLAINTEXT; } return new Syncroton_Model_EmailBody($params); } /** * Getter for Body attribute value according to client version * * @param mixed $body Body element * @param int $type Result data type (to which the body will be converted, if specified). * One or array of Syncroton_Model_EmailBody constants. * * @return string|null Body value */ protected function getBody($body, $type = null) { $data = null; if ($body && $body->data) { $data = $body->data; } if (!$data || empty($type)) { return null; } $type = (array) $type; // Convert to specified type if (!in_array($body->type, $type)) { $converter = new kolab_sync_body_converter($data, $body->type); $data = $converter->convert($type[0]); } return $data; } /** * Converts text (plain or html) into ActiveSync Body element. * Takes bodyPreferences into account and detects if the text is plain or html. */ protected function body_from_kolab($body, $collection) { if (empty($body)) { return; } $opts = $collection->options; $prefs = $opts['bodyPreferences']; $html_type = Syncroton_Command_Sync::BODY_TYPE_HTML; $type = Syncroton_Command_Sync::BODY_TYPE_PLAIN_TEXT; $params = []; // HTML? check for opening and closing or tags $is_html = preg_match('/<(html|body)(\s+[a-z]|>)/', $body, $m) && strpos($body, '') > 0; // here we assume that all devices support plain text if ($is_html) { // device supports HTML... if (!empty($prefs[$html_type])) { $type = $html_type; } // ...else convert to plain text else { $txt = new rcube_html2text($body, false, true); $body = $txt->get_text(); } } // strip out any non utf-8 characters $body = rcube_charset::clean($body); $real_length = $body_length = strlen($body); // truncate the body if needed if (isset($prefs[$type]['truncationSize']) && ($truncateAt = $prefs[$type]['truncationSize']) && $body_length > $truncateAt) { $body = mb_strcut($body, 0, $truncateAt); $body_length = strlen($body); $params['truncated'] = 1; $params['estimatedDataSize'] = $real_length; } $params['type'] = $type; return $this->setBody($body, $params); } /** * Converts PHP DateTime, date (YYYY-MM-DD) or unixtimestamp into PHP DateTime in UTC * * @param DateTime|int|string $date Unix timestamp, date (YYYY-MM-DD) or PHP DateTime object * * @return DateTime|null Datetime object */ protected static function date_from_kolab($date) { if (!empty($date)) { if (is_numeric($date)) { $date = new DateTime('@' . $date); } elseif (is_string($date)) { $date = new DateTime($date, new DateTimeZone('UTC')); } elseif ($date instanceof DateTime) { $date = clone $date; $tz = $date->getTimezone(); $tz_name = $tz->getName(); // convert to UTC if needed if ($tz_name != 'UTC') { $utc = new DateTimeZone('UTC'); // safe dateonly object conversion to UTC // note: _dateonly flag is set by libkolab e.g. for birthdays if (!empty($date->_dateonly)) { // avoid time change $date = new DateTime($date->format('Y-m-d'), $utc); // set time to noon to avoid timezone troubles $date->setTime(12, 0, 0); } else { $date->setTimezone($utc); } } } else { return null; // invalid input } return $date; } return null; } /** * Convert Kolab event/task recurrence into ActiveSync */ protected function recurrence_from_kolab($collection, $data, &$result, $type = 'Event') { if (empty($data['recurrence']) || !empty($data['recurrence_date']) || empty($data['recurrence']['FREQ'])) { return; } $recurrence = []; $r = $data['recurrence']; // required fields switch($r['FREQ']) { case 'DAILY': $recurrence['type'] = self::RECUR_TYPE_DAILY; break; case 'WEEKLY': $day = $r['BYDAY'] ?? 0; if (!$day && (!empty($data['_start']) || !empty($data['start']))) { $days = ['', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA','SU']; $start = $data['_start'] ?? $data['start']; $day = $days[$start->format('N')]; } $recurrence['type'] = self::RECUR_TYPE_WEEKLY; $recurrence['dayOfWeek'] = $this->day2bitmask($day); break; case 'MONTHLY': if (!empty($r['BYMONTHDAY'])) { // @TODO: ActiveSync doesn't support multi-valued month days, // should we replicate the recurrence element for each day of month? [$month_day, ] = explode(',', $r['BYMONTHDAY']); $recurrence['type'] = self::RECUR_TYPE_MONTHLY; $recurrence['dayOfMonth'] = $month_day; } elseif (!empty($r['BYDAY'])) { $week = (int) substr($r['BYDAY'], 0, -2); $week = ($week == -1) ? 5 : $week; $day = substr($r['BYDAY'], -2); $recurrence['type'] = self::RECUR_TYPE_MONTHLY_DAYN; $recurrence['weekOfMonth'] = $week; $recurrence['dayOfWeek'] = $this->day2bitmask($day); } else { return; } break; case 'YEARLY': // @TODO: ActiveSync doesn't support multi-valued months, // should we replicate the recurrence element for each month? [$month, ] = explode(',', $r['BYMONTH']); if (!empty($r['BYDAY'])) { $week = (int) substr($r['BYDAY'], 0, -2); $week = ($week == -1) ? 5 : $week; $day = substr($r['BYDAY'], -2); $recurrence['type'] = self::RECUR_TYPE_YEARLY_DAYN; $recurrence['weekOfMonth'] = $week; $recurrence['dayOfWeek'] = $this->day2bitmask($day); $recurrence['monthOfYear'] = $month; } elseif (!empty($r['BYMONTHDAY'])) { // @TODO: ActiveSync doesn't support multi-valued month days, // should we replicate the recurrence element for each day of month? [$month_day, ] = explode(',', $r['BYMONTHDAY']); $recurrence['type'] = self::RECUR_TYPE_YEARLY; $recurrence['dayOfMonth'] = $month_day; $recurrence['monthOfYear'] = $month; } else { $recurrence['type'] = self::RECUR_TYPE_YEARLY; $recurrence['monthOfYear'] = $month; } break; } // Skip all empty values (T2519) if ($recurrence['type'] != self::RECUR_TYPE_DAILY) { $recurrence = array_filter($recurrence); } // required field $recurrence['interval'] = $r['INTERVAL'] ?: 1; if (!empty($r['UNTIL'])) { $recurrence['until'] = self::date_from_kolab($r['UNTIL']); } elseif (!empty($r['COUNT'])) { $recurrence['occurrences'] = $r['COUNT']; } $class = 'Syncroton_Model_' . $type . 'Recurrence'; $result['recurrence'] = new $class($recurrence); // Tasks do not support exceptions if ($type == 'Event') { $result['exceptions'] = $this->exceptions_from_kolab($collection, $data); } } /** * Convert ActiveSync event/task recurrence into Kolab */ protected function recurrence_to_kolab($data, $folderid, $timezone = null) { if (!($data->recurrence instanceof Syncroton_Model_EventRecurrence) && !($data->recurrence instanceof Syncroton_Model_TaskRecurrence) ) { return; } if (!isset($data->recurrence->type)) { return; } $recurrence = $data->recurrence; $type = $recurrence->type; switch ($type) { case self::RECUR_TYPE_DAILY: break; case self::RECUR_TYPE_WEEKLY: $rrule['BYDAY'] = $this->bitmask2day($recurrence->dayOfWeek); break; case self::RECUR_TYPE_MONTHLY: $rrule['BYMONTHDAY'] = $recurrence->dayOfMonth; break; case self::RECUR_TYPE_MONTHLY_DAYN: $week = $recurrence->weekOfMonth; $day = $recurrence->dayOfWeek; $byDay = $week == 5 ? -1 : $week; $byDay .= $this->bitmask2day($day); $rrule['BYDAY'] = $byDay; break; case self::RECUR_TYPE_YEARLY: $rrule['BYMONTH'] = $recurrence->monthOfYear; $rrule['BYMONTHDAY'] = $recurrence->dayOfMonth; break; case self::RECUR_TYPE_YEARLY_DAYN: $rrule['BYMONTH'] = $recurrence->monthOfYear; $week = $recurrence->weekOfMonth; $day = $recurrence->dayOfWeek; $byDay = $week == 5 ? -1 : $week; $byDay .= $this->bitmask2day($day); $rrule['BYDAY'] = $byDay; break; } $rrule['FREQ'] = $this->recurTypeMap[$type]; $rrule['INTERVAL'] = $recurrence->interval ?? 1; if (isset($recurrence->until)) { if ($timezone) { $recurrence->until->setTimezone($timezone); } $rrule['UNTIL'] = $recurrence->until; } elseif (!empty($recurrence->occurrences)) { $rrule['COUNT'] = $recurrence->occurrences; } // recurrence exceptions (not supported by Tasks) if ($data instanceof Syncroton_Model_Event) { $this->exceptions_to_kolab($data, $rrule, $folderid, $timezone); } return $rrule; } /** * Convert Kolab event recurrence exceptions into ActiveSync */ protected function exceptions_from_kolab($collection, $data) { if (empty($data['recurrence']['EXCEPTIONS']) && empty($data['recurrence']['EXDATE'])) { return null; } $ex_list = []; // exceptions (modified occurences) if (!empty($data['recurrence']['EXCEPTIONS'])) { foreach ((array)$data['recurrence']['EXCEPTIONS'] as $exception) { $exception['_mailbox'] = $data['_mailbox']; $ex = $this->getEntry($collection, $exception, true); // @phpstan-ignore-line $date = clone ($exception['recurrence_date'] ?: $ex['startTime']); $ex['exceptionStartTime'] = self::set_exception_time($date, $data['_start'] ?? null); // remove fields not supported by Syncroton_Model_EventException unset($ex['uID']); // @TODO: 'thisandfuture=true' is not supported in Activesync // we'd need to slit the event into two separate events $ex_list[] = new Syncroton_Model_EventException($ex); } } // exdate (deleted occurences) if (!empty($data['recurrence']['EXDATE'])) { foreach ((array)$data['recurrence']['EXDATE'] as $exception) { if (!($exception instanceof DateTime)) { continue; } $ex = [ 'deleted' => 1, 'exceptionStartTime' => self::set_exception_time($exception, $data['_start'] ?? null), ]; $ex_list[] = new Syncroton_Model_EventException($ex); } } return $ex_list; } /** * Convert ActiveSync event recurrence exceptions into Kolab */ protected function exceptions_to_kolab($data, &$rrule, $folderid, $timezone = null) { $rrule['EXDATE'] = []; $rrule['EXCEPTIONS'] = []; // handle exceptions from recurrence if (!empty($data->exceptions)) { foreach ($data->exceptions as $exception) { $date = clone $exception->exceptionStartTime; if ($timezone) { $date->setTimezone($timezone); } if ($exception->deleted) { $date->setTime(0, 0, 0); $rrule['EXDATE'][] = $date; } else { $ex = $this->toKolab($exception, $folderid, null, $timezone); // @phpstan-ignore-line $ex['recurrence_date'] = $date; if (!empty($data->allDayEvent)) { $ex['allday'] = 1; } $rrule['EXCEPTIONS'][] = $ex; } } } if (empty($rrule['EXDATE'])) { unset($rrule['EXDATE']); } if (empty($rrule['EXCEPTIONS'])) { unset($rrule['EXCEPTIONS']); } } /** * Sets ExceptionStartTime according to occurrence date and event start time */ protected static function set_exception_time($exception_date, $event_start) { if ($exception_date && $event_start) { $hour = $event_start->format('H'); $minute = $event_start->format('i'); $second = $event_start->format('s'); $exception_date->setTime($hour, $minute, $second); $exception_date->_dateonly = false; return self::date_from_kolab($exception_date); } } /** * Converts string of days (TU,TH) to bitmask used by ActiveSync * * @param string $days * * @return int */ protected function day2bitmask($days) { $days = explode(',', $days); $result = 0; foreach ($days as $day) { if ($day) { $result = $result + ($this->recurDayMap[$day] ?? 0); } } return $result; } /** * Convert bitmask used by ActiveSync to string of days (TU,TH) * * @param int $days * * @return string */ protected function bitmask2day($days) { $days_arr = []; for ($bitmask = 1; $bitmask <= self::RECUR_DOW_SATURDAY; $bitmask = $bitmask << 1) { $dayMatch = $days & $bitmask; if ($dayMatch === $bitmask) { $days_arr[] = array_search($bitmask, $this->recurDayMap); } } $result = implode(',', $days_arr); return $result; } /** * Check if current device type string matches any of options */ protected function deviceTypeFilter($options) { foreach ($options as $option) { if ($option[0] == '/') { if (preg_match($option, $this->device->devicetype)) { return true; } } elseif (stripos($this->device->devicetype, $option) !== false) { return true; } } return false; } /** * Returns all email addresses of the current user */ protected function user_emails() { $user_emails = kolab_sync::get_instance()->user->list_emails(); $user_emails = array_map(function ($v) { return $v['email']; }, $user_emails); return $user_emails; } /** * Generate CRC-based ServerId from object UID */ protected function serverId($uid, $folder) { // When ActiveSync communicates with the client, it refers to objects with a ServerId // We can't use object UID for ServerId because: // - ServerId is limited to 64 chars, // - there can be multiple calendars with a copy of the same event. // // The solution is to; Take the original UID, and regardless of its length, execute the following: // - Hash the UID concatenated with the Folder ID using CRC32b, // - Prefix the UID with 'CRC' and the hash string, // - Tryncate the result to 64 characters. // // Searching for the server-side copy of the object now follows the logic; // - If the ServerId is prefixed with 'CRC', strip off the first 11 characters // and we search for the UID using the remainder; // - if the UID is shorter than 53 characters, it'll be the complete UID, // - if the UID is longer than 53 characters, it'll be the truncated UID, // and we search for a wildcard match of * // When multiple copies of the same event are found, the same CRC32b hash can be used // on the events metadata (i.e. the copy's UID and Folder ID), and compared with the CRC from the ServerId. // ServerId is max. 64 characters, below we generate a string of max. 64 chars // Note: crc32b is always 8 characters return 'CRC' . $this->objectCRC($uid, $folder) . substr($uid, 0, 53); } /** * Calculate checksum on object UID and folder UID */ protected function objectCRC($uid, $folder) { if (!is_object($folder)) { $folder = $this->backend->getFolder($folder, $this->device->deviceid, $this->modelName); } $folder_uid = $folder->get_uid(); return strtoupper(hash('crc32b', $folder_uid . $uid)); // always 8 chars } } diff --git a/lib/kolab_sync_data_contacts.php b/lib/kolab_sync_data_contacts.php index 8da47c6..de12931 100644 --- a/lib/kolab_sync_data_contacts.php +++ b/lib/kolab_sync_data_contacts.php @@ -1,640 +1,640 @@ | | | | This program is free software: you can redistribute it and/or modify | | it under the terms of the GNU Affero General Public License as published | | by the Free Software Foundation, either version 3 of the License, or | | (at your option) any later version. | | | | This program 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 Affero General Public License for more details. | | | | You should have received a copy of the GNU Affero General Public License | | along with this program. If not, see | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ /** * COntacts data class for Syncroton */ class kolab_sync_data_contacts extends kolab_sync_data { /** * Mapping from ActiveSync Contacts namespace fields */ protected $mapping = [ 'anniversary' => 'anniversary', 'assistantName' => 'assistant:0', //'assistantPhoneNumber' => 'assistantphonenumber', 'birthday' => 'birthday', 'body' => 'notes', 'businessAddressCity' => 'address.work.locality', 'businessAddressCountry' => 'address.work.country', 'businessAddressPostalCode' => 'address.work.code', 'businessAddressState' => 'address.work.region', 'businessAddressStreet' => 'address.work.street', 'businessFaxNumber' => 'phone.workfax.number', 'businessPhoneNumber' => 'phone.work.number', 'carPhoneNumber' => 'phone.car.number', //'categories' => 'categories', 'children' => 'children', 'companyName' => 'organization', 'department' => 'department', //'email1Address' => 'email:0', //'email2Address' => 'email:1', //'email3Address' => 'email:2', //'fileAs' => 'fileas', //@TODO: ? 'firstName' => 'firstname', //'home2PhoneNumber' => 'home2phonenumber', 'homeAddressCity' => 'address.home.locality', 'homeAddressCountry' => 'address.home.country', 'homeAddressPostalCode' => 'address.home.code', 'homeAddressState' => 'address.home.region', 'homeAddressStreet' => 'address.home.street', 'homeFaxNumber' => 'phone.homefax.number', 'homePhoneNumber' => 'phone.home.number', 'jobTitle' => 'jobtitle', 'lastName' => 'surname', 'middleName' => 'middlename', 'mobilePhoneNumber' => 'phone.mobile.number', //'officeLocation' => 'officelocation', 'otherAddressCity' => 'address.office.locality', 'otherAddressCountry' => 'address.office.country', 'otherAddressPostalCode' => 'address.office.code', 'otherAddressState' => 'address.office.region', 'otherAddressStreet' => 'address.office.street', 'pagerNumber' => 'phone.pager.number', 'picture' => 'photo', //'radioPhoneNumber' => 'radiophonenumber', //'rtf' => 'rtf', 'spouse' => 'spouse', 'suffix' => 'suffix', 'title' => 'prefix', 'webPage' => 'website.homepage.url', //'yomiCompanyName' => 'yomicompanyname', //'yomiFirstName' => 'yomifirstname', //'yomiLastName' => 'yomilastname', // Mapping from ActiveSync Contacts2 namespace fields //'accountName' => 'accountname', //'companyMainPhone' => 'companymainphone', //'customerId' => 'customerid', //'governmentId' => 'governmentid', 'iMAddress' => 'im:0', 'iMAddress2' => 'im:1', 'iMAddress3' => 'im:2', 'managerName' => 'manager:0', //'mMS' => 'mms', 'nickName' => 'nickname', ]; /** * Kolab object type * * @var string */ protected $modelName = 'contact'; /** * Type of the default folder * * @var int */ protected $defaultFolderType = Syncroton_Command_FolderSync::FOLDERTYPE_CONTACT; /** * Default container for new entries * * @var string */ protected $defaultFolder = 'Contacts'; /** * Type of user created folders * * @var int */ protected $folderType = Syncroton_Command_FolderSync::FOLDERTYPE_CONTACT_USER_CREATED; /** * Identifier of special Global Address List folder * * @var string */ protected $galFolder = 'GAL'; /** * Name of special Global Address List folder * * @var string */ protected $galFolderName = 'Global Address Book'; protected $galPrefix = 'GAL:'; protected $galSources; protected $galResult; protected $galCache; /** * Creates model object * * @param Syncroton_Model_SyncCollection $collection Collection data * @param string $serverId Local entry identifier * * @return array|Syncroton_Model_Contact Contact object */ public function getEntry(Syncroton_Model_SyncCollection $collection, $serverId) { $data = is_array($serverId) ? $serverId : $this->getObject($collection->collectionId, $serverId); $result = []; if (empty($data)) { throw new Syncroton_Exception_NotFound("Contact $serverId not found"); } // Contacts namespace fields foreach ($this->mapping as $key => $name) { $value = $this->getKolabDataItem($data, $name); switch ($name) { case 'photo': if ($value) { // ActiveSync limits photo size to 48KB (of base64 encoded string) if (strlen($value) * 1.33 > 48 * 1024) { continue 2; } } break; case 'birthday': case 'anniversary': $value = self::date_from_kolab($value); break; case 'notes': $value = $this->body_from_kolab($value, $collection); break; } if (empty($value) || is_array($value)) { continue; } $result[$key] = $value; } // email address(es): email1Address, email2Address, email3Address for ($x = 0; $x < 3; $x++) { if (!empty($data['email'][$x])) { $email = $data['email'][$x]; if (is_array($email)) { $email = $email['address']; } if ($email) { $result['email' . ($x + 1) . 'Address'] = $email; } } } return new Syncroton_Model_Contact($result); } /** * convert contact from xml to libkolab array * * @param Syncroton_Model_Contact $data Contact to convert * @param string $folderId Folder identifier * @param array $entry Existing entry * * @return array Kolab object array */ public function toKolab($data, $folderId, $entry = null) { $contact = !empty($entry) ? $entry : []; // Contacts namespace fields foreach ($this->mapping as $key => $name) { $value = $data->$key; switch ($name) { case 'address.work.street': if (strtolower($this->device->devicetype) == 'palm') { // palm pre sends the whole address in the tag $value = null; } break; case 'website.homepage.url': // remove facebook urls if (preg_match('/^fb:\/\//', $value)) { $value = null; } break; case 'notes': $value = $this->getBody($value, Syncroton_Model_EmailBody::TYPE_PLAINTEXT); // If note isn't specified keep old note if ($value === null) { continue 2; } break; case 'photo': // If photo isn't specified keep old photo if ($value === null) { continue 2; } break; case 'birthday': case 'anniversary': if ($value) { // convert date to string format, so libkolab will store // it with no time and timezone what could be incorrectly re-calculated (#2555) $value = $value->format('Y-m-d'); } break; } $this->setKolabDataItem($contact, $name, $value); } // email address(es): email1Address, email2Address, email3Address $emails = []; for ($x = 0; $x < 3; $x++) { $key = 'email' . ($x + 1) . 'Address'; if ($value = $data->$key) { // Android sends email address as: Lars Kneschke if (preg_match('/(.*)<(.+@[^@]+)>/', $value, $matches)) { $value = trim($matches[2]); } // sanitize email address, it can contain broken (non-unicode) characters (#3287) $value = rcube_charset::clean($value); // try to find address type, at least we can do this if // address wasn't changed $type = ''; foreach ((array)$contact['email'] as $email) { if ($email['address'] == $value) { $type = $email['type']; } } $emails[] = ['address' => $value, 'type' => $type]; } } $contact['email'] = $emails; return $contact; } /** * Return list of supported folders for this backend * * @return array */ public function getAllFolders() { $list = parent::getAllFolders(); if ($this->isMultiFolder() && $this->hasGAL()) { $list[$this->galFolder] = new Syncroton_Model_Folder([ 'displayName' => $this->galFolderName, // @TODO: localization? 'serverId' => $this->galFolder, 'parentId' => 0, 'type' => 14, ]); } return $list; } /** * Updates a folder */ public function updateFolder(Syncroton_Model_IFolder $folder) { if ($folder->serverId === $this->galFolder && $this->hasGAL()) { throw new Syncroton_Exception_AccessDenied("Updating GAL folder is not possible"); } return parent::updateFolder($folder); } /** * Deletes a folder */ public function deleteFolder($folder) { if ($folder instanceof Syncroton_Model_IFolder) { $folder = $folder->serverId; } if ($folder === $this->galFolder && $this->hasGAL()) { throw new Syncroton_Exception_AccessDenied("Deleting GAL folder is not possible"); } return parent::deleteFolder($folder); } /** * Empty folder (remove all entries and optionally subfolders) * * @param string $folderid Folder identifier * @param array $options Options */ public function emptyFolderContents($folderid, $options) { if ($folderid === $this->galFolder && $this->hasGAL()) { throw new Syncroton_Exception_AccessDenied("Emptying GAL folder is not possible"); } return parent::emptyFolderContents($folderid, $options); } /** * Moves object into another location (folder) * * @param string $srcFolderId Source folder identifier * @param string $serverId Object identifier * @param string $dstFolderId Destination folder identifier * * @throws Syncroton_Exception_Status * @return string New object identifier */ public function moveItem($srcFolderId, $serverId, $dstFolderId) { if (strpos($serverId, $this->galPrefix) === 0 && $this->hasGAL()) { throw new Syncroton_Exception_AccessDenied("Moving GAL entries is not possible"); } if ($srcFolderId === $this->galFolder && $this->hasGAL()) { throw new Syncroton_Exception_AccessDenied("Moving/Deleting GAL entries is not possible"); } if ($dstFolderId === $this->galFolder && $this->hasGAL()) { throw new Syncroton_Exception_AccessDenied("Creating GAL entries is not possible"); } return parent::moveItem($srcFolderId, $serverId, $dstFolderId); } /** * Add entry * * @param string $folderId Folder identifier * @param Syncroton_Model_IEntry $entry Entry object * * @return string ID of the created entry */ public function createEntry($folderId, Syncroton_Model_IEntry $entry) { if ($folderId === $this->galFolder && $this->hasGAL()) { throw new Syncroton_Exception_AccessDenied("Creating GAL entries is not possible"); } return parent::createEntry($folderId, $entry); } /** * update existing entry * * @param string $folderId * @param string $serverId * @param Syncroton_Model_IEntry $entry * * @return string ID of the updated entry */ public function updateEntry($folderId, $serverId, Syncroton_Model_IEntry $entry) { if (strpos($serverId, $this->galPrefix) === 0 && $this->hasGAL()) { throw new Syncroton_Exception_AccessDenied("Updating GAL entries is not possible"); } return parent::updateEntry($folderId, $serverId, $entry); } /** * Delete an entry * * @param string $folderId * @param string $serverId * @param ?Syncroton_Model_SyncCollection $collectionData */ public function deleteEntry($folderId, $serverId, $collectionData = null) { if (strpos($serverId, $this->galPrefix) === 0 && $this->hasGAL()) { throw new Syncroton_Exception_AccessDenied("Deleting GAL entries is not possible"); } return parent::deleteEntry($folderId, $serverId, $collectionData); } /** * Returns filter query array according to specified ActiveSync FilterType * * @param int $filter_type Filter type * * @return array Filter query */ protected function filter($filter_type = 0) { // specify object type, contact folders in Kolab might // contain also ditribution-list objects, we'll skip them return [['type', '=', $this->modelName]]; } /** * Check if GAL synchronization is enabled for current device */ protected function hasGAL() { return count($this->getGALSources()); } /** * Search for existing entries * * @param string $folderid Folder identifier * @param array $filter Search filter * @param int $result_type Type of the result (see RESULT_* constants) * * @return array|int Search result as count or array of uids/objects */ - protected function searchEntries($folderid, $filter = [], $result_type = self::RESULT_UID) + protected function searchEntries($folderid, $filter = [], $result_type = self::RESULT_UID, $extraData = null) { // GAL Folder exists, return result from LDAP only if ($folderid === $this->galFolder && $this->hasGAL()) { return $this->searchGALEntries($filter, $result_type); } - $result = parent::searchEntries($folderid, $filter, $result_type); + $result = parent::searchEntries($folderid, $filter, $result_type, $extraData); // Merge results from LDAP if ($this->hasGAL() && !$this->isMultiFolder()) { $gal_result = $this->searchGALEntries($filter, $result_type); if ($result_type == self::RESULT_COUNT) { $result += $gal_result; } else { $result = array_merge($result, $gal_result); } } return $result; } /** * Fetches the entry from the backend */ protected function getObject($folderid, $entryid) { if (strpos($entryid, $this->galPrefix) === 0 && $this->hasGAL()) { return $this->getGALEntry($entryid); } return parent::getObject($folderid, $entryid); } /** * Search for existing LDAP entries * * @param array $filter Search filter * @param int $result_type Type of the result (see RESULT_* constants) * * @return array|int Search result as count or array of uids/objects */ protected function searchGALEntries($filter, $result_type) { // For GAL we don't check for changes. // When something changed a new UID will be generated so the update // will be done as delete + create foreach ($filter as $f) { if ($f[0] == 'changed') { return $result_type == self::RESULT_COUNT ? 0 : []; } } if ($this->galCache && ($result = $this->galCache->get('index')) !== null) { $result = explode("\n", $result); return $result_type == self::RESULT_COUNT ? count($result) : $result; } $result = []; foreach ($this->getGALSources() as $source) { if ($book = kolab_sync_data_gal::get_address_book($source['id'])) { $book->reset(); $book->set_page(1); $book->set_pagesize(10000); $set = $book->list_records(); foreach ($set as $contact) { $result[] = $this->createGALEntryUID($contact, $source['id']); } } } if ($this->galCache) { $this->galCache->set('index', implode("\n", $result)); } return $result_type == self::RESULT_COUNT ? count($result) : $result; } /** * Return specified LDAP entry * * @param string $serverId Entry identifier * * @return array|null Contact data */ protected function getGALEntry($serverId) { [$source, $timestamp, $uid] = $this->resolveGALEntryUID($serverId); if ($source && $uid && ($book = kolab_sync_data_gal::get_address_book($source))) { $book->reset(); $set = $book->search('uid', [$uid], rcube_addressbook::SEARCH_STRICT, true, true); $result = $set->first(); if ($result['uid'] == $uid && $result['changed'] == $timestamp) { // As in kolab_sync_data_gal we use only one email address if (empty($result['email'])) { $emails = $book->get_col_values('email', $result, true); $result['email'] = [$emails[0]]; } return $result; } } return null; } /** * Return LDAP address books list * * @return array Address books array */ protected function getGALSources() { if ($this->galSources === null) { $rcube = rcube::get_instance(); $gal_sync = $rcube->config->get('activesync_gal_sync'); $enabled = false; if ($gal_sync === true) { $enabled = true; } elseif (is_array($gal_sync)) { $enabled = $this->deviceTypeFilter($gal_sync); } $this->galSources = $enabled ? kolab_sync_data_gal::get_address_sources() : []; if ($cache_type = $rcube->config->get('activesync_gal_cache', 'db')) { $cache_ttl = $rcube->config->get('activesync_gal_cache_ttl', '1d'); $this->galCache = $rcube->get_cache('activesync_gal', $cache_type, $cache_ttl, false); // expunge cache every now and then if (rand(0, 10) === 0) { $this->galCache->expunge(); } } } return $this->galSources; } /** * Builds contact identifier from contact data and source id */ protected function createGALEntryUID($contact, $source_id) { return $this->galPrefix . sprintf('%s:%s:%s', rcube_ldap::dn_encode($source_id), $contact['changed'], $contact['uid']); } /** * Extracts contact identification data from contact identifier */ protected function resolveGALEntryUID($uid) { if (strpos($uid, $this->galPrefix) === 0) { $items = explode(':', substr($uid, strlen($this->galPrefix))); $items[0] = rcube_ldap::dn_decode($items[0]); return $items; // source, timestamp, uid } return []; } } diff --git a/lib/kolab_sync_storage.php b/lib/kolab_sync_storage.php index c098ad1..948749c 100644 --- a/lib/kolab_sync_storage.php +++ b/lib/kolab_sync_storage.php @@ -1,2070 +1,2017 @@ | | | | This program is free software: you can redistribute it and/or modify | | it under the terms of the GNU Affero General Public License as published | | by the Free Software Foundation, either version 3 of the License, or | | (at your option) any later version. | | | | This program 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 Affero General Public License for more details. | | | | You should have received a copy of the GNU Affero General Public License | | along with this program. If not, see | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ /** * Storage handling class with basic Kolab support (everything stored in IMAP) */ class kolab_sync_storage { public const INIT_SUB_PERSONAL = 1; // all subscribed folders in personal namespace public const INIT_ALL_PERSONAL = 2; // all folders in personal namespace public const INIT_SUB_OTHER = 4; // all subscribed folders in other users namespace public const INIT_ALL_OTHER = 8; // all folders in other users namespace public const INIT_SUB_SHARED = 16; // all subscribed folders in shared namespace public const INIT_ALL_SHARED = 32; // all folders in shared namespace public const MODEL_CALENDAR = 'event'; public const MODEL_CONTACTS = 'contact'; public const MODEL_EMAIL = 'mail'; public const MODEL_NOTES = 'note'; public const MODEL_TASKS = 'task'; public const ROOT_MAILBOX = 'INBOX'; public const ASYNC_KEY = '/private/vendor/kolab/activesync'; public const UID_KEY = '/shared/vendor/cmu/cyrus-imapd/uniqueid'; public const CTYPE_KEY = '/shared/vendor/kolab/folder-type'; public const CTYPE_KEY_PRIVATE = '/private/vendor/kolab/folder-type'; public $syncTimeStamp; protected $storage; protected $folder_meta; protected $folder_uids; protected $folders = []; - protected $modseq = []; protected $root_meta; protected $relations = []; protected $relationSupport = true; protected $tag_rts = []; + private $modseq = []; protected static $instance; protected static $types = [ 1 => '', 2 => 'mail.inbox', 3 => 'mail.drafts', 4 => 'mail.wastebasket', 5 => 'mail.sentitems', 6 => 'mail.outbox', 7 => 'task.default', 8 => 'event.default', 9 => 'contact.default', 10 => 'note.default', 11 => 'journal.default', 12 => 'mail', 13 => 'event', 14 => 'contact', 15 => 'task', 16 => 'journal', 17 => 'note', ]; /** * This implements the 'singleton' design pattern * * @return kolab_sync_storage The one and only instance */ public static function get_instance() { if (!self::$instance) { self::$instance = new kolab_sync_storage(); self::$instance->startup(); // init AFTER object was linked with self::$instance } return self::$instance; } /** * Class initialization */ public function startup() { $this->storage = kolab_sync::get_instance()->get_storage(); // set additional header used by libkolab $this->storage->set_options([ // @TODO: there can be Roundcube plugins defining additional headers, // we maybe would need to add them here 'fetch_headers' => 'X-KOLAB-TYPE X-KOLAB-MIME-VERSION', 'skip_deleted' => true, 'threading' => false, ]); // Disable paging $this->storage->set_pagesize(999999); } /** * Clear internal cache state */ public function reset() { $this->folders = []; } /** * List known devices * * @return array Device list as hash array */ public function devices_list() { if ($this->root_meta === null) { // @TODO: consider server annotation instead of INBOX if ($meta = $this->storage->get_metadata(self::ROOT_MAILBOX, self::ASYNC_KEY)) { $this->root_meta = $this->unserialize_metadata($meta[self::ROOT_MAILBOX][self::ASYNC_KEY]); } else { $this->root_meta = []; } } if (!empty($this->root_meta['DEVICE']) && is_array($this->root_meta['DEVICE'])) { return $this->root_meta['DEVICE']; } return []; } /** * Get list of folders available for sync * * @param string $deviceid Device identifier * @param string $type Folder type * @param bool $flat_mode Enables flat-list mode * * @return array|bool List of mailbox folders, False on backend failure */ public function folders_list($deviceid, $type, $flat_mode = false) { // get all folders of specified type $folders = kolab_storage::list_folders('', '*', $type, false, $typedata); // get folders activesync config $folderdata = $this->folder_meta(); if (!is_array($folders) || !is_array($folderdata)) { return false; } $folders_list = []; // check if folders are "subscribed" for activesync foreach ($folderdata as $folder => $meta) { if (empty($meta['FOLDER']) || empty($meta['FOLDER'][$deviceid]) || empty($meta['FOLDER'][$deviceid]['S']) ) { continue; } // force numeric folder name to be a string (T1283) $folder = (string) $folder; if (!empty($type) && !in_array($folder, $folders)) { continue; } // Activesync folder identifier (serverId) $folder_type = !empty($typedata[$folder]) ? $typedata[$folder] : 'mail'; $folder_id = $this->folder_id($folder, $folder_type); $folders_list[$folder_id] = $this->folder_data($folder, $folder_type); } if ($flat_mode) { $folders_list = $this->folders_list_flat($folders_list, $type, $typedata); } return $folders_list; } /** * Converts list of folders to a "flat" list */ protected function folders_list_flat($folders, $type, $typedata) { $delim = $this->storage->get_hierarchy_delimiter(); foreach ($folders as $idx => $folder) { if ($folder['parentId']) { // for non-mail folders we make the list completely flat if ($type != self::MODEL_EMAIL) { $display_name = kolab_storage::object_name($folder['imap_name']); $display_name = html_entity_decode($display_name, ENT_COMPAT, RCUBE_CHARSET); $folders[$idx]['parentId'] = 0; $folders[$idx]['displayName'] = $display_name; } // for mail folders we modify only folders with non-existing parents elseif (!isset($folders[$folder['parentId']])) { $items = explode($delim, $folder['imap_name']); $parent = 0; // find existing parent while (count($items) > 0) { array_pop($items); $parent_name = implode($delim, $items); $parent_type = !empty($typedata[$parent_name]) ? $typedata[$parent_name] : 'mail'; $parent_id = $this->folder_id($parent_name, $parent_type); if (isset($folders[$parent_id])) { $parent = $parent_id; break; } } if (!$parent) { $display_name = kolab_storage::object_name($folder['imap_name']); $display_name = html_entity_decode($display_name, ENT_COMPAT, RCUBE_CHARSET); } else { $parent_name = isset($parent_id) ? $folders[$parent_id]['imap_name'] : ''; $display_name = substr($folder['imap_name'], strlen($parent_name) + 1); $display_name = rcube_charset::convert($display_name, 'UTF7-IMAP'); $display_name = str_replace($delim, ' » ', $display_name); } $folders[$idx]['parentId'] = $parent; $folders[$idx]['displayName'] = $display_name; } } } return $folders; } /** * Getter for folder metadata * * @return array|bool Hash array with meta data for each folder, False on backend failure */ protected function folder_meta() { if (!isset($this->folder_meta)) { // get folders activesync config $folderdata = $this->storage->get_metadata("*", self::ASYNC_KEY); if (!is_array($folderdata)) { return $this->folder_meta = false; } $this->folder_meta = []; foreach ($folderdata as $folder => $meta) { if (isset($meta[self::ASYNC_KEY])) { if ($metadata = $this->unserialize_metadata($meta[self::ASYNC_KEY])) { $this->folder_meta[$folder] = $metadata; } } } } return $this->folder_meta; } /** * Creates folder and subscribes to the device * * @param string $name Folder name (UTF8) * @param int $type Folder (ActiveSync) type * @param string $deviceid Device identifier * @param ?string $parentid Parent folder id identifier * * @return string|false New folder identifier on success, False on failure */ public function folder_create($name, $type, $deviceid, $parentid = null) { $parent = null; $name = rcube_charset::convert($name, kolab_sync::CHARSET, 'UTF7-IMAP'); if ($parentid) { $parent = $this->folder_id2name($parentid, $deviceid); if ($parent === null) { throw new Syncroton_Exception_Status_FolderCreate(Syncroton_Exception_Status_FolderCreate::PARENT_NOT_FOUND); } } if ($parent !== null) { $delim = $this->storage->get_hierarchy_delimiter(); $name = $parent . $delim . $name; } if ($this->storage->folder_exists($name)) { throw new Syncroton_Exception_Status_FolderCreate(Syncroton_Exception_Status_FolderCreate::FOLDER_EXISTS); } $type = self::type_activesync2kolab($type); $created = kolab_storage::folder_create($name, $type, true); if ($created) { // Set ActiveSync subscription flag $this->folder_set($name, $deviceid, 1); return $this->folder_id($name, $type); } // Special case when client tries to create a subfolder of INBOX // which is not possible on Cyrus-IMAP (T2223) if ($parent === 'INBOX' && stripos($this->last_error(), 'invalid') !== false) { throw new Syncroton_Exception_Status_FolderCreate(Syncroton_Exception_Status_FolderCreate::SPECIAL_FOLDER); } return false; } /** * Renames a folder * * @param string $folderid Folder identifier * @param string $deviceid Device identifier * @param string $new_name New folder name (UTF8) * @param ?string $parentid Folder parent identifier * * @return bool True on success, False on failure */ public function folder_rename($folderid, $deviceid, $new_name, $parentid) { $old_name = $this->folder_id2name($folderid, $deviceid); if ($parentid) { $parent = $this->folder_id2name($parentid, $deviceid); } $name = rcube_charset::convert($new_name, kolab_sync::CHARSET, 'UTF7-IMAP'); if (isset($parent)) { $delim = $this->storage->get_hierarchy_delimiter(); $name = $parent . $delim . $name; } // Rename/move IMAP folder if ($name === $old_name) { return true; } $this->folder_meta = null; // TODO: folder type change? $type = kolab_storage::folder_type($old_name); // don't use kolab_storage for moving mail folders if (preg_match('/^mail/', $type)) { return $this->storage->rename_folder($old_name, $name); } else { return kolab_storage::folder_rename($old_name, $name); } } /** * Deletes folder * * @param string $folderid Folder identifier * @param string $deviceid Device identifier * * @return bool True on success, False otherwise */ public function folder_delete($folderid, $deviceid) { $name = $this->folder_id2name($folderid, $deviceid); $type = kolab_storage::folder_type($name); unset($this->folder_meta[$name]); // don't use kolab_storage for deleting mail folders if (preg_match('/^mail/', $type)) { return $this->storage->delete_folder($name); } return kolab_storage::folder_delete($name); } /** * Deletes contents of a folder * * @param string $folderid Folder identifier * @param string $deviceid Device identifier * @param bool $recursive Apply to the folder and its subfolders * * @return bool True on success, False otherwise */ public function folder_empty($folderid, $deviceid, $recursive = false) { $foldername = $this->folder_id2name($folderid, $deviceid); // Remove all entries if (!$this->storage->clear_folder($foldername)) { return false; } // Remove subfolders if ($recursive) { $delim = $this->storage->get_hierarchy_delimiter(); $folderdata = $this->folder_meta(); if (!is_array($folderdata)) { return false; } foreach ($folderdata as $subfolder => $meta) { if (!empty($meta['FOLDER'][$deviceid]['S']) && strpos((string) $subfolder, $foldername . $delim)) { if (!$this->storage->clear_folder((string) $subfolder)) { return false; } } } } return true; } /** * Sets ActiveSync subscription flag on a folder * * @param string $name Folder name (UTF7-IMAP) * @param string $deviceid Device identifier * @param int $flag Flag value (0|1|2) * * @return bool True on success, False on failure */ protected function folder_set($name, $deviceid, $flag) { if (empty($deviceid)) { return false; } // get folders activesync config $metadata = $this->folder_meta(); if (!is_array($metadata)) { return false; } $metadata = $metadata[$name] ?? []; if ($flag) { if (empty($metadata)) { $metadata = []; } if (empty($metadata['FOLDER'])) { $metadata['FOLDER'] = []; } if (empty($metadata['FOLDER'][$deviceid])) { $metadata['FOLDER'][$deviceid] = []; } // Z-Push uses: // 1 - synchronize, no alarms // 2 - synchronize with alarms $metadata['FOLDER'][$deviceid]['S'] = $flag; } else { unset($metadata['FOLDER'][$deviceid]['S']); if (empty($metadata['FOLDER'][$deviceid])) { unset($metadata['FOLDER'][$deviceid]); } if (empty($metadata['FOLDER'])) { unset($metadata['FOLDER']); } if (empty($metadata)) { $metadata = null; } } // Return if nothing's been changed if (!self::data_array_diff($this->folder_meta[$name] ?? null, $metadata)) { return true; } $this->folder_meta[$name] = $metadata; return $this->storage->set_metadata($name, [self::ASYNC_KEY => $this->serialize_metadata($metadata)]); } /** * Returns device metadata * * @param string $id Device ID * * @return array|null Device metadata */ public function device_get($id) { $devices_list = $this->devices_list(); return $devices_list[$id] ?? null; } /** * Registers new device on server * * @param array $device Device data * @param string $id Device ID * * @return bool True on success, False on failure */ public function device_create($device, $id) { // Fill local cache $this->devices_list(); // Some devices create dummy devices with name "validate" (#1109) // This device entry is used in two initial requests, but later // the device registers a real name. We can remove this dummy entry // on new device creation $this->device_delete('validate'); // Old Kolab_ZPush device parameters // MODE: -1 | 0 | 1 (not set | flatmode | foldermode) // TYPE: device type string // ALIAS: user-friendly device name // Syncroton (kolab_sync_backend_device) uses // ID: internal identifier in syncroton database // TYPE: device type string // ALIAS: user-friendly device name $metadata = $this->root_meta; $metadata['DEVICE'][$id] = $device; $metadata = [self::ASYNC_KEY => $this->serialize_metadata($metadata)]; $result = $this->storage->set_metadata(self::ROOT_MAILBOX, $metadata); if ($result) { // Update local cache $this->root_meta['DEVICE'][$id] = $device; // subscribe default set of folders $this->device_init_subscriptions($id); } return $result; } /** * Device update. * * @param array $device Device data * @param string $id Device ID * * @return bool True on success, False on failure */ public function device_update($device, $id) { $devices_list = $this->devices_list(); $old_device = $devices_list[$id]; if (!$old_device) { return false; } // Do nothing if nothing is changed if (!self::data_array_diff($old_device, $device)) { return true; } $device = array_merge($old_device, $device); $metadata = $this->root_meta; $metadata['DEVICE'][$id] = $device; $metadata = [self::ASYNC_KEY => $this->serialize_metadata($metadata)]; $result = $this->storage->set_metadata(self::ROOT_MAILBOX, $metadata); if ($result) { // Update local cache $this->root_meta['DEVICE'][$id] = $device; } return $result; } /** * Device delete. * * @param string $id Device ID * * @return bool True on success, False on failure */ public function device_delete($id) { $device = $this->device_get($id); if (!$device) { return false; } unset($this->root_meta['DEVICE'][$id], $this->root_meta['FOLDER'][$id]); if (empty($this->root_meta['DEVICE'])) { unset($this->root_meta['DEVICE']); } if (empty($this->root_meta['FOLDER'])) { unset($this->root_meta['FOLDER']); } $metadata = $this->serialize_metadata($this->root_meta); $metadata = [self::ASYNC_KEY => $metadata]; // update meta data $result = $this->storage->set_metadata(self::ROOT_MAILBOX, $metadata); if ($result) { // remove device annotation for every folder foreach ($this->folder_meta() as $folder => $meta) { // skip root folder (already handled above) if ($folder == self::ROOT_MAILBOX) { continue; } if (!empty($meta['FOLDER']) && isset($meta['FOLDER'][$id])) { unset($meta['FOLDER'][$id]); if (empty($meta['FOLDER'])) { unset($this->folder_meta[$folder]['FOLDER']); unset($meta['FOLDER']); } if (empty($meta)) { unset($this->folder_meta[$folder]); $meta = null; } $metadata = [self::ASYNC_KEY => $this->serialize_metadata($meta)]; $res = $this->storage->set_metadata($folder, $metadata); if ($res && $meta) { $this->folder_meta[$folder] = $meta; } } } } return $result; } /** * Creates an item in a folder. * * @param string $folderid Folder identifier * @param string $deviceid Device identifier * @param string $type Activesync model name (folder type) * @param string|array $data Object data (string for email, array for other types) * @param array $params Additional parameters (e.g. mail flags) * * @return string|null Item UID on success or null on failure */ public function createItem($folderid, $deviceid, $type, $data, $params = []) { if ($type == self::MODEL_EMAIL) { $foldername = $this->folder_id2name($folderid, $deviceid); $uid = $this->storage->save_message($foldername, $data, '', false, $params['flags'] ?? []); if (!$uid) { // $this->logger->error("Error while storing the message " . $this->storage->get_error_str()); } return $uid; } $useTags = $this->relationSupport && ($type == self::MODEL_TASKS || $type == self::MODEL_NOTES); // convert categories into tags, save them after creating an object if ($useTags && !empty($data['categories'])) { $tags = $data['categories']; unset($data['categories']); } $folder = $this->getFolder($folderid, $deviceid, $type); // Set User-Agent for saved objects $app = kolab_sync::get_instance(); $app->config->set('useragent', $app->app_name . ' ' . kolab_sync::VERSION); if ($folder && $folder->valid && $folder->save($data)) { if (!empty($tags) && ($type == self::MODEL_TASKS || $type == self::MODEL_NOTES)) { $this->setCategories($data['uid'], $tags); } return $data['uid']; } return null; } /** * Deletes an item from a folder by UID. * * @param string $folderid Folder identifier * @param string $deviceid Device identifier * @param string $type Activesync model name (folder type) * @param string $uid Requested object UID * @param bool $moveToTrash Move to trash, instead of delete (for mail messages only) * * @return bool True on success, False on failure */ public function deleteItem($folderid, $deviceid, $type, $uid, $moveToTrash = false) { if ($type == self::MODEL_EMAIL) { $foldername = $this->folder_id2name($folderid, $deviceid); $trash = kolab_sync::get_instance()->config->get('trash_mbox'); // move message to the Trash folder if ($moveToTrash && strlen($trash) && $trash != $foldername && $this->storage->folder_exists($trash)) { return $this->storage->move_message($uid, $trash, $foldername); } // delete the message // According to the ActiveSync spec. "If the DeletesAsMoves element is set to false, // the deletion is PERMANENT.", therefore we delete the message, and not flag as deleted. // FIXME: We could consider acting according to the 'flag_for_deletion' setting. // Don't forget about 'read_when_deleted' setting then. // $this->storage->set_flag($uid, 'DELETED', $foldername); // $this->storage->set_flag($uid, 'SEEN', $foldername); return $this->storage->delete_message($uid, $foldername); } $useTags = $this->relationSupport && ($type == self::MODEL_TASKS || $type == self::MODEL_NOTES); $folder = $this->getFolder($folderid, $deviceid, $type); if (!$folder || !$folder->valid) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } if ($folder->delete($uid)) { if ($useTags) { $this->setCategories($uid, []); } return true; } return false; } /** * Updates an item in a folder. * * @param string $folderid Folder identifier * @param string $deviceid Device identifier * @param string $type Activesync model name (folder type) * @param string $uid Object UID * @param string|array $data Object data (string for email, array for other types) * @param array $params Additional parameters (e.g. mail flags) * * @return string|null Item UID on success or null on failure */ public function updateItem($folderid, $deviceid, $type, $uid, $data, $params = []) { if ($type == self::MODEL_EMAIL) { $foldername = $this->folder_id2name($folderid, $deviceid); // Note: We do not support a message body update, as it's not needed foreach (($params['flags'] ?? []) as $flag) { $this->storage->set_flag($uid, $flag, $foldername); } // Categories (Tags) change if (isset($params['categories']) && $this->relationSupport) { $message = new rcube_message($uid, $foldername); if (empty($message->headers)) { throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR); } $this->setCategories($message, $params['categories']); } return $uid; } $folder = $this->getFolder($folderid, $deviceid, $type); $useTags = $this->relationSupport && ($type == self::MODEL_TASKS || $type == self::MODEL_NOTES); // convert categories into tags, save them after updating an object if ($useTags && array_key_exists('categories', $data)) { $tags = (array) $data['categories']; unset($data['categories']); } // Set User-Agent for saved objects $app = kolab_sync::get_instance(); $app->config->set('useragent', $app->app_name . ' ' . kolab_sync::VERSION); if ($folder && $folder->valid && $folder->save($data, $type, $uid)) { if (isset($tags)) { $this->setCategories($uid, $tags); } return $uid; } return null; } /** * Returns list of categories assigned to an object * * @param object|string $object UID or rcube_message object * @param array $categories Addition tag names to merge with * * @return array List of categories */ public function getCategories($object, $categories = []) { if (is_object($object)) { // support only messages with message-id if (!($msg_id = $object->headers->get('message-id', false))) { return []; } $config = kolab_storage_config::get_instance(); $delta = Syncroton_Registry::getPingTimeout(); $folder = $object->folder; $uid = $object->uid; // get tag objects raleted to specified message-id $tags = $config->get_tags($msg_id); foreach ($tags as $idx => $tag) { // resolve members if it wasn't done recently $force = empty($this->tag_rts[$tag['uid']]) || $this->tag_rts[$tag['uid']] <= time() - $delta; $members = $config->resolve_members($tag, $force); if (empty($members[$folder]) || !in_array($uid, $members[$folder])) { unset($tags[$idx]); } if ($force) { $this->tag_rts[$tag['uid']] = time(); } } // make sure current folder is set correctly again $this->storage->set_folder($folder); } else { $config = kolab_storage_config::get_instance(); $tags = $config->get_tags($object); } $tags = array_filter(array_map(function ($v) { return $v['name']; }, $tags)); // merge result with old categories if (!empty($categories)) { $tags = array_unique(array_merge($tags, (array) $categories)); } return $tags; } /** * Gets kolab_storage_folder object from Activesync folder ID. * * @param string $folderid Folder identifier * @param string $deviceid Device identifier * @param string $type Activesync model name (folder type) * * @return ?kolab_storage_folder */ public function getFolder($folderid, $deviceid, $type) { $unique_key = "$folderid:$deviceid:$type"; if (array_key_exists($unique_key, $this->folders)) { return $this->folders[$unique_key]; } $foldername = $this->folder_id2name($folderid, $deviceid); return $this->folders[$unique_key] = kolab_storage::get_folder($foldername, $type); } /** * Gets Activesync preferences for a folder. * * @param string $folderid Folder identifier * @param string $deviceid Device identifier * @param string $type Activesync model name (folder type) * * @return array Folder preferences */ public function getFolderConfig($folderid, $deviceid, $type) { $foldername = $this->folder_id2name($folderid, $deviceid); $metadata = $this->folder_meta(); $config = []; if (!empty($metadata[$foldername]['FOLDER'][$deviceid])) { $config = $metadata[$foldername]['FOLDER'][$deviceid]; } return [ 'ALARMS' => ($config['S'] ?? 0) == 2, ]; } /** * Gets an item from a folder by UID. * * @param string $folderid Folder identifier * @param string $deviceid Device identifier * @param string $type Activesync model name (folder type) * @param string $uid Requested object UID * * @return array|rcube_message|null Object properties */ public function getItem($folderid, $deviceid, $type, $uid) { if ($type == self::MODEL_EMAIL) { $foldername = $this->folder_id2name($folderid, $deviceid); $message = new rcube_message($uid, $foldername); if (!empty($message->headers)) { if ($this->relationSupport) { $message->headers->others['categories'] = $this->getCategories($message); } return $message; } return null; } $folder = $this->getFolder($folderid, $deviceid, $type); if (!$folder || !$folder->valid) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } $result = $folder->get_object($uid); if ($result === false) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } $useTags = $this->relationSupport && ($type == self::MODEL_TASKS || $type == self::MODEL_NOTES); if ($useTags) { $result['categories'] = $this->getCategories($uid, $result['categories'] ?? []); } return $result; } /** * Gets items matching UID by prefix. * * @param string $folderid Folder identifier * @param string $deviceid Device identifier * @param string $type Activesync model name (folder type) * @param string $uid Requested object UID prefix * * @return array|iterable List of objects */ public function getItemsByUidPrefix($folderid, $deviceid, $type, $uid) { $folder = $this->getFolder($folderid, $deviceid, $type); if (!$folder || !$folder->valid) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } $result = $folder->select([['uid', '~*', $uid]]); if ($result === null) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } return $result; } /** * Move an item from one folder to another. * * @param string $srcFolderId Source folder identifier * @param string $deviceid Device identifier * @param string $type Activesync model name (folder type) * @param string $uid Object UID * @param string $dstFolderId Destination folder identifier * * @return string New object UID * @throws Syncroton_Exception_Status */ public function moveItem($srcFolderId, $deviceid, $type, $uid, $dstFolderId) { if ($type === self::MODEL_EMAIL) { $src_name = $this->folder_id2name($srcFolderId, $deviceid); $dst_name = $this->folder_id2name($dstFolderId, $deviceid); if ($dst_name === null) { throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_DESTINATION); } if ($src_name === null) { throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_SOURCE); } if (!$this->storage->move_message($uid, $dst_name, $src_name)) { throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_DESTINATION); } // Use COPYUID feature (RFC2359) to get the new UID of the copied message if (empty($this->storage->conn->data['COPYUID'])) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } return $this->storage->conn->data['COPYUID'][1]; } $srcFolder = $this->getFolder($srcFolderId, $deviceid, $type); $dstFolder = $this->getFolder($dstFolderId, $deviceid, $type); if (!$srcFolder || !$dstFolder) { throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_DESTINATION); } if (!$srcFolder->move($uid, $dstFolder)) { throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_SOURCE); } return $uid; } /** * Set categories to an object * * @param object|string $object UID or rcube_message object * @param array $categories List of Category names */ public function setCategories($object, $categories) { if (!is_object($object)) { $config = kolab_storage_config::get_instance(); $config->save_tags($object, $categories); return; } $config = kolab_storage_config::get_instance(); $delta = Syncroton_Registry::getPingTimeout(); $uri = kolab_storage_config::get_message_uri($object->headers, $object->folder); // for all tag objects... foreach ($config->get_tags() as $relation) { // resolve members if it wasn't done recently $uid = $relation['uid']; $force = empty($this->tag_rts[$uid]) || $this->tag_rts[$uid] <= time() - $delta; if ($force) { $config->resolve_members($relation, $force); $this->tag_rts[$relation['uid']] = time(); } $selected = !empty($categories) && in_array($relation['name'], $categories); $found = !empty($relation['members']) && in_array($uri, $relation['members']); $update = false; // remove member from the relation if ($found && !$selected) { $relation['members'] = array_diff($relation['members'], (array) $uri); $update = true; } // add member to the relation elseif (!$found && $selected) { $relation['members'][] = $uri; $update = true; } if ($update) { $config->save($relation, 'relation'); } $categories = array_diff($categories, (array) $relation['name']); } // create new relations if (!empty($categories)) { foreach ($categories as $tag) { $relation = [ 'name' => $tag, 'members' => (array) $uri, 'category' => 'tag', ]; $config->save($relation, 'relation'); } } // make sure current folder is set correctly again $this->storage->set_folder($object->folder); } /** * Search for existing objects in a folder * * @param string $folderid Folder identifier * @param string $deviceid Device identifier - * @param string $deviceid_key Device table primary key * @param string $type Activesync model name (folder type) * @param array $filter Filter * @param int $result_type Type of the result (see kolab_sync_data::RESULT_* constants) * @param bool $force Force IMAP folder cache synchronization + * @param string $extraData Extra data as extracted by the getExtraData during the last sync * * @return array|int Search result as count or array of uids */ - public function searchEntries($folderid, $deviceid, $deviceid_key, $type, $filter, $result_type, $force) + public function searchEntries($folderid, $deviceid, $type, $filter, $result_type, $force, $extraData) { if ($type != self::MODEL_EMAIL) { return $this->searchKolabEntries($folderid, $deviceid, $type, $filter, $result_type, $force); } $filter_str = 'ALL UNDELETED'; + $getChangesMode = false; // convert filter into one IMAP search string foreach ($filter as $idx => $filter_item) { if (is_array($filter_item)) { - // This is a request for changes since last time - // we'll use HIGHESTMODSEQ value from the last Sync if ($filter_item[0] == 'changed' && $filter_item[1] == '>') { - $modseq_lasttime = $filter_item[2]; - $modseq_data = []; - $modseq = (array) $this->modseq_get($deviceid_key, $folderid, $modseq_lasttime); + $getChangesMode = true; } } else { $filter_str .= ' ' . $filter_item; } } // get members of modified relations if ($this->relationSupport) { $changed_msgs = $this->getChangesByRelations($folderid, $deviceid, $type, $filter); } $result = $result_type == kolab_sync_data::RESULT_COUNT ? 0 : []; $foldername = $this->folder_id2name($folderid, $deviceid); if ($foldername === null) { return $result; } $this->storage->set_folder($foldername); // Synchronize folder (if it wasn't synced in this request already) if ($force) { $this->storage->folder_sync($foldername); } + $modified = true; // We're in "get changes" mode - if (isset($modseq_data)) { + if ($getChangesMode) { $folder_data = $this->storage->folder_data($foldername); - $modified = false; - // If previous HIGHESTMODSEQ doesn't exist we can't get changes - // We can only get folder's HIGHESTMODSEQ value and store it for the next try - // Skip search if HIGHESTMODSEQ didn't change + // If HIGHESTMODSEQ doesn't exist we can't get changes if (!empty($folder_data['HIGHESTMODSEQ'])) { - $modseq_data[$foldername] = $folder_data['HIGHESTMODSEQ']; - $modseq_old = $modseq[$foldername] ?? null; - if ($modseq_data[$foldername] != $modseq_old) { - $modseq_update = true; - if (!empty($modseq) && $modseq_old) { - $modified = true; + // Store modseq for later in getExtraData + if (!array_key_exists($deviceid, $this->modseq)) { + $this->modseq[$deviceid] = []; + } + $this->modseq[$deviceid][$folderid] = $folder_data['HIGHESTMODSEQ']; + // After the initial sync we have no extraData + if ($extraData) { + $modseq_old = json_decode($extraData)->modseq; + // Skip search if HIGHESTMODSEQ didn't change + if ($folder_data['HIGHESTMODSEQ'] == $modseq_old) { + $modified = false; + } else { $filter_str .= " MODSEQ " . ($modseq_old + 1); } } + } else { + // We have no way of finding the changes. + // We could fall back to search by date or ignore changes, but both seems suboptimal. + throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } - } else { - $modified = true; } // We could use messages cache by replacing search() with index() // in some cases. This however is possible only if user has skip_deleted=true, // in his Roundcube preferences, otherwise we'd make often cache re-initialization, // because Roundcube message cache can work only with one skip_deleted // setting at a time. We'd also need to make sure folder_sync() was called // before (see above). // // if ($filter_str == 'ALL UNDELETED') // $search = $this->storage->index($foldername, null, null, true, true); // else if ($modified) { $search = $this->storage->search_once($foldername, $filter_str); if (!($search instanceof rcube_result_index) || $search->is_error()) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } switch ($result_type) { case kolab_sync_data::RESULT_COUNT: $result = $search->count(); break; case kolab_sync_data::RESULT_UID: $result = $search->get(); break; } } // handle relation changes if (!empty($changed_msgs)) { $members = $this->findRelationMembersInFolder($foldername, $changed_msgs, $filter); switch ($result_type) { case kolab_sync_data::RESULT_COUNT: $result += count($members); break; case kolab_sync_data::RESULT_UID: $result = array_values(array_unique(array_merge($result, $members))); break; } } - //FIXME: - //We shouldn't delete modseq info, and instead attach it to the synckey. - //Otherwise, change detection will e.g. fail if we send the same sync-key twice - //Don't update when we just count, we can only retrieve the changes once. - //We trigger this codepath because we first count in kolab_sync_data::hasChanges adn then we fetch via kolab_sync_data::getChangedEntries - if (!empty($modseq_update) && !empty($modseq_data) && $result_type == kolab_sync_data::RESULT_UID) { - $this->modseq_set($deviceid_key, $folderid, $this->syncTimeStamp, $modseq_data); + return $result; + } + + + /** + * Return extra data that is stored with the sync key and passed in during the search to find changes. + * + * @param string $folderid Folder identifier + * @param string $deviceid Device identifier + * + * @return string|null Extra data + */ + public function getExtraData($folderid, $deviceid) + { + //We explicitly return a cached value that was used during the search. + //Otherwise we'd risk storing a higher modseq value and missing an update. + if (array_key_exists($deviceid, $this->modseq) && $value = $this->modseq[$deviceid][$folderid]) { + return json_encode(['modseq' => intval($value)]); + } - // if previous modseq information does not exist save current set as it, - // we would at least be able to detect changes since now - if (empty($result) && empty($modseq)) { - $this->modseq_set($deviceid_key, $folderid, $modseq_lasttime ?? 0, $modseq_data); + //If we didn't fetch modseq in the first place we have to fetch it now. + $foldername = $this->folder_id2name($folderid, $deviceid); + if ($foldername !== null) { + $folder_data = $this->storage->folder_data($foldername); + if (!empty($folder_data['HIGHESTMODSEQ'])) { + return json_encode(['modseq' => intval($folder_data['HIGHESTMODSEQ'])]); } } - return $result; + return null; } /** * Search for existing objects in a folder * * @param string $folderid Folder identifier * @param string $deviceid Device identifier * @param string $type Activesync model name (folder type) * @param array $filter Filter * @param int $result_type Type of the result (see kolab_sync_data::RESULT_* constants) * @param bool $force Force IMAP folder cache synchronization * * @return array|int Search result as count or array of uids */ protected function searchKolabEntries($folderid, $deviceid, $type, $filter, $result_type, $force) { // there's a PHP Warning from kolab_storage if $filter isn't an array if (empty($filter)) { $filter = []; } elseif ($this->relationSupport && ($type == self::MODEL_TASKS || $type == self::MODEL_NOTES)) { $changed_objects = $this->getChangesByRelations($folderid, $deviceid, $type, $filter); } $folder = $this->getFolder($folderid, $deviceid, $type); if (!$folder || !$folder->valid) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } $error = false; switch ($result_type) { case kolab_sync_data::RESULT_COUNT: $count = $folder->count($filter); if ($count === null) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } $result = (int) $count; break; case kolab_sync_data::RESULT_UID: default: $uids = $folder->get_uids($filter); if (!is_array($uids)) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } $result = $uids; break; } // handle tag modifications if (!empty($changed_objects)) { // build new filter // search objects mathing current filter, // relations may contain members of many types, we need to // search them by UID in all requested folders to get // only these with requested type (and that really exist // in specified folders) $tag_filter = [['uid', '=', $changed_objects]]; foreach ($filter as $f) { if ($f[0] != 'changed') { $tag_filter[] = $f; } } switch ($result_type) { case kolab_sync_data::RESULT_COUNT: // Note: this way we're potentally counting the same objects twice // I'm not sure if this is a problem, we most likely do not // need a precise result here $count = $folder->count($tag_filter); if ($count !== null) { $result += (int) $count; } break; case kolab_sync_data::RESULT_UID: default: $uids = $folder->get_uids($tag_filter); if (is_array($uids) && !empty($uids)) { $result = array_unique(array_merge($result, $uids)); } break; } } return $result; } /** * Find members (messages) in specified folder */ protected function findRelationMembersInFolder($foldername, $members, $filter) { foreach ($members as $member) { // IMAP URI members if ($url = kolab_storage_config::parse_member_url($member)) { $result[$url['folder']][$url['uid']] = $url['params']; } } // convert filter into one IMAP search string $filter_str = 'ALL UNDELETED'; foreach ($filter as $filter_item) { if (is_string($filter_item)) { $filter_str .= ' ' . $filter_item; } } $found = []; // first find messages by UID if (!empty($result[$foldername])) { $index = $this->storage->search_once($foldername, 'UID ' . rcube_imap_generic::compressMessageSet(array_keys($result[$foldername]))); $found = $index->get(); // remove found messages from the $result if (!empty($found)) { $result[$foldername] = array_diff_key($result[$foldername], array_flip($found)); if (empty($result[$foldername])) { unset($result[$foldername]); } // now apply the current filter to the found messages $index = $this->storage->search_once($foldername, $filter_str . ' UID ' . rcube_imap_generic::compressMessageSet($found)); $found = $index->get(); } } // search by message parameters if (!empty($result)) { // @TODO: do this search in chunks (for e.g. 25 messages)? $search = ''; $search_count = 0; foreach ($result as $data) { foreach ($data as $p) { $search_params = []; $search_count++; foreach ($p as $key => $val) { $key = strtoupper($key); // don't search by subject, we don't want false-positives if ($key != 'SUBJECT') { $search_params[] = 'HEADER ' . $key . ' ' . rcube_imap_generic::escape($val); } } $search .= ' (' . implode(' ', $search_params) . ')'; } } $search_str = str_repeat(' OR', $search_count - 1) . $search; // search messages in current folder $search = $this->storage->search_once($foldername, $search_str); $uids = $search->get(); if (!empty($uids)) { // add UIDs into the result $found = array_unique(array_merge($found, $uids)); } } return $found; } /** * Detect changes of relation (tag) objects data and assigned objects * Returns relation member identifiers */ protected function getChangesByRelations($folderid, $deviceid, $type, $filter) { // get period filter, create new objects filter foreach ($filter as $f) { if ($f[0] == 'changed' && $f[1] == '>') { $since = $f[2]; } } // this is not search for changes, do nothing if (empty($since)) { return; } // get relations state from the last sync $last_state = (array) $this->relations_state_get($deviceid, $folderid, $since); // get current relations state $config = kolab_storage_config::get_instance(); $default = true; $filter = [ ['type', '=', 'relation'], ['category', '=', 'tag'], ]; $relations = $config->get_objects($filter, $default, 100); $result = []; $changed = false; // compare states, get members of changed relations foreach ($relations as $relation) { $rel_id = $relation['uid']; if ($relation['changed']) { $relation['changed']->setTimezone(new DateTimeZone('UTC')); } // last state unknown... if (empty($last_state[$rel_id])) { // ...get all members if (!empty($relation['members'])) { $changed = true; $result = array_merge($result, $relation['members']); } } // last state known, changed tag name... elseif ($last_state[$rel_id]['name'] != $relation['name']) { // ...get all (old and new) members $members_old = explode("\n", $last_state[$rel_id]['members']); $changed = true; $members = array_unique(array_merge($relation['members'], $members_old)); $result = array_merge($result, $members); } // last state known, any other change change... elseif ($last_state[$rel_id]['changed'] < $relation['changed']->format('U')) { // ...find new and removed members $members_old = explode("\n", $last_state[$rel_id]['members']); $new = array_diff($relation['members'], $members_old); $removed = array_diff($members_old, $relation['members']); if (!empty($new) || !empty($removed)) { $changed = true; $result = array_merge($result, $new, $removed); } } unset($last_state[$rel_id]); } // get members of deleted relations if (!empty($last_state)) { $changed = true; foreach ($last_state as $relation) { $members = explode("\n", $relation['members']); $result = array_merge($result, $members); } } // save current state if ($changed) { $data = []; foreach ($relations as $relation) { $data[$relation['uid']] = [ 'name' => $relation['name'], 'changed' => $relation['changed']->format('U'), 'members' => implode("\n", (array)$relation['members']), ]; } $now = new DateTime('now', new DateTimeZone('UTC')); $this->relations_state_set($deviceid, $folderid, $now, $data); } // in mail mode return only message URIs if ($type == self::MODEL_EMAIL) { // lambda function to skip email members $filter_func = function ($value) { return strpos($value, 'imap://') === 0; }; $result = array_filter(array_unique($result), $filter_func); } // otherwise return only object UIDs else { // lambda function to skip email members $filter_func = function ($value) { return strpos($value, 'urn:uuid:') === 0; }; // lambda function to parse member URI $member_func = function ($value) { if (strpos($value, 'urn:uuid:') === 0) { $value = substr($value, 9); } return $value; }; $result = array_map($member_func, array_filter(array_unique($result), $filter_func)); } return $result; } /** * Subscribe default set of folders on device registration */ protected function device_init_subscriptions($deviceid) { // INBOX always exists $this->folder_set('INBOX', $deviceid, 1); $supported_types = [ 'mail.drafts', 'mail.wastebasket', 'mail.sentitems', 'mail.outbox', 'event.default', 'contact.default', 'note.default', 'task.default', 'event', 'contact', 'note', 'task', 'event.confidential', 'event.private', 'task.confidential', 'task.private', ]; $rcube = rcube::get_instance(); $config = $rcube->config; $mode = (int) $config->get('activesync_init_subscriptions'); $folders = []; // Subscribe to default folders $foldertypes = kolab_storage::folders_typedata(); if (!empty($foldertypes)) { $_foldertypes = array_intersect($foldertypes, $supported_types); // get default folders foreach ($_foldertypes as $folder => $type) { // only personal folders if ($this->storage->folder_namespace($folder) == 'personal') { $flag = preg_match('/^(event|task)/', $type) ? 2 : 1; $this->folder_set($folder, $deviceid, $flag); $folders[] = $folder; } } } // we're in default mode, exit if (!$mode) { return; } // below we support additionally all mail folders $supported_types[] = 'mail'; $supported_types[] = 'mail.junkemail'; // get configured special folders $special_folders = []; $map = [ 'drafts' => 'mail.drafts', 'junk' => 'mail.junkemail', 'sent' => 'mail.sentitems', 'trash' => 'mail.wastebasket', ]; foreach ($map as $folder => $type) { if ($folder = $config->get($folder . '_mbox')) { $special_folders[$folder] = $type; } } // get folders list(s) if (($mode & self::INIT_ALL_PERSONAL) || ($mode & self::INIT_ALL_OTHER) || ($mode & self::INIT_ALL_SHARED)) { $all_folders = $this->storage->list_folders(); if (($mode & self::INIT_SUB_PERSONAL) || ($mode & self::INIT_SUB_OTHER) || ($mode & self::INIT_SUB_SHARED)) { $subscribed_folders = $this->storage->list_folders_subscribed(); } } else { $all_folders = $this->storage->list_folders_subscribed(); } foreach ($all_folders as $folder) { // folder already subscribed if (in_array($folder, $folders)) { continue; } $type = ($foldertypes[$folder] ?? null) ?: 'mail'; if ($type == 'mail' && isset($special_folders[$folder])) { $type = $special_folders[$folder]; } if (!in_array($type, $supported_types)) { continue; } $ns = strtoupper($this->storage->folder_namespace($folder)); // subscribe the folder according to configured mode // and folder namespace/subscription status if (($mode & constant("self::INIT_ALL_{$ns}")) || (($mode & constant("self::INIT_SUB_{$ns}")) && (!isset($subscribed_folders) || in_array($folder, $subscribed_folders))) ) { $flag = preg_match('/^(event|task)/', $type) ? 2 : 1; $this->folder_set($folder, $deviceid, $flag); } } } /** * Helper method to decode saved IMAP metadata */ protected function unserialize_metadata($str) { if (!empty($str)) { $data = json_decode($str, true); return $data; } return null; } /** * Helper method to encode IMAP metadata for saving */ protected function serialize_metadata($data) { if (!empty($data) && is_array($data)) { $data = json_encode($data); return $data; } return null; } /** * Returns Kolab folder type for specified ActiveSync type ID */ protected static function type_activesync2kolab($type) { if (!empty(self::$types[$type])) { return self::$types[$type]; } return ''; } /** * Returns ActiveSync folder type for specified Kolab type */ protected static function type_kolab2activesync($type) { $type = preg_replace('/\.(confidential|private)$/i', '', $type); if ($key = array_search($type, self::$types)) { return $key; } return key(self::$types); } /** * Returns folder data in Syncroton format */ protected function folder_data($folder, $type) { // Folder name parameters $delim = $this->storage->get_hierarchy_delimiter(); $items = explode($delim, $folder); $name = array_pop($items); // Folder UID $folder_id = $this->folder_id($folder, $type); // Folder type if (strcasecmp($folder, 'INBOX') === 0) { // INBOX is always inbox, prevent from issues related with a change of // folder type annotation (it can be initially unset). $as_type = 2; } else { $as_type = self::type_kolab2activesync($type); // fix type, if there's no type annotation it's detected as UNKNOWN we'll use 'mail' (12) if ($as_type == 1) { $as_type = 12; } } // Syncroton folder data array return [ 'serverId' => $folder_id, 'parentId' => count($items) ? $this->folder_id(implode($delim, $items), $type) : 0, 'displayName' => rcube_charset::convert($name, 'UTF7-IMAP', kolab_sync::CHARSET), 'type' => $as_type, // for internal use 'imap_name' => $folder, ]; } /** * Builds folder ID based on folder name */ protected function folder_id($name, $type = null) { // ActiveSync expects folder identifiers to be max.64 characters // So we can't use just folder name $name = (string) $name; if ($name === '') { return null; } if (isset($this->folder_uids[$name])) { return $this->folder_uids[$name]; } /* @TODO: For now uniqueid annotation doesn't work, we will create UIDs by ourselves. There's one inconvenience of this solution: folder name/type change would be handled in ActiveSync as delete + create. // get folders unique identifier $folderdata = $this->storage->get_metadata($name, self::UID_KEY); if ($folderdata && !empty($folderdata[$name])) { $uid = $folderdata[$name][self::UID_KEY]; return $this->folder_uids[$name] = $uid; } */ if (strcasecmp($name, 'INBOX') === 0) { // INBOX is always inbox, prevent from issues related with a change of // folder type annotation (it can be initially unset). $type = 'mail.inbox'; } else { if ($type === null) { $type = kolab_storage::folder_type($name); } if ($type != null) { $type = preg_replace('/\.(confidential|private)$/i', '', $type); } } // Add type to folder UID hash, so type change can be detected by Syncroton $uid = $name . '!!' . $type; $uid = md5($uid); return $this->folder_uids[$name] = $uid; } /** * Returns IMAP folder name * * @param string $id Folder identifier * @param string $deviceid Device dentifier * * @return string|null Folder name (UTF7-IMAP) */ public function folder_id2name($id, $deviceid) { // check in cache first if (!empty($this->folder_uids)) { if (($name = array_search($id, $this->folder_uids)) !== false) { return $name; } } /* @TODO: see folder_id() // get folders unique identifier $folderdata = $this->storage->get_metadata('*', self::UID_KEY); foreach ((array)$folderdata as $folder => $data) { if (!empty($data[self::UID_KEY])) { $uid = $data[self::UID_KEY]; $this->folder_uids[$folder] = $uid; if ($uid == $id) { $name = $folder; } } } */ // get all folders of specified type $folderdata = $this->folder_meta(); if (!is_array($folderdata) || empty($id)) { return null; } $name = null; // check if folders are "subscribed" for activesync foreach ($folderdata as $folder => $meta) { if (empty($meta['FOLDER']) || empty($meta['FOLDER'][$deviceid]) || empty($meta['FOLDER'][$deviceid]['S']) ) { continue; } if ($uid = $this->folder_id($folder)) { $this->folder_uids[$folder] = $uid; } if ($uid === $id) { $name = $folder; } } return $name; } - /** - * Save MODSEQ value for a folder - */ - protected function modseq_set($deviceid, $folderid, $synctime, $data) - { - $synctime = $synctime->format('Y-m-d H:i:s'); - $rcube = rcube::get_instance(); - $db = $rcube->get_dbh(); - $old_data = $this->modseq[$folderid][$synctime] ?? null; - - if (empty($old_data)) { - $this->modseq[$folderid][$synctime] = $data; - $data = json_encode($data); - - $db->set_option('ignore_key_errors', true); - $db->query( - "INSERT INTO `syncroton_modseq` (`device_id`, `folder_id`, `synctime`, `data`)" - . " VALUES (?, ?, ?, ?)", - $deviceid, - $folderid, - $synctime, - $data - ); - $db->set_option('ignore_key_errors', false); - } - } - - /** - * Get stored MODSEQ value for a folder - */ - protected function modseq_get($deviceid, $folderid, $synctime) - { - $synctime = $synctime->format('Y-m-d H:i:s'); - - if (empty($this->modseq[$folderid][$synctime])) { - $this->modseq[$folderid] = []; - - $rcube = rcube::get_instance(); - $db = $rcube->get_dbh(); - - $db->limitquery( - "SELECT `data`, `synctime` FROM `syncroton_modseq`" - . " WHERE `device_id` = ? AND `folder_id` = ? AND `synctime` <= ?" - . " ORDER BY `synctime` DESC", - 0, - 1, - $deviceid, - $folderid, - $synctime - ); - - if ($row = $db->fetch_assoc()) { - $synctime = $row['synctime']; - // @TODO: make sure synctime from sql is in "Y-m-d H:i:s" format - $this->modseq[$folderid][$synctime] = json_decode($row['data'], true); - } - - // Cleanup: remove all records except the current one - $db->query( - "DELETE FROM `syncroton_modseq`" - . " WHERE `device_id` = ? AND `folder_id` = ? AND `synctime` <> ?", - $deviceid, - $folderid, - $synctime - ); - } - - return $this->modseq[$folderid][$synctime] ?? null; - } - /** * Set state of relation objects at specified point in time */ public function relations_state_set($deviceid, $folderid, $synctime, $relations) { $synctime = $synctime->format('Y-m-d H:i:s'); $rcube = rcube::get_instance(); $db = $rcube->get_dbh(); $old_data = $this->relations[$folderid][$synctime] ?? null; if (empty($old_data)) { $this->relations[$folderid][$synctime] = $relations; $data = rcube_charset::clean(json_encode($relations)); $db->set_option('ignore_key_errors', true); $db->query( "INSERT INTO `syncroton_relations_state`" . " (`device_id`, `folder_id`, `synctime`, `data`)" . " VALUES (?, ?, ?, ?)", $deviceid, $folderid, $synctime, $data ); $db->set_option('ignore_key_errors', false); } } /** * Get state of relation objects at specified point in time */ protected function relations_state_get($deviceid, $folderid, $synctime) { $synctime = $synctime->format('Y-m-d H:i:s'); if (empty($this->relations[$folderid][$synctime])) { $this->relations[$folderid] = []; $rcube = rcube::get_instance(); $db = $rcube->get_dbh(); $db->limitquery( "SELECT `data`, `synctime` FROM `syncroton_relations_state`" . " WHERE `device_id` = ? AND `folder_id` = ? AND `synctime` <= ?" . " ORDER BY `synctime` DESC", 0, 1, $deviceid, $folderid, $synctime ); if ($row = $db->fetch_assoc()) { $synctime = $row['synctime']; // @TODO: make sure synctime from sql is in "Y-m-d H:i:s" format $this->relations[$folderid][$synctime] = json_decode($row['data'], true); } // Cleanup: remove all records except the current one $db->query( "DELETE FROM `syncroton_relations_state`" . " WHERE `device_id` = ? AND `folder_id` = ? AND `synctime` <> ?", $deviceid, $folderid, $synctime ); } return $this->relations[$folderid][$synctime] ?? null; } /** * Return last storage error */ public static function last_error() { return kolab_storage::$last_error; } /** * Compares two arrays * * @param array $array1 * @param array $array2 * * @return bool True if arrays differs, False otherwise */ protected static function data_array_diff($array1, $array2) { if (!is_array($array1) || !is_array($array2)) { return $array1 != $array2; } if (count($array1) != count($array2)) { return true; } foreach ($array1 as $key => $val) { if (!array_key_exists($key, $array2)) { return true; } if ($val !== $array2[$key]) { return true; } } return false; } } diff --git a/lib/kolab_sync_storage_kolab4.php b/lib/kolab_sync_storage_kolab4.php index b15b22a..d5e5b18 100644 --- a/lib/kolab_sync_storage_kolab4.php +++ b/lib/kolab_sync_storage_kolab4.php @@ -1,563 +1,570 @@ | | | | This program is free software: you can redistribute it and/or modify | | it under the terms of the GNU Affero General Public License as published | | by the Free Software Foundation, either version 3 of the License, or | | (at your option) any later version. | | | | This program 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 Affero General Public License for more details. | | | | You should have received a copy of the GNU Affero General Public License | | along with this program. If not, see | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ /** * Storage handling class with Kolab 4 support (IMAP + CalDAV + CardDAV) */ class kolab_sync_storage_kolab4 extends kolab_sync_storage { protected $davStorage = null; protected $relationSupport = false; /** * This implements the 'singleton' design pattern * * @return kolab_sync_storage_kolab4 The one and only instance */ public static function get_instance() { if (!self::$instance) { self::$instance = new kolab_sync_storage_kolab4(); self::$instance->startup(); // init AFTER object was linked with self::$instance } return self::$instance; } /** * Class initialization */ public function startup() { $sync = kolab_sync::get_instance(); if ($sync->username === null || $sync->password === null) { throw new Exception("Unsupported storage handler use!"); } $url = $sync->config->get('activesync_dav_server', 'http://localhost'); if (strpos($url, '://') === false) { $url = 'http://' . $url; } // Inject user+password to the URL, there's no other way to pass it to the DAV client $url = str_replace('://', '://' . rawurlencode($sync->username) . ':' . rawurlencode($sync->password) . '@', $url); $this->davStorage = new kolab_storage_dav($url); // DAV $this->storage = $sync->get_storage(); // IMAP // set additional header used by libkolab $this->storage->set_options([ 'skip_deleted' => true, 'threading' => false, ]); // Disable paging $this->storage->set_pagesize(999999); } /** * Get list of folders available for sync * * @param string $deviceid Device identifier * @param string $type Folder (class) type * @param bool $flat_mode Enables flat-list mode * * @return array|bool List of mailbox folders, False on backend failure */ public function folders_list($deviceid, $type, $flat_mode = false) { $list = []; // get mail folders subscribed for sync if ($type === self::MODEL_EMAIL) { $folderdata = $this->folder_meta(); if (!is_array($folderdata)) { return false; } $special_folders = $this->storage->get_special_folders(true); $type_map = [ 'drafts' => 3, 'trash' => 4, 'sent' => 5, ]; // Get the folders "subscribed" for activesync foreach ($folderdata as $folder => $meta) { if (empty($meta['FOLDER']) || empty($meta['FOLDER'][$deviceid]) || empty($meta['FOLDER'][$deviceid]['S']) ) { continue; } // Force numeric folder name to be a string (T1283) $folder = (string) $folder; // Activesync folder properties $folder_data = $this->folder_data($folder, 'mail'); // Set proper type for special folders if (($type = array_search($folder, $special_folders)) && isset($type_map[$type])) { $folder_data['type'] = $type_map[$type]; } $list[$folder_data['serverId']] = $folder_data; } } elseif (in_array($type, [self::MODEL_CONTACTS, self::MODEL_CALENDAR, self::MODEL_TASKS])) { if (!empty($this->folders)) { foreach ($this->folders as $unique_key => $folder) { if (strpos($unique_key, "DAV:$type:") === 0) { $folder_data = $this->folder_data($folder, $type); $list[$folder_data['serverId']] = $folder_data; } } } // TODO: For now all DAV folders are subscribed if (empty($list)) { foreach ($this->davStorage->get_folders($type) as $folder) { $folder_data = $this->folder_data($folder, $type); $list[$folder_data['serverId']] = $folder_data; // Store all folder objects in internal cache, otherwise // Any access to the folder (or list) will invoke excessive DAV requests $unique_key = $folder_data['serverId'] . ":$deviceid:$type"; $this->folders[$unique_key] = $folder; } } } /* // TODO if ($flat_mode) { $list = $this->folders_list_flat($list, $type, $typedata); } */ return $list; } /** * Creates folder and subscribes to the device * * @param string $name Folder name (UTF8) * @param int $type Folder (ActiveSync) type * @param string $deviceid Device identifier * @param ?string $parentid Parent folder identifier * * @return string|false New folder identifier on success, False on failure */ public function folder_create($name, $type, $deviceid, $parentid = null) { // Mail folder if ($type <= 6 || $type == 12) { $parent = null; $name = rcube_charset::convert($name, kolab_sync::CHARSET, 'UTF7-IMAP'); if ($parentid) { $parent = $this->folder_id2name($parentid, $deviceid); if ($parent === null) { throw new Syncroton_Exception_Status_FolderCreate(Syncroton_Exception_Status_FolderCreate::PARENT_NOT_FOUND); } } if ($parent !== null) { $delim = $this->storage->get_hierarchy_delimiter(); $name = $parent . $delim . $name; } if ($this->storage->folder_exists($name)) { throw new Syncroton_Exception_Status_FolderCreate(Syncroton_Exception_Status_FolderCreate::FOLDER_EXISTS); } // TODO: Support setting folder types? $created = $this->storage->create_folder($name, true); if ($created) { // Set ActiveSync subscription flag $this->folder_set($name, $deviceid, 1); return $this->folder_id($name, 'mail'); } // Special case when client tries to create a subfolder of INBOX // which is not possible on Cyrus-IMAP (T2223) if ($parent == 'INBOX' && stripos($this->last_error(), 'invalid') !== false) { throw new Syncroton_Exception('', Syncroton_Exception_Status_FolderCreate::SPECIAL_FOLDER); } return false; } elseif ($type == 8 || $type == 13 || $type == 7 || $type == 15 || $type == 9 || $type == 14) { // DAV folder $type = preg_replace('|\..*|', '', self::type_activesync2kolab($type)); // TODO: Folder hierarchy support // Check if folder exists foreach ($this->davStorage->get_folders($type) as $folder) { if ($folder->get_name() == $name) { throw new Syncroton_Exception_Status_FolderCreate(Syncroton_Exception_Status_FolderCreate::FOLDER_EXISTS); } } $props = ['name' => $name, 'type' => $type]; if ($id = $this->davStorage->folder_update($props)) { return "DAV:{$type}:{$id}"; } return false; } throw new \Exception("Not implemented"); } /** * Renames a folder * * @param string $folderid Folder identifier * @param string $deviceid Device identifier * @param string $new_name New folder name (UTF8) * @param ?string $parentid Folder parent identifier * * @return bool True on success, False on failure */ public function folder_rename($folderid, $deviceid, $new_name, $parentid) { // DAV folder if (strpos($folderid, 'DAV:') === 0) { [, $type, $id] = explode(':', $folderid); $props = [ 'id' => $id, 'name' => $new_name, 'type' => $type, ]; // TODO: Folder hierarchy support return $this->davStorage->folder_update($props) !== false; } // Mail folder $old_name = $this->folder_id2name($folderid, $deviceid); if ($parentid) { $parent = $this->folder_id2name($parentid, $deviceid); } $name = rcube_charset::convert($new_name, kolab_sync::CHARSET, 'UTF7-IMAP'); if (isset($parent)) { $delim = $this->storage->get_hierarchy_delimiter(); $name = $parent . $delim . $name; } if ($name === $old_name) { return true; } $this->folder_meta = null; return $this->storage->rename_folder($old_name, $name); } /** * Deletes folder * * @param string $folderid Folder identifier * @param string $deviceid Device identifier * * @return bool True on success, False otherwise */ public function folder_delete($folderid, $deviceid) { // DAV folder if (strpos($folderid, 'DAV:') === 0) { [, $type, $id] = explode(':', $folderid); return $this->davStorage->folder_delete($id, $type) !== false; } // Mail folder $name = $this->folder_id2name($folderid, $deviceid); unset($this->folder_meta[$name]); return $this->storage->delete_folder($name); } /** * Deletes contents of a folder * * @param string $folderid Folder identifier * @param string $deviceid Device identifier * @param bool $recursive Apply to the folder and its subfolders * * @return bool True on success, False otherwise */ public function folder_empty($folderid, $deviceid, $recursive = false) { // DAV folder if (strpos($folderid, 'DAV:') === 0) { [, $type, $id] = explode(':', $folderid); if ($folder = $this->davStorage->get_folder($id, $type)) { return $folder->delete_all(); } // TODO: $recursive=true return false; } // Mail folder return parent::folder_empty($folderid, $deviceid, $recursive); } /** * Returns folder data in Syncroton format */ protected function folder_data($folder, $type) { // Mail folders if (strpos($type, 'mail') === 0) { return parent::folder_data($folder, $type); } // DAV folders return [ 'serverId' => "DAV:{$type}:{$folder->id}", 'parentId' => 0, // TODO: Folder hierarchy 'displayName' => $folder->get_name(), 'type' => $this->type_kolab2activesync($type), ]; } /** * Builds folder ID based on folder name * * @param string $name Folder name (UTF7-IMAP) * @param string $type Kolab folder type * * @return string|null Folder identifier (up to 64 characters) */ protected function folder_id($name, $type = null) { if (!$type) { $type = 'mail'; } // ActiveSync expects folder identifiers to be max.64 characters // So we can't use just folder name $name = (string) $name; if ($name === '') { return null; } if (strpos($type, 'mail') !== 0) { throw new Exception("Unsupported folder_id() call on a DAV folder"); } if (isset($this->folder_uids[$name])) { return $this->folder_uids[$name]; } /* @TODO: For now uniqueid annotation doesn't work, we will create UIDs by ourselves. There's one inconvenience of this solution: folder name/type change would be handled in ActiveSync as delete + create. @TODO: Consider using MAILBOXID (RFC8474) that Cyrus v3 supports // get folders unique identifier $folderdata = $this->storage->get_metadata($name, self::UID_KEY); if ($folderdata && !empty($folderdata[$name])) { $uid = $folderdata[$name][self::UID_KEY]; return $this->folder_uids[$name] = $uid; } */ if (strcasecmp($name, 'INBOX') === 0) { // INBOX is always inbox, prevent from issues related with a change of // folder type annotation (it can be initially unset). $type = 'mail.inbox'; } // Add type to folder UID hash, so type change can be detected by Syncroton $uid = $name . '!!' . $type; $uid = md5($uid); return $this->folder_uids[$name] = $uid; } /** * Returns IMAP folder name * * @param string $id Folder identifier * @param string $deviceid Device dentifier * * @return null|string Folder name (UTF7-IMAP) */ public function folder_id2name($id, $deviceid) { // TODO: This method should become protected and be used for mail folders only if (strpos($id, 'DAV:') === 0) { throw new Exception("Unsupported folder_id2name() call on a DAV folder"); } // check in cache first if (!empty($this->folder_uids)) { if (($name = array_search($id, $this->folder_uids)) !== false) { return $name; } } // get all folders of specified type $folderdata = $this->folder_meta(); if (!is_array($folderdata) || empty($id)) { return null; } // check if folders are "subscribed" for activesync foreach ($folderdata as $folder => $meta) { if (empty($meta['FOLDER']) || empty($meta['FOLDER'][$deviceid]) || empty($meta['FOLDER'][$deviceid]['S']) ) { continue; } if ($uid = $this->folder_id($folder, 'mail')) { $this->folder_uids[$folder] = $uid; } if ($uid === $id) { $name = $folder; } } return $name ?? null; } /** * Gets kolab_storage_folder object from Activesync folder ID. * * @param string $folderid Folder identifier * @param string $deviceid Device identifier * @param string $type Activesync model name (folder type) * * @return ?kolab_storage_folder */ public function getFolder($folderid, $deviceid, $type) { if (strpos($folderid, 'DAV:') !== 0) { throw new Exception("Unsupported getFolder() call on a mail folder"); } $unique_key = "$folderid:$deviceid:$type"; if (array_key_exists($unique_key, $this->folders)) { return $this->folders[$unique_key]; } [, $type, $id] = explode(':', $folderid); return $this->folders[$unique_key] = $this->davStorage->get_folder($id, $type); } /** * Gets Activesync preferences for a folder. * * @param string $folderid Folder identifier * @param string $deviceid Device identifier * @param string $type Activesync model name (folder type) * * @return array Folder preferences */ public function getFolderConfig($folderid, $deviceid, $type) { // TODO: Get "alarms" from the DAV folder props, or implement // a storage for folder properties return [ 'ALARMS' => true, ]; } /** * Return last storage error */ public static function last_error() { // TODO return null; } /** * Subscribe default set of folders on device registration */ protected function device_init_subscriptions($deviceid) { $config = rcube::get_instance()->config; $mode = (int) $config->get('activesync_init_subscriptions'); $subscribed_folders = null; // Special folders only if (!$mode) { $all_folders = $this->storage->get_special_folders(true); // We do not subscribe to the Spam folder by default, same as the old Kolab driver does unset($all_folders['junk']); $all_folders = array_unique(array_merge(['INBOX'], array_values($all_folders))); } // other modes elseif (($mode & self::INIT_ALL_PERSONAL) || ($mode & self::INIT_ALL_OTHER) || ($mode & self::INIT_ALL_SHARED)) { $all_folders = $this->storage->list_folders(); if (($mode & self::INIT_SUB_PERSONAL) || ($mode & self::INIT_SUB_OTHER) || ($mode & self::INIT_SUB_SHARED)) { $subscribed_folders = $this->storage->list_folders_subscribed(); } } else { $all_folders = $this->storage->list_folders_subscribed(); } foreach ($all_folders as $folder) { $ns = strtoupper($this->storage->folder_namespace($folder)); // subscribe the folder according to configured mode // and folder namespace/subscription status if (!$mode || ($mode & constant("self::INIT_ALL_{$ns}")) || (($mode & constant("self::INIT_SUB_{$ns}")) && ($subscribed_folders === null || in_array($folder, $subscribed_folders))) ) { $this->folder_set($folder, $deviceid, 1); } } // TODO: Subscribe personal DAV folders, for now we assume all are subscribed // TODO: Subscribe shared DAV folders } + + public function getExtraData($folderid, $deviceid) { + if (strpos($folderid, 'DAV:') === 0) { + return null; + } + return parent::getExtraData($folderid, $deviceid); + } } diff --git a/tests/Sync/Sync/EmailTest.php b/tests/Sync/Sync/EmailTest.php index b62d6c6..924137f 100644 --- a/tests/Sync/Sync/EmailTest.php +++ b/tests/Sync/Sync/EmailTest.php @@ -1,375 +1,468 @@ emptyTestFolder('INBOX', 'mail'); $this->registerDevice(); // Test invalid collection identifier $request = << 0 1111111111 EOF; $response = $this->request($request, 'Sync'); $this->assertEquals(200, $response->getStatusCode()); $dom = $this->fromWbxml($response->getBody()); $xpath = $this->xpath($dom); $this->assertSame('12', $xpath->query("//ns:Sync/ns:Status")->item(0)->nodeValue); // Test INBOX $folderId = '38b950ebd62cd9a66929c89615d0fc04'; $syncKey = 0; $request = << {$syncKey} {$folderId} EOF; $response = $this->request($request, 'Sync'); $this->assertEquals(200, $response->getStatusCode()); $dom = $this->fromWbxml($response->getBody()); $xpath = $this->xpath($dom); $this->assertSame('1', $xpath->query("//ns:Sync/ns:Collections/ns:Collection/ns:Status")->item(0)->nodeValue); $this->assertSame(strval(++$syncKey), $xpath->query("//ns:Sync/ns:Collections/ns:Collection/ns:SyncKey")->item(0)->nodeValue); $this->assertSame('Email', $xpath->query("//ns:Sync/ns:Collections/ns:Collection/ns:Class")->item(0)->nodeValue); $this->assertSame($folderId, $xpath->query("//ns:Sync/ns:Collections/ns:Collection/ns:CollectionId")->item(0)->nodeValue); // Test listing mail in INBOX, use WindowSize=1 // Append two mail messages $this->appendMail('INBOX', 'mail.sync1'); $this->appendMail('INBOX', 'mail.sync2'); $request = << {$syncKey} {$folderId} 1 1 1 0 1 2 51200 0 EOF; $response = $this->request($request, 'Sync'); $this->assertEquals(200, $response->getStatusCode()); $dom = $this->fromWbxml($response->getBody()); $xpath = $this->xpath($dom); $root = "//ns:Sync/ns:Collections/ns:Collection"; $this->assertSame('1', $xpath->query("{$root}/ns:Status")->item(0)->nodeValue); $this->assertSame(strval(++$syncKey), $xpath->query("{$root}/ns:SyncKey")->item(0)->nodeValue); $this->assertSame($folderId, $xpath->query("{$root}/ns:CollectionId")->item(0)->nodeValue); $this->assertSame(1, $xpath->query("{$root}/ns:Commands/ns:Add")->count()); // Note: We assume messages are in IMAP default order, it may change in future $root .= "/ns:Commands/ns:Add"; $this->assertStringMatchesFormat("{$folderId}::%d", $xpath->query("{$root}/ns:ServerId")->item(0)->nodeValue); $this->assertSame('test sync', $xpath->query("{$root}/ns:ApplicationData/Email:Subject")->item(0)->nodeValue); // List the rest of the mail $request = << {$syncKey} {$folderId} 1 1 0 1 2 51200 0 EOF; $response = $this->request($request, 'Sync'); $this->assertEquals(200, $response->getStatusCode()); $dom = $this->fromWbxml($response->getBody()); $xpath = $this->xpath($dom); $root = "//ns:Sync/ns:Collections/ns:Collection"; $this->assertSame('1', $xpath->query("{$root}/ns:Status")->item(0)->nodeValue); $this->assertSame(strval(++$syncKey), $xpath->query("{$root}/ns:SyncKey")->item(0)->nodeValue); $this->assertSame($folderId, $xpath->query("{$root}/ns:CollectionId")->item(0)->nodeValue); $this->assertSame(1, $xpath->query("{$root}/ns:Commands/ns:Add")->count()); // Note: We assume messages are in IMAP default order, it may change in future $root .= "/ns:Commands/ns:Add"; $this->assertStringMatchesFormat("{$folderId}::%d", $xpath->query("{$root}/ns:ServerId")->item(0)->nodeValue); $this->assertSame('sync test with attachment', $xpath->query("{$root}/ns:ApplicationData/Email:Subject")->item(0)->nodeValue); return $syncKey; } /** * Test empty sync response * * @depends testSync */ public function testEmptySync($syncKey) { $folderId = '38b950ebd62cd9a66929c89615d0fc04'; $request = << {$syncKey} {$folderId} 1 1 0 1 2 51200 0 EOF; $response = $this->request($request, 'Sync'); $this->assertEquals(200, $response->getStatusCode()); // We expect an empty response without a change $this->assertEquals(0, $response->getBody()->getSize()); return $syncKey; } /** * Test flag change * * @depends testEmptySync */ public function testFlagChange($syncKey) { $this->assertTrue($this->markMailAsRead('INBOX', '*')); $folderId = '38b950ebd62cd9a66929c89615d0fc04'; $request = << {$syncKey} {$folderId} 1 1 0 1 2 51200 0 EOF; $response = $this->request($request, 'Sync'); $this->assertEquals(200, $response->getStatusCode()); $dom = $this->fromWbxml($response->getBody()); $xpath = $this->xpath($dom); - print($dom->saveXML()); + // print($dom->saveXML()); $root = "//ns:Sync/ns:Collections/ns:Collection"; $this->assertSame('1', $xpath->query("{$root}/ns:Status")->item(0)->nodeValue); $this->assertSame(strval(++$syncKey), $xpath->query("{$root}/ns:SyncKey")->item(0)->nodeValue); $this->assertSame($folderId, $xpath->query("{$root}/ns:CollectionId")->item(0)->nodeValue); $this->assertSame(0, $xpath->query("{$root}/ns:Commands/ns:Add")->count()); $this->assertSame(2, $xpath->query("{$root}/ns:Commands/ns:Change")->count()); return $syncKey; } /** * Retry flag change * Resending the same syncKey should result in the same changes. - * FIXME: This doesn't currently work because we update the modseq information, - * so on the next check syncroton thinks everything is up to date. * - * @expectedException PHPUnit_Framework_ExpectationFailedException * @depends testFlagChange */ public function testRetryFlagChange($syncKey) { - $this->markTestIncomplete(); $syncKey--; $folderId = '38b950ebd62cd9a66929c89615d0fc04'; $request = << {$syncKey} {$folderId} 1 1 0 1 2 51200 0 EOF; $response = $this->request($request, 'Sync'); $this->assertEquals(200, $response->getStatusCode()); $dom = $this->fromWbxml($response->getBody()); $xpath = $this->xpath($dom); - print($dom->saveXML()); + // print($dom->saveXML()); $root = "//ns:Sync/ns:Collections/ns:Collection"; $this->assertSame('1', $xpath->query("{$root}/ns:Status")->item(0)->nodeValue); - $this->assertSame(strval(++$syncKey), $xpath->query("{$root}/ns:SyncKey")->item(0)->nodeValue); + //FIXME I'm not sure why we get syncKey + 2, I suppose we just always increase the current synckey by one. + $this->assertSame(strval($syncKey += 2), $xpath->query("{$root}/ns:SyncKey")->item(0)->nodeValue); $this->assertSame($folderId, $xpath->query("{$root}/ns:CollectionId")->item(0)->nodeValue); $this->assertSame(0, $xpath->query("{$root}/ns:Commands/ns:Add")->count()); $this->assertSame(2, $xpath->query("{$root}/ns:Commands/ns:Change")->count()); - return $syncKey; + $serverId1 = $xpath->query("{$root}/ns:Commands/ns:Change/ns:ServerId")->item(0)->nodeValue; + + return [ + 'syncKey' => $syncKey, + 'serverId' => $serverId1 + ]; } /** * Test updating message properties from client * - * @depends testSync + * @depends testRetryFlagChange */ - public function testChangeFromClient($syncKey) + public function testChangeFromClient($values) { - $this->markTestIncomplete(); + $folderId = '38b950ebd62cd9a66929c89615d0fc04'; + $syncKey = $values['syncKey']; + $serverId = $values['serverId']; + + $request = << + + + + + {$syncKey} + {$folderId} + + + {$serverId} + + 0 + + + + + + + + EOF; + + $response = $this->request($request, 'Sync'); + + $this->assertEquals(200, $response->getStatusCode()); + $dom = $this->fromWbxml($response->getBody()); + $xpath = $this->xpath($dom); + // print($dom->saveXML()); + + $root = "//ns:Sync/ns:Collections/ns:Collection"; + $this->assertSame('1', $xpath->query("{$root}/ns:Status")->item(0)->nodeValue); + $this->assertSame(strval(++$syncKey), $xpath->query("{$root}/ns:SyncKey")->item(0)->nodeValue); + $this->assertSame($folderId, $xpath->query("{$root}/ns:CollectionId")->item(0)->nodeValue); + $this->assertSame(0, $xpath->query("{$root}/ns:Commands/ns:Add")->count()); + //The server doesn't have to report back successful changes + $this->assertSame(0, $xpath->query("{$root}/ns:Commands/ns:Change")->count()); + + $emails = $this->listEmails('INBOX', '*'); + $uid = explode("::", $serverId)[1]; + $this->assertSame(2, count($emails)); + $this->assertTrue(!array_key_exists('SEEN', $emails[$uid])); + + return [ + 'syncKey' => $syncKey, + 'serverId' => $serverId + ]; } /** * Test deleting messages from client * * @depends testChangeFromClient */ - public function testDeleteFromClient($syncKey) + public function testDeleteFromClient($values) { - $this->markTestIncomplete(); + $folderId = '38b950ebd62cd9a66929c89615d0fc04'; + $syncKey = $values['syncKey']; + $serverId = $values['serverId']; + + $request = << + + + + + {$syncKey} + {$folderId} + + + {$serverId} + + + + + + EOF; + + $response = $this->request($request, 'Sync'); + + $this->assertEquals(200, $response->getStatusCode()); + $dom = $this->fromWbxml($response->getBody()); + $xpath = $this->xpath($dom); + // print($dom->saveXML()); + + $root = "//ns:Sync/ns:Collections/ns:Collection"; + $this->assertSame('1', $xpath->query("{$root}/ns:Status")->item(0)->nodeValue); + $this->assertSame(strval(++$syncKey), $xpath->query("{$root}/ns:SyncKey")->item(0)->nodeValue); + $this->assertSame($folderId, $xpath->query("{$root}/ns:CollectionId")->item(0)->nodeValue); + $this->assertSame(0, $xpath->query("{$root}/ns:Commands/ns:Add")->count()); + $this->assertSame(0, $xpath->query("{$root}/ns:Commands/ns:Change")->count()); + $this->assertSame(0, $xpath->query("{$root}/ns:Commands/ns:Delete")->count()); + + $emails = $this->listEmails('INBOX', '*'); + $uid = explode("::", $serverId)[1]; + $this->assertSame(2, count($emails)); + $this->assertTrue($emails[$uid]['DELETED']); + + return $syncKey; } /** * Test a sync key that doesn't exist yet. - * @depends testFlagChange + * @depends testDeleteFromClient */ public function testInvalidSyncKey($syncKey) { $syncKey++; $folderId = '38b950ebd62cd9a66929c89615d0fc04'; $request = << {$syncKey} {$folderId} 1 1 0 1 2 51200 0 EOF; $response = $this->request($request, 'Sync'); $this->assertEquals(200, $response->getStatusCode()); $dom = $this->fromWbxml($response->getBody()); $xpath = $this->xpath($dom); - print($dom->saveXML()); + // print($dom->saveXML()); $root = "//ns:Sync/ns:Collections/ns:Collection"; $this->assertSame('3', $xpath->query("{$root}/ns:Status")->item(0)->nodeValue); $this->assertSame('0', $xpath->query("{$root}/ns:SyncKey")->item(0)->nodeValue); //We have to start over after this. The sync state was removed. return 0; } } diff --git a/tests/SyncTestCase.php b/tests/SyncTestCase.php index d5fd9ab..543439e 100644 --- a/tests/SyncTestCase.php +++ b/tests/SyncTestCase.php @@ -1,385 +1,393 @@ markTestSkipped('Not setup'); } self::$deviceType = null; } /** * {@inheritDoc} */ public static function setUpBeforeClass(): void { $sync = \kolab_sync::get_instance(); $config = $sync->config; $db = $sync->get_dbh(); self::$username = $config->get('activesync_test_username'); self::$password = $config->get('activesync_test_password'); if (empty(self::$username)) { return; } self::$deviceId = 'test' . time(); $db->query('DELETE FROM syncroton_device'); $db->query('DELETE FROM syncroton_synckey'); $db->query('DELETE FROM syncroton_folder'); $db->query('DELETE FROM syncroton_data'); $db->query('DELETE FROM syncroton_data_folder'); $db->query('DELETE FROM syncroton_modseq'); $db->query('DELETE FROM syncroton_content'); self::$client = new \GuzzleHttp\Client([ 'http_errors' => false, 'base_uri' => 'http://localhost:8000', 'verify' => false, 'auth' => [self::$username, self::$password], 'connect_timeout' => 10, 'timeout' => 10, 'headers' => [ 'Content-Type' => 'application/xml; charset=utf-8', 'Depth' => '1', ], ]); // TODO: execute: php -S localhost:8000 } /** * {@inheritDoc} */ public static function tearDownAfterClass(): void { if (self::$deviceId) { $sync = \kolab_sync::get_instance(); if (self::$authenticated || $sync->authenticate(self::$username, self::$password)) { $sync->password = self::$password; $storage = $sync->storage(); $storage->device_delete(self::$deviceId); } $db = $sync->get_dbh(); $db->query('DELETE FROM syncroton_device'); $db->query('DELETE FROM syncroton_synckey'); $db->query('DELETE FROM syncroton_folder'); } } /** * Append an email message to the IMAP folder */ protected function appendMail($folder, $filename, $replace = []) { $imap = $this->getImapStorage(); $source = __DIR__ . '/src/' . $filename; if (!file_exists($source)) { exit("File does not exist: {$source}"); } $is_file = true; if (!empty($replace)) { $is_file = false; $source = file_get_contents($source); foreach ($replace as $token => $value) { $source = str_replace($token, $value, $source); } } $uid = $imap->save_message($folder, $source, '', $is_file); if ($uid === false) { exit("Failed to append mail into {$folder}"); } return $uid; } /** * Mark an email message as read over IMAP */ protected function markMailAsRead($folder, $uids) { $imap = $this->getImapStorage(); - return $imap->set_flag($uids, 'SEEN', $folder); } + /** + * List emails over IMAP + */ + protected function listEmails($folder, $uids) + { + $imap = $this->getImapStorage(); + return $imap->list_flags($folder, $uids); + } + /** * Append an DAV object to a DAV/IMAP folder */ protected function appendObject($foldername, $filename, $type) { $path = __DIR__ . '/src/' . $filename; if (!file_exists($path)) { exit("File does not exist: {$path}"); } $content = file_get_contents($path); $uid = preg_match('/UID:(?:urn:uuid:)?([a-z0-9-]+)/', $content, $m) ? $m[1] : null; if (empty($uid)) { exit("Filed to find UID in {$path}"); } if ($this->isStorageDriver('kolab')) { $imap = $this->getImapStorage(); if ($imap->folder_exists($foldername)) { // TODO exit("Not implemented for Kolab v3 storage driver"); } return; } $dav = $this->getDavStorage(); foreach ($dav->get_folders($type) as $folder) { if ($folder->get_name() === $foldername) { $dav_type = $folder->get_dav_type(); $location = $folder->object_location($uid); if ($folder->dav->create($location, $content, $dav_type) !== false) { return; } } } exit("Failed to append object into {$foldername}"); } /** * Delete a folder */ protected function deleteTestFolder($name, $type) { // Deleting IMAP folders if ($type == 'mail' || $this->isStorageDriver('kolab')) { $imap = $this->getImapStorage(); if ($imap->folder_exists($name)) { $imap->delete_folder($name); } return; } // Deleting DAV folders $dav = $this->getDavStorage(); foreach ($dav->get_folders($type) as $folder) { if ($folder->get_name() === $name) { $dav->folder_delete($folder->id, $type); } } } /** * Remove all objects from a folder */ protected function emptyTestFolder($name, $type) { // Deleting in IMAP folders if ($type == 'mail' || $this->isStorageDriver('kolab')) { $imap = $this->getImapStorage(); $imap->delete_message('*', $name); return; } // Deleting in DAV folders $dav = $this->getDavStorage(); foreach ($dav->get_folders($type) as $folder) { if ($folder->get_name() === $name) { $folder->delete_all(); } } } /** * Convert WBXML binary content into XML */ protected function fromWbxml($binary) { $stream = fopen('php://memory', 'r+'); fwrite($stream, $binary); rewind($stream); $decoder = new \Syncroton_Wbxml_Decoder($stream); return $decoder->decode(); } /** * Initialize DAV storage */ protected function getDavStorage() { $sync = \kolab_sync::get_instance(); $url = $sync->config->get('activesync_dav_server', 'http://localhost'); if (strpos($url, '://') === false) { $url = 'http://' . $url; } // Inject user+password to the URL, there's no other way to pass it to the DAV client $url = str_replace('://', '://' . rawurlencode(self::$username) . ':' . rawurlencode(self::$password) . '@', $url); // Make sure user is authenticated $this->getImapStorage(); if (!empty($sync->user)) { // required e.g. for DAV client cache use \rcube::get_instance()->user = $sync->user; } return new \kolab_storage_dav($url); } /** * Initialize IMAP storage */ protected function getImapStorage() { $sync = \kolab_sync::get_instance(); if (!self::$authenticated) { if ($sync->authenticate(self::$username, self::$password)) { self::$authenticated = true; $sync->password = self::$password; } } return $sync->get_storage(); } /** * Check the configured activesync_storage driver */ protected function isStorageDriver($name) { return $name === \kolab_sync::get_instance()->config->get('activesync_storage', 'kolab'); } /** * Make a HTTP request to the ActiveSync server */ protected function request($body, $cmd, $type = 'POST') { $username = self::$username; $deviceId = self::$deviceId; $deviceType = self::$deviceType ?: 'WindowsOutlook15'; $body = $this->toWbxml($body); return self::$client->request( $type, "?Cmd={$cmd}&User={$username}&DeviceId={$deviceId}&DeviceType={$deviceType}", [ 'headers' => [ 'Content-Type' => 'application/vnd.ms-sync.wbxml', 'MS-ASProtocolVersion' => '14.0', ], 'body' => $body, ] ); } /** * Register the device for tests, some commands do not work until device/folders are registered */ protected function registerDevice() { // Execute initial FolderSync, it is required before executing some commands $request = << 0 EOF; $response = $this->request($request, 'FolderSync'); $this->assertEquals(200, $response->getStatusCode()); $dom = $this->fromWbxml($response->getBody()); $xpath = $this->xpath($dom); foreach ($xpath->query("//ns:FolderSync/ns:Changes/ns:Add") as $idx => $folder) { $serverId = $folder->getElementsByTagName('ServerId')->item(0)->nodeValue; $displayName = $folder->getElementsByTagName('DisplayName')->item(0)->nodeValue; $this->folders[$serverId] = $displayName; } } /** * Convert XML into WBXML binary content */ protected function toWbxml($xml) { $outputStream = fopen('php://temp', 'r+'); $encoder = new \Syncroton_Wbxml_Encoder($outputStream, 'UTF-8', 3); $dom = new \DOMDocument(); $dom->loadXML($xml); $encoder->encode($dom); rewind($outputStream); return stream_get_contents($outputStream); } /** * Get XPath from a DOM */ protected function xpath($dom) { $xpath = new \DOMXpath($dom); $xpath->registerNamespace("ns", $dom->documentElement->namespaceURI); $xpath->registerNamespace("AirSync", "uri:AirSync"); $xpath->registerNamespace("Calendar", "uri:Calendar"); $xpath->registerNamespace("Contacts", "uri:Contacts"); $xpath->registerNamespace("Email", "uri:Email"); $xpath->registerNamespace("Email2", "uri:Email2"); $xpath->registerNamespace("Settings", "uri:Settings"); $xpath->registerNamespace("Tasks", "uri:Tasks"); return $xpath; } /** * adapter for phpunit < 9 */ public static function assertMatchesRegularExpression(string $arg1, string $arg2, string $message = ''): void { if (method_exists("PHPUnit\Framework\TestCase", "assertMatchesRegularExpression")) { parent::assertMatchesRegularExpression($arg1, $arg2, $message); } else { parent::assertRegExp($arg1, $arg2); } } }