diff --git a/lib/kolab_sync_storage.php b/lib/kolab_sync_storage.php index 5e13323..7440e65 100644 --- a/lib/kolab_sync_storage.php +++ b/lib/kolab_sync_storage.php @@ -1,2029 +1,2028 @@ | | | | 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 | +--------------------------------------------------------------------------+ */ /** * Storage handling class with basic Kolab support (everything stored in IMAP) */ class kolab_sync_storage { public const INIT_SUB_PERSONAL = 1; // all subscribed folders in personal namespace public const INIT_ALL_PERSONAL = 2; // all folders in personal namespace public const INIT_SUB_OTHER = 4; // all subscribed folders in other users namespace public const INIT_ALL_OTHER = 8; // all folders in other users namespace public const INIT_SUB_SHARED = 16; // all subscribed folders in shared namespace public const INIT_ALL_SHARED = 32; // all folders in shared namespace public const MODEL_CALENDAR = 'event'; public const MODEL_CONTACTS = 'contact'; public const MODEL_EMAIL = 'mail'; public const MODEL_NOTES = 'note'; public const MODEL_TASKS = 'task'; public const ROOT_MAILBOX = 'INBOX'; public const ASYNC_KEY = '/private/vendor/kolab/activesync'; public const UID_KEY = '/shared/vendor/cmu/cyrus-imapd/uniqueid'; public const CTYPE_KEY = '/shared/vendor/kolab/folder-type'; public const CTYPE_KEY_PRIVATE = '/private/vendor/kolab/folder-type'; public $syncTimeStamp; protected $storage; protected $folder_meta; protected $folder_uids; protected $folders = []; protected $root_meta; protected $relations = []; public $relationSupport = true; protected $tag_rts = []; private $modseq = []; protected static $instance; protected static $types = [ 1 => '', 2 => 'mail.inbox', 3 => 'mail.drafts', 4 => 'mail.wastebasket', 5 => 'mail.sentitems', 6 => 'mail.outbox', 7 => 'task.default', 8 => 'event.default', 9 => 'contact.default', 10 => 'note.default', 11 => 'journal.default', 12 => 'mail', 13 => 'event', 14 => 'contact', 15 => 'task', 16 => 'journal', 17 => 'note', ]; /** * This implements the 'singleton' design pattern * * @return kolab_sync_storage The one and only instance */ public static function get_instance() { if (!self::$instance) { self::$instance = new kolab_sync_storage(); self::$instance->startup(); // init AFTER object was linked with self::$instance } return self::$instance; } /** * Class initialization */ public function startup() { $this->storage = kolab_sync::get_instance()->get_storage(); // set additional header used by libkolab $this->storage->set_options([ // @TODO: there can be Roundcube plugins defining additional headers, // we maybe would need to add them here 'fetch_headers' => 'X-KOLAB-TYPE X-KOLAB-MIME-VERSION', 'skip_deleted' => true, 'threading' => false, ]); // Disable paging $this->storage->set_pagesize(999999); } /** * Clear internal cache state */ public function reset() { $this->folders = []; } /** * List known devices * * @return array Device list as hash array */ public function devices_list() { if ($this->root_meta === null) { // @TODO: consider server annotation instead of INBOX if ($meta = $this->storage->get_metadata(self::ROOT_MAILBOX, self::ASYNC_KEY)) { $this->root_meta = $this->unserialize_metadata($meta[self::ROOT_MAILBOX][self::ASYNC_KEY]); } else { $this->root_meta = []; } } if (!empty($this->root_meta['DEVICE']) && is_array($this->root_meta['DEVICE'])) { return $this->root_meta['DEVICE']; } return []; } /** * Get list of folders available for sync * * @param string $deviceid Device identifier * @param string $type Folder type * @param bool $flat_mode Enables flat-list mode * * @return array|bool List of mailbox folders, False on backend failure */ public function folders_list($deviceid, $type, $flat_mode = false) { // get all folders of specified type $folders = kolab_storage::list_folders('', '*', $type, false, $typedata); // get folders activesync config $folderdata = $this->folder_meta(); if (!is_array($folders) || !is_array($folderdata)) { return false; } $folders_list = []; // check if folders are "subscribed" for activesync foreach ($folderdata as $folder => $meta) { if (empty($meta['FOLDER']) || empty($meta['FOLDER'][$deviceid]) || empty($meta['FOLDER'][$deviceid]['S']) ) { continue; } // force numeric folder name to be a string (T1283) $folder = (string) $folder; if (!empty($type) && !in_array($folder, $folders)) { continue; } // Activesync folder identifier (serverId) $folder_type = !empty($typedata[$folder]) ? $typedata[$folder] : 'mail'; $folder_id = $this->folder_id($folder, $folder_type); $folders_list[$folder_id] = $this->folder_data($folder, $folder_type); } if ($flat_mode) { $folders_list = $this->folders_list_flat($folders_list, $type, $typedata); } return $folders_list; } /** * Converts list of folders to a "flat" list */ protected function folders_list_flat($folders, $type, $typedata) { $delim = $this->storage->get_hierarchy_delimiter(); foreach ($folders as $idx => $folder) { if ($folder['parentId']) { // for non-mail folders we make the list completely flat if ($type != self::MODEL_EMAIL) { $display_name = kolab_storage::object_name($folder['imap_name']); $display_name = html_entity_decode($display_name, ENT_COMPAT, RCUBE_CHARSET); $folders[$idx]['parentId'] = 0; $folders[$idx]['displayName'] = $display_name; } // for mail folders we modify only folders with non-existing parents elseif (!isset($folders[$folder['parentId']])) { $items = explode($delim, $folder['imap_name']); $parent = 0; // find existing parent while (count($items) > 0) { array_pop($items); $parent_name = implode($delim, $items); $parent_type = !empty($typedata[$parent_name]) ? $typedata[$parent_name] : 'mail'; $parent_id = $this->folder_id($parent_name, $parent_type); if (isset($folders[$parent_id])) { $parent = $parent_id; break; } } if (!$parent) { $display_name = kolab_storage::object_name($folder['imap_name']); $display_name = html_entity_decode($display_name, ENT_COMPAT, RCUBE_CHARSET); } else { $parent_name = isset($parent_id) ? $folders[$parent_id]['imap_name'] : ''; $display_name = substr($folder['imap_name'], strlen($parent_name) + 1); $display_name = rcube_charset::convert($display_name, 'UTF7-IMAP'); $display_name = str_replace($delim, ' ยป ', $display_name); } $folders[$idx]['parentId'] = $parent; $folders[$idx]['displayName'] = $display_name; } } } return $folders; } /** * Getter for folder metadata * * @return array|bool Hash array with meta data for each folder, False on backend failure */ protected function folder_meta() { if (!isset($this->folder_meta)) { // get folders activesync config $folderdata = $this->storage->get_metadata("*", self::ASYNC_KEY); if (!is_array($folderdata)) { return $this->folder_meta = false; } $this->folder_meta = []; foreach ($folderdata as $folder => $meta) { if (isset($meta[self::ASYNC_KEY])) { if ($metadata = $this->unserialize_metadata($meta[self::ASYNC_KEY])) { $this->folder_meta[$folder] = $metadata; } } } } return $this->folder_meta; } /** * Creates folder and subscribes to the device * * @param string $name Folder name (UTF8) * @param int $type Folder (ActiveSync) type * @param string $deviceid Device identifier * @param ?string $parentid Parent folder id identifier * * @return string|false New folder identifier on success, False on failure */ public function folder_create($name, $type, $deviceid, $parentid = null) { $parent = null; $name = rcube_charset::convert($name, kolab_sync::CHARSET, 'UTF7-IMAP'); if ($parentid) { $parent = $this->folder_id2name($parentid, $deviceid); if ($parent === null) { throw new Syncroton_Exception_Status_FolderCreate(Syncroton_Exception_Status_FolderCreate::PARENT_NOT_FOUND); } } if ($parent !== null) { $delim = $this->storage->get_hierarchy_delimiter(); $name = $parent . $delim . $name; } if ($this->storage->folder_exists($name)) { throw new Syncroton_Exception_Status_FolderCreate(Syncroton_Exception_Status_FolderCreate::FOLDER_EXISTS); } $type = self::type_activesync2kolab($type); $created = kolab_storage::folder_create($name, $type, true); if ($created) { // Set ActiveSync subscription flag $this->folder_set($name, $deviceid, 1); return $this->folder_id($name, $type); } // Special case when client tries to create a subfolder of INBOX // which is not possible on Cyrus-IMAP (T2223) if ($parent === 'INBOX' && stripos($this->last_error(), 'invalid') !== false) { throw new Syncroton_Exception_Status_FolderCreate(Syncroton_Exception_Status_FolderCreate::SPECIAL_FOLDER); } return false; } /** * Renames a folder * * @param string $folderid Folder identifier * @param string $deviceid Device identifier * @param string $new_name New folder name (UTF8) * @param ?string $parentid Folder parent identifier * * @return bool True on success, False on failure */ public function folder_rename($folderid, $deviceid, $new_name, $parentid) { $old_name = $this->folder_id2name($folderid, $deviceid); if ($parentid) { $parent = $this->folder_id2name($parentid, $deviceid); } $name = rcube_charset::convert($new_name, kolab_sync::CHARSET, 'UTF7-IMAP'); if (isset($parent)) { $delim = $this->storage->get_hierarchy_delimiter(); $name = $parent . $delim . $name; } // Rename/move IMAP folder if ($name === $old_name) { return true; } $this->folder_meta = null; // TODO: folder type change? $type = kolab_storage::folder_type($old_name); // don't use kolab_storage for moving mail folders if (preg_match('/^mail/', $type)) { return $this->storage->rename_folder($old_name, $name); } else { return kolab_storage::folder_rename($old_name, $name); } } /** * Deletes folder * * @param string $folderid Folder identifier * @param string $deviceid Device identifier * * @return bool True on success, False otherwise */ public function folder_delete($folderid, $deviceid) { $name = $this->folder_id2name($folderid, $deviceid); $type = kolab_storage::folder_type($name); unset($this->folder_meta[$name]); // don't use kolab_storage for deleting mail folders if (preg_match('/^mail/', $type)) { return $this->storage->delete_folder($name); } return kolab_storage::folder_delete($name); } /** * Deletes contents of a folder * * @param string $folderid Folder identifier * @param string $deviceid Device identifier * @param bool $recursive Apply to the folder and its subfolders * * @return bool True on success, False otherwise */ public function folder_empty($folderid, $deviceid, $recursive = false) { $foldername = $this->folder_id2name($folderid, $deviceid); // Remove all entries if (!$this->storage->clear_folder($foldername)) { return false; } // Remove subfolders if ($recursive) { $delim = $this->storage->get_hierarchy_delimiter(); $folderdata = $this->folder_meta(); if (!is_array($folderdata)) { return false; } foreach ($folderdata as $subfolder => $meta) { if (!empty($meta['FOLDER'][$deviceid]['S']) && strpos((string) $subfolder, $foldername . $delim)) { if (!$this->storage->clear_folder((string) $subfolder)) { return false; } } } } return true; } /** * Sets ActiveSync subscription flag on a folder * * @param string $name Folder name (UTF7-IMAP) * @param string $deviceid Device identifier * @param int $flag Flag value (0|1|2) * * @return bool True on success, False on failure */ protected function folder_set($name, $deviceid, $flag) { if (empty($deviceid)) { return false; } // get folders activesync config $metadata = $this->folder_meta(); if (!is_array($metadata)) { return false; } $metadata = $metadata[$name] ?? []; if ($flag) { if (empty($metadata)) { $metadata = []; } if (empty($metadata['FOLDER'])) { $metadata['FOLDER'] = []; } if (empty($metadata['FOLDER'][$deviceid])) { $metadata['FOLDER'][$deviceid] = []; } // Z-Push uses: // 1 - synchronize, no alarms // 2 - synchronize with alarms $metadata['FOLDER'][$deviceid]['S'] = $flag; } else { unset($metadata['FOLDER'][$deviceid]['S']); if (empty($metadata['FOLDER'][$deviceid])) { unset($metadata['FOLDER'][$deviceid]); } if (empty($metadata['FOLDER'])) { unset($metadata['FOLDER']); } if (empty($metadata)) { $metadata = null; } } // Return if nothing's been changed if (!self::data_array_diff($this->folder_meta[$name] ?? null, $metadata)) { return true; } $this->folder_meta[$name] = $metadata; return $this->storage->set_metadata($name, [self::ASYNC_KEY => $this->serialize_metadata($metadata)]); } /** * Returns device metadata * * @param string $id Device ID * * @return array|null Device metadata */ public function device_get($id) { $devices_list = $this->devices_list(); return $devices_list[$id] ?? null; } /** * Registers new device on server * * @param array $device Device data * @param string $id Device ID * * @return bool True on success, False on failure */ public function device_create($device, $id) { // Fill local cache $this->devices_list(); // Some devices create dummy devices with name "validate" (#1109) // This device entry is used in two initial requests, but later // the device registers a real name. We can remove this dummy entry // on new device creation $this->device_delete('validate'); // Old Kolab_ZPush device parameters // MODE: -1 | 0 | 1 (not set | flatmode | foldermode) // TYPE: device type string // ALIAS: user-friendly device name // Syncroton (kolab_sync_backend_device) uses // ID: internal identifier in syncroton database // TYPE: device type string // ALIAS: user-friendly device name $metadata = $this->root_meta; $metadata['DEVICE'][$id] = $device; $metadata = [self::ASYNC_KEY => $this->serialize_metadata($metadata)]; $result = $this->storage->set_metadata(self::ROOT_MAILBOX, $metadata); if ($result) { // Update local cache $this->root_meta['DEVICE'][$id] = $device; // subscribe default set of folders $this->device_init_subscriptions($id); } return $result; } /** * Device update. * * @param array $device Device data * @param string $id Device ID * * @return bool True on success, False on failure */ public function device_update($device, $id) { $devices_list = $this->devices_list(); $old_device = $devices_list[$id]; if (!$old_device) { return false; } // Do nothing if nothing is changed if (!self::data_array_diff($old_device, $device)) { return true; } $device = array_merge($old_device, $device); $metadata = $this->root_meta; $metadata['DEVICE'][$id] = $device; $metadata = [self::ASYNC_KEY => $this->serialize_metadata($metadata)]; $result = $this->storage->set_metadata(self::ROOT_MAILBOX, $metadata); if ($result) { // Update local cache $this->root_meta['DEVICE'][$id] = $device; } return $result; } /** * Device delete. * * @param string $id Device ID * * @return bool True on success, False on failure */ public function device_delete($id) { $device = $this->device_get($id); if (!$device) { return false; } unset($this->root_meta['DEVICE'][$id], $this->root_meta['FOLDER'][$id]); if (empty($this->root_meta['DEVICE'])) { unset($this->root_meta['DEVICE']); } if (empty($this->root_meta['FOLDER'])) { unset($this->root_meta['FOLDER']); } $metadata = $this->serialize_metadata($this->root_meta); $metadata = [self::ASYNC_KEY => $metadata]; // update meta data $result = $this->storage->set_metadata(self::ROOT_MAILBOX, $metadata); if ($result) { // remove device annotation for every folder foreach ($this->folder_meta() as $folder => $meta) { // skip root folder (already handled above) if ($folder == self::ROOT_MAILBOX) { continue; } if (!empty($meta['FOLDER']) && isset($meta['FOLDER'][$id])) { unset($meta['FOLDER'][$id]); if (empty($meta['FOLDER'])) { unset($this->folder_meta[$folder]['FOLDER']); unset($meta['FOLDER']); } if (empty($meta)) { unset($this->folder_meta[$folder]); $meta = null; } $metadata = [self::ASYNC_KEY => $this->serialize_metadata($meta)]; $res = $this->storage->set_metadata($folder, $metadata); if ($res && $meta) { $this->folder_meta[$folder] = $meta; } } } } return $result; } /** * Creates an item in a folder. * * @param string $folderid Folder identifier * @param string $deviceid Device identifier * @param string $type Activesync model name (folder type) * @param string|array $data Object data (string for email, array for other types) * @param array $params Additional parameters (e.g. mail flags) * * @return string|null Item UID on success or null on failure */ public function createItem($folderid, $deviceid, $type, $data, $params = []) { if ($type == self::MODEL_EMAIL) { $foldername = $this->folder_id2name($folderid, $deviceid); $uid = $this->storage->save_message($foldername, $data, '', false, $params['flags'] ?? []); if (!$uid) { // $this->logger->error("Error while storing the message " . $this->storage->get_error_str()); } return $uid; } $useTags = $this->relationSupport && ($type == self::MODEL_TASKS || $type == self::MODEL_NOTES); // convert categories into tags, save them after creating an object if ($useTags && !empty($data['categories'])) { $tags = $data['categories']; unset($data['categories']); } $folder = $this->getFolder($folderid, $deviceid, $type); // 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) && ($type == self::MODEL_TASKS || $type == self::MODEL_NOTES)) { $this->setCategories($data['uid'], $tags); } return $data['uid']; } return null; } /** * Deletes an item from a folder by UID. * * @param string $folderid Folder identifier * @param string $deviceid Device identifier * @param string $type Activesync model name (folder type) * @param string $uid Requested object UID * @param bool $moveToTrash Move to trash, instead of delete (for mail messages only) * * @return bool True on success, False on failure */ public function deleteItem($folderid, $deviceid, $type, $uid, $moveToTrash = false) { if ($type == self::MODEL_EMAIL) { $foldername = $this->folder_id2name($folderid, $deviceid); $trash = kolab_sync::get_instance()->config->get('trash_mbox'); // move message to the Trash folder if ($moveToTrash && strlen($trash) && $trash != $foldername && $this->storage->folder_exists($trash)) { return $this->storage->move_message($uid, $trash, $foldername); } // delete the message // According to the ActiveSync spec. "If the DeletesAsMoves element is set to false, // the deletion is PERMANENT.", therefore we delete the message, and not flag as deleted. // FIXME: We could consider acting according to the 'flag_for_deletion' setting. // Don't forget about 'read_when_deleted' setting then. // $this->storage->set_flag($uid, 'DELETED', $foldername); // $this->storage->set_flag($uid, 'SEEN', $foldername); return $this->storage->delete_message($uid, $foldername); } $useTags = $this->relationSupport && ($type == self::MODEL_TASKS || $type == self::MODEL_NOTES); $folder = $this->getFolder($folderid, $deviceid, $type); if (!$folder || !$folder->valid) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } if ($folder->delete($uid)) { if ($useTags) { $this->setCategories($uid, []); } return true; } return false; } /** * Updates an item in a folder. * * @param string $folderid Folder identifier * @param string $deviceid Device identifier * @param string $type Activesync model name (folder type) * @param string $uid Object UID * @param string|array $data Object data (string for email, array for other types) * @param array $params Additional parameters (e.g. mail flags) * * @return string|null Item UID on success or null on failure */ public function updateItem($folderid, $deviceid, $type, $uid, $data, $params = []) { if ($type == self::MODEL_EMAIL) { $foldername = $this->folder_id2name($folderid, $deviceid); // Note: We do not support a message body update, as it's not needed foreach (($params['flags'] ?? []) as $flag) { $this->storage->set_flag($uid, $flag, $foldername); } // Categories (Tags) change if (isset($params['categories']) && $this->relationSupport) { $message = new rcube_message($uid, $foldername); if (empty($message->headers)) { throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR); } $this->setCategories($message, $params['categories']); } return $uid; } $folder = $this->getFolder($folderid, $deviceid, $type); $useTags = $this->relationSupport && ($type == self::MODEL_TASKS || $type == self::MODEL_NOTES); // convert categories into tags, save them after updating an object if ($useTags && 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, $type, $uid)) { if (isset($tags)) { $this->setCategories($uid, $tags); } return $uid; } return null; } /** * Returns list of categories assigned to an object * * @param object|string $object UID or rcube_message object * @param array $categories Addition tag names to merge with * * @return array List of categories */ public function getCategories($object, $categories = []) { if (is_object($object)) { // support only messages with message-id if (!($msg_id = $object->headers->get('message-id', false))) { return []; } $config = kolab_storage_config::get_instance(); $delta = Syncroton_Registry::getPingTimeout(); $folder = $object->folder; $uid = $object->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; $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(); } } // make sure current folder is set correctly again $this->storage->set_folder($folder); } else { $config = kolab_storage_config::get_instance(); $tags = $config->get_tags($object); } $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; } /** * Gets kolab_storage_folder object from Activesync folder ID. * * @param string $folderid Folder identifier * @param string $deviceid Device identifier * @param string $type Activesync model name (folder type) * * @return ?kolab_storage_folder */ public function getFolder($folderid, $deviceid, $type) { $unique_key = "$folderid:$deviceid:$type"; if (array_key_exists($unique_key, $this->folders)) { return $this->folders[$unique_key]; } $foldername = $this->folder_id2name($folderid, $deviceid); return $this->folders[$unique_key] = kolab_storage::get_folder($foldername, $type); } /** * Gets Activesync preferences for a folder. * * @param string $folderid Folder identifier * @param string $deviceid Device identifier * @param string $type Activesync model name (folder type) * * @return array Folder preferences */ public function getFolderConfig($folderid, $deviceid, $type) { $foldername = $this->folder_id2name($folderid, $deviceid); $metadata = $this->folder_meta(); $config = []; if (!empty($metadata[$foldername]['FOLDER'][$deviceid])) { $config = $metadata[$foldername]['FOLDER'][$deviceid]; } return [ 'ALARMS' => ($config['S'] ?? 0) == 2, ]; } /** * Gets an item from a folder by UID. * * @param string $folderid Folder identifier * @param string $deviceid Device identifier * @param string $type Activesync model name (folder type) * @param string $uid Requested object UID * * @return array|rcube_message|null Object properties */ public function getItem($folderid, $deviceid, $type, $uid) { if ($type == self::MODEL_EMAIL) { $foldername = $this->folder_id2name($folderid, $deviceid); $message = new rcube_message($uid, $foldername); if (!empty($message->headers)) { if ($this->relationSupport) { $message->headers->others['categories'] = $this->getCategories($message); } return $message; } return null; } $folder = $this->getFolder($folderid, $deviceid, $type); if (!$folder || !$folder->valid) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } $result = $folder->get_object($uid); if ($result === false) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } $useTags = $this->relationSupport && ($type == self::MODEL_TASKS || $type == self::MODEL_NOTES); if ($useTags) { $result['categories'] = $this->getCategories($uid, $result['categories'] ?? []); } return $result; } /** * Gets items matching UID by prefix. * * @param string $folderid Folder identifier * @param string $deviceid Device identifier * @param string $type Activesync model name (folder type) * @param string $uid Requested object UID prefix * * @return array|iterable List of objects */ public function getItemsByUidPrefix($folderid, $deviceid, $type, $uid) { $folder = $this->getFolder($folderid, $deviceid, $type); if (!$folder || !$folder->valid) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } $result = $folder->select([['uid', '~*', $uid]]); if ($result === null) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } return $result; } /** * Move an item from one folder to another. * * @param string $srcFolderId Source folder identifier * @param string $deviceid Device identifier * @param string $type Activesync model name (folder type) * @param string $uid Object UID * @param string $dstFolderId Destination folder identifier * * @return string New object UID * @throws Syncroton_Exception_Status */ public function moveItem($srcFolderId, $deviceid, $type, $uid, $dstFolderId) { if ($type === self::MODEL_EMAIL) { $src_name = $this->folder_id2name($srcFolderId, $deviceid); $dst_name = $this->folder_id2name($dstFolderId, $deviceid); if ($dst_name === null) { throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_DESTINATION); } if ($src_name === null) { throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_SOURCE); } if (!$this->storage->move_message($uid, $dst_name, $src_name)) { throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_DESTINATION); } // Use COPYUID feature (RFC2359) to get the new UID of the copied message if (empty($this->storage->conn->data['COPYUID'])) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } return $this->storage->conn->data['COPYUID'][1]; } $srcFolder = $this->getFolder($srcFolderId, $deviceid, $type); $dstFolder = $this->getFolder($dstFolderId, $deviceid, $type); if (!$srcFolder || !$dstFolder) { throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_DESTINATION); } if (!$srcFolder->move($uid, $dstFolder)) { throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_SOURCE); } return $uid; } /** * Set categories to an object * * @param object|string $object UID or rcube_message object * @param array $categories List of Category names */ public function setCategories($object, $categories) { if (!is_object($object)) { $config = kolab_storage_config::get_instance(); $config->save_tags($object, $categories); return; } $config = kolab_storage_config::get_instance(); $delta = Syncroton_Registry::getPingTimeout(); $uri = kolab_storage_config::get_message_uri($object->headers, $object->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; if ($force) { $config->resolve_members($relation, $force); $this->tag_rts[$relation['uid']] = time(); } $selected = !empty($categories) && in_array($relation['name'], $categories); $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 elseif (!$found && $selected) { $relation['members'][] = $uri; $update = true; } if ($update) { $config->save($relation, 'relation'); } $categories = array_diff($categories, (array) $relation['name']); } // create new relations if (!empty($categories)) { foreach ($categories as $tag) { $relation = [ 'name' => $tag, 'members' => (array) $uri, 'category' => 'tag', ]; $config->save($relation, 'relation'); } } // make sure current folder is set correctly again $this->storage->set_folder($object->folder); } /** * Search for existing objects in a folder * * @param string $folderid Folder identifier * @param string $deviceid Device identifier * @param string $device_key Device primary key * @param string $type Activesync model name (folder type) * @param array $filter Filter * @param int $result_type Type of the result (see kolab_sync_data::RESULT_* constants) * @param bool $force Force IMAP folder cache synchronization * @param string $extraData Extra data as extracted by the getExtraData during the last sync * * @return array|int Search result as count or array of uids */ public function searchEntries($folderid, $deviceid, $device_key, $type, $filter, $result_type, $force, $extraData) { if ($type != self::MODEL_EMAIL) { return $this->searchKolabEntries($folderid, $deviceid, $device_key, $type, $filter, $result_type, $force); } $filter_str = 'ALL UNDELETED'; $getChangesMode = false; // convert filter into one IMAP search string foreach ($filter as $idx => $filter_item) { if (is_array($filter_item)) { if ($filter_item[0] == 'changed' && $filter_item[1] == '>') { $getChangesMode = true; } } else { $filter_str .= ' ' . $filter_item; } } $result = $result_type == kolab_sync_data::RESULT_COUNT ? 0 : []; $foldername = $this->folder_id2name($folderid, $deviceid); if ($foldername === null) { return $result; } $this->storage->set_folder($foldername); // Synchronize folder (if it wasn't synced in this request already) if ($force) { $this->storage->folder_sync($foldername); } $modified = true; // We're in "get changes" mode if ($getChangesMode) { $folder_data = $this->storage->folder_data($foldername); // If HIGHESTMODSEQ doesn't exist we can't get changes if (!empty($folder_data['HIGHESTMODSEQ'])) { // Store modseq for later in getExtraData if (!array_key_exists($deviceid, $this->modseq)) { $this->modseq[$deviceid] = []; } $this->modseq[$deviceid][$folderid] = $folder_data['HIGHESTMODSEQ']; // After the initial sync we have no extraData if ($extraData) { $modseq_old = json_decode($extraData)->modseq; // Skip search if HIGHESTMODSEQ didn't change if ($folder_data['HIGHESTMODSEQ'] == $modseq_old) { $modified = false; } else { $filter_str .= " MODSEQ " . ($modseq_old + 1); } } else { // If we don't have extra data we can't search for changes. // Either we are in initial sync, which means there are no changes to find, // or we are in the migration (no previous extraData), in which case we ignore changes for one sync key // because we don't have the means to search for the changes. Going forward we'll have the modseq info. $modified = false; } } else { // We have no way of finding the changes. // We could fall back to search by date or ignore changes, but both seems suboptimal. throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } } // 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 kolab_sync_data::RESULT_COUNT: $result = $search->count(); break; case kolab_sync_data::RESULT_UID: $result = $search->get(); break; } } // get members of modified relations if ($this->relationSupport) { $changed_msgs = $this->getChangesByRelations($folderid, $device_key, $type, $filter); // handle relation changes if (!empty($changed_msgs)) { $members = $this->findRelationMembersInFolder($foldername, $changed_msgs, $filter); switch ($result_type) { case kolab_sync_data::RESULT_COUNT: $result += count($members); break; case kolab_sync_data::RESULT_UID: $result = array_values(array_unique(array_merge($result, $members))); break; } } } return $result; } /** * Return extra data that is stored with the sync key and passed in during the search to find changes. * * @param string $folderid Folder identifier * @param string $deviceid Device identifier * * @return string|null Extra data (JSON-encoded) */ public function getExtraData($folderid, $deviceid) { //We explicitly return a cached value that was used during the search. //Otherwise we'd risk storing a higher modseq value and missing an update. if (isset($this->modseq[$deviceid][$folderid])) { return json_encode(['modseq' => intval($this->modseq[$deviceid][$folderid])]); } //If we didn't fetch modseq in the first place we have to fetch it now. $foldername = $this->folder_id2name($folderid, $deviceid); if ($foldername !== null) { $folder_data = $this->storage->folder_data($foldername); if (!empty($folder_data['HIGHESTMODSEQ'])) { return json_encode(['modseq' => intval($folder_data['HIGHESTMODSEQ'])]); } } return null; } /** * Search for existing objects in a folder * * @param string $folderid Folder identifier * @param string $deviceid Device identifier * @param string $device_key Device primary key * @param string $type Activesync model name (folder type) * @param array $filter Filter * @param int $result_type Type of the result (see kolab_sync_data::RESULT_* constants) * @param bool $force Force IMAP folder cache synchronization * * @return array|int Search result as count or array of uids */ protected function searchKolabEntries($folderid, $deviceid, $device_key, $type, $filter, $result_type, $force) { // there's a PHP Warning from kolab_storage if $filter isn't an array if (empty($filter)) { $filter = []; } elseif ($this->relationSupport && ($type == self::MODEL_TASKS || $type == self::MODEL_NOTES)) { $changed_objects = $this->getChangesByRelations($folderid, $device_key, $type, $filter); } $folder = $this->getFolder($folderid, $deviceid, $type); if (!$folder || !$folder->valid) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } $error = false; switch ($result_type) { case kolab_sync_data::RESULT_COUNT: $count = $folder->count($filter); if ($count === null) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } $result = (int) $count; break; case kolab_sync_data::RESULT_UID: default: $uids = $folder->get_uids($filter); if (!is_array($uids)) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } $result = $uids; break; } // 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 = [['uid', '=', $changed_objects]]; foreach ($filter as $f) { if ($f[0] != 'changed') { $tag_filter[] = $f; } } switch ($result_type) { case kolab_sync_data::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) { $result += (int) $count; } break; case kolab_sync_data::RESULT_UID: default: $uids = $folder->get_uids($tag_filter); if (is_array($uids) && !empty($uids)) { $result = array_unique(array_merge($result, $uids)); } break; } } 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; } } $found = []; // first find messages by UID if (!empty($result[$foldername])) { $index = $this->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 = $this->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 = []; $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 = $this->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; } /** * Detect changes of relation (tag) objects data and assigned objects * Returns relation member identifiers */ protected function getChangesByRelations($folderid, $device_key, $type, $filter) { // 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->relations_state_get($device_key, $folderid, $since); // get current relations state $config = kolab_storage_config::get_instance(); $default = true; $filter = [ ['type', '=', 'relation'], ['category', '=', 'tag'], ]; $relations = $config->get_objects($filter, $default, 100); $result = []; $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... elseif ($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... elseif ($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 = []; foreach ($relations as $relation) { $data[$relation['uid']] = [ 'name' => $relation['name'], 'changed' => $relation['changed']->format('U'), 'members' => implode("\n", (array)$relation['members']), ]; } - $now = new DateTime('now', new DateTimeZone('UTC')); - - $this->relations_state_set($device_key, $folderid, $now, $data); + $this->relations_state_set($device_key, $folderid, $this->syncTimeStamp, $data); } // in mail mode return only message URIs if ($type == self::MODEL_EMAIL) { // 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; } /** * Subscribe default set of folders on device registration */ protected function device_init_subscriptions($deviceid) { // INBOX always exists $this->folder_set('INBOX', $deviceid, 1); $supported_types = [ 'mail.drafts', 'mail.wastebasket', 'mail.sentitems', 'mail.outbox', 'event.default', 'contact.default', 'note.default', 'task.default', 'event', 'contact', 'note', 'task', 'event.confidential', 'event.private', 'task.confidential', 'task.private', ]; $rcube = rcube::get_instance(); $config = $rcube->config; $mode = (int) $config->get('activesync_init_subscriptions'); $folders = []; // Subscribe to default folders $foldertypes = kolab_storage::folders_typedata(); if (!empty($foldertypes)) { $_foldertypes = array_intersect($foldertypes, $supported_types); // get default folders foreach ($_foldertypes as $folder => $type) { // only personal folders if ($this->storage->folder_namespace($folder) == 'personal') { $flag = preg_match('/^(event|task)/', $type) ? 2 : 1; $this->folder_set($folder, $deviceid, $flag); $folders[] = $folder; } } } // we're in default mode, exit if (!$mode) { return; } // below we support additionally all mail folders $supported_types[] = 'mail'; $supported_types[] = 'mail.junkemail'; // get configured special folders $special_folders = []; $map = [ 'drafts' => 'mail.drafts', 'junk' => 'mail.junkemail', 'sent' => 'mail.sentitems', 'trash' => 'mail.wastebasket', ]; foreach ($map as $folder => $type) { if ($folder = $config->get($folder . '_mbox')) { $special_folders[$folder] = $type; } } // get folders list(s) if (($mode & self::INIT_ALL_PERSONAL) || ($mode & self::INIT_ALL_OTHER) || ($mode & self::INIT_ALL_SHARED)) { $all_folders = $this->storage->list_folders(); if (($mode & self::INIT_SUB_PERSONAL) || ($mode & self::INIT_SUB_OTHER) || ($mode & self::INIT_SUB_SHARED)) { $subscribed_folders = $this->storage->list_folders_subscribed(); } } else { $all_folders = $this->storage->list_folders_subscribed(); } foreach ($all_folders as $folder) { // folder already subscribed if (in_array($folder, $folders)) { continue; } $type = ($foldertypes[$folder] ?? null) ?: 'mail'; if ($type == 'mail' && isset($special_folders[$folder])) { $type = $special_folders[$folder]; } if (!in_array($type, $supported_types)) { continue; } $ns = strtoupper($this->storage->folder_namespace($folder)); // subscribe the folder according to configured mode // and folder namespace/subscription status if (($mode & constant("self::INIT_ALL_{$ns}")) || (($mode & constant("self::INIT_SUB_{$ns}")) && (!isset($subscribed_folders) || in_array($folder, $subscribed_folders))) ) { $flag = preg_match('/^(event|task)/', $type) ? 2 : 1; $this->folder_set($folder, $deviceid, $flag); } } } /** * Helper method to decode saved IMAP metadata */ protected function unserialize_metadata($str) { if (!empty($str)) { $data = json_decode($str, true); return $data; } return null; } /** * Helper method to encode IMAP metadata for saving */ protected function serialize_metadata($data) { if (!empty($data) && is_array($data)) { $data = json_encode($data); return $data; } return null; } /** * Returns Kolab folder type for specified ActiveSync type ID */ protected static function type_activesync2kolab($type) { if (!empty(self::$types[$type])) { return self::$types[$type]; } return ''; } /** * Returns ActiveSync folder type for specified Kolab type */ protected static function type_kolab2activesync($type) { $type = preg_replace('/\.(confidential|private)$/i', '', $type); if ($key = array_search($type, self::$types)) { return $key; } return key(self::$types); } /** * Returns folder data in Syncroton format */ protected function folder_data($folder, $type) { // Folder name parameters $delim = $this->storage->get_hierarchy_delimiter(); $items = explode($delim, $folder); $name = array_pop($items); // Folder UID $folder_id = $this->folder_id($folder, $type); // Folder type if (strcasecmp($folder, 'INBOX') === 0) { // INBOX is always inbox, prevent from issues related with a change of // folder type annotation (it can be initially unset). $as_type = 2; } else { $as_type = self::type_kolab2activesync($type); // fix type, if there's no type annotation it's detected as UNKNOWN we'll use 'mail' (12) if ($as_type == 1) { $as_type = 12; } } // Syncroton folder data array return [ 'serverId' => $folder_id, 'parentId' => count($items) ? $this->folder_id(implode($delim, $items), $type) : 0, 'displayName' => rcube_charset::convert($name, 'UTF7-IMAP', kolab_sync::CHARSET), 'type' => $as_type, // for internal use 'imap_name' => $folder, ]; } /** * Builds folder ID based on folder name */ protected function folder_id($name, $type = null) { // ActiveSync expects folder identifiers to be max.64 characters // So we can't use just folder name $name = (string) $name; if ($name === '') { return null; } if (isset($this->folder_uids[$name])) { return $this->folder_uids[$name]; } /* @TODO: For now uniqueid annotation doesn't work, we will create UIDs by ourselves. There's one inconvenience of this solution: folder name/type change would be handled in ActiveSync as delete + create. // get folders unique identifier $folderdata = $this->storage->get_metadata($name, self::UID_KEY); if ($folderdata && !empty($folderdata[$name])) { $uid = $folderdata[$name][self::UID_KEY]; return $this->folder_uids[$name] = $uid; } */ if (strcasecmp($name, 'INBOX') === 0) { // INBOX is always inbox, prevent from issues related with a change of // folder type annotation (it can be initially unset). $type = 'mail.inbox'; } else { if ($type === null) { $type = kolab_storage::folder_type($name); } if ($type != null) { $type = preg_replace('/\.(confidential|private)$/i', '', $type); } } // Add type to folder UID hash, so type change can be detected by Syncroton $uid = $name . '!!' . $type; $uid = md5($uid); return $this->folder_uids[$name] = $uid; } /** * Returns IMAP folder name * * @param string $id Folder identifier * @param string $deviceid Device dentifier * * @return string|null Folder name (UTF7-IMAP) */ public function folder_id2name($id, $deviceid) { // check in cache first if (!empty($this->folder_uids)) { if (($name = array_search($id, $this->folder_uids)) !== false) { return $name; } } /* @TODO: see folder_id() // get folders unique identifier $folderdata = $this->storage->get_metadata('*', self::UID_KEY); foreach ((array)$folderdata as $folder => $data) { if (!empty($data[self::UID_KEY])) { $uid = $data[self::UID_KEY]; $this->folder_uids[$folder] = $uid; if ($uid == $id) { $name = $folder; } } } */ // get all folders of specified type $folderdata = $this->folder_meta(); if (!is_array($folderdata) || empty($id)) { return null; } $name = null; // check if folders are "subscribed" for activesync foreach ($folderdata as $folder => $meta) { if (empty($meta['FOLDER']) || empty($meta['FOLDER'][$deviceid]) || empty($meta['FOLDER'][$deviceid]['S']) ) { continue; } if ($uid = $this->folder_id($folder)) { $this->folder_uids[$folder] = $uid; } if ($uid === $id) { $name = $folder; } } return $name; } /** * Set state of relation objects at specified point in time */ public function relations_state_set($device_key, $folderid, $synctime, $relations) { $synctime = $synctime->format('Y-m-d H:i:s'); - $rcube = rcube::get_instance(); - $db = $rcube->get_dbh(); - $old_data = $this->relations[$folderid][$synctime] ?? null; - if (empty($old_data)) { + // Protect against inserting the same values twice (this code can be executed twice in the same request) + if (empty($this->relations[$folderid][$synctime])) { + $rcube = rcube::get_instance(); + $db = $rcube->get_dbh(); $this->relations[$folderid][$synctime] = $relations; $data = rcube_charset::clean(json_encode($relations)); $result = $db->query( "INSERT INTO `syncroton_relations_state`" . " (`device_id`, `folder_id`, `synctime`, `data`)" . " VALUES (?, ?, ?, ?)", $device_key, $folderid, $synctime, $data ); if ($err = $db->is_error($result)) { throw new Exception("Failed to save relation: {$err}"); } + } } /** * Get state of relation objects at specified point in time */ protected function relations_state_get($device_key, $folderid, $synctime) { $synctime = $synctime->format('Y-m-d H:i:s'); if (empty($this->relations[$folderid][$synctime])) { $this->relations[$folderid] = []; $rcube = rcube::get_instance(); $db = $rcube->get_dbh(); $db->limitquery( "SELECT `data`, `synctime` FROM `syncroton_relations_state`" . " WHERE `device_id` = ? AND `folder_id` = ? AND `synctime` <= ?" . " ORDER BY `synctime` DESC", 0, 1, $device_key, $folderid, $synctime ); if ($row = $db->fetch_assoc()) { // Don't use $row['synctime'] for the internal cache. // The synctime of the found row is usually earlier than the requested synctime. // Note: We use internal cache because there's a call to both hasChanges() and // getChangedEntries() in Sync. It's needed until we add some caching on a higher level. $this->relations[$folderid][$synctime] = json_decode($row['data'], true); - // Cleanup: remove all records except the current one + // Cleanup: remove all records older than the current one $db->query( "DELETE FROM `syncroton_relations_state`" . " WHERE `device_id` = ? AND `folder_id` = ? AND `synctime` <> ?", $device_key, $folderid, - $row['synctime'] + $synctime ); } } return $this->relations[$folderid][$synctime] ?? null; } /** * Return last storage error */ public static function last_error() { return kolab_storage::$last_error; } /** * Compares two arrays * * @param array $array1 * @param array $array2 * * @return bool True if arrays differs, False otherwise */ protected static function data_array_diff($array1, $array2) { if (!is_array($array1) || !is_array($array2)) { return $array1 != $array2; } if (count($array1) != count($array2)) { return true; } foreach ($array1 as $key => $val) { if (!array_key_exists($key, $array2)) { return true; } if ($val !== $array2[$key]) { return true; } } return false; } }