diff --git a/lib/ext/Syncroton/Command/Ping.php b/lib/ext/Syncroton/Command/Ping.php index d7abf59..b1fc5fd 100644 --- a/lib/ext/Syncroton/Command/Ping.php +++ b/lib/ext/Syncroton/Command/Ping.php @@ -1,294 +1,294 @@ */ /** * class to handle ActiveSync Ping command * * @package Syncroton * @subpackage Command */ class Syncroton_Command_Ping extends Syncroton_Command_Wbxml { public const STATUS_NO_CHANGES_FOUND = 1; public const STATUS_CHANGES_FOUND = 2; public const STATUS_MISSING_PARAMETERS = 3; public const STATUS_REQUEST_FORMAT_ERROR = 4; public const STATUS_INTERVAL_TO_GREAT_OR_SMALL = 5; public const STATUS_TOO_MANY_FOLDERS = 6; public const STATUS_FOLDER_NOT_FOUND = 7; public const STATUS_GENERAL_ERROR = 8; public const MAX_PING_INTERVAL = 3540; // 59 minutes limit defined in Activesync protocol spec. protected $_defaultNameSpace = 'uri:Ping'; protected $_documentElement = 'Ping'; protected $_skipValidatePolicyKey = true; protected $_changesDetected = false; protected $_foldersWithChanges = []; /** * process the XML file and add, change, delete or fetches data * * @todo can we get rid of LIBXML_NOWARNING * @todo we need to stored the initial data for folders and lifetime as the phone is sending them only when they change */ public function handle() { $intervalStart = time(); $status = self::STATUS_NO_CHANGES_FOUND; // the client does not send a wbxml document, if the Ping parameters did not change compared with the last request if ($this->_requestBody instanceof DOMDocument) { $xml = simplexml_import_dom($this->_requestBody); $xml->registerXPathNamespace('Ping', 'Ping'); if(isset($xml->HeartbeatInterval)) { $this->_device->pinglifetime = (int)$xml->HeartbeatInterval; } if (isset($xml->Folders->Folder)) { $maxCollections = Syncroton_Registry::getMaxCollections(); if ($maxCollections && count($xml->Folders->Folder) > $maxCollections) { $ping = $this->_outputDom->documentElement; $ping->appendChild($this->_outputDom->createElementNS('uri:Ping', 'Status', self::STATUS_TOO_MANY_FOLDERS)); $ping->appendChild($this->_outputDom->createElementNS('uri:Ping', 'MaxFolders', $maxCollections)); return; } $folders = []; foreach ($xml->Folders->Folder as $folderXml) { try { // does the folder exist? $folder = $this->_folderBackend->getFolder($this->_device, (string)$folderXml->Id); $folders[$folder->id] = $folder; } catch (Syncroton_Exception_NotFound $senf) { if ($this->_logger instanceof Zend_Log) { $this->_logger->debug(__METHOD__ . '::' . __LINE__ . " " . $senf->getMessage()); } $status = self::STATUS_FOLDER_NOT_FOUND; break; } } $this->_device->pingfolder = serialize(array_keys($folders)); } } $this->_device->lastping = new DateTime('now', new DateTimeZone('UTC')); if ($status == self::STATUS_NO_CHANGES_FOUND) { $this->_device = $this->_deviceBackend->update($this->_device); // @phpstan-ignore-line } $lifeTime = $this->_device->pinglifetime; $maxInterval = Syncroton_Registry::getPingInterval(); if ($maxInterval <= 0 || $maxInterval > Syncroton_Server::MAX_HEARTBEAT_INTERVAL) { $maxInterval = Syncroton_Server::MAX_HEARTBEAT_INTERVAL; } if ($lifeTime > $maxInterval) { $ping = $this->_outputDom->documentElement; $ping->appendChild($this->_outputDom->createElementNS('uri:Ping', 'Status', self::STATUS_INTERVAL_TO_GREAT_OR_SMALL)); $ping->appendChild($this->_outputDom->createElementNS('uri:Ping', 'HeartbeatInterval', $maxInterval)); return; } $intervalEnd = $intervalStart + $lifeTime; $secondsLeft = $intervalEnd; $folders = $this->_device->pingfolder ? unserialize($this->_device->pingfolder) : []; if ($status === self::STATUS_NO_CHANGES_FOUND && (!is_array($folders) || count($folders) == 0)) { $status = self::STATUS_MISSING_PARAMETERS; } if ($this->_logger instanceof Zend_Log) { $this->_logger->debug(__METHOD__ . '::' . __LINE__ . " Folders to monitor($lifeTime / $intervalStart / $intervalEnd / $status): " . print_r($folders, true)); } if ($status === self::STATUS_NO_CHANGES_FOUND) { $sleepCallback = Syncroton_Registry::getSleepCallback(); $wakeupCallback = Syncroton_Registry::getWakeupCallback(); do { // take a break to save battery lifetime call_user_func($sleepCallback); - sleep(Syncroton_Registry::getPingTimeout()); + sleep(min(Syncroton_Registry::getPingTimeout(), $lifeTime)); // 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; } // reconnect external connections, etc. call_user_func($wakeupCallback); // Calculate secondsLeft before any loop break just to have a correct value // for logging purposes in case we breaked from the loop early $secondsLeft = $intervalEnd - time(); try { /** @var Syncroton_Model_Device $device */ $device = $this->_deviceBackend->get($this->_device->id); } catch (Syncroton_Exception_NotFound $e) { if ($this->_logger instanceof Zend_Log) { $this->_logger->debug(__METHOD__ . '::' . __LINE__ . " " . $e->getMessage()); } $status = self::STATUS_FOLDER_NOT_FOUND; break; } catch (Exception $e) { if ($this->_logger instanceof Zend_Log) { $this->_logger->err(__METHOD__ . '::' . __LINE__ . " " . $e->getMessage()); } // do nothing, maybe temporal issue, should we stop? continue; } // if another Ping command updated lastping property, we can stop processing this Ping command request if ((isset($device->lastping) && $device->lastping instanceof DateTime) && $device->pingfolder === $this->_device->pingfolder && $device->lastping->getTimestamp() > $this->_device->lastping->getTimestamp() ) { break; } // If folders hierarchy changed, break the loop and ask the client for FolderSync try { if ($this->_folderBackend->hasHierarchyChanges($this->_device)) { if ($this->_logger instanceof Zend_Log) { $this->_logger->debug(__METHOD__ . '::' . __LINE__ . ' Detected changes in folders hierarchy'); } $status = self::STATUS_FOLDER_NOT_FOUND; break; } } catch (Exception $e) { if ($this->_logger instanceof Zend_Log) { $this->_logger->err(__METHOD__ . '::' . __LINE__ . " " . $e->getMessage()); } // do nothing, maybe temporal issue, should we stop? continue; } $now = new DateTime('now', new DateTimeZone('UTC')); foreach ($folders as $folderId) { try { /** @var Syncroton_Model_Folder $folder */ $folder = $this->_folderBackend->get($folderId); $dataController = Syncroton_Data_Factory::factory($folder->class, $this->_device, $this->_syncTimeStamp); } catch (Syncroton_Exception_NotFound $e) { if ($this->_logger instanceof Zend_Log) { $this->_logger->debug(__METHOD__ . '::' . __LINE__ . " " . $e->getMessage()); } $status = self::STATUS_FOLDER_NOT_FOUND; break; } catch (Exception $e) { if ($this->_logger instanceof Zend_Log) { $this->_logger->err(__METHOD__ . '::' . __LINE__ . " " . $e->getMessage()); } // do nothing, maybe temporal issue, should we stop? continue; } try { $syncState = $this->_syncStateBackend->getSyncState($this->_device, $folder); // another process synchronized data of this folder already. let's skip it if ($syncState->lastsync > $this->_syncTimeStamp) { continue; } // safe battery time by skipping folders which got synchronied less than Syncroton_Registry::getQuietTime() seconds ago if (($now->getTimestamp() - $syncState->lastsync->getTimestamp()) < Syncroton_Registry::getQuietTime()) { continue; } $foundChanges = $dataController->hasChanges($this->_contentStateBackend, $folder, $syncState); } catch (Syncroton_Exception_NotFound $e) { // folder got never synchronized to client if ($this->_logger instanceof Zend_Log) { $this->_logger->debug(__METHOD__ . '::' . __LINE__ . " " . $e->getMessage()); } if ($this->_logger instanceof Zend_Log) { $this->_logger->info(__METHOD__ . '::' . __LINE__ . ' syncstate not found. enforce sync for folder: ' . $folder->serverId); } $foundChanges = true; } if ($foundChanges == true) { $this->_foldersWithChanges[] = $folder; $status = self::STATUS_CHANGES_FOUND; } } if ($status != self::STATUS_NO_CHANGES_FOUND) { break; } // Update secondsLeft (again) $secondsLeft = $intervalEnd - time(); if ($this->_logger instanceof Zend_Log) { $this->_logger->debug(__METHOD__ . '::' . __LINE__ . " DeviceId: " . $this->_device->deviceid . " seconds left: " . $secondsLeft); } // See: http://www.tine20.org/forum/viewtopic.php?f=12&t=12146 // // break if there are less than PingTimeout + 10 seconds left for the next loop // otherwise the response will be returned after the client has finished his Ping // request already maybe } while (Syncroton_Server::validateSession() && $secondsLeft > (Syncroton_Registry::getPingTimeout() + 10)); } if ($this->_logger instanceof Zend_Log) { $this->_logger->info(__METHOD__ . '::' . __LINE__ . " DeviceId: " . $this->_device->deviceid . " Lifetime: $lifeTime SecondsLeft: $secondsLeft Status: $status)"); } $ping = $this->_outputDom->documentElement; $ping->appendChild($this->_outputDom->createElementNS('uri:Ping', 'Status', $status)); if($status === self::STATUS_CHANGES_FOUND) { $folders = $ping->appendChild($this->_outputDom->createElementNS('uri:Ping', 'Folders')); foreach($this->_foldersWithChanges as $changedFolder) { $folder = $folders->appendChild($this->_outputDom->createElementNS('uri:Ping', 'Folder', $changedFolder->serverId)); if ($this->_logger instanceof Zend_Log) { $this->_logger->info(__METHOD__ . '::' . __LINE__ . " DeviceId: " . $this->_device->deviceid . " changes in folder: " . $changedFolder->serverId); } } } } /** * generate ping command response * */ public function getResponse() { return $this->_outputDom; } } diff --git a/tests/Sync/PingTest.php b/tests/Sync/PingTest.php new file mode 100644 index 0000000..0689744 --- /dev/null +++ b/tests/Sync/PingTest.php @@ -0,0 +1,102 @@ + + + + 900 + + + 38b950ebd62cd9a66929c89615d0fc04 + Email + + + + EOF; + + $response = $this->request($request, 'Ping'); + + $this->assertEquals(200, $response->getStatusCode()); + + $dom = $this->fromWbxml($response->getBody()); + $xpath = $this->xpath($dom); + $this->printDom($dom); + + //Initially we know no folders + $this->assertSame('7', $xpath->query("//ns:Ping/ns:Status")->item(0)->nodeValue); + + + //We discover folders with a foldersync + $request = << + + + 0 + + EOF; + + $response = $this->request($request, 'FolderSync'); + $this->assertEquals(200, $response->getStatusCode()); + + //Now we get to the actual ping + $request = << + + + 0 + + + 38b950ebd62cd9a66929c89615d0fc04 + Email + + + + EOF; + + $response = $this->request($request, 'Ping'); + $this->assertEquals(200, $response->getStatusCode()); + $dom = $this->fromWbxml($response->getBody()); + $xpath = $this->xpath($dom); + // $this->printDom($dom); + //Initially we know no folders + $this->assertSame('2', $xpath->query("//ns:Ping/ns:Status")->item(0)->nodeValue); + } + + /** + * Test Ping command + */ + public function testUnknownFolder() + { + $request = << + + + 900 + + + foobar + Email + + + + EOF; + + $response = $this->request($request, 'Ping'); + + $this->assertEquals(200, $response->getStatusCode()); + + $dom = $this->fromWbxml($response->getBody()); + $xpath = $this->xpath($dom); + // $this->printDom($dom); + + $this->assertSame('7', $xpath->query("//ns:Ping/ns:Status")->item(0)->nodeValue); + } +} diff --git a/tests/SyncTestCase.php b/tests/SyncTestCase.php index 0a8209c..2fe406d 100644 --- a/tests/SyncTestCase.php +++ b/tests/SyncTestCase.php @@ -1,428 +1,436 @@ markTestSkipped('Not setup'); } self::$deviceType = null; } /** * {@inheritDoc} */ public static function setUpBeforeClass(): void { $sync = \kolab_sync::get_instance(); $config = $sync->config; $db = $sync->get_dbh(); self::$username = $config->get('activesync_test_username'); self::$password = $config->get('activesync_test_password'); self::$host = $config->get('activesync_test_host', 'http://localhost:8000'); if (empty(self::$username)) { return; } self::$deviceId = 'test' . time(); $db->query('DELETE FROM syncroton_device'); $db->query('DELETE FROM syncroton_synckey'); $db->query('DELETE FROM syncroton_folder'); $db->query('DELETE FROM syncroton_data'); $db->query('DELETE FROM syncroton_data_folder'); $db->query('DELETE FROM syncroton_content'); $db->query('DELETE FROM syncroton_relations_state'); self::$client = new \GuzzleHttp\Client([ 'http_errors' => false, 'base_uri' => self::$host, 'verify' => false, 'auth' => [self::$username, self::$password], 'connect_timeout' => 10, 'timeout' => 10, 'headers' => [ 'Content-Type' => 'application/xml; charset=utf-8', 'Depth' => '1', ], ]); // TODO: execute: php -S localhost:8000 } /** * {@inheritDoc} */ public static function tearDownAfterClass(): void { if (self::$deviceId) { $sync = \kolab_sync::get_instance(); if (self::$authenticated || $sync->authenticate(self::$username, self::$password)) { $sync->password = self::$password; $storage = $sync->storage(); $storage->device_delete(self::$deviceId); } $db = $sync->get_dbh(); $db->query('DELETE FROM syncroton_device'); $db->query('DELETE FROM syncroton_synckey'); $db->query('DELETE FROM syncroton_folder'); $db->query('DELETE FROM syncroton_relations_state'); } } /** * Append an email message to the IMAP folder */ protected function appendMail($folder, $filename, $replace = []) { $imap = $this->getImapStorage(); $source = __DIR__ . '/src/' . $filename; if (!file_exists($source)) { exit("File does not exist: {$source}"); } $is_file = true; if (!empty($replace)) { $is_file = false; $source = file_get_contents($source); foreach ($replace as $token => $value) { $source = str_replace($token, $value, $source); } } $uid = $imap->save_message($folder, $source, '', $is_file); if ($uid === false) { exit("Failed to append mail {$filename} into {$folder}"); } return $uid; } /** * Run A SQL query */ protected function runSQLQuery($query) { $sync = \kolab_sync::get_instance(); $db = $sync->get_dbh(); $db->query($query); } /** * Mark an email message as read over IMAP */ protected function markMailAsRead($folder, $uids) { $imap = $this->getImapStorage(); return $imap->set_flag($uids, 'SEEN', $folder); } /** * List emails over IMAP */ protected function listEmails($folder, $uids) { $imap = $this->getImapStorage(); return $imap->list_flags($folder, $uids); } /** * Append an DAV object to a DAV/IMAP folder */ protected function appendObject($foldername, $filename, $type) { $path = __DIR__ . '/src/' . $filename; if (!file_exists($path)) { exit("File does not exist: {$path}"); } $content = file_get_contents($path); $uid = preg_match('/UID:(?:urn:uuid:)?([a-z0-9-]+)/', $content, $m) ? $m[1] : null; if (empty($uid)) { exit("Filed to find UID in {$path}"); } if ($this->isStorageDriver('kolab')) { $imap = $this->getImapStorage(); if ($imap->folder_exists($foldername)) { // TODO exit("Not implemented for Kolab v3 storage driver"); } else { exit("Folder is missing"); } } $dav = $this->getDavStorage(); foreach ($dav->get_folders($type) as $folder) { if ($folder->get_name() === $foldername) { $dav_type = $folder->get_dav_type(); $location = $folder->object_location($uid); if ($folder->dav->create($location, $content, $dav_type) !== false) { return; } } } exit("Failed to append object into {$foldername}"); } /** * Delete a folder */ protected function deleteTestFolder($name, $type) { // Deleting IMAP folders if ($type == 'mail' || $this->isStorageDriver('kolab')) { $imap = $this->getImapStorage(); if ($imap->folder_exists($name)) { $imap->delete_folder($name); } return; } // Deleting DAV folders $dav = $this->getDavStorage(); foreach ($dav->get_folders($type) as $folder) { if ($folder->get_name() === $name) { $dav->folder_delete($folder->id, $type); } } } /** * Create a folder */ protected function createTestFolder($name, $type) { // Create IMAP folders if ($type == 'mail' || $this->isStorageDriver('kolab')) { $imap = $this->getImapStorage(); //TODO set type if not mail $imap->create_folder($name, true); $metadata = []; $metadata['FOLDER'] = []; $metadata['FOLDER'][self::$deviceId] = []; $metadata['FOLDER'][self::$deviceId]['S'] = '1'; $imap->set_metadata($name, ['/private/vendor/kolab/activesync' => json_encode($metadata)]); return; } } /** * Remove all objects from a folder */ protected function emptyTestFolder($name, $type) { // Deleting in IMAP folders if ($type == 'mail' || $this->isStorageDriver('kolab')) { $imap = $this->getImapStorage(); $imap->delete_message('*', $name); return; } // Deleting in DAV folders $dav = $this->getDavStorage(); foreach ($dav->get_folders($type) as $folder) { if ($folder->get_name() === $name) { $folder->delete_all(); } } } /** * Convert WBXML binary content into XML */ protected function fromWbxml($binary) { $stream = fopen('php://memory', 'r+'); fwrite($stream, $binary); rewind($stream); $decoder = new \Syncroton_Wbxml_Decoder($stream); return $decoder->decode(); } /** * Initialize DAV storage */ protected function getDavStorage() { $sync = \kolab_sync::get_instance(); $url = $sync->config->get('activesync_dav_server', 'http://localhost'); if (strpos($url, '://') === false) { $url = 'http://' . $url; } // Inject user+password to the URL, there's no other way to pass it to the DAV client $url = str_replace('://', '://' . rawurlencode(self::$username) . ':' . rawurlencode(self::$password) . '@', $url); // Make sure user is authenticated $this->getImapStorage(); if (!empty($sync->user)) { // required e.g. for DAV client cache use \rcube::get_instance()->user = $sync->user; } return new \kolab_storage_dav($url); } /** * Initialize IMAP storage */ protected function getImapStorage() { $sync = \kolab_sync::get_instance(); if (!self::$authenticated) { if ($sync->authenticate(self::$username, self::$password)) { self::$authenticated = true; $sync->password = self::$password; } } return $sync->get_storage(); } /** * Check the configured activesync_storage driver */ protected function isStorageDriver($name) { return $name === \kolab_sync::get_instance()->config->get('activesync_storage', 'kolab'); } /** * Make a HTTP request to the ActiveSync server */ protected function request($body, $cmd, $type = 'POST') { $username = self::$username; $deviceId = self::$deviceId; $deviceType = self::$deviceType ?: 'WindowsOutlook15'; $body = $this->toWbxml($body); return self::$client->request( $type, "?Cmd={$cmd}&User={$username}&DeviceId={$deviceId}&DeviceType={$deviceType}", [ 'headers' => [ 'Content-Type' => 'application/vnd.ms-sync.wbxml', 'MS-ASProtocolVersion' => '14.0', ], 'body' => $body, ] ); } /** * Register the device for tests, some commands do not work until device/folders are registered */ protected function registerDevice() { // Execute initial FolderSync, it is required before executing some commands $request = << 0 EOF; $response = $this->request($request, 'FolderSync'); $this->assertEquals(200, $response->getStatusCode()); $dom = $this->fromWbxml($response->getBody()); $xpath = $this->xpath($dom); foreach ($xpath->query("//ns:FolderSync/ns:Changes/ns:Add") as $idx => $folder) { $serverId = $folder->getElementsByTagName('ServerId')->item(0)->nodeValue; $displayName = $folder->getElementsByTagName('DisplayName')->item(0)->nodeValue; $this->folders[$serverId] = $displayName; } } /** * Convert XML into WBXML binary content */ protected function toWbxml($xml) { $outputStream = fopen('php://temp', 'r+'); $encoder = new \Syncroton_Wbxml_Encoder($outputStream, 'UTF-8', 3); $dom = new \DOMDocument(); $dom->loadXML($xml); $encoder->encode($dom); rewind($outputStream); return stream_get_contents($outputStream); } /** * Get XPath from a DOM */ protected function xpath($dom) { $xpath = new \DOMXpath($dom); $xpath->registerNamespace("ns", $dom->documentElement->namespaceURI); $xpath->registerNamespace("AirSync", "uri:AirSync"); $xpath->registerNamespace("AirSyncBase", "uri:AirSyncBase"); $xpath->registerNamespace("Calendar", "uri:Calendar"); $xpath->registerNamespace("Contacts", "uri:Contacts"); $xpath->registerNamespace("Email", "uri:Email"); $xpath->registerNamespace("Email2", "uri:Email2"); $xpath->registerNamespace("Settings", "uri:Settings"); $xpath->registerNamespace("Tasks", "uri:Tasks"); return $xpath; } + /** + * Pretty print the DOM + */ + protected function printDom($dom) + { + $dom->formatOutput = true; + print($dom->saveXML()); + } /** * adapter for phpunit < 9 */ public static function assertMatchesRegularExpression(string $arg1, string $arg2, string $message = ''): void { if (method_exists("PHPUnit\Framework\TestCase", "assertMatchesRegularExpression")) { parent::assertMatchesRegularExpression($arg1, $arg2, $message); } else { parent::assertRegExp($arg1, $arg2); } } }