diff --git a/lib/ext/Syncroton/Command/Ping.php b/lib/ext/Syncroton/Command/Ping.php index a2bd926..2e71fd2 100644 --- a/lib/ext/Syncroton/Command/Ping.php +++ b/lib/ext/Syncroton/Command/Ping.php @@ -1,233 +1,237 @@ */ /** * class to handle ActiveSync Ping command * * @package Syncroton * @subpackage Command */ class Syncroton_Command_Ping extends Syncroton_Command_Wbxml { const STATUS_NO_CHANGES_FOUND = 1; const STATUS_CHANGES_FOUND = 2; const STATUS_MISSING_PARAMETERS = 3; const STATUS_REQUEST_FORMAT_ERROR = 4; const STATUS_INTERVAL_TO_GREAT_OR_SMALL = 5; const STATUS_TO_MUCH_FOLDERS = 6; const STATUS_FOLDER_NOT_FOUND = 7; const STATUS_GENERAL_ERROR = 8; protected $_skipValidatePolicyKey = true; protected $_changesDetected = false; /** * @var Syncroton_Backend_StandAlone_Abstract */ protected $_dataBackend; protected $_defaultNameSpace = 'uri:Ping'; protected $_documentElement = 'Ping'; protected $_foldersWithChanges = array(); /** * process the XML file and add, change, delete or fetches data * * @todo can we get rid of LIBXML_NOWARNING * @todo we need to stored the initial data for folders and lifetime as the phone is sending them only when they change * @return resource */ public function handle() { $intervalStart = time(); $status = self::STATUS_NO_CHANGES_FOUND; // the client does not send a wbxml document, if the Ping parameters did not change compared with the last request if ($this->_requestBody instanceof DOMDocument) { $xml = simplexml_import_dom($this->_requestBody); $xml->registerXPathNamespace('Ping', 'Ping'); if(isset($xml->HeartbeatInterval)) { $this->_device->pinglifetime = (int)$xml->HeartbeatInterval; } if (isset($xml->Folders->Folder)) { $folders = array(); foreach ($xml->Folders->Folder as $folderXml) { try { // does the folder exist? $folder = $this->_folderBackend->getFolder($this->_device, (string)$folderXml->Id); $folders[$folder->id] = $folder; } catch (Syncroton_Exception_NotFound $senf) { if ($this->_logger instanceof Zend_Log) $this->_logger->debug(__METHOD__ . '::' . __LINE__ . " " . $senf->getMessage()); $status = self::STATUS_FOLDER_NOT_FOUND; break; } } $this->_device->pingfolder = serialize(array_keys($folders)); } } $this->_device->lastping = new DateTime('now', new DateTimeZone('utc')); if ($status == self::STATUS_NO_CHANGES_FOUND) { $this->_device = $this->_deviceBackend->update($this->_device); } $lifeTime = $this->_device->pinglifetime; - $maxLifeTime = Syncroton_Registry::getPingInterval(); + $maxInterval = Syncroton_Registry::getPingInterval(); - if ($maxLifeTime > 0 && $lifeTime > $maxLifeTime) { + if ($maxInterval <= 0 || $maxInterval > Syncroton_Server::MAX_HEARTBEAT_INTERVAL) { + $maxInterval = Syncroton_Server::MAX_HEARTBEAT_INTERVAL; + } + + if ($lifeTime > $maxInterval) { $ping = $this->_outputDom->documentElement; $ping->appendChild($this->_outputDom->createElementNS('uri:Ping', 'Status', self::STATUS_INTERVAL_TO_GREAT_OR_SMALL)); - $ping->appendChild($this->_outputDom->createElementNS('uri:Ping', 'HeartbeatInterval', $maxLifeTime)); + $ping->appendChild($this->_outputDom->createElementNS('uri:Ping', 'HeartbeatInterval', $maxInterval)); return; } $intervalEnd = $intervalStart + $lifeTime; $secondsLeft = $intervalEnd; $folders = unserialize($this->_device->pingfolder); if ($status === self::STATUS_NO_CHANGES_FOUND && (!is_array($folders) || count($folders) == 0)) { $status = self::STATUS_MISSING_PARAMETERS; } if ($this->_logger instanceof Zend_Log) $this->_logger->debug(__METHOD__ . '::' . __LINE__ . " Folders to monitor($lifeTime / $intervalStart / $intervalEnd / $status): " . print_r($folders, true)); if ($status === self::STATUS_NO_CHANGES_FOUND) { do { // take a break to save battery lifetime sleep(Syncroton_Registry::getPingTimeout()); try { $device = $this->_deviceBackend->get($this->_device->id); } catch (Syncroton_Exception_NotFound $e) { if ($this->_logger instanceof Zend_Log) $this->_logger->debug(__METHOD__ . '::' . __LINE__ . " " . $e->getMessage()); $status = self::STATUS_FOLDER_NOT_FOUND; break; } catch (Exception $e) { if ($this->_logger instanceof Zend_Log) $this->_logger->err(__METHOD__ . '::' . __LINE__ . " " . $e->getMessage()); // do nothing, maybe temporal issue, should we stop? continue; } // if another Ping command updated lastping property, we can stop processing this Ping command request if ((isset($device->lastping) && $device->lastping instanceof DateTime) && $device->pingfolder === $this->_device->pingfolder && $device->lastping->getTimestamp() > $this->_device->lastping->getTimestamp() ) { break; } $now = new DateTime('now', new DateTimeZone('utc')); foreach ($folders as $folderId) { try { $folder = $this->_folderBackend->get($folderId); $dataController = Syncroton_Data_Factory::factory($folder->class, $this->_device, $this->_syncTimeStamp); } catch (Syncroton_Exception_NotFound $e) { if ($this->_logger instanceof Zend_Log) $this->_logger->debug(__METHOD__ . '::' . __LINE__ . " " . $e->getMessage()); $status = self::STATUS_FOLDER_NOT_FOUND; break; } catch (Exception $e) { if ($this->_logger instanceof Zend_Log) $this->_logger->err(__METHOD__ . '::' . __LINE__ . " " . $e->getMessage()); // do nothing, maybe temporal issue, should we stop? continue; } try { $syncState = $this->_syncStateBackend->getSyncState($this->_device, $folder); // another process synchronized data of this folder already. let's skip it if ($syncState->lastsync > $this->_syncTimeStamp) { continue; } // safe battery time by skipping folders which got synchronied less than Syncroton_Registry::getQuietTime() seconds ago if (($now->getTimestamp() - $syncState->lastsync->getTimestamp()) < Syncroton_Registry::getQuietTime()) { continue; } $foundChanges = $dataController->hasChanges($this->_contentStateBackend, $folder, $syncState); } catch (Syncroton_Exception_NotFound $e) { // folder got never synchronized to client if ($this->_logger instanceof Zend_Log) $this->_logger->debug(__METHOD__ . '::' . __LINE__ . " " . $e->getMessage()); if ($this->_logger instanceof Zend_Log) $this->_logger->info(__METHOD__ . '::' . __LINE__ . ' syncstate not found. enforce sync for folder: ' . $folder->serverId); $foundChanges = true; } if ($foundChanges == true) { $this->_foldersWithChanges[] = $folder; $status = self::STATUS_CHANGES_FOUND; } } if ($status != self::STATUS_NO_CHANGES_FOUND) { break; } $secondsLeft = $intervalEnd - time(); if ($this->_logger instanceof Zend_Log) $this->_logger->debug(__METHOD__ . '::' . __LINE__ . " DeviceId: " . $this->_device->deviceid . " seconds left: " . $secondsLeft); // 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 ($secondsLeft > (Syncroton_Registry::getPingTimeout() + 10)); } if ($this->_logger instanceof Zend_Log) $this->_logger->info(__METHOD__ . '::' . __LINE__ . " DeviceId: " . $this->_device->deviceid . " Lifetime: $lifeTime SecondsLeft: $secondsLeft Status: $status)"); $ping = $this->_outputDom->documentElement; $ping->appendChild($this->_outputDom->createElementNS('uri:Ping', 'Status', $status)); if($status === self::STATUS_CHANGES_FOUND) { $folders = $ping->appendChild($this->_outputDom->createElementNS('uri:Ping', 'Folders')); foreach($this->_foldersWithChanges as $changedFolder) { $folder = $folders->appendChild($this->_outputDom->createElementNS('uri:Ping', 'Folder', $changedFolder->serverId)); if ($this->_logger instanceof Zend_Log) $this->_logger->info(__METHOD__ . '::' . __LINE__ . " DeviceId: " . $this->_device->deviceid . " changes in folder: " . $changedFolder->serverId); } } } /** * generate ping command response * */ public function getResponse() { return $this->_outputDom; } } diff --git a/lib/ext/Syncroton/Command/Sync.php b/lib/ext/Syncroton/Command/Sync.php index 3a3bc26..e528999 100644 --- a/lib/ext/Syncroton/Command/Sync.php +++ b/lib/ext/Syncroton/Command/Sync.php @@ -1,1108 +1,1121 @@ */ /** * 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 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; /** * @var Syncroton_Model_SyncState */ protected $_syncState; 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; - } elseif (isset($requestXML->Wait)) { - $this->_heartbeatInterval = (int)$requestXML->Wait * 60; + } 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:Sync', 'Status', self::STATUS_WAIT_INTERVAL_OUT_OF_RANGE)); + $sync->appendChild($this->_outputDom->createElementNS('uri:Sync', 'Limit', floor($maxInterval/$intervalDiv))); + $this->_heartbeatInterval = null; } $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(); } } $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; } // check for invalid sycnkey 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"); 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"); $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($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($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"); $toBeFecthed = 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(); do { // take a break to save battery lifetime sleep(Syncroton_Registry::getPingTimeout()); $now = new DateTime(null, 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 (time() - $intervalStart < $this->_heartbeatInterval - (Syncroton_Registry::getPingTimeout() + 10)); } 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 collectionid provided if (! ($collectionData->folder instanceof Syncroton_Model_IFolder)) { $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_FOLDER_HIERARCHY_HAS_CHANGED)); // invalid synckey provided } elseif (! ($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(), ); 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; } elseif ($dataController->hasChanges($this->_contentStateBackend, $collectionData->folder, $collectionData->syncState)) { // update _syncTimeStamp as $dataController->hasChanges might have spent some time $this->_syncTimeStamp = new DateTime(null, new DateTimeZone('utc')); try { // fetch entries added since last sync $allClientEntries = $this->_contentStateBackend->getFolderState( $this->_device, $collectionData->folder ); $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); // fetch entries changed since last sync $serverModifications['changed'] = $dataController->getChangedEntries( $collectionData->collectionId, $collectionData->syncState->lastsync, $this->_syncTimeStamp, $collectionData->options['filterType'] ); $serverModifications['changed'] = array_merge($serverModifications['changed'], $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 set Status and break the loop here? $serverModifications = array( 'added' => array(), 'changed' => array(), 'deleted' => array(), ); } } 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', self::STATUS_SUCCESS)); $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 is 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); $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); continue; } 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()); } // mark as sent to the client, even the conversion to xml might have failed $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 )); 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); continue; } catch (Exception $e) { if ($this->_logger instanceof Zend_Log) $this->_logger->warn(__METHOD__ . '::' . __LINE__ . " unable to convert entry to xml: " . $e->getMessage()); } 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()); } unset($serverModifications['deleted'][$id]); } $countOfPendingChanges = (count($serverModifications['added']) + count($serverModifications['changed']) + count($serverModifications['deleted'])); if ($countOfPendingChanges > 0) { $collection->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'MoreAvailable')); } else { $serverModifications = null; } if ($commands->hasChildNodes() === true) { $collection->appendChild($commands); } $totalChanges += $collectionChanges; // 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); } } 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; } if (!empty($clientModifications['added'])) { if ($this->_logger instanceof Zend_Log) $this->_logger->info(__METHOD__ . '::' . __LINE__ . " remove previous synckey as client added new entries"); $keepPreviousSyncKey = false; } else { $keepPreviousSyncKey = true; } $collectionData->syncState->lastsync = clone $this->_syncTimeStamp; // increment sync timestamp by 1 second $collectionData->syncState->lastsync->modify('+1 sec'); try { $transactionId = Syncroton_Registry::getTransactionManager()->startTransaction(Syncroton_Registry::getDatabase()); // store new synckey $this->_syncStateBackend->create($collectionData->syncState, $keepPreviousSyncKey); // store contentstates for new entries added to client foreach($newContentStates as $state) { $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); } catch (Zend_Db_Statement_Exception $zdse) { // 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'], array()); } } 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/Server.php b/lib/ext/Syncroton/Server.php index e89b0fe..0fa4855 100644 --- a/lib/ext/Syncroton/Server.php +++ b/lib/ext/Syncroton/Server.php @@ -1,445 +1,446 @@ */ /** * class to handle incoming http ActiveSync requests * * @package Syncroton */ class Syncroton_Server { const PARAMETER_ATTACHMENTNAME = 0; const PARAMETER_COLLECTIONID = 1; const PARAMETER_ITEMID = 3; const PARAMETER_OPTIONS = 7; + const MAX_HEARTBEAT_INTERVAL = 3540; // 59 minutes protected $_body; /** * informations about the currently device * * @var Syncroton_Backend_IDevice */ protected $_deviceBackend; /** * @var Zend_Log */ protected $_logger; /** * @var Zend_Controller_Request_Http */ protected $_request; protected $_userId; public function __construct($userId, Zend_Controller_Request_Http $request = null, $body = null) { if (Syncroton_Registry::isRegistered('loggerBackend')) { $this->_logger = Syncroton_Registry::get('loggerBackend'); } $this->_userId = $userId; $this->_request = $request instanceof Zend_Controller_Request_Http ? $request : new Zend_Controller_Request_Http(); $this->_body = $body !== null ? $body : fopen('php://input', 'r'); $this->_deviceBackend = Syncroton_Registry::getDeviceBackend(); } public function handle() { if ($this->_logger instanceof Zend_Log) $this->_logger->debug(__METHOD__ . '::' . __LINE__ . ' REQUEST METHOD: ' . $this->_request->getMethod()); switch($this->_request->getMethod()) { case 'OPTIONS': $this->_handleOptions(); break; case 'POST': $this->_handlePost(); break; case 'GET': echo "It works!
Your userid is: {$this->_userId} and your IP address is: {$_SERVER['REMOTE_ADDR']}."; break; } } /** * handle options request */ protected function _handleOptions() { $command = new Syncroton_Command_Options(); $this->_sendHeaders($command->getHeaders()); } protected function _sendHeaders(array $headers) { foreach ($headers as $name => $value) { header($name . ': ' . $value); } } /** * handle post request */ protected function _handlePost() { $requestParameters = $this->_getRequestParameters($this->_request); if ($this->_logger instanceof Zend_Log) $this->_logger->debug(__METHOD__ . '::' . __LINE__ . ' REQUEST ' . print_r($requestParameters, true)); $className = 'Syncroton_Command_' . $requestParameters['command']; if(!class_exists($className)) { if ($this->_logger instanceof Zend_Log) $this->_logger->crit(__METHOD__ . '::' . __LINE__ . " command not supported: " . $requestParameters['command']); header("HTTP/1.1 501 not implemented"); return; } // get user device $device = $this->_getUserDevice($this->_userId, $requestParameters); if ($requestParameters['contentType'] == 'application/vnd.ms-sync.wbxml' || $requestParameters['contentType'] == 'application/vnd.ms-sync') { // decode wbxml request try { $decoder = new Syncroton_Wbxml_Decoder($this->_body); $requestBody = $decoder->decode(); if ($this->_logger instanceof Zend_Log) { $requestBody->formatOutput = true; $this->_logger->debug(__METHOD__ . '::' . __LINE__ . " xml request:\n" . $requestBody->saveXML()); } } catch(Syncroton_Wbxml_Exception_UnexpectedEndOfFile $e) { $requestBody = NULL; } } else { $requestBody = $this->_body; } header("MS-Server-ActiveSync: 14.00.0536.000"); // avoid sending HTTP header "Content-Type: text/html" for empty sync responses ini_set('default_mimetype', 'application/vnd.ms-sync.wbxml'); try { $command = new $className($requestBody, $device, $requestParameters); $command->handle(); $response = $command->getResponse(); } catch (Syncroton_Exception_ProvisioningNeeded $sepn) { if ($this->_logger instanceof Zend_Log) $this->_logger->info(__METHOD__ . '::' . __LINE__ . " provisioning needed"); header("HTTP/1.1 449 Retry after sending a PROVISION command"); if (version_compare($device->acsversion, '14.0', '>=')) { $response = $sepn->domDocument; } else { // pre 14.0 method return; } } catch (Exception $e) { if ($this->_logger instanceof Zend_Log) $this->_logger->crit(__METHOD__ . '::' . __LINE__ . " unexpected exception occured: " . get_class($e)); if ($this->_logger instanceof Zend_Log) $this->_logger->crit(__METHOD__ . '::' . __LINE__ . " exception message: " . $e->getMessage()); if ($this->_logger instanceof Zend_Log) $this->_logger->crit(__METHOD__ . '::' . __LINE__ . " " . $e->getTraceAsString()); header("HTTP/1.1 500 Internal server error"); return; } if ($response instanceof DOMDocument) { if ($this->_logger instanceof Zend_Log) { $this->_logDomDocument(Zend_Log::DEBUG, $response, __METHOD__, __LINE__); } if (isset($command) && $command instanceof Syncroton_Command_ICommand) { $this->_sendHeaders($command->getHeaders()); } $outputStream = fopen("php://temp", 'r+'); $encoder = new Syncroton_Wbxml_Encoder($outputStream, 'UTF-8', 3); try { $encoder->encode($response); } catch (Syncroton_Wbxml_Exception $swe) { if ($this->_logger instanceof Zend_Log) { $this->_logger->err(__METHOD__ . '::' . __LINE__ . " Could not encode output: " . $swe); $this->_logDomDocument(Zend_Log::ERR, $response, __METHOD__, __LINE__); } header("HTTP/1.1 500 Internal server error"); return; } if ($requestParameters['acceptMultipart'] == true) { $parts = $command->getParts(); // output multipartheader $bodyPartCount = 1 + count($parts); // number of parts (4 bytes) $header = pack('i', $bodyPartCount); $partOffset = 4 + (($bodyPartCount * 2) * 4); // wbxml body start and length $streamStat = fstat($outputStream); $header .= pack('ii', $partOffset, $streamStat['size']); $partOffset += $streamStat['size']; // calculate start and length of parts foreach ($parts as $partId => $partStream) { rewind($partStream); $streamStat = fstat($partStream); // part start and length $header .= pack('ii', $partOffset, $streamStat['size']); $partOffset += $streamStat['size']; } echo $header; } // output body rewind($outputStream); fpassthru($outputStream); // output multiparts if (isset($parts)) { foreach ($parts as $partStream) { rewind($partStream); fpassthru($partStream); } } } } /** * write (possible big) DOMDocument in smaller chunks to log file * * @param unknown $priority * @param DOMDocument $dom * @param string $method * @param int $line */ protected function _logDomDocument($priority, DOMDocument $dom, $method, $line) { $loops = 0; $tempStream = fopen('php://temp/maxmemory:5242880', 'r+'); $dom->formatOutput = true; fwrite($tempStream, $dom->saveXML()); $dom->formatOutput = false; rewind($tempStream); // log data in 1MByte chunks while (!feof($tempStream)) { $this->_logger->log($method . '::' . $line . " xml response($loops):\n" . fread($tempStream, 1048576), $priority); $loops++; } fclose($tempStream); } /** * return request params * * @return array */ protected function _getRequestParameters(Zend_Controller_Request_Http $request) { if (strpos($request->getRequestUri(), '&') === false) { $commands = array( 0 => 'Sync', 1 => 'SendMail', 2 => 'SmartForward', 3 => 'SmartReply', 4 => 'GetAttachment', 9 => 'FolderSync', 10 => 'FolderCreate', 11 => 'FolderDelete', 12 => 'FolderUpdate', 13 => 'MoveItems', 14 => 'GetItemEstimate', 15 => 'MeetingResponse', 16 => 'Search', 17 => 'Settings', 18 => 'Ping', 19 => 'ItemOperations', 20 => 'Provision', 21 => 'ResolveRecipients', 22 => 'ValidateCert' ); $requestParameters = substr($request->getRequestUri(), strpos($request->getRequestUri(), '?')); $stream = fopen("php://temp", 'r+'); fwrite($stream, base64_decode($requestParameters)); rewind($stream); // unpack the first 4 bytes $unpacked = unpack('CprotocolVersion/Ccommand/vlocale', fread($stream, 4)); // 140 => 14.0 $protocolVersion = substr($unpacked['protocolVersion'], 0, -1) . '.' . substr($unpacked['protocolVersion'], -1); $command = $commands[$unpacked['command']]; $locale = $unpacked['locale']; // unpack deviceId $length = ord(fread($stream, 1)); if ($length > 0) { $toUnpack = fread($stream, $length); $unpacked = unpack("H" . ($length * 2) . "string", $toUnpack); $deviceId = $unpacked['string']; } // unpack policyKey $length = ord(fread($stream, 1)); if ($length > 0) { $unpacked = unpack('Vstring', fread($stream, $length)); $policyKey = $unpacked['string']; } // unpack device type $length = ord(fread($stream, 1)); if ($length > 0) { $unpacked = unpack('A' . $length . 'string', fread($stream, $length)); $deviceType = $unpacked['string']; } while (! feof($stream)) { $tag = ord(fread($stream, 1)); $length = ord(fread($stream, 1)); switch ($tag) { case self::PARAMETER_ATTACHMENTNAME: $unpacked = unpack('A' . $length . 'string', fread($stream, $length)); $attachmentName = $unpacked['string']; break; case self::PARAMETER_COLLECTIONID: $unpacked = unpack('A' . $length . 'string', fread($stream, $length)); $collectionId = $unpacked['string']; break; case self::PARAMETER_ITEMID: $unpacked = unpack('A' . $length . 'string', fread($stream, $length)); $itemId = $unpacked['string']; break; case self::PARAMETER_OPTIONS: $options = ord(fread($stream, 1)); $saveInSent = !!($options & 0x01); $acceptMultiPart = !!($options & 0x02); break; default: if ($this->_logger instanceof Zend_Log) $this->_logger->crit(__METHOD__ . '::' . __LINE__ . " found unhandled command parameters"); } } $result = array( 'protocolVersion' => $protocolVersion, 'command' => $command, 'deviceId' => $deviceId, 'deviceType' => isset($deviceType) ? $deviceType : null, 'policyKey' => isset($policyKey) ? $policyKey : null, 'saveInSent' => isset($saveInSent) ? $saveInSent : false, 'collectionId' => isset($collectionId) ? $collectionId : null, 'itemId' => isset($itemId) ? $itemId : null, 'attachmentName' => isset($attachmentName) ? $attachmentName : null, 'acceptMultipart' => isset($acceptMultiPart) ? $acceptMultiPart : false ); } else { $result = array( 'protocolVersion' => $request->getServer('HTTP_MS_ASPROTOCOLVERSION'), 'command' => $request->getQuery('Cmd'), 'deviceId' => $request->getQuery('DeviceId'), 'deviceType' => $request->getQuery('DeviceType'), 'policyKey' => $request->getServer('HTTP_X_MS_POLICYKEY'), 'saveInSent' => $request->getQuery('SaveInSent') == 'T', 'collectionId' => $request->getQuery('CollectionId'), 'itemId' => $request->getQuery('ItemId'), 'attachmentName' => $request->getQuery('AttachmentName'), 'acceptMultipart' => $request->getServer('HTTP_MS_ASACCEPTMULTIPART') == 'T' ); } $result['userAgent'] = $request->getServer('HTTP_USER_AGENT', $result['deviceType']); $result['contentType'] = $request->getServer('CONTENT_TYPE'); return $result; } /** * get existing device of owner or create new device for owner * * @param unknown_type $ownerId * @param unknown_type $deviceId * @param unknown_type $deviceType * @param unknown_type $userAgent * @param unknown_type $protocolVersion * @return Syncroton_Model_Device */ protected function _getUserDevice($ownerId, $requestParameters) { try { $device = $this->_deviceBackend->getUserDevice($ownerId, $requestParameters['deviceId']); $device->useragent = $requestParameters['userAgent']; $device->acsversion = $requestParameters['protocolVersion']; if ($device->isDirty()) { $device = $this->_deviceBackend->update($device); } } catch (Syncroton_Exception_NotFound $senf) { $device = $this->_deviceBackend->create(new Syncroton_Model_Device(array( 'owner_id' => $ownerId, 'deviceid' => $requestParameters['deviceId'], 'devicetype' => $requestParameters['deviceType'], 'useragent' => $requestParameters['userAgent'], 'acsversion' => $requestParameters['protocolVersion'], 'policyId' => Syncroton_Registry::isRegistered(Syncroton_Registry::DEFAULT_POLICY) ? Syncroton_Registry::get(Syncroton_Registry::DEFAULT_POLICY) : null ))); } return $device; } }