diff --git a/lib/ext/Syncroton/Command/Sync.php b/lib/ext/Syncroton/Command/Sync.php index db7f2df..43daebb 100644 --- a/lib/ext/Syncroton/Command/Sync.php +++ b/lib/ext/Syncroton/Command/Sync.php @@ -1,1163 +1,1177 @@ */ /** * class to handle ActiveSync Sync command * * @package Syncroton * @subpackage Command */ -class Syncroton_Command_Sync extends Syncroton_Command_Wbxml +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; /** * @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; } 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; } // 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()) { + if ($collectionData->hasClientAdds()) { $adds = $collectionData->getClientAdds(); - - if ($this->_logger instanceof Zend_Log) + + 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) + 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) + 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 - ))) - ); - + + $options = array('class' => $add->Class, 'send' => isset($add->Send)); + $result = $dataController->createEntry($collectionData->collectionId, new $dataClass($add->ApplicationData), $options); + + $this->_registerSyncResponse($result, $add, $clientModifications, $collectionData); + } catch (Exception $e) { - if ($this->_logger instanceof Zend_Log) + 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 - ); + + $result = new Syncroton_Model_SyncResponse(array('status' => self::STATUS_SERVER_ERROR)); + $this->_registerSyncResponse($result, $add, $clientModifications, $collectionData); } } } - + // handle changes, but only if not first sync - if($collectionData->syncKey > 1 && $collectionData->hasClientChanges()) { + if ($collectionData->syncKey > 1 && $collectionData->hasClientChanges()) { $changes = $collectionData->getClientChanges(); - - if ($this->_logger instanceof Zend_Log) + + 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; - + $options = array('instanceId' => $change->InstanceId, 'send' => isset($change->Send)); + try { - $dataController->updateEntry($collectionData->collectionId, $serverId, new $dataClass($change->ApplicationData)); - $clientModifications['changed'][$serverId] = self::STATUS_SUCCESS; - + $result = $dataController->updateEntry($collectionData->collectionId, $serverId, new $dataClass($change->ApplicationData), $options); + } catch (Syncroton_Exception_AccessDenied $e) { - $clientModifications['changed'][$serverId] = self::STATUS_CONFLICT_MATCHING_THE_CLIENT_AND_SERVER_OBJECT; + $result = new Syncroton_Model_SyncResponse(array('status' => 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; - + $result = new Syncroton_Model_SyncResponse(array('status' => 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; + $result = new Syncroton_Model_SyncResponse(array('status' => self::STATUS_SERVER_ERROR)); } + + $this->_registerSyncResponse($result, $change, $clientModifications); } } - + // handle deletes, but only if not first sync - if($collectionData->hasClientDeletes()) { + if ($collectionData->hasClientDeletes()) { $deletes = $collectionData->getClientDeletes(); - if ($this->_logger instanceof Zend_Log) + + 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); - + $result = $dataController->deleteEntry($collectionData->collectionId, $serverId, $collectionData); + } catch(Syncroton_Exception_NotFound $e) { - if ($this->_logger instanceof Zend_Log) + 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) + 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) + 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; + + $this->_registerSyncResponse($result, $delete, $clientModifications); } } - + // handle fetches, but only if not first sync - if($collectionData->syncKey > 1 && $collectionData->hasClientFetches()) { + 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(); $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(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 (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)) { $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(null, new DateTimeZone('utc')); try { // fetch entries added since last sync $allClientEntries = $this->_contentStateBackend->getFolderState( $this->_device, $collectionData->folder ); // fetch entries changed since last sync $allChangedEntries = $dataController->getChangedEntries( $collectionData->collectionId, $collectionData->syncState->lastsync, $this->_syncTimeStamp, $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) { + 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 (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) { + 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 (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'); - + + // The client only receives responses for successful additions, successful fetches, + // successful changes that include an attachment being added, and failed changes and deletions + // send reponse for newly added entries - if(!empty($clientModifications['added'])) { - foreach($clientModifications['added'] as $entryData) { + if (!empty($clientModifications['added'])) { + foreach ($clientModifications['added'] as $entry) { $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'])); + $entry->appendXML($add, $this->_device); } } - + // send reponse for changed entries - if(!empty($clientModifications['changed'])) { - foreach($clientModifications['changed'] as $serverId => $status) { - if ($status !== Syncroton_Command_Sync::STATUS_SUCCESS) { + if (!empty($clientModifications['changed'])) { + foreach ($clientModifications['changed'] as $serverId => $entry) { + if ($entry->status !== Syncroton_Command_Sync::STATUS_SUCCESS + || (isset($entry->applicationData) && !empty($entry->applicationData->attachments)) + ) { $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)); + $entry->appendXML($change, $this->_device); } } } - + // send response for to be fetched entries - if(!empty($collectionData->toBeFetched)) { + 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); + $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); + $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]); + unset($serverModifications['changed'][$id]); } - foreach($serverModifications['deleted'] as $id => $serverId) { + 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 - ) + 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++; - } + // ...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; } + + private function _registerSyncResponse($result, $request, &$register, $collectionData = null) + { + switch ($request->getName()) { + case 'Add': $mode = 'added'; break; + case 'Change': $mode = 'changed'; break; + case 'Delete': $mode = 'deleted'; break; + } + + if (! $result instanceof Syncroton_Model_SyncResponse) { + $result = new Syncroton_Model_SyncResponse(array( + 'serverId' => $result, + )); + } + + if (empty($result->serverId) && $request->ServerId) { + $result->serverId = (string) $request->ServerId; + } + + if (empty($result->instanceId) && $request->InstanceId) { + $result->instanceId = (string) $request->InstanceId; + } + + if (empty($result->clientId) && $request->ClientId) { + $result->clientId = (string) $request->ClientId; + } + + if (!isset($result->status)) { + $result->status = self::STATUS_SUCCESS; + } + + if ($collectionData && $mode == 'added') { + $result->contentState = $this->_contentStateBackend->create(new Syncroton_Model_Content(array( + 'device_id' => $this->_device, + 'folder_id' => $collectionData->folder, + 'contentid' => $result->serverId, + 'creation_time' => $this->_syncTimeStamp, + 'creation_synckey' => $collectionData->syncKey + 1 + ))); + } + + $register[$mode][$result->serverId] = $result; + + return $result; + } } diff --git a/lib/ext/Syncroton/Data/AData.php b/lib/ext/Syncroton/Data/AData.php index 7867237..520e0d2 100644 --- a/lib/ext/Syncroton/Data/AData.php +++ b/lib/ext/Syncroton/Data/AData.php @@ -1,366 +1,364 @@ */ /** * 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 ⇔ - + 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 - + $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( + + $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, + public function createEntry($_folderId, Syncroton_Model_IEntry $_entry, $options = array()) + { + $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; - } - + )); + + 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)); - + + $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(); - + $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) { $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(); - + + $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(); - + $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); + + $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; + } + + 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); - + + $addedEntries = array_diff($allServerEntries, $allClientEntries); + $deletedEntries = array_diff($allClientEntries, $allServerEntries); + $changedEntries = $this->getChangedEntries($folder->serverId, $syncState->lastsync, null, $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'); - } - + 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) - { + 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 - )); - + $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) + public function updateEntry($_folderId, $_serverId, Syncroton_Model_IEntry $_entry, $options = array()) { $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) - { + */ + 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/Email.php b/lib/ext/Syncroton/Data/Email.php index b55f9e8..af03cff 100644 --- a/lib/ext/Syncroton/Data/Email.php +++ b/lib/ext/Syncroton/Data/Email.php @@ -1,87 +1,87 @@ */ /** * class to handle ActiveSync Sync command * * @package Syncroton * @subpackage Data */ class Syncroton_Data_Email extends Syncroton_Data_AData implements Syncroton_Data_IDataEmail { protected $_supportedFolderTypes = array( Syncroton_Command_FolderSync::FOLDERTYPE_DELETEDITEMS, Syncroton_Command_FolderSync::FOLDERTYPE_DRAFTS, Syncroton_Command_FolderSync::FOLDERTYPE_INBOX, Syncroton_Command_FolderSync::FOLDERTYPE_MAIL_USER_CREATED, Syncroton_Command_FolderSync::FOLDERTYPE_OUTBOX, Syncroton_Command_FolderSync::FOLDERTYPE_SENTMAIL ); - + /** * (non-PHPdoc) * @see Syncroton_Data_IDataEmail::forwardEmail() */ public function forwardEmail($source, $inputStream, $saveInSent, $replaceMime) { - if ($inputStream == 'triggerException') { - throw new Syncroton_Exception_Status(Syncroton_Exception_Status::MAILBOX_SERVER_OFFLINE); - } - + if ($inputStream == 'triggerException') { + throw new Syncroton_Exception_Status(Syncroton_Exception_Status::MAILBOX_SERVER_OFFLINE); + } + // forward email } - + /** * (non-PHPdoc) * @see Syncroton_Data_AData::getFileReference() */ - public function getFileReference($fileReference) - { - list($messageId, $partId) = explode(Syncroton_Data_AData::LONGID_DELIMITER, $fileReference, 2); - - // example code - return new Syncroton_Model_FileReference(array( - 'contentType' => 'text/plain', - 'data' => 'Lars' - )); + public function getFileReference($fileReference) + { + list($messageId, $partId) = explode(Syncroton_Data_AData::LONGID_DELIMITER, $fileReference, 2); + + // example code + return new Syncroton_Model_FileReference(array( + 'contentType' => 'text/plain', + 'data' => 'Lars' + )); } - + /** * (non-PHPdoc) * @see Syncroton_Data_IDataEmail::replyEmail() - */ - public function replyEmail($source, $inputStream, $saveInSent, $replaceMime) - { - // forward email - } - + */ + public function replyEmail($source, $inputStream, $saveInSent, $replaceMime) + { + // forward email + } + /** * (non-PHPdoc) * @see Syncroton_Data_AData::updateEntry() */ - public function updateEntry($_folderId, $_serverId, Syncroton_Model_IEntry $_entry) + public function updateEntry($_folderId, $_serverId, Syncroton_Model_IEntry $_entry, $options = array()) { - // not used by email + // update an email (or SMS) } - + /** * (non-PHPdoc) * @see Syncroton_Data_IDataEmail::sendEmail() */ public function sendEmail($inputStream, $saveInSent) { if ($inputStream == 'triggerException') { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::MAILBOX_SERVER_OFFLINE); } + // send email } } - diff --git a/lib/ext/Syncroton/Data/IData.php b/lib/ext/Syncroton/Data/IData.php index a7e05b4..e7fa24b 100644 --- a/lib/ext/Syncroton/Data/IData.php +++ b/lib/ext/Syncroton/Data/IData.php @@ -1,127 +1,132 @@ */ /** * 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 + * @param array $options + * + * @return string|Syncroton_Model_SyncResponse id of updated entry, or response data */ - public function createEntry($folderId, Syncroton_Model_IEntry $entry); - + public function createEntry($folderId, Syncroton_Model_IEntry $entry, $options = array()); + /** * 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 unknown_type $_collectionData + * + * @return null|Syncroton_Model_SyncResponse Response data or nothing */ public function deleteEntry($_folderId, $_serverId, $_collectionData); - + /** * 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); - + /** * 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 unknown_type $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 - */ + * + * @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 + * @param array $options + * + * @return string|Syncroton_Model_SyncResponse id of updated entry, or response data */ - public function updateEntry($folderId, $serverId, Syncroton_Model_IEntry $entry); - + public function updateEntry($folderId, $serverId, Syncroton_Model_IEntry $entry, $options = array()); + public function updateFolder(Syncroton_Model_IFolder $folder); } - diff --git a/lib/ext/Syncroton/Model/EmailBody.php b/lib/ext/Syncroton/Model/EmailBody.php index b4d29ab..9fed1c8 100644 --- a/lib/ext/Syncroton/Model/EmailBody.php +++ b/lib/ext/Syncroton/Model/EmailBody.php @@ -1,44 +1,44 @@ */ /** * class to handle AirSyncBase:Body * * @package Syncroton * @subpackage Model - * @property int EstimatedDataSize - * @property string Data - * @property string Part - * @property string Preview - * @property bool Truncated - * @property string Type + * + * @property int $estimatedDataSize + * @property string $data + * @property string $part + * @property string $preview + * @property bool $truncated + * @property string $type */ - class Syncroton_Model_EmailBody extends Syncroton_Model_AXMLEntry { const TYPE_PLAINTEXT = 1; const TYPE_HTML = 2; const TYPE_RTF = 3; const TYPE_MIME = 4; - + protected $_xmlBaseElement = 'Body'; - + protected $_properties = array( 'AirSyncBase' => array( 'type' => array('type' => 'string'), 'estimatedDataSize' => array('type' => 'string'), 'data' => array('type' => 'string'), 'truncated' => array('type' => 'number'), 'part' => array('type' => 'number'), 'preview' => array('type' => 'string', 'supportedSince' => '14.0'), ), ); -} \ No newline at end of file +} diff --git a/lib/ext/Syncroton/Model/Folder.php b/lib/ext/Syncroton/Model/Folder.php index 4da0a97..e4cae87 100644 --- a/lib/ext/Syncroton/Model/Folder.php +++ b/lib/ext/Syncroton/Model/Folder.php @@ -1,39 +1,39 @@ */ /** * class to handle ActiveSync Sync command * * @package Syncroton * @subpackage Model */ class Syncroton_Model_Folder extends Syncroton_Model_AXMLEntry implements Syncroton_Model_IFolder { protected $_xmlBaseElement = array('FolderUpdate', 'FolderCreate'); - - protected $_properties = array( - 'FolderHierarchy' => array( - 'parentId' => array('type' => 'string'), - 'serverId' => array('type' => 'string'), - 'displayName' => array('type' => 'string'), + + protected $_properties = array( + 'FolderHierarchy' => array( + 'parentId' => array('type' => 'string'), + 'serverId' => array('type' => 'string'), + 'displayName' => array('type' => 'string'), 'type' => array('type' => 'number') - ), + ), 'Internal' => array( 'id' => array('type' => 'string'), 'deviceId' => array('type' => 'string'), 'ownerId' => array('type' => 'string'), 'class' => array('type' => 'string'), 'creationTime' => array('type' => 'datetime'), 'lastfiltertype' => array('type' => 'number') ), ); } diff --git a/lib/ext/Syncroton/Model/GAL.php b/lib/ext/Syncroton/Model/GAL.php index 64765ce..dbd8f0f 100644 --- a/lib/ext/Syncroton/Model/GAL.php +++ b/lib/ext/Syncroton/Model/GAL.php @@ -1,51 +1,51 @@ * @author Aleksander Machniak */ /** * class to handle ActiveSync GAL result * * @package Syncroton * @subpackage Model * - * @property string Alias - * @property string Company - * @property string DisplayName - * @property string EmailAddress - * @property string FirstName - * @property string LastName - * @property string MobilePhone - * @property string Office - * @property string Phone - * @property string Picture - * @property string Title + * @property string $alias + * @property string $company + * @property string $displayName + * @property string $emailAddress + * @property string $firstName + * @property string $lastName + * @property string $mobilePhone + * @property string $office + * @property string $phone + * @property Syncroton_ModelGALPicture $picture + * @property string $title */ class Syncroton_Model_GAL extends Syncroton_Model_AXMLEntry { protected $_xmlBaseElement = 'ApplicationData'; protected $_properties = array( 'GAL' => array( - 'alias' => array('type' => 'string', 'supportedSince' => '2.5'), - 'company' => array('type' => 'string', 'supportedSince' => '2.5'), - 'displayName' => array('type' => 'string', 'supportedSince' => '2.5'), - 'emailAddress' => array('type' => 'string', 'supportedSince' => '2.5'), - 'firstName' => array('type' => 'string', 'supportedSince' => '2.5'), - 'lastName' => array('type' => 'string', 'supportedSince' => '2.5'), - 'mobilePhone' => array('type' => 'string', 'supportedSince' => '2.5'), - 'office' => array('type' => 'string', 'supportedSince' => '2.5'), - 'phone' => array('type' => 'string', 'supportedSince' => '2.5'), + 'alias' => array('type' => 'string'), + 'company' => array('type' => 'string'), + 'displayName' => array('type' => 'string'), + 'emailAddress' => array('type' => 'string'), + 'firstName' => array('type' => 'string'), + 'lastName' => array('type' => 'string'), + 'mobilePhone' => array('type' => 'string'), + 'office' => array('type' => 'string'), + 'phone' => array('type' => 'string'), 'picture' => array('type' => 'container', 'supportedSince' => '14.0'), - 'title' => array('type' => 'string', 'supportedSince' => '2.5'), + 'title' => array('type' => 'string'), ) ); } diff --git a/lib/ext/Syncroton/Model/GALPicture.php b/lib/ext/Syncroton/Model/GALPicture.php index 8122490..1c92612 100644 --- a/lib/ext/Syncroton/Model/GALPicture.php +++ b/lib/ext/Syncroton/Model/GALPicture.php @@ -1,40 +1,40 @@ * @author Aleksander Machniak */ /** * class to handle ActiveSync GAL Picture element * * @package Syncroton * @subpackage Model * - * @property string Status - * @property string Data + * @property string $status + * @property string $data */ class Syncroton_Model_GALPicture extends Syncroton_Model_AXMLEntry { const STATUS_SUCCESS = 1; const STATUS_NOPHOTO = 173; const STATUS_TOOLARGE = 174; const STATUS_OVERLIMIT = 175; protected $_xmlBaseElement = 'ApplicationData'; protected $_properties = array( 'AirSync' => array( 'status' => array('type' => 'number'), ), 'GAL' => array( 'data' => array('type' => 'byteArray'), ), ); } diff --git a/lib/ext/Syncroton/Model/SyncCollection.php b/lib/ext/Syncroton/Model/SyncCollection.php index 1eaaa07..a55a824 100644 --- a/lib/ext/Syncroton/Model/SyncCollection.php +++ b/lib/ext/Syncroton/Model/SyncCollection.php @@ -1,319 +1,314 @@ */ /** * class to handle ActiveSync Sync collection * * @package Syncroton * @subpackage Model - * @property string class - * @property string collectionId - * @property bool deletesAsMoves - * @property bool getChanges - * @property string syncKey - * @property int windowSize + * + * @property string $class + * @property string $collectionId + * @property bool $conversationMode + * @property bool $deletesAsMoves + * @property bool $getChanges + * @property array $options + * @property string $syncKey + * @property int $windowSize */ - class Syncroton_Model_SyncCollection extends Syncroton_Model_AXMLEntry { protected $_elements = array( 'syncState' => null, - 'folder' => null + 'folder' => null, + 'options' => array(), ); - + protected $_xmlCollection; - + protected $_xmlBaseElement = 'Collection'; - - public function __construct($properties = null) - { - if ($properties instanceof SimpleXMLElement) { - $this->setFromSimpleXMLElement($properties); - } elseif (is_array($properties)) { - $this->setFromArray($properties); - } - - if (!isset($this->_elements['options'])) { - $this->_elements['options'] = array(); - } + + + public function __construct($properties = null) + { + parent::__construct($properties); + if (!isset($this->_elements['options']['filterType'])) { $this->_elements['options']['filterType'] = Syncroton_Command_Sync::FILTER_NOTHING; } if (!isset($this->_elements['options']['mimeSupport'])) { $this->_elements['options']['mimeSupport'] = Syncroton_Command_Sync::MIMESUPPORT_DONT_SEND_MIME; } if (!isset($this->_elements['options']['mimeTruncation'])) { $this->_elements['options']['mimeTruncation'] = Syncroton_Command_Sync::TRUNCATE_NOTHING; } - if (!isset($this->_elements['options']['bodyPreferences'])) { + if (!isset($this->_elements['options']['bodyPreferences'])) { $this->_elements['options']['bodyPreferences'] = array(); } - } - + } + /** * return XML element which holds all client Add commands - * + * * @return SimpleXMLElement */ public function getClientAdds() { if (! $this->_xmlCollection instanceof SimpleXMLElement) { throw new InvalidArgumentException('no collection xml element set'); } - + return $this->_xmlCollection->Commands->Add; } - + /** * return XML element which holds all client Change commands - * + * * @return SimpleXMLElement */ public function getClientChanges() { if (! $this->_xmlCollection instanceof SimpleXMLElement) { throw new InvalidArgumentException('no collection xml element set'); } - + return $this->_xmlCollection->Commands->Change; } - + /** * return XML element which holds all client Delete commands - * + * * @return SimpleXMLElement */ public function getClientDeletes() { if (! $this->_xmlCollection instanceof SimpleXMLElement) { throw new InvalidArgumentException('no collection xml element set'); } - + return $this->_xmlCollection->Commands->Delete; } - + /** * return XML element which holds all client Fetch commands - * + * * @return SimpleXMLElement */ public function getClientFetches() { if (! $this->_xmlCollection instanceof SimpleXMLElement) { throw new InvalidArgumentException('no collection xml element set'); } - + return $this->_xmlCollection->Commands->Fetch; } - + /** * check if client sent a Add command - * + * * @throws InvalidArgumentException * @return bool */ public function hasClientAdds() { if (! $this->_xmlCollection instanceof SimpleXMLElement) { return false; } - + return isset($this->_xmlCollection->Commands->Add); } - + /** * check if client sent a Change command - * + * * @throws InvalidArgumentException * @return bool */ public function hasClientChanges() { if (! $this->_xmlCollection instanceof SimpleXMLElement) { return false; } - + return isset($this->_xmlCollection->Commands->Change); } - + /** * check if client sent a Delete command - * + * * @throws InvalidArgumentException * @return bool */ public function hasClientDeletes() { if (! $this->_xmlCollection instanceof SimpleXMLElement) { return false; } - + return isset($this->_xmlCollection->Commands->Delete); } - + /** * check if client sent a Fetch command - * + * * @throws InvalidArgumentException * @return bool */ public function hasClientFetches() { if (! $this->_xmlCollection instanceof SimpleXMLElement) { return false; } - + return isset($this->_xmlCollection->Commands->Fetch); } - + /** * this functions does not only set from SimpleXMLElement but also does merge from SimpleXMLElement * to support partial sync requests - * + * * @param SimpleXMLElement $properties * @throws InvalidArgumentException */ public function setFromSimpleXMLElement(SimpleXMLElement $properties) { if (!in_array($properties->getName(), (array) $this->_xmlBaseElement)) { throw new InvalidArgumentException('Unexpected element name: ' . $properties->getName()); } - + $this->_xmlCollection = $properties; - + if (isset($properties->CollectionId)) { $this->_elements['collectionId'] = (string)$properties->CollectionId; } - + if (isset($properties->SyncKey)) { $this->_elements['syncKey'] = (int)$properties->SyncKey; } - + if (isset($properties->Class)) { $this->_elements['class'] = (string)$properties->Class; } elseif (!array_key_exists('class', $this->_elements)) { $this->_elements['class'] = null; } - + if (isset($properties->WindowSize)) { $this->_elements['windowSize'] = (string)$properties->WindowSize; } elseif (!array_key_exists('windowSize', $this->_elements)) { $this->_elements['windowSize'] = 100; } - + if (isset($properties->DeletesAsMoves)) { if ((string)$properties->DeletesAsMoves === '0') { $this->_elements['deletesAsMoves'] = false; } else { $this->_elements['deletesAsMoves'] = true; } } elseif (!array_key_exists('deletesAsMoves', $this->_elements)) { $this->_elements['deletesAsMoves'] = true; } - + if (isset($properties->ConversationMode)) { if ((string)$properties->ConversationMode === '0') { $this->_elements['conversationMode'] = false; } else { $this->_elements['conversationMode'] = true; } } elseif (!array_key_exists('conversationMode', $this->_elements)) { $this->_elements['conversationMode'] = true; } - + if (isset($properties->GetChanges)) { if ((string)$properties->GetChanges === '0') { $this->_elements['getChanges'] = false; } else { $this->_elements['getChanges'] = true; } } elseif (!array_key_exists('getChanges', $this->_elements)) { $this->_elements['getChanges'] = true; } - + if (isset($properties->Supported)) { // @todo collect supported elements } - - // process options + + // process options if (isset($properties->Options)) { $this->_elements['options'] = array(); - - // optional parameters - if (isset($properties->Options->FilterType)) { - $this->_elements['options']['filterType'] = (int)$properties->Options->FilterType; - } - if (isset($properties->Options->MIMESupport)) { - $this->_elements['options']['mimeSupport'] = (int)$properties->Options->MIMESupport; - } - if (isset($properties->Options->MIMETruncation)) { - $this->_elements['options']['mimeTruncation'] = (int)$properties->Options->MIMETruncation; + + // optional parameters + if (isset($properties->Options->FilterType)) { + $this->_elements['options']['filterType'] = (int)$properties->Options->FilterType; + } + if (isset($properties->Options->MIMESupport)) { + $this->_elements['options']['mimeSupport'] = (int)$properties->Options->MIMESupport; } - if (isset($properties->Options->Class)) { - $this->_elements['options']['class'] = (string)$properties->Options->Class; - } - - // try to fetch element from AirSyncBase:BodyPreference - $airSyncBase = $properties->Options->children('uri:AirSyncBase'); - - if (isset($airSyncBase->BodyPreference)) { - - foreach ($airSyncBase->BodyPreference as $bodyPreference) { - $type = (int) $bodyPreference->Type; - $this->_elements['options']['bodyPreferences'][$type] = array( - 'type' => $type - ); - - // optional - if (isset($bodyPreference->TruncationSize)) { - $this->_elements['options']['bodyPreferences'][$type]['truncationSize'] = (int) $bodyPreference->TruncationSize; + if (isset($properties->Options->MIMETruncation)) { + $this->_elements['options']['mimeTruncation'] = (int)$properties->Options->MIMETruncation; + } + if (isset($properties->Options->Class)) { + $this->_elements['options']['class'] = (string)$properties->Options->Class; + } + + // try to fetch element from AirSyncBase:BodyPreference + $airSyncBase = $properties->Options->children('uri:AirSyncBase'); + + if (isset($airSyncBase->BodyPreference)) { + foreach ($airSyncBase->BodyPreference as $bodyPreference) { + $type = (int) $bodyPreference->Type; + $this->_elements['options']['bodyPreferences'][$type] = array( + 'type' => $type + ); + + // optional + if (isset($bodyPreference->TruncationSize)) { + $this->_elements['options']['bodyPreferences'][$type]['truncationSize'] = (int) $bodyPreference->TruncationSize; + } + + // optional + if (isset($bodyPreference->Preview)) { + $this->_elements['options']['bodyPreferences'][$type]['preview'] = (int) $bodyPreference->Preview; } - - // optional - if (isset($bodyPreference->Preview)) { - $this->_elements['options']['bodyPreferences'][$type]['preview'] = (int) $bodyPreference->Preview; - } } } - + if (isset($airSyncBase->BodyPartPreference)) { // process BodyPartPreference elements } - } + } } - + public function toArray() { $result = array(); - + foreach (array('syncKey', 'collectionId', 'deletesAsMoves', 'conversationMode', 'getChanges', 'windowSize', 'class', 'options') as $key) { if (isset($this->$key)) { $result[$key] = $this->$key; } } - + return $result; } - + public function &__get($name) { - if (array_key_exists($name, $this->_elements)) { - return $this->_elements[$name]; + if (array_key_exists($name, $this->_elements)) { + return $this->_elements[$name]; } - echo $name . PHP_EOL; - return null; } - + public function __set($name, $value) { $this->_elements[$name] = $value; } -} \ No newline at end of file +} diff --git a/lib/ext/Syncroton/Model/SyncResponse.php b/lib/ext/Syncroton/Model/SyncResponse.php new file mode 100644 index 0000000..8c0a725 --- /dev/null +++ b/lib/ext/Syncroton/Model/SyncResponse.php @@ -0,0 +1,46 @@ + + */ + +/** + * Class to handle Responses::(Add|Change|Delete) + * + * @package Syncroton + * @subpackage Model + * + * @property string $class + * @property string $clientId + * @property Syncroton_Model_Content $contentState + * @property string $instanceId + * @property string $serverId + * @property int $status + * @property Syncroton_Model_IEntry $applicationData + */ +class Syncroton_Model_SyncResponse extends Syncroton_Model_AXMLEntry +{ + protected $_xmlBaseElement = array('Add', 'Change', 'Delete'); + + protected $_properties = array( + 'AirSync' => array( + 'class' => array('type' => 'string'), + 'clientId' => array('type' => 'string'), + 'serverId' => array('type' => 'string'), + 'status' => array('type' => 'number'), + 'applicationData' => array('type' => 'container', 'supportedSince' => '16.0'), + ), + 'AirSyncBase' => array( + 'instanceId' => array('type' => 'string', 'supportedSince' => '16.0'), + ), + 'internal' => array( + 'contentState' => array('type' => 'container'), + ), + ); +} diff --git a/lib/kolab_sync_data.php b/lib/kolab_sync_data.php index e14a504..9ab3637 100644 --- a/lib/kolab_sync_data.php +++ b/lib/kolab_sync_data.php @@ -1,2018 +1,2020 @@ | | | | 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 int */ protected $asversion = 0; /** * 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; /** * type of user created folders * * @var int */ protected $folderType; /** * Internal cache for kolab_storage folder objects * * @var array */ protected $folders = array(); /** * Internal cache for IMAP folders list * * @var array */ protected $imap_folders = array(); /** * 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 = array( 'iphone', 'ipad', 'thundertine', 'windowsphone', 'wp', 'wp8', 'playbook', ); const RESULT_OBJECT = 0; const RESULT_UID = 1; const RESULT_COUNT = 2; /** * Recurrence types */ const RECUR_TYPE_DAILY = 0; // Recurs daily. const RECUR_TYPE_WEEKLY = 1; // Recurs weekly const RECUR_TYPE_MONTHLY = 2; // Recurs monthly const RECUR_TYPE_MONTHLY_DAYN = 3; // Recurs monthly on the nth day const RECUR_TYPE_YEARLY = 5; // Recurs yearly const RECUR_TYPE_YEARLY_DAYN = 6; // Recurs yearly on the nth day /** * Day of week constants */ const RECUR_DOW_SUNDAY = 1; const RECUR_DOW_MONDAY = 2; const RECUR_DOW_TUESDAY = 4; const RECUR_DOW_WEDNESDAY = 8; const RECUR_DOW_THURSDAY = 16; const RECUR_DOW_FRIDAY = 32; const RECUR_DOW_SATURDAY = 64; 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 = array( 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 = array( '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_backend::get_instance(); $this->device = $device; $this->asversion = floatval($device->acsversion); $this->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 = array(); // device supports multiple folders ? if ($this->isMultiFolder()) { // get the folders the user has access to $list = $this->listFolders(); } else if ($default = $this->getDefaultFolder()) { $list = array($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) { return array(); } /** * 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)) { $key = array_shift(array_keys($folders)); $default = $folders[$key]; // 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) { $parentid = $folder->parentId; $type = $folder->type; $display_name = $folder->displayName; if ($parentid) { $parent = $this->backend->folder_id2name($parentid, $this->device->deviceid); if ($parent === null) { throw new Syncroton_Exception_Status_FolderCreate(Syncroton_Exception_Status_FolderCreate::PARENT_NOT_FOUND); } } $name = rcube_charset::convert($display_name, kolab_sync::CHARSET, 'UTF7-IMAP'); if ($parent !== null) { $rcube = rcube::get_instance(); $storage = $rcube->get_storage(); $delim = $storage->get_hierarchy_delimiter(); $name = $parent . $delim . $name; } // Create IMAP folder $result = $this->backend->folder_create($name, $type, $this->device->deviceid); if ($result) { $folder->serverId = $this->backend->folder_id($name); return $folder; } $errno = Syncroton_Exception_Status_FolderCreate::UNKNOWN_ERROR; // Special case when client tries to create a subfolder of INBOX // which is not possible on Cyrus-IMAP (T2223) if ($parent == 'INBOX' && stripos($this->backend->last_error(), 'invalid') !== false) { $errno = Syncroton_Exception_Status_FolderCreate::SPECIAL_FOLDER; } // Note: Looks like Outlook 2013 ignores any errors on FolderCreate command throw new Syncroton_Exception_Status_FolderCreate($errno); } /** * Updates a folder */ public function updateFolder(Syncroton_Model_IFolder $folder) { $parentid = $folder->parentId; $type = $folder->type; $display_name = $folder->displayName; $old_name = $this->backend->folder_id2name($folder->serverId, $this->device->deviceid); if ($parentid) { $parent = $this->backend->folder_id2name($parentid, $this->device->deviceid); } $name = rcube_charset::convert($display_name, kolab_sync::CHARSET, 'UTF7-IMAP'); if ($parent !== null) { $rcube = rcube::get_instance(); $storage = $rcube->get_storage(); $delim = $storage->get_hierarchy_delimiter(); $name = $parent . $delim . $name; } // Rename/move IMAP folder if ($name == $old_name) { $result = true; // @TODO: folder type change? } else { $result = $this->backend->folder_rename($old_name, $name, $type); } if ($result) { return $folder; } // @TODO: throw exception } /** * Deletes a folder */ public function deleteFolder($folder) { if ($folder instanceof Syncroton_Model_IFolder) { $folder = $folder->serverId; } $name = $this->backend->folder_id2name($folder, $this->device->deviceid); // @TODO: throw exception return $this->backend->folder_delete($name, $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) { $folders = $this->extractFolders($folderid); foreach ($folders as $folderid) { $foldername = $this->backend->folder_id2name($folderid, $this->device->deviceid); $folder = $this->getFolderObject($foldername); if (!$folder || !$folder->valid) { throw new Syncroton_Exception_Status_ItemOperations(Syncroton_Exception_Status_ItemOperations::ITEM_SERVER_ERROR); } // Remove all entries $folder->delete_all(); // Remove subfolders if (!empty($options['deleteSubFolders'])) { $list = $this->listFolders($folderid); if (!is_array($list)) { throw new Syncroton_Exception_Status_ItemOperations(Syncroton_Exception_Status_ItemOperations::ITEM_SERVER_ERROR); } foreach ($list as $folderid => $folder) { $foldername = $this->backend->folder_id2name($folderid, $this->device->deviceid); $folder = $this->getFolderObject($foldername); if (!$folder || !$folder->valid) { throw new Syncroton_Exception_Status_ItemOperations(Syncroton_Exception_Status_ItemOperations::ITEM_SERVER_ERROR); } // Remove all entries $folder->delete_all(); } } } } /** * 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) { $item = $this->getObject($srcFolderId, $serverId, $folder); if (!$item || !$folder) { throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_SOURCE); } $dstname = $this->backend->folder_id2name($dstFolderId, $this->device->deviceid); if ($dstname === null) { throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_DESTINATION); } if (!$folder->move($serverId, $dstname)) { throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_SOURCE); } return $item['uid']; } /** * Add entry * * @param string $folderId Folder identifier * @param Syncroton_Model_IEntry $entry Entry object + * @param array $options Request options * * @return string ID of the created entry */ - public function createEntry($folderId, Syncroton_Model_IEntry $entry) + public function createEntry($folderId, Syncroton_Model_IEntry $entry, $options = array()) { $entry = $this->toKolab($entry, $folderId); $entry = $this->createObject($folderId, $entry); if (empty($entry)) { throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR); } return $entry['_serverId']; } /** * update existing entry * - * @param string $folderId - * @param string $serverId - * @param SimpleXMLElement $entry + * @param string $folderId Folder identifier + * @param string $serverId Entry identifier + * @param SimpleXMLElement $entry Data + * @param array $options Request options * * @return string ID of the updated entry */ - public function updateEntry($folderId, $serverId, Syncroton_Model_IEntry $entry) + public function updateEntry($folderId, $serverId, Syncroton_Model_IEntry $entry, $options = array()) { $oldEntry = $this->getObject($folderId, $serverId); if (empty($oldEntry)) { throw new Syncroton_Exception_NotFound('entry not found'); } $entry = $this->toKolab($entry, $folderId, $oldEntry); $entry = $this->updateObject($folderId, $serverId, $entry); if (empty($entry)) { throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR); } return $entry['_serverId']; } /** * delete entry * * @param string $folderId * @param string $serverId * @param array $collectionData */ public function deleteEntry($folderId, $serverId, $collectionData) { $deleted = $this->deleteObject($folderId, $serverId); if (!$deleted) { throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR); } } public function getFileReference($fileReference) { // to be implemented by Email data class // @TODO: throw "unimplemented" exception here? } /** * 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 = array(), $result_type = self::RESULT_UID) { if ($folderid == $this->defaultRootFolder) { $folders = $this->listFolders(); if (!is_array($folders)) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } $folders = array_keys($folders); } else { $folders = array($folderid); } // there's a PHP Warning from kolab_storage if $filter isn't an array if (empty($filter)) { $filter = array(); } else { $changed_objects = $this->getChangesByRelations($folderid, $filter); } $result = $result_type == self::RESULT_COUNT ? 0 : array(); $found = 0; foreach ($folders as $folder_id) { $foldername = $this->backend->folder_id2name($folder_id, $this->device->deviceid); $folder = $this->getFolderObject($foldername); if (!$folder || !$folder->valid) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } $found++; $error = false; switch ($result_type) { case self::RESULT_COUNT: $count = $folder->count($filter); if ($count === null || $count === false) { $error = true; } else { $result += (int) $count; } break; case self::RESULT_UID: $uids = $folder->get_uids($filter); if (!is_array($uids)) { $error = true; } else if (!empty($uids)) { $result = array_merge($result, $this->applyServerId($uids, $folder)); } break; } if ($error) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } // 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 = array(array('uid', '=', $changed_objects)); foreach ($filter as $f) { if ($f[0] != 'changed') { $tag_filter[] = $f; } } switch ($result_type) { case self::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 && $count !== false) { $result += (int) $count; } break; case self::RESULT_UID: $uids = $folder->get_uids($tag_filter); if (is_array($uids) && !empty($uids)) { $result = array_unique(array_merge($result, $this->applyServerId($uids, $folder))); } break; } } } if (!$found) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } return $result; } /** * Detect changes of relation (tag) objects data and assigned objects * Returns relation member identifiers */ protected function getChangesByRelations($folderid, $filter) { if (!$this->tag_categories) { return; } // 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->backend->relations_state_get($this->device->id, $folderid, $since); // get current relations state $config = kolab_storage_config::get_instance(); $default = true; $filter = array( array('type', '=', 'relation'), array('category', '=', 'tag') ); $relations = $config->get_objects($filter, $default, 100); $result = array(); $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... else if ($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... else if ($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 = array(); foreach ($relations as $relation) { $data[$relation['uid']] = array( 'name' => $relation['name'], 'changed' => $relation['changed']->format('U'), 'members' => implode("\n", (array)$relation['members']), ); } $now = new DateTime('now', new DateTimeZone('UTC')); $this->backend->relations_state_set($this->device->id, $folderid, $now, $data); } // in mail mode return only message URIs if ($this->modelName == 'mail') { // 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; } /** * Returns filter query array according to specified ActiveSync FilterType * * @param int $filter_type Filter type * * @param array Filter query */ protected function filter($filter_type = 0) { // overwrite by child class according to specified type return array(); } /** * get all entries changed between two dates * * @param string $folderId * @param DateTime $start * @param DateTime $end * @param int $filterType * * @return array */ public function getChangedEntries($folderId, DateTime $start, DateTime $end = null, $filter_type = null) { $filter = $this->filter($filter_type); $filter[] = array('changed', '>', $start); if ($end) { $filter[] = array('changed', '<=', $end); } return $this->searchEntries($folderId, $filter, self::RESULT_UID); } /** * Get count of entries changed between two dates * * @param string $folderId * @param DateTime $start * @param DateTime $end * @param int $filterType * * @return int */ public function getChangedEntriesCount($folderId, DateTime $start, DateTime $end = null, $filter_type = null) { $filter = $this->filter($filter_type); $filter[] = array('changed', '>', $start); if ($end) { $filter[] = array('changed', '<=', $end); } return $this->searchEntries($folderId, $filter, self::RESULT_COUNT); } /** * get id's of all entries available on the server * * @param string $folderId * @param int $filterType * * @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 $folderId * @param int $filterType * * @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) { $allClientEntries = $contentBackend->getFolderState($this->device, $folder); $allServerEntries = $this->getServerEntries($folder->serverId, $folder->lastfiltertype); $changedEntries = $this->getChangedEntriesCount($folder->serverId, $syncState->lastsync, null, $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)) { return true; } $allClientEntries = $contentBackend->getFolderState($this->device, $folder); // @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, &$folder = null) { $folders = $this->extractFolders($folderid); if (empty($folders)) { return null; } foreach ($folders as $folderid) { $foldername = $this->backend->folder_id2name($folderid, $this->device->deviceid); $folder = $this->getFolderObject($foldername); if ($folder && $folder->valid) { $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) { foreach ($folder->select(array(array('uid', '~*', $uid))) as $object) { if (($object['uid'] == $uid || strpos($object['uid'], $uid) === 0) && $crc == $this->objectCRC($object['uid'], $folder) ) { $object['_folderid'] = $folderid; return $object; } } continue; } } // Or (faster) strict UID matching... if (($object = $folder->get_object($uid)) && ($crc === null || $crc == $this->objectCRC($object['uid'], $folder)) ) { $object['_folderid'] = $folderid; return $object; } } } } /** * Saves the entry on the backend */ protected function createObject($folderid, $data) { if ($folderid == $this->defaultRootFolder) { $default = $this->getDefaultFolder(); if (!is_array($default)) { return null; } $folderid = isset($default['realid']) ? $default['realid'] : $default['serverId']; } // convert categories into tags, save them after creating an object if ($this->tag_categories) { $tags = $data['categories']; unset($data['categories']); } $foldername = $this->backend->folder_id2name($folderid, $this->device->deviceid); $folder = $this->getFolderObject($foldername); // 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)) { $this->setKolabTags($data['uid'], $tags); } $data['_serverId'] = $this->serverId($data['uid'], $folder); return $data; } } /** * Updates the entry on the backend */ protected function updateObject($folderid, $entryid, $data) { $object = $this->getObject($folderid, $entryid); if ($object) { $folder = $this->getFolderObject($object['_mailbox']); // convert categories into tags, save them after updating an object if ($this->tag_categories && 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)) { if (isset($tags)) { $this->setKolabTags($data['uid'], $tags); } $data['_serverId'] = $this->serverId($object['uid'], $folder); return $data; } } } /** * Removes the entry from the backend */ protected function deleteObject($folderid, $entryid) { $object = $this->getObject($folderid, $entryid); if ($object) { $folder = $this->getFolderObject($object['_mailbox']); if ($folder && $folder->valid && $folder->delete($object['uid'])) { if ($this->tag_categories) { $this->setKolabTags($object['uid'], null); } return true; } return false; } // object doesn't exist, confirm deletion return true; } /** * 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)) { return null; } $folders = array_keys($folders); } else { $folders = array($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->imap_folders)) { $this->imap_folders = $this->backend->folders_list( $this->device->deviceid, $this->modelName, $this->isMultiFolder()); } if ($parentid === null || !is_array($this->imap_folders)) { return $this->imap_folders; } $folders = array(); $parents = array($parentid); foreach ($this->imap_folders as $folder_id => $folder) { if ($folder['parentId'] && in_array($folder['parentId'], $parents)) { $folders[$folder_id] = $folder; $parents[] = $folder_id; } } return $folders; } /** * Returns Folder object (uses internal cache) * * @param string $name Folder name (UTF7-IMAP) * * @return kolab_storage_folder Folder object */ protected function getFolderObject($name) { if ($name === null || $name === '') { return null; } if (!isset($this->folders[$name])) { $this->folders[$name] = kolab_storage::get_folder($name, $this->modelName); } return $this->folders[$name]; } /** * Returns ActiveSync settings of specified folder * * @param string $name Folder name (UTF7-IMAP) * * @return array Folder settings */ protected function getFolderConfig($name) { $metadata = $this->backend->folder_meta(); if (!is_array($metadata)) { return array(); } $deviceid = $this->device->deviceid; $config = $metadata[$name]['FOLDER'][$deviceid]; return array( 'ALARMS' => $config['S'] == 2, ); } /** * Returns real folder name for specified folder ID */ protected function getFolderName($folderid) { if ($folderid == $this->defaultRootFolder) { $default = $this->getDefaultFolder(); if (!is_array($default)) { return null; } $folderid = isset($default['realid']) ? $default['realid'] : $default['serverId']; } return $this->backend->folder_id2name($folderid, $this->device->deviceid); } /** * Returns folder ID from Kolab folder object */ protected function getFolderId($folder) { if (!$this->isMultiFolder()) { return $this->defaultRootFolder; } return $this->backend->folder_id($folder->get_name(), $folder->get_type()); } /** * Convert contact from xml to kolab format * * @param Syncroton_Model_IEntry $data Contact data * @param string $folderId Folder identifier * @param array $entry Old Contact data for merge * * @return array */ abstract function toKolab(Syncroton_Model_IEntry $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; foreach ((array) $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] = array(); } 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] = array('type' => $type); } $data[$name][$found][$key_name] = $value; 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]) { $data['x-custom'][$idx][1] = $value; return; } } $data['x-custom'][] = array($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 $param Body parameters * * @reurn Syncroton_Model_EmailBody Body element */ protected function setBody($value, $params = array()) { 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 Body value */ protected function getBody($body, $type = null) { if ($body && $body->data) { $data = $body->data; } if (!$data || empty($type)) { return; } $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 = array(); // HTML? check for opening and closing or tags $is_html = preg_match('/<(html|body)(\s+[a-z]|>)/', $body, $m) && strpos($body, '') > 0; // here we assume that all devices support plain text if ($is_html) { // device supports HTML... if (!empty($prefs[$html_type])) { $type = $html_type; } // ...else convert to plain text else { $txt = new rcube_html2text($body, false, true); $body = $txt->get_text(); } } // strip out any non utf-8 characters $body = rcube_charset::clean($body); $real_length = $body_length = strlen($body); // truncate the body if needed if (($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 Datetime object */ protected static function date_from_kolab($date) { if (!empty($date)) { if (is_numeric($date)) { $date = new DateTime('@' . $date); } else if (is_string($date)) { $date = new DateTime($date, new DateTimeZone('UTC')); } else if ($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 ($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; } } /** * 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'])) { return; } $recurrence = array(); $r = $data['recurrence']; // required fields switch($r['FREQ']) { case 'DAILY': $recurrence['type'] = self::RECUR_TYPE_DAILY; break; case 'WEEKLY': $recurrence['type'] = self::RECUR_TYPE_WEEKLY; $recurrence['dayOfWeek'] = $this->day2bitmask($r['BYDAY']); 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 = array_shift(explode(',', $r['BYMONTHDAY'])); $recurrence['type'] = self::RECUR_TYPE_MONTHLY; $recurrence['dayOfMonth'] = $month_day; } else { $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); } break; case 'YEARLY': // @TODO: ActiveSync doesn't support multi-valued months, // should we replicate the recurrence element for each month? $month = array_shift(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; } else 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 = array_shift(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; default: return; } // 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']); } else if (!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'] = isset($recurrence->interval) ? $recurrence->interval : 1; if (isset($recurrence->until)) { if ($timezone) { $recurrence->until->setTimezone($timezone); } $rrule['UNTIL'] = $recurrence->until; } else if (!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 = array(); // exceptions (modified occurences) foreach ((array)$data['recurrence']['EXCEPTIONS'] as $exception) { $exception['_mailbox'] = $data['_mailbox']; $ex = $this->getEntry($collection, $exception, true); $date = clone ($exception['recurrence_date'] ?: $ex['startTime']); $ex['exceptionStartTime'] = self::set_exception_time($date, $data['_start']); // 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) foreach ((array)$data['recurrence']['EXDATE'] as $exception) { if (!($exception instanceof DateTime)) { continue; } $ex = array( 'deleted' => 1, 'exceptionStartTime' => self::set_exception_time($exception, $data['_start']), ); $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'] = array(); $rrule['EXCEPTIONS'] = array(); // handle exceptions from recurrence if (!empty($data->exceptions)) { foreach ($data->exceptions as $exception) { if ($exception->deleted) { $date = clone $exception->exceptionStartTime; if ($timezone) { $date->setTimezone($timezone); } $date->setTime(0, 0, 0); $rrule['EXDATE'][] = $date; } else { $ex = $this->toKolab($exception, $folderid, null, $timezone); if ($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); } } /** * Returns list of tag names assigned to kolab object */ - protected function getKolabTags($uid, $categories = null) + public static function getKolabTags($uid, $categories = null) { $config = kolab_storage_config::get_instance(); $tags = $config->get_tags($uid); $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; } /** * Set tags to kolab object */ - protected function setKolabTags($uid, $tags) + public static function setKolabTags($uid, $tags) { $config = kolab_storage_config::get_instance(); $config->save_tags($uid, $tags); } /** * 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) { $result = $result + $this->recurDayMap[$day]; } return $result; } /** * Convert bitmask used by ActiveSync to string of days (TU,TH) * * @param int $days * * @return string */ protected function bitmask2day($days) { $days_arr = array(); 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; } } else if (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) { if ($this->modelName == 'mail') { return $uid; } // 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->getFolderObject($folder); } $folder_uid = $folder->get_uid(); return strtoupper(hash('crc32b', $folder_uid . $uid)); // always 8 chars } /** * Apply serverId() on a set of uids */ protected function applyServerId($uids, $folder) { if (!empty($uids) && $this->modelName != 'mail') { $self = $this; $func = function($uid) use ($self, $folder) { return $self->serverId($uid, $folder); }; $uids = array_map($func, $uids); } return $uids; } } diff --git a/lib/kolab_sync_data_calendar.php b/lib/kolab_sync_data_calendar.php index d843813..9462e3d 100644 --- a/lib/kolab_sync_data_calendar.php +++ b/lib/kolab_sync_data_calendar.php @@ -1,1166 +1,1172 @@ | | | | 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 | +--------------------------------------------------------------------------+ */ /** * Calendar (Events) data class for Syncroton */ class kolab_sync_data_calendar extends kolab_sync_data implements Syncroton_Data_IDataCalendar { /** * Mapping from ActiveSync Calendar namespace fields */ protected $mapping = array( 'allDayEvent' => 'allday', 'startTime' => 'start', // keep it before endTime here //'attendees' => 'attendees', 'body' => 'description', //'bodyTruncated' => 'bodytruncated', 'busyStatus' => 'free_busy', //'categories' => 'categories', 'dtStamp' => 'changed', 'endTime' => 'end', //'exceptions' => 'exceptions', 'location' => 'location', //'meetingStatus' => 'meetingstatus', //'organizerEmail' => 'organizeremail', //'organizerName' => 'organizername', //'recurrence' => 'recurrence', //'reminder' => 'reminder', //'responseRequested' => 'responserequested', //'responseType' => 'responsetype', 'sensitivity' => 'sensitivity', 'subject' => 'title', //'timezone' => 'timezone', 'uID' => 'uid', ); /** * Kolab object type * * @var string */ protected $modelName = 'event'; /** * Type of the default folder * * @var int */ protected $defaultFolderType = Syncroton_Command_FolderSync::FOLDERTYPE_CALENDAR; /** * Default container for new entries * * @var string */ protected $defaultFolder = 'Calendar'; /** * Type of user created folders * * @var int */ protected $folderType = Syncroton_Command_FolderSync::FOLDERTYPE_CALENDAR_USER_CREATED; /** * attendee status */ const ATTENDEE_STATUS_UNKNOWN = 0; const ATTENDEE_STATUS_TENTATIVE = 2; const ATTENDEE_STATUS_ACCEPTED = 3; const ATTENDEE_STATUS_DECLINED = 4; const ATTENDEE_STATUS_NOTRESPONDED = 5; /** * attendee types */ const ATTENDEE_TYPE_REQUIRED = 1; const ATTENDEE_TYPE_OPTIONAL = 2; const ATTENDEE_TYPE_RESOURCE = 3; /** * busy status constants */ const BUSY_STATUS_FREE = 0; const BUSY_STATUS_TENTATIVE = 1; const BUSY_STATUS_BUSY = 2; const BUSY_STATUS_OUTOFOFFICE = 3; /** * Sensitivity values */ const SENSITIVITY_NORMAL = 0; const SENSITIVITY_PERSONAL = 1; const SENSITIVITY_PRIVATE = 2; const SENSITIVITY_CONFIDENTIAL = 3; const KEY_DTSTAMP = 'x-custom.X-ACTIVESYNC-DTSTAMP'; const KEY_RESPONSE_DTSTAMP = 'x-custom.X-ACTIVESYNC-RESPONSE-DTSTAMP'; /** * Mapping of attendee status * * @var array */ protected $attendeeStatusMap = array( 'UNKNOWN' => self::ATTENDEE_STATUS_UNKNOWN, 'TENTATIVE' => self::ATTENDEE_STATUS_TENTATIVE, 'ACCEPTED' => self::ATTENDEE_STATUS_ACCEPTED, 'DECLINED' => self::ATTENDEE_STATUS_DECLINED, 'DELEGATED' => self::ATTENDEE_STATUS_UNKNOWN, 'NEEDS-ACTION' => self::ATTENDEE_STATUS_NOTRESPONDED, ); /** * Mapping of attendee type * * NOTE: recurrences need extra handling! * @var array */ protected $attendeeTypeMap = array( 'REQ-PARTICIPANT' => self::ATTENDEE_TYPE_REQUIRED, 'OPT-PARTICIPANT' => self::ATTENDEE_TYPE_OPTIONAL, // 'NON-PARTICIPANT' => self::ATTENDEE_TYPE_RESOURCE, // 'CHAIR' => self::ATTENDEE_TYPE_RESOURCE, ); /** * Mapping of busy status * * @var array */ protected $busyStatusMap = array( 'free' => self::BUSY_STATUS_FREE, 'tentative' => self::BUSY_STATUS_TENTATIVE, 'busy' => self::BUSY_STATUS_BUSY, 'outofoffice' => self::BUSY_STATUS_OUTOFOFFICE, ); /** * mapping of sensitivity * * @var array */ protected $sensitivityMap = array( 'public' => self::SENSITIVITY_PERSONAL, 'private' => self::SENSITIVITY_PRIVATE, 'confidential' => self::SENSITIVITY_CONFIDENTIAL, ); /** * Appends contact data to xml element * * @param Syncroton_Model_SyncCollection $collection Collection data * @param string $serverId Local entry identifier * @param boolean $as_array Return entry as array * * @return array|Syncroton_Model_Event|array Event object */ public function getEntry(Syncroton_Model_SyncCollection $collection, $serverId, $as_array = false) { $event = is_array($serverId) ? $serverId : $this->getObject($collection->collectionId, $serverId); $config = $this->getFolderConfig($event['_mailbox']); $result = array(); // Timezone // Kolab Format 3.0 and xCal does support timezone per-date, but ActiveSync allows // only one timezone per-event. We'll use timezone of the start date if ($event['start'] instanceof DateTime) { $timezone = $event['start']->getTimezone(); if ($timezone && ($tz_name = $timezone->getName()) != 'UTC') { $tzc = kolab_sync_timezone_converter::getInstance(); if ($tz_name = $tzc->encodeTimezone($tz_name)) { $result['timezone'] = $tz_name; } } } // Calendar namespace fields foreach ($this->mapping as $key => $name) { $value = $this->getKolabDataItem($event, $name); switch ($name) { case 'changed': case 'end': case 'start': // For all-day events Kolab uses different times // At least Android doesn't display such event as all-day event if ($value && is_a($value, 'DateTime')) { $date = clone $value; if ($event['allday']) { // need this for self::date_from_kolab() $date->_dateonly = false; if ($name == 'start') { $date->setTime(0, 0, 0); } else if ($name == 'end') { $date->setTime(0, 0, 0); $date->modify('+1 day'); } } // set this date for use in recurrence exceptions handling if ($name == 'start') { $event['_start'] = $date; } $value = self::date_from_kolab($date); } break; case 'sensitivity': $value = intval($this->sensitivityMap[$value]); break; case 'free_busy': $value = $this->busyStatusMap[$value]; break; case 'description': $value = $this->body_from_kolab($value, $collection); break; case 'location': $value = new Syncroton_Model_Location($value); break; } // Ignore empty values (but not integer 0) if ((empty($value) || is_array($value)) && $value !== 0) { continue; } $result[$key] = $value; } // Event reminder time if ($config['ALARMS']) { $result['reminder'] = $this->from_kolab_alarm($event); } $result['categories'] = array(); $result['attendees'] = array(); // Categories, Roundcube Calendar plugin supports only one category at a time if (!empty($event['categories'])) { $result['categories'] = (array) $event['categories']; } // Organizer if (!empty($event['attendees'])) { foreach ($event['attendees'] as $idx => $attendee) { if ($attendee['role'] == 'ORGANIZER') { if ($name = $attendee['name']) { $result['organizerName'] = $name; } if ($email = $attendee['email']) { $result['organizerEmail'] = $email; } unset($event['attendees'][$idx]); break; } } } // Attendees if (!empty($event['attendees'])) { $user_emails = $this->user_emails(); $user_rsvp = false; foreach ($event['attendees'] as $idx => $attendee) { $att = array(); if ($email = $attendee['email']) { $att['email'] = $email; } else { // In Activesync email is required continue; } $att['name'] = $attendee['name'] ?: $email; $type = isset($attendee['role']) ? $this->attendeeTypeMap[$attendee['role']] : null; $status = isset($attendee['status']) ? $this->attendeeStatusMap[$attendee['status']] : null; if ($this->asversion >= 12) { $att['attendeeType'] = $type ?: self::ATTENDEE_TYPE_REQUIRED; $att['attendeeStatus'] = $status ?: self::ATTENDEE_STATUS_UNKNOWN; } if ($email && in_array_nocase($email, $user_emails)) { $user_rsvp = !empty($attendee['rsvp']); $resp_type = $status ?: self::ATTENDEE_STATUS_UNKNOWN; } $result['attendees'][] = new Syncroton_Model_EventAttendee($att); } + + // Kolab does not support counter (new time) proposals + $result['disallowNewTimeProposal'] = true; } // Event meeting status $this->meeting_status_from_kolab($collection, $event, $result); // Recurrence (and exceptions) $this->recurrence_from_kolab($collection, $event, $result); // RSVP status $result['responseRequested'] = $result['meetingStatus'] == 3 && $user_rsvp ? 1 : 0; $result['responseType'] = $result['meetingStatus'] == 3 ? $resp_type : null; // Attachments if ($this->asversion >= 16) { $attachments = array(); if (!empty($event['_attachments'])) { foreach ($event['_attachments'] as $attachment) { $attachments[] = array( 'displayName' => rcube_charset::clean($attachment['name']), 'contentType' => rcube_charset::clean($attachment['mimetype']), 'fileReference' => sprintf('%s||%s||%s', $collection->collectionId, $serverId, $attachment['id']), 'method' => Syncroton_Model_Attachment::METHOD_NORMAL, 'estimatedDataSize' => $attachment['size'], // todo: clientId ); } } $result['attachments'] = new Syncroton_Model_Attachments($attachments); } return $as_array ? $result : new Syncroton_Model_Event($result); } /** * convert contact from xml to libkolab array * * @param Syncroton_Model_IEntry $data Contact to convert * @param string $folderid Folder identifier * @param array $entry Existing entry * @param DateTimeZone $timezone Timezone of the event * * @return array */ public function toKolab(Syncroton_Model_IEntry $data, $folderid, $entry = null, $timezone = null) { $event = !empty($entry) ? $entry : array(); $foldername = isset($event['_mailbox']) ? $event['_mailbox'] : $this->getFolderName($folderid); $config = $this->getFolderConfig($foldername); $is_exception = $data instanceof Syncroton_Model_EventException; $dummy_tz = str_repeat('A', 230) . '=='; $is_outlook = stripos($this->device->devicetype, 'outlook') !== false; $v16_update = $this->asversion >= 16 && !empty($event); // check data validity $this->check_event($data); if (!empty($event['start']) && ($event['start'] instanceof DateTime)) { $old_timezone = $event['start']->getTimezone(); } // Timezone if (!$timezone && isset($data->timezone) && $data->timezone != $dummy_tz) { $tzc = kolab_sync_timezone_converter::getInstance(); $expected = $old_timezone ?: kolab_format::$timezone; try { $timezone = $tzc->getTimezone($data->timezone, $expected->getName()); $timezone = new DateTimeZone($timezone); } catch (Exception $e) { $timezone = null; } } if (empty($timezone)) { $timezone = $old_timezone ?: new DateTimeZone('UTC'); } $event['allday'] = $v16_update && !isset($data->allDayEvent) ? $event['allday'] : 0; // Calendar namespace fields foreach ($this->mapping as $key => $name) { // skip UID field, unsupported in event exceptions // we need to do this here, because the next line (data getter) will throw an exception if ($is_exception && $key == 'uID') { continue; } // In ActiveSync >= v16 all properties are ghosted if ($v16_update && !isset($data->$key)) { continue; } $value = $data->$key; switch ($name) { case 'changed': $value = null; break; case 'end': case 'start': if ($timezone && $value) { $value->setTimezone($timezone); } if ($value && $data->allDayEvent) { $value->_dateonly = true; // In ActiveSync all-day event ends on 00:00:00 next day // In Kolab we just ignore the time spec. if ($name == 'end') { $diff = date_diff($event['start'], $value); $value = clone $event['start']; if ($diff->days > 1) { $value->add(new DateInterval('P' . ($diff->days - 1) . 'D')); } } } break; case 'sensitivity': $map = array_flip($this->sensitivityMap); $value = $map[$value]; break; case 'free_busy': $map = array_flip($this->busyStatusMap); $value = $map[$value]; break; case 'description': $value = $this->getBody($value, Syncroton_Model_EmailBody::TYPE_PLAINTEXT); // If description isn't specified keep old description if ($value === null) { continue 2; } break; case 'location': $value = (string) $value; break; } $this->setKolabDataItem($event, $name, $value); } // Try to fix allday events from Android // It doesn't set all-day flag but the period is a whole day if (!$event['allday'] && $event['end'] && $event['start']) { $interval = @date_diff($event['start'], $event['end']); if ($interval && $interval->format('%y%m%d%h%i%s') === '001000') { $event['allday'] = 1; $event['end'] = clone $event['start']; } } // Reminder // @TODO: should alarms be used when importing event from phone? if ($config['ALARMS'] && (!$v16_update || isset($data->reminder))) { $event['valarms'] = $this->to_kolab_alarm($data->reminder, $event); } $attendees = array(); $categories = array(); // Categories if (isset($data->categories)) { foreach ($data->categories as $category) { $categories[] = $category; } } if (!$v16_update || isset($data->categories)) { $event['categories'] = $categories; } // Organizer if (!$is_exception && ($organizer_email = $data->organizerEmail)) { $attendees[] = array( 'role' => 'ORGANIZER', 'name' => $data->organizerName, 'email' => $organizer_email, ); } // Attendees // Outlook 2013 sends a dummy update just after MeetingResponse has been processed, // this update resets attendee status set in the MeetingResponse request. // We ignore changes to attendees data on such updates if ($is_outlook && $this->isDummyOutlookUpdate($data, $entry, $event)) { $attendees = $entry['attendees']; } else if (isset($data->attendees)) { $statusMap = array_flip($this->attendeeStatusMap); foreach ($data->attendees as $attendee) { if ($attendee->email && $attendee->email == $organizer_email) { continue; } $role = false; if (isset($attendee->attendeeType)) { $role = array_search($attendee->attendeeType, $this->attendeeTypeMap); } if ($role === false) { $role = array_search(self::ATTENDEE_TYPE_REQUIRED, $this->attendeeTypeMap); } $_attendee = array( 'role' => $role, 'name' => $attendee->name != $attendee->email ? $attendee->name : '', 'email' => $attendee->email, ); if (isset($attendee->attendeeStatus)) { $_attendee['status'] = $attendee->attendeeStatus ? array_search($attendee->attendeeStatus, $this->attendeeStatusMap) : null; if (!$_attendee['status']) { $_attendee['status'] = 'NEEDS-ACTION'; $_attendee['rsvp'] = true; } } else if (!empty($event['attendees']) && !empty($attendee->email)) { // copy the old attendee status foreach ($event['attendees'] as $old_attendee) { if ($old_attendee['email'] == $_attendee['email'] && isset($old_attendee['status'])) { $_attendee['status'] = $old_attendee['status']; $_attendee['rsvp'] = $old_attendee['rsvp']; break; } } } $attendees[] = $_attendee; } } // Make sure the event has the organizer set if (!$organizer_email && ($identity = kolab_sync::get_instance()->user->get_identity())) { $attendees[] = array( 'role' => 'ORGANIZER', 'name' => $identity['name'], 'email' => $identity['email'], ); } // Note: In ActiveSync >= v16 all properties are ghosted if (!$v16_update || isset($data->attendees) || isset($data->organizerEmail)) { $event['attendees'] = $attendees; } // recurrence (and exceptions) if (!$is_exception) { $event['recurrence'] = $this->recurrence_to_kolab($data, $folderid, $timezone); } // Bump SEQUENCE number on update (Outlook only). // It's been confirmed that any change of the event that has attendees specified // bumps SEQUENCE number of the event (we can see this in sent iTips). // Unfortunately Outlook also sends an update when no SEQUENCE bump // is needed, e.g. when updating attendee status. // We try our best to bump the SEQUENCE only when expected if (!empty($entry) && !$is_exception && !empty($data->attendees) && $data->timezone != $dummy_tz) { if ($last_update = $this->getKolabDataItem($event, self::KEY_DTSTAMP)) { $last_update = new DateTime($last_update); } if ($data->dtStamp && $data->dtStamp != $last_update) { if ($this->has_significant_changes($event, $entry)) { $event['sequence']++; $this->logger->debug('Found significant changes in the updated event. Bumping SEQUENCE to ' . $event['sequence']); } } } // Because we use last event modification time above, we make sure // the event modification time is not (re)set by the server, // we use the original Outlook's timestamp. if ($is_outlook && $data->dtStamp) { $this->setKolabDataItem($event, self::KEY_DTSTAMP, $data->dtStamp->format(DateTime::ATOM)); } // This prevents kolab_format code to bump the sequence when not needed if (!isset($event['sequence'])) { $event['sequence'] = 0; } // Attachments Add/Delete if (isset($data->attachments) && $v16_update) { // @TODO } return $event; } /** + * Fetches body of calendar event attachment + * * @return Syncroton_Model_FileReference + * @throws Syncroton_Exception_NotFound */ public function getFileReference($fileReference) { list($collectionId, $serverId, $partId) = explode('||', $fileReference); $event = $this->getObject($collectionId, $serverId, $folder); if (!$event) { throw new Syncroton_Exception_NotFound('Event not found'); } foreach ((array) $event['_attachments'] as $attachment) { if (strval($attachments['id']) === strval($partId)) { $body = $folder->get_attachment($event['_uid'], $partId, $event['_mailbox'], false, null, true); return new Syncroton_Model_FileReference(array( 'contentType' => rcube_charset::clean($attachment['mimetype']), 'data' => $body, )); } } throw new Syncroton_Exception_NotFound('File reference not found'); } /** * Set attendee status for meeting * * @param Syncroton_Model_MeetingResponse $request The meeting response * * @return string ID of new calendar entry */ public function setAttendeeStatus(Syncroton_Model_MeetingResponse $request) { $status_map = array( 1 => 'ACCEPTED', 2 => 'TENTATIVE', 3 => 'DECLINED', ); if ($status = $status_map[$request->userResponse]) { // extract event from the invitation list($event, $existing) = $this->get_event_from_invitation($request); /* switch ($status) { case 'ACCEPTED': $event['free_busy'] = 'busy'; break; case 'TENTATIVE': $event['free_busy'] = 'tentative'; break; case 'DECLINED': $event['free_busy'] = 'free'; break; } */ // Store Outlook response timestamp for further use if (stripos($this->device->devicetype, 'outlook') !== false) { $dtstamp = new DateTime('now', new DateTimeZone('UTC')); $dtstamp = $dtstamp->format(DateTime::ATOM); } // Update/Save the event if (empty($existing)) { if ($dtstamp) { $this->setKolabDataItem($event, self::KEY_RESPONSE_DTSTAMP, $dtstamp); } $folder = $this->save_event($event, $status); // Create SyncState for the new event, so it is not synced twice if ($folder) { $folderId = $this->getFolderId($folder); try { $syncBackend = Syncroton_Registry::getSyncStateBackend(); $folderBackend = Syncroton_Registry::getFolderBackend(); $contentBackend = Syncroton_Registry::getContentStateBackend(); $syncFolder = $folderBackend->getFolder($this->device->id, $folderId); $syncState = $syncBackend->getSyncState($this->device->id, $syncFolder->id); $contentBackend->create(new Syncroton_Model_Content(array( 'device_id' => $this->device->id, 'folder_id' => $syncFolder->id, 'contentid' => $this->serverId($event['uid'], $folder), 'creation_time' => $syncState->lastsync, 'creation_synckey' => $syncState->counter, ))); } catch (Exception $e) { // ignore } } } else { if ($dtstamp) { $this->setKolabDataItem($existing, self::KEY_RESPONSE_DTSTAMP, $dtstamp); } $folder = $this->update_event($event, $existing, $status, $request->instanceId); } if (!$folder) { throw new Syncroton_Exception_Status_MeetingResponse(Syncroton_Exception_Status_MeetingResponse::MEETING_ERROR); } // TODO: ActiveSync version >= 16, send the iTip response. if (isset($request->sendResponse)) { // SendResponse can contain Body to use as email body (can be empty) // TODO: Activesync >= 16.1 proposedStartTime and proposedEndTime. } } // FIXME: We should not return an UID when status=DECLINED // as it's expected by the specification. Server // should delete an event in such a case, but we // keep the event copy with appropriate attendee status instead. return empty($status) ? null : $this->serverId($event['uid'], $folder); } /** * Get an event from the invitation email or calendar folder */ protected function get_event_from_invitation(Syncroton_Model_MeetingResponse $request) { // Limitation: LongId might be used instead of RequestId, this is not supported if ($request->requestId) { $mail_class = new kolab_sync_data_email($this->device, $this->syncTimeStamp); // Event from an invitation email if ($event = $mail_class->get_invitation_event($request->requestId)) { // find the event in calendar $existing = $this->find_event_by_uid($event['uid']); return array($event, $existing); } // Event from calendar folder if ($event = $this->getObject($request->collectionId, $request->requestId, $folder)) { return array($event, $event); } throw new Syncroton_Exception_Status_MeetingResponse(Syncroton_Exception_Status_MeetingResponse::INVALID_REQUEST); } throw new Syncroton_Exception_Status_MeetingResponse(Syncroton_Exception_Status_MeetingResponse::MEETING_ERROR); } /** * Find the Kolab event in any (of subscribed personal calendars) folder */ protected function find_event_by_uid($uid) { if (empty($uid)) { return; } // TODO: should we check every existing event folder even if not subscribed for sync? foreach ($this->listFolders() as $folder) { $storage_folder = $this->getFolderObject($folder['imap_name']); if ($storage_folder->get_namespace() == 'personal' && ($result = $storage_folder->get_object($uid)) ) { return $result; } } } /** * Wrapper to update an event object */ protected function update_event($event, $old, $status, $instanceId = null) { // TODO: instanceId - DateTime - of the exception to be processed, if not set process all occurrences if ($instanceId) { throw new Syncroton_Exception_Status_MeetingResponse(Syncroton_Exception_Status_MeetingResponse::INVALID_REQUEST); } if ($event['free_busy']) { $old['free_busy'] = $event['free_busy']; } // Updating an existing event is most-likely a response // to an iTip request with bumped SEQUENCE $old['sequence'] += 1; // Update the event return $this->save_event($old, $status); } /** * Save the Kolab event (create if not exist) * If an event does not exist it will be created in the default folder */ protected function save_event(&$event, $status = null) { // Find default folder to which we'll save the event if (!isset($event['_mailbox'])) { $folders = $this->listFolders(); $storage = rcube::get_instance()->get_storage(); // find the default foreach ($folders as $folder) { if ($folder['type'] == 8 && $storage->folder_namespace($folder['imap_name']) == 'personal') { $event['_mailbox'] = $folder['imap_name']; break; } } // if there's no folder marked as default, use any if (!isset($event['_mailbox']) && !empty($folders)) { foreach ($folders as $folder) { if ($storage->folder_namespace($folder['imap_name']) == 'personal') { $event['_mailbox'] = $folder['imap_name']; break; } } } // TODO: what if the user has no subscribed event folders for this device // should we use any existing event folder even if not subscribed for sync? } if ($status) { $this->update_attendee_status($event, $status); } // TODO: Free/busy trigger? if (isset($event['_mailbox'])) { $folder = $this->getFolderObject($event['_mailbox']); if ($folder && $folder->valid && $folder->save($event)) { return $folder; } } return false; } /** * Update the attendee status of the user */ protected function update_attendee_status(&$event, $status) { $organizer = null; $emails = $this->user_emails(); foreach ((array) $event['attendees'] as $i => $attendee) { if ($attendee['role'] == 'ORGANIZER') { $organizer = $attendee; } else if ($attendee['email'] && in_array_nocase($attendee['email'], $emails)) { $event['attendees'][$i]['status'] = $status; $event['attendees'][$i]['rsvp'] = false; $event_attendee = $attendee; } } if (!$event_attendee) { $this->logger->warn('MeetingResponse on an event where the user is not an attendee. UID: ' . $event['uid']); throw new Syncroton_Exception_Status_MeetingResponse(Syncroton_Exception_Status_MeetingResponse::MEETING_ERROR); } } /** * Returns filter query array according to specified ActiveSync FilterType * * @param int $filter_type Filter type * * @param array Filter query */ protected function filter($filter_type = 0) { $filter = array(array('type', '=', $this->modelName)); switch ($filter_type) { case Syncroton_Command_Sync::FILTER_2_WEEKS_BACK: $mod = '-2 weeks'; break; case Syncroton_Command_Sync::FILTER_1_MONTH_BACK: $mod = '-1 month'; break; case Syncroton_Command_Sync::FILTER_3_MONTHS_BACK: $mod = '-3 months'; break; case Syncroton_Command_Sync::FILTER_6_MONTHS_BACK: $mod = '-6 months'; break; } if (!empty($mod)) { $dt = new DateTime('now', new DateTimeZone('UTC')); $dt->modify($mod); $filter[] = array('dtend', '>', $dt); } return $filter; } /** * Set MeetingStatus according to event data */ protected function meeting_status_from_kolab($collection, $event, &$result) { // 0 - The event is an appointment, which has no attendees. // 1 - The event is a meeting and the user is the meeting organizer. // 3 - This event is a meeting, and the user is not the meeting organizer. // 5 - The meeting has been canceled and the user was the meeting organizer. // 7 - The meeting has been canceled. The user was not the meeting organizer. $status = 0; if (!empty($event['attendees'])) { // Find out if the user is an organizer // TODO: Delegation/aliases support $user_emails = $this->user_emails(); $is_organizer = false; if ($event['organizer'] && $event['organizer']['email']) { $is_organizer = in_array_nocase($event['organizer']['email'], $user_emails); } if ($event['status'] == 'CANCELLED') { $status = $is_organizer ? 5 : 7; } else { $status = $is_organizer ? 1 : 3; } } $result['meetingStatus'] = $status; } /** * Converts libkolab alarms spec. into a number of minutes */ protected function from_kolab_alarm($event) { if (isset($event['valarms'])) { foreach ($event['valarms'] as $alarm) { if (in_array($alarm['action'], array('DISPLAY', 'AUDIO'))) { $value = $alarm['trigger']; break; } } } if ($value && $value instanceof DateTime) { if ($event['start'] && ($interval = $event['start']->diff($value))) { if ($interval->invert && !$interval->m && !$interval->y) { return intval(round($interval->s/60) + $interval->i + $interval->h * 60 + $interval->d * 60 * 24); } } } else if ($value && preg_match('/^([-+]*)[PT]*([0-9]+)([WDHMS])$/', $value, $matches)) { $value = intval($matches[2]); if ($value && $matches[1] != '-') { return null; } switch ($matches[3]) { case 'S': $value = intval(round($value/60)); break; case 'H': $value *= 60; break; case 'D': $value *= 24 * 60; break; case 'W': $value *= 7 * 24 * 60; break; } return $value; } } /** * Converts ActiveSync reminder into libkolab alarms spec. */ protected function to_kolab_alarm($value, $event) { if ($value === null || $value === '') { return (array) $event['valarms']; } $valarms = array(); $unsupported = array(); if (!empty($event['valarms'])) { foreach ($event['valarms'] as $alarm) { if (!$current && in_array($alarm['action'], array('DISPLAY', 'AUDIO'))) { $current = $alarm; } else { $unsupported[] = $alarm; } } } $valarms[] = array( 'action' => $current['action'] ?: 'DISPLAY', 'description' => $current['description'] ?: '', 'trigger' => sprintf('-PT%dM', $value), ); if (!empty($unsupported)) { $valarms = array_merge($valarms, $unsupported); } return $valarms; } /** * Sanity checks on event input * * @param Syncroton_Model_IEntry &$entry Entry object * * @throws Syncroton_Exception_Status_Sync */ protected function check_event(Syncroton_Model_IEntry &$entry) { // https://msdn.microsoft.com/en-us/library/jj194434(v=exchg.80).aspx $now = new DateTime('now'); $rounded = new DateTime('now'); $min = (int) $rounded->format('i'); $add = $min > 30 ? (60 - $min) : (30 - $min); $rounded->add(new DateInterval('PT' . $add . 'M')); if (empty($entry->startTime) && empty($entry->endTime)) { // use current time rounded to 30 minutes $end = clone $rounded; $end->add(new DateInterval($entry->allDayEvent ? 'P1D' : 'PT30M')); $entry->startTime = $rounded; $entry->endTime = $end; } else if (empty($entry->startTime)) { if ($entry->endTime < $now || $entry->endTime < $rounded) { throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::INVALID_ITEM); } $entry->startTime = $rounded; } else if (empty($entry->endTime)) { if ($entry->startTime < $now) { throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::INVALID_ITEM); } $rounded->add(new DateInterval($entry->allDayEvent ? 'P1D' : 'PT30M')); $entry->endTime = $rounded; } } /** * Check if the new event version has any significant changes */ protected function has_significant_changes($event, $old) { // Calendar namespace fields foreach (array('allday', 'start', 'end', 'location', 'recurrence') as $key) { if ($event[$key] != $old[$key]) { // Comparing recurrence is tricky as there can be differences in default // value handling. Let's try to handle most common cases if ($key == 'recurrence' && $this->fixed_recurrence($event) == $this->fixed_recurrence($old)) { continue; } return true; } } if (count($event['attendees']) != count($old['attendees'])) { return true; } foreach ($event['attendees'] as $idx => $attendee) { $old_attendee = $old['attendees'][$idx]; if ($old_attendee['email'] != $attendee['email'] || ($attendee['role'] != 'ORGANIZER' && $attendee['status'] != $old_attendee['status'] && $attendee['status'] == 'NEEDS-ACTION') ) { return true; } } return false; } /** * Unify recurrence spec. for comparison */ protected function fixed_recurrence($event) { $rec = (array) $event['recurrence']; // Add BYDAY if not exists if ($rec['FREQ'] == 'WEEKLY' && empty($rec['BYDAY'])) { $days = array('SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'); $day = $event['start']->format('w'); $rec['BYDAY'] = $days[$day]; } if (!$rec['INTERVAL']) { $rec['INTERVAL'] = 1; } ksort($rec); return $rec; } /** * Check if the event update request is a fake (for Outlook) */ protected function isDummyOutlookUpdate($data, $entry, &$result) { $is_dummy = false; // Outlook 2013 sends a dummy update just after MeetingResponse has been processed, // this update resets attendee status set in the MeetingResponse request. // We ignore attendees data in such updates, they should not happen according to // https://msdn.microsoft.com/en-us/library/office/hh428685(v=exchg.140).aspx // but they will contain some data as alarms and free/busy status so we don't // ignore them completely if (!empty($entry) && !empty($data->attendees) && stripos($this->device->devicetype, 'outlook') !== false) { // Some of these requests use just dummy Timezone $dummy_tz = str_repeat('A', 230) . '=='; if ($data->timezone == $dummy_tz) { $is_dummy = true; } // But some of them do not, so we have check if that is a first // update immediately (up to 5 seconds) after MeetingResponse request if (!$is_dummy && ($dtstamp = $this->getKolabDataItem($entry, self::KEY_RESPONSE_DTSTAMP))) { $dtstamp = new DateTime($dtstamp); $now = new DateTime('now', new DateTimeZone('UTC')); $is_dummy = $now->getTimestamp() - $dtstamp->getTimestamp() <= 5; } $this->unsetKolabDataItem($result, self::KEY_RESPONSE_DTSTAMP); } return $is_dummy; } } diff --git a/lib/kolab_sync_data_contacts.php b/lib/kolab_sync_data_contacts.php index 0562377..db7ed74 100644 --- a/lib/kolab_sync_data_contacts.php +++ b/lib/kolab_sync_data_contacts.php @@ -1,637 +1,639 @@ | | | | 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 = array( '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 */ public function getEntry(Syncroton_Model_SyncCollection $collection, $serverId) { $data = is_array($serverId) ? $serverId : $this->getObject($collection->collectionId, $serverId); $result = array(); 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; } } 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 ($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_IEntry $data Contact to convert * @param string $folderId Folder identifier * @param array $entry Existing entry * * @return array Kolab object array */ public function toKolab(Syncroton_Model_IEntry $data, $folderId, $entry = null) { $contact = !empty($entry) ? $entry : array(); // 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 = array(); 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[] = array('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(array( '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 + * @param array $options Request options * * @return string ID of the created entry */ - public function createEntry($folderId, Syncroton_Model_IEntry $entry) + public function createEntry($folderId, Syncroton_Model_IEntry $entry, $options = array()) { 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 + * Update existing entry * - * @param string $folderId - * @param string $serverId - * @param SimpleXMLElement $entry + * @param string $folderId Folder identifier + * @param string $serverId Entry identifier + * @param SimpleXMLElement $entry Data + * @param array $options Request options * * @return string ID of the updated entry */ - public function updateEntry($folderId, $serverId, Syncroton_Model_IEntry $entry) + public function updateEntry($folderId, $serverId, Syncroton_Model_IEntry $entry, $options = array()) { 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 entry * * @param string $folderId * @param string $serverId * @param array $collectionData */ public function deleteEntry($folderId, $serverId, $collectionData) { 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 * * @param 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 array(array('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 = array(), $result_type = self::RESULT_UID) { // 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); // 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, &$folder = null) { if (strpos($entryid, $this->galPrefix) === 0 && $this->hasGAL()) { return $this->getGALEntry($entryid); } return parent::getObject($folderid, $entryid, $folder); } /** * 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 : array(); } } if ($this->galCache && ($result = $this->galCache->get('index')) !== null) { $result = explode("\n", $result); return $result_type == self::RESULT_COUNT ? count($result) : $result; } $result = array(); 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(); while ($contact = $set->next()) { $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 Contact data */ protected function getGALEntry($serverId) { list($source, $timestamp, $uid) = $this->resolveGALEntryUID($serverId); if ($source && $uid && ($book = kolab_sync_data_gal::get_address_book($source))) { $book->reset(); $set = $book->search('uid', array($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'] = array($emails[0]); } return $result; } } } /** * 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; } else if (is_array($gal_sync)) { $enabled = $this->deviceTypeFilter($gal_sync); } $this->galSources = $enabled ? kolab_sync_data_gal::get_address_sources() : array(); 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 array(); } } diff --git a/lib/kolab_sync_data_email.php b/lib/kolab_sync_data_email.php index 5030512..6804e17 100644 --- a/lib/kolab_sync_data_email.php +++ b/lib/kolab_sync_data_email.php @@ -1,1724 +1,1724 @@ | | | | 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 | +--------------------------------------------------------------------------+ */ /** * Email data class for Syncroton */ class kolab_sync_data_email extends kolab_sync_data implements Syncroton_Data_IDataSearch { const MAX_SEARCH_RESULT = 200; /** * Mapping from ActiveSync Email namespace fields */ protected $mapping = array( + 'bcc' => 'bcc', 'cc' => 'cc', //'contentClass' => 'contentclass', 'dateReceived' => 'internaldate', //'displayTo' => 'displayto', //? //'flag' => 'flag', 'from' => 'from', //'importance' => 'importance', 'internetCPID' => 'charset', //'messageClass' => 'messageclass', 'replyTo' => 'replyto', //'read' => 'read', 'subject' => 'subject', //'threadTopic' => 'threadtopic', 'to' => 'to', ); /** * Special folder type/name map * * @var array */ protected $folder_types = array( 2 => 'Inbox', 3 => 'Drafts', 4 => 'Deleted Items', 5 => 'Sent Items', 6 => 'Outbox', ); /** * Kolab object type * * @var string */ protected $modelName = 'mail'; /** * Type of the default folder * * @var int */ protected $defaultFolderType = Syncroton_Command_FolderSync::FOLDERTYPE_INBOX; /** * Default container for new entries * * @var string */ protected $defaultFolder = 'INBOX'; /** * Type of user created folders * * @var int */ protected $folderType = Syncroton_Command_FolderSync::FOLDERTYPE_MAIL_USER_CREATED; + static protected $tag_rts = array(); + /** * the constructor * * @param Syncroton_Model_IDevice $device * @param DateTime $syncTimeStamp */ public function __construct(Syncroton_Model_IDevice $device, DateTime $syncTimeStamp) { parent::__construct($device, $syncTimeStamp); $this->storage = rcube::get_instance()->get_storage(); // Outlook 2013 support multi-folder $this->ext_devices[] = 'windowsoutlook15'; if ($this->asversion >= 14) { $this->tag_categories = true; } } /** * Creates model object * * @param Syncroton_Model_SyncCollection $collection Collection data * @param string $serverId Local entry identifier * * @return Syncroton_Model_Email Email object */ public function getEntry(Syncroton_Model_SyncCollection $collection, $serverId) { $message = $this->getObject($serverId); // error (message doesn't exist?) if (empty($message)) { throw new Syncroton_Exception_NotFound("Message $serverId not found"); } $headers = $message->headers; // rcube_message_header $this->storage->set_folder($message->folder); $this->logger->debug(sprintf("Processing message %s (size: %.2f MB)", $serverId, $headers->size / 1024 / 1024)); // Calendar namespace fields foreach ($this->mapping as $key => $name) { $value = null; switch ($name) { case 'internaldate': $value = self::date_from_kolab(rcube_utils::strtotime($headers->internaldate)); break; + case 'bcc': case 'cc': case 'to': case 'replyto': case 'from': $addresses = rcube_mime::decode_address_list($headers->$name, null, true, $headers->charset); foreach ($addresses as $idx => $part) { // @FIXME: set name + address or address only? $addresses[$idx] = format_email_recipient($part['mailto'], $part['name']); } $value = implode(',', $addresses); break; case 'subject': $value = $headers->get('subject'); break; case 'charset': $value = self::charset_to_cp($headers->charset); break; } if (empty($value) || is_array($value)) { continue; } if (is_string($value)) { $value = rcube_charset::clean($value); } $result[$key] = $value; } // $result['ConversationId'] = 'FF68022058BD485996BE15F6F6D99320'; // $result['ConversationIndex'] = 'CA2CFA8A23'; + // Mark as Draft + if ($collection->folder->type == 3) { + $result['isDraft'] = true; + } + // Read flag $result['read'] = intval(!empty($headers->flags['SEEN'])); // Flagged message if (!empty($headers->flags['FLAGGED'])) { // Use FollowUp flag which is used in Android when message is marked with a star $result['flag'] = new Syncroton_Model_EmailFlag(array( 'flagType' => 'FollowUp', 'status' => Syncroton_Model_EmailFlag::STATUS_ACTIVE, )); } else { $result['flag'] = new Syncroton_Model_EmailFlag(); } // Importance/Priority if ($headers->priority) { if ($headers->priority < 3) { $result['importance'] = 2; // High } else if ($headers->priority > 3) { $result['importance'] = 0; // Low } } // get truncation and body type $airSyncBaseType = Syncroton_Command_Sync::BODY_TYPE_PLAIN_TEXT; $truncateAt = null; $opts = $collection->options; $prefs = $opts['bodyPreferences']; if ($opts['mimeSupport'] == Syncroton_Command_Sync::MIMESUPPORT_SEND_MIME) { $airSyncBaseType = Syncroton_Command_Sync::BODY_TYPE_MIME; if (isset($prefs[Syncroton_Command_Sync::BODY_TYPE_MIME]['truncationSize'])) { $truncateAt = $prefs[Syncroton_Command_Sync::BODY_TYPE_MIME]['truncationSize']; } else if (isset($opts['mimeTruncation']) && $opts['mimeTruncation'] < Syncroton_Command_Sync::TRUNCATE_NOTHING) { switch ($opts['mimeTruncation']) { case Syncroton_Command_Sync::TRUNCATE_ALL: $truncateAt = 0; break; case Syncroton_Command_Sync::TRUNCATE_4096: $truncateAt = 4096; break; case Syncroton_Command_Sync::TRUNCATE_5120: $truncateAt = 5120; break; case Syncroton_Command_Sync::TRUNCATE_7168: $truncateAt = 7168; break; case Syncroton_Command_Sync::TRUNCATE_10240: $truncateAt = 10240; break; case Syncroton_Command_Sync::TRUNCATE_20480: $truncateAt = 20480; break; case Syncroton_Command_Sync::TRUNCATE_51200: $truncateAt = 51200; break; case Syncroton_Command_Sync::TRUNCATE_102400: $truncateAt = 102400; break; } } } else { // The spec is not very clear, but it looks that if MimeSupport is not set // we can't add Syncroton_Command_Sync::BODY_TYPE_MIME to the supported types // list below (Bug #1688) $types = array( Syncroton_Command_Sync::BODY_TYPE_HTML, Syncroton_Command_Sync::BODY_TYPE_PLAIN_TEXT, ); // @TODO: if client can support both HTML and TEXT use one of // them which is better according to the real message body type foreach ($types as $type) { if (!empty($prefs[$type])) { if (!empty($prefs[$type]['truncationSize'])) { $truncateAt = $prefs[$type]['truncationSize']; } $preview = (int) $prefs[$type]['preview']; $airSyncBaseType = $type; break; } } } $body_params = array('type' => $airSyncBaseType); // Message body // In Sync examples there's one in which bodyPreferences is not defined // in such case Truncated=1 and there's no body sent to the client // only it's estimated size if (empty($prefs)) { $messageBody = ''; $real_length = $headers->size; $truncateAt = 0; $body_length = 0; $isTruncated = 1; } else if ($airSyncBaseType == Syncroton_Command_Sync::BODY_TYPE_MIME) { // Check if we have enough memory to handle the message $messageBody = $this->message_mem_check($message, $headers->size); if (empty($messageBody)) { $messageBody = $this->storage->get_raw_body($message->uid); } // make the source safe (Bug #2715, #2757) $messageBody = kolab_sync_message::recode_message($messageBody); // strip out any non utf-8 characters $messageBody = rcube_charset::clean($messageBody); $real_length = $body_length = strlen($messageBody); } else { $messageBody = $this->getMessageBody($message, $airSyncBaseType == Syncroton_Command_Sync::BODY_TYPE_HTML); // strip out any non utf-8 characters $messageBody = rcube_charset::clean($messageBody); $real_length = $body_length = strlen($messageBody); } // add Preview element to the Body result if (!empty($preview) && $body_length) { $body_params['preview'] = $this->getPreview($messageBody, $airSyncBaseType, $preview); } // truncate the body if needed if ($truncateAt && $body_length > $truncateAt) { $messageBody = mb_strcut($messageBody, 0, $truncateAt); $body_length = strlen($messageBody); $isTruncated = 1; } if ($isTruncated) { $body_params['truncated'] = 1; $body_params['estimatedDataSize'] = $real_length; } // add Body element to the result $result['body'] = $this->setBody($messageBody, $body_params); // original body type // @TODO: get this value from getMessageBody() $result['nativeBodyType'] = $message->has_html_part() ? 2 : 1; // Message class if ($headers->ctype == 'multipart/signed' && count($message->attachments) == 1 && $message->attachments[0]->mimetype == 'application/pkcs7-signature' ) { $result['messageClass'] = 'IPM.Note.SMIME.MultipartSigned'; } else if ($headers->ctype == 'application/pkcs7-mime' || $headers->ctype == 'application/x-pkcs7-mime') { $result['messageClass'] = 'IPM.Note.SMIME'; } else { $result['messageClass'] = 'IPM.Note'; } $result['contentClass'] = 'urn:content-classes:message'; // Categories (Tags) if ($this->tag_categories) { // convert kolab tags into categories $result['categories'] = $this->getKolabTags($message); } // attachments $attachments = array_merge($message->attachments, $message->inline_parts); if (!empty($attachments)) { $result['attachments'] = array(); foreach ($attachments as $attachment) { $filename = rcube_charset::clean($attachment->filename); if (empty($filename) && $attachment->mimetype == 'text/html') { $filename = 'HTML Part'; } $att = array( 'displayName' => $filename, 'fileReference' => $serverId . '::' . $attachment->mime_id, 'method' => Syncroton_Model_Attachment::METHOD_NORMAL, 'estimatedDataSize' => $attachment->size, ); if (!empty($attachment->content_id)) { $att['contentId'] = rcube_charset::clean($attachment->content_id); } if (!empty($attachment->content_location)) { $att['contentLocation'] = rcube_charset::clean($attachment->content_location); } if (in_array($attachment, $message->inline_parts)) { $att['isInline'] = 1; } $result['attachments'][] = new Syncroton_Model_Attachment($att); } } return new Syncroton_Model_Email($result); } /** * Returns properties of a message for Search response * * @param string $longId Message identifier * @param array $options Search options * * @return Syncroton_Model_Email Email object */ public function getSearchEntry($longId, $options) { $collection = new Syncroton_Model_SyncCollection(array( 'options' => $options, )); return $this->getEntry($collection, $longId); } /** - * convert contact from xml to libkolab array + * Convert email data from xml to internal Kolab representation * - * @param Syncroton_Model_IEntry $data Contact to convert - * @param string $folderid Folder identifier - * @param array $entry Existing entry + * @param Syncroton_Model_IEntry $data Email data + * @param string $folderid Folder identifier + * @param array $entry Existing entry * * @return array */ public function toKolab(Syncroton_Model_IEntry $data, $folderid, $entry = null) { - // does nothing => you can't add emails via ActiveSync + // empty, it's here because it's required by the class abstraction, but we don't use it for email } /** * Returns filter query array according to specified ActiveSync FilterType * * @param int $filter_type Filter type * * @param array Filter query */ protected function filter($filter_type = 0) { $filter = array(); switch ($filter_type) { case Syncroton_Command_Sync::FILTER_1_DAY_BACK: $mod = '-1 day'; break; case Syncroton_Command_Sync::FILTER_3_DAYS_BACK: $mod = '-3 days'; break; case Syncroton_Command_Sync::FILTER_1_WEEK_BACK: $mod = '-1 week'; break; case Syncroton_Command_Sync::FILTER_2_WEEKS_BACK: $mod = '-2 weeks'; break; case Syncroton_Command_Sync::FILTER_1_MONTH_BACK: $mod = '-1 month'; break; } if (!empty($mod)) { $dt = new DateTime('now', new DateTimeZone('UTC')); $dt->modify($mod); // RFC3501: IMAP SEARCH $filter[] = 'SINCE ' . $dt->format('d-M-Y'); } return $filter; } /** * Return list of supported folders for this backend * * @return array */ public function getAllFolders() { $list = $this->listFolders(); if (!is_array($list)) { throw new Syncroton_Exception_Status_FolderSync(Syncroton_Exception_Status_FolderSync::FOLDER_SERVER_ERROR); } // device doesn't support multiple folders if (!$this->isMultiFolder()) { // We'll return max. one folder of supported type $result = array(); $types = $this->folder_types; foreach ($list as $idx => $folder) { $type = $folder['type'] == 12 ? 2 : $folder['type']; // unknown to Inbox if ($folder_id = $types[$type]) { $result[$folder_id] = array( 'displayName' => $folder_id, 'serverId' => $folder_id, 'parentId' => 0, 'type' => $type, ); } } $list = $result; } foreach ($list as $idx => $folder) { $list[$idx] = new Syncroton_Model_Folder($folder); } return $list; } /** * Return list of folders for specified folder ID * * @return array Folder identifiers list */ protected function extractFolders($folder_id) { $list = $this->listFolders(); $result = array(); if (!is_array($list)) { throw new Syncroton_Exception_NotFound('Folder not found'); } // device supports multiple folders? if ($this->isMultiFolder()) { if ($list[$folder_id]) { $result[] = $folder_id; } } else if ($type = array_search($folder_id, $this->folder_types)) { foreach ($list as $id => $folder) { if ($folder['type'] == $type || ($folder_id == 'Inbox' && $folder['type'] == 12)) { $result[] = $id; } } } if (empty($result)) { throw new Syncroton_Exception_NotFound('Folder not found'); } return $result; } /** * 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) { $msg = $this->parseMessageId($serverId); $dest = $this->extractFolders($dstFolderId); $dest_id = array_shift($dest); $dest_name = $this->backend->folder_id2name($dest_id, $this->device->deviceid); if (empty($msg)) { throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_SOURCE); } if ($dest_name === null) { throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_DESTINATION); } if (!$this->storage->move_message($msg['uid'], $dest_name, $msg['foldername'])) { throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_SOURCE); } // Use COPYUID feature (RFC2359) to get the new UID of the copied message $copyuid = $this->storage->conn->data['COPYUID']; if (is_array($copyuid) && ($uid = $copyuid[1])) { return $this->createMessageId($dest_id, $uid); } } /** - * add entry from xml data + * Add (draft) message from xml data * * @param string $folderId Folder identifier * @param Syncroton_Model_IEntry $entry Entry + * @param array $options Request options * - * @return array + * @return string|Syncroton_Model_SyncResponse ActiveSync identifier of the created message */ - public function createEntry($folderId, Syncroton_Model_IEntry $entry) + public function createEntry($folderId, Syncroton_Model_IEntry $entry, $options = array()) { - // Throw exception here for better handling of unsupported - // entry creation, it can be object of class Email or SMS here - throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::INVALID_ITEM); + // We do not support SMS, and Drafts are supported from v16 + if ($options['class'] == 'SMS' || $this->asversion < 16) { + throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::INVALID_ITEM); + } + + $options['folderId'] = $folderId; + + $draft = new kolab_sync_draft($entry, null, $options); + + return $draft->save(); } /** * Update existing message * * @param string $folderId Folder identifier * @param string $serverId Entry identifier * @param Syncroton_Model_IEntry $entry Entry + * @param array $options Request options + * + * @return string|Syncroton_Model_SyncResponse ActiveSync identifier of the updated message */ - public function updateEntry($folderId, $serverId, Syncroton_Model_IEntry $entry) + public function updateEntry($folderId, $serverId, Syncroton_Model_IEntry $entry, $options = array()) { $msg = $this->parseMessageId($serverId); $message = $this->getObject($serverId); if (empty($message)) { throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR); } - $is_flagged = !empty($message->headers->flags['FLAGGED']); - - // Read status change - if (isset($entry->read)) { - // here we update only Read flag - $flag = (((int)$entry->read != 1) ? 'UN' : '') . 'SEEN'; - $this->storage->set_flag($msg['uid'], $flag, $msg['foldername']); - } + $options['folderId'] = $folderId; - // Flag change - if (isset($entry->flag) && (empty($entry->flag) || empty($entry->flag->flagType))) { - if ($is_flagged) { - $this->storage->set_flag($msg['uid'], 'UNFLAGGED', $msg['foldername']); - } - } - else if (!$is_flagged && !empty($entry->flag)) { - if ($entry->flag->flagType && preg_match('/follow\s*up/i', $entry->flag->flagType)) { - $this->storage->set_flag($msg['uid'], 'FLAGGED', $msg['foldername']); - } - } + $draft = new kolab_sync_draft($entry, $message, $options); - // Categories (Tags) change - if (isset($entry->categories)) { - $this->setKolabTags($message, $entry->categories); - } + return $draft->save(); } /** * delete entry * * @param string $folderId * @param string $serverId * @param Syncroton_Model_SyncCollection $collection */ public function deleteEntry($folderId, $serverId, $collection) { $trash = kolab_sync::get_instance()->config->get('trash_mbox'); $msg = $this->parseMessageId($serverId); if (empty($msg)) { throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR); } // move message to trash folder if ($collection->deletesAsMoves && strlen($trash) && $trash != $msg['foldername'] && $this->storage->folder_exists($trash) ) { $this->storage->move_message($msg['uid'], $trash, $msg['foldername']); } // set delete flag else { $this->storage->set_flag($msg['uid'], 'DELETED', $msg['foldername']); } } /** * Send an email * * @param mixed $message MIME message * @param boolean $saveInSent Enables saving the sent message in Sent folder * * @throws Syncroton_Exception_Status */ public function sendEmail($message, $saveInSent) { if (!($message instanceof kolab_sync_message)) { $message = new kolab_sync_message($message); } $sent = $message->send($smtp_error); if (!$sent) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::MAIL_SUBMISSION_FAILED); } // Save sent message in Sent folder if ($saveInSent) { $sent_folder = kolab_sync::get_instance()->config->get('sent_mbox'); if (strlen($sent_folder) && $this->storage->folder_exists($sent_folder)) { return $this->storage->save_message($sent_folder, $message->source(), '', false, array('SEEN')); } } } /** * Forward an email * * @param array|string $itemId A string LongId or an array with following properties: * collectionId, itemId and instanceId * @param resource|string $body MIME message * @param boolean $saveInSent Enables saving the sent message in Sent folder * @param boolean $replaceMime If enabled, original message would be appended * * @throws Syncroton_Exception_Status */ public function forwardEmail($itemId, $body, $saveInSent, $replaceMime) { /* @TODO: The SmartForward command can be applied to a meeting. When SmartForward is applied to a recurring meeting, the InstanceId element (section 2.2.3.83.2) specifies the ID of a particular occurrence in the recurring meeting. If SmartForward is applied to a recurring meeting and the InstanceId element is absent, the server SHOULD forward the entire recurring meeting. If the value of the InstanceId element is invalid, the server responds with Status element (section 2.2.3.162.15) value 104, as specified in section 2.2.4. When the SmartForward command is used for an appointment, the original message is included by the server as an attachment to the outgoing message. When the SmartForward command is used for a normal message or a meeting, the behavior of the SmartForward command is the same as that of the SmartReply command (section 2.2.2.18). */ $msg = $this->parseMessageId($itemId); $message = $this->getObject($itemId); if (empty($message)) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::ITEM_NOT_FOUND); } // Parse message $sync_msg = new kolab_sync_message($body); // forward original message as attachment if (!$replaceMime) { $this->storage->set_folder($msg['foldername']); $attachment = $this->storage->get_raw_body($msg['uid']); if (empty($attachment)) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::ITEM_NOT_FOUND); } $sync_msg->add_attachment($attachment, array( 'encoding' => '8bit', 'content_type' => 'message/rfc822', 'disposition' => 'inline', //'name' => 'message.eml', )); } // Send message $this->sendEmail($sync_msg, $saveInSent); // Set FORWARDED flag on the replied message if (empty($message->headers->flags['FORWARDED'])) { $this->storage->set_flag($msg['uid'], 'FORWARDED', $msg['foldername']); } } /** * Reply to an email * * @param array|string $itemId A string LongId or an array with following properties: * collectionId, itemId and instanceId * @param resource|string $body MIME message * @param boolean $saveInSent Enables saving the sent message in Sent folder * @param boolean $replaceMime If enabled, original message would be appended * * @throws Syncroton_Exception_Status */ public function replyEmail($itemId, $body, $saveInSent, $replaceMime) { $msg = $this->parseMessageId($itemId); $message = $this->getObject($itemId); if (empty($message)) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::ITEM_NOT_FOUND); } $sync_msg = new kolab_sync_message($body); $headers = $sync_msg->headers(); // Add References header if (empty($headers['References'])) { $sync_msg->set_header('References', trim($message->headers->references . ' ' . $message->headers->messageID)); } // Get original message body if (!$replaceMime) { // @TODO: here we're assuming that reply message is in text/plain format // So, original message will be converted to plain text if needed $message_body = $this->getMessageBody($message, false); // Quote original message body $message_body = self::wrap_and_quote(trim($message_body), 72); // Join bodies $sync_msg->append("\n" . ltrim($message_body)); } // Send message $this->sendEmail($sync_msg, $saveInSent); // Set ANSWERED flag on the replied message if (empty($message->headers->flags['ANSWERED'])) { $this->storage->set_flag($msg['uid'], 'ANSWERED', $msg['foldername']); } } /** * Search for existing entries * * @param string $folderid * @param array $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 = array(), $result_type = self::RESULT_UID) { $folders = $this->extractFolders($folderid); $filter_str = 'ALL UNDELETED'; // 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 = array(); $modseq = (array) $this->backend->modseq_get($this->device->id, $folderid, $modseq_lasttime); } } else { $filter_str .= ' ' . $filter_item; } } // get members of modified relations $changed_msgs = $this->getChangesByRelations($folderid, $filter); $result = $result_type == self::RESULT_COUNT ? 0 : array(); $found = 0; $ts = time(); foreach ($folders as $folder_id) { $foldername = $this->backend->folder_id2name($folder_id, $this->device->deviceid); if ($foldername === null) { continue; } $found++; $this->storage->set_folder($foldername); // Synchronize folder (if it wasn't synced in this request already) if ($this->lastsync_folder != $folderid || $this->lastsync_time <= $ts - Syncroton_Registry::getPingTimeout() ) { $this->storage->folder_sync($foldername); } // We're in "get changes" mode if (isset($modseq_data)) { $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 ($folder_data['HIGHESTMODSEQ']) { $modseq_data[$foldername] = $folder_data['HIGHESTMODSEQ']; if ($modseq_data[$foldername] != $modseq[$foldername]) { $modseq_update = true; if ($modseq && $modseq[$foldername]) { $modified = true; $filter_str .= " MODSEQ " . ($modseq[$foldername] + 1); } } } } 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 self::RESULT_COUNT: $result += (int) $search->count(); break; case self::RESULT_UID: if ($uids = $search->get()) { foreach ($uids as $idx => $uid) { $uids[$idx] = $this->createMessageId($folder_id, $uid); } $result = array_merge($result, $uids); } break; } } // handle relation changes if (!empty($changed_msgs)) { $uids = $this->findRelationMembersInFolder($foldername, $changed_msgs, $filter); switch ($result_type) { case self::RESULT_COUNT: $result += (int) count($uids); break; case self::RESULT_UID: foreach ($uids as $idx => $uid) { $uids[$idx] = $this->createMessageId($folder_id, $uid); } $result = array_unique(array_merge($result, $uids)); break; } } } if (!$found) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } $this->lastsync_folder = $folderid; $this->lastsync_time = $ts; if (!empty($modseq_update)) { $this->backend->modseq_set($this->device->id, $folderid, $this->syncTimeStamp, $modseq_data); // 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->backend->modseq_set($this->device->id, $folderid, $modseq_lasttime, $modseq_data); } } 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; } } $rcube = rcube::get_instance(); $storage = $rcube->get_storage(); $found = array(); // first find messages by UID if (!empty($result[$foldername])) { $index = $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 = $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 = array(); $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 = $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; } /** * ActiveSync Search handler * * @param Syncroton_Model_StoreRequest $store Search query * * @return Syncroton_Model_StoreResponse Complete Search response */ public function search(Syncroton_Model_StoreRequest $store) { list($folders, $search_str) = $this->parse_search_query($store); if (empty($search_str)) { throw new Exception('Empty/invalid search request'); } if (!is_array($folders)) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } $result = array(); // @TODO: caching with Options->RebuildResults support foreach ($folders as $folderid) { $foldername = $this->backend->folder_id2name($folderid, $this->device->deviceid); if ($foldername === null) { continue; } // $this->storage->set_folder($foldername); // $this->storage->folder_sync($foldername); $search = $this->storage->search_once($foldername, $search_str); if (!($search instanceof rcube_result_index)) { continue; } $uids = $search->get(); foreach ($uids as $idx => $uid) { $uids[$idx] = new Syncroton_Model_StoreResponseResult(array( 'longId' => $this->createMessageId($folderid, $uid), 'collectionId' => $folderid, 'class' => 'Email', )); } $result = array_merge($result, $uids); // We don't want to search all folders if we've got already a lot messages if (count($result) >= self::MAX_SEARCH_RESULT) { break; } } $result = array_values($result); $response = new Syncroton_Model_StoreResponse(); // Calculate requested range $start = (int) $store->options['range'][0]; $limit = (int) $store->options['range'][1] + 1; $total = count($result); $response->total = $total; // Get requested chunk of data set if ($total) { if ($start > $total) { $start = $total; } if ($limit > $total) { $limit = max($start+1, $total); } if ($start > 0 || $limit < $total) { $result = array_slice($result, $start, $limit-$start); } $response->range = array($start, $start + count($result) - 1); } // Build result array, convert to ActiveSync format foreach ($result as $idx => $rec) { $rec->properties = $this->getSearchEntry($rec->longId, $store->options); $response->result[] = $rec; unset($result[$idx]); } return $response; } /** * Converts ActiveSync search parameters into IMAP search string */ protected function parse_search_query($store) { $options = $store->options; $query = $store->query; $search_str = ''; $folders = array(); if (empty($query) || !is_array($query)) { return array(); } if (isset($query['and']['freeText']) && strlen($query['and']['freeText'])) { $search = $query['and']['freeText']; } if (!empty($query['and']['collections'])) { foreach ($query['and']['collections'] as $collection) { $folders = array_merge($folders, $this->extractFolders($collection)); } } if (!empty($query['and']['greaterThan']) && !empty($query['and']['greaterThan']['dateReceived']) && !empty($query['and']['greaterThan']['value']) ) { $search_str .= ' SINCE ' . $query['and']['greaterThan']['value']->format('d-M-Y'); } if (!empty($query['and']['lessThan']) && !empty($query['and']['lessThan']['dateReceived']) && !empty($query['and']['lessThan']['value']) ) { $search_str .= ' BEFORE ' . $query['and']['lessThan']['value']->format('d-M-Y'); } if ($search !== null) { // @FIXME: should we use TEXT/BODY search? // ActiveSync protocol specification says "indexed fields" $search_keys = array('SUBJECT', 'TO', 'FROM', 'CC'); $search_str .= str_repeat(' OR', count($search_keys)-1); foreach ($search_keys as $key) { $search_str .= sprintf(" %s {%d}\r\n%s", $key, strlen($search), $search); } } if (empty($search_str)) { return array(); } $search_str = 'ALL UNDELETED ' . trim($search_str); // @TODO: DeepTraversal if (empty($folders)) { $folders = $this->listFolders(); if (!is_array($folders)) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } $folders = array_keys($folders); } return array($folders, $search_str); } /** * Fetches the entry from the backend */ protected function getObject($entryid, $dummy = null, &$folder = null) { $message = $this->parseMessageId($entryid); if (empty($message)) { // @TODO: exception? return null; } // get message $message = new rcube_message($message['uid'], $message['foldername']); return $message && !empty($message->headers) ? $message : null; } /** * @return Syncroton_Model_FileReference */ public function getFileReference($fileReference) { list($folderid, $uid, $part_id) = explode('::', $fileReference); $message = $this->getObject($fileReference); if (!$message) { throw new Syncroton_Exception_NotFound('Message not found'); } $part = $message->mime_parts[$part_id]; $body = $message->get_part_body($part_id); return new Syncroton_Model_FileReference(array( 'contentType' => $part->mimetype, 'data' => $body, )); } /** * Parses entry ID to get folder name and UID of the message */ protected function parseMessageId($entryid) { // replyEmail/forwardEmail if (is_array($entryid)) { $entryid = $entryid['itemId']; } // Note: the id might be in a form of ::[::] list($folderid, $uid) = explode('::', $entryid); if (empty($uid)) { return; } $foldername = $this->backend->folder_id2name($folderid, $this->device->deviceid); if ($foldername === null || $foldername === false) { return; } return array( 'uid' => $uid, 'folderid' => $folderid, 'foldername' => $foldername, ); } /** * Creates entry ID of the message */ - public function createMessageId($folderid, $uid) + public static function createMessageId($folderid, $uid) { return $folderid . '::' . $uid; } /** * Returns body of the message in specified format */ protected function getMessageBody($message, $html = false) { if (!is_array($message->parts) && empty($message->body)) { return ''; } if (!empty($message->parts)) { foreach ($message->parts as $part) { // skip no-content and attachment parts (#1488557) if ($part->type != 'content' || !$part->size || $message->is_attachment($part)) { continue; } return $this->getMessagePartBody($message, $part, $html); } } return $this->getMessagePartBody($message, $message, $html); } /** * Returns body of the message part in specified format */ protected function getMessagePartBody($message, $part, $html = false) { // Check if we have enough memory to handle the message in it $body = $this->message_mem_check($message, $part->size, false); if ($body !== false) { $body = $message->get_part_body($part->mime_id, true); } // message is cached but not exists, or other error if ($body === false) { return ''; } if ($html) { if ($part->ctype_secondary == 'html') { // charset was converted to UTF-8 in rcube_storage::get_message_part(), // change/add charset specification in HTML accordingly $meta = ''; // remove old meta tag and add the new one, making sure // that it is placed in the head $body = preg_replace('/]+charset=[a-z0-9-_]+[^>]*>/Ui', '', $body); $body = preg_replace('/(]*>)/Ui', '\\1'.$meta, $body, -1, $rcount); if (!$rcount) { $body = '' . $meta . '' . $body; } } else if ($part->ctype_secondary == 'enriched') { $body = rcube_enriched::to_html($body); } else { // Roundcube >= 1.2 if (class_exists('rcube_text2html')) { $flowed = $part->ctype_parameters['format'] == 'flowed'; $delsp = $part->ctype_parameters['delsp'] == 'yes'; $options = array('flowed' => $flowed, 'wrap' => false, 'delsp' => $delsp); $text2html = new rcube_text2html($body, false, $options); $body = '' . $text2html->get_html() . ''; } else { $body = '
' . $body . '
'; } } } else { if ($part->ctype_secondary == 'enriched') { $body = rcube_enriched::to_html($body); $part->ctype_secondary = 'html'; } if ($part->ctype_secondary == 'html') { $txt = new rcube_html2text($body, false, true); $body = $txt->get_text(); } else { if ($part->ctype_secondary == 'plain' && $part->ctype_parameters['format'] == 'flowed') { $body = rcube_mime::unfold_flowed($body); } } } return $body; } /** * Converts and truncates message body for use in * * @return string Truncated plain text message */ protected function getPreview($body, $type, $size) { if ($type == Syncroton_Command_Sync::BODY_TYPE_HTML) { $txt = new rcube_html2text($body, false, true); $body = $txt->get_text(); } // size limit defined in ActiveSync protocol if ($size > 255) { $size = 255; } return mb_strcut(trim($body), 0, $size); } /** * Returns list of tag names assigned to an email message */ - protected function getKolabTags($message, $dummy = null) + public static function getKolabTags($message, $dummy = null) { // support only messages with message-id if (!($msg_id = $message->headers->get('message-id', false))) { return null; } $config = kolab_storage_config::get_instance(); $delta = Syncroton_Registry::getPingTimeout(); $folder = $message->folder; $uid = $message->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; + $force = empty(self::$tag_rts[$tag['uid']]) || self::$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(); + self::$tag_rts[$tag['uid']] = time(); } } $tags = array_filter(array_map(function($v) { return $v['name']; }, $tags)); // make sure current folder is set correctly again - $this->storage->set_folder($folder); + rcube::get_instance()->get_storage()->set_folder($folder); return !empty($tags) ? $tags : null; } /** * Set tags to an email message */ - protected function setKolabTags($message, $tags) + public static function setKolabTags($message, $tags) { $config = kolab_storage_config::get_instance(); $delta = Syncroton_Registry::getPingTimeout(); $folder = $message->folder; $uri = kolab_storage_config::get_message_uri($message->headers, $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; + $force = empty(self::$tag_rts[$uid]) || self::$tag_rts[$uid] <= time() - $delta; if ($force) { $config->resolve_members($relation, $force); - $this->tag_rts[$tag['uid']] = time(); + self::$tag_rts[$tag['uid']] = time(); } $selected = !empty($tags) && in_array($relation['name'], $tags); $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 else if (!$found && $selected) { $relation['members'][] = $uri; $update = true; } if ($update) { $config->save($relation, 'relation'); } $tags = array_diff($tags, (array) $relation['name']); } // create new relations if (!empty($tags)) { foreach ($tags as $tag) { $relation = array( 'name' => $tag, 'members' => (array) $uri, 'category' => 'tag', ); $config->save($relation, 'relation'); } } // make sure current folder is set correctly again - $this->storage->set_folder($folder); + rcube::get_instance()->get_storage()->set_folder($folder); } public static function charset_to_cp($charset) { // @TODO: ????? // The body is converted to utf-8 in get_part_body(), what about headers? return 65001; // UTF-8 $aliases = array( 'asmo708' => 708, 'shiftjis' => 932, 'gb2312' => 936, 'ksc56011987' => 949, 'big5' => 950, 'utf16' => 1200, 'utf16le' => 1200, 'unicodefffe' => 1201, 'utf16be' => 1201, 'johab' => 1361, 'macintosh' => 10000, 'macjapanese' => 10001, 'macchinesetrad' => 10002, 'mackorean' => 10003, 'macarabic' => 10004, 'machebrew' => 10005, 'macgreek' => 10006, 'maccyrillic' => 10007, 'macchinesesimp' => 10008, 'macromanian' => 10010, 'macukrainian' => 10017, 'macthai' => 10021, 'macce' => 10029, 'macicelandic' => 10079, 'macturkish' => 10081, 'maccroatian' => 10082, 'utf32' => 12000, 'utf32be' => 12001, 'chinesecns' => 20000, 'chineseeten' => 20002, 'ia5' => 20105, 'ia5german' => 20106, 'ia5swedish' => 20107, 'ia5norwegian' => 20108, 'usascii' => 20127, 'ibm273' => 20273, 'ibm277' => 20277, 'ibm278' => 20278, 'ibm280' => 20280, 'ibm284' => 20284, 'ibm285' => 20285, 'ibm290' => 20290, 'ibm297' => 20297, 'ibm420' => 20420, 'ibm423' => 20423, 'ibm424' => 20424, 'ebcdickoreanextended' => 20833, 'ibmthai' => 20838, 'koi8r' => 20866, 'ibm871' => 20871, 'ibm880' => 20880, 'ibm905' => 20905, 'ibm00924' => 20924, 'cp1025' => 21025, 'koi8u' => 21866, 'iso88591' => 28591, 'iso88592' => 28592, 'iso88593' => 28593, 'iso88594' => 28594, 'iso88595' => 28595, 'iso88596' => 28596, 'iso88597' => 28597, 'iso88598' => 28598, 'iso88599' => 28599, 'iso885913' => 28603, 'iso885915' => 28605, 'xeuropa' => 29001, 'iso88598i' => 38598, 'iso2022jp' => 50220, 'csiso2022jp' => 50221, 'iso2022jp' => 50222, 'iso2022kr' => 50225, 'eucjp' => 51932, 'euccn' => 51936, 'euckr' => 51949, 'hzgb2312' => 52936, 'gb18030' => 54936, 'isciide' => 57002, 'isciibe' => 57003, 'isciita' => 57004, 'isciite' => 57005, 'isciias' => 57006, 'isciior' => 57007, 'isciika' => 57008, 'isciima' => 57009, 'isciigu' => 57010, 'isciipa' => 57011, 'utf7' => 65000, 'utf8' => 65001, ); $charset = strtolower($charset); $charset = preg_replace(array('/^x-/', '/[^a-z0-9]/'), '', $charset); if (isset($aliases[$charset])) { return $aliases[$charset]; } if (preg_match('/^(ibm|dos|cp|windows|win)[0-9]+/', $charset, $m)) { return substr($charset, strlen($m[1]) + 1); } } /** * Wrap text to a given number of characters per line * but respect the mail quotation of replies messages (>). * Finally add another quotation level by prepending the lines * with > * * @param string $text Text to wrap * @param int $length The line width * * @return string The wrapped text */ protected static function wrap_and_quote($text, $length = 72) { // Function stolen from Roundcube ;) // Rebuild the message body with a maximum of $max chars, while keeping quoted message. $max = min(77, $length + 8); $lines = preg_split('/\r?\n/', trim($text)); $out = ''; foreach ($lines as $line) { // don't wrap already quoted lines if ($line[0] == '>') { $line = '>' . rtrim($line); } else if (mb_strlen($line) > $max) { $newline = ''; foreach (explode("\n", rcube_mime::wordwrap($line, $length - 2)) as $l) { if (strlen($l)) { $newline .= '> ' . $l . "\n"; } else { $newline .= ">\n"; } } $line = rtrim($newline); } else { $line = '> ' . $line; } // Append the line $out .= $line . "\n"; } return $out; } /** * Returns calendar event data from the iTip invitation attached to a mail message */ public function get_invitation_event($messageId) { // Get the mail message object if ($message = $this->getObject($messageId)) { // Parse the message and find iTip attachments $libcal = libcalendaring::get_instance(); $libcal->mail_message_load(array('object' => $message)); $ical_objects = $libcal->get_mail_ical_objects(); // We support only one event in the iTip foreach ($ical_objects as $mime_id => $event) { if ($event['_type'] == 'event') { return $event; } } } } /** * Checks if the message can be processed, depending on its size and * memory_limit, otherwise throws an exception or returns fake body. */ protected function message_mem_check($message, $size, $result = null) { static $memory_rised; // @FIXME: we need up to 5x more memory than the body // Note: Biggest memory multiplication happens in recode_message() // and the Syncroton engine (which also does not support passing bodies // as streams). It also happens when parsing the plain/html text body // in getMessagePartBody() though the footprint there is probably lower. if (!rcube_utils::mem_check($size * 5)) { // If we already rised the memory we throw an exception, so the message // will be synchronized in the next run (then we might have enough memory) if ($memory_rised) { throw new Syncroton_Exception_MemoryExhausted; } $memory_rised = true; $memory_max = 512; // maximum in MB $memory_limit = round(parse_bytes(ini_get('memory_limit')) / 1024 / 1024); // current limit (in MB) $memory_add = round($size * 5 / 1024 / 1024); // how much we need (in MB) $memory_needed = min($memory_limit + $memory_add, $memory_max) . "M"; if ($memory_limit < $memory_max) { $this->logger->debug("Setting memory_limit=$memory_needed"); if (ini_set('memory_limit', $memory_needed) !== false) { // Memory has been rised, check again if (rcube_utils::mem_check($size * 5)) { return; } } } $this->logger->warn("Not enough memory. Using fake email body."); if ($result !== null) { return $result; } // Let's return a fake message. If we return an empty body Outlook // will not list the message at all. This way user can do something // with the message (flag, delete, move) and see the reason why it's fake // and importantly see its subject, sender, etc. // TODO: Localization? $msg = "This message is too large for ActiveSync."; // $msg .= "See https://kb.kolabenterprise.com/documentation/some-place for more information."; // Get original message headers $headers = $this->storage->get_raw_headers($message->uid); // Build a fake message with original headers, but changed body return kolab_sync_message::fake_message($headers, $msg); } } } diff --git a/lib/kolab_sync_draft.php b/lib/kolab_sync_draft.php new file mode 100644 index 0000000..cb65693 --- /dev/null +++ b/lib/kolab_sync_draft.php @@ -0,0 +1,498 @@ + | + | | + | 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 | + +--------------------------------------------------------------------------+ +*/ + +/** + * A class to handle operations on email drafts + * and other email message updates + */ +class kolab_sync_draft +{ + protected $headers = array(); + protected $flags = array(); + protected $params = array(); + protected $message; + protected $folder; + protected $folderId; + protected $is_draft; + protected $categories; + protected $body; + protected $attachments; + + + /** + * Constructor + * + * @param Syncroton_Model_IEntry $draft Message data + * @param rcube_message $existing Existing message + * @param array $params Request parameters (folder, folderId, send) + */ + function __construct($data, $existing = null, $params = array()) + { + // IMAP folder name + if (isset($params['folder']) && strlen($params['folder'])) { + $this->folder = $params['folder']; + } + + // ActiveSync folder ID + if (isset($params['folderId']) && strlen($params['folderId'])) { + $this->folderId = $params['folderId']; + } + + $headers = array('To', 'Cc', 'Bcc', 'ReplyTo', 'Subject'); + foreach ($headers as $header_name) { + $prop_name = lcfirst($header_name); + if (isset($data->$prop_name)) { + $this->headers[$header_name] = (string) $data->$prop_name; + } + } + + if (isset($data->importance)) { + $priorities_map = array(0 => '5 (Lowest)', 2 => '1 (Highest)'); + $this->headers['X-Priority'] = $priorities_map[(int) $data->importance]; + } + + if (isset($data->read)) { + $this->flags[] = ($entry->read ? 'UN' : '') . 'SEEN'; + } + + $is_flagged = !empty($existing) && !empty($existing->headers->flags['FLAGGED']); + + // Flag change + if (isset($data->flag) && (empty($data->flag) || empty($data->flag->flagType))) { + if ($is_flagged) { + $this->flags[] = 'UNFLAGGED'; + } + } + else if (!$is_flagged && !empty($data->flag)) { + if ($data->flag->flagType && preg_match('/follow\s*up/i', $data->flag->flagType)) { + $this->flags[] = 'FLAGGED'; + } + } + + $this->is_draft = empty($existing) || !empty($this->headers) || $data->body || $data->attachments; + $this->message = $existing; + $this->params = $params; + $this->body = $data->body; + $this->attachments = $data->attachments; + $this->categories = $data->categories; + } + + /** + * Save changes in the draft to the IMAP storage + */ + public function save() + { + $rcube = rcube::get_instance(); + $storage = $rcube->get_storage(); + + // Non-draft, only update flags and categories (tags) + if (!$this->is_draft) { + foreach ($this->flags as $flag) { + $storage->set_flag($this->message->uid, $flag, $this->message->folder); + } + + // Categories (Tags) + if (isset($this->categories)) { + kolab_sync_data_email::setKolabTags($this->message, $this->categories); + } + + return; + } + + // Send request + if ($this->params['send']) { + return $this->send(); + } + + // Prepare headers and body + list($headers, $body, $is_file) = $this->get_body_and_headers(); + + // TODO: Attachments + + $uid = $this->save_message($this->folder, $body, $headers, $is_file); + + return kolab_sync_data_email::createMessageId($this->folderId, $uid); + } + + /** + * Send the given message (store in sent and delete the draft) + */ + protected function send() + { + $rcube = rcube::get_instance(); + + // Prepare headers and body + list($headers, $body, $is_file) = $this->get_body_and_headers(); + + $headers['User-Agent'] = $rcube->app_name . ' ' . kolab_sync::VERSION; + if ($agent = $rcube->config->get('useragent')) { + $headers['User-Agent'] .= '/' . $agent; + } + + if (empty($headers['From'])) { + $headers['From'] = $this->get_identity(); + } + // make sure there's sender name in From: + else if ($rcube->config->get('activesync_fix_from') + && preg_match('/^?$/', trim($headers['From']), $m) + ) { + $identities = kolab_sync::get_instance()->user->list_identities(); + $email = $m[1]; + + foreach ((array) $identities as $ident) { + if ($ident['email'] == $email) { + if ($ident['name']) { + $headers['From'] = format_email_recipient($email, $ident['name']); + } + break; + } + } + } + + // generate list of recipients + $recipients = array(); + + if (!empty($headers['To'])) + $recipients[] = $headers['To']; + if (!empty($headers['Cc'])) + $recipients[] = $headers['Cc']; + if (!empty($headers['Bcc'])) + $recipients[] = $headers['Bcc']; + + $smtp_headers = $headers; + + // remove Bcc header + unset($smtp_headers['Bcc']); + + // send message + if (!is_object($rcube->smtp)) { + $rcube->smtp_init(true); + } + + if ($is_file) { + $filename = $body; + $body = fopen($body); + } + + $sent = $rcube->smtp->send_mail($headers['From'], $recipients, $smtp_headers, $body); + $smtp_response = $rcube->smtp->get_response(); + $smtp_error = $rcube->smtp->get_error(); + + // log error + if (!$sent) { + if ($is_file) { + fclose($body); + unlink($filename); + } + + rcube::raise_error(array('code' => 800, 'type' => 'smtp', + 'line' => __LINE__, 'file' => __FILE__, + 'message' => "SMTP error: ".join("\n", $smtp_response)), true, false); + + // TODO throw exception + } + + if ($sent) { + $rcube->plugins->exec_hook('message_sent', array('headers' => $headers, 'body' => $body)); + + // remove MDN headers after sending + unset($headers['Return-Receipt-To'], $headers['Disposition-Notification-To']); + + if ($rcube->config->get('smtp_log')) { + // get all recipients + $mailto = implode(' ', $recipients); + if (preg_match_all('/<([^@]+@[^>]+)>/', $mailto, $m)) { + $mailto = implode(', ', array_unique($m[1])); + } + + rcube::write_log('sendmail', sprintf("User %s [%s]; Message for %s; %s", + $rcube->get_user_name(), + $_SERVER['REMOTE_ADDR'], + $mailto, + !empty($smtp_response) ? join('; ', $smtp_response) : '')); + } + + if ($is_file) { + fclose($body); + $body = $filename; + } + + // TODO: save in sent and delete draft + // $uid = $this->save_message($folder, $body, $headers, $is_file); + + if ($is_file) { + unlink($filename); + } + + return; + } + + // TODO throw exception + } + + /** + * Prepare message body and headers + * + * @return array Result array (0 => headers array, 1 => message body, 2 => true if body is filename) + */ + protected function get_body_and_headers() + { + $rcube = rcube::get_instance(); + $storage = $rcube->get_storage(); + $is_file = false; + + // Body contains full MIME message (headers and body) + if ($this->body && $this->body->type == Syncroton_Command_Sync::BODY_TYPE_MIME) { + $headers = ''; + $size = strlen($this->body->data); + + // Split body and headers + if ($size < 1024 * 1024) { + list($headers, $message) = explode("\r\n\r\n", $this->body->data, 2); + } + else { + // Use temp file for big messages (> 1MB) + if ($pos = strpos($this->body->data, "\r\n\r\n")) { + $headers = substr($this->body->data, 0, $pos); + } + + $message = rcube_utils::temp_filename('msg'); + $offset = $pos ? $pos + 4 : 0; + $is_file = true; + + if ($fp = fopen($message)) { + // Write the data in chunks to lower the peak memory usage + while ($offset < $size) { + $end = $offset + 512000; + + if ($end >= $size) { + $end = $size; + } + + $chunk = substr($this->body->data, $offset, $end - $offset); + fwrite($fp, $chunk); + $offset = $end; + } + + fclose($fp); + } + } + } + else if ($this->body) { + // TODO + } + else if ($this->message) { + if ($this->message->size < 1024 * 1024) { + $message = $storage->get_raw_body($this->message->uid, null, 'TEXT'); + } + else { + $message = rcube_utils::temp_filename('msg'); + $is_file = true; + + if ($fp = fopen($fp)) { + $storage->get_raw_body($this->message->uid, $fp, 'TEXT'); + fclose($fp); + } + } + } + + // Get headers of the existing message + if ($this->message && !isset($headers)) { + $headers = $storage->get_raw_headers($this->message->uid); + } + + $headers = self::parse_headers($headers); + + // Build the message headers + if (!empty($this->headers)) { + foreach ($this->headers as $header => $header_value) { + $headers[$header] = $header . ': ' . $header_value; + } + } + + if (empty($headers['Message-ID'])) { + $headers['Message-ID'] = 'Message-ID: ' . $rcube->gen_message_id(); + } + + $headers['Date'] = $this->user_date(); + + // remove empty headers + $headers = array_filter($headers); + + return array($headers, $message, $is_file); + } + + /** + * Save message to IMAP folder, set flags and tags + */ + protected function save_message($folder, $headers, $body, $is_file) + { + $headers = trim(implode("\r\n", $headers)); + $flags = array_intersect($this->flags, array('SEEN', 'FLAGGED')); + + // TODO: Attachments + + // Save the message + $uid = $storage->save_message($folder, $body, $headers, $is_file, $flags); + + // Remove temp file + if ($is_file) { + unlink($body); + } + + // Categories (Tags) + if ($uid && isset($this->categories)) { + $message = rcube_message($uid, $folder); + kolab_sync_data_email::setKolabTags($message, $this->categories); + } + + // TODO: throw exception + + return $uid; + } + + /** + * Parse message source with headers + */ + protected static function parse_headers($headers) + { + // Parse headers + $headers = str_replace("\r\n", "\n", $headers); + $headers = explode("\n", trim($headers)); + + $ln = 0; + $lines = array(); + + foreach ($headers as $line) { + if (ord($line[0]) <= 32) { + $lines[$ln] .= (empty($lines[$ln]) ? '' : "\r\n") . $line; + } + else { + $lines[++$ln] = trim($line); + } + } + + // Unify char-case of header names + $headers = array(); + foreach ($lines as $line) { + list($field, $string) = explode(':', $line, 2); + if ($field = self::normalize_header_name($field)) { + $headers[$field] = trim($string); + } + } + + return $headers; + } + + /** + * Normalize (fix) header names + */ + protected static function normalize_header_name($name) + { + $headers_map = array( + 'subject' => 'Subject', + 'from' => 'From', + 'to' => 'To', + 'cc' => 'Cc', + 'bcc' => 'Bcc', + 'message-id' => 'Message-ID', + 'references' => 'References', + 'content-type' => 'Content-Type', + 'content-transfer-encoding' => 'Content-Transfer-Encoding', + ); + + $name_lc = strtolower($name); + + return isset($headers_map[$name_lc]) ? $headers_map[$name_lc] : $name; + } + + /** + * Encodes message/part body + * + * @param string $body Message/part body + * @param string $encoding Content encoding + * + * @return string Encoded body + */ +/* + protected function encode($body, $encoding) + { + switch ($encoding) { + case 'base64': + $body = base64_encode($body); + $body = chunk_split($body, 76, "\r\n"); + break; + case 'quoted-printable': + $body = quoted_printable_encode($body); + break; + } + + return $body; + } +*/ + /** + * Decodes message/part body + * + * @param string $body Message/part body + * @param string $encoding Content encoding + * + * @return string Decoded body + */ +/* + protected function decode($body, $encoding) + { + $body = str_replace("\r\n", "\n", $body); + + switch ($encoding) { + case 'base64': + $body = base64_decode($body); + break; + case 'quoted-printable': + $body = quoted_printable_decode($body); + break; + } + + return $body; + } +*/ + /** + * Returns RFC2822 formatted current date in user's timezone + * + * @return string Date + */ + protected function user_date() + { + // get user's timezone + try { + $tz = new DateTimeZone(rcube::get_instance()->config->get('timezone')); + $date = new DateTime('now', $tz); + } + catch (Exception $e) { + $date = new DateTime(); + } + + return $date->format('r'); + } +}