Page MenuHomePhorge

No OneTemporary

diff --git a/config/config.ini.sample b/config/config.ini.sample
index b97c011..20dba14 100644
--- a/config/config.ini.sample
+++ b/config/config.ini.sample
@@ -1,67 +1,89 @@
;; Kolab Free/Busy Service configuration
; Logging configuration
[log]
driver = file ; supported drivers: file, syslog
path = ./logs
name = freebusy
level = 300 ; (100 = Debug, 200 = Info, 300 = Warn, 400 = Error, 500 = Critical)
;;
;; try local filesystem first (F/B has been generated externally)
;;
[directory "local"]
type = static
filter = "@example.org"
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-people"]
type = ldap
host = "ldap://localhost:389"
bind_dn = "uid=kolab-service,ou=Special Users,dc=example,dc=org"
bind_pw = "SomePassword"
base_dn = "ou=People,dc=example,dc=org"
filter = "(&(objectClass=kolabInetOrgPerson)(|(mail=%s)(alias=%s))"
attributes[] = mail
lc_attributes[] = mail
primary_domain = "example.org"
; %s is replaced by the user's result_attribute found
fbsource = imaps://%s:CyrusAdminPassword@imap.example.org/?proxy_auth=cyrus-admin
loglevel = 300
cacheto = /var/cache/kolab-freebusy/%mail.ifb
expires = 10m
;;
;; 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=example,dc=org"
bind_pw = "SomePassword"
base_dn = "ou=Resources,dc=example,dc=org"
filter = "(&(objectClass=kolabsharedfolder)(kolabfoldertype=event)(mail=%s))"
attributes = mail, kolabtargetfolder
primary_domain = "example.org"
; Use the Free/Busy daemon that separates the abuse of credentials
;fbsource = "fbdaemon://localhost:<port>?folder=%kolabtargetfolder"
;timeout = 10 ; abort after 10 seconds
fbsource = "imap://cyrus-admin:CyrusAdminPassword@imap.lhm.klab.cc/%kolabtargetfolder?acl=lrs"
cacheto = /var/cache/kolab-freebusy/%mail.ifb
expires = 10m
loglevel = 300
+;;
+;; For collections, aggregate the free/busy data from all its members
+;;
+[directory "kolab-resource-collections"]
+type = ldap
+host = "ldap://localhost:389"
+bind_dn = "uid=kolab-service,ou=Special Users,dc=example,dc=org"
+bind_pw = "SomePassword"
+base_dn = "ou=Resources,dc=example,dc=org"
+filter = "(&(objectClass=kolabgroupofuniquenames)(mail=%s))"
+attributes = uniquemember, mail
+resolve_dn = uniquemember
+resolve_attribute = mail
+; the 'aggregate' source takes one parameter
+; denoting the attribute holding all member email addresses
+fbsource = "aggregate://%uniquemember"
+; consider these directories for getting the member's free/busy data
+directories = kolab-resources
+cacheto = /var/cache/kolab-freebusy/%mail.ifb
+expires = 10m
+loglevel = 200 ; Info
+
diff --git a/lib/Kolab/FreeBusy/DirectoryLDAP.php b/lib/Kolab/FreeBusy/DirectoryLDAP.php
index c155d98..c97340a 100644
--- a/lib/Kolab/FreeBusy/DirectoryLDAP.php
+++ b/lib/Kolab/FreeBusy/DirectoryLDAP.php
@@ -1,136 +1,158 @@
<?php
/**
* This file is part of the Kolab Server Free/Busy Service
*
* @author Thomas Bruederli <bruederli@kolabsys.com>
*
* Copyright (C) 2013, Kolab Systems AG <contact@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
* 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 <http://www.gnu.org/licenses/>.
*/
namespace Kolab\FreeBusy;
// PEAR modules operate in global namespace
use \Net_LDAP3;
use \Kolab\Config;
use \Monolog\Logger as Monolog;
/**
* Implementation of an address lookup using an LDAP directory
*/
class DirectoryLDAP extends Directory
{
private $ldap;
private $logger;
private $ready = false;
/**
* Default constructor loading directory configuration
*/
public function __construct($config)
{
$this->config = $config;
$host = parse_url($config['host']);
$ldap_config = array(
'hosts' => array($host['host']),
'port' => $host['port'] ?: 389,
'use_tls' => $host['scheme'] == 'tls' || $host['scheme'] == 'ldaps',
'root_dn' => $config['base_dn'],
'log_hook' => array($this, 'log'),
) + $config;
// instantiate Net_LDAP3 and connect with logger
$this->logger = Logger::get('ldap', intval($config['loglevel']));
$this->ldap = new Net_LDAP3($ldap_config);
// connect + bind to LDAP server
if ($this->ldap->connect()) {
$this->ready = $this->ldap->bind($config['bind_dn'], $config['bind_pw']);
}
if ($this->ready) {
$this->logger->addInfo("Connected to $config[host] with '$config[bind_dn]'");
}
else {
$this->logger->addWarning("Connectiion to $config[host] with '$config[bind_dn]' failed!");
}
}
/**
* Callback for Net_LDAP3 logging
*/
public function log($level, $msg)
{
// map PHP log levels to Monolog levels
static $loglevels = array(
LOG_DEBUG => Monolog::DEBUG,
LOG_NOTICE => Monolog::NOTICE,
LOG_INFO => Monolog::INFO,
LOG_WARNING => Monolog::WARNING,
LOG_ERR => Monolog::ERROR,
LOG_CRIT => Monolog::CRITICAL,
LOG_ALERT => Monolog::ALERT,
LOG_EMERG => Monolog::EMERGENCY,
);
$msg = is_array($msg) ? join('; ', $msg) : strval($msg);
$this->logger->addRecord($loglevels[$level], $msg);
}
/**
* @see Directory::resolve()
*/
public function resolve($user)
{
$result = array('s' => $user);
if ($this->ready) {
// search with configured base_dn and filter
list($u, $d) = explode('@', $user);
if (empty($d)) $d = $this->config['primary_domain'];
$replaces = array('%dc' => 'dc=' . str_replace('.', ',dc=', $d), '%u' => $u);
$base_dn = strtr($this->config['base_dn'], $replaces);
$filter = str_replace('%s', Net_LDAP3::quote_string($user), strtr($this->config['filter'], $replaces));
$ldapresult = $this->ldap->search($base_dn, $filter, 'sub', Config::convert($this->config['attributes'], Config::ARR));
// got a valid result
if ($ldapresult && $ldapresult->count()) {
$ldapresult->rewind();
$entry = Net_LDAP3::normalize_entry($ldapresult->current()); // get the first entry
$this->logger->addInfo("Found " . $ldapresult->count() . " entries for $filter", $entry);
// convert entry attributes to strings and add them to the final result hash array
- foreach ($entry as $k => $v) {
- if (is_array($v) && count($v) > 1) {
- $result[$k] = array_map('strval', $v);
- }
- else if (!empty($v)) {
- $result[$k] = strval(is_array($v) ? $v[0] : $v);
+ $result += self::_compact_entry($entry);
+
+ // resolve DN attribute into the actual record
+ if (!empty($this->config['resolve_dn']) && array_key_exists($this->config['resolve_dn'], $result)) {
+ $k = $this->config['resolve_dn'];
+ $member_attr = $this->config['resolve_attribute'] ?: 'mail';
+ foreach ((array)$result[$k] as $i => $member_dn) {
+ if ($member_rec = $this->ldap->get_entry($member_dn, array($member_attr))) {
+ $member_rec = self::_compact_entry(Net_LDAP3::normalize_entry($member_rec));
+ $result[$k][$i] = $member_rec[$member_attr];
+ }
}
}
return $result;
}
$this->logger->addInfo("No entry found for $filter");
}
return false;
}
+ /**
+ * Helper method to convert entry attributes to simple values
+ */
+ private static function _compact_entry($entry)
+ {
+ $result = array();
+ foreach ($entry as $k => $v) {
+ if (is_array($v) && count($v) > 1) {
+ $result[$k] = array_map('strval', $v);
+ }
+ else if (!empty($v)) {
+ $result[$k] = strval(is_array($v) ? $v[0] : $v);
+ }
+ }
+ return $result;
+ }
+
}
diff --git a/lib/Kolab/FreeBusy/Source.php b/lib/Kolab/FreeBusy/Source.php
index def290f..92a673a 100644
--- a/lib/Kolab/FreeBusy/Source.php
+++ b/lib/Kolab/FreeBusy/Source.php
@@ -1,121 +1,122 @@
<?php
/**
* This file is part of the Kolab Server Free/Busy Service
*
* @author Thomas Bruederli <bruederli@kolabsys.com>
*
* Copyright (C) 2013, Kolab Systems AG <contact@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
* 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 <http://www.gnu.org/licenses/>.
*/
namespace Kolab\FreeBusy;
/**
* Abstract class to fetch free/busy data from a specific source
*/
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, $conf)
{
$config = parse_url($url);
$config['url'] = $url;
switch ($config['scheme']) {
case 'file': return new SourceFile($config + $conf);
case 'imap':
case 'imaps': return new SourceIMAP($config + $conf);
case 'http':
case 'https': return new SourceURL($config + $conf);
case 'fbd':
case 'fbdaemon': return new SourceFBDaemon($config + $conf);
+ case 'aggregate': return new SourceAggregator($config + $conf);
}
Logger::get('source')->addError("Invalid source configuration: " . $url);
return null;
}
/**
* Default constructor
*/
public function __construct($config)
{
$this->config = $config;
}
/**
* Retrieve free/busy data for the given user
*
* @param array Hash array with user attributes
*/
abstract public function getFreeBusyData($user, $extended);
/**
* Replace all %varname strings in config with values from $user
*/
protected function getUserConfig($user)
{
$config = array();
foreach ($this->config as $k => $val) {
if (is_string($val) && strpos($val, '%') !== false) {
$val = preg_replace_callback(
'/%\{?([a-z0-9]+)\}?/',
function($m) use ($k, $user) {
$enc = $k == 'url' || $k == 'query' || $k == 'fbsource';
return $enc ? urlencode($user[$m[1]]) : $user[$m[1]];
},
$val);
}
$config[$k] = $val;
}
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/SourceAggregator.php b/lib/Kolab/FreeBusy/SourceAggregator.php
new file mode 100644
index 0000000..05f0971
--- /dev/null
+++ b/lib/Kolab/FreeBusy/SourceAggregator.php
@@ -0,0 +1,230 @@
+<?php
+
+/**
+ * This file is part of the Kolab Server Free/Busy Service
+ *
+ * @author Thomas Bruederli <bruederli@kolabsys.com>
+ *
+ * Copyright (C) 2014, Kolab Systems AG <contact@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
+ * 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 <http://www.gnu.org/licenses/>.
+ */
+
+namespace Kolab\FreeBusy;
+
+
+use Kolab\Config;
+use Sabre\VObject;
+use Sabre\VObject\Reader;
+use Sabre\VObject\ParseException;
+
+/**
+ * Implementation of a Free/Busy data source aggregating multiple free/busy data sources
+ */
+class SourceAggregator extends Source
+{
+ /**
+ * @see Source::getFreeBusyData()
+ */
+ public function getFreeBusyData($user, $extended)
+ {
+ $log = Logger::get('aggregate', intval($this->config['loglevel']));
+ # $config = $this->getUserConfig($user);
+
+ $attr = str_replace('%', '', strval($this->config['path'] ?: $this->config['host']));
+ if (!empty($user[$attr])) {
+ $members = (array)$user[$attr];
+ $busy_periods = array();
+ $log->debug("Aggregate data for members", $members);
+
+ foreach ($members as $i => $member) {
+ $busy_times[$i] = array();
+
+ if ($member_data = $this->getDataFor($member)) {
+ try {
+ $vobject = Reader::read($member_data, Reader::OPTION_FORGIVING | Reader::OPTION_IGNORE_INVALID_LINES);
+ }
+ catch (Exception $e) {
+ $log->addError("Error parsing freebusy data", $e);
+ #continue;
+ }
+
+ // extract busy periods
+ if ($vobject && $vobject->name == 'VCALENDAR') {
+ $vfb = reset($vobject->select('VFREEBUSY'));
+ foreach ((array)$vfb->children as $prop) {
+ switch ($prop->name) {
+ case 'FREEBUSY':
+ // The freebusy component can hold more than 1 value, separated by commas.
+ $periods = explode(',', $prop->value);
+ $fbtype = strval($prop['FBTYPE']) ?: 'BUSY';
+
+ 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($busy_start, $busy_end) = explode('/', $period);
+
+ $busy_start = VObject\DateTimeParser::parse($busy_start);
+ $busy_end = VObject\DateTimeParser::parse($busy_end);
+ if ($busy_end instanceof \DateInterval) {
+ $tmp = clone $busy_start;
+ $tmp->add($busy_end);
+ $busy_end = $tmp;
+ }
+
+ if ($busy_end && $busy_end > $busy_start) {
+ $busy_times[$i][] = array($busy_start, $busy_end, $fbtype);
+ }
+ }
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ $calendar = VObject\Component::create('VCALENDAR');
+ $calendar->PRODID = Utils::PRODID;
+ $calendar->METHOD = 'PUBLISH';
+ $calendar->CALSCALE = 'GREGORIAN';
+
+ $vfreebusy = VObject\Component::create('VFREEBUSY');
+ $vfreebusy->UID = date('YmdHi') . '-' . substr(md5($user['s']), 0, 16);
+ $vfreebusy->ORGANIZER = 'mailto:' . $user['s'];
+
+ $dtstart = VObject\Property::create('DTSTART');
+ $dtstart->setDateTime(Utils::periodStartDT(), VObject\Property\DateTime::UTC);
+ $vfreebusy->add($dtstart);
+
+ $dtend = VObject\Property::create('DTEND');
+ $dtend->setDateTime(Utils::periodEndDT(), VObject\Property\DateTime::UTC);
+ $vfreebusy->add($dtend);
+
+ $dtstamp = VObject\Property::create('DTSTAMP');
+ $dtstamp->setDateTime(new \DateTime('now'), VObject\Property\DateTime::UTC);
+ $vfreebusy->add($dtstamp);
+
+ $calendar->add($vfreebusy);
+
+ // add aggregated busy periods
+ foreach ($this->aggregatedBusyTimes($busy_times) as $busy) {
+ $busy[0]->setTimeZone(new \DateTimeZone('UTC'));
+ $busy[1]->setTimeZone(new \DateTimeZone('UTC'));
+
+ $prop = VObject\Property::create(
+ 'FREEBUSY',
+ $busy[0]->format('Ymd\\THis\\Z') . '/' . $busy[1]->format('Ymd\\THis\\Z')
+ );
+ $prop['FBTYPE'] = $busy[2];
+ $vfreebusy->add($prop);
+ }
+
+ // serialize to VCALENDAR format
+ return $calendar->serialize();
+ }
+
+ return null;
+ }
+
+ /**
+ * Compose a full url from the given config (previously extracted with parse_url())
+ */
+ private function getDataFor($subject)
+ {
+ $conf = Config::get_instance();
+ $log = Logger::get('aggregate', intval($this->config['loglevel']));
+
+ $directories = $this->config['directories'] ?
+ Config::arr($this->config['directories']) :
+ array_keys($config->directory);
+
+ $log->addDebug('Fetch data for ' . $subject . ' in direcotories', $directories);
+
+ // iterate over directories
+ // same as in public_html/index.php
+ foreach ($directories as $key) {
+ if ($dirconfig = $conf->directory[$key]) {
+ $log->addDebug("Trying directory $key", $dirconfig);
+
+ $directory = Directory::factory($dirconfig);
+ if ($directory && ($fbdata = $directory->getFreeBusyData($subject, false))) {
+ $log->addInfo("Found valid data for subject $subject in directory $key");
+ return $fbdata;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Compare overlapping times and only keep those busy by ALL members
+ */
+ private function aggregatedBusyTimes($busy_times)
+ {
+ $result = array();
+
+ // 1. sort member busy times by the number of entries
+ usort($busy_times, function($a, $b) { return count($a) - count($b); });
+
+ // 2. compare busy slots from the first member with all the others.
+ // a time slot it only considered busy (for the entire collection) if ALL members are busy.
+ $member_times = array_shift($busy_times);
+
+ // if the collection only has one member, that one rules
+ if (!count($busy_times)) {
+ return $member_times;
+ }
+
+ foreach ($member_times as $busy_candidate) {
+ $start = $busy_candidate[0];
+ $end = $busy_candidate[1];
+ $type = $busy_candidate[2];
+
+ foreach ($busy_times as $other_member_times) {
+ $members_is_busy = false;
+ foreach ($other_member_times as $busy_time) {
+ // check for overlap with current candidate
+ if ($busy_time[1] > $start && $busy_time[0] < $end) {
+ $members_is_busy = true;
+
+ // reduce candidate to the overlapping range
+ if ($busy_time[0] > $start) {
+ $start = $busy_time[0];
+ }
+ if ($busy_time[1] < $end) {
+ $end = $busy_time[1];
+ }
+ if ($busy_time[2] == 'BUSY') {
+ $type = $busy_time[2];
+ }
+ }
+ }
+
+ // skip this candidate if one of the member is not busy
+ if (!$members_is_busy) {
+ continue 2;
+ }
+ }
+
+ // if we end up here, the slot if busy for all members: add to result
+ if ($start < $end) {
+ $result[] = array($start, $end, $type);
+ }
+ }
+
+ return $result;
+ }
+}

File Metadata

Mime Type
text/x-diff
Expires
Fri, Nov 22, 9:31 AM (22 h, 45 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
80097
Default Alt Text
(19 KB)

Event Timeline