diff --git a/lib/filter/mapistore.php b/lib/filter/mapistore.php index 36333f7..f748df9 100644 --- a/lib/filter/mapistore.php +++ b/lib/filter/mapistore.php @@ -1,636 +1,613 @@ | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ class kolab_api_filter_mapistore extends kolab_api_filter { protected $input; protected $api; protected $attrs_filter; /** * Modify request path * * @param array (Exploded) request path */ public function path(&$path) { // handle differences between OpenChange API and Kolab API // here we do only very basic modifications, just to be able // to select apprioprate api action class if ($path[0] == 'calendars') { $path[0] = 'events'; } } /** * Executed before every api action * * @param kolab_api_input Request */ public function input(&$input) { // handle differences between OpenChange API and Kolab API $this->input = $input; $this->api = $input->api; $this->common_action = !in_array($input->action, array('folders', 'info')); // convert / to // or /// if ($this->common_action && ($uid = $input->path[0])) { list($folder, $msg, $attach) = self::uid_decode($uid); $path = array($folder, $msg); if ($attach) { $path[] = $attach; } array_splice($input->path, 0, 1, $path); } // convert parent_id into path on object create request if ($input->method == 'POST' && $this->common_action && !count($input->path)) { $data = $input->input(null, true); if ($data['parent_id']) { $input->path[0] = $data['parent_id']; } else { throw new kolab_api_exception(kolab_api_exception::INVALID_REQUEST); } } // convert parent_id into path on object update request else if ($input->method == 'PUT' && $folder && count($input->path) == 2) { $data = $input->input(null, true); if ($data['parent_id'] && $data['parent_id'] != $folder) { $this->parent_change_handler($data); } } // handle actions on contact photo attachments switch ($input->action) { case 'attachments': $this->attachment_actions_handler(); break; case 'folders': $this->folder_actions_handler(); break; case 'notes': // Notes do not have attachments in Exchange if ($input->path[1] === 'attachments' || count($this->path) > 2) { throw new kolab_api_exception(kolab_api_exception::NOT_FOUND); } break; } } /** * Executed when parsing request body * * @param string Request data * @param string Expected object type * @param string Original object data (set on update requests) */ public function input_body(&$data, $type = null, $original_object = null) { // handle differences between OpenChange API and Kolab API // Note: input->path is already modified by input() and path() above switch ($this->input->action) { case 'folders': // folders//deletemessages input if ($this->input->path[1] == 'deleteobjects') { // Kolab API expects just a list of identifiers, I.e.: // [{"id": "1"}, {"id": "2"}] => ["1", "2"] foreach ((array) $data as $idx => $element) { $data[$idx] = $element['id']; } } break; } switch ($type) { case 'attachment': case 'event': case 'note': case 'task': case 'contact': case 'mail': case 'folder': $model = $this->get_model_class($type); $data = $model->input($data, $original_object); break; } } /** * Apply filter on output data * * @param array Result data * @param string Object type * @param array Context (folder_uid, object_uid, object) * @param array Optional attributes filter */ public function output(&$result, $type, $context = null, $attrs_filter = array()) { // handle differences between OpenChange API and Kolab API $model = $this->get_model_class($type); if (!empty($this->attrs_filter)) { $attrs_filter = array_combine($this->attrs_filter, $this->attrs_filter); } else if (!empty($attrs_filter)) { $attrs_filter = $this->attributes_filter($attrs_filter, $type, true); $attrs_filter = array_combine($attrs_filter, $attrs_filter); } // Add contact photo to attachments list if ($this->input->action == 'contacts' && $this->input->path[2] == 'attachments' && $context && !empty($context['object']) ) { if ($attachment = kolab_api_filter_mapistore_contact::photo_attachment($context['object'])) { $result[] = $attachment; } } foreach ($result as $idx => $data) { if ($filtered = $model->output($data, $context)) { // apply properties filter (again) if (!empty($attrs_filter)) { $filtered = array_intersect_key($filtered, $attrs_filter); } $result[$idx] = $filtered; } else { unset($result[$idx]); $unset = true; } } if ($unset) { $result = array_values($result); } // cleanup unset($_SESSION['uploads']['MAPIATTACH']); } /** * Executed for response headers * * @param array Response headers * @param array Context (folder_uid, object_uid, object) */ public function headers(&$headers, $context = null) { // handle differences between OpenChange API and Kolab API foreach ($headers as $name => $value) { switch ($name) { case 'X-Count': // Add contact photo to attachments count if ($this->input->action == 'contacts' && $this->input->path[2] == 'attachments' && $context && !empty($context['object']) && kolab_api_filter_mapistore_contact::photo_attachment($context['object']) ) { $value += 1; } $headers['X-mapistore-rowcount'] = $value; unset($headers[$name]); break; } } } /** * Executed for empty response status * * @param int Status code */ public function send_status(&$status) { // handle differences between OpenChange API and Kolab API if ($this->input->method == 'PUT' && !in_array($input->action, array('info'))) { // Mapistore expects 204 on object updates // however, we'd like to send modified UID of the object sometimes // $status = kolab_api_output::STATUS_EMPTY; } } /** * Converts kolab identifiers describind the object into * MAPI identifier that can be easily used in URL. * * @param string Folder UID * @param string Object UID * @param string Optional attachment identifier * * @return string Object identifier */ public static function uid_encode($folder_uid, $msg_uid, $attach_id = null) { $result = array($folder_uid, $msg_uid); if ($attach_id) { $result[] = $attach_id; } $result = array_map(array('kolab_api_filter_mapistore', 'uid_encode_item'), $result); return implode('.', $result); } /** * Converts back the MAPI identifier into kolab folder/object/attachment IDs * * @param string Object identifier * * @return array Object identifiers */ public static function uid_decode($uid) { $result = explode('.', $uid); $result = array_map(array('kolab_api_filter_mapistore', 'uid_decode_item'), $result); return $result; } /** * Encodes UID element */ protected static function uid_encode_item($str) { $fn = function($match) { return '_' . ord($match[1]); }; $str = preg_replace_callback('/([^0-9a-zA-Z-])/', $fn, $str); return $str; } /** * Decodes UID element */ protected static function uid_decode_item($str) { $fn = function($match) { return chr($match[1]); }; $str = preg_replace_callback('/_([0-9]{2})/', $fn, $str); return $str; } /** * Filter property names */ protected function attributes_filter($attrs, $type = null, $reverse = false) { $model = $this->get_model_class($type); return $model->attributes_filter($attrs, $reverse); } /** * Return instance of model class object */ protected function get_model_class($type) { $class = "kolab_api_filter_mapistore_$type"; return new $class($this); } /** * Handles object parent modification (move) */ protected function parent_change_handler($data) { $folder = $this->input->path[0]; $uid = $this->input->path[1]; $target = $data['parent_id']; $api = kolab_api::get_instance(); // move the object $api->backend->objects_move($folder, $target, array($uid)); // replace folder uid in input arguments $this->input->path[0] = $target; // exit if the rest of input is empty if (count($data) < 2) { $api->output->send_status(kolab_api_output::STATUS_EMPTY); } } /** * Overwrite attachment actions for contact photos */ protected function attachment_actions_handler() { if ($this->input->path[2] == kolab_api_filter_mapistore_contact::PHOTO_ATTACHMENT_ID) { $folder = $this->input->path[0]; $object_uid = $this->input->path[1]; $attach_uid = $this->input->path[2]; $object = $this->api->backend->object_get($folder, $object_uid); $attachment = kolab_api_filter_mapistore_contact::photo_attachment($object); $context = array( 'folder_uid' => $folder, 'object_uid' => $object_uid, 'object' => $object ); if (!$attachment) { throw new kolab_api_exception(kolab_api_exception::NOT_FOUND); } // fetch photo info/body if ($this->input->method == 'GET') { $this->api->output->send($attachment, 'attachment', $context); } // photo existence check else if ($this->input->method == 'HEAD') { $this->api->output->send_status(kolab_api_output::STATUS_OK); } // photo delete else if ($this->input->method == 'DELETE') { unset($object['photo']); $this->api->backend->object_update($folder, $object, 'contact'); $this->api->output->send_status(kolab_api_output::STATUS_OK); } // photo update else if ($this->input->method == 'PUT') { $data = file_get_contents('php://input'); $data = trim($data); $data = json_decode($data, true); if (empty($data) || empty($data['PidTagAttachDataBinary'])) { $error = "Invalid input for contact photo update request"; throw new kolab_api_exception(kolab_api_exception::INVALID_REQUEST, null, $error); } $object['photo'] = base64_decode($data['PidTagAttachDataBinary']); $this->api->backend->object_update($folder, $object, 'contact'); $this->api->output->send_status(kolab_api_output::STATUS_OK); } } // add photo to contact? else if ($this->input->path[0] && $this->input->path[1] && $this->input->method == 'POST') { $data = $this->input->input(null, true); if ($data['PidTagAttachmentContactPhoto']) { $folder = $this->input->path[0]; $object_uid = $this->input->path[1]; $object = $this->api->backend->object_get($folder, $object_uid); if (empty($data['PidTagAttachDataBinary'])) { $error = "Invalid input for contact photo create request"; throw new kolab_api_exception(kolab_api_exception::INVALID_REQUEST, null, $error); } $object['photo'] = base64_decode($data['PidTagAttachDataBinary']); $this->api->backend->object_update($folder, $object, 'contact'); $this->api->output->send_status(kolab_api_output::STATUS_OK); } } } /** * Overwrite folders actions */ protected function folder_actions_handler() { $input = $this->input; // in OpenChange folders/1/folders means get folders of the IPM Subtree if ($input->method == 'GET' && $input->path[0] === '1' && $input->path[1] == 'folders') { $input->path = array(); $type = 'folder'; } // in OpenChange folders/0/folders means get the hierarchy of the NON-IPM Subtree else if ($input->method == 'GET' && $input->path[0] === '0' && $input->path[1] == 'folders') { $list = $this->get_builtin_folder_list(0); $this->api->output->send($list, 'folder-list', null); } else if ($input->path[1] == 'messages') { $input->path[1] = 'objects'; if ($input->args['properties'] && !$this->is_builtin_folder($this->path[0])) { $type = $this->api->backend->folder_type($input->path[0]); list($type, ) = explode('.', $type); } } else if ($input->path[1] == 'deletemessages') { $input->path[1] = 'deleteobjects'; } // properties filter, map MAPI attribute names to Kolab attributes if ($type && $input->args['properties']) { $this->attrs_filter = explode(',', $this->input->args['properties']); $properties = $this->attributes_filter($this->attrs_filter, $type); $input->args['properties'] = implode(',', $properties); } // request for built-in folder if ($this->is_builtin_folder($input->path[0])) { $folder = $this->get_builtin_folder($input->path[0]); if (count($input->path) == 1) { // folder info if ($input->method == 'GET') { $this->api->output->send($folder, 'folder', null); } // folder exists else if ($input->method == 'HEAD') { $this->api->output->send_status(kolab_api_output::STATUS_OK); } throw new kolab_api_exception(kolab_api_exception::NOT_IMPLEMENTED); } else { switch (strtolower((string) $input->path[1])) { case 'objects': if ($input->method == 'HEAD') { $this->builtin_folder_count_objects(); } else if ($input->method == 'GET') { $this->builtin_folder_list_objects(); } break; case 'folders': if ($input->method == 'HEAD') { $this->builtin_folder_count_folders(); } else if ($input->method == 'GET') { $this->builtin_folder_list_folders(); } break; case 'empty': if ($input->method == 'POST') { $this->builtin_folder_empty(); } break; case 'deleteobjects': if ($input->method == 'POST') { $this->builtin_folder_delete_objects(); } break; } } throw new kolab_api_exception(kolab_api_exception::NOT_FOUND); } } /** * Returns list of built-in folders (NON-IPM subtree) */ protected function get_builtin_folder_list($parent = null) { $folders = kolab_api_filter_mapistore_folder::$builtin_folders; foreach ($folders as $idx => $folder) { if ($parent !== null && $parent != $folder['parent']) { unset($folders[$idx]); continue; } $folders[$idx] = array_merge(array( 'comment' => $folder['name'], 'uid' => $idx, 'hidden' => true, 'role' => 10, 'system_idx' => $idx, - 'item_count' => 0, // @TODO ), $folder); } return $folders; } /** * Returns built-in folder information */ protected function get_builtin_folder($uid) { $list = $this->get_builtin_folder_list(); $result = $list[$uid]; if (!$result) { throw new kolab_api_exception(kolab_api_exception::NOT_FOUND); } - if (strval($uid) === '1') { - $folders = $this->api->backend->folders_list(); - $type_rxp = '/^(mail|task|note|event|journal|contact)/'; - - // count real top-level folders - foreach ($folders as $folder) { - if (!$folder['parent'] && (!$folder['type'] || preg_match($type_rxp, $folder['type']))) { - $result['item_count']++; - } - } - - // @FIXME: should we add item_count+1 for Outbox - return $result; - } - - // count subfolders of the built-in folder - foreach ($list as $folder) { - if ($folder['parent'] == $uid) { - $result['item_count']++; - } - } - return $result; } /** * Check if specified uid is an uid of builtin folder */ protected function is_builtin_folder($uid) { return is_numeric($uid) && strlen($uid) < 4; } /** * Coun objects in built-in folder */ protected function builtin_folder_count_objects() { // @TODO $this->api->output->headers(array('X-Count' => 0)); $this->api->output->send_status(kolab_api_output::STATUS_OK); } /** * List objects in built-in folder */ protected function builtin_folder_list_objects() { // @TODO $this->api->output->send(array(), 'folder-list'); } /** * Count sub-folders of built-in folder */ protected function builtin_folder_count_folders() { // @TODO $this->api->output->headers(array('X-Count' => 0)); $this->api->output->send_status(kolab_api_output::STATUS_OK); } /** * List sub-folders in built-in folder */ protected function builtin_folder_list_folders() { // @TODO $this->api->output->send(array(), 'folder-list'); } /** * Delete all objects in built-in folder */ protected function builtin_folder_empty() { throw new kolab_api_exception(kolab_api_exception::NOT_IMPLEMENTED); } /** * Delete objects in built0in folder */ protected function builtin_folder_delete_objects() { throw new kolab_api_exception(kolab_api_exception::NOT_IMPLEMENTED); } } diff --git a/lib/filter/mapistore/folder.php b/lib/filter/mapistore/folder.php index e5d7fc8..72cfd66 100644 --- a/lib/filter/mapistore/folder.php +++ b/lib/filter/mapistore/folder.php @@ -1,328 +1,328 @@ | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ class kolab_api_filter_mapistore_folder extends kolab_api_filter_mapistore_common { const TYPE_ROOT = 0; const TYPE_GENERIC = 1; const TYPE_SEARCH = 2; const FOLDER_ROOT = 0; const FOLDER_MSGROOT = 1; const FOLDER_INBOX = 10; const FOLDER_OUTBOX = 11; const FOLDER_DRAFTS = 12; const FOLDER_SENT = 13; const FOLDER_DELETED_ITEMS = 14; const FOLDER_CONTACTS = 15; const FOLDER_CALENDAR = 16; const FOLDER_TASKS = 17; const FOLDER_NOTES = 18; const FOLDER_JOURNAL = 19; // const FOLDER_FALLBACK = -1; const FOLDER_DEFERRED = 101; const FOLDER_SPOOLER = 102; const FOLDER_COMMON_VIEWS = 103; const FOLDER_SCHEDULE = 104; const FOLDER_FINDER = 105; const FOLDER_VIEW = 106; const FOLDER_SHORTCUTS = 107; protected $model = 'folder'; protected $map = array( // [MS-OXCFOLD] read-only properties 'PidTagAccess' => '', 'PidTagChangeKey' => '', 'PidTagCreationTime' => '', // PtypTime, @TODO: store in folder annotation? 'PidTagLastModificationTime' => '', // PtypTime 'PidTagContentCount' => '', // PtypInteger32 'PidTagContentUnreadCount' => '', // PtypInteger32 'PidTagDeletedOn' => '', // PtypTime // 'PidTagAddressbookEntryId' => '', // PtypBinary 'PidTagFolderId' => '', // PtypInteger64 'PidTagHierarchyChangeNumber' => '', // PtypInteger32, 'PidTagMessageSize' => '', // PtypInteger32, size of all messages 'PidTagMessageSizeExtended' => '', // PtypInteger64 'PidTagSubfolders' => '', // PtypBoolean 'PidTagLocalCommitTime' => '', // PtypTime, last change time in UTC 'PidTagLocalCommitTimeMax' => '', // PtypTime 'PidTagDeletedCountTotal' => '', // PtypInteger32 // read-write properties 'PidTagAttributeHidden' => '', // PtypBoolean 'PidTagComment' => 'comment', // PtypString, @TODO: store in folder annotation? 'PidTagContainerClass' => 'type', // PtypString, IPF.* 'PidTagContainerHierarchy' => '', // PtypObject 'PidTagDisplayName' => 'name', // PtypString 'PidTagFolderAssociatedContents' => '', // PtypObject 'PidTagFolderType' => '', // PtypInteger32, 0 - namespace roots, 1 - other, 2 - virtual/search folders 'PidTagRights' => '', // PtypInteger32 'PidTagAccessControlListData' => '', // PtypBinary, see [MS-OXCPERM] ); protected $type_map = array( '' => 'IPF.Note', 'mail' => 'IPF.Note', 'task' => 'IPF.Task', 'note' => 'IPF.StickyNote', 'event' => 'IPF.Appointment', 'journal' => 'IPF.Journal', 'contact' => 'IPF.Contact', ); public static $builtin_folders = array( // Roots self::FOLDER_ROOT => array( 'name' => 'Root', 'comment' => '/', 'parent' => -1, 'hidden' => false, 'role' => -1, 'system_idx' => true, ), self::FOLDER_MSGROOT => array( 'name' => 'MsgRoot', 'comment' => '/', 'parent' => -1, 'hidden' => false, 'role' => -1, 'system_idx' => true, ), // IPM subtree (and openchange "contexts") self::FOLDER_INBOX => array( 'name' => 'INBOX', 'parent' => self::FOLDER_MSGROOT, 'role' => 0, 'type' => 'mail.inbox', ), self::FOLDER_OUTBOX => array( 'name' => 'Outbox', 'parent' => self::FOLDER_MSGROOT, 'role' => 3, ), self::FOLDER_DRAFTS => array( 'name' => 'Drafts', 'parent' => self::FOLDER_MSGROOT, 'role' => 1, 'type' => 'mail.drafts', ), self::FOLDER_SENT => array( 'name' => 'Sent Items', 'parent' => self::FOLDER_MSGROOT, 'role' => 2, 'type' => 'mail.sentitems', ), self::FOLDER_DELETED_ITEMS => array( 'name' => 'Deleted Items', 'parent' => self::FOLDER_MSGROOT, 'role' => 4, 'type' => 'mail.wastebasket', ), self::FOLDER_CONTACTS => array( 'name' => 'Contacts', 'parent' => self::FOLDER_MSGROOT, 'role' => 6, 'type' => 'contact.default', ), self::FOLDER_CALENDAR => array( 'name' => 'Calendar', 'parent' => self::FOLDER_MSGROOT, 'role' => 5, 'type' => 'event.default', ), self::FOLDER_TASKS => array( 'name' => 'Tasks', 'parent' => self::FOLDER_MSGROOT, 'role' => 7, 'type' => 'task.default', ), self::FOLDER_NOTES => array( 'name' => 'Notes', 'parent' => self::FOLDER_MSGROOT, 'role' => 8, 'type' => 'note.default', ), /* self::FOLDER_JOURNAL => array( 'name' => 'Journal', 'parent' => self::FOLDER_MSGROOT, 'role' => 9, 'type' => 'journal.default', ), */ // Non-IPM Subtree self::FOLDER_DEFERRED => array( 'name' => 'Deferred Action', 'parent' => self::FOLDER_ROOT, ), self::FOLDER_SPOOLER => array( 'name' => 'Spooler Queue', 'parent' => self::FOLDER_ROOT, ), self::FOLDER_COMMON_VIEWS => array( 'name' => 'Common Views', 'parent' => self::FOLDER_ROOT, ), self::FOLDER_SCHEDULE => array( 'name' => 'Schedule', 'parent' => self::FOLDER_ROOT, ), self::FOLDER_FINDER => array( 'name' => 'Finder', 'parent' => self::FOLDER_ROOT, ), self::FOLDER_VIEW => array( 'name' => 'View', 'parent' => self::FOLDER_ROOT, ), self::FOLDER_SHORTCUTS => array( 'name' => 'Shortcuts', 'parent' => self::FOLDER_ROOT, ), ); /** * Convert Kolab to MAPI * * @param array Data * @param array Context (folder_uid, object_uid, object) * * @return array Data */ public function output($data, $context = null) { list($type, ) = explode('.', $data['type']); $type = $this->type_map[(string)$type]; // skip folders of unsupported type if (empty($type)) { return; } if (!isset($data['parent'])) { $data['parent'] = 1; } // skip folders that are not subfolders of the specified folder, // in list-mode MAPI always requests for one-level of the hierarchy (?) if ($api->input->path[1] == 'folders') { $api = kolab_api::get_instance(); $parent = $api->input->path[0]; if ($data['parent'] != $parent) { return; } } $result = array( // mapistore properties 'id' => $data['uid'], 'collection' => 'folders', // MAPI properties 'PidTagDisplayName' => $data['name'], 'PidTagContainerClass' => $type, ); if ($data['uid'] === 1 || $data['uid'] === 0) { $result['PidTagFolderType'] = self::TYPE_ROOT; } else { $result['PidTagFolderType'] = self::TYPE_GENERIC; } if (isset($data['parent'])) { $result['parent_id'] = $data['parent']; } if (isset($data['comment'])) { $result['PidTagComment'] = $data['comment']; } // special Mapistore props - foreach (array('role', 'item_count', 'system_idx', 'hidden') as $prop) { + foreach (array('role', 'system_idx', 'hidden') as $prop) { if (isset($data[$prop])) { $result[$prop] = $data[$prop]; } } $result = array_filter($result, function($v) { return $v !== null; }); return $result; } /** * Convert from MAPI to Kolab * * @param array Data * @param array Data of the object that is being updated * * @return array Data */ public function input($data, $object = null) { $result = array(); // mapistore properties if ($data['id']) { $result['uid'] = $data['id']; } if ($data['parent_id']) { $result['parent'] = $data['parent_id']; } // MAPI properties if ($data['PidTagDisplayName']) { $result['name'] = $data['PidTagDisplayName']; } if ($data['PidTagContainerClass']) { // @TODO: what if folder is already a *.default or *.sentitems, etc. // we should keep the subtype intact $map = array_flip($this->type_map); $result['type'] = $map[$data['PidTagContainerClass']]; } return $result; } /** * Returns the attributes names mapping */ public function map() { $map = array_filter($this->map); $map['parent_id'] = 'parent'; $map['PidTagContainerClass'] = 'type'; $map['PidTagFolderType'] = 'PidTagFolderType'; return $map; } }