diff --git a/bin/resync.php b/bin/resync.php new file mode 100755 index 0000000..8826d81 --- /dev/null +++ b/bin/resync.php @@ -0,0 +1,116 @@ +#!/usr/bin/php + | + | | + | 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 | + +--------------------------------------------------------------------------+ +*/ + +define('RCUBE_INSTALL_PATH', realpath(dirname(__FILE__) . '/../') . '/'); +define('RCUBE_PLUGINS_DIR', RCUBE_INSTALL_PATH . 'lib/plugins/'); + +// Define include path +$include_path = RCUBE_INSTALL_PATH . 'lib' . PATH_SEPARATOR; +$include_path .= RCUBE_INSTALL_PATH . 'lib/ext' . PATH_SEPARATOR; +$include_path .= ini_get('include_path'); +set_include_path($include_path); + +// include composer autoloader (if available) +if (@file_exists(RCUBE_INSTALL_PATH . 'vendor/autoload.php')) { + require RCUBE_INSTALL_PATH . 'vendor/autoload.php'; +} + +// include global functions from Roundcube Framework +require_once 'Roundcube/bootstrap.php'; + +$opts = rcube_utils::get_opt([ + 'o' => 'owner', + 'f' => 'folder', + 'd' => 'deviceid', + 't' => 'devicetype', // e.g. WindowsOutlook15 or iPhone +]); + +$rcube = \rcube::get_instance(); +$db = $rcube->get_dbh(); + +if (empty($opts['owner'])) { + rcube::raise_error("Owner not specified (--owner).", false, true); +} +if (empty($opts['folder'])) { + rcube::raise_error("Folder name not specified (--folder).", false, true); +} + +$select = $db->query( + "SELECT `user_id` FROM `users` WHERE `username` = ? ORDER BY `user_id` DESC", + \strtolower($opts['owner']) +); + +if ($data = $db->fetch_assoc($select)) { + $userid = $data['user_id']; +} else { + rcube::raise_error("User not found in Roundcube database.", false, true); +} + +$devices = []; +if (!empty($opts['deviceid'])) { + $select = $db->query( + "SELECT `id` FROM `syncroton_device` WHERE `owner_id` = ? AND `deviceid` = ?", + $userid, + $opts['deviceid'] + ); + while ($record = $db->fetch_assoc($select)) { + $devices[] = $record['id']; + } +} elseif (!empty($opts['devicetype'])) { + $select = $db->query( + "SELECT `id` FROM `syncroton_device` WHERE `owner_id` = ? AND `devicetype` = ?", + $userid, + $opts['devicetype'] + ); + while ($record = $db->fetch_assoc($select)) { + $devices[] = $record['id']; + } +} else { + $select = $db->query("SELECT `id` FROM `syncroton_device` WHERE `owner_id` = ?", $userid); + while ($record = $db->fetch_assoc($select)) { + $devices[] = $record['id']; + } +} + +if (empty($devices)) { + rcube::raise_error("Device not found.", false, true); +} + +// TODO: Support not only top-level folders + +$select = $db->query( + "SELECT `id`, `displayname`, `folderid` FROM `syncroton_folder`" + . " WHERE `device_id` IN (" . $db->array2list($devices) . ")" + . " AND `parentid` = '0' AND `displayname` = " . $db->quote($opts['folder']) +); + +while ($record = $db->fetch_assoc($select)) { + if (!empty($opts['dry-run'])) { + print("[DRY-RUN] {$record['displayname']} ({$record['id']}:{$record['folderid']})\n"); + } else { + $db->query("UPDATE `syncroton_folder` SET `resync` = 1 WHERE id = ?", $record['id']); + print("{$record['displayname']} ({$record['id']}:{$record['folderid']})\n"); + } +} diff --git a/docs/SQL/mysql/2024101700.sql b/docs/SQL/mysql/2024101700.sql new file mode 100644 index 0000000..93d8cee --- /dev/null +++ b/docs/SQL/mysql/2024101700.sql @@ -0,0 +1 @@ +ALTER TABLE `syncroton_folder` ADD `resync` tinyint(1) DEFAULT NULL; diff --git a/lib/ext/Syncroton/Command/FolderSync.php b/lib/ext/Syncroton/Command/FolderSync.php index 565832e..f5892c5 100644 --- a/lib/ext/Syncroton/Command/FolderSync.php +++ b/lib/ext/Syncroton/Command/FolderSync.php @@ -1,292 +1,298 @@ */ /** * class to handle ActiveSync FolderSync command * * @package Syncroton * @subpackage Command */ class Syncroton_Command_FolderSync extends Syncroton_Command_Wbxml { public const STATUS_SUCCESS = 1; public const STATUS_FOLDER_EXISTS = 2; public const STATUS_IS_SPECIAL_FOLDER = 3; public const STATUS_FOLDER_NOT_FOUND = 4; public const STATUS_PARENT_FOLDER_NOT_FOUND = 5; public const STATUS_SERVER_ERROR = 6; public const STATUS_ACCESS_DENIED = 7; public const STATUS_REQUEST_TIMED_OUT = 8; public const STATUS_INVALID_SYNC_KEY = 9; public const STATUS_MISFORMATTED = 10; public const STATUS_UNKNOWN_ERROR = 11; /** * some usefull constants for working with the xml files */ public const FOLDERTYPE_GENERIC_USER_CREATED = 1; public const FOLDERTYPE_INBOX = 2; public const FOLDERTYPE_DRAFTS = 3; public const FOLDERTYPE_DELETEDITEMS = 4; public const FOLDERTYPE_SENTMAIL = 5; public const FOLDERTYPE_OUTBOX = 6; public const FOLDERTYPE_TASK = 7; public const FOLDERTYPE_CALENDAR = 8; public const FOLDERTYPE_CONTACT = 9; public const FOLDERTYPE_NOTE = 10; public const FOLDERTYPE_JOURNAL = 11; public const FOLDERTYPE_MAIL_USER_CREATED = 12; public const FOLDERTYPE_CALENDAR_USER_CREATED = 13; public const FOLDERTYPE_CONTACT_USER_CREATED = 14; public const FOLDERTYPE_TASK_USER_CREATED = 15; public const FOLDERTYPE_JOURNAL_USER_CREATED = 16; public const FOLDERTYPE_NOTE_USER_CREATED = 17; public const FOLDERTYPE_UNKOWN = 18; protected $_defaultNameSpace = 'uri:FolderHierarchy'; protected $_documentElement = 'FolderSync'; protected $_classes = [ Syncroton_Data_Factory::CLASS_CALENDAR, Syncroton_Data_Factory::CLASS_CONTACTS, Syncroton_Data_Factory::CLASS_EMAIL, Syncroton_Data_Factory::CLASS_NOTES, Syncroton_Data_Factory::CLASS_TASKS, ]; /** * @var string */ protected $_syncKey; /** * Parse FolderSync request */ public function handle() { $xml = simplexml_import_dom($this->_requestBody); $syncKey = (int)$xml->SyncKey; if ($this->_logger instanceof Zend_Log) { $this->_logger->debug(__METHOD__ . '::' . __LINE__ . " synckey is $syncKey"); } if ($syncKey === 0) { $this->_syncState = new Syncroton_Model_SyncState([ 'device_id' => $this->_device, 'counter' => 0, 'type' => 'FolderSync', 'lastsync' => $this->_syncTimeStamp, ]); // reset state of foldersync $this->_syncStateBackend->resetState($this->_device, 'FolderSync'); return; } if (!($this->_syncState = $this->_syncStateBackend->validate($this->_device, 'FolderSync', $syncKey)) instanceof Syncroton_Model_SyncState) { $this->_syncStateBackend->resetState($this->_device, 'FolderSync'); } } /** * generate FolderSync response * * @todo changes are missing in response (folder got renamed for example) */ public function getResponse() { $folderSync = $this->_outputDom->documentElement; // invalid synckey provided if (!$this->_syncState instanceof Syncroton_Model_SyncState) { if ($this->_logger instanceof Zend_Log) { $this->_logger->info(__METHOD__ . '::' . __LINE__ . " invalid synckey provided. FolderSync 0 needed."); } $folderSync->appendChild($this->_outputDom->createElementNS('uri:FolderHierarchy', 'Status', self::STATUS_INVALID_SYNC_KEY)); return $this->_outputDom; } // send headers from options command also when FolderSync SyncKey is 0 if ($this->_syncState->counter == 0) { $optionsCommand = new Syncroton_Command_Options(); $this->_headers = array_merge($this->_headers, $optionsCommand->getHeaders()); } $adds = []; $updates = []; $deletes = []; foreach($this->_classes as $class) { try { $dataController = Syncroton_Data_Factory::factory($class, $this->_device, $this->_syncTimeStamp); } catch (Exception $e) { // backend not defined if ($this->_logger instanceof Zend_Log) { $this->_logger->info(__METHOD__ . '::' . __LINE__ . " no data backend defined for class: " . $class); } continue; } try { // retrieve all folders available in data backend $serverFolders = $dataController->getAllFolders(); // retrieve all folders sent to client $clientFolders = $this->_folderBackend->getFolderState($this->_device, $class); if ($this->_syncState->counter > 0) { // retrieve all folders changed since last sync $changedFolders = $dataController->getChangedFolders($this->_syncState->lastsync, $this->_syncTimeStamp); } else { $changedFolders = []; } // only folders which were sent to the client already are allowed to be in $changedFolders $changedFolders = array_intersect_key($changedFolders, $clientFolders); } catch (Exception $e) { if ($this->_logger instanceof Zend_Log) { $this->_logger->crit(__METHOD__ . '::' . __LINE__ . " Syncing folder hierarchy failed: " . $e->getMessage()); } if ($this->_logger instanceof Zend_Log) { $this->_logger->debug(__METHOD__ . '::' . __LINE__ . " Syncing folder hierarchy failed: " . $e->getTraceAsString()); } // The Status element is global for all collections. If one collection fails, // a failure status MUST be returned for all collections. if ($e instanceof Syncroton_Exception_Status) { $status = $e->getCode(); } else { $status = Syncroton_Exception_Status_FolderSync::UNKNOWN_ERROR; } $folderSync->appendChild($this->_outputDom->createElementNS('uri:FolderHierarchy', 'Status', $status)); return $this->_outputDom; } $serverFoldersIds = array_keys($serverFolders); // is this the first sync? if ($this->_syncState->counter == 0) { $clientFoldersIds = []; } else { $clientFoldersIds = array_keys($clientFolders); } // calculate added entries $serverDiff = array_diff($serverFoldersIds, $clientFoldersIds); foreach ($serverDiff as $serverFolderId) { // have we created a folderObject in syncroton_folder before? if (isset($clientFolders[$serverFolderId])) { $add = $clientFolders[$serverFolderId]; } else { $add = $serverFolders[$serverFolderId]; $add->creationTime = $this->_syncTimeStamp; $add->deviceId = $this->_device->id; unset($add->id); } $add->class = $class; $adds[] = $add; } // calculate changed entries foreach ($changedFolders as $changedFolder) { $change = $clientFolders[$changedFolder->serverId]; $change->displayName = $changedFolder->displayName; $change->parentId = $changedFolder->parentId; $change->type = $changedFolder->type; $updates[] = $change; } // Find changes in case backend does not support folder changes detection. // On some backends getChangedFolders() can return an empty result. // We make sure all is up-to-date comparing folder properties. foreach ($clientFoldersIds as $folderId) { if (isset($serverFolders[$folderId])) { $c = $clientFolders[$folderId]; $s = $serverFolders[$folderId]; if ($c->displayName !== $s->displayName || strval($c->parentId) !== strval($s->parentId) || $c->type != $s->type ) { $c->displayName = $s->displayName; $c->parentId = $s->parentId; $c->type = $s->type; $updates[] = $c; } } } + // Handle folders set for forced re-sync, we'll send a delete action to the client, + // but because the folder is still existing and subscribed on the backend it should + // "immediately" be added again (and re-synced). + $forceDeleteIds = array_keys(array_filter($clientFolders, function ($f) { return !empty($f->resync); })); + $serverFoldersIds = array_diff($serverFoldersIds, $forceDeleteIds); + // calculate deleted entries $serverDiff = array_diff($clientFoldersIds, $serverFoldersIds); foreach ($serverDiff as $serverFolderId) { $deletes[] = $clientFolders[$serverFolderId]; } } $folderSync->appendChild($this->_outputDom->createElementNS('uri:FolderHierarchy', 'Status', self::STATUS_SUCCESS)); $count = count($adds) + count($updates) + count($deletes); if($count > 0) { $this->_syncState->counter++; $this->_syncState->lastsync = $this->_syncTimeStamp; } // create xml output $folderSync->appendChild($this->_outputDom->createElementNS('uri:FolderHierarchy', 'SyncKey', $this->_syncState->counter)); $changes = $folderSync->appendChild($this->_outputDom->createElementNS('uri:FolderHierarchy', 'Changes')); $changes->appendChild($this->_outputDom->createElementNS('uri:FolderHierarchy', 'Count', $count)); foreach($adds as $folder) { $add = $changes->appendChild($this->_outputDom->createElementNS('uri:FolderHierarchy', 'Add')); $folder->appendXML($add, $this->_device); // store folder in backend if (empty($folder->id)) { $this->_folderBackend->create($folder); } } foreach($updates as $folder) { $update = $changes->appendChild($this->_outputDom->createElementNS('uri:FolderHierarchy', 'Update')); $folder->appendXML($update, $this->_device); $this->_folderBackend->update($folder); } foreach($deletes as $folder) { $delete = $changes->appendChild($this->_outputDom->createElementNS('uri:FolderHierarchy', 'Delete')); $delete->appendChild($this->_outputDom->createElementNS('uri:FolderHierarchy', 'ServerId', $folder->serverId)); $this->_folderBackend->delete($folder); } if (empty($this->_syncState->id)) { $this->_syncStateBackend->create($this->_syncState); } else { $this->_syncStateBackend->update($this->_syncState); } return $this->_outputDom; } } diff --git a/lib/ext/Syncroton/Model/Folder.php b/lib/ext/Syncroton/Model/Folder.php index e57b427..86e1ba6 100644 --- a/lib/ext/Syncroton/Model/Folder.php +++ b/lib/ext/Syncroton/Model/Folder.php @@ -1,39 +1,40 @@ */ /** * class to handle ActiveSync Sync command * * @package Syncroton * @subpackage Model */ class Syncroton_Model_Folder extends Syncroton_Model_AXMLEntry implements Syncroton_Model_IFolder { protected $_xmlBaseElement = ['FolderUpdate', 'FolderCreate']; protected $_properties = [ 'FolderHierarchy' => [ 'parentId' => ['type' => 'string'], 'serverId' => ['type' => 'string'], 'displayName' => ['type' => 'string'], 'type' => ['type' => 'number'], ], 'Internal' => [ 'id' => ['type' => 'string'], 'deviceId' => ['type' => 'string'], 'ownerId' => ['type' => 'string'], 'class' => ['type' => 'string'], 'creationTime' => ['type' => 'datetime'], 'lastfiltertype' => ['type' => 'number'], + 'resync' => ['type' => 'number'], ], ]; } diff --git a/lib/kolab_sync_backend_folder.php b/lib/kolab_sync_backend_folder.php index 7df726e..4dbe899 100644 --- a/lib/kolab_sync_backend_folder.php +++ b/lib/kolab_sync_backend_folder.php @@ -1,190 +1,195 @@ | | | | 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 | +--------------------------------------------------------------------------+ */ /** * Kolab backend class for the folder state storage */ class kolab_sync_backend_folder extends kolab_sync_backend_common implements Syncroton_Backend_IFolder { protected $table_name = 'syncroton_folder'; protected $interface_name = 'Syncroton_Model_IFolder'; /** * Delete all stored folder ids for a given device * * @param Syncroton_Model_Device|string $deviceid Device object or identifier */ public function resetState($deviceid) { $device_id = $deviceid instanceof Syncroton_Model_IDevice ? $deviceid->id : $deviceid; $where[] = $this->db->quote_identifier('device_id') . ' = ' . $this->db->quote($device_id); $this->db->query('DELETE FROM `' . $this->table_name . '` WHERE ' . implode(' AND ', $where)); } /** * Get array of ids which got send to the client for a given class * * @param Syncroton_Model_Device|string $deviceid Device object or identifier * @param string $class Class name * * @return array List of object identifiers */ public function getFolderState($deviceid, $class) { $device_id = $deviceid instanceof Syncroton_Model_IDevice ? $deviceid->id : $deviceid; $where[] = $this->db->quote_identifier('device_id') . ' = ' . $this->db->quote($device_id); $where[] = $this->db->quote_identifier('class') . ' = ' . $this->db->quote($class); $select = $this->db->query('SELECT * FROM `' . $this->table_name . '` WHERE ' . implode(' AND ', $where)); $result = []; while ($folder = $this->db->fetch_assoc($select)) { $result[$folder['folderid']] = $this->get_object($folder); } return $result; } /** * Get folder * * @param Syncroton_Model_Device|string $deviceid Device object or identifier * @param string $folderid Folder identifier * * @return Syncroton_Model_IFolder Folder object */ public function getFolder($deviceid, $folderid) { $device_id = $deviceid instanceof Syncroton_Model_IDevice ? $deviceid->id : $deviceid; $where[] = $this->db->quote_identifier('device_id') . ' = ' . $this->db->quote($device_id); $where[] = $this->db->quote_identifier('folderid') . ' = ' . $this->db->quote($folderid); $select = $this->db->query('SELECT * FROM `' . $this->table_name . '` WHERE ' . implode(' AND ', $where)); $folder = $this->db->fetch_assoc($select); - if (empty($folder)) { + if (empty($folder) || !empty($folder['resync'])) { throw new Syncroton_Exception_NotFound('Folder not found'); } return $this->get_object($folder); } /** * Find out if the folder hierarchy changed since the last FolderSync * * @param Syncroton_Model_Device $device Device object * * @return bool True if folders hierarchy changed, False otherwise */ public function hasHierarchyChanges($device) { $timestamp = new DateTime('now', new DateTimeZone('utc')); $client_crc = ''; $server_crc = ''; $client_folders = []; $server_folders = []; $folder_classes = [ Syncroton_Data_Factory::CLASS_CALENDAR, Syncroton_Data_Factory::CLASS_CONTACTS, Syncroton_Data_Factory::CLASS_EMAIL, Syncroton_Data_Factory::CLASS_NOTES, Syncroton_Data_Factory::CLASS_TASKS, ]; // Reset imap cache so we work with up-to-date folders list rcube::get_instance()->get_storage()->clear_cache('mailboxes', true); + // Retrieve all folders already sent to the client + $select = $this->db->query("SELECT * FROM `{$this->table_name}` WHERE `device_id` = ?", $device->id); + + while ($folder = $this->db->fetch_assoc($select)) { + if (!empty($folder['resync'])) { + // Folder re-sync requested + return true; + } + + $client_folders[$folder['folderid']] = $this->get_object($folder); + } + foreach ($folder_classes as $class) { try { // retrieve all folders available in data backend $dataController = Syncroton_Data_Factory::factory($class, $device, $timestamp); $server_folders = array_merge($server_folders, $dataController->getAllFolders()); } catch (Exception $e) { rcube::raise_error($e, true, false); // This is server error, returning True might cause infinite sync loops return false; } } - // retrieve all folders sent to the client - $select = $this->db->query("SELECT * FROM `{$this->table_name}` WHERE `device_id` = ?", $device->id); - - while ($folder = $this->db->fetch_assoc($select)) { - $client_folders[$folder['folderid']] = $this->get_object($folder); - } - ksort($client_folders); ksort($server_folders); foreach ($client_folders as $folder) { $client_crc .= '^' . $folder->serverId . ':' . $folder->displayName . ':' . $folder->parentId; } foreach ($server_folders as $folder) { $server_crc .= '^' . $folder->serverId . ':' . $folder->displayName . ':' . $folder->parentId; } return $client_crc !== $server_crc; } /** * (non-PHPdoc) * @see kolab_sync_backend_common::from_camelcase() */ protected function from_camelcase($string) { switch ($string) { case 'displayName': case 'parentId': return strtolower($string); case 'serverId': return 'folderid'; default: return parent::from_camelcase($string); } } /** * (non-PHPdoc) * @see kolab_sync_backend_common::to_camelcase() */ protected function to_camelcase($string, $ucFirst = true) { switch ($string) { case 'displayname': return 'displayName'; case 'parentid': return 'parentId'; case 'folderid': return 'serverId'; default: return parent::to_camelcase($string, $ucFirst); } } }