diff --git a/lib/Kolab/FreeBusy/SourceIMAP.php b/lib/Kolab/FreeBusy/SourceIMAP.php index e0de62b..c2d0e79 100644 --- a/lib/Kolab/FreeBusy/SourceIMAP.php +++ b/lib/Kolab/FreeBusy/SourceIMAP.php @@ -1,377 +1,379 @@ * * 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 . */ namespace Kolab\FreeBusy; use Kolab\Config; use Sabre\VObject; use Sabre\VObject\Component\VCalendar; use Sabre\VObject\FreeBusyGenerator; use Sabre\VObject\ParseException; // configure env for Roundcube framework define('RCUBE_INSTALL_PATH', KOLAB_FREEBUSY_ROOT . '/'); define('RCUBE_CONFIG_DIR', KOLAB_FREEBUSY_ROOT . '/config/'); define('RCUBE_PLUGINS_DIR', KOLAB_FREEBUSY_ROOT . '/lib/plugins/'); /** * Implementation of a Free/Busy data source reading from IMAP * (not yet implemented!) */ class SourceIMAP extends Source { private $folders = array(); public function __construct($config) { parent::__construct($config + array('mail_attributes' => 'mail')); // load the Roundcube framework with its autoloader require_once KOLAB_FREEBUSY_ROOT . '/lib/Roundcube/bootstrap.php'; $rcube = \rcube::get_instance(\rcube::INIT_WITH_DB | \rcube::INIT_WITH_PLUGINS); // Load plugins $rcube->plugins->init($rcube); $rcube->plugins->load_plugins(array(), array('libkolab','libcalendaring')); + + // get libvcalendar instance + $this->libvcal = \libcalendaring::get_ical(); } /** * @see Source::getFreeBusyData() */ public function getFreeBusyData($user, $extended) { $log = Logger::get('imap', intval($this->config['loglevel'])); $config = $this->getUserConfig($user); parse_str(strval($config['query']), $param); $config += $param; // log this... $log->addInfo("Fetching data for ", $config); // caching is enabled if (!empty($config['cacheto'])) { // check for cached data if ($cached = $this->getCached($config)) { $log->addInfo("Deliver cached data from " . $config['cacheto']); return $cached; } // touch cache file to avoid multiple requests generating the same data if (file_exists($config['cacheto'])) { touch($config['cacheto']); } else { file_put_contents($config['cacheto'], Utils::dummyVFreebusy($user['mail'])); $tempfile = $config['cacheto']; } } // compose a list of user email addresses $user_email = array(); foreach (Config::convert($this->config['mail_attributes'], Config::ARR) as $key) { if (!empty($user[$key])) { $user_email = array_merge($user_email, (array)$user[$key]); } } // synchronize with IMAP and read Kolab event objects if ($imap = $this->imap_login($config)) { // target folder is specified in source URI if ($config['path'] && $config['path'] != '/') { $folders = array(\kolab_storage::get_folder(substr($config['path'], 1))); $read_all = true; } else { // list all folders of type 'event' $folders = \kolab_storage::get_folders('event', false); $read_all = false; } - // make \libvcalendar class available - \libcalendaring::get_ical(); - $utc = new \DateTimezone('UTC'); $dtstart = Utils::periodStartDT(); $dtend = Utils::periodEndDT(); - $calendar = VObject\Component::create('VCALENDAR'); + $calendar = new VObject\Component\VCalendar(); $seen = array(); + $this->libvcal->set_timezone($utc); + $log->addInfo("Getting events from IMAP in range", array($dtstart->format('c'), $dtend->format('c'))); $query = array(array('dtstart','<=',$dtend), array('dtend','>=',$dtstart)); foreach ($folders as $folder) { $count = 0; $log->debug('Reading Kolab folder: ' . $folder->name, $folder->get_folder_info()); // skip other user's shared calendars if (!$read_all && $folder->get_namespace() == 'other') { continue; } // set ACL (temporarily) if ($config['acl']) { $folder->_old_acl = $folder->get_myrights(); $imap->set_acl($folder->name, $config['user'], $config['acl']); } foreach ($folder->select($query) as $event) { //$log->debug('Processing event', $event); if ($event['cancelled']) { continue; } // only consider shared namespace events if user is a confirmed participant (or organizer) if (!$read_all && $folder->get_namespace() == 'shared') { $participant = false; if (is_array($event['organizer']) && !empty($event['organizer']['email'])) { $participant = in_array($event['organizer']['email'], $user_email); } else if (is_array($event['attendees'])) { foreach ($event['attendees'] as $attendee) { if (in_array($attendee['email'], $user_email)) { if ($attendee['status'] == 'ACCEPTED') { $participant = true; break; } else if ($attendee['status'] == 'TENTATIVE') { $event['free_busy'] = 'tentative'; $participant = true; break; } } } } if (!$participant) { $log->debug('Skip shared event', array($event['uid'], $event['title'])); continue; } } // skip declined events else if (is_array($event['attendees']) && !$this->check_participation($event, $user_email)) { $log->debug('Skip declined event', array($event['uid'], $event['title'])); continue; } // translate all-day dates into absolute UTC times // FIXME: use server timezone? if ($event['allday']) { $utc = new \DateTimeZone('UTC'); if (!empty($event['start'])) { $event['start']->setTimeZone($utc); $event['start']->setTime(0,0,0); } if (!empty($event['end'])) { $event['end']->setTimeZone($utc); $event['end']->setTime(23,59,59); } } // avoid duplicate entries $key = $event['start']->format('c') . '/' . $event['end']->format('c'); if ($seen[$key]++) { $log->debug('Skipping duplicate event at ' . $key, array($event['uid'], $event['title'])); continue; } // copied from libvcalendar::_to_ical() - $ve = $this->to_vevent($event); + $ve = $this->to_vevent($event, $calendar); if ($event['recurrence']) { if ($exdates = $event['recurrence']['EXDATE']) unset($event['recurrence']['EXDATE']); if ($rdates = $event['recurrence']['RDATE']) unset($event['recurrence']['RDATE']); if ($event['recurrence']['FREQ']) $ve->add('RRULE', \libcalendaring::to_rrule($event['recurrence'])); // consider recurrence exceptions if (is_array($event['recurrence']['EXCEPTIONS'])) { foreach ($event['recurrence']['EXCEPTIONS'] as $i => $exception) { // register exdate for this occurrence if ($exception['recurrence_date'] instanceof \DateTime) { $exdates[] = $exception['recurrence_date']; } // add exception to vcalendar container if (!$exception['cancelled'] && $this->check_participation($exception, $user_email)) { - $vex = $this->to_vevent($exception); + $vex = $this->to_vevent($exception, $calendar); $vex->UID = $event['uid'] . '-' . $i; $calendar->add($vex); $log->debug("Adding event exception for processing:\n" . $vex->serialize()); } } } // add EXDATEs each one per line (for Thunderbird Lightning) if ($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($utc); $ve->add(new VObject\Property('EXDATE', $exd->format('Ymd\\THis\\Z'))); } } } // add RDATEs if (!empty($rdates)) { $sample = \libvcalendar::datetime_prop('RDATE', $rdates[0]); $rdprop = new VObject\Property\MultiDateTime('RDATE', null); $rdprop->setDateTimes($rdates, $sample->getDateType()); $ve->add($rdprop); } } // append to vcalendar container $calendar->add($ve); $count++; $log->debug("Adding event for processing:\n" . $ve->serialize()); } $log->addInfo("Added $count events from folder" . $folder->name); } $this->imap_disconnect($imap, $config, $folders); // feed the calendar object into the free/busy generator // we must specify a start and end date, because recurring events are expanded. nice! $fbgen = new FreeBusyGenerator($dtstart, $dtend, $calendar); // get the freebusy report $freebusy = $fbgen->getResult(); $freebusy->PRODID = Utils::PRODID; $freebusy->METHOD = 'PUBLISH'; $freebusy->VFREEBUSY->UID = date('YmdHi') . '-' . substr(md5($user_email[0]), 0, 16); $freebusy->VFREEBUSY->ORGANIZER = 'mailto:' . $user_email[0]; // serialize to VCALENDAR format return $freebusy->serialize(); } // remove (temporary) cache file again else if ($tempfile) { unlink($tempfile); } return false; } /** * Helper method to establish connection to the configured IMAP backend */ private function imap_login($config) { $rcube = \rcube::get_instance(); $imap = $rcube->get_storage(); $host = $config['host']; $port = $config['port'] ?: ($config['scheme'] == 'imaps' ? 993 : 143); // detect ssl|tls method if ($config['scheme'] == 'imaps' || $port == 993) { $ssl = 'imaps'; } elseif ($config['scheme'] == 'tls') { $ssl = 'tls'; } else { $ssl = false; } // enable proxy authentication if (!empty($config['proxy_auth'])) { $imap->set_options(array('auth_cid' => $config['proxy_auth'], 'auth_pw' => $config['pass'])); } // authenticate user in IMAP if (!$imap->connect($host, $config['user'], $config['pass'], $port, $ssl)) { Logger::get('imap')->addWarning("Failed to connect to IMAP server: " . $imap->get_error_code(), $config); return false; } // fake user object to rcube framework $rcube->set_user(new \rcube_user('0', array('username' => $config['user']))); return $imap; } /** * Cleanup and close IMAP connection */ private function imap_disconnect($imap, $config, $folders) { // reset ACL if ($config['acl'] && !empty($folders)) { foreach ($folders as $folder) { $imap->set_acl($folder->name, $config['user'], $folder->_old_acl); } } $imap->close(); } /** * Helper method to build a Sabre/VObject from the gieven event data */ - private function to_vevent($event) + private function to_vevent($event, $cal) { // copied from libvcalendar::_to_ical() - $ve = VObject\Component::create('VEVENT'); + $ve = $cal->create('VEVENT'); $ve->UID = $event['uid']; if (!empty($event['start'])) - $ve->add(\libvcalendar::datetime_prop('DTSTART', $event['start'], false, false)); + $ve->add($this->libvcal->datetime_prop($cal, 'DTSTART', $event['start'], false, false)); if (!empty($event['end'])) - $ve->add(\libvcalendar::datetime_prop('DTEND', $event['end'], false, false)); + $ve->add($this->libvcal->datetime_prop($cal, 'DTEND', $event['end'], false, false)); if (!empty($event['free_busy'])) $ve->add('TRANSP', $event['free_busy'] == 'free' ? 'TRANSPARENT' : 'OPAQUE'); if ($event['free_busy'] == 'tentative') $ve->add('STATUS', 'TENTATIVE'); else if (!empty($event['status'])) $ve->add('STATUS', $event['status']); return $ve; } /** * Helper method to check the participation status of the requested user */ private function check_participation($event, $user_email) { $result = true; if (is_array($event['attendees'])) { foreach ($event['attendees'] as $attendee) { if (in_array($attendee['email'], $user_email) && $attendee['status'] == 'DECLINED') { $result = false; break; } } } return $result; } } diff --git a/lib/Kolab/FreeBusy/Utils.php b/lib/Kolab/FreeBusy/Utils.php index 362461d..3bcb599 100644 --- a/lib/Kolab/FreeBusy/Utils.php +++ b/lib/Kolab/FreeBusy/Utils.php @@ -1,240 +1,262 @@ * * Copyright (C) 2013-2014, 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 . */ namespace Kolab\FreeBusy; /** * Static calss providing utility functions for the Free/Busy service */ class Utils { const PRODID = '-//kolab.org//NONSGML Kolab Free-Busy Service 3.2//EN'; /** * Resolve the given directory to a real path ending with $append * * @param string Arbitrary directory directory path * @param string Make path end with this string/character * @return string Absolute file system path */ public static function abspath($dirname, $append = '') { if ($dirname[0] != '/') $dirname = realpath(KOLAB_FREEBUSY_ROOT . '/' . $dirname); return rtrim($dirname, '/') . $append; } /** * Returns remote IP address and forwarded addresses if found * * @return string Remote IP address(es) */ public static function remoteIP() { $address = $_SERVER['REMOTE_ADDR']; // use the NGINX X-Real-IP header, if set if (!empty($_SERVER['HTTP_X_REAL_IP'])) { $address = $_SERVER['HTTP_X_REAL_IP']; } // use the X-Forwarded-For header, if set if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { $address = $_SERVER['HTTP_X_FORWARDED_FOR']; } return $address; } /** * Checks if the given IP address is in one of the provided ranges * * @param string IP address * @param array List of IP ranges/subnets to check against * @return boolean True if in range, False if not */ public static function checkIPRange($ip, $ranges) { $ipv6 = strpos($ip, ':') !== false; $ipbin = $ipv6 ? self::ip6net2bits($ip) : ip2long($ip); foreach ((array)$ranges as $range) { // don't compare IPv4 and IPv6 addresses/ranges $rangev6 = strpos($range, ':') !== false; if ($ipv6 != $rangev6) { continue; } // quick substring check (e.g. 192.168.0.) if (( $ipv6 && strpos($ipbin, self::ip6net2bits($range)) === 0) || (!$ipv6 && strpos($ip, rtrim($range, '*')) === 0)) { return true; } // range from-to specified (IPv4 only) list($lower, $upper) = explode('-', $range); if (strlen($upper) && !$ipv6) { if ($ipbin >= ip2long(trim($lower)) && $ipbin <= ip2long(trim($upper))) { return true; } } // subnet/length is given list($subnet, $bits) = explode('/', $range); // IPv6 subnet if (strlen($bits) && $ipv6) { $subnetbin = self::ip6net2bits($subnet); if (substr($ipbin, 0, $bits) === substr($subnetbin, 0, $bits)) { return true; } } // IPv4 subnet else if (strlen($bits)) { $subnet = ip2long($subnet); $mask = -1 << $bits; $subnet &= $mask; // just in case the supplied subnet wasn't correctly aligned if (($ipbin & $mask) == $subnet) { return true; } } } return false; } /** * Convert the given IPv6 address to a binary string representation. * (from http://stackoverflow.com/questions/7951061/matching-ipv6-address-to-a-cidr-subnet) */ public static function ip6net2bits($inet) { $binaryip = ''; $unpacked = @unpack('A16', inet_pton($inet)); foreach (str_split($unpacked[1]) as $char) { $binaryip .= str_pad(decbin(ord($char)), 8, '0', STR_PAD_LEFT); } return $binaryip; } /** * Returns number of seconds for a specified offset string. * * @param string String representation of the offset (e.g. 20min, 5h, 2days, 1week) * @return int Number of seconds */ public static function getOffsetSec($str) { if (preg_match('/^([0-9]+)\s*([smhdw])/i', $str, $regs)) { $amount = (int) $regs[1]; $unit = strtolower($regs[2]); } else { $amount = (int) $str; $unit = 's'; } switch ($unit) { case 'w': $amount *= 7; case 'd': $amount *= 24; case 'h': $amount *= 60; case 'm': $amount *= 60; } return $amount; } /** * Getter for the free/busy period start time * * @return int Unix timestamp */ public static function periodStart() { + // use date from HTTP query + if (!empty($_GET['dtstart'])) { + return self::periodStartDT()->format('u'); + } + // Should probably be a setting. For now, do 8 weeks in the past return time() - (60 * 60 * 24 * 7 * 8); } /** * Getter for the free/busy period start time * * @return object DateTime instance */ public static function periodStartDT() { + // use date from HTTP query + if (!empty($_GET['dtstart']) && + ($dtstart = \rcube_utils::anytodatetime(filter_input(INPUT_GET, 'dtstart', FILTER_SANITIZE_STRING)))) { + return $dtstart; + } + // Should probably be a setting. For now, do 8 weeks in the past return new \DateTime('now - 8 weeks 00:00:00', new \DateTimezone('UTC')); } /** * Getter for the free/busy period end time * * @return int Unix timestamp */ public static function periodEnd() { + // use date from HTTP query + if (!empty($_GET['dtend'])) { + return self::periodEndDT()->format('u'); + } + // Should probably be a setting. For now, do 16 weeks into the future return time() + (60 * 60 * 24 * 7 * 16); } /** * Getter for the free/busy period end time * * @return object DateTime instance */ public static function periodEndDT() { + // use date from HTTP query + if (!empty($_GET['dtend']) && + ($dtend = \rcube_utils::anytodatetime(filter_input(INPUT_GET, 'dtend', FILTER_SANITIZE_STRING)))) { + return $dtend; + } + // Should probably be a setting. For now, do 8 weeks in the past return new \DateTime('now + 16 weeks 00:00:00', new \DateTimezone('UTC')); } /** * Returns an apparent empty Free/Busy list for the given user */ public static function dummyVFreebusy($user) { $now = time(); $dtformat = 'Ymd\THis\Z'; $dummy = "BEGIN:VCALENDAR\n"; $dummy .= "VERSION:2.0\n"; $dummy .= "PRODID:" . self::PRODID . "\n"; $dummy .= "METHOD:PUBLISH\n"; $dummy .= "BEGIN:VFREEBUSY\n"; $dummy .= "ORGANIZER:MAILTO:" . $user . "\n"; $dummy .= "DTSTAMP:" . gmdate($dtformat) . "\n"; $dummy .= "DTSTART:" . gmdate($dtformat, self::periodStart()) . "\n"; $dummy .= "DTEND:" . gmdate($dtformat, self::periodEnd()) . "\n"; $dummy .= "COMMENT:This is a dummy vfreebusy that indicates an empty calendar\n"; $dummy .= "FREEBUSY:19700101T000000Z/19700101T000000Z\n"; $dummy .= "END:VFREEBUSY\n"; $dummy .= "END:VCALENDAR\n"; return $dummy; } } \ No newline at end of file