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, '' . $m[1] . '>') > 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);
}
}
}