diff --git a/doc/events.rst b/doc/events.rst index 4cfad8d..9bdb077 100644 --- a/doc/events.rst +++ b/doc/events.rst @@ -1,100 +1,131 @@ =========== ``/events`` =========== ``GET /events//`` ======================================== Request an event. .. NOTE:: Obtain the folder uid from ``/folders``, and the event uid from ``/folders//objects`` -**Example Result** +**Example** ``GET /events/6c11cd1e5283576e/1D41B2DB805B93596B85F6B7BCAD5700-9EFCC9880FAF1EAB`` .. parsed-literal:: { "class": "PUBLIC", "created": "2015-02-05T05:26:20Z", "dtend": { "date-time": "2015-02-06T12:30:00", "parameters": { "tzid": "/kolab.org/Europe/Berlin" } }, "dtstamp": "2015-02-05T05:26:20Z", "dtstart": { "date-time": "2015-02-06T06:30:00", "parameters": { "tzid": "/kolab.org/Europe/Berlin" } }, "organizer": { "cal-address": "mailto:%3Cjeroen%40kolab.org%3E", "parameters": { "cn": "van Meeuwen, Jeroen" } }, "sequence": 0, "summary": "test", "uid": "1D41B2DB805B93596B85F6B7BCAD5700-9EFCC9880FAF1EAB" } ``GET /events///attachments`` ===================================================== List the attachments on the object, if any. -**Example Result** +**Example** ``GET /events/6c11cd1e5283576e/1D41B2DB805B93596B85F6B7BCAD5700-9EFCC9880FAF1EAB/attachments`` .. parsed-literal:: [ { "id": "3", "mimetype": "application\/pdf", "size": 239932, "filename": "doc.pdf", "disposition":"attachment" } ] ``HEAD /events///attachments`` ====================================================== Count the attachments on the object. Returns `X-Count` header with a numeric value. ``DELETE /events//`` ============================================ Delete an existing event object. On success code 204 is returned. ``POST /events`` ================ Create a new event in the default calendar folder. .. NOTE:: Not yet implemented. ``POST /events/`` ============================= Create a new event in specified folder. ``HEAD /events//`` ========================================== Check if the event object exists. If object exists code 200 is returned, 404 otherwise. ``PUT /events//`` ========================================= Update an existing event object. + +``GET /events/inbox`` +===================== + +Request a list of iTip objects from user INBOX folder. List will contain entries +in an event format with additional ``itip`` item describing the mail message. + +.. NOTE:: + + The response includes properties that can be filtered. For example, + ``GET /events/inbox?properties=uid`` will return only the uids of events. + +**Example** + +``GET /events/inbox`` + +.. parsed-literal:: + + [ + { + "summary": "test", + "uid": "1D41B2DB805B93596B85F6B7BCAD5700-9EFCC9880FAF1EAB", + ..., + "itip": { + "method": "REQUEST", + "uri": "mails/35d7656c-d70e-4443-94ac-d8ae1dd45ed3/61", + "from": "\"Sand, Carol\" ", + "subject": "You have been invited to \"test\"" + } + } + ] diff --git a/lib/api/events.php b/lib/api/events.php index b7f0e5e..cec2ca5 100644 --- a/lib/api/events.php +++ b/lib/api/events.php @@ -1,29 +1,55 @@ | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ class kolab_api_events extends kolab_api { protected $model = 'event'; + + public function run() + { + $this->initialize_handler(); + + $path = $this->input->path; + $method = $this->input->method; + + if ($path[0] === 'inbox' && $method == 'GET') { + $this->api_event_inbox(); + } + + parent::run(); + } + + /** + * Returns list of event invitations in the INBOX folder + */ + protected function api_event_inbox() + { + $cal = new kolab_api_calendaring($this->backend); + $list = $cal->get_scheduling_objects($this->model); + $props = $this->input->args['properties'] ? explode(',', $this->input->args['properties']) : null; + + $this->output->send($list, $this->model . '-list', null, $props); + } } diff --git a/lib/kolab_api.php b/lib/kolab_api.php index 30f8097..0ec6d78 100644 --- a/lib/kolab_api.php +++ b/lib/kolab_api.php @@ -1,479 +1,486 @@ | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ class kolab_api extends rcube { const APP_NAME = 'Kolab REST API'; const VERSION = '0.1'; public $backend; public $filter; public $input; public $output; /** * Current time in UTC. Use it to override * system time, e.g. for unit-testing. * * @var DateTime */ public static $now; protected $model; + protected $initialized = false; /** * This implements the 'singleton' design pattern * * @return kolab_api The one and only instance */ public static function get_instance() { if (!self::$instance || !is_a(self::$instance, 'kolab_api')) { $path = kolab_api_input::request_path(); $request = array_shift($path) ?: 'info'; $class = 'kolab_api_' . $request; if (!$request || !class_exists($class)) { throw new kolab_api_exception(kolab_api_exception::NOT_FOUND, array( 'line' => __LINE__, 'file' => __FILE__, 'message' => "Invalid request method: $request" )); } self::$instance = new $class(); self::$instance->startup(); } return self::$instance; } /** * Initial startup function * to register session, create database and imap connections */ protected function startup() { $this->init(self::INIT_WITH_DB | self::INIT_WITH_PLUGINS); // Get list of plugins // WARNING: We can use only plugins that are prepared for this // e.g. are not using output or rcmail objects or // doesn't throw errors when using them $plugins = (array) $this->config->get('kolab_api_plugins', array('kolab_auth')); - $plugins = array_unique(array_merge($plugins, array('libkolab'))); + $plugins = array_unique(array_merge($plugins, array('libkolab', 'libcalendaring'))); // this way we're compatible with Roundcube Framework 1.2 // we can't use load_plugins() here foreach ($plugins as $plugin) { $this->plugins->load_plugin($plugin, true); } } /** * Exception handler * * @param kolab_api_exception Exception */ public static function exception_handler($exception) { $code = $exception->getCode(); $message = $exception->getMessage(); if ($code == 401) { header('WWW-Authenticate: Basic realm="' . self::APP_NAME .'"'); } if (!$exception instanceof kolab_api_exception) { rcube::raise_error($exception, true, false); } header("HTTP/1.1 $code $message"); exit; } /** * Program execution handler */ protected function initialize_handler() { + if ($this->initialized) { + return; + } + + $this->initialized = true; + // Handle request input $this->input = kolab_api_input::factory($this); // Get input/output filter $this->filter = $this->input->filter; // Start session, validate it and authenticate the user if needed if (!$this->session_validate()) { $this->authenticate(); $authenticated = true; } // Initialize backend $this->backend = kolab_api_backend::get_instance(); // set response output class $this->output = kolab_api_output::factory($this); // Filter the input, we want this after authentication if ($this->filter) { $this->filter->input($this->input); } if ($authenticated) { $this->output->headers(array('X-Session-Token' => session_id())); } } /** * Script shutdown handler */ public function shutdown() { parent::shutdown(); // write performance stats to logs/console if ($this->config->get('devel_mode')) { if (function_exists('memory_get_peak_usage')) $mem = memory_get_peak_usage(); else if (function_exists('memory_get_usage')) $mem = memory_get_usage(); $log = trim(kolab_api_input::request_uri() . ($mem ? sprintf(' [%.1f MB]', $mem/1024/1024) : '')); if (defined('KOLAB_API_START')) { rcube::print_timer(KOLAB_API_START, $log); } else { rcube::console($log); } } } /** * Validate the submitted session token */ protected function session_validate() { $sess_id = $this->input->request_header('X-Session-Token'); if (empty($sess_id)) { session_start(); return false; } session_id($sess_id); session_start(); // Session timeout $timeout = $this->config->get('kolab_api_session_timeout'); if ($timeout && $_SESSION['time'] && $_SESSION['time'] < time() - $timeout) { $_SESSION = array(); return false; } // update session time $_SESSION['time'] = time(); return true; } /** * Authentication request handler (HTTP Auth) */ protected function authenticate() { if (!empty($_SERVER['PHP_AUTH_USER'])) { $username = $_SERVER['PHP_AUTH_USER']; $password = $_SERVER['PHP_AUTH_PW']; } // when used with (f)cgi no PHP_AUTH* variables are available without defining a special rewrite rule else if (!isset($_SERVER['PHP_AUTH_USER'])) { // "Basic didhfiefdhfu4fjfjdsa34drsdfterrde..." if (isset($_SERVER['REMOTE_USER'])) { $basicAuthData = base64_decode(substr($_SERVER['REMOTE_USER'], 6)); } else if (isset($_SERVER['REDIRECT_REMOTE_USER'])) { $basicAuthData = base64_decode(substr($_SERVER['REDIRECT_REMOTE_USER'], 6)); } else if (isset($_SERVER['Authorization'])) { $basicAuthData = base64_decode(substr($_SERVER['Authorization'], 6)); } else if (isset($_SERVER['HTTP_AUTHORIZATION'])) { $basicAuthData = base64_decode(substr($_SERVER['HTTP_AUTHORIZATION'], 6)); } if (isset($basicAuthData) && !empty($basicAuthData)) { list($username, $password) = explode(':', $basicAuthData); } } if (!empty($username)) { $backend = kolab_api_backend::get_instance(); $result = $backend->authenticate($username, $password); } if (empty($result)) { throw new kolab_api_exception(kolab_api_exception::UNAUTHORIZED); } $_SESSION['time'] = time(); } /** * Handle API request */ public function run() { $this->initialize_handler(); $path = $this->input->path; $method = $this->input->method; if (!$path[1] && $path[0] && $method == 'POST') { $this->api_object_create(); } else if ($path[1]) { switch (strtolower($path[2])) { case 'attachments': if ($method == 'HEAD') { $this->api_object_count_attachments(); } else if ($method == 'GET') { $this->api_object_list_attachments(); } break; case '': if ($method == 'GET') { $this->api_object_info(); } else if ($method == 'PUT') { $this->api_object_update(); } else if ($method == 'HEAD') { $this->api_object_exists(); } else if ($method == 'DELETE') { $this->api_object_delete(); } } } throw new kolab_api_exception(kolab_api_exception::NOT_FOUND); } /** * Fetch object info */ protected function api_object_info() { $folder = $this->input->path[0]; $uid = $this->input->path[1]; $object = $this->backend->object_get($folder, $uid); $context = array('folder_uid' => $folder, 'object' => $object); $props = $this->input->args['properties'] ? explode(',', $this->input->args['properties']) : null; $this->output->send($object, $this->model, $context, $props); } /** * Create an object */ protected function api_object_create() { $folder = $this->input->path[0]; $input = $this->input->input($this->model); $context = array('folder_uid' => $folder); $uid = $this->backend->object_create($folder, $input, $this->model); $this->output->send(array('uid' => $uid), $this->model, $context, array('uid')); } /** * Update specified object */ protected function api_object_update() { $folder = $this->input->path[0]; $uid = $this->input->path[1]; $object = $this->backend->object_get($folder, $uid); $context = array( 'folder_uid' => $folder, 'object_uid' => $uid, 'object' => $object, ); // parse input and merge with current data (result is in kolab_format/kolab_api_mail) $input = $this->input->input($this->model, false, $object); // update object on the backend $uid = $this->backend->object_update($folder, $input, $this->model); $this->output->send(array('uid' => $uid), $this->model, $context, array('uid')); } /** * Check if specified object exists */ protected function api_object_exists() { $folder = $this->input->path[0]; $uid = $this->input->path[1]; $object = $this->backend->object_get($folder, $uid); $this->output->send_status(kolab_api_output::STATUS_OK); } /** * Remove specified object */ protected function api_object_delete() { $folder = $this->input->path[0]; $uid = $this->input->path[1]; $object = $this->backend->object_get($folder, $uid); $this->backend->objects_delete($folder, array($uid)); $this->output->send_status(kolab_api_output::STATUS_EMPTY); } /** * Count object attachments */ protected function api_object_count_attachments() { $folder = $this->input->path[0]; $uid = $this->input->path[1]; $object = $this->backend->object_get($folder, $uid); $context = array( 'folder_uid' => $folder, 'object_uid' => $uid, 'object' => $object, ); $count = !empty($object['_attachments']) ? count($object['_attachments']) : 0; $this->output->headers(array('X-Count' => $count), $context); $this->output->send_status(kolab_api_output::STATUS_OK); } /** * List object attachments */ protected function api_object_list_attachments() { $folder = $this->input->path[0]; $uid = $this->input->path[1]; $object = $this->backend->object_get($folder, $uid); $props = $this->input->args['properties'] ? explode(',', $this->input->args['properties']) : null; $context = array( 'folder_uid' => $folder, 'object_uid' => $uid, 'object' => $object, ); // @TODO: currently Kolab format (libkolabxml) allows attachments // in events, tasks and notes. We should support them also in contacts $list = $this->get_object_attachments($object); $this->output->send($list, 'attachment-list', $context, $props); } /** * Extract attachments from the object, depending if it's * Kolab object or email message */ protected function get_object_attachments($object) { // this is a kolab_format object data if (is_array($object)) { $list = (array) $object['_attachments']; foreach ($list as $idx => $att) { $attachment = new rcube_message_part; $attachment->mime_id = $att['id']; $attachment->filename = $att['name']; $attachment->mimetype = $att['mimetype']; $attachment->size = $att['size']; $attachment->disposition = 'attachment'; $attachment->encoding = $att['encoding']; $list[$idx] = $attachment; } } // this is kolab_api_mail or rcube_message(_header) else { $list = (array) $object->attachments; } return $list; } /** * Convert kolab_format object into API format * * @param array Object data in kolab_format * @param string Object type * * @return array Object data in API format */ public function get_object_data($object, $type) { $output = $this->output; if (!$this->output instanceof kolab_api_output_json) { $class = "kolab_api_output_json"; $output = new $class($this); } return $output->convert($object, $type); } /** * Returns RFC2822 formatted current date in user's timezone * * @return string Date */ public function user_date() { // get user's timezone try { $tz = new DateTimeZone($this->config->get('timezone')); $date = self::$now ?: new DateTime('now'); $date->setTimezone($tz); } catch (Exception $e) { $date = new DateTime(); } return $date->format('r'); } } diff --git a/lib/kolab_api_calendaring.php b/lib/kolab_api_calendaring.php new file mode 100644 index 0000000..fff0884 --- /dev/null +++ b/lib/kolab_api_calendaring.php @@ -0,0 +1,151 @@ + | + +--------------------------------------------------------------------------+ + | Author: Aleksander Machniak | + +--------------------------------------------------------------------------+ +*/ + +/** + * Utility class with calendaring related functionality + */ +class kolab_api_calendaring +{ + protected $backend; + + /** + * Constructor + * + * @param kolab_api_backend Backend object + */ + function __construct($backend) + { + $this->backend = $backend; + } + + /** + * Returns all scheduling (invitation) objects for the INBOX folder. + * + * @param string $type Object type: 'event' or 'task' + * + * @return array + */ + public function get_scheduling_objects($type) + { + // find iTip messages in users email INBOX and extract the ics attachment + + $results = array(); + $folder_uid = $this->backend->folder_name2uid('INBOX'); + + // TODO: Should we have a default time limit, so we don't search the whole + // folder all the time, but e.g. only the last month? + + $search = $this->backend->storage->search_once('INBOX', + 'UNDELETED OR OR' + . ' HEADER Content-Type text/calendar' + . ' HEADER Content-Type multipart/mixed' + . ' HEADER Content-Type multipart/alternative' + ); + + foreach ($search->get() as $msguid) { + // get bodystructure and check for iTip parts + $message = new rcube_message($msguid, 'INBOX'); + $parts = array(); + + foreach ((array) $message->mime_parts as $part) { + if (self::part_is_itip($part)) { + $parts[] = $part; + } + } + + // parse iTip data + foreach ($parts as $part) { + if ($ical = $message->get_part_body($part->mime_id)) { + if ($event = $this->parse_ical($ical)) { + if ($event['_type'] != $type) { + continue; + } +/* + // filter past event invitations + if (is_a($event['end'], '\\DateTime') && $event['end'] < $threshold && empty($event['recurrence'])) { + continue; + } +*/ + $event['itip'] = array( + 'method' => $event['_method'], + 'uri' => sprintf('mails/%s/%s', urlencode($folder_uid), urlencode($message->uid)), + 'from' => $message->headers->get('from'), + 'subject' => $message->headers->get('subject'), + ); + + $results[] = $event; + } + } + } + } + + return $results; + } + + /** + * Checks if specified message part is a iCal data + * + * @param rcube_message_part Part object + * + * @return boolean True if part is of type iCal + */ + protected static function part_is_itip($part) + { + return !empty($part->ctype_parameters['method']) + && ( + in_array($part->mimetype, array('text/calendar', 'text/x-vcalendar', 'application/ics')) + || ($part->mimetype == 'application/x-any' && $part->filename && preg_match('/\.ics$/i', $part->filename)) + ); + } + + /** + * Parse the given iCal string into a hash array kolab_format_event can handle + * + * @param string $data iCal data block + * + * @return array Hash array with event properties or null on failure + */ + protected function parse_ical($data) + { + try { + $ical = libcalendaring::get_ical(); + $objects = $ical->import($data); + + // return the first object + if (count($objects)) { + $objects[0]['_method'] = $ical->method; + return $objects[0]; + } + } + catch (VObject\ParseException $e) { + rcube::raise_error(array( + 'code' => 600, + 'file' => __FILE__, + 'line' => __LINE__, + 'message' => "iCal data parse error: " . $e->getMessage()), + true, false); + } + } +} diff --git a/lib/output/json/event.php b/lib/output/json/event.php index 36a3517..d815ee4 100644 --- a/lib/output/json/event.php +++ b/lib/output/json/event.php @@ -1,102 +1,112 @@ | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ class kolab_api_output_json_event { protected $output; protected $attrs_filter; protected $array_elements = array( 'attach', 'attendee', 'categories', 'x-custom', 'valarm', ); + protected $extra_items = array( + 'itip', + ); /** * Object constructor * * @param kolab_api_output Output object */ public function __construct($output) { $this->output = $output; } /** * Convert data into an array * * @param array Data * @param array Optional attributes filter * * @return array Data */ public function element($data, $attrs_filter = array()) { // partial data if (is_array($data) && count($data) == 1) { return $data; } $this->attrs_filter = $attrs_filter; $result = $this->output->object_to_array($data, 'event', 'vevent'); $result = array_map(array($this, 'subelement'), $result); $event = array_shift($result); if (!empty($result) && (empty($attrs_filter) || in_array('exceptions', $attrs_filter))) { $event['exceptions'] = $result; } + // Add extra items that do not belong to Kolab format, e.g. iTip data + foreach ($this->extra_items as $item) { + if (isset($data[$item])) { + $event[$item] = $data[$item]; + } + } + return $event; } /** * Event properties converter */ protected function subelement($element) { if (!empty($this->attrs_filter)) { $element['properties'] = array_intersect_key($element['properties'], array_combine($this->attrs_filter, $this->attrs_filter)); } // add 'components' to the result if (!empty($element['components'])) { $element['properties'] += (array) $element['components']; } $element = $element['properties']; kolab_api_output_json::parse_array_result($element, $this->array_elements); // make sure exdate/rdate format is unified kolab_api_output_json::parse_recurrence($element); return $element; } }