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