3 commits - config/config.ini.sample lib/Kolab

Thomas Brüderli bruederli at kolabsys.com
Fri Oct 31 10:28:09 CET 2014


 config/config.ini.sample                |   24 +++
 lib/Kolab/FreeBusy/DirectoryLDAP.php    |   37 ++++-
 lib/Kolab/FreeBusy/Source.php           |    1 
 lib/Kolab/FreeBusy/SourceAggregator.php |  230 ++++++++++++++++++++++++++++++++
 lib/Kolab/FreeBusy/SourceIMAP.php       |    4 
 lib/Kolab/FreeBusy/Utils.php            |   22 +++
 6 files changed, 309 insertions(+), 9 deletions(-)

New commits:
commit 26534dec74dfc2076fd66457000349eb5177679b
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Fri Oct 31 10:28:03 2014 +0100

    Add aggregator source to build free/busy data for resource collections (#3165)

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
@@ -65,3 +65,25 @@ 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
@@ -114,12 +114,17 @@ class DirectoryLDAP extends Directory
 				$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];
+						}
 					}
 				}
 
@@ -132,5 +137,22 @@ class DirectoryLDAP extends Directory
 		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
@@ -49,6 +49,7 @@ abstract class Source
 			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);
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 at kolabsys.com>
+ *
+ * Copyright (C) 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
+ * 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;
+	}
+}


commit e7432c6c0283ad219555579e3a6dfb7c5cf9d77b
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Fri Oct 31 10:27:16 2014 +0100

    Use utility functions to get period start/end dates

diff --git a/lib/Kolab/FreeBusy/SourceIMAP.php b/lib/Kolab/FreeBusy/SourceIMAP.php
index 9f39313..5a4bd43 100644
--- a/lib/Kolab/FreeBusy/SourceIMAP.php
+++ b/lib/Kolab/FreeBusy/SourceIMAP.php
@@ -113,8 +113,8 @@ class SourceIMAP extends Source
 			\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);
+			$dtstart  = Utils::periodStartDT();
+			$dtend    = Utils::periodEndDT();
 			$calendar = VObject\Component::create('VCALENDAR');
 			$seen     = array();
 
diff --git a/lib/Kolab/FreeBusy/Utils.php b/lib/Kolab/FreeBusy/Utils.php
index 942341e..362461d 100644
--- a/lib/Kolab/FreeBusy/Utils.php
+++ b/lib/Kolab/FreeBusy/Utils.php
@@ -181,6 +181,17 @@ class Utils
 	}
 
 	/**
+	 * Getter for the free/busy period start time
+	 *
+	 * @return object DateTime instance
+	 */
+	public static function periodStartDT()
+	{
+		// 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
@@ -192,6 +203,17 @@ class Utils
 	}
 
 	/**
+	 * Getter for the free/busy period end time
+	 *
+	 * @return object DateTime instance
+	 */
+	public static function periodEndDT()
+	{
+		// 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)


commit 9889e6561f7cabfdb7ddb69848b55ac52151e21f
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Thu Oct 30 19:13:27 2014 +0100

    Quote filter attribute value; add fallback domain name for LDAP queries

diff --git a/config/config.ini.sample b/config/config.ini.sample
index 77abec5..b97c011 100644
--- a/config/config.ini.sample
+++ b/config/config.ini.sample
@@ -35,6 +35,7 @@ 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
@@ -53,6 +54,7 @@ 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"
diff --git a/lib/Kolab/FreeBusy/DirectoryLDAP.php b/lib/Kolab/FreeBusy/DirectoryLDAP.php
index 0a4ecff..c155d98 100644
--- a/lib/Kolab/FreeBusy/DirectoryLDAP.php
+++ b/lib/Kolab/FreeBusy/DirectoryLDAP.php
@@ -101,9 +101,10 @@ class DirectoryLDAP extends Directory
 		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 = preg_replace('/%s/i', $user, strtr($this->config['filter'], $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




More information about the commits mailing list