diff --git a/config/config.inc.php.dist b/config/config.inc.php.dist index 6864820..2433475 100644 --- a/config/config.inc.php.dist +++ b/config/config.inc.php.dist @@ -1,94 +1,118 @@ / folder exists $config['activesync_user_debug'] = false; // If specified all ActiveSync-related logs will be saved to this file // Note: This doesn't change Roundcube Framework log locations $config['activesync_log_file'] = null; // Type of ActiveSync cache. Supported values: 'db', 'apc' and 'memcache'. // Note: This is only for some additional data like timezones mapping. $config['activesync_cache'] = 'db'; // lifetime of ActiveSync cache // possible units: s, m, h, d, w $config['activesync_cache_ttl'] = '1d'; // Type of ActiveSync Auth cache. Supported values: 'db', 'apc' and 'memcache'. // Note: This is only for username canonification map. $config['activesync_auth_cache'] = 'db'; // lifetime of ActiveSync Auth cache // possible units: s, m, h, d, w $config['activesync_auth_cache_ttl'] = '1d'; // List of global addressbooks (GAL) // Note: If empty 'autocomplete_addressbooks' setting will be used $config['activesync_addressbooks'] = array(); // ActiveSync => Roundcube contact fields map for GAL search /* Default: array( 'alias' => 'nickname', 'company' => 'organization', 'displayName' => 'name', 'emailAddress' => 'email', 'firstName' => 'firstname', 'lastName' => 'surname', 'mobilePhone' => 'phone.mobile', 'office' => 'office', 'picture' => 'photo', 'phone' => 'phone', 'title' => 'jobtitle', ); */ $config['activesync_gal_fieldmap'] = null; +// List of device types that will sync the LDAP addressbook(s) as a normal folder. +// For devices that do not support GAL searching, e.g. Outlook. +// Examples: +// array('windowsoutlook') # enable for Oultook only +// true # enable for all +$config['activesync_gal_sync'] = false; + +// GAL cache. As reading all contacts from LDAP may be slow, caching is recommended. +$config['activesync_gal_cache'] = 'db'; + +// TTL of GAL cache entries. Technically this causes that synchronized +// contacts will not be updated (queried) often than the specified interval. +$config['activesync_gal_cache_ttl'] = '1d'; + // List of Roundcube plugins // WARNING: Not all plugins used in Roundcube can be listed here $config['activesync_plugins'] = array(); // Defines for how many seconds we'll sleep between every // action for detecting changes in folders. Default: 60 $config['activesync_ping_timeout'] = 60; // Defines maximum Ping interval in seconds. Default: 900 (15 minutes) $config['activesync_ping_interval'] = 900; // We start detecting changes n seconds since the last sync of a folder // Default: 180 $config['activesync_quiet_time'] = 180; // When a device is reqistered, by default a set of folders are // subscribed for syncronization, i.e. INBOX and personal folders with // defined folder type: // mail.drafts, mail.wastebasket, mail.sentitems, mail.outbox, // event, event.default, // contact, contact.default, // task, task.default // This default set can be extended by adding following values: // 1 - all subscribed folders in personal namespace // 2 - all folders in personal namespace // 4 - all subscribed folders in other users namespace // 8 - all folders in other users namespace // 16 - all subscribed folders in shared namespace // 32 - all folders in shared namespace $config['activesync_init_subscriptions'] = 0; // Defines blacklist of devices (device type strings) that do not support folder hierarchies. // When set to an array folder hierarchies are used on all devices not listed here. // When set to null an old whitelist approach will be used where we do opposite // action and enable folder hierarchies only on device types known to support it. $config['activesync_multifolder_blacklist'] = null; +// Blacklist overwrites for specified object type. If set to an array +// it will have a precedence over 'activesync_multifolder_blacklist' list only for that type. +// Note: Outlook does not support multiple folders for contacts, +// in that case use $config['activesync_multifolder_blacklist_contact'] = array('windowsoutlook'); +$config['activesync_multifolder_blacklist_mail'] = null; +$config['activesync_multifolder_blacklist_event'] = null; +$config['activesync_multifolder_blacklist_contact'] = null; +$config['activesync_multifolder_blacklist_note'] = null; +$config['activesync_multifolder_blacklist_task'] = null; + // Enables adding sender name in the From: header of send email // when a device uses email address only (e.g. iOS devices) $config['activesync_fix_from'] = false; diff --git a/lib/kolab_sync_data.php b/lib/kolab_sync_data.php index a32e738..b03d04b 100644 --- a/lib/kolab_sync_data.php +++ b/lib/kolab_sync_data.php @@ -1,1822 +1,1832 @@ | | | | This program is free software: you can redistribute it and/or modify | | it under the terms of the GNU Affero General Public License as published | | by the Free Software Foundation, either version 3 of the License, or | | (at your option) any later version. | | | | This program is distributed in the hope that it will be useful, | | but WITHOUT ANY WARRANTY; without even the implied warranty of | | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | | GNU Affero General Public License for more details. | | | | You should have received a copy of the GNU Affero General Public License | | along with this program. If not, see | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ /** * Base class for Syncroton data backends */ abstract class kolab_sync_data implements Syncroton_Data_IData { /** * ActiveSync protocol version * * @var int */ protected $asversion = 0; /** * 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; /** * type of user created folders * * @var int */ protected $folderType; /** * Internal cache for kolab_storage folder objects * * @var array */ protected $folders = array(); /** * Internal cache for IMAP folders list * * @var array */ protected $imap_folders = array(); /** * Timezone * * @var string */ protected $timezone; /** * List of device types with multiple folders support * * @var array */ protected $ext_devices = array( 'iphone', 'ipad', 'thundertine', 'windowsphone', 'wp', 'wp8', 'playbook', ); const RESULT_OBJECT = 0; const RESULT_UID = 1; const RESULT_COUNT = 2; /** * Recurrence types */ const RECUR_TYPE_DAILY = 0; // Recurs daily. const RECUR_TYPE_WEEKLY = 1; // Recurs weekly const RECUR_TYPE_MONTHLY = 2; // Recurs monthly const RECUR_TYPE_MONTHLY_DAYN = 3; // Recurs monthly on the nth day const RECUR_TYPE_YEARLY = 5; // Recurs yearly const RECUR_TYPE_YEARLY_DAYN = 6; // Recurs yearly on the nth day /** * Day of week constants */ const RECUR_DOW_SUNDAY = 1; const RECUR_DOW_MONDAY = 2; const RECUR_DOW_TUESDAY = 4; const RECUR_DOW_WEDNESDAY = 8; const RECUR_DOW_THURSDAY = 16; const RECUR_DOW_FRIDAY = 32; const RECUR_DOW_SATURDAY = 64; 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 = array( 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 = array( '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_backend::get_instance(); $this->device = $device; $this->asversion = floatval($device->acsversion); $this->syncTimeStamp = $syncTimeStamp; $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 = array(); // device supports multiple folders ? if ($this->isMultiFolder()) { // get the folders the user has access to $list = $this->listFolders(); } else if ($default = $this->getDefaultFolder()) { $list = array($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) { return array(); } /** * Returns true if the device supports multiple folders or it was configured so */ protected function isMultiFolder() { - $blacklist = rcube::get_instance()->config->get('activesync_multifolder_blacklist'); + $config = rcube::get_instance()->config; + $blacklist = $config->get('activesync_multifolder_blacklist_' . $this->modelName); - if (is_array($blacklist)) { - $is_multifolder = !in_array_nocase($this->device->devicetype, $blacklist); + if (!is_array($blacklist)) { + $blacklist = $config->get('activesync_multifolder_blacklist'); } - else { - $is_multifolder = in_array_nocase($this->device->devicetype, $this->ext_devices); + + if (is_array($blacklist)) { + return !$this->deviceTypeFilter($blacklist); } - return $is_multifolder; + 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)) { $key = array_shift(array_keys($folders)); $default = $folders[$key]; // 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) { $parentid = $folder->parentId; $type = $folder->type; $display_name = $folder->displayName; if ($parentid) { $parent = $this->backend->folder_id2name($parentid, $this->device->deviceid); if ($parent === null) { throw new Syncroton_Exception_Status_FolderCreate(Syncroton_Exception_Status_FolderCreate::PARENT_NOT_FOUND); } } $name = rcube_charset::convert($display_name, kolab_sync::CHARSET, 'UTF7-IMAP'); if ($parent !== null) { $rcube = rcube::get_instance(); $storage = $rcube->get_storage(); $delim = $storage->get_hierarchy_delimiter(); $name = $parent . $delim . $name; } // Create IMAP folder $result = $this->backend->folder_create($name, $type, $this->device->deviceid); if ($result) { $folder->serverId = $this->backend->folder_id($name); return $folder; } $errno = Syncroton_Exception_Status_FolderCreate::UNKNOWN_ERROR; // Special case when client tries to create a subfolder of INBOX // which is not possible on Cyrus-IMAP (T2223) if ($parent == 'INBOX' && stripos($this->backend->last_error(), 'invalid') !== false) { $errno = Syncroton_Exception_Status_FolderCreate::SPECIAL_FOLDER; } // Note: Looks like Outlook 2013 ignores any errors on FolderCreate command throw new Syncroton_Exception_Status_FolderCreate($errno); } /** * Updates a folder */ public function updateFolder(Syncroton_Model_IFolder $folder) { $parentid = $folder->parentId; $type = $folder->type; $display_name = $folder->displayName; $old_name = $this->backend->folder_id2name($folder->serverId, $this->device->deviceid); if ($parentid) { $parent = $this->backend->folder_id2name($parentid, $this->device->deviceid); } $name = rcube_charset::convert($display_name, kolab_sync::CHARSET, 'UTF7-IMAP'); if ($parent !== null) { $rcube = rcube::get_instance(); $storage = $rcube->get_storage(); $delim = $storage->get_hierarchy_delimiter(); $name = $parent . $delim . $name; } // Rename/move IMAP folder if ($name == $old_name) { $result = true; // @TODO: folder type change? } else { $result = $this->backend->folder_rename($old_name, $name, $type); } if ($result) { return $folder; } // @TODO: throw exception } /** * Deletes a folder */ public function deleteFolder($folder) { if ($folder instanceof Syncroton_Model_IFolder) { $folder = $folder->serverId; } $name = $this->backend->folder_id2name($folder, $this->device->deviceid); // @TODO: throw exception return $this->backend->folder_delete($name, $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) { $folders = $this->extractFolders($folderid); foreach ($folders as $folderid) { $foldername = $this->backend->folder_id2name($folderid, $this->device->deviceid); $folder = $this->getFolderObject($foldername); if (!$folder || !$folder->valid) { throw new Syncroton_Exception_Status_ItemOperations(Syncroton_Exception_Status_ItemOperations::ITEM_SERVER_ERROR); } // Remove all entries $folder->delete_all(); // Remove subfolders if (!empty($options['deleteSubFolders'])) { $list = $this->listFolders($folderid); foreach ($list as $folderid => $folder) { $foldername = $this->backend->folder_id2name($folderid, $this->device->deviceid); $folder = $this->getFolderObject($foldername); if (!$folder || !$folder->valid) { throw new Syncroton_Exception_Status_ItemOperations(Syncroton_Exception_Status_ItemOperations::ITEM_SERVER_ERROR); } // Remove all entries $folder->delete_all(); } } } } /** * 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) { $item = $this->getObject($srcFolderId, $serverId, $folder); if (!$item || !$folder) { throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_SOURCE); } $dstname = $this->backend->folder_id2name($dstFolderId, $this->device->deviceid); if ($dstname === null) { throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_DESTINATION); } if (!$folder->move($serverId, $dstname)) { throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_SOURCE); } return $item['uid']; } /** * 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); $entry = $this->createObject($folderId, $entry); if (empty($entry)) { throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR); } return $entry['uid']; } /** * update existing entry * * @param string $folderId * @param string $serverId * @param SimpleXMLElement $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('id not found'); } $entry = $this->toKolab($entry, $folderId, $oldEntry); $entry = $this->updateObject($folderId, $serverId, $entry); if (empty($entry)) { throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR); } return $entry['uid']; } /** * delete entry * * @param string $folderId * @param string $serverId * @param array $collectionData */ public function deleteEntry($folderId, $serverId, $collectionData) { $deleted = $this->deleteObject($folderId, $serverId); if (!$deleted) { throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR); } } public function getFileReference($fileReference) { // to be implemented by Email data class // @TODO: throw "unimplemented" exception here? } /** * 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 = array(), $result_type = self::RESULT_UID) { if ($folderid == $this->defaultRootFolder) { $folders = $this->listFolders(); if (!is_array($folders)) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } $folders = array_keys($folders); } else { $folders = array($folderid); } // there's a PHP Warning from kolab_storage if $filter isn't an array if (empty($filter)) { $filter = array(); } else { $changed_objects = $this->getChangesByRelations($folderid, $filter); } $result = $result_type == self::RESULT_COUNT ? 0 : array(); $found = 0; foreach ($folders as $folder_id) { $foldername = $this->backend->folder_id2name($folder_id, $this->device->deviceid); $folder = $this->getFolderObject($foldername); if (!$folder || !$folder->valid) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } $found++; $error = false; switch ($result_type) { case self::RESULT_COUNT: $count = $folder->count($filter); if ($count === null || $count === false) { $error = true; } else { $result += (int) $count; } break; case self::RESULT_UID: $uids = $folder->get_uids($filter); if (!is_array($uids)) { $error = true; } else { $result = array_merge($result, $uids); } break; } if ($error) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } // 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 = array(array('uid', '=', $changed_objects)); foreach ($filter as $f) { if ($f[0] != 'changed') { $tag_filter[] = $f; } } switch ($result_type) { case self::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 && $count !== false) { $result += (int) $count; } break; case self::RESULT_UID: $uids = $folder->get_uids($tag_filter); if (is_array($uids)) { $result = array_unique(array_merge($result, $uids)); } break; } } } if (!$found) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } return $result; } /** * Detect changes of relation (tag) objects data and assigned objects * Returns relation member identifiers */ protected function getChangesByRelations($folderid, $filter) { if (!$this->tag_categories) { return; } // 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->backend->relations_state_get($this->device->id, $folderid, $since); // get current relations state $config = kolab_storage_config::get_instance(); $default = true; $filter = array( array('type', '=', 'relation'), array('category', '=', 'tag') ); $relations = $config->get_objects($filter, $default, 100); $result = array(); $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... else if ($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... else if ($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 = array(); foreach ($relations as $relation) { $data[$relation['uid']] = array( 'name' => $relation['name'], 'changed' => $relation['changed']->format('U'), 'members' => implode("\n", $relation['members']), ); } $now = new DateTime('now', new DateTimeZone('UTC')); $this->backend->relations_state_set($this->device->id, $folderid, $now, $data); } // in mail mode return only message URIs if ($this->modelName == 'mail') { // 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; } /** * Returns filter query array according to specified ActiveSync FilterType * * @param int $filter_type Filter type * * @param array Filter query */ protected function filter($filter_type = 0) { // overwrite by child class according to specified type return array(); } /** * get all entries changed between two dates * * @param string $folderId * @param DateTime $start * @param DateTime $end * @param int $filterType * * @return array */ public function getChangedEntries($folderId, DateTime $start, DateTime $end = null, $filter_type = null) { $filter = $this->filter($filter_type); $filter[] = array('changed', '>', $start); if ($end) { $filter[] = array('changed', '<=', $end); } return $this->searchEntries($folderId, $filter, self::RESULT_UID); } /** * Get count of entries changed between two dates * * @param string $folderId * @param DateTime $start * @param DateTime $end * @param int $filterType * * @return int */ public function getChangedEntriesCount($folderId, DateTime $start, DateTime $end = null, $filter_type = null) { $filter = $this->filter($filter_type); $filter[] = array('changed', '>', $start); if ($end) { $filter[] = array('changed', '<=', $end); } return $this->searchEntries($folderId, $filter, self::RESULT_COUNT); } /** * get id's of all entries available on the server * * @param string $folderId * @param int $filterType * * @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 $folderId * @param int $filterType * * @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); $allServerEntries = $this->getServerEntries($folder->serverId, $folder->lastfiltertype); $changedEntries = $this->getChangedEntriesCount($folder->serverId, $syncState->lastsync, null, $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 to detect change in multi-folder mode and throw exception - // so device will re-sync folders hierarchy - // @TODO: this is a temp solution until we have real hierarchy - // changes detection fort Ping/Hartbeat - $is_multifolder = $this->isMultiFolder(); - if (($is_multifolder && $folder->serverId == $this->defaultRootFolder) - || (!$is_multifolder && $folder->type >= 12) - ) { - throw new Syncroton_Exception_NotFound('Folder not found'); - } - try { if ($this->getChangedEntriesCount($folder->serverId, $syncState->lastsync, null, $folder->lastfiltertype)) { return true; } $allClientEntries = $contentBackend->getFolderState($this->device, $folder); // @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, &$folder = null) { $folders = $this->extractFolders($folderid); foreach ($folders as $folderid) { $foldername = $this->backend->folder_id2name($folderid, $this->device->deviceid); $folder = $this->getFolderObject($foldername); if ($folder && $folder->valid && ($object = $folder->get_object($entryid))) { $object['_folderid'] = $folderid; return $object; } } } /** * Saves the entry on the backend */ protected function createObject($folderid, $data) { if ($folderid == $this->defaultRootFolder) { $default = $this->getDefaultFolder(); if (!is_array($default)) { return null; } $folderid = isset($default['realid']) ? $default['realid'] : $default['serverId']; } // convert categories into tags, save them after creating an object if ($this->tag_categories) { $tags = $data['categories']; unset($data['categories']); } $foldername = $this->backend->folder_id2name($folderid, $this->device->deviceid); $folder = $this->getFolderObject($foldername); if ($folder && $folder->valid && $folder->save($data)) { if (!empty($tags)) { $this->setKolabTags($data['uid'], $tags); } return $data; } } /** * Updates the entry on the backend */ protected function updateObject($folderid, $entryid, $data) { $object = $this->getObject($folderid, $entryid); if ($object) { $folder = $this->getFolderObject($object['_mailbox']); // convert categories into tags, save them after updating an object if ($this->tag_categories && array_key_exists('categories', $data)) { $tags = (array) $data['categories']; unset($data['categories']); } if ($folder && $folder->valid && $folder->save($data)) { if (isset($tags)) { $this->setKolabTags($data['uid'], $tags); } return $data; } } } /** * Removes the entry from the backend */ protected function deleteObject($folderid, $entryid) { $object = $this->getObject($folderid, $entryid); if ($object) { $folder = $this->getFolderObject($object['_mailbox']); if ($folder && $folder->valid && $folder->delete($entryid)) { if ($this->tag_categories) { $this->setKolabTags($object['uid'], null); } return true; } return false; } // object doesn't exist, confirm deletion return true; } /** * 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)) { return null; } $folders = array_keys($folders); } else { $folders = array($folderid); } return $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->imap_folders)) { $this->imap_folders = $this->backend->folders_list( $this->device->deviceid, $this->modelName, $this->isMultiFolder()); } if ($parentid === null) { return $this->imap_folders; } $folders = array(); $parents = array($parentid); foreach ($this->imap_folders as $folder_id => $folder) { if ($folder['parentId'] && in_array($folder['parentId'], $parents)) { $folders[$folder_id] = $folder; $parents[] = $folder_id; } } return $folders; } /** * Returns Folder object (uses internal cache) * * @param string $name Folder name (UTF7-IMAP) * * @return kolab_storage_folder Folder object */ protected function getFolderObject($name) { if ($name === null || $name === '') { return null; } if (!isset($this->folders[$name])) { $this->folders[$name] = kolab_storage::get_folder($name, $this->modelName); } return $this->folders[$name]; } /** * Returns ActiveSync settings of specified folder * * @param string $name Folder name (UTF7-IMAP) * * @return array Folder settings */ protected function getFolderConfig($name) { $metadata = $this->backend->folder_meta(); if (!is_array($metadata)) { return array(); } $deviceid = $this->device->deviceid; $config = $metadata[$name]['FOLDER'][$deviceid]; return array( 'ALARMS' => $config['S'] == 2, ); } /** * Returns real folder name for specified folder ID */ protected function getFolderName($folderid) { if ($folderid == $this->defaultRootFolder) { $default = $this->getDefaultFolder(); if (!is_array($default)) { return null; } $folderid = isset($default['realid']) ? $default['realid'] : $default['serverId']; } return $this->backend->folder_id2name($folderid, $this->device->deviceid); } /** * Convert contact from xml to kolab format * * @param Syncroton_Model_IEntry $data Contact data * @param string $folderId Folder identifier * @param array $entry Old Contact data for merge * * @return array */ abstract function toKolab(Syncroton_Model_IEntry $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; } /* // hash array e.g. organizer else if ($count == 2) { $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; } */ $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); // multi-level array (e.g. address, phone) if (count($name_items) == 3) { $name = $name_items[0]; $type = $name_items[1]; $key_name = $name_items[2]; if (!isset($data[$name])) { $data[$name] = array(); } 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] = array('type' => $type); } $data[$name][$found][$key_name] = $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); // multi-level array (e.g. address, phone) if (count($name_items) == 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; } $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 $param Body parameters * * @reurn Syncroton_Model_EmailBody Body element */ protected function setBody($value, $params = array()) { 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 Body value */ protected function getBody($body, $type = null) { if ($body && $body->data) { $data = $body->data; } if (!$data || empty($type)) { return; } $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 = array(); // HTML? check for opening and closing or tags $is_html = preg_match('/<(html|body)(\s+[a-z]|>)/', $body, $m) && strpos($body, '') > 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 (($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 Datetime object */ protected static function date_from_kolab($date) { if (!empty($date)) { if (is_numeric($date)) { $date = new DateTime('@' . $date); } else if (is_string($date)) { $date = new DateTime($date, new DateTimeZone('UTC')); } else if ($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 ($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; } } /** * Convert Kolab event/task recurrence into ActiveSync */ protected function recurrence_from_kolab($collection, $data, &$result, $type = 'Event') { if (empty($data['recurrence'])) { return; } $recurrence = array(); $r = $data['recurrence']; // required fields switch($r['FREQ']) { case 'DAILY': $recurrence['type'] = self::RECUR_TYPE_DAILY; break; case 'WEEKLY': $recurrence['type'] = self::RECUR_TYPE_WEEKLY; $recurrence['dayOfWeek'] = $this->day2bitmask($r['BYDAY']); 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 = array_shift(explode(',', $r['BYMONTHDAY'])); $recurrence['type'] = self::RECUR_TYPE_MONTHLY; $recurrence['dayOfMonth'] = $month_day; } else { $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); } break; case 'YEARLY': // @TODO: ActiveSync doesn't support multi-valued months, // should we replicate the recurrence element for each month? $month = array_shift(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; } else 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 = array_shift(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; } // required field $recurrence['interval'] = $r['INTERVAL'] ? $r['INTERVAL'] : 1; if (!empty($r['UNTIL'])) { $recurrence['until'] = self::date_from_kolab($r['UNTIL']); } else if (!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) || !isset($data->recurrence->type)) { return null; } $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'] = isset($recurrence->interval) ? $recurrence->interval : 1; if (isset($recurrence->until)) { if ($timezone) { $recurrence->until->setTimezone($timezone); } $rrule['UNTIL'] = $recurrence->until; } else if (!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 = array(); // exceptions (modified occurences) foreach ((array)$data['recurrence']['EXCEPTIONS'] as $exception) { $exception['_mailbox'] = $data['_mailbox']; $ex = $this->getEntry($collection, $exception, true); $ex['exceptionStartTime'] = clone $ex['startTime']; // 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) foreach ((array)$data['recurrence']['EXDATE'] as $exception) { if (!($exception instanceof DateTime)) { continue; } // set event start time to exception date // that can't be any time, tested with Android $hour = $data['_start']->format('H'); $minute = $data['_start']->format('i'); $second = $data['_start']->format('s'); $exception->setTime($hour, $minute, $second); $exception->_dateonly = false; $ex = array( 'deleted' => 1, 'exceptionStartTime' => self::date_from_kolab($exception), ); $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'] = array(); $rrule['EXCEPTIONS'] = array(); // handle exceptions from recurrence if (!empty($data->exceptions)) { foreach ($data->exceptions as $exception) { if ($exception->deleted) { $date = clone $exception->exceptionStartTime; if ($timezone) { $date->setTimezone($timezone); } $date->setTime(0, 0, 0); $rrule['EXDATE'][] = $date; } else if (!$exception->deleted) { $ex = $this->toKolab($exception, $folderid, null, $timezone); if ($data->allDayEvent) { $ex['allday'] = 1; } $rrule['EXCEPTIONS'][] = $ex; } } } if (empty($rrule['EXDATE'])) { unset($rrule['EXDATE']); } if (empty($rrule['EXCEPTIONS'])) { unset($rrule['EXCEPTIONS']); } } /** * Returns list of tag names assigned to kolab object */ protected function getKolabTags($uid, $categories = null) { $config = kolab_storage_config::get_instance(); $tags = $config->get_tags($uid); $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; } /** * Set tags to kolab object */ protected function setKolabTags($uid, $tags) { $config = kolab_storage_config::get_instance(); $config->save_tags($uid, $tags); } /** * 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) { $result = $result + $this->recurDayMap[$day]; } return $result; } /** * Convert bitmask used by ActiveSync to string of days (TU,TH) * * @param int $days * * @return string */ protected function bitmask2day($days) { $days_arr = array(); 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; + } + } + else if (stripos($this->device->devicetype, $option) !== false) { + return true; + } + } + + return false; + } } diff --git a/lib/kolab_sync_data_contacts.php b/lib/kolab_sync_data_contacts.php index e348474..0562377 100644 --- a/lib/kolab_sync_data_contacts.php +++ b/lib/kolab_sync_data_contacts.php @@ -1,283 +1,637 @@ | + | Copyright (C) 2011-2017, Kolab Systems AG | | | | This program is free software: you can redistribute it and/or modify | | it under the terms of the GNU Affero General Public License as published | | by the Free Software Foundation, either version 3 of the License, or | | (at your option) any later version. | | | | This program is distributed in the hope that it will be useful, | | but WITHOUT ANY WARRANTY; without even the implied warranty of | | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | | GNU Affero General Public License for more details. | | | | You should have received a copy of the GNU Affero General Public License | | along with this program. If not, see | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ /** * COntacts data class for Syncroton */ class kolab_sync_data_contacts extends kolab_sync_data { /** * Mapping from ActiveSync Contacts namespace fields */ protected $mapping = array( 'anniversary' => 'anniversary', 'assistantName' => 'assistant:0', //'assistantPhoneNumber' => 'assistantphonenumber', 'birthday' => 'birthday', 'body' => 'notes', 'businessAddressCity' => 'address.work.locality', 'businessAddressCountry' => 'address.work.country', 'businessAddressPostalCode' => 'address.work.code', 'businessAddressState' => 'address.work.region', 'businessAddressStreet' => 'address.work.street', 'businessFaxNumber' => 'phone.workfax.number', 'businessPhoneNumber' => 'phone.work.number', 'carPhoneNumber' => 'phone.car.number', //'categories' => 'categories', 'children' => 'children', 'companyName' => 'organization', 'department' => 'department', //'email1Address' => 'email:0', //'email2Address' => 'email:1', //'email3Address' => 'email:2', //'fileAs' => 'fileas', //@TODO: ? 'firstName' => 'firstname', //'home2PhoneNumber' => 'home2phonenumber', 'homeAddressCity' => 'address.home.locality', 'homeAddressCountry' => 'address.home.country', 'homeAddressPostalCode' => 'address.home.code', 'homeAddressState' => 'address.home.region', 'homeAddressStreet' => 'address.home.street', 'homeFaxNumber' => 'phone.homefax.number', 'homePhoneNumber' => 'phone.home.number', 'jobTitle' => 'jobtitle', 'lastName' => 'surname', 'middleName' => 'middlename', 'mobilePhoneNumber' => 'phone.mobile.number', //'officeLocation' => 'officelocation', 'otherAddressCity' => 'address.office.locality', 'otherAddressCountry' => 'address.office.country', 'otherAddressPostalCode' => 'address.office.code', 'otherAddressState' => 'address.office.region', 'otherAddressStreet' => 'address.office.street', 'pagerNumber' => 'phone.pager.number', 'picture' => 'photo', //'radioPhoneNumber' => 'radiophonenumber', //'rtf' => 'rtf', 'spouse' => 'spouse', 'suffix' => 'suffix', 'title' => 'prefix', 'webPage' => 'website.homepage.url', //'yomiCompanyName' => 'yomicompanyname', //'yomiFirstName' => 'yomifirstname', //'yomiLastName' => 'yomilastname', // Mapping from ActiveSync Contacts2 namespace fields //'accountName' => 'accountname', //'companyMainPhone' => 'companymainphone', //'customerId' => 'customerid', //'governmentId' => 'governmentid', 'iMAddress' => 'im:0', 'iMAddress2' => 'im:1', 'iMAddress3' => 'im:2', 'managerName' => 'manager:0', //'mMS' => 'mms', 'nickName' => 'nickname', ); /** * Kolab object type * * @var string */ protected $modelName = 'contact'; /** * Type of the default folder * * @var int */ protected $defaultFolderType = Syncroton_Command_FolderSync::FOLDERTYPE_CONTACT; /** * Default container for new entries * * @var string */ protected $defaultFolder = 'Contacts'; /** * Type of user created folders * * @var int */ protected $folderType = Syncroton_Command_FolderSync::FOLDERTYPE_CONTACT_USER_CREATED; + /** + * Identifier of special Global Address List folder + * + * @var string + */ + protected $galFolder = 'GAL'; + + /** + * Name of special Global Address List folder + * + * @var string + */ + protected $galFolderName = 'Global Address Book'; + + protected $galPrefix = 'GAL:'; + protected $galSources; + protected $galResult; + protected $galCache; + /** * Creates model object * * @param Syncroton_Model_SyncCollection $collection Collection data * @param string $serverId Local entry identifier */ public function getEntry(Syncroton_Model_SyncCollection $collection, $serverId) { $data = is_array($serverId) ? $serverId : $this->getObject($collection->collectionId, $serverId); $result = array(); + if (empty($data)) { + throw new Syncroton_Exception_NotFound("Contact $serverId not found"); + } + // Contacts namespace fields foreach ($this->mapping as $key => $name) { $value = $this->getKolabDataItem($data, $name); switch ($name) { case 'photo': if ($value) { // ActiveSync limits photo size to 48KB (of base64 encoded string) if (strlen($value) * 1.33 > 48 * 1024) { continue; } } break; case 'birthday': case 'anniversary': $value = self::date_from_kolab($value); break; case 'notes': $value = $this->body_from_kolab($value, $collection); break; } if (empty($value) || is_array($value)) { continue; } $result[$key] = $value; } // email address(es): email1Address, email2Address, email3Address for ($x=0; $x<3; $x++) { - if (!empty($data['email'][$x]) && !empty($data['email'][$x]['address'])) { - $result['email' . ($x+1) . 'Address'] = $data['email'][$x]['address']; + if ($email = $data['email'][$x]) { + if (is_array($email)) { + $email = $email['address']; + } + if ($email) { + $result['email' . ($x+1) . 'Address'] = $email; + } } } return new Syncroton_Model_Contact($result); } /** * convert contact from xml to libkolab array * * @param Syncroton_Model_IEntry $data Contact to convert * @param string $folderId Folder identifier * @param array $entry Existing entry * * @return array Kolab object array */ public function toKolab(Syncroton_Model_IEntry $data, $folderId, $entry = null) { $contact = !empty($entry) ? $entry : array(); // Contacts namespace fields foreach ($this->mapping as $key => $name) { $value = $data->$key; switch ($name) { case 'address.work.street': if (strtolower($this->device->devicetype) == 'palm') { // palm pre sends the whole address in the tag $value = null; } break; case 'website.homepage.url': // remove facebook urls if (preg_match('/^fb:\/\//', $value)) { $value = null; } break; case 'notes': $value = $this->getBody($value, Syncroton_Model_EmailBody::TYPE_PLAINTEXT); // If note isn't specified keep old note if ($value === null) { continue 2; } break; case 'photo': // If photo isn't specified keep old photo if ($value === null) { continue 2; } break; case 'birthday': case 'anniversary': if ($value) { // convert date to string format, so libkolab will store // it with no time and timezone what could be incorrectly re-calculated (#2555) $value = $value->format('Y-m-d'); } break; } $this->setKolabDataItem($contact, $name, $value); } // email address(es): email1Address, email2Address, email3Address $emails = array(); for ($x=0; $x<3; $x++) { $key = 'email' . ($x+1) . 'Address'; if ($value = $data->$key) { // Android sends email address as: Lars Kneschke if (preg_match('/(.*)<(.+@[^@]+)>/', $value, $matches)) { $value = trim($matches[2]); } // sanitize email address, it can contain broken (non-unicode) characters (#3287) $value = rcube_charset::clean($value); // try to find address type, at least we can do this if // address wasn't changed $type = ''; foreach ((array)$contact['email'] as $email) { if ($email['address'] == $value) { $type = $email['type']; } } $emails[] = array('address' => $value, 'type' => $type); } } $contact['email'] = $emails; return $contact; } + /** + * Return list of supported folders for this backend + * + * @return array + */ + public function getAllFolders() + { + $list = parent::getAllFolders(); + + if ($this->isMultiFolder() && $this->hasGAL()) { + $list[$this->galFolder] = new Syncroton_Model_Folder(array( + 'displayName' => $this->galFolderName, // @TODO: localization? + 'serverId' => $this->galFolder, + 'parentId' => 0, + 'type' => 14, + )); + } + + return $list; + } + + /** + * Updates a folder + */ + public function updateFolder(Syncroton_Model_IFolder $folder) + { + if ($folder->serverId === $this->galFolder && $this->hasGAL()) { + throw new Syncroton_Exception_AccessDenied("Updating GAL folder is not possible"); + } + + return parent::updateFolder($folder); + } + + /** + * Deletes a folder + */ + public function deleteFolder($folder) + { + if ($folder instanceof Syncroton_Model_IFolder) { + $folder = $folder->serverId; + } + + if ($folder === $this->galFolder && $this->hasGAL()) { + throw new Syncroton_Exception_AccessDenied("Deleting GAL folder is not possible"); + } + + return parent::deleteFolder($folder); + } + + /** + * Empty folder (remove all entries and optionally subfolders) + * + * @param string $folderId Folder identifier + * @param array $options Options + */ + public function emptyFolderContents($folderid, $options) + { + if ($folderid === $this->galFolder && $this->hasGAL()) { + throw new Syncroton_Exception_AccessDenied("Emptying GAL folder is not possible"); + } + + return parent::emptyFolderContents($folderid, $options); + } + + /** + * 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) + { + if (strpos($serverId, $this->galPrefix) === 0 && $this->hasGAL()) { + throw new Syncroton_Exception_AccessDenied("Moving GAL entries is not possible"); + } + + if ($srcFolderId === $this->galFolder && $this->hasGAL()) { + throw new Syncroton_Exception_AccessDenied("Moving/Deleting GAL entries is not possible"); + } + + if ($dstFolderId === $this->galFolder && $this->hasGAL()) { + throw new Syncroton_Exception_AccessDenied("Creating GAL entries is not possible"); + } + + return parent::moveItem($srcFolderId, $serverId, $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) + { + if ($folderId === $this->galFolder && $this->hasGAL()) { + throw new Syncroton_Exception_AccessDenied("Creating GAL entries is not possible"); + } + + return parent::createEntry($folderId, $entry); + } + + /** + * update existing entry + * + * @param string $folderId + * @param string $serverId + * @param SimpleXMLElement $entry + * + * @return string ID of the updated entry + */ + public function updateEntry($folderId, $serverId, Syncroton_Model_IEntry $entry) + { + if (strpos($serverId, $this->galPrefix) === 0 && $this->hasGAL()) { + throw new Syncroton_Exception_AccessDenied("Updating GAL entries is not possible"); + } + + return parent::updateEntry($folderId, $serverId, $entry); + } + + /** + * delete entry + * + * @param string $folderId + * @param string $serverId + * @param array $collectionData + */ + public function deleteEntry($folderId, $serverId, $collectionData) + { + if (strpos($serverId, $this->galPrefix) === 0 && $this->hasGAL()) { + throw new Syncroton_Exception_AccessDenied("Deleting GAL entries is not possible"); + } + + return parent::deleteEntry($folderId, $serverId, $collectionData); + } + /** * Returns filter query array according to specified ActiveSync FilterType * * @param int $filter_type Filter type * * @param array Filter query */ protected function filter($filter_type = 0) { // specify object type, contact folders in Kolab might // contain also ditribution-list objects, we'll skip them return array(array('type', '=', $this->modelName)); } + /** + * Check if GAL synchronization is enabled for current device + */ + protected function hasGAL() + { + return count($this->getGALSources()); + } + + /** + * 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 = array(), $result_type = self::RESULT_UID) + { + // GAL Folder exists, return result from LDAP only + if ($folderid === $this->galFolder && $this->hasGAL()) { + return $this->searchGALEntries($filter, $result_type); + } + + $result = parent::searchEntries($folderid, $filter, $result_type); + + // Merge results from LDAP + if ($this->hasGAL() && !$this->isMultiFolder()) { + $gal_result = $this->searchGALEntries($filter, $result_type); + + if ($result_type == self::RESULT_COUNT) { + $result += $gal_result; + } + else { + $result = array_merge($result, $gal_result); + } + } + + return $result; + } + + /** + * Fetches the entry from the backend + */ + protected function getObject($folderid, $entryid, &$folder = null) + { + if (strpos($entryid, $this->galPrefix) === 0 && $this->hasGAL()) { + return $this->getGALEntry($entryid); + } + + return parent::getObject($folderid, $entryid, $folder); + } + + /** + * Search for existing LDAP entries + * + * @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 searchGALEntries($filter, $result_type) + { + // For GAL we don't check for changes. + // When something changed a new UID will be generated so the update + // will be done as delete + create + foreach ($filter as $f) { + if ($f[0] == 'changed') { + return $result_type == self::RESULT_COUNT ? 0 : array(); + } + } + + if ($this->galCache && ($result = $this->galCache->get('index')) !== null) { + $result = explode("\n", $result); + return $result_type == self::RESULT_COUNT ? count($result) : $result; + } + + $result = array(); + + foreach ($this->getGALSources() as $source) { + if ($book = kolab_sync_data_gal::get_address_book($source['id'])) { + $book->reset(); + $book->set_page(1); + $book->set_pagesize(10000); + + $set = $book->list_records(); + while ($contact = $set->next()) { + $result[] = $this->createGALEntryUID($contact, $source['id']); + } + } + } + + if ($this->galCache) { + $this->galCache->set('index', implode("\n", $result)); + } + + return $result_type == self::RESULT_COUNT ? count($result) : $result; + } + + /** + * Return specified LDAP entry + * + * @param string $serverId Entry identifier + * + * @return array Contact data + */ + protected function getGALEntry($serverId) + { + list($source, $timestamp, $uid) = $this->resolveGALEntryUID($serverId); + + if ($source && $uid && ($book = kolab_sync_data_gal::get_address_book($source))) { + $book->reset(); + + $set = $book->search('uid', array($uid), rcube_addressbook::SEARCH_STRICT, true, true); + $result = $set->first(); + + if ($result['uid'] == $uid && $result['changed'] == $timestamp) { + // As in kolab_sync_data_gal we use only one email address + if (empty($result['email'])) { + $emails = $book->get_col_values('email', $result, true); + $result['email'] = array($emails[0]); + } + + return $result; + } + } + } + + /** + * Return LDAP address books list + * + * @return array Address books array + */ + protected function getGALSources() + { + if ($this->galSources === null) { + $rcube = rcube::get_instance(); + $gal_sync = $rcube->config->get('activesync_gal_sync'); + $enabled = false; + + if ($gal_sync === true) { + $enabled = true; + } + else if (is_array($gal_sync)) { + $enabled = $this->deviceTypeFilter($gal_sync); + } + + $this->galSources = $enabled ? kolab_sync_data_gal::get_address_sources() : array(); + + if ($cache_type = $rcube->config->get('activesync_gal_cache', 'db')) { + $cache_ttl = $rcube->config->get('activesync_gal_cache_ttl', '1d'); + $this->galCache = $rcube->get_cache('activesync_gal', $cache_type, $cache_ttl, false); + + // expunge cache every now and then + if (rand(0, 10) === 0) { + $this->galCache->expunge(); + } + } + } + + return $this->galSources; + } + + /** + * Builds contact identifier from contact data and source id + */ + protected function createGALEntryUID($contact, $source_id) + { + return $this->galPrefix . sprintf('%s:%s:%s', rcube_ldap::dn_encode($source_id), $contact['changed'], $contact['uid']); + } + + /** + * Extracts contact identification data from contact identifier + */ + protected function resolveGALEntryUID($uid) + { + if (strpos($uid, $this->galPrefix) === 0) { + $items = explode(':', substr($uid, strlen($this->galPrefix))); + $items[0] = rcube_ldap::dn_decode($items[0]); + return $items; // source, timestamp, uid + } + + return array(); + } } diff --git a/lib/kolab_sync_data_gal.php b/lib/kolab_sync_data_gal.php index 297a4a9..95894e7 100644 --- a/lib/kolab_sync_data_gal.php +++ b/lib/kolab_sync_data_gal.php @@ -1,399 +1,390 @@ | | | | This program is free software: you can redistribute it and/or modify | | it under the terms of the GNU Affero General Public License as published | | by the Free Software Foundation, either version 3 of the License, or | | (at your option) any later version. | | | | This program is distributed in the hope that it will be useful, | | but WITHOUT ANY WARRANTY; without even the implied warranty of | | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | | GNU Affero General Public License for more details. | | | | You should have received a copy of the GNU Affero General Public License | | along with this program. If not, see | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ /** * GAL (Global Address List) data backend for Syncroton */ class kolab_sync_data_gal extends kolab_sync_data implements Syncroton_Data_IDataSearch { const MAX_SEARCH_RESULT = 100; /** * LDAP search result * * @var array */ protected $result = array(); /** * LDAP address books list * * @var array */ - protected $address_books = array(); + protected static $address_books = array(); /** * Mapping from ActiveSync Contacts namespace fields */ protected $mapping = array( 'alias' => 'nickname', 'company' => 'organization', 'displayName' => 'name', 'emailAddress' => 'email', 'firstName' => 'firstname', 'lastName' => 'surname', 'mobilePhone' => 'phone.mobile', 'office' => 'office', 'picture' => 'photo', 'phone' => 'phone', 'title' => 'jobtitle', ); /** * Kolab object type * * @var string */ protected $modelName = 'contact'; /** * Type of the default folder * * @var int */ protected $defaultFolderType = Syncroton_Command_FolderSync::FOLDERTYPE_CONTACT; /** * Default container for new entries * * @var string */ protected $defaultFolder = 'Contacts'; /** * Type of user created folders * * @var int */ protected $folderType = Syncroton_Command_FolderSync::FOLDERTYPE_CONTACT_USER_CREATED; /** * the constructor * * @param Syncroton_Model_IDevice $device * @param DateTime $syncTimeStamp */ public function __construct(Syncroton_Model_IDevice $device, DateTime $syncTimeStamp) { parent::__construct($device, $syncTimeStamp); // Use configured fields mapping $rcube = rcube::get_instance(); $fieldmap = (array) $rcube->config->get('activesync_gal_fieldmap'); if (!empty($fieldmap)) { $fieldmap = array_intersec_key($fieldmap, array_keys($this->mapping)); $this->mapping = array_merge($this->mapping, $fieldmap); } } /** * Not used but required by parent class */ public function toKolab(Syncroton_Model_IEntry $data, $folderId, $entry = null) { } /** * Not used but required by parent class */ public function getEntry(Syncroton_Model_SyncCollection $collection, $serverId) { } /** * Returns properties of a contact for Search response * * @param array $data Contact data * @param array $options Search options * * @return Syncroton_Model_GAL Contact (GAL) object */ public function getSearchEntry($data, $options) { $result = array(); // Contacts namespace fields foreach ($this->mapping as $key => $name) { $value = $this->getLDAPDataItem($data, $name); if (empty($value) || is_array($value)) { continue; } switch ($name) { case 'photo': // @TODO: MaxPictures option // ActiveSync limits photo size of GAL contact to 100KB $maxsize = 102400; if (!empty($options['picture']['maxSize'])) { $maxsize = min($maxsize, $options['picture']['maxSize']); } if (strlen($value) > $maxsize) { continue; } $value = new Syncroton_Model_GALPicture(array( 'data' => $value, // binary 'status' => Syncroton_Model_GALPicture::STATUS_SUCCESS, )); break; } $result[$key] = $value; } return new Syncroton_Model_GAL($result); } /** * ActiveSync Search handler * * @param Syncroton_Model_StoreRequest $store Search query parameters * * @return Syncroton_Model_StoreResponse Complete Search response * @throws Exception */ public function search(Syncroton_Model_StoreRequest $store) { $options = $store->options; $query = $store->query; if (empty($query) || !is_string($query)) { throw new Exception('Empty/invalid search request'); } $records = array(); $rcube = rcube::get_instance(); // @TODO: caching with Options->RebuildResults support - $books = $this->get_address_sources(); + $books = self::get_address_sources(); $mode = 2; // use prefix mode $fields = $rcube->config->get('contactlist_fields'); if (empty($fields)) { $fields = '*'; } foreach ($books as $idx => $book) { - $book = $this->get_address_book($idx); + $book = self::get_address_book($idx); if (!$book) { continue; } $book->set_page(1); $book->set_pagesize(self::MAX_SEARCH_RESULT); $result = $book->search($fields, $query, $mode, true, true, 'email'); if (!$result->count) { continue; } // get records $result = $book->list_records(); while ($row = $result->next()) { $row['sourceid'] = $idx; // make sure 'email' item is there, convert all email:* into one $row['email'] = $book->get_col_values('email', $row, true); $key = $this->contact_key($row); unset($row['_raw_attrib']); // save some memory, @TODO: do this in rcube_ldap $records[$key] = $row; } // We don't want to search all sources if we've got already a lot of contacts if (count($records) >= self::MAX_SEARCH_RESULT) { break; } } // sort the records ksort($records, SORT_LOCALE_STRING); $records = array_values($records); $response = new Syncroton_Model_StoreResponse(); // Calculate requested range $start = (int) $options['range'][0]; $limit = (int) $options['range'][1] + 1; $total = count($records); $response->total = $total; // Get requested chunk of data set if ($total) { if ($start > $total) { $start = $total; } if ($limit > $total) { $limit = max($start+1, $total); } if ($start > 0 || $limit < $total) { $records = array_slice($records, $start, $limit-$start); } $response->range = array($start, $start + count($records) - 1); } // Build result array, convert to ActiveSync format foreach ($records as $idx => $rec) { $response->result[] = new Syncroton_Model_StoreResponseResult(array( 'longId' => $rec['ID'], 'properties' => $this->getSearchEntry($rec, $options), )); unset($records[$idx]); } return $response; } /** * Return instance of the internal address book class * * @param string $id Address book identifier * * @return rcube_contacts Address book object */ - protected function get_address_book($id) + public static function get_address_book($id) { $config = rcube::get_instance()->config; $ldap_config = (array) $config->get('ldap_public'); // use existing instance - if (isset($this->address_books[$id]) && ($this->address_books[$id] instanceof rcube_addressbook)) { - $book = $this->address_books[$id]; + if (isset(self::$address_books[$id]) && (self::$address_books[$id] instanceof rcube_addressbook)) { + $book = self::$address_books[$id]; } else if ($id && $ldap_config[$id]) { $book = new rcube_ldap($ldap_config[$id], $config->get('ldap_debug'), $config->mail_domain($_SESSION['storage_host'])); } if (!$book) { rcube::raise_error(array( 'code' => 700, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Addressbook source ($id) not found!"), true, false); return null; } /* // set configured sort order if ($sort_col = $this->config->get('addressbook_sort_col')) $book->set_sort_order($sort_col); */ // add to the 'books' array for shutdown function - $this->address_books[$id] = $book; + self::$address_books[$id] = $book; return $book; } /** * Return LDAP address books list * * @return array Address books array */ - protected function get_address_sources() + public static function get_address_sources() { $config = rcube::get_instance()->config; $ldap_config = (array) $config->get('ldap_public'); $async_books = $config->get('activesync_addressbooks'); if ($async_books === null) { $async_books = (array) $config->get('autocomplete_addressbooks'); } $list = array(); foreach ((array)$async_books as $id) { $prop = $ldap_config[$id]; - // handle misconfiguration - if (empty($prop) || !is_array($prop)) { - continue; + if (!empty($prop) && is_array($prop)) { + $list[$id] = array( + 'id' => $id, + 'name' => $prop['name'], + ); } - - $list[$id] = array( - 'id' => $id, - 'name' => $prop['name'], - ); -/* - // register source for shutdown function - if (!is_object($this->address_books[$id])) - $this->address_books[$id] = $list[$id]; - } -*/ } return $list; } /** * Creates contact key for sorting by */ protected function contact_key($row) { $key = $row['name'] . ':' . $row['sourceid']; // add email to a key to not skip contacts with the same name if (!empty($row['email'])) { if (is_array($row['email'])) { $key .= ':' . implode(':', $row['email']); } else { $key .= ':' . $row['email']; } } return $key; } /** * Extracts data from Roundcube LDAP data array */ protected function getLDAPDataItem($data, $name) { list($name, $index) = explode(':', $name); $name = str_replace('.', ':', $name); if (isset($data[$name])) { if ($index) { return is_array($data[$name]) ? $data[$name][$index] : null; } return is_array($data[$name]) ? array_shift($data[$name]) : $data[$name]; } return null; } }