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);
}
}
}