composer.json config/config.ini.sample lib/Kolab public_html/index.php README.md
Thomas Brüderli
bruederli at kolabsys.com
Tue May 6 18:00:00 CEST 2014
README.md | 22 ++-
composer.json | 2
config/config.ini.sample | 27 +++
lib/Kolab/FreeBusy/Directory.php | 15 +-
lib/Kolab/FreeBusy/FormatExchange2010.php | 2
lib/Kolab/FreeBusy/Source.php | 36 ++++
lib/Kolab/FreeBusy/SourceFile.php | 5
lib/Kolab/FreeBusy/SourceIMAP.php | 218 +++++++++++++++++++++++++++++-
lib/Kolab/FreeBusy/Utils.php | 69 +++++++++
public_html/index.php | 27 ---
10 files changed, 381 insertions(+), 42 deletions(-)
New commits:
commit 886215e40e6fd4e5c8994d069bc390840ab0e0bf
Author: Thomas Bruederli <thomas at roundcube.net>
Date: Tue May 6 17:57:22 2014 +0200
- Implement source type 'imap' that fetches free-busy data right from IMAP folders.
This requires the Roundcube framework, config and plugins to be symlinked. Described in README
- Add caching option for all sources + expire option for file source.
diff --git a/README.md b/README.md
index e395922..9db77b0 100644
--- a/README.md
+++ b/README.md
@@ -7,25 +7,37 @@ This package uses [Composer](http://getcomposer.org) to install and maintain req
Execute this in the project root directory:
-$ curl -s http://getcomposer.org/installer | php
+`$ curl -s http://getcomposer.org/installer | php`
This will create a file named composer.phar in the project directory.
2. Install Dependencies
-$ php composer.phar install
+`$ php composer.phar install`
+
+2a. Link Roundcube framework and plugins
+
+If free-busy data is to be pulled from IMAP directly, the Roundcube framework, config
+and Kolab-specific plugins are required. Symlink them into the project directory:
+
+```
+$ ln -s /usr/share/roundcubemail/program/lib/Roundcube lib/Roundcube
+$ ln -s /usr/share/roundcubemail/plugins lib/plugins
+$ ln -s /etc/roundcubemail/defaults.inc.php config/defaults.inc.php
+$ ln -s /etc/roundcubemail/config.inc.php config/config.inc.php
+```
3. Create local config
Copy the config template file to config/config.ini:
-$ cp config/config.ini.sample config/config.ini
+`$ cp config/config.ini.sample config/config.ini`
Edit the local config/config.ini file according to your setup and taste.
4. Give write access for the webserver user to the 'log' folder:
-$ chown <www-user> log
+`$ chown <www-user> log`
-6. Configure your webserver to point to the 'web' directory of this package as document root.
+5. Configure your webserver to point to the 'web' directory of this package as document root.
diff --git a/composer.json b/composer.json
index 769b88d..91cad3b 100644
--- a/composer.json
+++ b/composer.json
@@ -2,7 +2,7 @@
"name": "kolab/free-busy",
"description": "Kolab Free/Busy Service",
"license": "AGPL-3.0",
- "version": "0.1.3",
+ "version": "0.1.4",
"repositories": [
{
"type": "pear",
diff --git a/config/config.ini.sample b/config/config.ini.sample
index 778f15a..eeb7347 100644
--- a/config/config.ini.sample
+++ b/config/config.ini.sample
@@ -39,6 +39,12 @@ type = static
filter = "@yourdomain"
fbsource = file:/var/lib/kolab-freebusy/%s.ifb
+;; check if primary email address hits a cache file (saves LDAP lookups)
+[directory "local-cache"]
+type = static
+fbsource = file:/var/cache/kolab-freebusy/%s.ifb
+expires = 10m
+
;; local Kolab directory server
[directory "kolab-ldap"]
type = ldap
@@ -49,7 +55,21 @@ base_dn = "dc=yourdomain,dc=com"
filter = "(&(objectClass=kolabInetOrgPerson)(|(uid=%s)(mail=%s)(alias=%s)))"
attributes = mail, sn
lc_attributes = sn
-fbsource = file:/www/kolab-freebusy/data/%mail.ifb
+fbsource = file:/var/lib/kolab-freebusy/%mail.ifb
+loglevel = 200 ; Info
+
+;; resolve Kolab resources from LDAP and fetch calendar from IMAP
+[directory "kolab-resources"]
+type = ldap
+host = ldap://localhost:389
+bind_dn = "uid=kolab-service,ou=Special Users,dc=yourdomain,dc=com"
+bind_pw = "<service-bind-pw>"
+base_dn = "ou=Resources,dc=yourdomain,dc=com"
+filter = "(&(objectClass=kolabsharedfolder)(mail=%s))"
+attributes = mail, kolabtargetfolder
+fbsource = "imap://cyrus-admin:<admin-pass>@localhost/%kolabtargetfolder?acl=lrs"
+cacheto = /var/cache/kolab-freebusy/%mail.ifb
+expires = 10m
loglevel = 100 ; Debug
;; external MS Exchange 2010 server
@@ -59,3 +79,8 @@ filter = "@microsoft.com$"
fbsource = https://externalhost/free-busy/%s.ics
format = Exchange2010
+;; further examples of fbsource URIs
+; - fetch data from another server by HTTP(s)
+; fbsource = "https://fb-service-user:imap-password@kolab-server/freebusy/%mail.ifb"
+; - read directoy from a users calendars (all) using IMAP proxy authentication
+; fbsource = "imap://%mail:<admin-pass>@localhost/?proxy_auth=cyrus-admin"
diff --git a/lib/Kolab/FreeBusy/Directory.php b/lib/Kolab/FreeBusy/Directory.php
index 8ac2c24..95ce87a 100644
--- a/lib/Kolab/FreeBusy/Directory.php
+++ b/lib/Kolab/FreeBusy/Directory.php
@@ -74,12 +74,25 @@ abstract class Directory
// resolve user record first
if ($user = $this->resolve($user)) {
$fbsource = $this->config['fbsource'];
- if ($source = Source::Factory($fbsource)) {
+ if ($source = Source::Factory($fbsource, $this->config)) {
// forward request to Source instance
if ($data = $source->getFreeBusyData($this->postprocessAttrib($user), $extended)) {
// send data through the according format converter
$converter = Format::factory($this->config['format']);
$data = $converter->toVCalendar($data);
+
+ // cache the generated data
+ if ($data && $this->config['cacheto'] && !$source->isCached()) {
+ $path = preg_replace_callback(
+ '/%\{?([a-z0-9]+)\}?/',
+ function($m) use ($user) { return $user[$m[1]]; },
+ $this->config['cacheto']
+ );
+
+ if (!@file_put_contents($path, $data, LOCK_EX)) {
+ Logger::get('directory')->addError("Failed to write to cache file '" . $path . "'!");
+ }
+ }
}
return $data;
diff --git a/lib/Kolab/FreeBusy/FormatExchange2010.php b/lib/Kolab/FreeBusy/FormatExchange2010.php
index 55a04dd..f1a09fa 100644
--- a/lib/Kolab/FreeBusy/FormatExchange2010.php
+++ b/lib/Kolab/FreeBusy/FormatExchange2010.php
@@ -69,7 +69,7 @@ class FormatExchange2010 extends Format
// get the freebusy report
$freebusy = $fbgen->getResult();
- $freebusy->PRODID = '-//kolab.org//NONSGML Kolab Server 3//EN';
+ $freebusy->PRODID = Utils::PRODID;
$freebusy->METHOD = 'PUBLISH';
// serialize to VCALENDAR format
diff --git a/lib/Kolab/FreeBusy/Source.php b/lib/Kolab/FreeBusy/Source.php
index 3a256a3..a0a915d 100644
--- a/lib/Kolab/FreeBusy/Source.php
+++ b/lib/Kolab/FreeBusy/Source.php
@@ -29,22 +29,24 @@ namespace Kolab\FreeBusy;
abstract class Source
{
protected $config = array();
+ protected $cached = false;
/**
* Factory method creating an instace of Source according to config
*
+ * @param string Source URI
* @param array Hash array with config
*/
- public static function factory($url)
+ public static function factory($url, $conf)
{
$config = parse_url($url);
$config['url'] = $url;
switch ($config['scheme']) {
- case 'file': return new SourceFile($config);
+ case 'file': return new SourceFile($config + $conf);
case 'imap':
- case 'imaps': return new SourceIMAP($config);
+ case 'imaps': return new SourceIMAP($config + $conf);
case 'http':
- case 'https': return new SourceURL($config);
+ case 'https': return new SourceURL($config + $conf);
}
Logger::get('source')->addError("Invalid source configuration: " . $url);
@@ -85,4 +87,30 @@ abstract class Source
return $config;
}
+
+ /**
+ * Helper method to check if a cached file exists and is still valid
+ *
+ * @param array Hash array with (replaced) config properties
+ * @return string Cached free-busy data or false if cache file doesn't exist or is expired
+ */
+ protected function getCached($config)
+ {
+ if ($config['cacheto'] && file_exists($config['cacheto'])) {
+ if (empty($config['expires']) || filemtime($config['cacheto']) + Utils::getOffsetSec($config['expires']) >= time()) {
+ $this->cached = true;
+ return file_get_contents($config['cacheto']);
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Return the value of the 'cached' flag
+ */
+ public function isCached()
+ {
+ return $this->cached;
+ }
}
\ No newline at end of file
diff --git a/lib/Kolab/FreeBusy/SourceFile.php b/lib/Kolab/FreeBusy/SourceFile.php
index af25b6e..5106322 100644
--- a/lib/Kolab/FreeBusy/SourceFile.php
+++ b/lib/Kolab/FreeBusy/SourceFile.php
@@ -38,7 +38,10 @@ class SourceFile extends Source
// deliver file contents if found
if (is_readable($config['path'])) {
- return file_get_contents($config['path']);
+ // check expiration if configured
+ if (empty($this->config['expires']) || filemtime($config['path']) + Utils::getOffsetSec($this->config['expires']) > time()) {
+ return file_get_contents($config['path']);
+ }
}
// not found
diff --git a/lib/Kolab/FreeBusy/SourceIMAP.php b/lib/Kolab/FreeBusy/SourceIMAP.php
index c545423..494a4d5 100644
--- a/lib/Kolab/FreeBusy/SourceIMAP.php
+++ b/lib/Kolab/FreeBusy/SourceIMAP.php
@@ -23,19 +23,235 @@
namespace Kolab\FreeBusy;
+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);
+
+ // 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'));
+ }
+
/**
* @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']));
+ }
+ }
+
+ // 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 = new \DateTime('now - 8 weeks 00:00:00', $utc);
+ $dtend = new \DateTime('now + 16 weeks 00:00:00', $utc);
+ $calendar = VObject\Component::create('VCALENDAR');
+
+ $query = array(array('dtstart','>',$dtstart), array('dtend','<',$dtend));
+ foreach ($folders as $folder) {
+ $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('Found event', $event);
+
+ if ($event['cancelled'])
+ continue;
+
+ // TODO: only consider shared namespace events if user is a confirmed participant
+ if (!$read_all && $folder->get_namespace() == 'shared') {
+ continue; // skip all for now
+ }
+
+ // copied from libvcalendar::_to_ical()
+ $ve = VObject\Component::create('VEVENT');
+
+ // 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['start']))
+ $ve->add(\libvcalendar::datetime_prop('DTSTART', $event['start'], false, (bool)$event['allday']));
+ if (!empty($event['end']))
+ $ve->add(\libvcalendar::datetime_prop('DTEND', $event['end'], false, (bool)$event['allday']));
+
+ 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']);
+
+ 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']));
+
+ // 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);
+ }
+ }
+
+ $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->ORGANIZER = 'mailto:' . $user['mail'];
+
+ // serialize to VCALENDAR format
+ return $freebusy->serialize();
+ }
+ // remove (temporary) cache file again
+ else if (!empty($config['cacheto']) && file_exists($config['cacheto'])) {
+ unlink($config['cacheto']);
+ }
+
+ 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);
+ $ssl = $config['scheme'] == 'imaps' || $port == 993;
+
+ // 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);
+ }
+ }
- // TODO: implement this
+ $imap->close();
}
}
diff --git a/lib/Kolab/FreeBusy/Utils.php b/lib/Kolab/FreeBusy/Utils.php
index 3fea324..0d76ab6 100644
--- a/lib/Kolab/FreeBusy/Utils.php
+++ b/lib/Kolab/FreeBusy/Utils.php
@@ -5,7 +5,7 @@
*
* @author Thomas Bruederli <bruederli at kolabsys.com>
*
- * Copyright (C) 2013, Kolab Systems AG <contact at kolabsys.com>
+ * Copyright (C) 2013-2014, Kolab Systems AG <contact at kolabsys.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
@@ -28,6 +28,8 @@ namespace Kolab\FreeBusy;
*/
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
*
@@ -69,7 +71,7 @@ class Utils
* 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
+ * @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)
@@ -136,4 +138,67 @@ class Utils
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;
+ }
+
+ /**
+ * Returns an apparent empty Free/Busy list for the given user
+ */
+ public static function dummyVFreebusy($user)
+ {
+ $now = time();
+ $dtformat = 'Ymd\THis\Z';
+
+ // NOTE: The following settings should probably correspond with
+ // whatever period of time kolab-freebusyd thinks it should use.
+
+ // Should probably be a setting. For now, do 8 weeks in the past
+ $start = $now - (60 * 60 * 24 * 7 * 8);
+ // Should probably be a setting. For now, do 16 weeks into the future
+ $end = $now + (60 * 60 * 24 * 7 * 16);
+
+ $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, $start) . "\n";
+ $dummy .= "DTEND:" . gmdate($dtformat, $end) . "\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
diff --git a/public_html/index.php b/public_html/index.php
index 14c549c..5f8058b 100644
--- a/public_html/index.php
+++ b/public_html/index.php
@@ -5,7 +5,7 @@
*
* This is the public API to provide Free/Busy information for Kolab users.
*
- * @version 0.1.3
+ * @version 0.1.4
* @author Thomas Bruederli <bruederli at kolabsys.com>
*
* Copyright (C) 2014, Kolab Systems AG <contact at kolabsys.com>
@@ -105,31 +105,8 @@ if ($config->valid()) {
else {
$log->addInfo("Returning empty Free/Busy list for user $user");
- $now = time();
- $dtformat = 'Ymd\THis\Z';
-
- // NOTE: The following settings should probably correspond with
- // whatever period of time kolab-freebusyd thinks it should use.
-
- // Should probably be a setting. For now, do 8 weeks in the past
- $start = $now - (60 * 60 * 24 * 7 * 8);
- // Should probably be a setting. For now, do 16 weeks into the future
- $end = $now + (60 * 60 * 24 * 7 * 16);
-
// Return an apparent empty Free/Busy list.
- print "BEGIN:VCALENDAR\n";
- print "VERSION:2.0\n";
- print "PRODID:-//kolab.org//NONSGML Kolab Server 3//EN\n";
- print "METHOD:PUBLISH\n";
- print "BEGIN:VFREEBUSY\n";
- print "ORGANIZER:MAILTO:" . $user . ".ifb\n";
- print "DTSTAMP:" . gmdate($dtformat) . "\n";
- print "DTSTART:" . gmdate($dtformat, $start) . "\n";
- print "DTEND:" . gmdate($dtformat, $end) . "\n";
- print "COMMENT:This is a dummy vfreebusy that indicates an empty calendar\n";
- print "FREEBUSY:19700101T000000Z/19700101T000000Z\n";
- print "END:VFREEBUSY\n";
- print "END:VCALENDAR\n";
+ print Utils::dummyVFreebusy($user);
}
}
More information about the commits
mailing list