Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F120839593
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
134 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/lib/kolab_sync_backend_folder.php b/lib/kolab_sync_backend_folder.php
index 2339797..e894d0a 100644
--- a/lib/kolab_sync_backend_folder.php
+++ b/lib/kolab_sync_backend_folder.php
@@ -1,222 +1,226 @@
<?php
/*
+--------------------------------------------------------------------------+
| Kolab Sync (ActiveSync for Kolab) |
| |
| Copyright (C) 2011-2012, Kolab Systems AG <contact@kolabsys.com> |
| |
| 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 <http://www.gnu.org/licenses/> |
+--------------------------------------------------------------------------+
| Author: Aleksander Machniak <machniak@kolabsys.com> |
+--------------------------------------------------------------------------+
*/
/**
* 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
* @param int $syncKey Sync key
*
* @return array List of object identifiers
*/
public function getFolderState($deviceid, $class, $syncKey = null)
{
$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);
if ($syncKey) {
$where[] = $this->db->quote_identifier('creation_synckey') . ' < ' . $this->db->quote($syncKey + 1);
}
$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['resync'])) {
throw new Syncroton_Exception_NotFound("Folder $folderid not found because of resync");
}
if (empty($folder)) {
throw new Syncroton_Exception_NotFound("Folder $folderid not found");
}
return $this->get_object($folder);
}
/**
* Check if the folder already exists
*
* @param Syncroton_Model_Device|string $deviceid Device object or identifier
* @param string $folderid Folder identifier
*
* @return bool true if it exists
*/
public function exists($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 1 FROM `' . $this->table_name . '` WHERE ' . implode(' AND ', $where));
$folder = $this->db->fetch_assoc($select);
return !empty($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
+ // Reset imap cache, metadata cache and the folder list cache so we work with up-to-date folders lists
rcube::get_instance()->get_storage()->clear_cache('mailboxes', true);
+ kolab_sync::storage()->reset();
+ foreach ($folder_classes as $class) {
+ Syncroton_Data_Factory::factory($class, $device, $timestamp)->clearCache();
+ }
// 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;
}
}
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);
}
}
}
diff --git a/lib/kolab_sync_data.php b/lib/kolab_sync_data.php
index ffb7de5..65aa601 100644
--- a/lib/kolab_sync_data.php
+++ b/lib/kolab_sync_data.php
@@ -1,1563 +1,1571 @@
<?php
/*
+--------------------------------------------------------------------------+
| Kolab Sync (ActiveSync for Kolab) |
| |
| Copyright (C) 2011-2012, Kolab Systems AG <contact@kolabsys.com> |
| |
| 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 <http://www.gnu.org/licenses/> |
+--------------------------------------------------------------------------+
| Author: Aleksander Machniak <machniak@kolabsys.com> |
+--------------------------------------------------------------------------+
*/
/**
* Base class for Syncroton data backends
*/
abstract class kolab_sync_data implements Syncroton_Data_IData
{
/**
* ActiveSync protocol version
*
* @var float
*/
protected $asversion = 0;
/**
* The storage backend
*
* @var kolab_sync_storage
*/
protected $backend;
/**
* information about the current device
*
* @var Syncroton_Model_IDevice
*/
protected $device;
/**
* timestamp to use for all sync requests
*
* @var DateTime
*/
protected $syncTimeStamp;
/**
* name of model to use
*
* @var string
*/
protected $modelName;
/**
* type of the default folder
*
* @var int
*/
protected $defaultFolderType;
/**
* default container for new entries
*
* @var string
*/
protected $defaultFolder;
/**
* default root folder
*
* @var string
*/
protected $defaultRootFolder;
/**
* type of user created folders
*
* @var int
*/
protected $folderType;
/**
* Internal cache for storage folders list
*
* @var array
*/
protected $folders = [];
/**
* Logger instance.
*
* @var kolab_sync_logger
*/
protected $logger;
/**
* Timezone
*
* @var string
*/
protected $timezone;
/**
* List of device types with multiple folders support
*
* @var array
*/
protected $ext_devices = [
'iphone',
'ipad',
'thundertine',
'windowsphone',
'wp',
'wp8',
'playbook',
];
protected $lastsync_folder = null;
protected $lastsync_time = null;
public const RESULT_OBJECT = 0;
public const RESULT_UID = 1;
public const RESULT_COUNT = 2;
/**
* Recurrence types
*/
public const RECUR_TYPE_DAILY = 0; // Recurs daily.
public const RECUR_TYPE_WEEKLY = 1; // Recurs weekly
public const RECUR_TYPE_MONTHLY = 2; // Recurs monthly
public const RECUR_TYPE_MONTHLY_DAYN = 3; // Recurs monthly on the nth day
public const RECUR_TYPE_YEARLY = 5; // Recurs yearly
public const RECUR_TYPE_YEARLY_DAYN = 6; // Recurs yearly on the nth day
/**
* Day of week constants
*/
public const RECUR_DOW_SUNDAY = 1;
public const RECUR_DOW_MONDAY = 2;
public const RECUR_DOW_TUESDAY = 4;
public const RECUR_DOW_WEDNESDAY = 8;
public const RECUR_DOW_THURSDAY = 16;
public const RECUR_DOW_FRIDAY = 32;
public const RECUR_DOW_SATURDAY = 64;
public const RECUR_DOW_LAST = 127; // The last day of the month. Used as a special value in monthly or yearly recurrences.
/**
* Mapping of recurrence types
*
* @var array
*/
protected $recurTypeMap = [
self::RECUR_TYPE_DAILY => 'DAILY',
self::RECUR_TYPE_WEEKLY => 'WEEKLY',
self::RECUR_TYPE_MONTHLY => 'MONTHLY',
self::RECUR_TYPE_MONTHLY_DAYN => 'MONTHLY',
self::RECUR_TYPE_YEARLY => 'YEARLY',
self::RECUR_TYPE_YEARLY_DAYN => 'YEARLY',
];
/**
* Mapping of weekdays
* NOTE: ActiveSync uses a bitmask
*
* @var array
*/
protected $recurDayMap = [
'SU' => self::RECUR_DOW_SUNDAY,
'MO' => self::RECUR_DOW_MONDAY,
'TU' => self::RECUR_DOW_TUESDAY,
'WE' => self::RECUR_DOW_WEDNESDAY,
'TH' => self::RECUR_DOW_THURSDAY,
'FR' => self::RECUR_DOW_FRIDAY,
'SA' => self::RECUR_DOW_SATURDAY,
];
/**
* the constructor
*
* @param Syncroton_Model_IDevice $device
* @param DateTime $syncTimeStamp
*/
public function __construct(Syncroton_Model_IDevice $device, DateTime $syncTimeStamp)
{
$this->backend = kolab_sync::storage();
$this->device = $device;
$this->asversion = floatval($device->acsversion);
$this->syncTimeStamp = $this->backend->syncTimeStamp = $syncTimeStamp;
$this->logger = Syncroton_Registry::get(Syncroton_Registry::LOGGERBACKEND);
$this->defaultRootFolder = $this->defaultFolder . '::Syncroton';
// set internal timezone of kolab_format to user timezone
try {
$this->timezone = rcube::get_instance()->config->get('timezone', 'GMT');
kolab_format::$timezone = new DateTimeZone($this->timezone);
} catch (Exception $e) {
//rcube::raise_error($e, true);
$this->timezone = 'GMT';
kolab_format::$timezone = new DateTimeZone('GMT');
}
}
/**
* return list of supported folders for this backend
*
* @return array
*/
public function getAllFolders()
{
$list = [];
// device supports multiple folders ?
if ($this->isMultiFolder()) {
// get the folders the user has access to
$list = $this->listFolders();
} elseif ($default = $this->getDefaultFolder()) {
$list = [$default['serverId'] => $default];
}
// getAllFolders() is called only in FolderSync
// throw Syncroton_Exception_Status_FolderSync exception
if (!is_array($list)) {
throw new Syncroton_Exception_Status_FolderSync(Syncroton_Exception_Status_FolderSync::FOLDER_SERVER_ERROR);
}
foreach ($list as $idx => $folder) {
$list[$idx] = new Syncroton_Model_Folder($folder);
}
return $list;
}
/**
* Retrieve folders which were modified since last sync
*
* @param DateTime $startTimeStamp
* @param DateTime $endTimeStamp
*
* @return array List of folders
*/
public function getChangedFolders(DateTime $startTimeStamp, DateTime $endTimeStamp)
{
// FIXME/TODO: Can we get mtime of a DAV folder?
// Without this, we have a problem if folder ID does not change on rename
return [];
}
/**
* Returns true if the device supports multiple folders or it was configured so
*/
protected function isMultiFolder()
{
$config = rcube::get_instance()->config;
$blacklist = $config->get('activesync_multifolder_blacklist_' . $this->modelName);
if (!is_array($blacklist)) {
$blacklist = $config->get('activesync_multifolder_blacklist');
}
if (is_array($blacklist)) {
return !$this->deviceTypeFilter($blacklist);
}
return in_array_nocase($this->device->devicetype, $this->ext_devices);
}
/**
* Returns default folder for current class type.
*/
protected function getDefaultFolder()
{
// Check if there's any folder configured for sync
$folders = $this->listFolders();
if (empty($folders)) {
return $folders;
}
foreach ($folders as $folder) {
if ($folder['type'] == $this->defaultFolderType) {
$default = $folder;
break;
}
}
// Return first on the list if there's no default
if (empty($default)) {
$default = array_first($folders);
// make sure the type is default here
$default['type'] = $this->defaultFolderType;
}
// Remember real folder ID and set ID/name to root folder
$default['realid'] = $default['serverId'];
$default['serverId'] = $this->defaultRootFolder;
$default['displayName'] = $this->defaultFolder;
return $default;
}
/**
* Creates a folder
*/
public function createFolder(Syncroton_Model_IFolder $folder)
{
$result = $this->backend->folder_create($folder->displayName, $folder->type, $this->device->deviceid, $folder->parentId);
if ($result) {
$folder->serverId = $result;
return $folder;
}
// Note: Looks like Outlook 2013 ignores any errors on FolderCreate command
throw new Syncroton_Exception_Status_FolderCreate(Syncroton_Exception_Status_FolderCreate::UNKNOWN_ERROR);
}
/**
* Updates a folder
*/
public function updateFolder(Syncroton_Model_IFolder $folder)
{
$result = $this->backend->folder_rename($folder->serverId, $this->device->deviceid, $folder->displayName, $folder->parentId);
if ($result) {
return $folder;
}
// @TODO: throw exception
}
/**
* Deletes a folder
*/
public function deleteFolder($folder)
{
if ($folder instanceof Syncroton_Model_IFolder) {
$folder = $folder->serverId;
}
// @TODO: throw exception
return $this->backend->folder_delete($folder, $this->device->deviceid);
}
/**
* Empty folder (remove all entries and optionally subfolders)
*
* @param string $folderid Folder identifier
* @param array $options Options
*/
public function emptyFolderContents($folderid, $options)
{
// ActiveSync spec.: Clients use EmptyFolderContents to empty the Deleted Items folder.
// The client can clear out all items in the Deleted Items folder when the user runs out of storage quota
// (indicated by the return of an MailboxQuotaExceeded (113) status code from the server.
// FIXME: Does that mean we don't need this to work on any other folder?
// TODO: Respond with MailboxQuotaExceeded status. Where exactly?
foreach ($this->extractFolders($folderid) as $folderid) {
if (!$this->backend->folder_empty($folderid, $this->device->deviceid, !empty($options['deleteSubFolders']))) {
throw new Syncroton_Exception_Status_ItemOperations(Syncroton_Exception_Status_ItemOperations::ITEM_SERVER_ERROR);
}
}
}
/**
* Moves object into another location (folder)
*
* @param string $srcFolderId Source folder identifier
* @param string $serverId Object identifier
* @param string $dstFolderId Destination folder identifier
*
* @throws Syncroton_Exception_Status
* @return string New object identifier
*/
public function moveItem($srcFolderId, $serverId, $dstFolderId)
{
// TODO: Optimize, we just need to find the folder ID and UID, we do not need to "fetch" it.
$item = $this->getObject($srcFolderId, $serverId);
if (!$item) {
throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_SOURCE);
}
$uid = $this->backend->moveItem($item['folderId'], $this->device->deviceid, $this->modelName, $item['uid'], $dstFolderId);
return $this->serverId($uid, $dstFolderId);
}
/**
* Add entry
*
* @param string $folderId Folder identifier
* @param Syncroton_Model_IEntry $entry Entry object
*
* @return string ID of the created entry
*/
public function createEntry($folderId, Syncroton_Model_IEntry $entry)
{
$entry = $this->toKolab($entry, $folderId);
if ($folderId == $this->defaultRootFolder) {
$default = $this->getDefaultFolder();
if (!is_array($default)) {
throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR);
}
$folderId = $default['realid'] ?? $default['serverId'];
}
$uid = $this->backend->createItem($folderId, $this->device->deviceid, $this->modelName, $entry);
if (empty($uid)) {
throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR);
}
return $this->serverId($uid, $folderId);
}
/**
* update existing entry
*
* @param string $folderId
* @param string $serverId
* @param Syncroton_Model_IEntry $entry
*
* @return string ID of the updated entry
*/
public function updateEntry($folderId, $serverId, Syncroton_Model_IEntry $entry)
{
$oldEntry = $this->getObject($folderId, $serverId);
if (empty($oldEntry)) {
throw new Syncroton_Exception_NotFound('entry not found');
}
$entry = $this->toKolab($entry, $folderId, $oldEntry);
$uid = $this->backend->updateItem($oldEntry['folderId'], $this->device->deviceid, $this->modelName, $oldEntry['uid'], $entry);
if (empty($uid)) {
throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR);
}
return $this->serverId($uid, $oldEntry['folderId']);
}
/**
* Delete entry
*
* @param string $folderId
* @param string $serverId
* @param ?Syncroton_Model_SyncCollection $collectionData
*/
public function deleteEntry($folderId, $serverId, $collectionData = null)
{
// TODO: Optimize, we just need to find the folder ID and UID, we do not need to "fetch" it.
$object = $this->getObject($folderId, $serverId);
if ($object) {
$deleted = $this->backend->deleteItem($object['folderId'], $this->device->deviceid, $this->modelName, $object['uid']);
if (!$deleted) {
throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR);
}
}
}
/**
* Get attachment data from the server.
*
* @param string $fileReference
*
* @return Syncroton_Model_FileReference
*/
public function getFileReference($fileReference)
{
// to be implemented by Email data class
throw new Syncroton_Exception_NotFound('File references not supported');
}
/**
* Search for existing entries
*
* @param string $folderid Folder identifier
* @param array $filter Search filter
* @param int $result_type Type of the result (see RESULT_* constants)
*
* @return array|int Search result as count or array of uids/objects
*/
protected function searchEntries($folderid, $filter = [], $result_type = self::RESULT_UID, $extraData = null)
{
$result = $result_type == self::RESULT_COUNT ? 0 : [];
$ts = time();
$force = $this->lastsync_folder != $folderid || $this->lastsync_time <= $ts - Syncroton_Registry::getPingTimeout();
$found = false;
foreach ($this->extractFolders($folderid) as $fid) {
$search = $this->backend->searchEntries($fid, $this->device->deviceid, $this->device->id, $this->modelName, $filter, $result_type, $force, $extraData);
$found = true;
switch ($result_type) {
case self::RESULT_COUNT:
$result += $search;
break;
case self::RESULT_UID:
foreach ($search as $idx => $uid) {
$search[$idx] = $this->serverId($uid, $fid);
}
$result = array_unique(array_merge($result, $search));
break;
}
}
if (!$found) {
throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR);
}
$this->lastsync_folder = $folderid;
$this->lastsync_time = $ts;
return $result;
}
/**
* Returns filter query array according to specified ActiveSync FilterType
*
* @param int $filter_type Filter type
*
* @return array Filter query
*/
protected function filter($filter_type = 0)
{
// overwrite by child class according to specified type
return [];
}
/**
* get all entries changed between two dates
*
* @param string $folderId
* @param Syncroton_Model_ISyncState $syncState
* @param int $filter_type
*
* @return array
*/
public function getChangedEntries($folderId, Syncroton_Model_ISyncState $syncState, $filter_type = null)
{
$start = $syncState->lastsync;
$filter = $this->filter($filter_type);
$filter[] = ['changed', '>', $start];
return $this->searchEntries($folderId, $filter, self::RESULT_UID, $syncState->extraData);
}
/**
* Get count of entries changed between two dates
*
* @param string $folderId
* @param Syncroton_Model_ISyncState $syncState
* @param int $filter_type
*
* @return int
*/
private function getChangedEntriesCount($folderId, Syncroton_Model_ISyncState $syncState, $filter_type = null)
{
$start = $syncState->lastsync;
$filter = $this->filter($filter_type);
$filter[] = ['changed', '>', $start];
return $this->searchEntries($folderId, $filter, self::RESULT_COUNT, $syncState->extraData);
}
public function getExtraData(Syncroton_Model_IFolder $folder)
{
return $this->backend->getExtraData($folder->serverId, $this->device->deviceid);
}
/**
* get id's of all entries available on the server
*
* @param string $folder_id
* @param string $filter_type
*
* @return array
*/
public function getServerEntries($folder_id, $filter_type)
{
$filter = $this->filter($filter_type);
$result = $this->searchEntries($folder_id, $filter, self::RESULT_UID);
return $result;
}
/**
* get count of all entries available on the server
*
* @param string $folder_id
* @param string $filter_type
*
* @return int
*/
public function getServerEntriesCount($folder_id, $filter_type)
{
$filter = $this->filter($filter_type);
$result = $this->searchEntries($folder_id, $filter, self::RESULT_COUNT);
return $result;
}
/**
* Returns number of changed objects in the backend folder
*
* @param Syncroton_Backend_IContent $contentBackend
* @param Syncroton_Model_IFolder $folder
* @param Syncroton_Model_ISyncState $syncState
*
* @return int
*/
public function getCountOfChanges(Syncroton_Backend_IContent $contentBackend, Syncroton_Model_IFolder $folder, Syncroton_Model_ISyncState $syncState)
{
$allClientEntries = $contentBackend->getFolderState($this->device, $folder, $syncState->counter);
$allServerEntries = $this->getServerEntries($folder->serverId, $folder->lastfiltertype);
$changedEntries = $this->getChangedEntriesCount($folder->serverId, $syncState, $folder->lastfiltertype);
$addedEntries = array_diff($allServerEntries, $allClientEntries);
$deletedEntries = array_diff($allClientEntries, $allServerEntries);
return count($addedEntries) + count($deletedEntries) + $changedEntries;
}
/**
* Returns true if any data got modified in the backend folder
*
* @param Syncroton_Backend_IContent $contentBackend
* @param Syncroton_Model_IFolder $folder
* @param Syncroton_Model_ISyncState $syncState
*
* @return bool
*/
public function hasChanges(Syncroton_Backend_IContent $contentBackend, Syncroton_Model_IFolder $folder, Syncroton_Model_ISyncState $syncState)
{
try {
if ($this->getChangedEntriesCount($folder->serverId, $syncState, $folder->lastfiltertype)) {
return true;
}
$allClientEntries = $contentBackend->getFolderState($this->device, $folder, $syncState->counter);
// @TODO: Consider looping over all folders here, not in getServerEntries() and
// getChangedEntriesCount(). This way we could break the loop and not check all folders
// or at least skip redundant cache sync of the same folder
$allServerEntries = $this->getServerEntries($folder->serverId, $folder->lastfiltertype);
$addedEntries = array_diff($allServerEntries, $allClientEntries);
$deletedEntries = array_diff($allClientEntries, $allServerEntries);
return count($addedEntries) > 0 || count($deletedEntries) > 0;
} catch (Exception $e) {
// return "no changes" if something failed
return false;
}
}
/**
* Fetches the entry from the backend
*/
protected function getObject($folderid, $entryid)
{
foreach ($this->extractFolders($folderid) as $fid) {
$crc = null;
$uid = $entryid;
// See self::serverId() for full explanation
// Use (slower) UID prefix matching...
if (preg_match('/^CRC([0-9A-Fa-f]{8})(.+)$/', $uid, $matches)) {
$crc = $matches[1];
$uid = $matches[2];
if (strlen($entryid) >= 64) {
$objects = $this->backend->getItemsByUidPrefix($fid, $this->device->deviceid, $this->modelName, $uid);
foreach ($objects as $object) {
if (($object['uid'] === $uid || strpos($object['uid'], $uid) === 0)
&& $crc == $this->objectCRC($object['uid'], $fid)
) {
$object['folderId'] = $fid;
return $object;
}
}
continue;
}
}
// Or (faster) strict UID matching...
$object = $this->backend->getItem($fid, $this->device->deviceid, $this->modelName, $uid);
if (!empty($object) && ($crc === null || $crc == $this->objectCRC($uid, $fid))) {
$object['folderId'] = $fid;
return $object;
}
}
}
/**
* Returns internal folder IDs
*
* @param string $folderid Folder identifier
*
* @return array List of folder identifiers
*/
protected function extractFolders($folderid)
{
if ($folderid instanceof Syncroton_Model_IFolder) {
$folderid = $folderid->serverId;
}
if ($folderid === $this->defaultRootFolder) {
$folders = $this->listFolders();
if (!is_array($folders)) {
throw new Syncroton_Exception_NotFound('Folder not found');
}
$folders = array_keys($folders);
} else {
$folders = [$folderid];
}
return $folders;
}
+ /**
+ * Clear the internal folder list cache
+ */
+ public function clearCache()
+ {
+ $this->folders = [];
+ }
+
/**
* List of all IMAP folders (or subtree)
*
* @param string $parentid Parent folder identifier
*
* @return array List of folder identifiers
*/
protected function listFolders($parentid = null)
{
if (empty($this->folders)) {
$this->folders = $this->backend->folders_list(
$this->device->deviceid,
$this->modelName,
$this->isMultiFolder()
);
}
if ($parentid === null || !is_array($this->folders)) {
return $this->folders;
}
$folders = [];
$parents = [$parentid];
foreach ($this->folders as $folder_id => $folder) {
if ($folder['parentId'] && in_array($folder['parentId'], $parents)) {
$folders[$folder_id] = $folder;
$parents[] = $folder_id;
}
}
return $folders;
}
/**
* Returns ActiveSync settings of specified folder
*
* @param string $folderid Folder identifier
*
* @return array Folder settings
*/
protected function getFolderConfig($folderid)
{
if ($folderid == $this->defaultRootFolder) {
$default = $this->getDefaultFolder();
if (!is_array($default)) {
return [];
}
$folderid = $default['realid'] ?? $default['serverId'];
}
return $this->backend->getFolderConfig($folderid, $this->device->deviceid, $this->modelName);
}
/**
* Convert contact from xml to kolab format
*
* @param mixed $data Contact data
* @param string $folderId Folder identifier
* @param array $entry Old Contact data for merge
*
* @return array
*/
abstract public function toKolab($data, $folderId, $entry = null);
/**
* Extracts data from kolab data array
*/
protected function getKolabDataItem($data, $name)
{
$name_items = explode('.', $name);
$count = count($name_items);
// multi-level array (e.g. address, phone)
if ($count == 3) {
$name = $name_items[0];
$type = $name_items[1];
$key_name = $name_items[2];
if (!empty($data[$name]) && is_array($data[$name])) {
foreach ($data[$name] as $element) {
if ($element['type'] == $type) {
return $element[$key_name];
}
}
}
return null;
}
// custom properties
if ($count == 2 && $name_items[0] == 'x-custom') {
$value = null;
if (!empty($data['x-custom']) && is_array($data['x-custom'])) {
foreach ($data['x-custom'] as $val) {
if (is_array($val) && $val[0] == $name_items[1]) {
$value = $val[1];
break;
}
}
}
return $value;
}
$name_items = explode(':', $name);
$name = $name_items[0];
if (empty($data[$name])) {
return null;
}
// simple array (e.g. email)
if (count($name_items) == 2) {
return $data[$name][$name_items[1]];
}
return $data[$name];
}
/**
* Saves data in kolab data array
*/
protected function setKolabDataItem(&$data, $name, $value)
{
if (empty($value)) {
return $this->unsetKolabDataItem($data, $name);
}
$name_items = explode('.', $name);
$count = count($name_items);
// multi-level array (e.g. address, phone)
if ($count == 3) {
$name = $name_items[0];
$type = $name_items[1];
$key_name = $name_items[2];
if (!isset($data[$name])) {
$data[$name] = [];
}
foreach ($data[$name] as $idx => $element) {
if ($element['type'] == $type) {
$found = $idx;
break;
}
}
if (!isset($found)) {
$data[$name] = array_values($data[$name]);
$found = count($data[$name]);
$data[$name][$found] = ['type' => $type];
}
$data[$name][$found][$key_name] = $value;
return;
}
// custom properties
if ($count == 2 && $name_items[0] == 'x-custom') {
$data['x-custom'] = isset($data['x-custom']) ? ((array) $data['x-custom']) : [];
foreach ($data['x-custom'] as $idx => $val) {
if (is_array($val) && $val[0] == $name_items[1]) {
$data['x-custom'][$idx][1] = $value;
return;
}
}
$data['x-custom'][] = [$name_items[1], $value];
return;
}
$name_items = explode(':', $name);
$name = $name_items[0];
// simple array (e.g. email)
if (count($name_items) == 2) {
$data[$name][$name_items[1]] = $value;
return;
}
$data[$name] = $value;
}
/**
* Unsets data item in kolab data array
*/
protected function unsetKolabDataItem(&$data, $name)
{
$name_items = explode('.', $name);
$count = count($name_items);
// multi-level array (e.g. address, phone)
if ($count == 3) {
$name = $name_items[0];
$type = $name_items[1];
$key_name = $name_items[2];
if (!isset($data[$name])) {
return;
}
foreach ($data[$name] as $idx => $element) {
if ($element['type'] == $type) {
$found = $idx;
break;
}
}
if (!isset($found)) {
return;
}
unset($data[$name][$found][$key_name]);
// if there's only one element and it's 'type', remove it
if (count($data[$name][$found]) == 1 && isset($data[$name][$found]['type'])) {
unset($data[$name][$found]['type']);
}
if (empty($data[$name][$found])) {
unset($data[$name][$found]);
}
if (empty($data[$name])) {
unset($data[$name]);
}
return;
}
// custom properties
if ($count == 2 && $name_items[0] == 'x-custom') {
foreach ((array) $data['x-custom'] as $idx => $val) {
if (is_array($val) && $val[0] == $name_items[1]) {
unset($data['x-custom'][$idx]);
}
}
}
$name_items = explode(':', $name);
$name = $name_items[0];
// simple array (e.g. email)
if (count($name_items) == 2) {
unset($data[$name][$name_items[1]]);
if (empty($data[$name])) {
unset($data[$name]);
}
return;
}
unset($data[$name]);
}
/**
* Setter for Body attribute according to client version
*
* @param string $value Body
* @param array $params Body parameters
*
* @reurn Syncroton_Model_EmailBody Body element
*/
protected function setBody($value, $params = [])
{
if (empty($value) && empty($params)) {
return;
}
// Old protocol version doesn't support AirSyncBase:Body, it's eg. WindowsCE
if ($this->asversion < 12) {
return;
}
if (!empty($value)) {
// cast to string to workaround issue described in Bug #1635
$params['data'] = (string) $value;
}
if (!isset($params['type'])) {
$params['type'] = Syncroton_Model_EmailBody::TYPE_PLAINTEXT;
}
return new Syncroton_Model_EmailBody($params);
}
/**
* Getter for Body attribute value according to client version
*
* @param mixed $body Body element
* @param int $type Result data type (to which the body will be converted, if specified).
* One or array of Syncroton_Model_EmailBody constants.
*
* @return string|null Body value
*/
protected function getBody($body, $type = null)
{
$data = null;
if ($body && $body->data) {
$data = $body->data;
}
if (!$data || empty($type)) {
return null;
}
$type = (array) $type;
// Convert to specified type
if (!in_array($body->type, $type)) {
$converter = new kolab_sync_body_converter($data, $body->type);
$data = $converter->convert($type[0]);
}
return $data;
}
/**
* Converts text (plain or html) into ActiveSync Body element.
* Takes bodyPreferences into account and detects if the text is plain or html.
*/
protected function body_from_kolab($body, $collection)
{
if (empty($body)) {
return;
}
$opts = $collection->options;
$prefs = $opts['bodyPreferences'];
$html_type = Syncroton_Command_Sync::BODY_TYPE_HTML;
$type = Syncroton_Command_Sync::BODY_TYPE_PLAIN_TEXT;
$params = [];
// HTML? check for opening and closing <html> or <body> tags
$is_html = preg_match('/<(html|body)(\s+[a-z]|>)/', $body, $m) && strpos($body, '</' . $m[1] . '>') > 0;
// here we assume that all devices support plain text
if ($is_html) {
// device supports HTML...
if (!empty($prefs[$html_type])) {
$type = $html_type;
}
// ...else convert to plain text
else {
$txt = new rcube_html2text($body, false, true);
$body = $txt->get_text();
}
}
// strip out any non utf-8 characters
$body = rcube_charset::clean($body);
$real_length = $body_length = strlen($body);
// truncate the body if needed
if (isset($prefs[$type]['truncationSize']) && ($truncateAt = $prefs[$type]['truncationSize']) && $body_length > $truncateAt) {
$body = mb_strcut($body, 0, $truncateAt);
$body_length = strlen($body);
$params['truncated'] = 1;
$params['estimatedDataSize'] = $real_length;
}
$params['type'] = $type;
return $this->setBody($body, $params);
}
/**
* Converts PHP DateTime, date (YYYY-MM-DD) or unixtimestamp into PHP DateTime in UTC
*
* @param DateTime|int|string $date Unix timestamp, date (YYYY-MM-DD) or PHP DateTime object
*
* @return DateTime|null Datetime object
*/
protected static function date_from_kolab($date)
{
if (!empty($date)) {
if (is_numeric($date)) {
$date = new DateTime('@' . $date);
} elseif (is_string($date)) {
$date = new DateTime($date, new DateTimeZone('UTC'));
} elseif ($date instanceof DateTime) {
$date = clone $date;
$tz = $date->getTimezone();
$tz_name = $tz->getName();
// convert to UTC if needed
if ($tz_name != 'UTC') {
$utc = new DateTimeZone('UTC');
// safe dateonly object conversion to UTC
// note: _dateonly flag is set by libkolab e.g. for birthdays
if (!empty($date->_dateonly)) {
// avoid time change
$date = new DateTime($date->format('Y-m-d'), $utc);
// set time to noon to avoid timezone troubles
$date->setTime(12, 0, 0);
} else {
$date->setTimezone($utc);
}
}
} else {
return null; // invalid input
}
return $date;
}
return null;
}
/**
* Convert Kolab event/task recurrence into ActiveSync
*/
protected function recurrence_from_kolab($collection, $data, &$result, $type = 'Event')
{
if (empty($data['recurrence']) || !empty($data['recurrence_date']) || empty($data['recurrence']['FREQ'])) {
return;
}
$recurrence = [];
$r = $data['recurrence'];
// required fields
switch ($r['FREQ']) {
case 'DAILY':
$recurrence['type'] = self::RECUR_TYPE_DAILY;
break;
case 'WEEKLY':
$day = $r['BYDAY'] ?? 0;
if (!$day && (!empty($data['_start']) || !empty($data['start']))) {
$days = ['', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA','SU'];
$start = $data['_start'] ?? $data['start'];
$day = $days[$start->format('N')];
}
$recurrence['type'] = self::RECUR_TYPE_WEEKLY;
$recurrence['dayOfWeek'] = $this->day2bitmask($day);
break;
case 'MONTHLY':
if (!empty($r['BYMONTHDAY'])) {
// @TODO: ActiveSync doesn't support multi-valued month days,
// should we replicate the recurrence element for each day of month?
[$month_day, ] = explode(',', $r['BYMONTHDAY']);
$recurrence['type'] = self::RECUR_TYPE_MONTHLY;
$recurrence['dayOfMonth'] = $month_day;
} elseif (!empty($r['BYDAY'])) {
$week = (int) substr($r['BYDAY'], 0, -2);
$week = ($week == -1) ? 5 : $week;
$day = substr($r['BYDAY'], -2);
$recurrence['type'] = self::RECUR_TYPE_MONTHLY_DAYN;
$recurrence['weekOfMonth'] = $week;
$recurrence['dayOfWeek'] = $this->day2bitmask($day);
} else {
return;
}
break;
case 'YEARLY':
// @TODO: ActiveSync doesn't support multi-valued months,
// should we replicate the recurrence element for each month?
[$month, ] = explode(',', $r['BYMONTH']);
if (!empty($r['BYDAY'])) {
$week = (int) substr($r['BYDAY'], 0, -2);
$week = ($week == -1) ? 5 : $week;
$day = substr($r['BYDAY'], -2);
$recurrence['type'] = self::RECUR_TYPE_YEARLY_DAYN;
$recurrence['weekOfMonth'] = $week;
$recurrence['dayOfWeek'] = $this->day2bitmask($day);
$recurrence['monthOfYear'] = $month;
} elseif (!empty($r['BYMONTHDAY'])) {
// @TODO: ActiveSync doesn't support multi-valued month days,
// should we replicate the recurrence element for each day of month?
[$month_day, ] = explode(',', $r['BYMONTHDAY']);
$recurrence['type'] = self::RECUR_TYPE_YEARLY;
$recurrence['dayOfMonth'] = $month_day;
$recurrence['monthOfYear'] = $month;
} else {
$recurrence['type'] = self::RECUR_TYPE_YEARLY;
$recurrence['monthOfYear'] = $month;
}
break;
}
// Skip all empty values (T2519)
if ($recurrence['type'] != self::RECUR_TYPE_DAILY) {
$recurrence = array_filter($recurrence);
}
// required field
$recurrence['interval'] = $r['INTERVAL'] ?: 1;
if (!empty($r['UNTIL'])) {
$recurrence['until'] = self::date_from_kolab($r['UNTIL']);
} elseif (!empty($r['COUNT'])) {
$recurrence['occurrences'] = $r['COUNT'];
}
$class = 'Syncroton_Model_' . $type . 'Recurrence';
$result['recurrence'] = new $class($recurrence);
// Tasks do not support exceptions
if ($type == 'Event') {
$result['exceptions'] = $this->exceptions_from_kolab($collection, $data);
}
}
/**
* Convert ActiveSync event/task recurrence into Kolab
*/
protected function recurrence_to_kolab($data, $folderid, $timezone = null)
{
if (!($data->recurrence instanceof Syncroton_Model_EventRecurrence)
&& !($data->recurrence instanceof Syncroton_Model_TaskRecurrence)
) {
return;
}
if (!isset($data->recurrence->type)) {
return;
}
$recurrence = $data->recurrence;
$type = $recurrence->type;
switch ($type) {
case self::RECUR_TYPE_DAILY:
break;
case self::RECUR_TYPE_WEEKLY:
$rrule['BYDAY'] = $this->bitmask2day($recurrence->dayOfWeek);
break;
case self::RECUR_TYPE_MONTHLY:
$rrule['BYMONTHDAY'] = $recurrence->dayOfMonth;
break;
case self::RECUR_TYPE_MONTHLY_DAYN:
$week = $recurrence->weekOfMonth;
$day = $recurrence->dayOfWeek;
$byDay = $week == 5 ? -1 : $week;
$byDay .= $this->bitmask2day($day);
$rrule['BYDAY'] = $byDay;
break;
case self::RECUR_TYPE_YEARLY:
$rrule['BYMONTH'] = $recurrence->monthOfYear;
$rrule['BYMONTHDAY'] = $recurrence->dayOfMonth;
break;
case self::RECUR_TYPE_YEARLY_DAYN:
$rrule['BYMONTH'] = $recurrence->monthOfYear;
$week = $recurrence->weekOfMonth;
$day = $recurrence->dayOfWeek;
$byDay = $week == 5 ? -1 : $week;
$byDay .= $this->bitmask2day($day);
$rrule['BYDAY'] = $byDay;
break;
}
$rrule['FREQ'] = $this->recurTypeMap[$type];
$rrule['INTERVAL'] = $recurrence->interval ?? 1;
if (isset($recurrence->until)) {
if ($timezone) {
$recurrence->until->setTimezone($timezone);
}
$rrule['UNTIL'] = $recurrence->until;
} elseif (!empty($recurrence->occurrences)) {
$rrule['COUNT'] = $recurrence->occurrences;
}
// recurrence exceptions (not supported by Tasks)
if ($data instanceof Syncroton_Model_Event) {
$this->exceptions_to_kolab($data, $rrule, $folderid, $timezone);
}
return $rrule;
}
/**
* Convert Kolab event recurrence exceptions into ActiveSync
*/
protected function exceptions_from_kolab($collection, $data)
{
if (empty($data['recurrence']['EXCEPTIONS']) && empty($data['recurrence']['EXDATE'])) {
return null;
}
$ex_list = [];
// exceptions (modified occurences)
if (!empty($data['recurrence']['EXCEPTIONS'])) {
foreach ((array)$data['recurrence']['EXCEPTIONS'] as $exception) {
$exception['_mailbox'] = $data['_mailbox'];
$ex = $this->getEntry($collection, $exception, true); // @phpstan-ignore-line
$date = clone ($exception['recurrence_date'] ?: $ex['startTime']);
$ex['exceptionStartTime'] = self::set_exception_time($date, $data['_start'] ?? null);
// remove fields not supported by Syncroton_Model_EventException
unset($ex['uID']);
// @TODO: 'thisandfuture=true' is not supported in Activesync
// we'd need to slit the event into two separate events
$ex_list[] = new Syncroton_Model_EventException($ex);
}
}
// exdate (deleted occurences)
if (!empty($data['recurrence']['EXDATE'])) {
foreach ((array)$data['recurrence']['EXDATE'] as $exception) {
if (!($exception instanceof DateTime)) {
continue;
}
$ex = [
'deleted' => 1,
'exceptionStartTime' => self::set_exception_time($exception, $data['_start'] ?? null),
];
$ex_list[] = new Syncroton_Model_EventException($ex);
}
}
return $ex_list;
}
/**
* Convert ActiveSync event recurrence exceptions into Kolab
*/
protected function exceptions_to_kolab($data, &$rrule, $folderid, $timezone = null)
{
$rrule['EXDATE'] = [];
$rrule['EXCEPTIONS'] = [];
// handle exceptions from recurrence
if (!empty($data->exceptions)) {
foreach ($data->exceptions as $exception) {
$date = clone $exception->exceptionStartTime;
if ($timezone) {
$date->setTimezone($timezone);
}
if ($exception->deleted) {
$date->setTime(0, 0, 0);
$rrule['EXDATE'][] = $date;
} else {
$ex = $this->toKolab($exception, $folderid, null, $timezone); // @phpstan-ignore-line
$ex['recurrence_date'] = $date;
if (!empty($data->allDayEvent)) {
$ex['allday'] = 1;
}
$rrule['EXCEPTIONS'][] = $ex;
}
}
}
if (empty($rrule['EXDATE'])) {
unset($rrule['EXDATE']);
}
if (empty($rrule['EXCEPTIONS'])) {
unset($rrule['EXCEPTIONS']);
}
}
/**
* Sets ExceptionStartTime according to occurrence date and event start time
*/
protected static function set_exception_time($exception_date, $event_start)
{
if ($exception_date && $event_start) {
$hour = $event_start->format('H');
$minute = $event_start->format('i');
$second = $event_start->format('s');
$exception_date->setTime($hour, $minute, $second);
$exception_date->_dateonly = false;
return self::date_from_kolab($exception_date);
}
}
/**
* Converts string of days (TU,TH) to bitmask used by ActiveSync
*
* @param string $days
*
* @return int
*/
protected function day2bitmask($days)
{
$days = explode(',', $days);
$result = 0;
foreach ($days as $day) {
if ($day) {
$result = $result + ($this->recurDayMap[$day] ?? 0);
}
}
return $result;
}
/**
* Convert bitmask used by ActiveSync to string of days (TU,TH)
*
* @param int $days
*
* @return string
*/
protected function bitmask2day($days)
{
$days_arr = [];
for ($bitmask = 1; $bitmask <= self::RECUR_DOW_SATURDAY; $bitmask = $bitmask << 1) {
$dayMatch = $days & $bitmask;
if ($dayMatch === $bitmask) {
$days_arr[] = array_search($bitmask, $this->recurDayMap);
}
}
$result = implode(',', $days_arr);
return $result;
}
/**
* Check if current device type string matches any of options
*/
protected function deviceTypeFilter($options)
{
foreach ($options as $option) {
if ($option[0] == '/') {
if (preg_match($option, $this->device->devicetype)) {
return true;
}
} elseif (stripos($this->device->devicetype, $option) !== false) {
return true;
}
}
return false;
}
/**
* Returns all email addresses of the current user
*/
protected function user_emails()
{
$user_emails = kolab_sync::get_instance()->user->list_emails();
$user_emails = array_map(function ($v) { return $v['email']; }, $user_emails);
return $user_emails;
}
/**
* Generate CRC-based ServerId from object UID
*/
protected function serverId($uid, $folder)
{
// When ActiveSync communicates with the client, it refers to objects with a ServerId
// We can't use object UID for ServerId because:
// - ServerId is limited to 64 chars,
// - there can be multiple calendars with a copy of the same event.
//
// The solution is to; Take the original UID, and regardless of its length, execute the following:
// - Hash the UID concatenated with the Folder ID using CRC32b,
// - Prefix the UID with 'CRC' and the hash string,
// - Tryncate the result to 64 characters.
//
// Searching for the server-side copy of the object now follows the logic;
// - If the ServerId is prefixed with 'CRC', strip off the first 11 characters
// and we search for the UID using the remainder;
// - if the UID is shorter than 53 characters, it'll be the complete UID,
// - if the UID is longer than 53 characters, it'll be the truncated UID,
// and we search for a wildcard match of <uid>*
// When multiple copies of the same event are found, the same CRC32b hash can be used
// on the events metadata (i.e. the copy's UID and Folder ID), and compared with the CRC from the ServerId.
// ServerId is max. 64 characters, below we generate a string of max. 64 chars
// Note: crc32b is always 8 characters
return 'CRC' . $this->objectCRC($uid, $folder) . substr($uid, 0, 53);
}
/**
* Calculate checksum on object UID and folder UID
*/
protected function objectCRC($uid, $folder)
{
if (!is_object($folder)) {
$folder = $this->backend->getFolder($folder, $this->device->deviceid, $this->modelName);
}
$folder_uid = $folder->get_uid();
return strtoupper(hash('crc32b', $folder_uid . $uid)); // always 8 chars
}
}
diff --git a/lib/kolab_sync_storage.php b/lib/kolab_sync_storage.php
index 0129a91..439d342 100644
--- a/lib/kolab_sync_storage.php
+++ b/lib/kolab_sync_storage.php
@@ -1,2079 +1,2080 @@
<?php
/*
+--------------------------------------------------------------------------+
| Kolab Sync (ActiveSync for Kolab) |
| |
| Copyright (C) 2011-2012, Kolab Systems AG <contact@kolabsys.com> |
| |
| 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 <http://www.gnu.org/licenses/> |
+--------------------------------------------------------------------------+
| Author: Aleksander Machniak <machniak@kolabsys.com> |
+--------------------------------------------------------------------------+
*/
/**
* 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 = [];
protected $relationSupport = [self::MODEL_TASKS, self::MODEL_NOTES, self::MODEL_EMAIL];
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 MESSAGE-ID',
'skip_deleted' => true,
'threading' => false,
]);
// Disable paging
$this->storage->set_pagesize(999999);
}
/**
* Clear internal cache state
*/
public function reset()
{
$this->folders = [];
+ $this->folder_meta = null;
}
/**
* 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);
+ $folderdata = $this->storage->get_metadata("*", self::ASYNC_KEY, [], true);
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 = in_array($type, $this->relationSupport);
// 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 = in_array($type, $this->relationSupport);
$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']) && in_array($type, $this->relationSupport)) {
$headers = $this->storage->fetch_headers($foldername, $uid, false);
if (empty($headers) || empty($headers[$uid])) {
throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR);
}
$this->setCategories($headers[$uid], $params['categories']);
}
return $uid;
}
$folder = $this->getFolder($folderid, $deviceid, $type);
$useTags = in_array($type, $this->relationSupport);
// 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 rcube_message_header|string $object UID or mail message headers
* @param array $categories Addition tag names to merge with
*
* @return array List of categories
*/
protected function getCategories($object, $categories = [])
{
if (is_object($object)) {
// support only messages with message-id
if (!($msg_id = $object->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 (in_array($type, $this->relationSupport)) {
$message->headers->folder = $foldername;
$message->headers->uid = $uid;
$message->headers->others['categories'] = $this->getCategories($message->headers);
}
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 = in_array($type, $this->relationSupport);
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'])) {
// Check if the source item actually exists. Cyrus IMAP reports
// OK on a MOVE with an invalid UID, But COPYUID will be empty.
// This way we only incur the cost of the extra check once the move fails.
if (!$this->storage->get_message_headers($uid, $src_name)) {
throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_SOURCE);
}
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 rcube_message_header|string $object UID or mail message headers
* @param array $categories List of Category names
*/
protected 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, $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 (in_array($type, $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); // @phpstan-ignore-line
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 (in_array($type, $this->relationSupport)) {
$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; // @phpstan-ignore-line
}
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']),
];
}
// If the new and the old timestamp are the same our cache breaks.
// We must preserve the previous changes, because if this function is rerun we must detect the same changes again.
$sinceFormatted = $since->format('Y-m-d H:i:s');
if ($this->syncTimeStamp->format('Y-m-d H:i:s') == $sinceFormatted) {
// Preserve the previous timestamp (relations_state_get just checks the overflow bucket first)
// FIXME: The one caveat is that we will still update the database and thus overwrite the old entry.
// That means if we rerun the same request, the changes will not be detected
// => We should not be dealing with timestamps really.
$this->relations[$folderid][$sinceFormatted . "-1"] = $this->relations[$folderid][$sinceFormatted] ?? null;
$this->relations[$folderid][$sinceFormatted] = null;
}
$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');
// Protect against inserting the same values twice (this code can be executed twice in the same request)
if (!isset($this->relations[$folderid][$synctime])) {
$rcube = rcube::get_instance();
$db = $rcube->get_dbh();
$this->relations[$folderid][$synctime] = $relations;
$data = gzdeflate(json_encode($relations));
if ($data === false) {
throw new Exception("Failed to compress relation data");
}
$result = $db->insert_or_update(
'syncroton_relations_state',
['device_id' => $device_key, 'folder_id' => $folderid, 'synctime' => $synctime],
['data'],
[$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 we had a collision before
if (isset($this->relations[$folderid][$synctime . "-1"])) {
return $this->relations[$folderid][$synctime . "-1"];
}
if (!isset($this->relations[$folderid][$synctime])) {
$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
);
$row = $db->fetch_assoc();
if (!$row) {
// Only if we don't have a timestamp that is older than synctime do we return the next best.
// We MUST return an entry if there are any, because otherwise we will keep INSERTing new entries in,
// relations_state_set
$db->limitquery(
"SELECT `data`, `synctime` FROM `syncroton_relations_state`"
. " WHERE `device_id` = ? AND `folder_id` = ?"
. " ORDER BY `synctime` ASC",
0,
1,
$device_key,
$folderid,
$synctime
);
$row = $db->fetch_assoc();
}
if ($row) {
// Don't use $row['synctime'] for the internal cache, and use $synctime instead.
// The synctime of the found row is usually earlier than the requested synctime.
// Note: We use the 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.
$data = $row['data'];
// Support data in both compressed and uncompressed format
if (strlen($data) && $data[0] != '{' && $data[0] != '[') {
$data = gzinflate($data);
}
$this->relations[$folderid][$synctime] = json_decode($data, true);
// Cleanup: remove all records older than the current one.
// We must use the row's synctime, otherwise we would delete the record we just loaded
// We must delete all entries that are before the synctime to clean up old entries,
// but we must also delete all entries that are more recent in case the sync gets rerun
// with the same timestamp (e.g. when rerunning the same sync request).
// Otherwise the number of entries will start to grow with every sync.
$db->query(
"DELETE FROM `syncroton_relations_state`"
. " WHERE `device_id` = ? AND `folder_id` = ? AND `synctime` <> ?",
$device_key,
$folderid,
$row['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;
}
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Fri, Apr 24, 2:15 PM (1 w, 3 d ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18889426
Default Alt Text
(134 KB)
Attached To
Mode
rS syncroton
Attached
Detach File
Event Timeline