diff --git a/plugins/libcalendaring/libvcalendar.php b/plugins/libcalendaring/libvcalendar.php index e7beef8d..1884bbab 100644 --- a/plugins/libcalendaring/libvcalendar.php +++ b/plugins/libcalendaring/libvcalendar.php @@ -1,1383 +1,1383 @@ * * Copyright (C) 2013-2015, 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 . */ use \Sabre\VObject; use \Sabre\VObject\DateTimeParser; /** * Class to parse and build vCalendar (iCalendar) files * * Uses the Sabre VObject library, version 3.x. * */ class libvcalendar implements Iterator { private $timezone; private $attach_uri = null; private $prodid = '-//Roundcube libcalendaring//Sabre//Sabre VObject//EN'; private $type_component_map = array('event' => 'VEVENT', 'task' => 'VTODO'); private $attendee_keymap = array( 'name' => 'CN', 'status' => 'PARTSTAT', 'role' => 'ROLE', 'cutype' => 'CUTYPE', 'rsvp' => 'RSVP', 'delegated-from' => 'DELEGATED-FROM', 'delegated-to' => 'DELEGATED-TO', 'schedule-status' => 'SCHEDULE-STATUS', ); private $iteratorkey = 0; private $charset; private $forward_exceptions; private $vhead; private $fp; private $vtimezones = array(); public $method; public $agent = ''; public $objects = array(); public $freebusy = array(); /** * Default constructor */ function __construct($tz = null) { $this->timezone = $tz; $this->prodid = '-//Roundcube libcalendaring ' . RCUBE_VERSION . '//Sabre//Sabre VObject ' . VObject\Version::VERSION . '//EN'; } /** * Setter for timezone information */ public function set_timezone($tz) { $this->timezone = $tz; } /** * Setter for URI template for attachment links */ public function set_attach_uri($uri) { $this->attach_uri = $uri; } /** * Setter for a custom PRODID attribute */ public function set_prodid($prodid) { $this->prodid = $prodid; } /** * Setter for a user-agent string to tweak input/output accordingly */ public function set_agent($agent) { $this->agent = $agent; } /** * Free resources by clearing member vars */ public function reset() { $this->vhead = ''; $this->method = ''; $this->objects = array(); $this->freebusy = array(); $this->vtimezones = array(); $this->iteratorkey = 0; if ($this->fp) { fclose($this->fp); $this->fp = null; } } /** * Import events from iCalendar format * * @param string vCalendar input * @param string Input charset (from envelope) * @param boolean True if parsing exceptions should be forwarded to the caller * @return array List of events extracted from the input */ public function import($vcal, $charset = 'UTF-8', $forward_exceptions = false, $memcheck = true) { // TODO: convert charset to UTF-8 if other try { // estimate the memory usage and try to avoid fatal errors when allowed memory gets exhausted if ($memcheck) { $count = substr_count($vcal, 'BEGIN:VEVENT') + substr_count($vcal, 'BEGIN:VTODO'); $expected_memory = $count * 70*1024; // assume ~ 70K per event (empirically determined) if (!rcube_utils::mem_check($expected_memory)) { throw new Exception("iCal file too big"); } } $vobject = VObject\Reader::read($vcal, VObject\Reader::OPTION_FORGIVING | VObject\Reader::OPTION_IGNORE_INVALID_LINES); if ($vobject) return $this->import_from_vobject($vobject); } catch (Exception $e) { if ($forward_exceptions) { throw $e; } else { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "iCal data parse error: " . $e->getMessage()), true, false); } } return array(); } /** * Read iCalendar events from a file * * @param string File path to read from * @param string Input charset (from envelope) * @param boolean True if parsing exceptions should be forwarded to the caller * @return array List of events extracted from the file */ public function import_from_file($filepath, $charset = 'UTF-8', $forward_exceptions = false) { if ($this->fopen($filepath, $charset, $forward_exceptions)) { while ($this->_parse_next(false)) { // nop } fclose($this->fp); $this->fp = null; } return $this->objects; } /** * Open a file to read iCalendar events sequentially * * @param string File path to read from * @param string Input charset (from envelope) * @param boolean True if parsing exceptions should be forwarded to the caller * @return boolean True if file contents are considered valid */ public function fopen($filepath, $charset = 'UTF-8', $forward_exceptions = false) { $this->reset(); // just to be sure... @ini_set('auto_detect_line_endings', true); $this->charset = $charset; $this->forward_exceptions = $forward_exceptions; $this->fp = fopen($filepath, 'r'); // check file content first $begin = fread($this->fp, 1024); if (!preg_match('/BEGIN:VCALENDAR/i', $begin)) { return false; } fseek($this->fp, 0); return $this->_parse_next(); } /** * Parse the next event/todo/freebusy object from the input file */ private function _parse_next($reset = true) { if ($reset) { $this->iteratorkey = 0; $this->objects = array(); $this->freebusy = array(); } $next = $this->_next_component(); $buffer = $next; // load the next component(s) too, as they could contain recurrence exceptions while (preg_match('/(RRULE|RECURRENCE-ID)[:;]/i', $next)) { $next = $this->_next_component(); $buffer .= $next; } // parse the vevent block surrounded with the vcalendar heading if (strlen($buffer) && preg_match('/BEGIN:(VEVENT|VTODO|VFREEBUSY)/i', $buffer)) { try { $this->import($this->vhead . $buffer . "END:VCALENDAR", $this->charset, true, false); } catch (Exception $e) { if ($this->forward_exceptions) { throw new VObject\ParseException($e->getMessage() . " in\n" . $buffer); } else { // write the failing section to error log rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => $e->getMessage() . " in\n" . $buffer), true, false); } // advance to next return $this->_parse_next($reset); } return count($this->objects) > 0; } return false; } /** * Helper method to read the next calendar component from the file */ private function _next_component() { $buffer = ''; $vcalendar_head = false; while (($line = fgets($this->fp, 1024)) !== false) { // ignore END:VCALENDAR lines if (preg_match('/END:VCALENDAR/i', $line)) { continue; } // read vcalendar header (with timezone defintion) if (preg_match('/BEGIN:VCALENDAR/i', $line)) { $this->vhead = ''; $vcalendar_head = true; } // end of VCALENDAR header part if ($vcalendar_head && preg_match('/BEGIN:(VEVENT|VTODO|VFREEBUSY)/i', $line)) { $vcalendar_head = false; } if ($vcalendar_head) { $this->vhead .= $line; } else { $buffer .= $line; if (preg_match('/END:(VEVENT|VTODO|VFREEBUSY)/i', $line)) { break; } } } return $buffer; } /** * Import objects from an already parsed Sabre\VObject\Component object * * @param object Sabre\VObject\Component to read from * @return array List of events extracted from the file */ public function import_from_vobject($vobject) { $seen = array(); $exceptions = array(); if ($vobject->name == 'VCALENDAR') { $this->method = strval($vobject->METHOD); $this->agent = strval($vobject->PRODID); foreach ($vobject->getComponents() as $ve) { if ($ve->name == 'VEVENT' || $ve->name == 'VTODO') { // convert to hash array representation $object = $this->_to_array($ve); // temporarily store this as exception if ($object['recurrence_date']) { $exceptions[] = $object; } else if (!$seen[$object['uid']]++) { $this->objects[] = $object; } } else if ($ve->name == 'VFREEBUSY') { $this->objects[] = $this->_parse_freebusy($ve); } } // add exceptions to the according master events foreach ($exceptions as $exception) { $uid = $exception['uid']; // make this exception the master if (!$seen[$uid]++) { $this->objects[] = $exception; } else { foreach ($this->objects as $i => $object) { // add as exception to existing entry with a matching UID if ($object['uid'] == $uid) { $this->objects[$i]['exceptions'][] = $exception; if (!empty($object['recurrence'])) { $this->objects[$i]['recurrence']['EXCEPTIONS'] = &$this->objects[$i]['exceptions']; } break; } } } } } return $this->objects; } /** * Getter for free-busy periods */ public function get_busy_periods() { $out = array(); foreach ((array)$this->freebusy['periods'] as $period) { if ($period[2] != 'FREE') { $out[] = $period; } } return $out; } /** * Helper method to determine whether the connected client is an Apple device */ private function is_apple() { return stripos($this->agent, 'Apple') !== false || stripos($this->agent, 'Mac OS X') !== false || stripos($this->agent, 'iOS/') !== false; } /** * Convert the given VEvent object to a libkolab compatible array representation * * @param object Vevent object to convert * @return array Hash array with object properties */ private function _to_array($ve) { $event = array( 'uid' => self::convert_string($ve->UID), 'title' => self::convert_string($ve->SUMMARY), '_type' => $ve->name == 'VTODO' ? 'task' : 'event', // set defaults 'priority' => 0, 'attendees' => array(), 'x-custom' => array(), ); // Catch possible exceptions when date is invalid (Bug #2144) // We can skip these fields, they aren't critical foreach (array('CREATED' => 'created', 'LAST-MODIFIED' => 'changed', 'DTSTAMP' => 'changed') as $attr => $field) { try { if (!$event[$field] && $ve->{$attr}) { $event[$field] = $ve->{$attr}->getDateTime(); } } catch (Exception $e) {} } // map other attributes to internal fields foreach ($ve->children as $prop) { if (!($prop instanceof VObject\Property)) continue; $value = strval($prop); switch ($prop->name) { case 'DTSTART': case 'DTEND': case 'DUE': $propmap = array('DTSTART' => 'start', 'DTEND' => 'end', 'DUE' => 'due'); $event[$propmap[$prop->name]] = self::convert_datetime($prop); break; case 'TRANSP': $event['free_busy'] = strval($prop) == 'TRANSPARENT' ? 'free' : 'busy'; break; case 'STATUS': if ($value == 'TENTATIVE') $event['free_busy'] = 'tentative'; else if ($value == 'CANCELLED') $event['cancelled'] = true; else if ($value == 'COMPLETED') $event['complete'] = 100; $event['status'] = $value; break; case 'PRIORITY': if (is_numeric($value)) $event['priority'] = $value; break; case 'RRULE': $params = is_array($event['recurrence']) ? $event['recurrence'] : array(); // parse recurrence rule attributes foreach ($prop->getParts() as $k => $v) { $params[strtoupper($k)] = $v; } if ($params['UNTIL']) $params['UNTIL'] = date_create($params['UNTIL']); if (!$params['INTERVAL']) $params['INTERVAL'] = 1; $event['recurrence'] = array_filter($params); break; case 'EXDATE': if (!empty($value)) { $exdates = array_map(function($_) { return is_array($_) ? $_[0] : $_; }, self::convert_datetime($prop, true)); $event['recurrence']['EXDATE'] = array_merge((array)$event['recurrence']['EXDATE'], $exdates); } break; case 'RDATE': if (!empty($value)) { $rdates = array_map(function($_) { return is_array($_) ? $_[0] : $_; }, self::convert_datetime($prop, true)); $event['recurrence']['RDATE'] = array_merge((array)$event['recurrence']['RDATE'], $rdates); } break; case 'RECURRENCE-ID': $event['recurrence_date'] = self::convert_datetime($prop); if ($prop->offsetGet('RANGE') == 'THISANDFUTURE' || $prop->offsetGet('THISANDFUTURE') !== null) { $event['thisandfuture'] = true; } break; case 'RELATED-TO': $reltype = $prop->offsetGet('RELTYPE'); if ($reltype == 'PARENT' || $reltype === null) { $event['parent_id'] = $value; } break; case 'SEQUENCE': $event['sequence'] = intval($value); break; case 'PERCENT-COMPLETE': $event['complete'] = intval($value); break; case 'LOCATION': case 'DESCRIPTION': case 'URL': case 'COMMENT': $event[strtolower($prop->name)] = self::convert_string($prop); break; case 'CATEGORY': case 'CATEGORIES': $event['categories'] = array_merge((array)$event['categories'], $prop->getParts()); break; case 'CLASS': case 'X-CALENDARSERVER-ACCESS': $event['sensitivity'] = strtolower($value); break; case 'X-MICROSOFT-CDO-BUSYSTATUS': if ($value == 'OOF') $event['free_busy'] = 'outofoffice'; else if (in_array($value, array('FREE', 'BUSY', 'TENTATIVE'))) $event['free_busy'] = strtolower($value); break; case 'ATTENDEE': case 'ORGANIZER': $params = array('RSVP' => false); foreach ($prop->parameters() as $pname => $pvalue) { switch ($pname) { case 'RSVP': $params[$pname] = strtolower($pvalue) == 'true'; break; case 'CN': $params[$pname] = self::unescape($pvalue); break; default: $params[$pname] = strval($pvalue); break; } } $attendee = self::map_keys($params, array_flip($this->attendee_keymap)); $attendee['email'] = preg_replace('!^mailto:!i', '', $value); if ($prop->name == 'ORGANIZER') { $attendee['role'] = 'ORGANIZER'; $attendee['status'] = 'ACCEPTED'; $event['organizer'] = $attendee; } else if ($attendee['email'] != $event['organizer']['email']) { $event['attendees'][] = $attendee; } break; case 'ATTACH': $params = self::parameters_array($prop); if (substr($value, 0, 4) == 'http' && !strpos($value, ':attachment:')) { $event['links'][] = $value; } else if (strlen($value) && strtoupper($params['VALUE']) == 'BINARY') { - $attachment = self::map_keys($params, array('FMTTYPE' => 'mimetype', 'X-LABEL' => 'name')); + $attachment = self::map_keys($params, array('FMTTYPE' => 'mimetype', 'X-LABEL' => 'name', 'X-APPLE-FILENAME' => 'name')); $attachment['data'] = $value; $attachment['size'] = strlen($value); $event['attachments'][] = $attachment; } break; default: if (substr($prop->name, 0, 2) == 'X-') $event['x-custom'][] = array($prop->name, strval($value)); break; } } // check DURATION property if no end date is set if (empty($event['end']) && $ve->DURATION) { try { $duration = new DateInterval(strval($ve->DURATION)); $end = clone $event['start']; $end->add($duration); $event['end'] = $end; } catch (\Exception $e) { trigger_error(strval($e), E_USER_WARNING); } } // validate event dates if ($event['_type'] == 'event') { // check for all-day dates if ($event['start']->_dateonly) { $event['allday'] = true; } // all-day events may lack the DTEND property if ($event['allday'] && empty($event['end'])) { $event['end'] = clone $event['start']; } // shift end-date by one day (except Thunderbird) else if ($event['allday'] && is_object($event['end'])) { $event['end']->sub(new \DateInterval('PT23H')); } // sanity-check and fix end date if (!empty($event['end']) && $event['end'] < $event['start']) { $event['end'] = clone $event['start']; } } // make organizer part of the attendees list for compatibility reasons if (!empty($event['organizer']) && is_array($event['attendees']) && $event['_type'] == 'event') { array_unshift($event['attendees'], $event['organizer']); } // find alarms foreach ($ve->select('VALARM') as $valarm) { $action = 'DISPLAY'; $trigger = null; $alarm = array(); foreach ($valarm->children as $prop) { $value = strval($prop); switch ($prop->name) { case 'TRIGGER': if ($prop['VALUE'] == 'DATE-TIME') { $trigger = '@' . $prop->getDateTime()->format('U'); $alarm['trigger'] = $prop->getDateTime(); } if (!$trigger && ($values = libcalendaring::parse_alarm_value($value))) { $trigger = $values[2]; } if (!$alarm['trigger']) { $alarm['trigger'] = rtrim(preg_replace('/([A-Z])0[WDHMS]/', '\\1', $value), 'T'); // if all 0-values have been stripped, assume 'at time' if ($alarm['trigger'] == 'P') $alarm['trigger'] = 'PT0S'; } break; case 'ACTION': $action = $alarm['action'] = strtoupper($value); break; case 'SUMMARY': case 'DESCRIPTION': case 'DURATION': $alarm[strtolower($prop->name)] = self::convert_string($prop); break; case 'REPEAT': $alarm['repeat'] = intval($value); break; case 'ATTENDEE': $alarm['attendees'][] = preg_replace('!^mailto:!i', '', $value); break; case 'ATTACH': $params = self::parameters_array($prop); if (strlen($value) && (preg_match('/^[a-z]+:/', $value) || strtoupper($params['VALUE']) == 'URI')) { // we only support URI-type of attachments here $alarm['uri'] = $value; } break; } } if ($action != 'NONE') { if ($trigger && !$event['alarms']) // store first alarm in legacy property $event['alarms'] = $trigger . ':' . $action; if ($alarm['trigger']) $event['valarms'][] = $alarm; } } // assign current timezone to event start/end if ($event['start'] instanceof DateTime) { if ($this->timezone) $event['start']->setTimezone($this->timezone); } else { unset($event['start']); } if ($event['end'] instanceof DateTime) { if ($this->timezone) $event['end']->setTimezone($this->timezone); } else { unset($event['end']); } // some iTip CANCEL messages only contain the start date if (!$event['end'] && $event['start'] && $this->method == 'CANCEL') { $event['end'] = clone $event['start']; } // minimal validation if (empty($event['uid']) || ($event['_type'] == 'event' && empty($event['start']) != empty($event['end']))) { throw new VObject\ParseException('Object validation failed: missing mandatory object properties'); } return $event; } /** * Parse the given vfreebusy component into an array representation */ private function _parse_freebusy($ve) { $this->freebusy = array('_type' => 'freebusy', 'periods' => array()); $seen = array(); foreach ($ve->children as $prop) { if (!($prop instanceof VObject\Property)) continue; $value = strval($prop); switch ($prop->name) { case 'CREATED': case 'LAST-MODIFIED': case 'DTSTAMP': case 'DTSTART': case 'DTEND': $propmap = array('DTSTART' => 'start', 'DTEND' => 'end', 'CREATED' => 'created', 'LAST-MODIFIED' => 'changed', 'DTSTAMP' => 'changed'); $this->freebusy[$propmap[$prop->name]] = self::convert_datetime($prop); break; case 'ORGANIZER': $this->freebusy['organizer'] = preg_replace('!^mailto:!i', '', $value); break; case 'FREEBUSY': // The freebusy component can hold more than 1 value, separated by commas. $periods = explode(',', $value); $fbtype = strval($prop['FBTYPE']) ?: 'BUSY'; // skip dupes if ($seen[$value.':'.$fbtype]++) continue; foreach ($periods as $period) { // Every period is formatted as [start]/[end]. The start is an // absolute UTC time, the end may be an absolute UTC time, or // duration (relative) value. list($busyStart, $busyEnd) = explode('/', $period); $busyStart = DateTimeParser::parse($busyStart); $busyEnd = DateTimeParser::parse($busyEnd); if ($busyEnd instanceof \DateInterval) { $tmp = clone $busyStart; $tmp->add($busyEnd); $busyEnd = $tmp; } if ($busyEnd && $busyEnd > $busyStart) $this->freebusy['periods'][] = array($busyStart, $busyEnd, $fbtype); } break; case 'COMMENT': $this->freebusy['comment'] = $value; } } return $this->freebusy; } /** * */ public static function convert_string($prop) { return strval($prop); } /** * */ public static function unescape($prop) { return str_replace('\,', ',', strval($prop)); } /** * Helper method to correctly interpret an all-day date value */ public static function convert_datetime($prop, $as_array = false) { if (empty($prop)) { return $as_array ? array() : null; } else if ($prop instanceof VObject\Property\iCalendar\DateTime) { if (count($prop->getDateTimes()) > 1) { $dt = array(); $dateonly = !$prop->hasTime(); foreach ($prop->getDateTimes() as $item) { $item->_dateonly = $dateonly; $dt[] = $item; } } else { $dt = $prop->getDateTime(); if (!$prop->hasTime()) { $dt->_dateonly = true; } } } else if ($prop instanceof VObject\Property\iCalendar\Period) { $dt = array(); foreach ($prop->getParts() as $val) { try { list($start, $end) = explode('/', $val); $start = DateTimeParser::parseDateTime($start); // This is a duration value. if ($end[0] === 'P') { $dur = DateTimeParser::parseDuration($end); $end = clone $start; $end->add($dur); } else { $end = DateTimeParser::parseDateTime($end); } $dt[] = array($start, $end); } catch (Exception $e) { // ignore single date parse errors } } } else if ($prop instanceof \DateTime) { $dt = $prop; } // force return value to array if requested if ($as_array && !is_array($dt)) { $dt = empty($dt) ? array() : array($dt); } return $dt; } /** * Create a Sabre\VObject\Property instance from a PHP DateTime object * * @param object VObject\Document parent node to create property for * @param string Property name * @param object DateTime * @param boolean Set as UTC date * @param boolean Set as VALUE=DATE property */ public function datetime_prop($cal, $name, $dt, $utc = false, $dateonly = null, $set_type = false) { if ($utc) { $dt->setTimeZone(new \DateTimeZone('UTC')); $is_utc = true; } else { $is_utc = ($tz = $dt->getTimezone()) && in_array($tz->getName(), array('UTC','GMT','Z')); } $is_dateonly = $dateonly === null ? (bool)$dt->_dateonly : (bool)$dateonly; $vdt = $cal->createProperty($name, $dt, null, $is_dateonly ? 'DATE' : 'DATE-TIME'); if ($is_dateonly) { $vdt['VALUE'] = 'DATE'; } else if ($set_type) { $vdt['VALUE'] = 'DATE-TIME'; } // register timezone for VTIMEZONE block if (!$is_utc && !$dateonly && $tz && ($tzname = $tz->getName())) { $ts = $dt->format('U'); if (is_array($this->vtimezones[$tzname])) { $this->vtimezones[$tzname][0] = min($this->vtimezones[$tzname][0], $ts); $this->vtimezones[$tzname][1] = max($this->vtimezones[$tzname][1], $ts); } else { $this->vtimezones[$tzname] = array($ts, $ts); } } return $vdt; } /** * Copy values from one hash array to another using a key-map */ public static function map_keys($values, $map) { $out = array(); foreach ($map as $from => $to) { if (isset($values[$from])) $out[$to] = is_array($values[$from]) ? join(',', $values[$from]) : $values[$from]; } return $out; } /** * */ private static function parameters_array($prop) { $params = array(); foreach ($prop->parameters() as $name => $value) { - $params[strtoupper($name)] = $value; + $params[strtoupper($name)] = strval($value); } return $params; } /** * Export events to iCalendar format * * @param array Events as array * @param string VCalendar method to advertise * @param boolean Directly send data to stdout instead of returning * @param callable Callback function to fetch attachment contents, false if no attachment export * @param boolean Add VTIMEZONE block with timezone definitions for the included events * @return string Events in iCalendar format (http://tools.ietf.org/html/rfc5545) */ public function export($objects, $method = null, $write = false, $get_attachment = false, $with_timezones = true) { $this->method = $method; // encapsulate in VCALENDAR container $vcal = new VObject\Component\VCalendar(); $vcal->VERSION = '2.0'; $vcal->PRODID = $this->prodid; $vcal->CALSCALE = 'GREGORIAN'; if (!empty($method)) { $vcal->METHOD = $method; } // write vcalendar header if ($write) { echo preg_replace('/END:VCALENDAR[\r\n]*$/m', '', $vcal->serialize()); } foreach ($objects as $object) { $this->_to_ical($object, !$write?$vcal:false, $get_attachment); } // include timezone information if ($with_timezones || !empty($method)) { foreach ($this->vtimezones as $tzid => $range) { $vt = self::get_vtimezone($tzid, $range[0], $range[1], $vcal); if (empty($vt)) { continue; // no timezone information found } if ($write) { echo $vt->serialize(); } else { $vcal->add($vt); } } } if ($write) { echo "END:VCALENDAR\r\n"; return true; } else { return $vcal->serialize(); } } /** * Build a valid iCal format block from the given event * * @param array Hash array with event/task properties from libkolab * @param object VCalendar object to append event to or false for directly sending data to stdout * @param callable Callback function to fetch attachment contents, false if no attachment export * @param object RECURRENCE-ID property when serializing a recurrence exception */ private function _to_ical($event, $vcal, $get_attachment, $recurrence_id = null) { $type = $event['_type'] ?: 'event'; $cal = $vcal ?: new VObject\Component\VCalendar(); $ve = $cal->create($this->type_component_map[$type]); $ve->UID = $event['uid']; // set DTSTAMP according to RFC 5545, 3.8.7.2. $dtstamp = !empty($event['changed']) && !empty($this->method) ? $event['changed'] : new DateTime('now', new \DateTimeZone('UTC')); $ve->DTSTAMP = $dtstamp; // all-day events end the next day if ($event['allday'] && !empty($event['end'])) { $event['end'] = clone $event['end']; $event['end']->add(new \DateInterval('P1D')); $event['end']->_dateonly = true; } if (!empty($event['created'])) $ve->add($this->datetime_prop($cal, 'CREATED', $event['created'], true)); if (!empty($event['changed'])) $ve->add($this->datetime_prop($cal, 'LAST-MODIFIED', $event['changed'], true)); if (!empty($event['start'])) $ve->add($this->datetime_prop($cal, 'DTSTART', $event['start'], false, (bool)$event['allday'])); if (!empty($event['end'])) $ve->add($this->datetime_prop($cal, 'DTEND', $event['end'], false, (bool)$event['allday'])); if (!empty($event['due'])) $ve->add($this->datetime_prop($cal, 'DUE', $event['due'], false)); // we're exporting a recurrence instance only if (!$recurrence_id && $event['recurrence_date'] && $event['recurrence_date'] instanceof DateTime) { $recurrence_id = $this->datetime_prop($cal, 'RECURRENCE-ID', $event['recurrence_date'], false, (bool)$event['allday']); if ($event['thisandfuture']) $recurrence_id->add('RANGE', 'THISANDFUTURE'); } if ($recurrence_id) { $ve->add($recurrence_id); } $ve->add('SUMMARY', $event['title']); if ($event['location']) $ve->add($this->is_apple() ? new vobject_location_property($cal, 'LOCATION', $event['location']) : $cal->create('LOCATION', $event['location'])); if ($event['description']) $ve->add('DESCRIPTION', strtr($event['description'], array("\r\n" => "\n", "\r" => "\n"))); // normalize line endings if (isset($event['sequence'])) $ve->add('SEQUENCE', $event['sequence']); if ($event['recurrence'] && !$recurrence_id) { $exdates = $rdates = null; if (isset($event['recurrence']['EXDATE'])) { $exdates = $event['recurrence']['EXDATE']; unset($event['recurrence']['EXDATE']); // don't serialize EXDATEs into RRULE value } if (isset($event['recurrence']['RDATE'])) { $rdates = $event['recurrence']['RDATE']; unset($event['recurrence']['RDATE']); // don't serialize RDATEs into RRULE value } if ($event['recurrence']['FREQ']) { $ve->add('RRULE', libcalendaring::to_rrule($event['recurrence'], (bool)$event['allday'])); } // add EXDATEs each one per line (for Thunderbird Lightning) if (is_array($exdates)) { foreach ($exdates as $ex) { if ($ex instanceof \DateTime) { $exd = clone $event['start']; $exd->setDate($ex->format('Y'), $ex->format('n'), $ex->format('j')); $exd->setTimeZone(new \DateTimeZone('UTC')); $ve->add($this->datetime_prop($cal, 'EXDATE', $exd, true)); } } } // add RDATEs if (!empty($rdates)) { foreach ((array)$rdates as $rdate) { $ve->add($this->datetime_prop($cal, 'RDATE', $rdate)); } } } if ($event['categories']) { $cat = $cal->create('CATEGORIES'); $cat->setParts((array)$event['categories']); $ve->add($cat); } if (!empty($event['free_busy'])) { $ve->add('TRANSP', $event['free_busy'] == 'free' ? 'TRANSPARENT' : 'OPAQUE'); // for Outlook clients we provide the X-MICROSOFT-CDO-BUSYSTATUS property if (stripos($this->agent, 'outlook') !== false) { $ve->add('X-MICROSOFT-CDO-BUSYSTATUS', $event['free_busy'] == 'outofoffice' ? 'OOF' : strtoupper($event['free_busy'])); } } if ($event['priority']) $ve->add('PRIORITY', $event['priority']); if ($event['cancelled']) $ve->add('STATUS', 'CANCELLED'); else if ($event['free_busy'] == 'tentative') $ve->add('STATUS', 'TENTATIVE'); else if ($event['complete'] == 100) $ve->add('STATUS', 'COMPLETED'); else if (!empty($event['status'])) $ve->add('STATUS', $event['status']); if (!empty($event['sensitivity'])) $ve->add('CLASS', strtoupper($event['sensitivity'])); if (!empty($event['complete'])) { $ve->add('PERCENT-COMPLETE', intval($event['complete'])); // Apple iCal required the COMPLETED date to be set in order to consider a task complete if ($event['complete'] == 100) $ve->add($this->datetime_prop($cal, 'COMPLETED', $event['changed'] ?: new DateTime('now - 1 hour'), true)); } if ($event['valarms']) { foreach ($event['valarms'] as $alarm) { $va = $cal->createComponent('VALARM'); $va->action = $alarm['action']; if ($alarm['trigger'] instanceof DateTime) { $va->add($this->datetime_prop($cal, 'TRIGGER', $alarm['trigger'], true, null, true)); } else { $va->add('TRIGGER', $alarm['trigger']); } if ($alarm['action'] == 'EMAIL') { foreach ((array)$alarm['attendees'] as $attendee) { $va->add('ATTENDEE', 'mailto:' . $attendee); } } if ($alarm['description']) { $va->add('DESCRIPTION', $alarm['description'] ?: $event['title']); } if ($alarm['summary']) { $va->add('SUMMARY', $alarm['summary']); } if ($alarm['duration']) { $va->add('DURATION', $alarm['duration']); $va->add('REPEAT', intval($alarm['repeat'])); } if ($alarm['uri']) { $va->add('ATTACH', $alarm['uri'], array('VALUE' => 'URI')); } $ve->add($va); } } // legacy support else if ($event['alarms']) { $va = $cal->createComponent('VALARM'); list($trigger, $va->action) = explode(':', $event['alarms']); $val = libcalendaring::parse_alarm_value($trigger); if ($val[3]) $va->add('TRIGGER', $val[3]); else if ($val[0] instanceof DateTime) $va->add($this->datetime_prop($cal, 'TRIGGER', $val[0], true, null, true)); $ve->add($va); } foreach ((array)$event['attendees'] as $attendee) { if ($attendee['role'] == 'ORGANIZER') { if (empty($event['organizer'])) $event['organizer'] = $attendee; } else if (!empty($attendee['email'])) { if (isset($attendee['rsvp'])) $attendee['rsvp'] = $attendee['rsvp'] ? 'TRUE' : null; $ve->add('ATTENDEE', 'mailto:' . $attendee['email'], array_filter(self::map_keys($attendee, $this->attendee_keymap))); } } if ($event['organizer']) { $ve->add('ORGANIZER', 'mailto:' . $event['organizer']['email'], array_filter(self::map_keys($event['organizer'], array('name' => 'CN', 'schedule-status' => 'SCHEDULE-STATUS')))); } foreach ((array)$event['url'] as $url) { if (!empty($url)) { $ve->add('URL', $url); } } if (!empty($event['parent_id'])) { $ve->add('RELATED-TO', $event['parent_id'], array('RELTYPE' => 'PARENT')); } if ($event['comment']) $ve->add('COMMENT', $event['comment']); $memory_limit = parse_bytes(ini_get('memory_limit')); // export attachments if (!empty($event['attachments'])) { foreach ((array)$event['attachments'] as $attach) { // check available memory and skip attachment export if we can't buffer it // @todo: use rcube_utils::mem_check() if (is_callable($get_attachment) && $memory_limit > 0 && ($memory_used = function_exists('memory_get_usage') ? memory_get_usage() : 16*1024*1024) && $attach['size'] && $memory_used + $attach['size'] * 3 > $memory_limit) { continue; } // embed attachments using the given callback function if (is_callable($get_attachment) && ($data = call_user_func($get_attachment, $attach['id'], $event))) { // embed attachments for iCal $ve->add('ATTACH', $data, array_filter(array('VALUE' => 'BINARY', 'ENCODING' => 'BASE64', 'FMTTYPE' => $attach['mimetype'], 'X-LABEL' => $attach['name']))); unset($data); // attempt to free memory } // list attachments as absolute URIs else if (!empty($this->attach_uri)) { $ve->add('ATTACH', strtr($this->attach_uri, array( '{{id}}' => urlencode($attach['id']), '{{name}}' => urlencode($attach['name']), '{{mimetype}}' => urlencode($attach['mimetype']), )), array('FMTTYPE' => $attach['mimetype'], 'VALUE' => 'URI')); } } } foreach ((array)$event['links'] as $uri) { $ve->add('ATTACH', $uri); } // add custom properties foreach ((array)$event['x-custom'] as $prop) { $ve->add($prop[0], $prop[1]); } // append to vcalendar container if ($vcal) { $vcal->add($ve); } else { // serialize and send to stdout echo $ve->serialize(); } // append recurrence exceptions if (is_array($event['recurrence']) && $event['recurrence']['EXCEPTIONS']) { foreach ($event['recurrence']['EXCEPTIONS'] as $ex) { $exdate = $ex['recurrence_date'] ?: $ex['start']; $recurrence_id = $this->datetime_prop($cal, 'RECURRENCE-ID', $exdate, false, (bool)$event['allday']); if ($ex['thisandfuture']) $recurrence_id->add('RANGE', 'THISANDFUTURE'); $this->_to_ical($ex, $vcal, $get_attachment, $recurrence_id); } } } /** * Returns a VTIMEZONE component for a Olson timezone identifier * with daylight transitions covering the given date range. * * @param string Timezone ID as used in PHP's Date functions * @param integer Unix timestamp with first date/time in this timezone * @param integer Unix timestap with last date/time in this timezone * * @return mixed A Sabre\VObject\Component object representing a VTIMEZONE definition * or false if no timezone information is available */ public static function get_vtimezone($tzid, $from = 0, $to = 0, $cal = null) { if (!$from) $from = time(); if (!$to) $to = $from; if (!$cal) $cal = new VObject\Component\VCalendar(); if (is_string($tzid)) { try { $tz = new \DateTimeZone($tzid); } catch (\Exception $e) { return false; } } else if (is_a($tzid, '\\DateTimeZone')) { $tz = $tzid; } if (!is_a($tz, '\\DateTimeZone')) { return false; } $year = 86400 * 360; $transitions = $tz->getTransitions($from - $year, $to + $year); $vt = $cal->createComponent('VTIMEZONE'); $vt->TZID = $tz->getName(); $std = null; $dst = null; foreach ($transitions as $i => $trans) { $cmp = null; if ($i == 0) { $tzfrom = $trans['offset'] / 3600; continue; } if ($trans['isdst']) { $t_dst = $trans['ts']; $dst = $cal->createComponent('DAYLIGHT'); $cmp = $dst; } else { $t_std = $trans['ts']; $std = $cal->createComponent('STANDARD'); $cmp = $std; } if ($cmp) { $dt = new DateTime($trans['time']); $offset = $trans['offset'] / 3600; $cmp->DTSTART = $dt->format('Ymd\THis'); $cmp->TZOFFSETFROM = sprintf('%s%02d%02d', $tzfrom >= 0 ? '+' : '', floor($tzfrom), ($tzfrom - floor($tzfrom)) * 60); $cmp->TZOFFSETTO = sprintf('%s%02d%02d', $offset >= 0 ? '+' : '', floor($offset), ($offset - floor($offset)) * 60); if (!empty($trans['abbr'])) { $cmp->TZNAME = $trans['abbr']; } $tzfrom = $offset; $vt->add($cmp); } // we covered the entire date range if ($std && $dst && min($t_std, $t_dst) < $from && max($t_std, $t_dst) > $to) { break; } } // add X-MICROSOFT-CDO-TZID if available $microsoftExchangeMap = array_flip(VObject\TimeZoneUtil::$microsoftExchangeMap); if (array_key_exists($tz->getName(), $microsoftExchangeMap)) { $vt->add('X-MICROSOFT-CDO-TZID', $microsoftExchangeMap[$tz->getName()]); } return $vt; } /*** Implement PHP 5 Iterator interface to make foreach work ***/ function current() { return $this->objects[$this->iteratorkey]; } function key() { return $this->iteratorkey; } function next() { $this->iteratorkey++; // read next chunk if we're reading from a file if (!$this->objects[$this->iteratorkey] && $this->fp) { $this->_parse_next(true); } return $this->valid(); } function rewind() { $this->iteratorkey = 0; } function valid() { return !empty($this->objects[$this->iteratorkey]); } } /** * Override Sabre\VObject\Property\Text that quotes commas in the location property * because Apple clients treat that property as list. */ class vobject_location_property extends VObject\Property\Text { /** * List of properties that are considered 'structured'. * * @var array */ protected $structuredValues = array( // vCard 'N', 'ADR', 'ORG', 'GENDER', 'LOCATION', // iCalendar 'REQUEST-STATUS', ); } diff --git a/plugins/libkolab/lib/kolab_storage_folder.php b/plugins/libkolab/lib/kolab_storage_folder.php index e0bf52e3..0ba00f50 100644 --- a/plugins/libkolab/lib/kolab_storage_folder.php +++ b/plugins/libkolab/lib/kolab_storage_folder.php @@ -1,1167 +1,1167 @@ * @author Aleksander Machniak * * Copyright (C) 2012-2013, 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 . */ class kolab_storage_folder extends kolab_storage_folder_api { /** * The kolab_storage_cache instance for caching operations * @var object */ public $cache; /** * Indicate validity status * @var boolean */ public $valid = false; protected $error = 0; protected $resource_uri; /** * Default constructor * * @param string The folder name/path * @param string Expected folder type */ function __construct($name, $type = null, $type_annotation = null) { parent::__construct($name); $this->imap->set_options(array('skip_deleted' => true)); $this->set_folder($name, $type, $type_annotation); } /** * Set the IMAP folder this instance connects to * * @param string The folder name/path * @param string Expected folder type * @param string Optional folder type if known */ public function set_folder($name, $type = null, $type_annotation = null) { if (empty($type_annotation)) { $type_annotation = kolab_storage::folder_type($name); } $oldtype = $this->type; list($this->type, $suffix) = explode('.', $type_annotation); $this->default = $suffix == 'default'; $this->subtype = $this->default ? '' : $suffix; $this->name = $name; $this->id = kolab_storage::folder_id($name); $this->valid = !empty($this->type) && $this->type != 'mail' && (!$type || $this->type == $type); if (!$this->valid) { $this->error = $this->imap->get_error_code() < 0 ? kolab_storage::ERROR_IMAP_CONN : kolab_storage::ERROR_INVALID_FOLDER; } // reset cached object properties $this->owner = $this->namespace = $this->resource_uri = $this->info = $this->idata = null; // get a new cache instance if folder type changed if (!$this->cache || $this->type != $oldtype) $this->cache = kolab_storage_cache::factory($this); else $this->cache->set_folder($this); $this->imap->set_folder($this->name); } /** * Returns code of last error * * @return int Error code */ public function get_error() { return $this->error ?: $this->cache->get_error(); } /** * Check IMAP connection error state */ public function check_error() { if (($err_code = $this->imap->get_error_code()) < 0) { $this->error = kolab_storage::ERROR_IMAP_CONN; if (($res_code = $this->imap->get_response_code()) !== 0 && in_array($res_code, array(rcube_storage::NOPERM, rcube_storage::READONLY))) { $this->error = kolab_storage::ERROR_NO_PERMISSION; } } return $this->error; } /** * Compose a unique resource URI for this IMAP folder */ public function get_resource_uri() { if (!empty($this->resource_uri)) return $this->resource_uri; // strip namespace prefix from folder name $ns = $this->get_namespace(); $nsdata = $this->imap->get_namespace($ns); if (is_array($nsdata[0]) && strlen($nsdata[0][0]) && strpos($this->name, $nsdata[0][0]) === 0) { $subpath = substr($this->name, strlen($nsdata[0][0])); if ($ns == 'other') { list($user, $suffix) = explode($nsdata[0][1], $subpath, 2); $subpath = $suffix; } } else { $subpath = $this->name; } // compose fully qualified ressource uri for this instance $this->resource_uri = 'imap://' . urlencode($this->get_owner(true)) . '@' . $this->imap->options['host'] . '/' . $subpath; return $this->resource_uri; } /** * Helper method to extract folder UID metadata * * @return string Folder's UID */ public function get_uid() { // UID is defined in folder METADATA $metakeys = array(kolab_storage::UID_KEY_SHARED, kolab_storage::UID_KEY_PRIVATE, kolab_storage::UID_KEY_CYRUS); $metadata = $this->get_metadata($metakeys); foreach ($metakeys as $key) { if (($uid = $metadata[$key])) { return $uid; } } // generate a folder UID and set it to IMAP $uid = rtrim(chunk_split(md5($this->name . $this->get_owner() . uniqid('-', true)), 12, '-'), '-'); if ($this->set_uid($uid)) { return $uid; } // create hash from folder name if we can't write the UID metadata return md5($this->name . $this->get_owner()); } /** * Helper method to set an UID value to the given IMAP folder instance * * @param string Folder's UID * @return boolean True on succes, False on failure */ public function set_uid($uid) { if (!($success = $this->set_metadata(array(kolab_storage::UID_KEY_SHARED => $uid)))) { $success = $this->set_metadata(array(kolab_storage::UID_KEY_PRIVATE => $uid)); } $this->check_error(); return $success; } /** * Compose a folder Etag identifier */ public function get_ctag() { $fdata = $this->get_imap_data(); $this->check_error(); return sprintf('%d-%d-%d', $fdata['UIDVALIDITY'], $fdata['HIGHESTMODSEQ'], $fdata['UIDNEXT']); } /** * Check activation status of this folder * * @return boolean True if enabled, false if not */ public function is_active() { return kolab_storage::folder_is_active($this->name); } /** * Change activation status of this folder * * @param boolean The desired subscription status: true = active, false = not active * * @return True on success, false on error */ public function activate($active) { return $active ? kolab_storage::folder_activate($this->name) : kolab_storage::folder_deactivate($this->name); } /** * Check subscription status of this folder * * @return boolean True if subscribed, false if not */ public function is_subscribed() { return kolab_storage::folder_is_subscribed($this->name); } /** * Change subscription status of this folder * * @param boolean The desired subscription status: true = subscribed, false = not subscribed * * @return True on success, false on error */ public function subscribe($subscribed) { return $subscribed ? kolab_storage::folder_subscribe($this->name) : kolab_storage::folder_unsubscribe($this->name); } /** * Get number of objects stored in this folder * * @param mixed Pseudo-SQL query as list of filter parameter triplets * or string with object type (e.g. contact, event, todo, journal, note, configuration) * @return integer The number of objects of the given type * @see self::select() */ public function count($query = null) { if (!$this->valid) { return 0; } // synchronize cache first $this->cache->synchronize(); return $this->cache->count($this->_prepare_query($query)); } /** * List all Kolab objects of the given type * * @param string $type Object type (e.g. contact, event, todo, journal, note, configuration) * @return array List of Kolab data objects (each represented as hash array) */ public function get_objects($type = null) { if (!$type) $type = $this->type; if (!$this->valid) { return array(); } // synchronize caches $this->cache->synchronize(); // fetch objects from cache return $this->cache->select($this->_prepare_query($type)); } /** * Select *some* Kolab objects matching the given query * * @param array Pseudo-SQL query as list of filter parameter triplets * triplet: array('', '', '') * @return array List of Kolab data objects (each represented as hash array) */ public function select($query = array()) { if (!$this->valid) { return array(); } // check query argument if (empty($query)) { return $this->get_objects(); } // synchronize caches $this->cache->synchronize(); // fetch objects from cache return $this->cache->select($this->_prepare_query($query)); } /** * Getter for object UIDs only * * @param array Pseudo-SQL query as list of filter parameter triplets * @return array List of Kolab object UIDs */ public function get_uids($query = array()) { if (!$this->valid) { return array(); } // synchronize caches $this->cache->synchronize(); // fetch UIDs from cache return $this->cache->select($this->_prepare_query($query), true); } /** * Setter for ORDER BY and LIMIT parameters for cache queries * * @param array List of columns to order by * @param integer Limit result set to this length * @param integer Offset row */ public function set_order_and_limit($sortcols, $length = null, $offset = 0) { $this->cache->set_order_by($sortcols); if ($length !== null) { $this->cache->set_limit($length, $offset); } } /** * Helper method to sanitize query arguments */ private function _prepare_query($query) { // string equals type query // FIXME: should not be called this way! if (is_string($query)) { return $this->cache->has_type_col() && !empty($query) ? array(array('type','=',$query)) : array(); } foreach ((array)$query as $i => $param) { if ($param[0] == 'type' && !$this->cache->has_type_col()) { unset($query[$i]); } else if (($param[0] == 'dtstart' || $param[0] == 'dtend' || $param[0] == 'changed')) { if (is_object($param[2]) && is_a($param[2], 'DateTime')) $param[2] = $param[2]->format('U'); if (is_numeric($param[2])) $query[$i][2] = date('Y-m-d H:i:s', $param[2]); } } return $query; } /** * Getter for a single Kolab object, identified by its UID * * @param string $uid Object UID * @param string $type Object type (e.g. contact, event, todo, journal, note, configuration) * Defaults to folder type * * @return array The Kolab object represented as hash array */ public function get_object($uid, $type = null) { if (!$this->valid) { return false; } // synchronize caches $this->cache->synchronize(); $msguid = $this->cache->uid2msguid($uid); if ($msguid && ($object = $this->cache->get($msguid, $type))) { return $object; } return false; } /** * Fetch a Kolab object attachment which is stored in a separate part * of the mail MIME message that represents the Kolab record. * * @param string Object's UID * @param string The attachment's mime number * @param string IMAP folder where message is stored; * If set, that also implies that the given UID is an IMAP UID * @param bool True to print the part content * @param resource File pointer to save the message part * @param boolean Disables charset conversion * * @return mixed The attachment content as binary string */ public function get_attachment($uid, $part, $mailbox = null, $print = false, $fp = null, $skip_charset_conv = false) { if ($this->valid && ($msguid = ($mailbox ? $uid : $this->cache->uid2msguid($uid)))) { $this->imap->set_folder($mailbox ? $mailbox : $this->name); if (substr($part, 0, 2) == 'i:') { // attachment data is stored in XML if ($object = $this->cache->get($msguid)) { // load data from XML (attachment content is not stored in cache) if ($object['_formatobj'] && isset($object['_size'])) { $object['_attachments'] = array(); $object['_formatobj']->get_attachments($object); } foreach ($object['_attachments'] as $attach) { if ($attach['id'] == $part) { if ($print) echo $attach['content']; else if ($fp) fwrite($fp, $attach['content']); else return $attach['content']; return true; } } } } else { // return message part from IMAP directly return $this->imap->get_message_part($msguid, $part, null, $print, $fp, $skip_charset_conv); } } return null; } /** * Fetch the mime message from the storage server and extract * the Kolab groupware object from it * * @param string The IMAP message UID to fetch * @param string The object type expected (use wildcard '*' to accept all types) * @param string The folder name where the message is stored * * @return mixed Hash array representing the Kolab object, a kolab_format instance or false if not found */ public function read_object($msguid, $type = null, $folder = null) { if (!$this->valid) { return false; } if (!$type) $type = $this->type; if (!$folder) $folder = $this->name; $this->imap->set_folder($folder); $this->cache->bypass(true); $message = new rcube_message($msguid); $this->cache->bypass(false); // Message doesn't exist? if (empty($message->headers)) { return false; } // extract the X-Kolab-Type header from the XML attachment part if missing if (empty($message->headers->others['x-kolab-type'])) { foreach ((array)$message->attachments as $part) { if (strpos($part->mimetype, kolab_format::KTYPE_PREFIX) === 0) { $message->headers->others['x-kolab-type'] = $part->mimetype; break; } } } // fix buggy messages stating the X-Kolab-Type header twice else if (is_array($message->headers->others['x-kolab-type'])) { $message->headers->others['x-kolab-type'] = reset($message->headers->others['x-kolab-type']); } // no object type header found: abort if (empty($message->headers->others['x-kolab-type'])) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "No X-Kolab-Type information found in message $msguid ($this->name).", ), true); return false; } $object_type = kolab_format::mime2object_type($message->headers->others['x-kolab-type']); $content_type = kolab_format::KTYPE_PREFIX . $object_type; // check object type header and abort on mismatch if ($type != '*' && $object_type != $type) return false; $attachments = array(); // get XML part foreach ((array)$message->attachments as $part) { if (!$xml && ($part->mimetype == $content_type || preg_match('!application/([a-z.]+\+)?xml!', $part->mimetype))) { $xml = $message->get_part_body($part->mime_id, true); } else if ($part->filename || $part->content_id) { $key = $part->content_id ? trim($part->content_id, '<>') : $part->filename; $size = null; // Use Content-Disposition 'size' as for the Kolab Format spec. if (isset($part->d_parameters['size'])) { $size = $part->d_parameters['size']; } // we can trust part size only if it's not encoded else if ($part->encoding == 'binary' || $part->encoding == '7bit' || $part->encoding == '8bit') { $size = $part->size; } $attachments[$key] = array( 'id' => $part->mime_id, 'name' => $part->filename, 'mimetype' => $part->mimetype, 'size' => $size, ); } } if (!$xml) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Could not find Kolab data part in message $msguid ($this->name).", ), true); return false; } // check kolab format version $format_version = $message->headers->others['x-kolab-mime-version']; if (empty($format_version)) { list($xmltype, $subtype) = explode('.', $object_type); $xmlhead = substr($xml, 0, 512); // detect old Kolab 2.0 format if (strpos($xmlhead, '<' . $xmltype) !== false && strpos($xmlhead, 'xmlns=') === false) $format_version = '2.0'; else $format_version = '3.0'; // assume 3.0 } // get Kolab format handler for the given type $format = kolab_format::factory($object_type, $format_version); if (is_a($format, 'PEAR_Error')) return false; // load Kolab object from XML part $format->load($xml); if ($format->is_valid()) { $object = $format->to_array(array('_attachments' => $attachments)); $object['_type'] = $object_type; $object['_msguid'] = $msguid; $object['_mailbox'] = $this->name; $object['_formatobj'] = $format; return $object; } else { // try to extract object UID from XML block if (preg_match('!(.+)!Uims', $xml, $m)) $msgadd = " UID = " . trim(strip_tags($m[1])); rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Could not parse Kolab object data in message $msguid ($this->name)." . $msgadd, ), true); } return false; } /** * Save an object in this folder. * * @param array $object The array that holds the data of the object. * @param string $type The type of the kolab object. * @param string $uid The UID of the old object if it existed before * @return boolean True on success, false on error */ public function save(&$object, $type = null, $uid = null) { if (!$this->valid) { return false; } if (!$type) $type = $this->type; // copy attachments from old message $copyfrom = $object['_copyfrom'] ?: $object['_msguid']; if (!empty($copyfrom) && ($old = $this->cache->get($copyfrom, $type, $object['_mailbox']))) { foreach ((array)$old['_attachments'] as $key => $att) { if (!isset($object['_attachments'][$key])) { $object['_attachments'][$key] = $old['_attachments'][$key]; } // unset deleted attachment entries if ($object['_attachments'][$key] == false) { unset($object['_attachments'][$key]); } // load photo.attachment from old Kolab2 format to be directly embedded in xcard block else if ($type == 'contact' && ($key == 'photo.attachment' || $key == 'kolab-picture.png') && $att['id']) { if (!isset($object['photo'])) $object['photo'] = $this->get_attachment($copyfrom, $att['id'], $object['_mailbox']); unset($object['_attachments'][$key]); } } } // save contact photo to attachment for Kolab2 format if (kolab_storage::$version == '2.0' && $object['photo']) { $attkey = 'kolab-picture.png'; // this file name is hard-coded in libkolab/kolabformatV2/contact.cpp $object['_attachments'][$attkey] = array( 'mimetype'=> rcube_mime::image_content_type($object['photo']), 'content' => preg_match('![^a-z0-9/=+-]!i', $object['photo']) ? $object['photo'] : base64_decode($object['photo']), ); } // process attachments if (is_array($object['_attachments'])) { $numatt = count($object['_attachments']); foreach ($object['_attachments'] as $key => $attachment) { // FIXME: kolab_storage and Roundcube attachment hooks use different fields! if (empty($attachment['content']) && !empty($attachment['data'])) { $attachment['content'] = $attachment['data']; unset($attachment['data'], $object['_attachments'][$key]['data']); } // make sure size is set, so object saved in cache contains this info if (!isset($attachment['size'])) { if (!empty($attachment['content'])) { if (is_resource($attachment['content'])) { // this need to be a seekable resource, otherwise // fstat() failes and we're unable to determine size // here nor in rcube_imap_generic before IMAP APPEND $stat = fstat($attachment['content']); $attachment['size'] = $stat ? $stat['size'] : 0; } else { $attachment['size'] = strlen($attachment['content']); } } else if (!empty($attachment['path'])) { $attachment['size'] = filesize($attachment['path']); } $object['_attachments'][$key] = $attachment; } // generate unique keys (used as content-id) for attachments if (is_numeric($key) && $key < $numatt) { // derrive content-id from attachment file name $ext = preg_match('/(\.[a-z0-9]{1,6})$/i', $attachment['name'], $m) ? $m[1] : null; $basename = preg_replace('/[^a-z0-9_.-]/i', '', basename($attachment['name'], $ext)); // to 7bit ascii if (!$basename) $basename = 'noname'; - $cid = $basename . '.' . microtime(true) . $ext; + $cid = $basename . '.' . microtime(true) . $key . $ext; $object['_attachments'][$cid] = $attachment; unset($object['_attachments'][$key]); } } } // save recurrence exceptions as individual objects due to lack of support in Kolab v2 format if (kolab_storage::$version == '2.0' && $object['recurrence']['EXCEPTIONS']) { $this->save_recurrence_exceptions($object, $type); } // check IMAP BINARY extension support for 'file' objects // allow configuration to workaround bug in Cyrus < 2.4.17 $rcmail = rcube::get_instance(); $binary = $type == 'file' && !$rcmail->config->get('kolab_binary_disable') && $this->imap->get_capability('BINARY'); // generate and save object message if ($raw_msg = $this->build_message($object, $type, $binary, $body_file)) { // resolve old msguid before saving if ($uid && empty($object['_msguid']) && ($msguid = $this->cache->uid2msguid($uid))) { $object['_msguid'] = $msguid; $object['_mailbox'] = $this->name; } $result = $this->imap->save_message($this->name, $raw_msg, null, false, null, null, $binary); // update cache with new UID if ($result) { $old_uid = $object['_msguid']; $object['_msguid'] = $result; $object['_mailbox'] = $this->name; if ($old_uid) { // delete old message $this->cache->bypass(true); $this->imap->delete_message($old_uid, $object['_mailbox']); $this->cache->bypass(false); } // insert/update message in cache $this->cache->save($result, $object, $old_uid); } // remove temp file if ($body_file) { @unlink($body_file); } } return $result; } /** * Save recurrence exceptions as individual objects. * The Kolab v2 format doesn't allow us to save fully embedded exception objects. * * @param array Hash array with event properties * @param string Object type */ private function save_recurrence_exceptions(&$object, $type = null) { if ($object['recurrence']['EXCEPTIONS']) { $exdates = array(); foreach ((array)$object['recurrence']['EXDATE'] as $exdate) { $key = is_a($exdate, 'DateTime') ? $exdate->format('Y-m-d') : strval($exdate); $exdates[$key] = 1; } // save every exception as individual object foreach((array)$object['recurrence']['EXCEPTIONS'] as $exception) { $exception['uid'] = self::recurrence_exception_uid($object['uid'], $exception['start']->format('Ymd')); $exception['sequence'] = $object['sequence'] + 1; if ($exception['thisandfuture']) { $exception['recurrence'] = $object['recurrence']; // adjust the recurrence duration of the exception if ($object['recurrence']['COUNT']) { $recurrence = new kolab_date_recurrence($object['_formatobj']); if ($end = $recurrence->end()) { unset($exception['recurrence']['COUNT']); $exception['recurrence']['UNTIL'] = $end; } } // set UNTIL date if we have a thisandfuture exception $untildate = clone $exception['start']; $untildate->sub(new DateInterval('P1D')); $object['recurrence']['UNTIL'] = $untildate; unset($object['recurrence']['COUNT']); } else { if (!$exdates[$exception['start']->format('Y-m-d')]) $object['recurrence']['EXDATE'][] = clone $exception['start']; unset($exception['recurrence']); } unset($exception['recurrence']['EXCEPTIONS'], $exception['_formatobj'], $exception['_msguid']); $this->save($exception, $type, $exception['uid']); } unset($object['recurrence']['EXCEPTIONS']); } } /** * Generate an object UID with the given recurrence-ID in a way that it is * unique (the original UID is not a substring) but still recoverable. */ private static function recurrence_exception_uid($uid, $recurrence_id) { $offset = -2; return substr($uid, 0, $offset) . '-' . $recurrence_id . '-' . substr($uid, $offset); } /** * Delete the specified object from this folder. * * @param mixed $object The Kolab object to delete or object UID * @param boolean $expunge Should the folder be expunged? * * @return boolean True if successful, false on error */ public function delete($object, $expunge = true) { if (!$this->valid) { return false; } $msguid = is_array($object) ? $object['_msguid'] : $this->cache->uid2msguid($object); $success = false; $this->cache->bypass(true); if ($msguid && $expunge) { $success = $this->imap->delete_message($msguid, $this->name); } else if ($msguid) { $success = $this->imap->set_flag($msguid, 'DELETED', $this->name); } $this->cache->bypass(false); if ($success) { $this->cache->set($msguid, false); } return $success; } /** * */ public function delete_all() { if (!$this->valid) { return false; } $this->cache->purge(); $this->cache->bypass(true); $result = $this->imap->clear_folder($this->name); $this->cache->bypass(false); return $result; } /** * Restore a previously deleted object * * @param string Object UID * @return mixed Message UID on success, false on error */ public function undelete($uid) { if (!$this->valid) { return false; } if ($msguid = $this->cache->uid2msguid($uid, true)) { $this->cache->bypass(true); $result = $this->imap->set_flag($msguid, 'UNDELETED', $this->name); $this->cache->bypass(false); if ($result) { return $msguid; } } return false; } /** * Move a Kolab object message to another IMAP folder * * @param string Object UID * @param string IMAP folder to move object to * @return boolean True on success, false on failure */ public function move($uid, $target_folder) { if (!$this->valid) { return false; } if (is_string($target_folder)) $target_folder = kolab_storage::get_folder($target_folder); if ($msguid = $this->cache->uid2msguid($uid)) { $this->cache->bypass(true); $result = $this->imap->move_message($msguid, $target_folder->name, $this->name); $this->cache->bypass(false); if ($result) { $this->cache->move($msguid, $uid, $target_folder); return true; } else { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Failed to move message $msguid to $target_folder: " . $this->imap->get_error_str(), ), true); } } return false; } /** * Creates source of the configuration object message * * @param array $object The array that holds the data of the object. * @param string $type The type of the kolab object. * @param bool $binary Enables use of binary encoding of attachment(s) * @param string $body_file Reference to filename of message body * * @return mixed Message as string or array with two elements * (one for message file path, second for message headers) */ private function build_message(&$object, $type, $binary, &$body_file) { // load old object to preserve data we don't understand/process if (is_object($object['_formatobj'])) $format = $object['_formatobj']; else if ($object['_msguid'] && ($old = $this->cache->get($object['_msguid'], $type, $object['_mailbox']))) $format = $old['_formatobj']; // create new kolab_format instance if (!$format) $format = kolab_format::factory($type, kolab_storage::$version); if (PEAR::isError($format)) return false; $format->set($object); $xml = $format->write(kolab_storage::$version); $object['uid'] = $format->uid; // read UID from format $object['_formatobj'] = $format; if (empty($xml) || !$format->is_valid() || empty($object['uid'])) { return false; } $mime = new Mail_mime("\r\n"); $rcmail = rcube::get_instance(); $headers = array(); $files = array(); $part_id = 1; $encoding = $binary ? 'binary' : 'base64'; if ($user_email = $rcmail->get_user_email()) { $headers['From'] = $user_email; $headers['To'] = $user_email; } $headers['Date'] = date('r'); $headers['X-Kolab-Type'] = kolab_format::KTYPE_PREFIX . $type; $headers['X-Kolab-Mime-Version'] = kolab_storage::$version; $headers['Subject'] = $object['uid']; // $headers['Message-ID'] = $rcmail->gen_message_id(); $headers['User-Agent'] = $rcmail->config->get('useragent'); // Check if we have enough memory to handle the message in it // It's faster than using files, so we'll do this if we only can if (!empty($object['_attachments']) && ($mem_limit = parse_bytes(ini_get('memory_limit'))) > 0) { $memory = function_exists('memory_get_usage') ? memory_get_usage() : 16*1024*1024; // safe value: 16MB foreach ($object['_attachments'] as $attachment) { $memory += $attachment['size']; } // 1.33 is for base64, we need at least 4x more memory than the message size if ($memory * ($binary ? 1 : 1.33) * 4 > $mem_limit) { $marker = '%%%~~~' . md5(microtime(true) . $memory) . '~~~%%%'; $is_file = true; $temp_dir = unslashify($rcmail->config->get('temp_dir')); $mime->setParam('delay_file_io', true); } } $mime->headers($headers); $mime->setTXTBody("This is a Kolab Groupware object. " . "To view this object you will need an email client that understands the Kolab Groupware format. " . "For a list of such email clients please visit http://www.kolab.org/\n\n"); $ctype = kolab_storage::$version == '2.0' ? $format->CTYPEv2 : $format->CTYPE; // Convert new lines to \r\n, to wrokaround "NO Message contains bare newlines" // when APPENDing from temp file $xml = preg_replace('/\r?\n/', "\r\n", $xml); $mime->addAttachment($xml, // file $ctype, // content-type 'kolab.xml', // filename false, // is_file '8bit', // encoding 'attachment', // disposition RCUBE_CHARSET // charset ); $part_id++; // save object attachments as separate parts foreach ((array)$object['_attachments'] as $key => $att) { if (empty($att['content']) && !empty($att['id'])) { // @TODO: use IMAP CATENATE to skip attachment fetch+push operation $msguid = $object['_copyfrom'] ?: ($object['_msguid'] ?: $object['uid']); if ($is_file) { $att['path'] = tempnam($temp_dir, 'rcmAttmnt'); if (($fp = fopen($att['path'], 'w')) && $this->get_attachment($msguid, $att['id'], $object['_mailbox'], false, $fp, true)) { fclose($fp); } else { return false; } } else { $att['content'] = $this->get_attachment($msguid, $att['id'], $object['_mailbox'], false, null, true); } } $headers = array('Content-ID' => Mail_mimePart::encodeHeader('Content-ID', '<' . $key . '>', RCUBE_CHARSET, 'quoted-printable')); $name = !empty($att['name']) ? $att['name'] : $key; // To store binary files we can use faster method // without writting full message content to a temporary file but // directly to IMAP, see rcube_imap_generic::append(). // I.e. use file handles where possible if (!empty($att['path'])) { if ($is_file && $binary) { $files[] = fopen($att['path'], 'r'); $mime->addAttachment($marker, $att['mimetype'], $name, false, $encoding, 'attachment', '', '', '', null, null, '', RCUBE_CHARSET, $headers); } else { $mime->addAttachment($att['path'], $att['mimetype'], $name, true, $encoding, 'attachment', '', '', '', null, null, '', RCUBE_CHARSET, $headers); } } else { if (is_resource($att['content']) && $is_file && $binary) { $files[] = $att['content']; $mime->addAttachment($marker, $att['mimetype'], $name, false, $encoding, 'attachment', '', '', '', null, null, '', RCUBE_CHARSET, $headers); } else { if (is_resource($att['content'])) { @rewind($att['content']); $att['content'] = stream_get_contents($att['content']); } $mime->addAttachment($att['content'], $att['mimetype'], $name, false, $encoding, 'attachment', '', '', '', null, null, '', RCUBE_CHARSET, $headers); } } $object['_attachments'][$key]['id'] = ++$part_id; } if (!$is_file || !empty($files)) { $message = $mime->getMessage(); } // parse message and build message array with // attachment file pointers in place of file markers if (!empty($files)) { $message = explode($marker, $message); $tmp = array(); foreach ($message as $msg_part) { $tmp[] = $msg_part; if ($file = array_shift($files)) { $tmp[] = $file; } } $message = $tmp; } // write complete message body into temp file else if ($is_file) { // use common temp dir $body_file = tempnam($temp_dir, 'rcmMsg'); if (PEAR::isError($mime_result = $mime->saveMessageBody($body_file))) { self::raise_error(array('code' => 650, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Could not create message: ".$mime_result->getMessage()), true, false); return false; } $message = array(trim($mime->txtHeaders()) . "\r\n\r\n", fopen($body_file, 'r')); } return $message; } /** * Triggers any required updates after changes within the * folder. This is currently only required for handling free/busy * information with Kolab. * * @return boolean|PEAR_Error True if successfull. */ public function trigger() { $owner = $this->get_owner(); $result = false; switch($this->type) { case 'event': if ($this->get_namespace() == 'personal') { $result = $this->trigger_url( sprintf('%s/trigger/%s/%s.pfb', kolab_storage::get_freebusy_server(), urlencode($owner), urlencode($this->imap->mod_folder($this->name)) ), $this->imap->options['user'], $this->imap->options['password'] ); } break; default: return true; } if ($result && is_object($result) && is_a($result, 'PEAR_Error')) { return PEAR::raiseError(sprintf("Failed triggering folder %s. Error was: %s", $this->name, $result->getMessage())); } return $result; } /** * Triggers a URL. * * @param string $url The URL to be triggered. * @param string $auth_user Username to authenticate with * @param string $auth_passwd Password for basic auth * @return boolean|PEAR_Error True if successfull. */ private function trigger_url($url, $auth_user = null, $auth_passwd = null) { try { $request = libkolab::http_request($url); // set authentication credentials if ($auth_user && $auth_passwd) $request->setAuth($auth_user, $auth_passwd); $result = $request->send(); // rcube::write_log('trigger', $result->getBody()); } catch (Exception $e) { return PEAR::raiseError($e->getMessage()); } return true; } }