diff --git a/composer.json b/composer.json --- a/composer.json +++ b/composer.json @@ -2,12 +2,7 @@ "name": "kolab/free-busy", "description": "Kolab Free/Busy Service", "license": "AGPL-3.0", - "version": "1.1.0", "repositories": [ - { - "type": "pear", - "url": "http://pear.php.net/" - }, { "type": "vcs", "url": "https://git.kolab.org/diffusion/PNL/php-net_ldap.git" @@ -15,13 +10,13 @@ ], "require": { "php": ">=5.3.3", - "monolog/monolog": "1.2.*", - "desarrolla2/cache": "dev-master", - "sabre/vobject": "~3.3.3" + "monolog/monolog": "~1.2", + "sabre/vobject": "~3.3.3", + "garethp/php-ews": "~0.10.1" }, "require-dev": { - "pear-pear.php.net/net_ldap2": ">=2.0.12", - "kolab/Net_LDAP3": "dev-master" + "pear/net_ldap2": "~2.2.0", + "kolab/net_ldap3": "~1.1.3" }, - "minimum-stability": "dev" + "minimum-stability": "stable" } diff --git a/composer.json-dist b/composer.json-dist --- a/composer.json-dist +++ b/composer.json-dist @@ -2,7 +2,6 @@ "name": "kolab/free-busy", "description": "Kolab Free/Busy Service", "license": "AGPL-3.0", - "version": "1.1.0", "autoload": { "psr-0": { "": "/usr/share/pear/" diff --git a/lib/Kolab/Config.php b/lib/Kolab/Config.php --- a/lib/Kolab/Config.php +++ b/lib/Kolab/Config.php @@ -175,7 +175,9 @@ } else if (is_array($config)) { $pkey = rtrim($prefix, '.'); - $this->data[$pkey] = is_array($this->data[$pkey]) ? array_replace_recursive((array)$this->data[$pkey], $config) : $config; + $this->data[$pkey] = isset($this->data[$pkey]) && is_array($this->data[$pkey]) + ? array_replace_recursive((array)$this->data[$pkey], $config) + : $config; } foreach ((array)$config as $key => $val) { @@ -216,7 +218,7 @@ */ public function __get($name) { - return $this->data[$name]; + return isset($this->data[$name]) ? $this->data[$name] : null; } /** diff --git a/lib/Kolab/FreeBusy/Directory.php b/lib/Kolab/FreeBusy/Directory.php --- a/lib/Kolab/FreeBusy/Directory.php +++ b/lib/Kolab/FreeBusy/Directory.php @@ -30,91 +30,108 @@ */ abstract class Directory { - protected $config; - - /** - * Factory method creating an instace of Directory according to config - * - * @param array Hash array with config - */ - public static function factory($config) - { - switch (strtolower($config['type'])) { - case 'ldap': - return new DirectoryLDAP($config); - - case 'static': - case 'external': - return new DirectoryStatic($config); - - default: - Logger::get('directory')->addError("Invalid directory type '" . $config['type'] . "'!"); - } - - return null; - } - - /** - * Resolve the given username to a Entity object - * - * @param string Username/Email to resolve - * @return object Entity if found, otherwise False - */ - abstract public function resolve($user); - - /** - * Retrieve free/busy data for the given user. - * - * @param string Username or email to resolve - * @param boolean Get extemded free-busy if possible - * @return string VCalendar container if found, False otherwise - */ - public function getFreeBusyData($user, $extended = false) - { - // resolve user record first - if ($user = $this->resolve($user)) { - $fbsource = $this->config['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; - } - } - - return false; - } - - /** - * Modify attribute values according to config - */ - protected function postprocessAttrib($attrib) - { - if (!empty($this->config['lc_attributes'])) { - foreach (Config::convert($this->config['lc_attributes'], Config::ARR) as $key) { - if (!empty($attrib[$key])) - $attrib[$key] = strtolower($attrib[$key]); - } - } - - return $attrib; - } - -} \ No newline at end of file + protected $config; + + /** + * Factory method creating an instace of Directory according to config + * + * @param array Hash array with config + */ + public static function factory($config) + { + switch (strtolower($config['type'])) { + case 'ldap': + return new DirectoryLDAP($config); + + case 'static': + case 'external': + return new DirectoryStatic($config); + + default: + Logger::get('directory')->addError("Invalid directory type '" . $config['type'] . "'!"); + } + + return null; + } + + /** + * Resolve the given username to a Entity object + * + * @param string Username/Email to resolve + * @return object Entity if found, otherwise False + */ + abstract public function resolve($user); + + /** + * Retrieve free/busy data for the given user. + * + * @param string Username or email to resolve + * @param boolean Get extemded free-busy if possible + * @return string VCalendar container if found, False otherwise + */ + public function getFreeBusyData($user, $extended = false) + { + $logger = Logger::get('directory'); + + // resolve user record first + if ($user = $this->resolve($user)) { + $fbsource = $this->config['fbsource']; + if ($source = Source::Factory($fbsource, $this->config)) { + $user = $this->postprocessAttrib($user); + + // Find the cached data + if (!empty($this->config['cacheto'])) { + $path = preg_replace_callback( + '/%\{?([a-z0-9]+)\}?/', + function($m) use ($user) { return $user[$m[1]]; }, + $this->config['cacheto'] + ); + + // check for cached data + if (file_exists($path)) { + if (empty($this->config['expires']) || filemtime($path) + Utils::getOffsetSec($this->config['expires']) >= time()) { + $logger->addInfo("Deliver cached data from {$path}"); + return file_get_contents($path); + } + } + } + + // forward request to Source instance + if ($data = $source->getFreeBusyData($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 && !empty($path)) { + if (!file_put_contents($path, $data, LOCK_EX)) { + $logger->addError("Failed to write to cache in {$path}"); + } else { + $logger->addInfo("Cached data in {$path}"); + } + } + } + + return $data; + } + } + + return false; + } + + /** + * Modify attribute values according to config + */ + protected function postprocessAttrib($attrib) + { + if (!empty($this->config['lc_attributes'])) { + foreach (Config::convert($this->config['lc_attributes'], Config::ARR) as $key) { + if (!empty($attrib[$key])) { + $attrib[$key] = strtolower($attrib[$key]); + } + } + } + + return $attrib; + } +} diff --git a/lib/Kolab/FreeBusy/DirectoryStatic.php b/lib/Kolab/FreeBusy/DirectoryStatic.php --- a/lib/Kolab/FreeBusy/DirectoryStatic.php +++ b/lib/Kolab/FreeBusy/DirectoryStatic.php @@ -31,29 +31,28 @@ */ class DirectoryStatic extends Directory { - /** - * Default constructor loading directory configuration - */ - public function __construct($config) - { - $this->config = $config; - } - - /** - * @see Directory::resolve() - */ - public function resolve($user) - { - $result = array('s' => $user); - - // check if user matches the filter property (if configured) - if (!empty($this->config['filter'])) { - if (!preg_match('!'.$this->config['filter'].'!i', $user)) - $result = false; - } - - return $result; - } - + /** + * Default constructor loading directory configuration + */ + public function __construct($config) + { + $this->config = $config; + } + + /** + * @see Directory::resolve() + */ + public function resolve($user) + { + $result = array('s' => $user); + + // check if user matches the filter property (if configured) + if (!empty($this->config['filter'])) { + if (!preg_match('!'.$this->config['filter'].'!i', $user)) { + $result = false; + } + } + + return $result; + } } - diff --git a/lib/Kolab/FreeBusy/Source.php b/lib/Kolab/FreeBusy/Source.php --- a/lib/Kolab/FreeBusy/Source.php +++ b/lib/Kolab/FreeBusy/Source.php @@ -29,7 +29,6 @@ abstract class Source { protected $config = array(); - protected $cached = false; /** * Factory method creating an instace of Source according to config @@ -51,6 +50,7 @@ case 'fbd': case 'fbdaemon': return new SourceFBDaemon($config + $conf); case 'aggregate': return new SourceAggregator($config + $conf); + case 'ews': return new SourceEWS($config + $conf); } Logger::get('source')->addError("Invalid source configuration: " . $url); @@ -68,7 +68,7 @@ /** * Retrieve free/busy data for the given user * - * @param array Hash array with user attributes + * @param false|string vCalendar (ICS) file output, or False on error */ abstract public function getFreeBusyData($user, $extended); @@ -81,7 +81,7 @@ foreach ($this->config as $k => $val) { if (is_string($val) && strpos($val, '%') !== false) { $val = preg_replace_callback( - '/%\{?([a-z0-9]+)\}?/', + '/%\{?([a-z]{1}[a-z0-9]*)\}?/', function($m) use ($k, $user) { $enc = $k == 'url' || $k == 'query' || $k == 'fbsource'; return $enc ? urlencode($user[$m[1]]) : $user[$m[1]]; @@ -94,30 +94,4 @@ 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; - } } diff --git a/lib/Kolab/FreeBusy/SourceAggregator.php b/lib/Kolab/FreeBusy/SourceAggregator.php --- a/lib/Kolab/FreeBusy/SourceAggregator.php +++ b/lib/Kolab/FreeBusy/SourceAggregator.php @@ -40,7 +40,7 @@ public function getFreeBusyData($user, $extended) { $log = Logger::get('aggregate', intval($this->config['loglevel'])); - # $config = $this->getUserConfig($user); + // $config = $this->getUserConfig($user); $attr = str_replace('%', '', strval($this->config['path'] ?: $this->config['host'])); if (!empty($user[$attr])) { diff --git a/lib/Kolab/FreeBusy/SourceEWS.php b/lib/Kolab/FreeBusy/SourceEWS.php new file mode 100644 --- /dev/null +++ b/lib/Kolab/FreeBusy/SourceEWS.php @@ -0,0 +1,202 @@ + + * + * Copyright (C) 2013-2021, Apheleia IT 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 garethp\ews\API; +use garethp\ews\API\Exception; + +/** + * Implementation of a Free/Busy data source reading from an EWS server + */ +class SourceEWS extends Source +{ + /** + * @see Source::getFreeBusyData() + */ + public function getFreeBusyData($user, $extended) + { + $config = $this->getUserConfig($user); + $user = is_array($user) ? $user['s'] : $user; + + // Get start and end date for the request + list($start, $end) = $this->getPeriod(); + + // Fetch the availability info from the EWS server + $data = $this->fetchData($user, $start, $end, $config); + + if ($data === false) { + return false; + } + + // Process the data (convert to ics) + foreach ($data as $idx => $event) { + // Map the busy-type property + switch ($event->getBusyType()) { + case 'Free': + $fbtype = 'FREE'; + break; + + case 'Tentative': + $fbtype = 'BUSY-TENTATIVE'; + break; + + case 'Busy': + // case 'OOF': + // case 'NoData': + default: + $fbtype = 'BUSY'; + break; + } + + $startTime = new \DateTime($event->getStartTime(), new \DateTimeZone('UTC')); + $endTime = new \DateTime($event->getEndTime(), new \DateTimeZone('UTC')); + + $data[$idx] = sprintf( + "FREEBUSY;FBTYPE=%s:%s/%s", + $fbtype, + $startTime->format('Ymd\THis\Z'), + $endTime->format('Ymd\THis\Z'), + ); + } + + return sprintf( + "BEGIN:VCALENDAR\n" + . "PRODID:%s\n" + . "VERSION:2.0\n" + . "METHOD:PUBLISH\n" + . "BEGIN:VFREEBUSY\n" + . "ORGANIZER:mailto:%s\n" + . "UID:%s\n" + . "DTSTAMP:%s\n" + . "DTSTART:%s\n" + . "DTEND:%s\n" + . "%s\n" + . "END:VFREEBUSY\n" + . "END:VCALENDAR\n", + Utils::PRODID, + $user, + \date('YmdHi') . '-' . \substr(\md5($user), 0, 16), + $start->format('Ymd\THis\Z'), + $start->format('Ymd\THis\Z'), + $end->format('Ymd\THis\Z'), + implode("\n", $data) + ); + } + + /** + * Fetches the user availability data from the EWS server + */ + private function fetchData($user, $startdate, $enddate, $config) + { + $logger = Logger::get('ews'); + + $logger->addDebug(sprintf("Fetching data from %s for %s...", $config['host'], $user)); + + try { + // Create and build the client + $api = API::withUsernameAndPassword( + $config['host'], + urldecode($config['user']), + urldecode($config['pass']), + array( + // return date-time in UTC + 'timezone' => 'UTC', + // act as the requested user + 'impersonation' => $user + ) + ); + + $calendar = $api->getCalendar(); + + // This is using the EWS's GetUserAvailability API + $options = array(); +/* + $options = array( + 'FreeBusyViewOptions' => array( + 'MergedFreeBusyIntervalInMinutes' => 15 // default: 30 + ) + ); +*/ + $availability = $calendar->getAvailabilityFor($startdate, $enddate, array($user), $options); + + // Check response status + $responseMessage = $availability->getFreeBusyResponseArray()->FreeBusyResponse->getResponseMessage(); + if ($responseMessage->getResponseClass() === 'Error') { + throw new Exception\ExchangeException($responseMessage); + } + + $items = $availability->getFreeBusyResponseArray()->FreeBusyResponse->getFreeBusyView()->getCalendarEventArray(); + + if ($items && $items->CalendarEvent) { + $logger->addDebug(sprintf("Found %d events", count($items->CalendarEvent))); + return $items->CalendarEvent; + } + + $logger->addDebug("Found 0 events"); + return array(); + } catch (Exception\UnauthorizedException $e) { + $logger->addError("EWS access unauthorized"); + } catch (Exception\ServiceUnavailableException $e) { + $logger->addError("EWS service unavailable"); + } catch (Exception\NoResponseReturnedException $e) { + $logger->addError("EWS no response"); + } catch (Exception\AutodiscoverFailed $e) { + $logger->addError("EWS autodiscovery failed"); + } catch (\Exception $e) { + $logger->addError($e->getMessage() ?: "EWS unknown error"); + } + + return false; + } + + /** + * Get request period start and end + */ + private function getPeriod() + { + // use date from HTTP query + if (!empty($_GET['dtstart']) && + ($dtstart = \filter_input(INPUT_GET, 'dtstart', FILTER_SANITIZE_STRING)) + ) { + try { + $start = new \DateTime($dtstart, new \DateTimezone('UTC')); + } catch (Exception $e) { + // ignore + } + } + + if (empty($start)) { + $start = new \DateTime('now', new \DateTimeZone('UTC')); + } + + // Set the period to 7 days in the past and 42-7 days in the future + // Note: 42 days is max. supported period on EWS + + $start->sub(new \DateInterval('P7D')); + $end = clone $start; + $end->add(new \DateInterval('P42D')); + + return array($start, $end); + } +} diff --git a/lib/Kolab/FreeBusy/SourceFBDaemon.php b/lib/Kolab/FreeBusy/SourceFBDaemon.php --- a/lib/Kolab/FreeBusy/SourceFBDaemon.php +++ b/lib/Kolab/FreeBusy/SourceFBDaemon.php @@ -42,23 +42,6 @@ // 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 command for freebusyd if (!empty($config['folder'])) { $cmd = 'FOLDER'; @@ -128,10 +111,6 @@ } return $fbdata; } - // remove (temporary) cache file again - else if ($tempfile) { - unlink($tempfile); - } // not found return false; diff --git a/lib/Kolab/FreeBusy/SourceIMAP.php b/lib/Kolab/FreeBusy/SourceIMAP.php --- a/lib/Kolab/FreeBusy/SourceIMAP.php +++ b/lib/Kolab/FreeBusy/SourceIMAP.php @@ -79,23 +79,6 @@ // 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) { @@ -258,10 +241,6 @@ // serialize to VCALENDAR format return $freebusy->serialize(); } - // remove (temporary) cache file again - else if ($tempfile) { - unlink($tempfile); - } return false; } diff --git a/lib/Kolab/FreeBusy/Utils.php b/lib/Kolab/FreeBusy/Utils.php --- a/lib/Kolab/FreeBusy/Utils.php +++ b/lib/Kolab/FreeBusy/Utils.php @@ -249,7 +249,7 @@ } } - // Should probably be a setting. For now, do 8 weeks in the past + // Should probably be a setting. For now, do 16 weeks into the future return new \DateTime('now + 16 weeks 00:00:00', new \DateTimezone('UTC')); } diff --git a/public_html/index.php b/public_html/index.php --- a/public_html/index.php +++ b/public_html/index.php @@ -50,79 +50,77 @@ // load config $config = Config::get_instance(KOLAB_FREEBUSY_ROOT . '/config'); if ($config->valid()) { - // check for trusted IP first - $remote_ip = Utils::remoteIP(); - $trusted_ip = $config->trustednetworks ? Utils::checkIPRange($remote_ip, $config->get('trustednetworks.allow', array(), Config::ARR)) : false; - - $log = Logger::get('web'); - - $uri = $_SERVER['REDIRECT_URL']; - - // we're not always redirected here - if (empty($uri)) { - $uri = $_SERVER['REQUEST_URI']; - $log->addDebug('Request (direct): ' . $uri, array('ip' => $remote_ip, 'trusted' => $trusted_ip)); - } else { - $log->addDebug('Request (redirect): ' . $uri, array('ip' => $remote_ip, 'trusted' => $trusted_ip)); - } - - list($uri, $args) = explode('?', $uri); - - // check HTTP authentication - if (!$trusted_ip && $config->httpauth) { - $_SERVER['FREEBUSY_URI'] = urldecode(rtrim($uri, '/')); - - if (!HTTPAuth::check($config->httpauth)) { - $log->addDebug("Abort with 401 Unauthorized"); - header('WWW-Authenticate: Basic realm="Kolab Free/Busy Service"'); - header($_SERVER['SERVER_PROTOCOL'] . " 401 Unauthorized", true); - exit; - } - } - - #header('Content-type: text/calendar; charset=utf-8', true); - header('Content-type: text/plain; charset=utf-8', true); - - // analyse request - $user = $_SERVER['FREEBUSY_USER']; - $extended = !empty($_SERVER['FREEBUSY_EXTENDED']); - - if (!$user) { - $url = array_filter(explode('/', $uri)); - $user = strtolower(array_pop($url)); - - // remove file extension - if (preg_match('/^(.+)\.([ipx]fb)$/i', $user, $m)) { - $user = urldecode($m[1]); - $extended = $m[2] == 'xfb'; - } - } - - // iterate over directories - foreach ($config->directory as $key => $dirconfig) { - $log->addDebug("Trying directory $key", $dirconfig); - - $directory = Directory::factory($dirconfig); - if ($directory && ($fbdata = $directory->getFreeBusyData($user, $extended))) { - $log->addInfo("Found valid data for user $user in directory $key"); - echo $fbdata; - exit; - } - } - - // return 404 if request was sent from a trusted IP - if ($trusted_ip) { - $log->addDebug("Returning '404 Not Found' for user $user"); - header($_SERVER['SERVER_PROTOCOL'] . " 404 Not found", true); - } - else { - $log->addInfo("Returning empty Free/Busy list for user $user"); - - // Return an apparent empty Free/Busy list. - print Utils::dummyVFreebusy($user); - } + // check for trusted IP first + $remote_ip = Utils::remoteIP(); + $trusted_ip = $config->trustednetworks ? Utils::checkIPRange($remote_ip, $config->get('trustednetworks.allow', array(), Config::ARR)) : false; + + $log = Logger::get('web'); + + $uri = isset($_SERVER['REDIRECT_URL']) ? $_SERVER['REDIRECT_URL'] : ''; + + // we're not always redirected here + if (empty($uri)) { + $uri = $_SERVER['REQUEST_URI']; + $log->addDebug('Request (direct): ' . $uri, array('ip' => $remote_ip, 'trusted' => $trusted_ip)); + } else { + $log->addDebug('Request (redirect): ' . $uri, array('ip' => $remote_ip, 'trusted' => $trusted_ip)); + } + + list($uri, ) = explode('?', $uri); + + // check HTTP authentication + if (!$trusted_ip && $config->httpauth) { + $_SERVER['FREEBUSY_URI'] = urldecode(rtrim($uri, '/')); + + if (!HTTPAuth::check($config->httpauth)) { + $log->addDebug("Abort with 401 Unauthorized"); + header('WWW-Authenticate: Basic realm="Kolab Free/Busy Service"'); + header($_SERVER['SERVER_PROTOCOL'] . " 401 Unauthorized", true); + exit; + } + } + + //header('Content-type: text/calendar; charset=utf-8', true); + header('Content-type: text/plain; charset=utf-8', true); + + // analyse request + $user = isset($_SERVER['FREEBUSY_USER']) ? $_SERVER['FREEBUSY_USER'] : null; + $extended = !empty($_SERVER['FREEBUSY_EXTENDED']); + + if (!$user) { + $url = array_filter(explode('/', $uri)); + $user = strtolower(array_pop($url)); + + // remove file extension + if (preg_match('/^(.+)\.([ipx]fb)$/i', $user, $m)) { + $user = urldecode($m[1]); + $extended = $m[2] == 'xfb'; + } + } + + // iterate over directories + foreach ($config->directory as $key => $dirconfig) { + $log->addDebug("Trying directory $key", $dirconfig); + + $directory = Directory::factory($dirconfig); + if ($directory && ($fbdata = $directory->getFreeBusyData($user, $extended))) { + $log->addInfo("Found valid data for user $user in directory $key"); + echo $fbdata; + exit; + } + } + + // return 404 if request was sent from a trusted IP + if ($trusted_ip) { + $log->addDebug("Returning '404 Not Found' for user $user"); + header($_SERVER['SERVER_PROTOCOL'] . " 404 Not found", true); + } else { + $log->addInfo("Returning empty Free/Busy list for user $user"); + + // Return an apparent empty Free/Busy list. + print Utils::dummyVFreebusy($user); + } } // exit with error # header($_SERVER['SERVER_PROTOCOL'] . " 500 Internal Server Error", true); -