diff --git a/composer.json b/composer.json index 0df0f67..88976dc 100644 --- a/composer.json +++ b/composer.json @@ -1,33 +1,28 @@ { "name": "kolab/irony", "description": "iRony - The Kolab WebDAV/CalDAV/CardDAV Server", "license": "AGPL-3.0", - "version": "0.3.0", + "version": "0.4-dev", "repositories": [ { "type": "pear", "url": "http://pear.php.net/" }, - { - "type": "vcs", - "url": "https://github.com/thomascube/sabre-vobject.git" - }, { "type": "vcs", "url": "https://git.kolab.org/diffusion/PNL/php-net_ldap.git" } ], "require": { - "php": ">=5.3.3", - "sabre/dav" : "1.8.9" + "php": ">=5.4.1", + "sabre/dav" : "2.1.*" }, "require-dev": { "pear/mail_mime": ">=1.8.9", - "pear/mail_mime-decode": ">=1.5.5", - "pear/http_request2": ">=2.1.1", + "pear/mail_mime-decode": "~1.5.5", "pear-pear.php.net/net_idna2": ">=0.1.1", "pear-pear.php.net/net_ldap2": ">=2.0.12", "kolab/Net_LDAP3": "dev-master" }, "minimum-stability": "dev" } diff --git a/config/dav.inc.php.sample b/config/dav.inc.php.sample index b530680..4fe2efe 100644 --- a/config/dav.inc.php.sample +++ b/config/dav.inc.php.sample @@ -1,119 +1,124 @@ . | | | +-------------------------------------------------------------------------+ */ $config = array(); // The HTTP path to the iRony root directory. // Set to / if the service is registered as document root for a virtual host $config['base_uri'] = '/iRony/'; // User agent string written to kolab storage MIME messages $config['useragent'] = 'Kolab DAV Server libkolab/' . RCUBE_VERSION; // Roundcube plugins. Not all are supported here. $config['kolabdav_plugins'] = array('kolab_auth'); // Type of Auth cache. Supported values: 'db', 'apc' and 'memcache'. // Note: This is only for username canonification map. $config['kolabdav_auth_cache'] = 'db'; // lifetime of the Auth cache, possible units: s, m, h, d, w $config['kolabdav_auth_cache_ttl'] = '1h'; // enable debug console showing the internal function calls triggered // by http requests. This will write log to /var/log/iRony/console $config['kolabdav_console'] = false; // enable per-user debugging if /var/log/iRony// folder exists $config['kolabdav_user_debug'] = false; // enable logging of full HTTP payload // (bitmask of these values: 2 = HTTP Requests, 4 = HTTP Responses) $config['kolabdav_http_log'] = 0; +// expose iTip invitations from email inbox in CalDAV scheduling inbox. +// this will make capable CalDAV clients process event invitations and +// as a result, the invitation messages are removed from the email inbox. +$config['kolabdav_caldav_inbox'] = false; + // Enables the CardDAV Directory Gateway Extension by exposing an // LDAP-based address book in the pricipals address book collection. // Properties of this option are the same as for $config['ldap_public'] entries. /* $config['kolabdav_ldap_directory'] = array( 'name' => 'Global Address Book', 'hosts' => 'localhost', 'port' => 389, 'use_tls' => false, // If true the base_dn, bind_dn and bind_pass default to the user's credentials. 'user_specific' => false, // It's possible to bind with the current user's credentials for individual address books. // The login name is used to search for the DN to bind with 'search_base_dn' => 'ou=People,dc=example,dc=org', 'search_bind_dn' => 'uid=kolab-service,ou=Special Users,dc=example,dc=org', 'search_bind_pw' => 'Welcome2KolabSystems', 'search_filter' => '(&(objectClass=inetOrgPerson)(mail=%fu))', // When 'user_specific' is enabled following variables can be used in base_dn/bind_dn config: // %fu - The full username provided, assumes the username is an email // address, uses the username_domain value if not an email address. // %u - The username prior to the '@'. // %d - The domain name after the '@'. // %dc - The domain name hierarchal string e.g. "dc=test,dc=domain,dc=com" // %dn - DN found by ldap search when search_filter/search_base_dn are used 'base_dn' => 'ou=People,dc=example,dc=org', 'bind_dn' => 'uid=kolab-service,ou=Special Users,dc=example,dc=org', 'bind_pass' => 'Welcome2KolabSystems', 'ldap_version' => 3, 'filter' => '(objectClass=inetOrgPerson)', 'search_fields' => array('displayname', 'mail'), 'sort' => array('displayname', 'sn', 'givenname', 'cn'), 'scope' => 'sub', 'searchonly' => true, // Set to false to enable listing 'sizelimit' => '1000', 'timelimit' => '0', 'fieldmap' => array( // Roundcube => LDAP 'name' => 'displayName', 'surname' => 'sn', 'firstname' => 'givenName', 'middlename' => 'initials', 'prefix' => 'title', 'email:work' => 'mail', 'email:other' => 'alias', 'phone:main' => 'telephoneNumber', 'phone:work' => 'alternateTelephoneNumber', 'phone:mobile' => 'mobile', 'phone:work2' => 'blackberry', 'street' => 'street', 'zipcode' => 'postalCode', 'locality' => 'l', 'organization' => 'o', 'jobtitle' => 'title', 'photo' => 'jpegphoto', // required for internal handling and caching 'uid' => 'nsuniqueid', 'changed' => 'modifytimestamp', ), ); */ // Enable caching for LDAP directory data. // This is recommended with 'searchonly' => false to speed-up sychronization of multiple clients // $config['kolabdav_ldap_cache'] = 'memcache'; // $config['kolabdav_ldap_cache_ttl'] = 600; // in seconds diff --git a/lib/Kolab/CalDAV/CalendarBackend.php b/lib/Kolab/CalDAV/CalendarBackend.php index 848c9dd..9f5a000 100644 --- a/lib/Kolab/CalDAV/CalendarBackend.php +++ b/lib/Kolab/CalDAV/CalendarBackend.php @@ -1,832 +1,1126 @@ * * Copyright (C) 2013, 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\CalDAV; use \PEAR; use \rcube; use \rcube_charset; +use \rcube_message; use \kolab_storage; use \kolab_storage_config; use \libcalendaring; use Kolab\Utils\DAVBackend; use Kolab\Utils\VObjectUtils; use Kolab\DAV\Auth\HTTPBasic; use Sabre\DAV; use Sabre\CalDAV; use Sabre\VObject; /** * Kolab Calendaring backend. * * Checkout the Sabre\CalDAV\Backend\BackendInterface for all the methods that must be implemented. * */ -class CalendarBackend extends CalDAV\Backend\AbstractBackend +class CalendarBackend extends CalDAV\Backend\AbstractBackend implements CalDAV\Backend\SchedulingSupport { private $calendars; private $folders; private $aliases; private $useragent; private $subscribed = null; /** * Read available calendar folders from server */ private function _read_calendars() { // already read sources if (isset($this->calendars)) return $this->calendars; // get all folders that have "event" type $folders = array_merge(kolab_storage::get_folders('event', $this->subscribed), kolab_storage::get_folders('task', $this->subscribed)); $this->calendars = $this->folders = $this->aliases = array(); $order = 1; + $default_calendar_id = null; foreach (kolab_storage::sort_folders($folders) as $folder) { $id = $folder->get_uid(); $this->folders[$id] = $folder; $fdata = $folder->get_imap_data(); // fetch IMAP folder data for CTag generation $this->calendars[$id] = array( 'id' => $id, 'uri' => $id, '{DAV:}displayname' => html_entity_decode($folder->get_name(), ENT_COMPAT, RCUBE_CHARSET), '{http://apple.com/ns/ical/}calendar-color' => '#' . $folder->get_color('FF0000') . 'FF', '{http://calendarserver.org/ns/}getctag' => sprintf('%d-%d-%d', $fdata['UIDVALIDITY'], $fdata['HIGHESTMODSEQ'], $fdata['UIDNEXT']), '{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set' => new CalDAV\Property\SupportedCalendarComponentSet(array(DAVBackend::$caldav_type_component_map[$folder->type])), '{urn:ietf:params:xml:ns:caldav}schedule-calendar-transp' => new CalDAV\Property\ScheduleCalendarTransp('opaque'), '{http://apple.com/ns/ical/}calendar-order' => $order++, ); + + if ($folder->default && $folder->type == 'event') { + $default_calendar_id = $id; + } + $this->aliases[$folder->name] = $id; // these properties are used for sharing supprt (not yet active) if (false && $folder->get_namespace() != 'personal') { $rights = $folder->get_myrights(); $this->calendars[$id]['{http://calendarserver.org/ns/}shared-url'] = '/calendars/' . $folder->get_owner() . '/' . $id; $this->calendars[$id]['{http://calendarserver.org/ns/}owner-principal'] = $folder->get_owner(); $this->calendars[$id]['{http://sabredav.org/ns}read-only'] = strpos($rights, 'i') === false; } } + // put default calendar on top of the list: + // {urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL is derived from the first item on this list + if ($default_calendar_id) { + $this->calendars = array($default_calendar_id => $this->calendars[$default_calendar_id]) + $this->calendars; + } + return $this->calendars; } /** * Getter for a kolab_storage_folder representing the calendar for the given ID * * @param string Calendar ID * @return object kolab_storage_folder instance */ public function get_storage_folder($id) { // resolve alias name if ($this->aliases[$id]) { $id = $this->aliases[$id]; } if ($this->folders[$id]) { DAVBackend::check_storage_folder($this->folders[$id]); return $this->folders[$id]; } else { return DAVBackend::get_storage_folder($id, ''); } } /** * Returns a list of calendars for a principal. * * Every calendars is an array with the following keys: * * id, a unique id that will be used by other functions to modify the * calendar. This can be the same as the uri or a database key. * * uri, which the basename of the uri with which the calendar is * accessed. * * principaluri. The owner of the calendar. Almost always the same as * principalUri passed to this method. * * Furthermore it can contain webdav properties in clark notation. A very * common one is '{DAV:}displayname'. * * @param string $principalUri * @return array */ public function getCalendarsForUser($principalUri) { console(__METHOD__, $principalUri); + if (!$this->is_current_pricipal($principalUri)) { + return array(); + } + $this->_read_calendars(); $calendars = array(); foreach ($this->calendars as $id => $cal) { $this->calendars[$id]['principaluri'] = $principalUri; $calendars[] = $this->calendars[$id]; } return $calendars; } /** * Returns calendar properties for a specific node identified by name/uri * * @param string Node name/uri * @return array Hash array with calendar properties or null if not found */ public function getCalendarByName($calendarUri) { console(__METHOD__, $calendarUri); $this->_read_calendars(); $id = $calendarUri; // resolve aliases (calendar by folder name) if ($this->aliases[$calendarUri]) { $id = $this->aliases[$calendarUri]; } if ($this->calendars[$id] && empty($this->calendars[$id]['principaluri'])) { $this->calendars[$id]['principaluri'] = 'principals/' . HTTPBasic::$current_user; } // retry with subscribed = false (#2701) - if (empty($this->calendars[$id]) && $id != 'outbox' && $this->subscribed === null && rcube::get_instance()->config->get('kolab_use_subscriptions')) { + if (empty($this->calendars[$id]) && !in_array($id, array('inbox','outbox','notifications')) && $this->subscribed === null && rcube::get_instance()->config->get('kolab_use_subscriptions')) { $this->subscribed = false; unset($this->calendars); return $this->getCalendarByName($calendarUri); } return $this->calendars[$id]; } /** * Creates a new calendar for a principal. * * If the creation was a success, an id must be returned that can be used to reference * this calendar in other methods, such as updateCalendar. * * @param string $principalUri * @param string $calendarUri * @param array $properties * @return void */ public function createCalendar($principalUri, $calendarUri, array $properties) { console(__METHOD__, $calendarUri, $properties); return DAVBackend::folder_create('event', $properties, $calendarUri); } /** * Updates properties for a calendar. * - * The mutations array uses the propertyName in clark-notation as key, - * and the array value for the property value. In the case a property - * should be deleted, the property value will be null. - * - * This method must be atomic. If one property cannot be changed, the - * entire operation must fail. - * - * If the operation was successful, true can be returned. - * If the operation failed, false can be returned. + * The list of mutations is stored in a Sabre\DAV\PropPatch object. + * To do the actual updates, you must tell this object which properties + * you're going to process with the handle() method. * - * Deletion of a non-existent property is always successful. + * Calling the handle method is like telling the PropPatch object "I + * promise I can handle updating this property". * - * Lastly, it is optional to return detailed information about any - * failures. In this case an array should be returned with the following - * structure: + * Read the PropPatch documenation for more info and examples. * - * array( - * 403 => array( - * '{DAV:}displayname' => null, - * ), - * 424 => array( - * '{DAV:}owner' => null, - * ) - * ) - * - * In this example it was forbidden to update {DAV:}displayname. - * (403 Forbidden), which in turn also caused {DAV:}owner to fail - * (424 Failed Dependency) because the request needs to be atomic. - * - * @param mixed $calendarId - * @param array $mutations - * @return bool|array + * @param string $path + * @param \Sabre\DAV\PropPatch $propPatch + * @return void */ - public function updateCalendar($calendarId, array $mutations) + public function updateCalendar($calendarId, \Sabre\DAV\PropPatch $propPatch) { - console(__METHOD__, $calendarId, $mutations); + console(__METHOD__, $calendarId, $propPatch); - $folder = $this->get_storage_folder($calendarId); - return DAVBackend::folder_update($folder, $mutations); + if ($folder = $this->get_storage_folder($calendarId)) { + DAVBackend::handle_propatch($folder, $propPatch); + } } /** * Delete a calendar and all it's objects * * @param mixed $calendarId * @return void */ public function deleteCalendar($calendarId) { console(__METHOD__, $calendarId); $folder = $this->get_storage_folder($calendarId); if ($folder && !kolab_storage::folder_delete($folder->name)) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error deleting calendar folder $folder->name"), true, false); } } /** * Returns all calendar objects within a calendar. * * Every item contains an array with the following keys: * * id - unique identifier which will be used for subsequent updates * * calendardata - The iCalendar-compatible calendar data (optional) * * uri - a unique key which will be used to construct the uri. This can be any arbitrary string. * * lastmodified - a timestamp of the last modification time * * etag - An arbitrary string, surrounded by double-quotes. (e.g.: "abcdef"') * * calendarid - The calendarid as it was passed to this function. * * size - The size of the calendar objects, in bytes. * * Note that the etag is optional, but it's highly encouraged to return for * speed reasons. * * If neither etag or size are specified, the calendardata will be * used/fetched to determine these numbers. If both are specified the * amount of times this is needed is reduced by a great degree. * * @param mixed $calendarId * @return array */ public function getCalendarObjects($calendarId) { console(__METHOD__, $calendarId); $events = array(); $query = $this->_event_filter_query(); $storage = $this->get_storage_folder($calendarId); if ($storage) { foreach ($storage->select($query) as $event) { // post-filter events to suppress declined invitations if (!$this->_event_filter_compare($event)) { continue; } // get tags/categories from relations $this->load_tags($event); $events[] = array( 'id' => $event['uid'], 'uri' => VObjectUtils::uid2uri($event['uid'], '.ics'), 'lastmodified' => $event['changed'] ? $event['changed']->format('U') : null, 'calendarid' => $calendarId, 'etag' => self::_get_etag($event), 'size' => $event['_size'], ); } } return $events; } /** * Returns information from a single calendar object, based on it's object * uri. * * The returned array must have the same keys as getCalendarObjects. The * 'calendardata' object is required here though, while it's not required * for getCalendarObjects. * * @param mixed $calendarId * @param string $objectUri * @return array */ public function getCalendarObject($calendarId, $objectUri) { console(__METHOD__, $calendarId, $objectUri); $uid = VObjectUtils::uri2uid($objectUri, '.ics'); $storage = $this->get_storage_folder($calendarId); // attachment content is requested if (preg_match('!^(.+).ics:attachment:(\d+):.+$!', $objectUri, $m)) { $uid = VObjectUtils::uri2uid($m[1]); $part = $m[2]; } if ($storage && ($event = $storage->get_object($uid))) { // deliver attachment content directly if ($part && !empty($event['_attachments'])) { foreach ($event['_attachments'] as $attachment) { if ($attachment['id'] == $part) { header('Content-Type: ' . $attachment['mimetype']); header('Content-Disposition: inline; filename="' . $attachment['name'] . '"'); $storage->get_attachment($uid, $part, null, true); exit; } } } // map attributes $event['attachments'] = $event['_attachments']; // compose an absolute URI for referencing object attachments $base_uri = DAVBackend::abs_url(array( CalDAV\Plugin::CALENDAR_ROOT, preg_replace('!principals/!', '', $this->calendars[$calendarId]['principaluri']), $calendarId, VObjectUtils::uid2uri($event['uid'], '.ics'), )); // get tags/categories from relations $this->load_tags($event); // default response return array( 'id' => $event['uid'], 'uri' => VObjectUtils::uid2uri($event['uid'], '.ics'), 'lastmodified' => $event['changed'] ? $event['changed']->format('U') : null, 'calendarid' => $calendarId, 'calendardata' => $this->_to_ical($event, $base_uri, $storage), 'etag' => self::_get_etag($event), ); } return array(); } /** * Creates a new calendar object. * * It is possible return an etag from this function, which will be used in * the response to this PUT request. Note that the ETag must be surrounded * by double-quotes. * * However, you should only really return this ETag if you don't mangle the * calendar-data. If the result of a subsequent GET to this object is not * the exact same as this request body, you should omit the ETag. * * @param mixed $calendarId * @param string $objectUri * @param string $calendarData * @return string|null */ public function createCalendarObject($calendarId, $objectUri, $calendarData) { console(__METHOD__, $calendarId, $objectUri, $calendarData); $uid = VObjectUtils::uri2uid($objectUri, '.ics'); $storage = $this->get_storage_folder($calendarId); $object = $this->parse_calendar_data($calendarData, $uid); if (empty($object) || empty($object['uid'])) { throw new DAV\Exception('Parse error: not a valid iCalendar 2.0 object'); } // if URI doesn't match the content's UID, the object might already exist! if ($object['uid'] != $uid && $storage->get_object($object['uid'])) { $objectUri = VObjectUtils::uid2uri($object['uid'], '.ics'); Plugin::$redirect_basename = $objectUri; return $this->updateCalendarObject($calendarId, $objectUri, $calendarData); } // map attachments attribute $object['_attachments'] = $object['attachments']; unset($object['attachments']); // remove categories from object data (only for tasks yet) if ($object['_type'] == 'task') { $tags = (array)$object['categories']; unset($object['categories']); } $success = $storage->save($object, $object['_type']); if (!$success) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error saving $object[_type] object to Kolab server"), true, false); throw new DAV\Exception('Error saving calendar object to backend'); } // save tag relations on success (only available for tasks yet) if ($object['_type'] == 'task') { $this->save_tags($uid, $tags); $object['categories'] = $tags; // add again for etag computation } // send Location: header if URI doesn't match object's UID (Bug #2109) if ($object['uid'] != $uid) { Plugin::$redirect_basename = VObjectUtils::uid2uri($object['uid'], '.ics'); } // return new Etag return $success ? self::_get_etag($object) : null; } /** * Updates an existing calendarobject, based on it's uri. * * It is possible return an etag from this function, which will be used in * the response to this PUT request. Note that the ETag must be surrounded * by double-quotes. * * However, you should only really return this ETag if you don't mangle the * calendar-data. If the result of a subsequent GET to this object is not * the exact same as this request body, you should omit the ETag. * * @param mixed $calendarId * @param string $objectUri * @param string $calendarData * @return string|null */ public function updateCalendarObject($calendarId, $objectUri, $calendarData) { console(__METHOD__, $calendarId, $objectUri, $calendarData); $uid = VObjectUtils::uri2uid($objectUri, '.ics'); $storage = $this->get_storage_folder($calendarId); $object = $this->parse_calendar_data($calendarData, $uid); if (empty($object)) { throw new DAV\Exception('Parse error: not a valid iCalendar 2.0 object'); } // sanity check if ($object['uid'] != $uid) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error creating calendar object: UID doesn't match object URI"), true, false); throw new DAV\Exception\NotFound("UID doesn't match object URI"); } // copy meta data (starting with _) from old object $old = $storage->get_object($uid); foreach ((array)$old as $key => $val) { if (!isset($object[$key]) && $key[0] == '_') $object[$key] = $val; } // process attachments if (/* user agent known to handle attachments inline */ !empty($object['attachments'])) { $object['_attachments'] = $object['attachments']; unset($object['attachments']); // mark all existing attachments as deleted (update is always absolute) foreach ($old['_attachments'] as $key => $attach) { $object['_attachments'][$key] = false; } } // remove categories from object data (only for tasks yet) if ($object['_type'] == 'task') { $tags = (array)$object['categories']; unset($object['categories']); } // save object $saved = $storage->save($object, $object['_type'], $uid); if (!$saved) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error saving event object to Kolab server"), true, false); Plugin::$redirect_basename = null; throw new DAV\Exception('Error saving event object to backend'); } // save tag relations on success (only available for tasks yet) if ($object['_type'] == 'task') { $this->save_tags($uid, $tags); $object['categories'] = $tags; // add again for etag computation } // return new Etag return self::_get_etag($object); } /** * Deletes an existing calendar object. * * @param mixed $calendarId * @param string $objectUri * @return void */ public function deleteCalendarObject($calendarId, $objectUri) { console(__METHOD__, $calendarId, $objectUri); $uid = VObjectUtils::uri2uid($objectUri, '.ics'); if ($storage = $this->get_storage_folder($calendarId)) { if ($storage->delete($uid)) { $this->save_tags($uid, null); } } } /** * Performs a calendar-query on the contents of this calendar. * * The calendar-query is defined in RFC4791 : CalDAV. Using the * calendar-query it is possible for a client to request a specific set of * object, based on contents of iCalendar properties, date-ranges and * iCalendar component types (VTODO, VEVENT). * * This method should just return a list of (relative) urls that match this * query. * * The list of filters are specified as an array. The exact array is * documented by Sabre\CalDAV\CalendarQueryParser. * * Note that it is extremely likely that getCalendarObject for every path * returned from this method will be called almost immediately after. You * may want to anticipate this to speed up these requests. * * Requests that are extremely common are: * * requests for just VEVENTS * * requests for just VTODO * * requests with a time-range-filter on either VEVENT or VTODO. * * ..and combinations of these requests. It may not be worth it to try to * handle every possible situation and just rely on the (relatively * easy to use) CalendarQueryValidator to handle the rest. * * Note that especially time-range-filters may be difficult to parse. A * time-range filter specified on a VEVENT must for instance also handle * recurrence rules correctly. * A good example of how to interprete all these filters can also simply * be found in Sabre\CalDAV\CalendarQueryFilter. This class is as correct * as possible, so it gives you a good idea on what type of stuff you need * to think of. * * @param mixed $calendarId * @param array $filters * @return array */ public function calendarQuery($calendarId, array $filters) { console(__METHOD__, $calendarId, $filters); // build kolab storage query from $filters $query = $this->_event_filter_query(); foreach ((array)$filters['comp-filters'] as $filter) { if ($filter['name'] != 'VEVENT') continue; if (is_array($filter['time-range'])) { if (!empty($filter['time-range']['end'])) { $query[] = array('dtstart', '<=', $filter['time-range']['end']); } if (!empty($filter['time-range']['start'])) { $query[] = array('dtend', '>=', $filter['time-range']['start']); } } + if (is_array($filter['prop-filters'])) { + foreach ($filter['prop-filters'] as $prop_filter) { + $match = $prop_filter['text-match']; + if ($match['value']) { + $op = $match['negate-condition'] ? '!=' : '='; + switch ($prop_filter['name']) { + case 'UID': + $query[] = array('uid', $op, $match['value']); + break; + } + } + } + } } $results = array(); if ($storage = $this->get_storage_folder($calendarId)) { foreach ($storage->select($query) as $event) { // post-filter events to suppress declined invitations if ($this->_event_filter_compare($event)) { $results[] = $event['uid'] . '.ics'; } } } return $results; } /** * Set User-Agent string of the connected client */ public function setUserAgent($uastring) { $ua_classes = array( 'ical' => 'iCal/\d', 'outlook' => 'iCal4OL/\d', 'lightning' => 'Lightning/\d', ); foreach ($ua_classes as $class => $regex) { if (preg_match("!$regex!", $uastring)) { $this->useragent = $class; break; } } } + /********** SchedulingBackend methods ***********/ + + /** + * Returns a single scheduling object for the inbox collection. + * + * The returned array should contain the following elements: + * * uri - A unique basename for the object. This will be used to + * construct a full uri. + * * calendardata - The iCalendar object + * * lastmodified - The last modification date. Can be an int for a unix + * timestamp, or a PHP DateTime object. + * * etag - A unique token that must change if the object changed. + * * size - The size of the object, in bytes. + * + * @param string $principalUri + * @param string $objectUri + * @return array + */ + public function getSchedulingObject($principalUri, $objectUri) + { + console(__METHOD__, $principalUri, $objectUri); + + if (!$this->is_current_pricipal($principalUri)) { + return array(); + } + + $uid = VObjectUtils::uri2uid($objectUri, '.ics'); + list($msguid, $mime_id) = explode('-', $uid, 2); + + $message = new rcube_message($msguid, 'INBOX'); + if ($ical = $message->get_part_content($mime_id, null, false, 0, false)) { + if ($event = $this->parse_calendar_data($ical, null)) { + if ($event['_type'] == 'event') { + $event['_msguid'] = $msguid; + return array( + 'uri' => $objectUri, + 'calendardata' => $ical, + 'lastmodified' => $event['changed'] ? $event['changed']->format('U') : null, + 'etag' => self::_get_etag($event), + ); + } + } + } + + return array(); + } + + /** + * Returns all scheduling objects for the inbox collection. + * + * These objects should be returned as an array. Every item in the array + * should follow the same structure as returned from getSchedulingObject. + * + * The main difference is that 'calendardata' is optional. + * + * @param string $principalUri + * @return array + */ + public function getSchedulingObjects($principalUri) + { + console(__METHOD__, $principalUri); + + $results = array(); + + // we can only access the current user's calendars (if enabled) + if (!$this->is_current_pricipal($principalUri) || !rcube::get_instance()->config->get('kolabdav_caldav_inbox', false)) { + return $results; + } + + // find iTip messages in users email INBOX and extract the ics attachment. + foreach ($this->search_email_inbox() as $msg) { + list($msguid, $mime_id) = $msg; + $message = new rcube_message($msguid, 'INBOX'); + if ($ical = $message->get_part_content($mime_id, null, false, 0, false)) { + if ($event = $this->parse_calendar_data($ical, null)) { + if ($event['_type'] != 'event') { + continue; + } + + $event['_msguid'] = $msguid; + $results[] = array( + 'uri' => VObjectUtils::uid2uri($msguid . '-' . $mime_id, '.ics'), + 'calendardata' => $ical, + 'lastmodified' => $event['changed'] ? $event['changed']->format('U') : null, + 'etag' => self::_get_etag($event), + ); + + break; + } + } + } + + return $results; + } + + /** + * Deletes a scheduling object from the inbox collection. + * + * @param string $principalUri + * @param string $objectUri + * @return void + */ + public function deleteSchedulingObject($principalUri, $objectUri) + { + console(__METHOD__, $principalUri, $objectUri); + + $rcube = rcube::get_instance(); + + // we can only access the current user's inbox (if enabled) + if (!$this->is_current_pricipal($principalUri) || !$rcube->config->get('kolabdav_caldav_inbox', false)) { + return; + } + + // TODO: get the referenced iTip message from email INBOX and + // copy it to the default calendar. This will also remove the message + // from the email inbox as the message is considered 'processed'. + $uid = VObjectUtils::uri2uid($objectUri, '.ics'); + list($msguid, $mime_id) = explode('-', $uid, 2); + + $message = new rcube_message($msguid, 'INBOX'); + if ($ical = $message->get_part_content($mime_id, null, false, 0, false)) { + if ($object = $this->parse_calendar_data($ical, null)) { + if ($object['_type'] != 'event') { + return; + } + + // get default calendar and search for an existing copy + $calendarId = reset(array_keys($this->_read_calendars())); + $storage = $this->get_storage_folder($calendarId); + $existing = $storage->get_object($object['uid']); + + // copy meta data (starting with _) from old object + if (!empty($existing)) { + foreach ((array)$existing as $key => $val) { + if (!isset($object[$key]) && $key[0] == '_') + $object[$key] = $val; + } + } + + // TODO: if iTip REPLY or CANCEL, only copy necessary properties + + // map attachments attribute + $object['_attachments'] = $object['attachments']; + unset($object['attachments']); + + $success = $storage->save($object, $object['_type'], $existing['uid']); + if (!$success) { + rcube::raise_error(array( + 'code' => 600, 'type' => 'php', + 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Error saving $object[_type] object to Kolab server"), + true, false); + + throw new DAV\Exception('Error saving calendar object to backend'); + } + + // remove iTip message from email inbox + // TODO: move to Trash instead ? + $imap = $rcube->get_storage(); + $imap->move_message($msguid, 'Trash', 'INBOX'); + //console("DELETE $msguid", $imap->delete_message($msguid, 'INBOX')); + } + } + } + + /** + * Creates a new scheduling object. This should land in a users' inbox. + * + * @param string $principalUri + * @param string $objectUri + * @param string $objectData + * @return void + */ + public function createSchedulingObject($principalUri, $objectUri, $objectData) + { + console(__METHOD__, $principalUri, $objectUri, $objectData); + + // accept only for current user principal (we don't have permissions for other users) + if ($this->is_current_pricipal($principalUri)) { + + } + else { + // send as iTip? + } + } + + /** + * Return the ctag value for the scheduling inbox + */ + public function getSchedulingInboxCtag($principalUri) + { + $rcube = rcube::get_instance(); + + if ($this->is_current_pricipal($principalUri) && $rcube->config->get('kolabdav_caldav_inbox', false)) { + // we could use the INBOX imap folder properties but these are likely subject to + // frequent changes without new invitations. Let's count potential iTip messages: + $candidates = $this->search_email_inbox(); + + if (count($candidates)) { + $fdata = $rcube->storage->folder_data('INBOX'); + return sprintf('%d-%d-%d-%d', + $fdata['UIDVALIDITY'], + $fdata['HIGHESTMODSEQ'], + $fdata['UIDNEXT'], + count($candidates) + ); + } + } + + return "empty-000"; + } + + /** + * + */ + protected function search_email_inbox() + { + $result = array(); + $rcube = rcube::get_instance(); + $imap = $rcube->get_storage(); + + // FIXME: search by content-type doesn't return any results from Cyrus IMAP + // $query = $imap->search_once('INBOX', 'UNDELETED OR OR HEADER Content-Type text/calendar HEADER Content-Type multipart/mixed HEADER Content-Type multipart/alternative'); + $query = $imap->search_once('INBOX', 'UNDELETED'); + if ($query && $query->count() > 0) { + foreach ($query->get() as $msguid) { + // get bodystructure and check for iTip parts + $message = new rcube_message($msguid, 'INBOX'); + foreach ((array)$message->mime_parts as $part) { + if (self::part_is_itip($part)) { + $result[] = array($msguid, $part->mime_id); + } + } + } + } + + return $result; + } + + /** + * Checks if specified message part is a vcalendar data + * + * @param rcube_message_part Part object + * @return boolean True if part is of type vcard + */ + protected static function part_is_itip($part) + { + return ( + in_array($part->mimetype, array('text/calendar', 'text/x-vcalendar', 'application/ics')) || + ($part->mimetype == 'application/x-any' && $part->filename && preg_match('/\.ics$/i', $part->filename)) + ) && !empty($part->ctype_parameters['method']); + } + + /********** Data conversion utilities ***********/ /** * Get object tags */ private function load_tags(&$event) { // tag relations are only available for tasks yet if ($event['_type'] != 'task') { return; } $config = kolab_storage_config::get_instance(); $tags = $config->get_tags($event['uid']); if (!empty($tags)) { $event['categories'] = array(); } foreach ($tags as $tag) { $event['categories'][] = $tag['name']; // modify changed time if relation is newer if ($tag['changed'] && !$event['changed'] || $tag['changed'] > $event['changed']) { $event['changed'] = $tag['changed']; } } } /** * Update object tags */ private function save_tags($uid, $tags) { $config = kolab_storage_config::get_instance(); $config->save_tags($uid, $tags); } /** * Parse the given iCal string into a hash array kolab_format_event can handle * * @param string iCal data block * @return array Hash array with event properties or null on failure */ private function parse_calendar_data($calendarData, $uid) { try { $ical = libcalendaring::get_ical(); // use already parsed object if (Plugin::$parsed_vevent && Plugin::$parsed_vevent->UID == $uid) { $objects = $ical->import_from_vobject(Plugin::$parsed_vcalendar); } else { $objects = $ical->import($calendarData); } // return the first object if (count($objects)) { + $objects[0]['_method'] = $ical->method; return $objects[0]; } } catch (VObject\ParseException $e) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "iCal data parse error: " . $e->getMessage()), true, false); } return null; } /** * Build a valid iCal format block from the given event * * @param array Hash array with event/task properties from libkolab * @param string Absolute URI referenceing this event object * @param object RECURRENCE-ID property when serializing a recurrence exception * @return mixed VCALENDAR string containing the VEVENT data * or VObject\VEvent object with a recurrence exception instance * @see: \libvcalendar::export() */ private function _to_ical($event, $base_uri, $storage, $recurrence_id = null) { $ical = libcalendaring::get_ical(); $ical->set_prodid('-//Kolab//iRony DAV Server ' . KOLAB_DAV_VERSION . '//Sabre//Sabre VObject ' . VObject\Version::VERSION . '//EN'); $ical->set_agent($this->useragent == 'ical' ? 'Apple' : ''); // list attachments as absolute URIs for Thunderbird if ($this->useragent == 'lightning') { $ical->set_attach_uri($base_uri . ':attachment:{{id}}:{{name}}'); $get_attachment = null; } else { // embed attachments for others $get_attachment = function($id, $event) use ($storage) { return $storage->get_attachment($event['uid'], $id); }; } $events = array($event); // add more instances from exceptions (not recurrence) to the output if (!empty($event['exceptions']) && empty($event['recurrence'])) { $events = array_merge($events, $event['exceptions']); } return $ical->export($events, null, false, $get_attachment, false); } /** * Wrapper for libcalendaring::get_user_emails() */ private function get_user_emails() { $emails = libcalendaring::get_instance()->get_user_emails(); if (empty($emails)) { $emails = array(HTTPBasic::$current_user); } return $emails; } /** * Provide basic query for kolab_storage_folder::select() + * + * @param boolean Filter for inbox events (i.e. status=NEEDS-ACTION) + * @return array List of query parameters for kolab_storage_folder::select() */ - private function _event_filter_query() + private function _event_filter_query($inbox = false) { // get email addresses of the current user $user_emails = $this->get_user_emails(); - $query = array(); + $query = $subquery = array(); // add query to exclude declined invitations foreach ($user_emails as $email) { - $query[] = array('tags', '!=', 'x-partstat:' . $email . ':declined'); - // $query[] = array('tags', '!=', 'x-partstat:' . $email . ':needs-action'); + if ($inbox) { + $subquery[] = array('tags', '=', 'x-partstat:' . $email . ':needs-action'); + $subquery[] = array('tags', '=', 'x-partstat:' . $email . ':needs-action'); + } + else { + $query[] = array('tags', '!=', 'x-partstat:' . $email . ':declined'); + } + } + + if (!empty($subquery)) { + $query[] = array($subquery, 'OR'); } return $query; } /** * Check the given event if it matches the filter * + * @param array Hash array with event properties + * @param boolean Filter for inbox events (i.e. status=NEEDS-ACTION) * @return boolean True if matches, false if not */ - private function _event_filter_compare($event) + private function _event_filter_compare($event, $inbox = false) { static $user_emails; if (!is_array($user_emails)) { $user_emails = $this->get_user_emails(); } if (is_array($event['attendees'])) { foreach ($event['attendees'] as $attendee) { - if (in_array($attendee['email'], $user_emails) && $attendee['status'] == 'DECLINED') { - return false; + if (in_array($attendee['email'], $user_emails)) { + if ($attendee['status'] == 'DECLINED') { + return false; + } + else if ($inbox && $attendee['status'] == 'NEEDS-ACTION') { + return true; + } } } } - return true; + return !$inbox; } /** * Generate an Etag string from the given event data * * @param array Hash array with event properties from libkolab * @return string Etag string */ private static function _get_etag($event) { return sprintf('"%s-%d-%s"', substr(md5($event['uid']), 0, 16), $event['_msguid'], !empty($event['categories']) ? substr(md5(join(',', (array)$event['categories'])), 0, 16) : '0' ); } + /** + * Helpter method to determine whether the given principal URI + * matches the authenticated user principal. + */ + private function is_current_pricipal($principalUri) + { + return $principalUri === 'principals/' . HTTPBasic::$current_user; + } + } diff --git a/lib/Kolab/CalDAV/CalendarRootNode.php b/lib/Kolab/CalDAV/CalendarRootNode.php index 62174a7..6af6e78 100644 --- a/lib/Kolab/CalDAV/CalendarRootNode.php +++ b/lib/Kolab/CalDAV/CalendarRootNode.php @@ -1,94 +1,53 @@ * * Copyright (C) 2013, 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\CalDAV; use \Sabre\CalDAV; -use \Sabre\DAVACL\PrincipalBackend; -use \Sabre\DAVACL\AbstractPrincipalCollection; /** * Calendars collection * * This object is responsible for generating a list of calendar-homes for each * user. * */ -class CalendarRootNode extends AbstractPrincipalCollection +class CalendarRootNode extends CalDAV\CalendarRoot { - /** - * CalDAV backend - * - * @var Sabre\CalDAV\Backend\BackendInterface - */ - protected $caldavBackend; - - /** - * Constructor - * - * This constructor needs both an authentication and a caldav backend. - * - * By default this class will show a list of calendar collections for - * principals in the 'principals' collection. If your main principals are - * actually located in a different path, use the $principalPrefix argument - * to override this. - * - * @param PrincipalBackend\BackendInterface $principalBackend - * @param Backend\BackendInterface $caldavBackend - * @param string $principalPrefix - */ - public function __construct(PrincipalBackend\BackendInterface $principalBackend, CalDAV\Backend\BackendInterface $caldavBackend, $principalPrefix = 'principals') - { - parent::__construct($principalBackend, $principalPrefix); - $this->caldavBackend = $caldavBackend; - } - - /** - * Returns the nodename - * - * We're overriding this, because the default will be the 'principalPrefix', - * and we want it to be Sabre\CalDAV\Plugin::CALENDAR_ROOT - * - * @return string - */ - public function getName() - { - return CalDAV\Plugin::CALENDAR_ROOT; - } /** * This method returns a node for a principal. * * The passed array contains principal information, and is guaranteed to * at least contain a uri item. Other properties may or may not be * supplied by the authentication backend. * * @param array $principal * @return \Sabre\DAV\INode */ public function getChildForPrincipal(array $principal) { return new UserCalendars($this->caldavBackend, $principal); } } diff --git a/lib/Kolab/CalDAV/IMip.php b/lib/Kolab/CalDAV/IMip.php deleted file mode 100644 index c08f37a..0000000 --- a/lib/Kolab/CalDAV/IMip.php +++ /dev/null @@ -1,141 +0,0 @@ - - * - * Copyright (C) 2013, 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\CalDAV; - -use \rcube; -use \rcube_utils; -use \Mail_mime; - -use Sabre\VObject; -use Sabre\CalDAV; -use Sabre\DAV; - -/** - * iMIP handler. - * - * This class is responsible for sending out iMIP messages. iMIP is the - * email-based transport for iTIP. iTIP deals with scheduling operations for - * iCalendar objects. - * - * If you want to customize the email that gets sent out, you can do so by - * extending this class and overriding the sendMessage method. - * - * @copyright Copyright (C) 2007-2013 Rooftop Solutions. All rights reserved. - * @author Evert Pot (http://www.rooftopsolutions.nl/) - * @license http://code.google.com/p/sabredav/wiki/License Modified BSD License - */ -class IMip extends CalDAV\Schedule\IMip -{ - public function __construct() - { - - } - - /** - * Sends one or more iTip messages through email. - * - * @param string $originator Originator Email - * @param array $recipients Array of email addresses - * @param VObject\Component $vObject - * @param string $principal Principal Url of the originator - * @return void - */ - public function sendMessage($originator, array $recipients, VObject\Component $vObject, $principal) - { - $ics = $vObject->serialize(); - console(__METHOD__, $originator, $recipients, $principal, $ics); - - $rcube = rcube::get_instance(); - $sender = $rcube->user->get_identity(); - $sender_email = $sender['email'] ?: $rcube->get_user_email(); - $sender_name = $sender['name'] ?: $rcube->get_user_name(); - - foreach($recipients as $recipient) { - $subject = 'KolabDAV iTIP message'; - switch (strtoupper($vObject->METHOD)) { - case 'REPLY' : - $subject = 'Response for: ' . $vObject->VEVENT->SUMMARY; - break; - case 'REQUEST' : - $subject = 'Invitation for: ' .$vObject->VEVENT->SUMMARY; - break; - case 'CANCEL' : - $subject = 'Cancelled event: ' . $vObject->VEVENT->SUMMARY; - break; - } - - $sender = rcube_utils::idn_to_ascii($sender_email); - $from = format_email_recipient($sender, $sender_name); - $mailto = rcube_utils::idn_to_ascii($recipient); - - // compose multipart message using PEAR:Mail_Mime - $message = new Mail_mime("\r\n"); - $message->setParam('text_encoding', 'quoted-printable'); - $message->setParam('head_encoding', 'quoted-printable'); - $message->setParam('head_charset', RCUBE_CHARSET); - $message->setParam('text_charset', RCUBE_CHARSET . ";\r\n format=flowed"); - - // compose common headers array - $headers = array( - 'To' => $mailto, - 'From' => $from, - 'Date' => date('r'), - 'Reply-To' => $originator, - 'Message-ID' => $rcube->gen_message_id(), - 'X-Sender' => $sender, - 'Subject' => $subject, - ); - if ($agent = $rcube->config->get('useragent')) - $headers['User-Agent'] = $agent; - - $message->headers($headers); - $message->setContentType('text/calendar', array('method' => strval($vObject->method), 'charset' => RCUBE_CHARSET)); - $message->setTXTBody($ics); - - // send message through Roundcube's SMTP feature - if (!$rcube->deliver_message($message, $sender, $mailto, $smtp_error)) { - rcube::raise_error(array( - 'code' => 600, 'type' => 'php', - 'file' => __FILE__, 'line' => __LINE__, - 'message' => "Failed to send iTIP message to " . $mailto), - true, false); - } - } - } - - /** - * This function is reponsible for sending the actual email. - * - * @param string $to Recipient email address - * @param string $subject Subject of the email - * @param string $body iCalendar body - * @param array $headers List of headers - * @return void - */ - protected function mail($to, $subject, $body, array $headers) - { - //mail($to, $subject, $body, implode("\r\n", $headers)); - } - -} diff --git a/lib/Kolab/CalDAV/IMipPlugin.php b/lib/Kolab/CalDAV/IMipPlugin.php new file mode 100644 index 0000000..5fae8f0 --- /dev/null +++ b/lib/Kolab/CalDAV/IMipPlugin.php @@ -0,0 +1,135 @@ + + * + * Copyright (C) 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\CalDAV; + +use \rcube; +use \rcube_utils; +use \Mail_mime; + +use Sabre\VObject; +use Sabre\CalDAV; +use Sabre\DAV; + +/** + * iMIP plugin. + * + * This class is responsible for sending out iMIP messages. iMIP is the + * email-based transport for iTIP. iTIP deals with scheduling operations for + * iCalendar objects. + */ +class IMipPlugin extends CalDAV\Schedule\IMipPlugin +{ + + /** + * Event handler for the 'schedule' event. + * + * @param ITip\Message $iTipMessage + * @return void + */ + function schedule(VObject\ITip\Message $iTipMessage) + { + console(__METHOD__, $iTipMessage->method, $iTipMessage->recipient, $iTipMessage->significantChange, $iTipMessage->scheduleStatus); + + // Not sending any emails if the system considers the update insignificant. + if (!$iTipMessage->significantChange) { + if (!$iTipMessage->scheduleStatus) { + $iTipMessage->scheduleStatus = '1.0;We got the message, but it\'s not significant enough to warrant an email'; + } + return; + } + + $recipient = preg_replace('!^mailto:!i', '', $iTipMessage->recipient); + $summary = strval($iTipMessage->message->VEVENT->SUMMARY); + + $rcube = rcube::get_instance(); + $sender = $rcube->user->get_identity(); + $sender_email = $sender['email'] ?: $rcube->get_user_email(); + $sender_name = $sender['name'] ?: $rcube->get_user_name(); + + $subject = 'KolabDAV iTIP message'; + switch (strtoupper($iTipMessage->method)) { + case 'REPLY' : + $subject = 'Re: ' . $summary; + break; + case 'REQUEST' : + $subject = 'Invitation: ' .$summary; + break; + case 'CANCEL' : + $subject = 'Cancelled: ' . $summary; + break; + } + + $sender = rcube_utils::idn_to_ascii($sender_email); + $from = format_email_recipient($sender, $sender_name); + $mailto = rcube_utils::idn_to_ascii($recipient); + $to = format_email_recipient($mailto, $iTipMessage->recipientName); + + // copy some missing properties from master event to make it validate in our clients + if (Plugin::$parsed_vevent && strval(Plugin::$parsed_vevent->UID) == strval($iTipMessage->uid)) { + if (isset(Plugin::$parsed_vevent->DTEND)) { + $iTipMessage->message->VEVENT->DTEND = clone Plugin::$parsed_vevent->DTEND; + } + if (isset(Plugin::$parsed_vevent->STATUS)) { + $iTipMessage->message->VEVENT->STATUS = strval(Plugin::$parsed_vevent->STATUS); + } + } + + // compose multipart message using PEAR:Mail_Mime + $message = new Mail_mime("\r\n"); + $message->setParam('text_encoding', 'quoted-printable'); + $message->setParam('head_encoding', 'quoted-printable'); + $message->setParam('head_charset', RCUBE_CHARSET); + $message->setParam('text_charset', RCUBE_CHARSET . ";\r\n format=flowed"); + + // compose common headers array + $headers = array( + 'To' => $to, + 'From' => $from, + 'Date' => date('r'), + 'Reply-To' => $originator, + 'Message-ID' => $rcube->gen_message_id(), + 'X-Sender' => $sender, + 'Subject' => $subject, + ); + if ($agent = $rcube->config->get('useragent')) + $headers['User-Agent'] = $agent; + + $message->headers($headers); + $message->setContentType('text/calendar', array('method' => strval($iTipMessage->method), 'charset' => RCUBE_CHARSET)); + $message->setTXTBody($iTipMessage->message->serialize()); + + // send message through Roundcube's SMTP feature + if ($rcube->deliver_message($message, $sender, $mailto, $smtp_error)) { + $iTipMessage->scheduleStatus = '1.1;Scheduling message sent via iMip'; + } + else { + rcube::raise_error(array( + 'code' => 600, 'type' => 'php', + 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Failed to send iTIP message to " . $mailto), + true, false); + } + } + +} diff --git a/lib/Kolab/CalDAV/Plugin.php b/lib/Kolab/CalDAV/Plugin.php index c23a06d..b584649 100644 --- a/lib/Kolab/CalDAV/Plugin.php +++ b/lib/Kolab/CalDAV/Plugin.php @@ -1,239 +1,229 @@ * * Copyright (C) 2013, 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\CalDAV; use Sabre\DAV; use Sabre\CalDAV; use Sabre\VObject; +use Sabre\HTTP; +use Sabre\HTTP\URLUtil; use Kolab\DAV\Auth\HTTPBasic; /** * Extended CalDAV plugin to tweak data validation */ class Plugin extends CalDAV\Plugin { // make already parsed text/calednar blocks available for later use public static $parsed_vcalendar; public static $parsed_vevent; // allow the backend to force a redirect Location public static $redirect_basename; /** * Initializes the plugin * * @param DAV\Server $server * @return void */ public function initialize(DAV\Server $server) { parent::initialize($server); - $server->subscribeEvent('afterCreateFile', array($this, 'afterWriteContent')); - $server->subscribeEvent('afterWriteContent', array($this, 'afterWriteContent')); + $server->on('afterCreateFile', array($this, 'afterWriteContent')); + $server->on('afterWriteContent', array($this, 'afterWriteContent')); } /** * Inject some additional HTTP response headers */ public function afterWriteContent($uri, $node) { // send Location: header to corrected URI if (self::$redirect_basename) { $path = explode('/', $uri); array_pop($path); array_push($path, self::$redirect_basename); $this->server->httpResponse->setHeader('Location', $this->server->getBaseUri() . join('/', array_map('urlencode', $path))); self::$redirect_basename = null; } } /** * Checks if the submitted iCalendar data is in fact, valid. * * An exception is thrown if it's not. * * @param resource|string $data * @param string $path + * @param bool $modified Should be set to true, if this event handler + * changed &$data. + * @param RequestInterface $request The http request. + * @param ResponseInterface $response The http response. + * @param bool $isNew Is the item a new one, or an update. * @return void */ - protected function validateICalendar(&$data, $path) + protected function validateICalendar(&$data, $path, &$modified, HTTP\RequestInterface $request, HTTP\ResponseInterface $response, $isNew) { // If it's a stream, we convert it to a string first. if (is_resource($data)) { $data = stream_get_contents($data); } + $before = md5($data); // Converting the data to unicode, if needed. $data = DAV\StringUtil::ensureUTF8($data); + if ($before !== md5($data)) + $modified = true; + try { - // modification: Set options to be more tolerant when parsing extended or invalid properties - $vobj = VObject\Reader::read($data, VObject\Reader::OPTION_FORGIVING | VObject\Reader::OPTION_IGNORE_INVALID_LINES); - - // keep the parsed object in memory for later processing - if ($vobj->name == 'VCALENDAR') { - self::$parsed_vcalendar = $vobj; - foreach ($vobj->getBaseComponents() ?: $vobj->getComponents() as $i => $vevent) { - if ($vevent->name == 'VEVENT' || $vevent->name == 'VTODO') { - self::$parsed_vevent = $vevent; - break; + // If the data starts with a [, we can reasonably assume we're dealing + // with a jCal object. + if (substr($data,0,1) === '[') { + $vobj = VObject\Reader::readJson($data); + + // Converting $data back to iCalendar, as that's what we + // technically support everywhere. + $data = $vobj->serialize(); + $modified = true; + } else { + // modification: Set options to be more tolerant when parsing extended or invalid properties + $vobj = VObject\Reader::read($data, VObject\Reader::OPTION_FORGIVING | VObject\Reader::OPTION_IGNORE_INVALID_LINES); + + // keep the parsed object in memory for later processing + if ($vobj->name == 'VCALENDAR') { + self::$parsed_vcalendar = $vobj; + foreach ($vobj->getBaseComponents() ?: $vobj->getComponents() as $vevent) { + if ($vevent->name == 'VEVENT' || $vevent->name == 'VTODO') { + self::$parsed_vevent = $vevent; + break; + } } } } } catch (VObject\ParseException $e) { - throw new DAV\Exception\UnsupportedMediaType('This resource requires valid iCalendar 2.0 data. Parse error: ' . $e->getMessage()); + throw new DAV\Exception\UnsupportedMediaType('This resource only supports valid iCalendar 2.0 data. Parse error: ' . $e->getMessage()); } if ($vobj->name !== 'VCALENDAR') { throw new DAV\Exception\UnsupportedMediaType('This collection can only support iCalendar objects.'); } + $sCCS = '{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set'; + // Get the Supported Components for the target calendar - list($parentPath,$object) = DAV\URLUtil::splitPath($path); - $calendarProperties = $this->server->getProperties($parentPath,array('{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set')); - $supportedComponents = $calendarProperties['{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set']->getValue(); + list($parentPath) = URLUtil::splitPath($path); + $calendarProperties = $this->server->getProperties($parentPath, [$sCCS]); + + if (isset($calendarProperties[$sCCS])) { + $supportedComponents = $calendarProperties[$sCCS]->getValue(); + } + else { + $supportedComponents = ['VTODO', 'VEVENT']; + } $foundType = null; $foundUID = null; foreach($vobj->getComponents() as $component) { switch($component->name) { case 'VTIMEZONE': continue 2; case 'VEVENT': case 'VTODO': case 'VJOURNAL': if (is_null($foundType)) { $foundType = $component->name; if (!in_array($foundType, $supportedComponents)) { - throw new CalDAV\Exception\InvalidComponentType('This calendar only supports ' . implode(', ', $supportedComponents) . '. We found a ' . $foundType); + throw new CalDAV\Exception\InvalidComponentType('This resource only supports ' . implode(', ', $supportedComponents) . '. We found a ' . $foundType); } if (!isset($component->UID)) { throw new DAV\Exception\BadRequest('Every ' . $component->name . ' component must have an UID'); } $foundUID = (string)$component->UID; } else { if ($foundType !== $component->name) { throw new DAV\Exception\BadRequest('A calendar object must only contain 1 component. We found a ' . $component->name . ' as well as a ' . $foundType); } if ($foundUID !== (string)$component->UID) { throw new DAV\Exception\BadRequest('Every ' . $component->name . ' in this object must have identical UIDs'); } } break; default: throw new DAV\Exception\BadRequest('You are not allowed to create components of type: ' . $component->name . ' here'); } } if (!$foundType) throw new DAV\Exception\BadRequest('iCalendar object must contain at least 1 of VEVENT, VTODO or VJOURNAL'); + + // We use an extra variable to allow event handles to tell us wether + // the object was modified or not. + // + // This helps us determine if we need to re-serialize the object. + $subModified = false; + + $this->server->emit( + 'calendarObjectChange', + [ + $request, + $response, + $vobj, + $parentPath, + &$subModified, + $isNew + ] + ); + + if ($subModified) { + // An event handler told us that it modified the object. + $data = $vobj->serialize(); + + // Using md5 to figure out if there was an *actual* change. + if (!$modified && $before !== md5($data)) { + $modified = true; + } + } } /** * Returns a list of features for the DAV: HTTP header. * Including 'calendar-schedule' to enable scheduling support in Thunderbird Lightning. * * @return array */ public function getFeatures() { $features = parent::getFeatures(); $features[] = 'calendar-schedule'; return $features; } - /** - * Returns free-busy information for a specific address. The returned - * data is an array containing the following properties: - * - * calendar-data : A VFREEBUSY VObject - * request-status : an iTip status code. - * href: The principal's email address, as requested - * - * @param string $email address - * @param \DateTime $start - * @param \DateTime $end - * @param VObject\Component $request - * @return array - */ - protected function getFreeBusyForEmail($email, \DateTime $start, \DateTime $end, VObject\Component $request) - { - console(__METHOD__, $email, $start, $end); - - $email = preg_replace('/^mailto:/', '', $email); - - // pass-through the pre-generatd free/busy feed from Kolab's free/busy service - if ($fburl = \kolab_storage::get_freebusy_url($email)) { - // use PEAR::HTTP_Request2 for data fetching - // @include_once('HTTP/Request2.php'); - - try { - $rcube = \rcube::get_instance(); - $request = new \HTTP_Request2($fburl); - $request->setConfig(array( - 'store_body' => true, - 'follow_redirects' => true, - 'ssl_verify_peer' => $rcube->config->get('kolab_ssl_verify_peer', true), - )); - - $response = $request->send(); - - // authentication required - if ($response->getStatus() == 401) { - $request->setAuth(HTTPBasic::$current_user, HTTPBasic::$current_pass); - $response = $request->send(); - } - - // success! - if ($response->getStatus() == 200) { - $vcalendar = VObject\Reader::read($response->getBody(), VObject\Reader::OPTION_FORGIVING | VObject\Reader::OPTION_IGNORE_INVALID_LINES); - return array( - 'calendar-data' => $vcalendar, - 'request-status' => '2.0;Success', - 'href' => 'mailto:' . $email, - ); - } - } - catch (\Exception $e) { - // log failures - \rcube::raise_error($e, true, false); - } - } - else { - // generate free/busy data from this user's calendars - return parent::getFreeBusyForEmail($email, $start, $end, $request); - } - - // return "not found" - return array( - 'request-status' => '3.7;Could not find principal', - 'href' => 'mailto:' . $email, - ); - } } \ No newline at end of file diff --git a/lib/Kolab/CalDAV/ScheduleInbox.php b/lib/Kolab/CalDAV/ScheduleInbox.php new file mode 100644 index 0000000..9514a46 --- /dev/null +++ b/lib/Kolab/CalDAV/ScheduleInbox.php @@ -0,0 +1,68 @@ + + * + * Copyright (C) 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\CalDAV; + +use Sabre\DAV; +use Sabre\CalDAV; + +/** + * Extended CalDAV scheduling inbox + */ +class ScheduleInbox extends CalDAV\Schedule\Inbox implements DAV\IProperties +{ + /** + * Updates properties on this node. + * + * @param PropPatch $propPatch + * @return void + * @see \Sabre\DAV\IProperties::propPatch() + */ + function propPatch(DAV\PropPatch $propPatch) + { + // not implemented + throw new DAV\Exception\Forbidden("The Scheduling inbox doesn't allow property changes'"); + } + + /** + * Returns a list of properties for this nodes. + * + * The properties list is a list of propertynames the client requested, + * encoded in clark-notation {xmlnamespace}tagname + * + * @param array $properties + * @return array + */ + function getProperties($properties) + { + $response = []; + foreach ($properties as $propname) { + // getctag is the only property we support + if ($propname == '{http://calendarserver.org/ns/}getctag') { + $response[$propname] = $this->caldavBackend->getSchedulingInboxCtag($this->principalUri); + } + } + + return $response; + } +} diff --git a/lib/Kolab/CalDAV/SchedulePlugin.php b/lib/Kolab/CalDAV/SchedulePlugin.php new file mode 100644 index 0000000..dbf4f0b --- /dev/null +++ b/lib/Kolab/CalDAV/SchedulePlugin.php @@ -0,0 +1,100 @@ + + * + * Copyright (C) 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\CalDAV; + +use Sabre\DAV; +use Sabre\CalDAV; +use Sabre\VObject; +use Sabre\HTTP; + +/** + * Extended CalDAV Schedule plugin + */ +class SchedulePlugin extends CalDAV\Schedule\Plugin +{ + /** + * Returns free-busy information for a specific address. The returned + * data is an array containing the following properties: + * + * calendar-data : A VFREEBUSY VObject + * request-status : an iTip status code. + * href: The principal's email address, as requested + * + * @param string $email address + * @param \DateTime $start + * @param \DateTime $end + * @param VObject\Component $request + * @return array + */ + protected function getFreeBusyForEmail($email, \DateTime $start, \DateTime $end, VObject\Component $request) + { + console(__METHOD__, $email, $start, $end); + + $email = preg_replace('!^mailto:!i', '', $email); + + // pass-through the pre-generatd free/busy feed from Kolab's free/busy service + if ($fburl = \kolab_storage::get_freebusy_url($email)) { + try { + $rcube = \rcube::get_instance(); + $client = new HTTP\Client(); + $client->addCurlSetting(CURLOPT_SSL_VERIFYPEER, $rcube->config->get('kolab_ssl_verify_peer', true)); + + // authentication required + $client->on('error:401', function($request, $response, &$retry, $retryCount) { + if ($retryCount <= 1) { + // We're only going to retry exactly once. + $request->setHeader('Authorization', 'Basic ' . base64_encode(HTTPBasic::$current_user . ':' . HTTPBasic::$current_pass)); + $retry = true; + } + }); + + $response = $client->send(new HTTP\Request('GET', $fburl)); + + // success! + if ($response->getStatus() == 200) { + $vcalendar = VObject\Reader::read($response->getBodyAsString(), VObject\Reader::OPTION_FORGIVING | VObject\Reader::OPTION_IGNORE_INVALID_LINES); + return array( + 'calendar-data' => $vcalendar, + 'request-status' => '2.0;Success', + 'href' => 'mailto:' . $email, + ); + } + } + catch (\Exception $e) { + // log failures + \rcube::raise_error($e, true, false); + } + } + else { + // generate free/busy data from this user's calendars + return parent::getFreeBusyForEmail($email, $start, $end, $request); + } + + // return "not found" + return array( + 'request-status' => '3.7;Could not find principal', + 'href' => 'mailto:' . $email, + ); + } +} \ No newline at end of file diff --git a/lib/Kolab/CalDAV/UserCalendars.php b/lib/Kolab/CalDAV/UserCalendars.php index dcd2aff..cd17ea1 100644 --- a/lib/Kolab/CalDAV/UserCalendars.php +++ b/lib/Kolab/CalDAV/UserCalendars.php @@ -1,166 +1,139 @@ * * Copyright (C) 2013, 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\CalDAV; use Sabre\DAV; use Sabre\DAVACL; use Sabre\CalDAV\Backend; use Sabre\CalDAV\Schedule; use Kolab\CalDAV\Calendar; /** * The UserCalenders class contains all calendars associated to one user * */ -class UserCalendars extends \Sabre\CalDAV\UserCalendars implements DAV\IExtendedCollection, DAVACL\IACL +class UserCalendars extends \Sabre\CalDAV\CalendarHome implements DAV\IExtendedCollection, DAVACL\IACL { - private $outbox; - - /** - * Returns a list of calendars - * - * @return array - */ - public function getChildren() - { - $calendars = $this->caldavBackend->getCalendarsForUser($this->principalInfo['uri']); - $objs = array(); - foreach ($calendars as $calendar) { - // TODO: (later) add sharing support by implenting this all - if ($this->caldavBackend instanceof Backend\SharingSupport) { - if (isset($calendar['{http://calendarserver.org/ns/}shared-url'])) { - $objs[] = new SharedCalendar($this->caldavBackend, $calendar); - } - else { - $objs[] = new ShareableCalendar($this->caldavBackend, $calendar); - } - } - else { - $objs[] = new Calendar($this->caldavBackend, $calendar); - } - } - - // add support for scheduling AKA free/busy - $objs[] = new Schedule\Outbox($this->principalInfo['uri']); - - // TODO: add notification support (check with clients first, if anybody supports it) - if ($this->caldavBackend instanceof Backend\NotificationSupport) { - $objs[] = new Notifications\Collection($this->caldavBackend, $this->principalInfo['uri']); - } - - return $objs; - } - /** * Returns a single calendar, by name * * @param string $name * @return Calendar */ public function getChild($name) { - if ($name == 'outbox') { - return new Schedule\Outbox($this->principalInfo['uri']); + // Special nodes + if ($name === 'inbox') { + return new ScheduleInbox($this->caldavBackend, $this->principalInfo['uri']); } - if ($calendar = $this->caldavBackend->getCalendarByName($name)) { + + if (!in_array($name, array('inbox','outbox','notifications')) && ($calendar = $this->caldavBackend->getCalendarByName($name))) { $calendar['principaluri'] = $this->principalInfo['uri']; return new Calendar($this->caldavBackend, $calendar); } - throw new DAV\Exception\NotFound('Calendar with name \'' . $name . '\' could not be found'); + return parent::getChild($name); } /** * Checks if a calendar exists. * * @param string $name * @return bool */ public function childExists($name) { + // Special nodes + if ($name === 'inbox' || $name === 'outbox') { + return true; + } + if ($name === 'notifications') { + return false; + } + if ($this->caldavBackend->getCalendarByName($name)) { return true; } return false; } /** * Returns a list of ACE's for this node. * * Each ACE has the following properties: * - 'privilege', a string such as {DAV:}read or {DAV:}write. These are currently the only supported privileges * - 'principal', a url to the principal who owns the node * - 'protected' (optional), indicating that this ACE is not allowed to be updated. * * @return array */ public function getACL() { // define rights for the user's calendar root (which is in fact INBOX) return array( array( 'privilege' => '{DAV:}read', 'principal' => $this->principalInfo['uri'], 'protected' => true, ), array( 'privilege' => '{DAV:}write', 'principal' => $this->principalInfo['uri'], 'protected' => true, ), /* TODO: implement sharing support array( 'privilege' => '{DAV:}read', 'principal' => $this->principalInfo['uri'] . '/calendar-proxy-write', 'protected' => true, ), array( 'privilege' => '{DAV:}write', 'principal' => $this->principalInfo['uri'] . '/calendar-proxy-write', 'protected' => true, ), array( 'privilege' => '{DAV:}read', 'principal' => $this->principalInfo['uri'] . '/calendar-proxy-read', 'protected' => true, ), */ ); } /** * Updates the ACL * * This method will receive a list of new ACE's. * * @param array $acl * @return void */ public function setACL(array $acl) { // TODO: implement this throw new DAV\Exception\MethodNotAllowed('Changing ACL is not yet supported'); } } diff --git a/lib/Kolab/CardDAV/ContactsBackend.php b/lib/Kolab/CardDAV/ContactsBackend.php index 4563668..b0d3b3f 100644 --- a/lib/Kolab/CardDAV/ContactsBackend.php +++ b/lib/Kolab/CardDAV/ContactsBackend.php @@ -1,1150 +1,1166 @@ * * Copyright (C) 2013, 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\CardDAV; use \rcube; use \rcube_charset; use \kolab_storage; use Sabre\DAV; use Sabre\CardDAV; use Sabre\VObject; +use Sabre\VObject\DateTimeParser; use Kolab\Utils\DAVBackend; use Kolab\Utils\VObjectUtils; /** * Kolab Contacts backend. * * Checkout the Sabre\CardDAV\Backend\BackendInterface for all the methods that must be implemented. */ class ContactsBackend extends CardDAV\Backend\AbstractBackend { public static $supported_address_data = array( array('contentType' => 'text/vcard', 'version' => '3.0'), array('contentType' => 'text/vcard', 'version' => '4.0'), ); public $ldap_directory; private $sources; private $folders; private $aliases; private $useragent; private $subscribed = null; // mapping of labelled X-AB properties to known vcard fields private $xab_labelled_map = array( 'X-ABDATE' => array( 'anniversary' => 'X-ANNIVERSARY', ), 'X-ABRELATEDNAMES' => array( 'child' => 'X-CHILDREN', 'spouse' => 'X-SPOUSE', 'manager' => 'X-MANAGER', 'assistant' => 'X-ASSISTANT', ), ); // known labels need to be quoted specially with _$!< >!$_ private $xab_known_labels = array('anniversary','child','parent','mother','father','brother','sister','friend','spouse','manager','assistant','partner','other'); // mapping of related types to internal contact properties private $related_map = array( 'child' => 'children', 'spouse' => 'spouse', 'manager' => 'manager', 'assistant' => 'assistant', ); /** * Read available contact folders from server */ private function _read_sources() { // already read sources if (isset($this->sources)) return $this->sources; // get all folders that have "contact" type $folders = kolab_storage::get_folders('contact', $this->subscribed); $this->sources = $this->folders = $this->aliases = array(); foreach (kolab_storage::sort_folders($folders) as $folder) { $id = $folder->get_uid(); $fdata = $folder->get_imap_data(); // fetch IMAP folder data for CTag generation $this->folders[$id] = $folder; $this->sources[$id] = array( 'id' => $id, 'uri' => $id, '{DAV:}displayname' => html_entity_decode($folder->get_name(), ENT_COMPAT, RCUBE_CHARSET), '{http://calendarserver.org/ns/}getctag' => sprintf('%d-%d-%d', $fdata['UIDVALIDITY'], $fdata['HIGHESTMODSEQ'], $fdata['UIDNEXT']), '{urn:ietf:params:xml:ns:carddav}supported-address-data' => new CardDAV\Property\SupportedAddressData(self::$supported_address_data), ); $this->aliases[$folder->name] = $id; // map default folder to the magic 'all' resource if ($folder->default) $this->aliases['__all__'] = $id; } return $this->sources; } /** * Getter for a kolab_storage_folder representing the address book for the given ID * * @param string Folder ID * @return object kolab_storage_folder instance */ public function get_storage_folder($id) { // resolve alias name if ($this->aliases[$id]) { $id = $this->aliases[$id]; } if ($this->folders[$id]) { DAVBackend::check_storage_folder($this->folders[$id]); return $this->folders[$id]; } else { return DAVBackend::get_storage_folder($id, 'contact'); } } /** * Returns the list of addressbooks for a specific user. * * @param string $principalUri * @return array */ public function getAddressBooksForUser($principalUri) { console(__METHOD__, $principalUri, $this->useragent); $this->_read_sources(); // special case for the apple address book which only supports one (!) address book if ($this->useragent == 'macosx' && count($this->sources) > 1) { $source = $this->getAddressBookByName('__all__'); $source['principaluri'] = $principalUri; return array($source); } $addressBooks = array(); foreach ($this->sources as $id => $source) { $source['principaluri'] = $principalUri; $addressBooks[] = $source; } return $addressBooks; } /** * Returns properties for a specific node identified by name/uri * * @param string Node name/uri * @return array Hash array with addressbook properties or null if not found */ public function getAddressBookByName($addressBookUri) { console(__METHOD__, $addressBookUri); $this->_read_sources(); $id = $addressBookUri; // return the magic *single* address book for Apple's Address Book App if ($id == '__all__') { $ctags = array(); foreach ($this->sources as $source) { $ctags[] = $source['{http://calendarserver.org/ns/}getctag']; } return array( 'id' => '__all__', 'uri' => '__all__', '{DAV:}displayname' => 'All', '{http://calendarserver.org/ns/}getctag' => join(':', $ctags), '{urn:ietf:params:xml:ns:carddav}supported-address-data' => new CardDAV\Property\SupportedAddressData(self::$supported_address_data), ); } // resolve aliases (addressbook by folder name) if ($this->aliases[$addressBookUri]) { $id = $this->aliases[$addressBookUri]; } // retry with subscribed = false (#2701) if (empty($this->sources[$id]) && $this->subscribed === null && rcube::get_instance()->config->get('kolab_use_subscriptions')) { $this->subscribed = false; unset($this->sources); return $this->getAddressBookByName($addressBookUri); } return $this->sources[$id]; } /** - * Updates an addressbook's properties + * Updates properties for an address book. * - * See Sabre\DAV\IProperties for a description of the mutations array, as - * well as the return value. + * The list of mutations is stored in a Sabre\DAV\PropPatch object. + * To do the actual updates, you must tell this object which properties + * you're going to process with the handle() method. * - * @param mixed $addressBookId - * @param array $mutations - * @see Sabre\DAV\IProperties::updateProperties - * @return bool|array + * Calling the handle method is like telling the PropPatch object "I + * promise I can handle updating this property". + * + * Read the PropPatch documenation for more info and examples. + * + * @param string $addressBookId + * @param \Sabre\DAV\PropPatch $propPatch + * @return void */ - public function updateAddressBook($addressBookId, array $mutations) + public function updateAddressBook($addressBookId, \Sabre\DAV\PropPatch $propPatch) { - console(__METHOD__, $addressBookId, $mutations); + console(__METHOD__, $addressBookId, $propPatch); if ($addressBookId == '__all__') return false; - $folder = $this->get_storage_folder($addressBookId); - return $folder ? DAVBackend::folder_update($folder, $mutations) : false; + if ($folder = $this->get_storage_folder($addressBookId)) { + DAVBackend::handle_propatch($folder, $propPatch); + } } /** * Creates a new address book * * @param string $principalUri * @param string $url Just the 'basename' of the url. * @param array $properties * @return void */ public function createAddressBook($principalUri, $url, array $properties) { console(__METHOD__, $principalUri, $url, $properties); return DAVBackend::folder_create('contact', $properties, $url); } /** * Deletes an entire addressbook and all its contents * * @param int $addressBookId * @return void */ public function deleteAddressBook($addressBookId) { console(__METHOD__, $addressBookId); if ($addressBookId == '__all__') return; $folder = $this->get_storage_folder($addressBookId); if ($folder && !kolab_storage::folder_delete($folder->name)) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error deleting calendar folder $folder->name"), true, false); } } /** * Returns all cards for a specific addressbook id. * * This method should return the following properties for each card: * * carddata - raw vcard data * * uri - Some unique url * * lastmodified - A unix timestamp * * etag - A unique etag. This must change every time the card changes. * * size - The size of the card in bytes. * * If these last two properties are provided, less time will be spent * calculating them. If they are specified, you can also ommit carddata. * This may speed up certain requests, especially with large cards. * * @param mixed $addressBookId * @return array */ public function getCards($addressBookId) { console(__METHOD__, $addressBookId); // recursively fetch contacts from all folders if ($addressBookId == '__all__') { $cards = array(); foreach ($this->sources as $id => $source) { $cards = array_merge($cards, $this->getCards($id)); } return $cards; } $groups_support = $this->useragent != 'thunderbird'; $query = array(array('type', '=', $groups_support ? array('contact','distribution-list') : 'contact')); $cards = array(); if ($storage = $this->get_storage_folder($addressBookId)) { foreach ($storage->select($query) as $contact) { $cards[] = array( 'id' => $contact['uid'], 'uri' => VObjectUtils::uid2uri($contact['uid'], '.vcf'), 'lastmodified' => is_a($contact['changed'], 'DateTime') ? $contact['changed']->format('U') : null, 'etag' => self::_get_etag($contact), 'size' => $contact['_size'], ); } } return $cards; } /** * Returns a specfic card. * * The same set of properties must be returned as with getCards. The only * exception is that 'carddata' is absolutely required. * * @param mixed $addressBookId * @param string $cardUri * @return array */ public function getCard($addressBookId, $cardUri) { console(__METHOD__, $addressBookId, $cardUri); $uid = VObjectUtils::uri2uid($cardUri, '.vcf'); // search all folders for the given card if ($addressBookId == '__all__') { $contact = $this->get_card_by_uid($uid, $storage); } // read card data from LDAP directory else if ($addressBookId == LDAPDirectory::DIRECTORY_NAME) { if (is_object($this->ldap_directory)) { $contact = $this->ldap_directory->getContactObject($uid); } } else { $storage = $this->get_storage_folder($addressBookId); $contact = $storage->get_object($uid, '*'); } if ($contact) { return array( 'id' => $contact['uid'], 'uri' => VObjectUtils::uid2uri($contact['uid'], '.vcf'), 'lastmodified' => is_a($contact['changed'], 'DateTime') ? $contact['changed']->format('U') : null, 'carddata' => $this->to_vcard($contact), 'etag' => self::_get_etag($contact), ); } return array(); } /** * Creates a new card. * * The addressbook id will be passed as the first argument. This is the * same id as it is returned from the getAddressbooksForUser method. * * The cardUri is a base uri, and doesn't include the full path. The * cardData argument is the vcard body, and is passed as a string. * * It is possible to return an ETag from this method. This ETag is for the * newly created resource, and must be enclosed with double quotes (that * is, the string itself must contain the double quotes). * * You should only return the ETag if you store the carddata as-is. If a * subsequent GET request on the same card does not have the same body, * byte-by-byte and you did return an ETag here, clients tend to get * confused. * * @param mixed $addressBookId * @param string $cardUri * @param string $cardData * @return string|null */ public function createCard($addressBookId, $cardUri, $cardData) { console(__METHOD__, $addressBookId, $cardUri, $cardData); $uid = VObjectUtils::uri2uid($cardUri, '.vcf'); $storage = $this->get_storage_folder($addressBookId); $object = $this->parse_vcard($cardData, $uid); if (empty($object) || empty($object['uid'])) { throw new DAV\Exception('Parse error: not a valid VCard object'); } // if URI doesn't match the content's UID, the object might already exist! $cardUri = VObjectUtils::uid2uri($object['uid'], '.vcf'); if ($object['uid'] != $uid && $this->getCard($addressBookId, $cardUri)) { Plugin::$redirect_basename = $cardUri; return $this->updateCard($addressBookId, $cardUri, $cardData); } $success = $storage->save($object, $object['_type']); if (!$success) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error saving contact object to Kolab server"), true, false); throw new DAV\Exception('Error saving contact card to backend'); } // send Location: header if URI doesn't match object's UID (Bug #2109) if ($object['uid'] != $uid) { Plugin::$redirect_basename = $cardUri; } // return new Etag return $success ? self::_get_etag($object) : null; } /** * Updates a card. * * The addressbook id will be passed as the first argument. This is the * same id as it is returned from the getAddressbooksForUser method. * * The cardUri is a base uri, and doesn't include the full path. The * cardData argument is the vcard body, and is passed as a string. * * It is possible to return an ETag from this method. This ETag should * match that of the updated resource, and must be enclosed with double * quotes (that is: the string itself must contain the actual quotes). * * If you don't return an ETag, you can just return null. * * @param mixed $addressBookId * @param string $cardUri * @param string $cardData * @return string|null */ public function updateCard($addressBookId, $cardUri, $cardData) { console(__METHOD__, $addressBookId, $cardUri, $cardData); $uid = VObjectUtils::uri2uid($cardUri, '.vcf'); $object = $this->parse_vcard($cardData, $uid); // sanity check if ($object['uid'] != $uid) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error creating contact object: UID doesn't match object URI"), true, false); throw new DAV\Exception\NotFound("UID doesn't match object URI"); } if ($addressBookId == '__all__') { $old = $this->get_card_by_uid($uid, $storage); } else { if ($storage = $this->get_storage_folder($addressBookId)) $old = $storage->get_object($uid); } if (!$storage) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Unable to find storage folder for contact $addressBookId/$cardUri"), true, false); throw new DAV\Exception\NotFound("Invalid address book URI"); } if (!$this->is_writeable($storage)) { throw new DAV\Exception\Forbidden('Insufficient privileges to update this card'); } // copy meta data (starting with _) from old object foreach ((array)$old as $key => $val) { if (!isset($object[$key]) && $key[0] == '_') $object[$key] = $val; } // save object $saved = $storage->save($object, $object['_type'], $uid); if (!$saved) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error saving contact object to Kolab server"), true, false); Plugin::$redirect_basename = null; throw new DAV\Exception('Error saving contact card to backend'); } // return new Etag return self::_get_etag($object); } /** * Deletes a card * * @param mixed $addressBookId * @param string $cardUri * @return bool */ public function deleteCard($addressBookId, $cardUri) { console(__METHOD__, $addressBookId, $cardUri); $uid = VObjectUtils::uri2uid($cardUri, '.vcf'); if ($addressBookId == '__all__') { $this->get_card_by_uid($uid, $storage); } else { $storage = $this->get_storage_folder($addressBookId); } if (!$storage || !$this->is_writeable($storage)) { throw new DAV\Exception\MethodNotAllowed('Insufficient privileges to delete this card'); } if ($storage) { return $storage->delete($uid); } return false; } /** * Set User-Agent string of the connected client */ public function setUserAgent($uastring) { $ua_classes = array( 'thunderbird' => 'Thunderbird/\d', 'macosx' => '(Mac OS X/.+)?AddressBook/\d(.+\sCardDAVPlugin)?', 'ios' => '(iOS/\d|[Dd]ata[Aa]ccessd/\d)', 'vcard4' => '[Vv][Cc]ard([/ ])?4', ); foreach ($ua_classes as $class => $regex) { if (preg_match("!$regex!", $uastring)) { $this->useragent = $class; break; } } } /** * Find an object and the containing folder by UID * * @param string Object UID * @param object Return parameter for the kolab_storage_folder instance * @return array|false */ private function get_card_by_uid($uid, &$storage) { $obj = kolab_storage::get_object($uid, 'contact'); if ($obj) { $storage = kolab_storage::get_folder($obj['_mailbox']); return $obj; } return false; } /** * Internal helper method to determine whether the given kolab_storage_folder is writeable * */ private function is_writeable($storage) { $rights = $storage->get_myrights(); return (strpos($rights, 'i') !== false || $storage->get_namespace() == 'personal'); } /** * Helper method to determine whether the connected client is an Apple device */ private function is_apple() { return $this->useragent == 'macosx' || $this->useragent == 'ios'; } /** * Helper method to determine whether the connected client supports VCard4 */ private function is_vcard4() { return $this->useragent == 'vcard4'; } /********** Data conversion utilities ***********/ private $phonetypes = array( 'main' => 'voice', 'homefax' => 'home,fax', 'workfax' => 'work,fax', 'mobile' => 'cell', 'other' => 'textphone', ); private $improtocols = array( 'jabber' => 'xmpp', ); /** * Parse the given VCard string into a hash array kolab_format_contact can handle * * @param string VCard data block * @return array Hash array with contact properties or null on failure */ private function parse_vcard($cardData, $uid) { try { // use already parsed object if (Plugin::$parsed_vcard && Plugin::$parsed_vcard->UID == $uid) { $vobject = Plugin::$parsed_vcard; } else { - VObject\Property::$classMap['REV'] = 'Sabre\\VObject\\Property\\DateTime'; $vobject = VObject\Reader::read($cardData, VObject\Reader::OPTION_FORGIVING | VObject\Reader::OPTION_IGNORE_INVALID_LINES); } if ($vobject && $vobject->name == 'VCARD') { $contact = $this->_to_array($vobject); if (!empty($contact['uid'])) { return $contact; } } } catch (VObject\ParseException $e) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "VCard data parse error: " . $e->getMessage()), true, false); } return null; } /** * Build a valid VCard format block from the given contact record * * @param array Hash array with contact properties from libkolab * @return string VCARD string containing the contact data */ public function to_vcard($contact) { $v4 = $this->is_vcard4(); $v4_prefix = $v4 ? '' : 'X-'; - $vc = VObject\Component::create('VCARD'); - $vc->version = $v4 ? '4.0' : '3.0'; - $vc->prodid = '-//Kolab//iRony DAV Server ' . KOLAB_DAV_VERSION . '//Sabre//Sabre VObject ' . VObject\Version::VERSION . '//EN'; + $vc = new VObject\Component\VCard(); + $vc->VERSION = $v4 ? '4.0' : '3.0'; + $vc->PRODID = '-//Kolab//iRony DAV Server ' . KOLAB_DAV_VERSION . '//Sabre//Sabre VObject ' . VObject\Version::VERSION . '//EN'; $vc->add('UID', $contact['uid']); $vc->add('FN', $contact['name']); // distlists are KIND:group if ($contact['_type'] == 'distribution-list') { // group cards are actually vcard version 4 if (!$this->is_apple()) { $vc->version = '4.0'; $prop_prefix = ''; } else { // prefix group properties for Apple $prop_prefix = 'X-ADDRESSBOOKSERVER-'; } $vc->add($prop_prefix . 'KIND', 'group'); foreach ((array)$contact['member'] as $member) { if ($member['uid']) $value = 'urn:uuid:' . $member['uid']; else if ($member['email'] && $member['name']) $value = 'mailto:' . urlencode(sprintf('"%s" <%s>', addcslashes($member['name'], '"'), $member['email'])); else if ($member['email']) $value = 'mailto:' . $member['email']; $vc->add($prop_prefix . 'MEMBER', $value); } } else if ($contact['surname'] . $contact['firstname'] . $contact['middlename'] . $contact['prefix'] . $contact['suffix'] != '') { - $n = VObject\Property::create('N'); - $n->setParts(array($contact['surname'], $contact['firstname'], $contact['middlename'], $contact['prefix'], $contact['suffix'])); + $n = $vc->create('N'); + $n->setParts(array(strval($contact['surname']), strval($contact['firstname']), strval($contact['middlename']), strval($contact['prefix']), strval($contact['suffix']))); $vc->add($n); } if (!empty($contact['nickname'])) $vc->add('NICKNAME', $contact['nickname']); if (!empty($contact['jobtitle'])) $vc->add('TITLE', $contact['jobtitle']); if (!empty($contact['profession'])) $vc->add('ROLE', $contact['profession']); if (!empty($contact['organization']) || !empty($contact['department'])) { - $org = VObject\Property::create('ORG'); + $org = $vc->create('ORG'); $org->setParts(array($contact['organization'], $contact['department'])); $vc->add($org); } // save as RELATED for VCard 4.0 if ($v4) { foreach ($this->related_map as $type => $field) { if (!empty($contact[$field])) { foreach ((array)$contact[$field] as $value) { - $vc->add(VObject\Property::create('RELATED', $value, array('type' => $type))); + $vc->add($vc->create('RELATED', $value, array('type' => $type))); } } } if (is_array($contact['related'])) { foreach ($contact['related'] as $value) { $vc->add('RELATED', $value); } } } else { foreach (array_values($this->related_map) as $field) { if (!empty($contact[$field])) { $vc->add(strtoupper('X-' . $field), join(',', (array)$contact[$field])); } } } foreach ((array)$contact['email'] as $email) { $types = array('INTERNET'); if (!empty($email['type'])) $types = array_merge($types, explode(',', strtoupper($email['type']))); $vc->add('EMAIL', $email['address'], array('type' => $types)); } foreach ((array)$contact['phone'] as $phone) { $type = $this->phonetypes[$phone['type']] ?: $phone['type']; $params = !empty($type) ? array('type' => explode(',', strtoupper($type))) : array(); $vc->add('TEL', $phone['number'], $params); } foreach ((array)$contact['website'] as $website) { $params = !empty($website['type']) ? array('type' => explode(',', strtoupper($website['type']))) : array(); $vc->add('URL', $website['url'], $params); } $improtocolmap = array_flip($this->improtocols); foreach ((array)$contact['im'] as $im) { list($prot, $val) = explode(':', $im, 2); if ($val) $vc->add('x-' . ($improtocolmap[$prot] ?: $prot), $val); else $vc->add('IMPP', $im); } foreach ((array)$contact['address'] as $adr) { $params = !empty($adr['type']) ? array('type' => strtoupper($adr['type'])) : array(); - $vadr = VObject\Property::create('ADR', null, $params); + $vadr = $vc->create('ADR', null, $params); $vadr->setParts(array('','', $adr['street'], $adr['locality'], $adr['region'], $adr['code'], $adr['country'])); $vc->add($vadr); } if (!empty($contact['notes'])) $vc->add('NOTE', $contact['notes']); if (!empty($contact['gender'])) $vc->add($this->is_apple() ? 'SEX' : $v4_prefix . 'GENDER', $contact['gender']); // convert date cols to DateTime objects foreach (array('birthday','anniversary') as $key) { if (!empty($contact[$key]) && !$contact[$key] instanceof \DateTime) { try { $contact[$key] = new \DateTime(\rcube_utils::clean_datestr($contact[$key])); } catch (\Exception $e) { $contact[$key] = null; } } } if (!empty($contact['birthday']) && $contact['birthday'] instanceof \DateTime) { // FIXME: Date values are ignored by Thunderbird $contact['birthday']->_dateonly = true; - $vc->add(VObjectUtils::datetime_prop('BDAY', $contact['birthday'], false)); + $vc->add(VObjectUtils::datetime_prop($vc, 'BDAY', $contact['birthday'], false)); } if (!empty($contact['anniversary']) && $contact['anniversary'] instanceof \DateTime) { $contact['anniversary']->_dateonly = true; - $vc->add(VObjectUtils::datetime_prop($v4_prefix . 'ANNIVERSARY', $contact['anniversary'], false)); + $vc->add(VObjectUtils::datetime_prop($vc, $v4_prefix . 'ANNIVERSARY', $contact['anniversary'], false)); } if (!empty($contact['categories'])) { - $cat = VObject\Property::create('CATEGORIES'); + $cat = $vc->create('CATEGORIES'); $cat->setParts((array)$contact['categories']); $vc->add($cat); } if (!empty($contact['freebusyurl'])) { $vc->add('FBURL', $contact['freebusyurl']); } if (is_array($contact['lang'])) { foreach ($contact['lang'] as $value) { $vc->add('LANG', $value); } } if (!empty($contact['photo'])) { - $vc->PHOTO = base64_encode($contact['photo']); - $vc->PHOTO->add('BASE64', null); + $vc->PHOTO = $contact['photo']; + $vc->PHOTO['ENCODING'] = 'B'; + // $vc->PHOTO['TYPE'] = 'GIF'; + // if ($v4) convert to uri; See VCardConverter::convertBinaryToUri() } // add custom properties foreach ((array)$contact['x-custom'] as $prop) { $vc->add($prop[0], $prop[1]); } // send some known fields as itemN.X-AB* for Apple clients if ($this->is_apple()) { $this->_to_apple($contact, $vc); } - if (!empty($contact['changed'])) - $vc->add(VObjectUtils::datetime_prop('REV', $contact['changed'], true)); + if (!empty($contact['changed']) && is_a($contact['changed'], 'DateTime')) { + $vc->REV = $contact['changed']->format('Ymd\\THis\\Z'); + } + + // convert to VCard4.0 + // if ($v4) { + // $vc->convert(VObject\Document::VCARD40); + //} return $vc->serialize(); } /** * Convert the given Sabre\VObject\Component\Vcard object to a libkolab compatible contact format * * @param object Vcard object to convert * @return array Hash array with contact properties */ private function _to_array($vc) { $contact = array( '_type' => 'contact', 'uid' => strval($vc->UID), 'name' => strval($vc->FN), 'x-custom' => array(), ); if ($vc->REV) { - try { $contact['changed'] = $vc->REV->getDateTime(); } + try { + $contact['changed'] = DateTimeParser::parseDateTime(strval($vc->REV)); + } catch (\Exception $e) { - try { $contact['changed'] = new \DateTime(strval($vc->REV)); } - catch (\Exception $e) { } + // ignore } } // normalize apple-style properties $this->_from_apple($vc); $phonetypemap = array_flip($this->phonetypes); $phonetypemap['fax,home'] = 'homefax'; $phonetypemap['fax,work'] = 'workfax'; // map attributes to internal fields foreach ($vc->children as $prop) { if (!($prop instanceof VObject\Property)) continue; + $value = strval($prop); + switch ($prop->name) { case 'N': list($contact['surname'], $contact['firstname'], $contact['middlename'], $contact['prefix'], $contact['suffix']) = $prop->getParts(); break; case 'NOTE': - $contact['notes'] = $prop->value; + $contact['notes'] = $value; break; case 'TITLE': - $contact['jobtitle'] = $prop->value; + $contact['jobtitle'] = $value; break; case 'NICKNAME': - $contact[strtolower($prop->name)] = $prop->value; + $contact[strtolower($prop->name)] = $value; break; case 'ORG': list($contact['organization'], $contact['department']) = $prop->getParts(); break; case 'CATEGORY': case 'CATEGORIES': $contact['categories'] = $prop->getParts(); break; case 'EMAIL': $types = array_values(self::prop_filter($prop->offsetGet('type'), 'internet,pref', true)); - $contact['email'][] = array('address' => $prop->value, 'type' => strtolower($types[0] ?: 'other')); + $contact['email'][] = array('address' => $value, 'type' => strtolower($types[0] ?: 'other')); break; case 'URL': $types = array_values(self::prop_filter($prop->offsetGet('type'), 'internet,pref', true)); - $contact['website'][] = array('url' => $prop->value, 'type' => strtolower($types[0])); + $contact['website'][] = array('url' => $value, 'type' => strtolower($types[0])); break; case 'TEL': $types = array_values(self::prop_filter($prop->offsetGet('type'), 'voice,pref', true)); $types_ = strtolower(join(',', $types)); $type = isset($phonetypemap[$types_]) ? $types_ : strtolower($types[0]); - $contact['phone'][] = array('number' => $prop->value, 'type' => $phonetypemap[$type] ?: $type); + $contact['phone'][] = array('number' => $value, 'type' => $phonetypemap[$type] ?: $type); break; case 'ADR': $types = array_values(self::prop_filter($prop->offsetGet('type'), 'pref', true)); $adr = array('type' => strtolower(!empty($types) ? strval($types[0]) : $prop->parameters[0]->name)); list(,, $adr['street'], $adr['locality'], $adr['region'], $adr['code'], $adr['country']) = $prop->getParts(); $contact['address'][] = $adr; break; case 'BDAY': - $contact['birthday'] = new \DateTime($prop->value); + $contact['birthday'] = new \DateTime($value); $contact['birthday']->_dateonly = true; break; case 'ANNIVERSARY': case 'X-ANNIVERSARY': - $contact['anniversary'] = new \DateTime($prop->value); + $contact['anniversary'] = new \DateTime($value); $contact['anniversary']->_dateonly = true; break; case 'SEX': case 'GENDER': case 'X-GENDER': - $contact['gender'] = $prop->value; + $contact['gender'] = $value; break; case 'ROLE': case 'X-PROFESSION': - $contact['profession'] = $prop->value; + $contact['profession'] = $value; break; case 'X-MANAGER': case 'X-ASSISTANT': case 'X-CHILDREN': case 'X-SPOUSE': - $contact[strtolower(substr($prop->name, 2))] = explode(',', $prop->value); + $contact[strtolower(substr($prop->name, 2))] = explode(',', $value); break; case 'X-JABBER': case 'X-ICQ': case 'X-MSN': case 'X-AIM': case 'X-YAHOO': case 'X-SKYPE': $protocol = strtolower(substr($prop->name, 2)); - $contact['im'][] = ($this->improtocols[$protocol] ?: $protocol) . ':' . preg_replace('/^[a-z]+:/i', '', $prop->value); + $contact['im'][] = ($this->improtocols[$protocol] ?: $protocol) . ':' . preg_replace('/^[a-z]+:/i', '', $value); break; case 'IMPP': $prot = null; - if (preg_match('/^[a-z]+:/i', $prop->value)) - list($prot, $val) = explode(':', $prop->value, 2); + if (preg_match('/^[a-z]+:/i', $value)) + list($prot, $val) = explode(':', $value, 2); else - $val = $prop->value; + $val = $value; $type = strtolower((string)$prop->offsetGet('X-SERVICE-TYPE')); $protocol = $type && (!$prot || $prot == 'aim') ? ($this->improtocols[$type] ?: $type) : $prot; $contact['im'][] = ($this->improtocols[$protocol] ?: $protocol) . ':' . urldecode($val); break; case 'PHOTO': $param = $prop->offsetGet('encoding') ?: $prop->parameters[0]; - if ($param->value && (strtolower($param->value) == 'b' || strtolower($param->value) == 'base64') || strtolower($param->name) == 'base64') { - $contact['photo'] = base64_decode($prop->value); + if (($pvalue = $param->getValue()) && (strtolower($pvalue) == 'b' || strtolower($pvalue) == 'base64') || strtolower($param->name) == 'base64') { + $contact['photo'] = $value; } break; // VCard 4.0 properties case 'FBURL': - $contact['freebusyurl'] = $prop->value; + $contact['freebusyurl'] = $value; break; case 'LANG': - $contact['lang'][] = $prop->value; + $contact['lang'][] = $value; break; case 'RELATED': $type = strtolower($prop->offsetGet('type')); if ($field = $this->related_map[$type]) { - $contact[$field][] = $prop->value; + $contact[$field][] = $value; } else { - $contact['related'][] = $prop->value; + $contact['related'][] = $value; } break; case 'KIND': case 'X-ADDRESSBOOKSERVER-KIND': - if (strtolower($prop->value) == 'group') { + if (strtolower($value) == 'group') { $contact['_type'] = 'distribution-list'; } break; case 'MEMBER': case 'X-ADDRESSBOOKSERVER-MEMBER': - if (strpos($prop->value, 'urn:uuid:') === 0) { - $contact['member'][] = array('uid' => substr($prop->value, 9)); + if (strpos($value, 'urn:uuid:') === 0) { + $contact['member'][] = array('uid' => substr($value, 9)); } - else if (strpos($prop->value, 'mailto:') === 0) { - $member = reset(\rcube_mime::decode_address_list(urldecode(substr($prop->value, 7)))); + else if (strpos($value, 'mailto:') === 0) { + $member = reset(\rcube_mime::decode_address_list(urldecode(substr($value, 7)))); if ($member['mailto']) $contact['member'][] = array('email' => $member['mailto'], 'name' => $member['name']); } break; // custom properties case 'CUSTOM1': case 'CUSTOM2': case 'CUSTOM3': case 'CUSTOM4': default: if (substr($prop->name, 0, 2) == 'X-' || substr($prop->name, 0, 6) == 'CUSTOM') { $prefix = $prop->group ? $prop->group . '.' : ''; - $contact['x-custom'][] = array($prefix . $prop->name, strval($prop->value)); + $contact['x-custom'][] = array($prefix . $prop->name, strval($value)); } break; } } if (is_array($contact['im'])) $contact['im'] = array_unique($contact['im']); return $contact; } /** * Convert Apple-style item1.X-AB* properties to flat X-AB*-Label values */ private function _from_apple($vc) { foreach ($this->xab_labelled_map as $propname => $known_map) { foreach ($vc->select($propname) as $prop) { $labelkey = $prop->group ? $prop->group . '.X-ABLABEL' : 'X-ABLABEL'; $labels = $vc->select($labelkey); - $field = !empty($labels) && ($label = reset($labels)) ? strtolower(trim($label->value, '_$!<>')) : null; + $field = !empty($labels) && ($label = reset($labels)) ? strtolower(trim(strval($label), '_$!<>')) : null; if ($field) { $prop->group = null; $prop->name = ($known_map[$field] ?: $propname . '-' . strtoupper($field)); unset($vc->{$labelkey}); } } // must be an apple client :-) $this->useragent = 'macosx'; } } /** * Translate custom fields back to Apple-style item1.X-AB* properties */ private function _to_apple($contact, $vc) { $this->item_count = 1; foreach ($this->xab_labelled_map as $propname => $known_map) { // convert known vcard properties into labelled ones foreach (array_flip($known_map) as $name => $label) { if ($vc->{$name}) { $this->_replace_with_labelled_prop($vc, $name, $propname, $label); } } // translate custom properties with a matching prefix to labelled items foreach ((array)$contact['x-custom'] as $prop) { $name = $prop[0]; if (strpos($name, $propname) === 0) { $label = strtolower(substr($name, strlen($propname)+1)); $this->_replace_with_labelled_prop($vc, $name, $propname, $label); } } } } /** * Helper method to replace a named property with a labelled one */ private function _replace_with_labelled_prop($vc, $name, $propname, $label) { $group = 'item' . ($this->item_count++); $prop = clone $vc->{$name}; $prop->name = $propname; $prop->group = $group; $vc->add($prop); - $ablabel = new VObject\Property('X-ABLabel'); - $ablabel->name = 'X-ABLabel'; + $ablabel = $vc->create('X-ABLabel'); $ablabel->group = $group; - $ablabel->value = in_array($label, $this->xab_known_labels) ? '_$!<'.ucfirst($label).'>!$_' : ucfirst($label); + $ablabel->setValue(in_array($label, $this->xab_known_labels) ? '_$!<'.ucfirst($label).'>!$_' : ucfirst($label)); $vc->add($ablabel); unset($vc->{$name}); } /** * Extract array values by a filter * * @param array Array to filter * @param keys Array or comma separated list of values to keep * @param boolean Invert key selection: remove the listed values * * @return array The filtered array */ private static function prop_filter($arr, $values, $inverse = false) { if (!is_array($values)) { $values = explode(',', $values); } // explode single, comma-separated value if (count($arr) == 1 && strpos($arr[0], ',')) { $arr = explode(',', $arr[0]); } $result = array(); $keep = array_flip((array)$values); if (!empty($arr)) { foreach ($arr as $key => $val) { if ($inverse != isset($keep[strtolower($val)])) { $result[$key] = $val; } } } return $result; } /** * Generate an Etag string from the given contact data * * @param array Hash array with contact properties from libkolab * @return string Etag string */ private static function _get_etag($contact) { return sprintf('"%s-%d"', substr(md5($contact['uid']), 0, 16), $contact['_msguid']); } } diff --git a/lib/Kolab/CardDAV/LDAPDirectory.php b/lib/Kolab/CardDAV/LDAPDirectory.php index 4030e11..7968ab9 100644 --- a/lib/Kolab/CardDAV/LDAPDirectory.php +++ b/lib/Kolab/CardDAV/LDAPDirectory.php @@ -1,578 +1,577 @@ * * Copyright (C) 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\CardDAV; use \rcube; use \rcube_ldap; use \rcube_ldap_generic; use Sabre\DAV; use Sabre\DAVACL; use Sabre\CardDAV\Property; use Kolab\Utils\VObjectUtils; /** * CardDAV Directory Gateway implementation */ class LDAPDirectory extends DAV\Collection implements \Sabre\CardDAV\IDirectory, DAV\IProperties, DAVACL\IACL { const DIRECTORY_NAME = 'ldap-directory'; private $config; private $ldap; private $carddavBackend; private $principalUri; private $addressBookInfo = array(); private $cache; private $query; private $filter; /** * Default constructor */ function __construct($config, $principalUri, $carddavBackend = null) { $this->config = $config; $this->principalUri = $principalUri; $this->addressBookInfo = array( 'id' => self::DIRECTORY_NAME, 'uri' => self::DIRECTORY_NAME, '{DAV:}displayname' => $config['name'] ?: "LDAP Directory", '{urn:ietf:params:xml:ns:carddav}supported-address-data' => new Property\SupportedAddressData(), 'principaluri' => $principalUri, ); // used for vcard serialization $this->carddavBackend = $carddavBackend ?: new ContactsBackend(); $this->carddavBackend->ldap_directory = $this; // initialize cache $rcube = rcube::get_instance(); if ($rcube->config->get('kolabdav_ldap_cache')) { $this->cache = $rcube->get_cache_shared('kolabdav_ldap'); // expunge cache every now and then if (rand(0,10) === 0) { $this->cache->expunge(); } } } private function connect() { if (!isset($this->ldap)) { $this->ldap = new rcube_ldap($this->config, $this->config['debug']); $this->ldap->set_pagesize($this->config['sizelimit'] ?: 10000); } return $this->ldap->ready ? $this->ldap : null; } /** * Set parsed addressbook-query object for filtering */ function setAddressbookQuery($query) { $this->query = $query; $this->filter = $this->addressbook_query2ldap_filter($query); } /** * Returns the name of the node. * * This is used to generate the url. * * @return string */ function getName() { return self::DIRECTORY_NAME; } /** * Returns a specific child node, referenced by its name * * This method must throw Sabre\DAV\Exception\NotFound if the node does not * exist. * * @param string $name * @return DAV\INode */ function getChild($cardUri) { console(__METHOD__, $cardUri); $uid = VObjectUtils::uri2uid($cardUri, '.vcf'); $record = null; // get from cache $cache_key = $uid; if ($this->cache && ($cached = $this->cache->get($cache_key))) { return new LDAPCard($this->carddavBackend, $this->addressBookInfo, $cached); } if ($contact = $this->getContactObject($uid)) { $obj = array( 'id' => $contact['uid'], 'uri' => VObjectUtils::uid2uri($contact['uid'], '.vcf'), 'lastmodified' => $contact['_timestamp'], 'carddata' => $this->carddavBackend->to_vcard($contact), 'etag' => self::_get_etag($contact), ); // cache this object if ($this->cache) { $this->cache->set($cache_key, $obj); } return new LDAPCard($this->carddavBackend, $this->addressBookInfo, $obj); } throw new DAV\Exception\NotFound('Card not found'); } /** * Read contact object from LDAP */ function getContactObject($uid) { $contact = null; if ($ldap = $this->connect()) { $ldap->reset(); // used cached uid mapping $cached_index = $this->cache ? $this->cache->get('index') : array(); if ($cached_index[$uid]) { $contact = $ldap->get_record($cached_index[$uid][0], true); } else { // query for uid $result = $ldap->search('uid', $uid, 1, true, true); if ($result->count) { $contact = $result[0]; } } if ($contact) { $this->_normalize_contact($contact); } } return $contact; } /** * Returns an array with all the child nodes * * @return DAV\INode[] */ function getChildren() { console(__METHOD__, $this->query, $this->filter); $children = array(); // return cached index if (!$this->query && !$this->config['searchonly'] && $this->cache && ($cached_index = $this->cache->get('index'))) { foreach ($cached_index as $uid => $c) { $obj = array( 'id' => $uid, 'uri' => VObjectUtils::uid2uri($uid, '.vcf'), 'etag' => $c[1], 'lastmodified' => $c[2], ); $children[] = new LDAPCard($this->carddavBackend, $this->addressBookInfo, $obj); } return $children; } // query LDAP if we have a search query or listing is allowed if (($this->query || !$this->config['searchonly']) && ($ldap = $this->connect())) { // set pagesize from query limit attribute if ($this->query && $this->query->limit) { $this->ldap->set_pagesize(intval($this->query->limit)); } // set the prepared LDAP filter derived from the addressbook-query if ($this->query && !empty($this->filter)) { $ldap->set_search_set($this->filter); } else { $ldap->set_search_set(null); } $results = $ldap->list_records(null); $directory_index = array(); // convert results into vcard blocks foreach ($results as $contact) { $this->_normalize_contact($contact); $obj = array( 'id' => $contact['uid'], 'uri' => VObjectUtils::uid2uri($contact['uid'], '.vcf'), 'lastmodified' => $contact['_timestamp'], 'carddata' => $this->carddavBackend->to_vcard($contact), 'etag' => self::_get_etag($contact), ); // cache record $cache_key = $contact['uid']; if ($this->cache) { $this->cache->set($cache_key, $obj); } $directory_index[$contact['uid']] = array($contact['ID'], $obj['etag'], $contact['_timestamp']); // add CardDAV node $children[] = new LDAPCard($this->carddavBackend, $this->addressBookInfo, $obj); } // cache the full listing if (empty($this->filter) && $this->cache) { $this->cache->set('index', $directory_index); } } return $children; } /** * Returns a list of properties for this node. * * The properties list is a list of propertynames the client requested, * encoded in clark-notation {xmlnamespace}tagname * * If the array is empty, it means 'all properties' were requested. * * @param array $properties * @return array */ public function getProperties($properties) { console(__METHOD__, $properties); $response = array(); foreach ($properties as $propertyName) { if (isset($this->addressBookInfo[$propertyName])) { $response[$propertyName] = $this->addressBookInfo[$propertyName]; } else if ($propertyName == '{DAV:}getlastmodified') { $response[$propertyName] = new DAV\Property\GetLastModified($this->getLastModified()); } } return $response; } /** * Returns the last modification time, as a unix timestamp * * @return int */ function getLastModified() { console(__METHOD__); return time(); } /** * Deletes the entire addressbook. * * @return void */ public function delete() { throw new DAV\Exception\MethodNotAllowed('Deleting directories is not allowed'); } /** * Renames the addressbook * * @param string $newName * @return void */ public function setName($newName) { throw new DAV\Exception\MethodNotAllowed('Renaming directories not allowed'); } /** * Returns the owner principal * * This must be a url to a principal, or null if there's no owner * * @return string|null */ public function getOwner() { return $this->principalUri; } /** * Returns a group principal * * This must be a url to a principal, or null if there's no owner * * @return string|null */ function getGroup() { return null; } /** * Returns a list of ACE's for this node. * * Each ACE has the following properties: * * 'privilege', a string such as {DAV:}read or {DAV:}write * * 'principal', a url to the principal who owns the node * * 'protected' (optional), indicating that this ACE is not allowed to be updated. * * @return array */ public function getACL() { $acl = array( array( 'privilege' => '{DAV:}read', 'principal' => $this->principalUri, 'protected' => true, ), ); return $acl; } /** * Updates the ACL * * @param array $acl * @return void */ function setACL(array $acl) { throw new DAV\Exception\MethodNotAllowed('Changing ACL for directories is not allowed'); } /** * Returns the list of supported privileges for this node. * * If null is returned from this method, the default privilege set is used, * which is fine for most common usecases. * * @return array|null */ function getSupportedPrivilegeSet() { return null; } /** - * Updates properties on this node, + * Updates properties on this node. * - * @param array $mutations - * @return bool|array + * @param PropPatch $propPatch + * @return void */ - function updateProperties($mutations) + public function propPatch(DAV\PropPatch $propPatch) { - console(__METHOD__, $mutations); - return false; + console(__METHOD__, $propPatch); + // NOP } - /** * Post-process the given contact record from rcube_ldap */ private function _normalize_contact(&$contact) { if (is_numeric($contact['changed'])) { $contact['_timestamp'] = intval($contact['changed']); $contact['changed'] = new \DateTime('@' . $contact['changed']); } else if (!empty($contact['changed'])) { try { $contact['changed'] = new \DateTime($contact['changed']); $contact['_timestamp'] = intval($contact['changed']->format('U')); } catch (Exception $e) { $contact['changed'] = null; } } // map col:subtype fields to a list that the vcard serialization function understands foreach (array('email' => 'address', 'phone' => 'number', 'website' => 'url') as $col => $prop) { foreach (rcube_ldap::get_col_values($col, $contact) as $type => $values) { foreach ((array)$values as $value) { $contact[$col][] = array($prop => $value, 'type' => $type); } } unset($contact[$col.':'.$type]); } $addresses = array(); foreach (rcube_ldap::get_col_values('address', $contact) as $type => $values) { foreach ((array)$values as $adr) { // skip empty address $adr = array_filter($adr); if (empty($adr)) continue; $addresses[] = array( 'type' => $type, 'street' => $adr['street'], 'locality' => $adr['locality'], 'code' => $adr['zipcode'], 'region' => $adr['region'], 'country' => $adr['country'], ); } unset($contact['address:'.$type]); } $contact['address'] = $addresses; } /** * Translate the given AddressBookQueryParser object into an LDAP filter */ private function addressbook_query2ldap_filter($query) { $criterias = array(); foreach ($query->filters as $filter) { $ldap_attrs = $this->map_property2ldap($filter['name']); $ldap_filter = ''; $count = 0; // unknown attribute, skip if (empty($ldap_attrs)) { continue; } foreach ((array)$filter['text-matches'] as $matcher) { // case-insensitive matching if (in_array($matcher['collation'], array('i;unicode-casemap', 'i;ascii-casemap'))) { $matcher['value'] = mb_strtolower($matcher['value']); } $value = rcube_ldap_generic::quote_string($matcher['value']); $ldap_match = ''; // this assumes fuzzy search capabilities of the LDAP backend switch ($matcher['match-type']) { case 'contains': $wp = $ws = '*'; break; case 'starts-with': $ws = '*'; break; case 'ends-with': $wp = '*'; break; default: $wp = $ws = ''; } // OR query for all attributes involved if (count($ldap_attrs) > 1) { $ldap_match .= '(|'; } foreach ($ldap_attrs as $attr) { $ldap_match .= "($attr=$wp$value$ws)"; } if (count($ldap_attrs) > 1) { $ldap_match .= ')'; } // negate the filter if ($matcher['negate-condition']) { $ldap_match = '(!' . $ldap_match . ')'; } $ldap_filter .= $ldap_match; $count++; } if ($count > 1) { $criterias[] = '(' . ($filter['test'] == 'allof' ? '&' : '|') . $ldap_filter . ')'; } else if (!empty($ldap_filter)) { $criterias[] = $ldap_filter; } } return empty($criterias) ? '' : sprintf('(%s%s)', $query->test == 'allof' ? '&' : '|', join('', $criterias)); } /** * Map a vcard property to an LDAP attribute */ private function map_property2ldap($propname) { $attribs = array(); // LDAP backend not available, abort if (!($ldap = $this->connect())) { return $attribs; } $vcard_fieldmap = array( 'FN' => array('name'), 'N' => array('surname','firstname','middlename'), 'ADR' => array('street','locality','region','code','country'), 'TITLE' => array('jobtitle'), 'ORG' => array('organization','department'), 'TEL' => array('phone'), 'URL' => array('website'), 'ROLE' => array('profession'), 'BDAY' => array('birthday'), 'IMPP' => array('im'), ); $fields = $vcard_fieldmap[$propname] ?: array(strtolower($propname)); foreach ($fields as $field) { if ($ldap->coltypes[$field]) { $attribs = array_merge($attribs, (array)$ldap->coltypes[$field]['attributes']); } } return $attribs; } /** * Generate an Etag string from the given contact data * * @param array Hash array with contact properties from libkolab * @return string Etag string */ private static function _get_etag($contact) { return sprintf('"%s-%d"', substr(md5($contact['uid']), 0, 16), $contact['_timestamp']); } } diff --git a/lib/Kolab/CardDAV/Plugin.php b/lib/Kolab/CardDAV/Plugin.php index 1441d29..053fee0 100644 --- a/lib/Kolab/CardDAV/Plugin.php +++ b/lib/Kolab/CardDAV/Plugin.php @@ -1,204 +1,225 @@ * * Copyright (C) 2013, 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\CardDAV; use Sabre\DAV; use Sabre\DAVACL; use Sabre\CardDAV; use Sabre\VObject; /** * Extended CardDAV plugin to tweak data validation */ class Plugin extends CardDAV\Plugin { // make already parsed vcard blocks available for later use public static $parsed_vcard; // allow the backend to force a redirect Location public static $redirect_basename; /** * Initializes the plugin * * @param DAV\Server $server * @return void */ public function initialize(DAV\Server $server) { parent::initialize($server); - $server->subscribeEvent('beforeMethod', array($this, 'beforeMethod')); - $server->subscribeEvent('afterCreateFile', array($this, 'afterWriteContent')); - $server->subscribeEvent('afterWriteContent', array($this, 'afterWriteContent')); + $server->on('beforeMethod', array($this, 'beforeMethod')); + $server->on('afterCreateFile', array($this, 'afterWriteContent')); + $server->on('afterWriteContent', array($this, 'afterWriteContent')); } /** * Adds all CardDAV-specific properties * - * @param string $path + * @param DAV\PropFind $propFind * @param DAV\INode $node - * @param array $requestedProperties - * @param array $returnedProperties * @return void */ - public function beforeGetProperties($path, DAV\INode $node, array &$requestedProperties, array &$returnedProperties) + public function propFindEarly(DAV\PropFind $propFind, DAV\INode $node) { // publish global ldap address book for this principal if ($node instanceof DAVACL\IPrincipal && empty($this->directories) && \rcube::get_instance()->config->get('kolabdav_ldap_directory')) { $this->directories[] = self::ADDRESSBOOK_ROOT . '/' . $node->getName() . '/' . LDAPDirectory::DIRECTORY_NAME; } - parent::beforeGetProperties($path, $node, $requestedProperties, $returnedProperties); + parent::propFindEarly($propFind, $node); } /** * Handler for beforeMethod events */ public function beforeMethod($method, $uri) { if ($method == 'PUT' && $this->server->httpRequest->getHeader('If-None-Match') == '*') { // In-None-Match: * is only valid with PUT requests creating a new resource. // SOGo Conenctor for Thunderbird also sends it with update requests which then fail // in the Server::checkPreconditions(). // See https://issues.kolab.org/show_bug.cgi?id=2589 and http://www.sogo.nu/bugs/view.php?id=1624 // This is a work-around for the buggy SOGo connector and should be removed once fixed. if (strpos($this->server->httpRequest->getHeader('User-Agent'), 'Thunderbird/') > 0) { unset($_SERVER['HTTP_IF_NONE_MATCH']); } } } /** * Inject some additional HTTP response headers */ public function afterWriteContent($uri, $node) { // send Location: header to corrected URI if (self::$redirect_basename) { $path = explode('/', $uri); array_pop($path); array_push($path, self::$redirect_basename); $this->server->httpResponse->setHeader('Location', $this->server->getBaseUri() . join('/', array_map('urlencode', $path))); self::$redirect_basename = null; } } /** * Checks if the submitted iCalendar data is in fact, valid. * * An exception is thrown if it's not. * * @param resource|string $data * @return void */ protected function validateVCard(&$data) { // If it's a stream, we convert it to a string first. if (is_resource($data)) { $data = stream_get_contents($data); } // Converting the data to unicode, if needed. $data = DAV\StringUtil::ensureUTF8($data); try { - VObject\Property::$classMap['REV'] = 'Sabre\\VObject\\Property\\DateTime'; $vobj = VObject\Reader::read($data, VObject\Reader::OPTION_FORGIVING | VObject\Reader::OPTION_IGNORE_INVALID_LINES); if ($vobj->name == 'VCARD') $this->parsed_vcard = $vobj; } catch (VObject\ParseException $e) { throw new DAV\Exception\UnsupportedMediaType('This resource only supports valid vcard data. Parse error: ' . $e->getMessage()); } if ($vobj->name !== 'VCARD') { throw new DAV\Exception\UnsupportedMediaType('This collection can only support vcard objects.'); } if (!isset($vobj->UID)) { throw new DAV\Exception\BadRequest('Every vcard must have a UID.'); } } + /** + * Converts a vcard blob to a different version, or jcard. + * + * (optimized version that skips parsing and re-serialization if possible) + * + * @param string $data + * @param string $target + * @return string + */ + protected function convertVCard($data, $target) + { + $version = 'vcard3'; + if (is_string($data) && preg_match('/VERSION:(\d)/', $data, $m)) { + $version = 'vcard' . $m[1]; + } + + // no conversion needed + if ($target == $version) { + return $data; + } + + return parent::convertVCard($data, $target); + } + /** * This function handles the addressbook-query REPORT * * This report is used by the client to filter an addressbook based on a * complex query. * * @param \DOMNode $dom * @return void */ protected function addressbookQueryReport($dom) { $node = $this->server->tree->getNodeForPath(($uri = $this->server->getRequestUri())); console(__METHOD__, $uri); // fix some bogus parameters in queries sent by the SOGo connector. // issue submitted in http://www.sogo.nu/bugs/view.php?id=2655 $xpath = new \DOMXPath($dom); $xpath->registerNameSpace('card', Plugin::NS_CARDDAV); $filters = $xpath->query('/card:addressbook-query/card:filter'); if ($filters->length === 1) { $filter = $filters->item(0); $propFilters = $xpath->query('card:prop-filter', $filter); for ($ii=0; $ii < $propFilters->length; $ii++) { $propFilter = $propFilters->item($ii); $name = $propFilter->getAttribute('name'); // attribute 'mail' => EMAIL if ($name == 'mail') { $propFilter->setAttribute('name', 'EMAIL'); } $textMatches = $xpath->query('card:text-match', $propFilter); for ($jj=0; $jj < $textMatches->length; $jj++) { $textMatch = $textMatches->item($jj); $collation = $textMatch->getAttribute('collation'); // 'i;unicasemap' is a non-standard collation if ($collation == 'i;unicasemap') { $textMatch->setAttribute('collation', 'i;unicode-casemap'); } } } } // query on LDAP node: pass along filter query if ($node instanceof LDAPDirectory) { $query = new CardDAV\AddressBookQueryParser($dom); $query->parse(); // set query and ... $node->setAddressbookQuery($query); } // ... proceed with default action parent::addressbookQueryReport($dom); } } \ No newline at end of file diff --git a/lib/Kolab/CardDAV/UserAddressBooks.php b/lib/Kolab/CardDAV/UserAddressBooks.php index 6beb78a..56f76f0 100644 --- a/lib/Kolab/CardDAV/UserAddressBooks.php +++ b/lib/Kolab/CardDAV/UserAddressBooks.php @@ -1,136 +1,131 @@ * * Copyright (C) 2013, 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\CardDAV; use \rcube; use Sabre\DAV; use Sabre\DAVACL; use Sabre\CardDAV; /** * UserAddressBooks class * * The UserAddressBooks collection contains a list of addressbooks associated with a user */ class UserAddressBooks extends \Sabre\CardDAV\UserAddressBooks implements DAV\IExtendedCollection, DAV\IProperties, DAVACL\IACL { // pseudo-singleton instance private $ldap_directory; /** * Returns a list of addressbooks * * @return array */ public function getChildren() { $addressbooks = $this->carddavBackend->getAddressbooksForUser($this->principalUri); $objs = array(); foreach($addressbooks as $addressbook) { $objs[] = new AddressBook($this->carddavBackend, $addressbook); } if (rcube::get_instance()->config->get('kolabdav_ldap_directory')) { $objs[] = $this->getLDAPDirectory(); } return $objs; } /** * Returns a single addressbook, by name * * @param string $name * @return \AddressBook */ public function getChild($name) { if ($name == LDAPDirectory::DIRECTORY_NAME) { return $this->getLDAPDirectory(); } if ($addressbook = $this->carddavBackend->getAddressBookByName($name)) { $addressbook['principaluri'] = $this->principalUri; return new AddressBook($this->carddavBackend, $addressbook); } throw new DAV\Exception\NotFound('Addressbook with name \'' . $name . '\' could not be found'); } /** * Getter for the singleton instance of the LDAP directory */ private function getLDAPDirectory() { if (!$this->ldap_directory) { $rcube = rcube::get_instance(); $config = $rcube->config->get('kolabdav_ldap_directory'); $config['debug'] = $rcube->config->get('ldap_debug'); $this->ldap_directory = new LDAPDirectory($config, $this->principalUri, $this->carddavBackend); } return $this->ldap_directory; } /** * Returns the list of properties * * @param array $requestedProperties * @return array */ public function getProperties($requestedProperties) { // console(__METHOD__, $requestedProperties); $response = array(); foreach ($requestedProperties as $prop) { switch($prop) { case '{urn:ietf:params:xml:ns:carddav}supported-address-data': $response[$prop] = new CardDAV\Property\SupportedAddressData(ContactsBackend::$supported_address_data); break; } } return $response; } /** - * Updates properties such as the display name and description + * Updates properties on this node. * - * @param array $mutations - * @return array + * @param PropPatch $propPatch + * @return void */ - public function updateProperties($mutations) + public function propPatch(DAV\PropPatch $propPatch) { - $errors = array(); - - foreach ($mutations as $prop => $val) { - $errors[403][$prop] = null; - } - - return $errors; + console(__METHOD__, $propPatch); + // NOP } } diff --git a/lib/Kolab/DAV/Auth/HTTPBasic.php b/lib/Kolab/DAV/Auth/HTTPBasic.php index f152cc8..bb9d9a2 100644 --- a/lib/Kolab/DAV/Auth/HTTPBasic.php +++ b/lib/Kolab/DAV/Auth/HTTPBasic.php @@ -1,242 +1,247 @@ * * Copyright (C) 2013, 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\DAV\Auth; use \rcube; use \rcube_imap_generic; use \rcube_user; use \rcube_utils; use Sabre\DAV; /** * */ class HTTPBasic extends DAV\Auth\Backend\AbstractBasic { // Make the current user name available to all classes public static $current_user = null; public static $current_pass = null; /** * Validates a username and password * * This method should return true or false depending on if login * succeeded. * * @param string $username * @param string $password * @return bool */ protected function validateUserPass($username, $password) { $rcube = rcube::get_instance(); $host = $this->_select_host($username); // use shared cache for kolab_auth plugin result (username canonification) $cache = $rcube->get_cache_shared('kolabdav_auth'); $cache_key = sha1($username . '::' . $host); if (!$cache || !($auth = $cache->get($cache_key))) { $auth = $rcube->plugins->exec_hook('authenticate', array( 'host' => $host, 'user' => $username, 'pass' => $password, )); if ($cache && !$auth['abort']) { $cache->set($cache_key, array( 'user' => $auth['user'], 'host' => $auth['host'], + 'dn' => $_SESSION['kolab_dn'], + 'vars' => $_SESSION['kolab_auth_vars'], )); } // LDAP server failure... send 503 error if ($auth['kolab_ldap_error']) { throw new ServiceUnavailable('The service is temporarily unavailable (LDAP failure)'); } } else { $auth['pass'] = $password; + // set some session vars from kolab_auth + $_SESSION['kolab_dn'] = $auth['dn']; + $_SESSION['kolab_auth_vars'] = $auth['vars']; } // authenticate user against the IMAP server $user_id = $auth['abort'] ? 0 : $this->_login($auth['user'], $auth['pass'], $auth['host'], $error); if ($user_id) { self::$current_user = $auth['user']; self::$current_pass = $auth['pass']; return true; } // IMAP server failure... send 503 error if ($error == rcube_imap_generic::ERROR_BAD) { throw new ServiceUnavailable('The service is temporarily unavailable (Storage failure)'); } return false; } /** * Returns information about the currently logged in username. * * If nobody is currently logged in, this method should return null. * * @return string|null */ public function getCurrentUser() { // return the canonic user name return self::$current_user; } /** * Storage host selection */ protected function _select_host($username) { // Get IMAP host $rcube = rcube::get_instance(); $host = $rcube->config->get('default_host', 'localhost'); if (is_array($host)) { list($user, $domain) = explode('@', $username); // try to select host by mail domain if (!empty($domain)) { foreach ($host as $storage_host => $mail_domains) { if (is_array($mail_domains) && in_array_nocase($domain, $mail_domains)) { $host = $storage_host; break; } else if (stripos($storage_host, $domain) !== false || stripos(strval($mail_domains), $domain) !== false) { $host = is_numeric($storage_host) ? $mail_domains : $storage_host; break; } } } // take the first entry if $host is not found if (is_array($host)) { list($key, $val) = each($default_host); $host = is_numeric($key) ? $val : $key; } } return rcube_utils::parse_host($host); } /** * Authenticates a user in IMAP and returns Roundcube user ID. */ protected function _login($username, $password, $host, &$error = null) { if (empty($username)) { return null; } $rcube = rcube::get_instance(); $storage = $rcube->get_storage(); $login_lc = $rcube->config->get('login_lc'); $default_port = $rcube->config->get('default_port', 143); // parse $host $a_host = parse_url($host); if ($a_host['host']) { $host = $a_host['host']; $ssl = (isset($a_host['scheme']) && in_array($a_host['scheme'], array('ssl','imaps','tls'))) ? $a_host['scheme'] : null; if (!empty($a_host['port'])) { $port = $a_host['port']; } else if ($ssl && $ssl != 'tls' && (!$default_port || $default_port == 143)) { $port = 993; } } if (!$port) { $port = $default_port; } // Convert username to lowercase. If storage backend // is case-insensitive we need to store always the same username if ($login_lc) { if ($login_lc == 2 || $login_lc === true) { $username = mb_strtolower($username); } else if (strpos($username, '@')) { // lowercase domain name list($local, $domain) = explode('@', $username); $username = $local . '@' . mb_strtolower($domain); } } // Here we need IDNA ASCII // Only rcube_contacts class is using domain names in Unicode $host = rcube_utils::idn_to_ascii($host); $username = rcube_utils::idn_to_ascii($username); // user already registered? if ($user = rcube_user::query($username, $host)) { $username = $user->data['username']; } // authenticate user in IMAP if (!$storage->connect($host, $username, $password, $port, $ssl)) { $error = $storage->get_error_code(); return null; } // No user in database, but IMAP auth works if (!is_object($user)) { if ($rcube->config->get('auto_create_user')) { // create a new user record $user = rcube_user::create($username, $host); if (!$user) { rcube::raise_error(array( 'code' => 620, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Failed to create a user record", ), true, false); return null; } } else { rcube::raise_error(array( 'code' => 620, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Access denied for new user $username. 'auto_create_user' is disabled", ), true, false); return null; } } // overwrite config with user preferences $rcube->user = $user; $rcube->config->set_user_prefs((array)$rcube->user->get_prefs()); $rcube->password = $password; setlocale(LC_ALL, 'en_US.utf8', 'en_US.UTF-8'); return $user->ID; } } diff --git a/lib/Kolab/DAV/File.php b/lib/Kolab/DAV/File.php index b7e0b20..d7a6dfd 100644 --- a/lib/Kolab/DAV/File.php +++ b/lib/Kolab/DAV/File.php @@ -1,224 +1,201 @@ * * Copyright (C) 2013, 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\DAV; use \Exception; use \DateTime; /** * File class */ class File extends Node implements \Sabre\DAV\IFile, \Sabre\DAV\IProperties { /** * Updates the data * * The data argument is a readable stream resource. * * After a succesful put operation, you may choose to return an ETag. The * etag must always be surrounded by double-quotes. These quotes must * appear in the actual string you're returning. * * Clients may use the ETag from a PUT request to later on make sure that * when they update the file, the contents haven't changed in the mean * time. * * If you don't plan to store the file byte-by-byte, and you return a * different object on a subsequent GET you are strongly recommended to not * return an ETag, and just return null. * * @param resource $data * @throws Sabre\DAV\Exception * @return string|null */ public function put($data) { $filedata = $this->fileData($this->path, $data); try { $this->backend->file_update($this->path, $filedata); } catch (Exception $e) { $this->throw_exception($e); } try { $this->data = $this->backend->file_info($this->path); } catch (Exception $e) { $this->throw_exception($e); } return $this->getETag(); } /** * Returns the file content * * This method may either return a string or a readable stream resource * * @throws Sabre\DAV\Exception * @return mixed */ public function get() { try { $fp = fopen('php://temp', 'bw+'); $this->backend->file_get($this->path, array(), $fp); rewind($fp); } catch (Exception $e) { $this->throw_exception($e); } return $fp; } /** * Delete the current file * * @throws Sabre\DAV\Exception * @return void */ public function delete() { try { $this->backend->file_delete($this->path); } catch (Exception $e) { $this->throw_exception($e); } // reset cache if ($this->parent) { $this->parent->children = null; } } /** * Returns the size of the node, in bytes * * @return int */ public function getSize() { return $this->data['size']; } /** * Returns the ETag for a file * * An ETag is a unique identifier representing the current version of the file. If the file changes, the ETag MUST change. * The ETag is an arbitrary string, but MUST be surrounded by double-quotes. * * Return null if the ETag can not effectively be determined * * @return mixed */ public function getETag() { return sprintf('"%s-%d"', substr(md5($this->path . ':' . $this->data['size']), 0, 16), $this->data['modified']); } /** * Returns the mime-type for a file * * If null is returned, we'll assume application/octet-stream * * @return mixed */ public function getContentType() { return $this->data['type']; } /** - * Updates properties on this node, + * Updates properties on this node. * - * The properties array uses the propertyName in clark-notation as key, - * and the array value for the property value. In the case a property - * should be deleted, the property value will be null. + * This method received a PropPatch object, which contains all the + * information about the update. * - * This method must be atomic. If one property cannot be changed, the - * entire operation must fail. + * To update specific properties, call the 'handle' method on this object. + * Read the PropPatch documentation for more information. * - * If the operation was successful, true can be returned. - * If the operation failed, false can be returned. - * - * Deletion of a non-existent property is always successful. - * - * Lastly, it is optional to return detailed information about any - * failures. In this case an array should be returned with the following - * structure: - * - * array( - * 403 => array( - * '{DAV:}displayname' => null, - * ), - * 424 => array( - * '{DAV:}owner' => null, - * ) - * ) - * - * In this example it was forbidden to update {DAV:}displayname. - * (403 Forbidden), which in turn also caused {DAV:}owner to fail - * (424 Failed Dependency) because the request needs to be atomic. - * - * @param array $mutations - * @return bool|array + * @param PropPatch $propPatch + * @return void */ - function updateProperties($mutations) + function propPatch(\Sabre\DAV\PropPatch $propPatch) { // not supported return false; } /** * Returns a list of properties for this node. * * The properties list is a list of propertynames the client requested, * encoded in clark-notation {xmlnamespace}tagname * * If the array is empty, it means 'all properties' were requested. * * Note that it's fine to liberally give properties back, instead of * conforming to the list of requested properties. * The Server class will filter out the extra. * * @param array $properties * @return void */ function getProperties($properties) { $result = array(); if ($this->data['created']) { $result['{DAV:}creationdate'] = \Sabre\HTTP\Util::toHTTPDate(new DateTime('@'.$this->data['created'])); } return $result; } } diff --git a/lib/Kolab/DAVACL/PrincipalBackend.php b/lib/Kolab/DAVACL/PrincipalBackend.php index 908aca3..4fd3630 100644 --- a/lib/Kolab/DAVACL/PrincipalBackend.php +++ b/lib/Kolab/DAVACL/PrincipalBackend.php @@ -1,255 +1,273 @@ * * Copyright (C) 2013, 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\DAVACL; use \rcube; +use \rcube_utils; +use \libcalendaring; +use \kolab_storage; use Sabre\DAV\Exception; use Sabre\DAV\URLUtil; use Kolab\DAV\Auth\HTTPBasic; /** * Kolab Principal Backend */ -class PrincipalBackend implements \Sabre\DAVACL\PrincipalBackend\BackendInterface +class PrincipalBackend extends \Sabre\DAVACL\PrincipalBackend\AbstractBackend implements \Sabre\DAVACL\PrincipalBackend\BackendInterface { /** * Sets up the backend. */ public function __construct() { } /** * Returns a pricipal record for the currently authenticated user */ public function getCurrentUser() { // console(__METHOD__, HTTPBasic::$current_user); if (HTTPBasic::$current_user) { $user_email = rcube::get_instance()->get_user_email(); + $emails = libcalendaring::get_instance()->get_user_emails(); + return array( 'uri' => 'principals/' . HTTPBasic::$current_user, '{DAV:}displayname' => HTTPBasic::$current_user, '{http://sabredav.org/ns}email-address' => $user_email, - '{http://calendarserver.org/ns/}email-address-set' => $user_email, + '{DAV:}alternate-URI-set' => array_map(function($email) { + return 'mailto:' . $email; + }, $emails), ); } return false; } /** * Returns a list of principals based on a prefix. * * This prefix will often contain something like 'principals'. You are only * expected to return principals that are in this base path. * * You are expected to return at least a 'uri' for every user, you can * return any additional properties if you wish so. Common properties are: * {DAV:}displayname * {http://sabredav.org/ns}email-address - This is a custom SabreDAV * field that's actualy injected in a number of other properties. If * you have an email address, use this property. * * @param string $prefixPath * @return array */ public function getPrincipalsByPrefix($prefixPath) { console(__METHOD__, $prefixPath); $principals = array(); if ($prefixPath == 'principals') { // TODO: list users from LDAP // we currently only advertise the authenticated user if ($user = $this->getCurrentUser()) { $principals[] = $user; } } return $principals; } /** * Returns a specific principal, specified by it's path. * The returned structure should be the exact same as from * getPrincipalsByPrefix. * * @param string $path * @return array */ public function getPrincipalByPath($path) { // console(__METHOD__, $path); list($prefix,$name) = explode('/', $path); if ($prefix == 'principals' && $name == HTTPBasic::$current_user) { return $this->getCurrentUser(); } - else if ($prefix == 'principals' && \rcube_utils::check_email($name, false)) { - // TODO: do a user lookup in LDAP + else if ($prefix == 'principals' && rcube_utils::check_email($name, false)) { list($localname,$domain) = explode('@', $name); + $displayname = ucfirst(str_replace('.', ' ', $localname)); + + // Do a user lookup in LDAP + foreach (kolab_storage::search_users($name, 1, array('email'), 1) as $user) { + $displayname = $user['displayname']; + } + return array( 'uri' => $path, - '{DAV:}displayname' => $localname, + '{DAV:}displayname' => $displayname, '{http://sabredav.org/ns}email-address' => $name, - '{http://calendarserver.org/ns/}email-address-set' => $name, ); } return null; } /** * Returns the list of members for a group-principal * * @param string $principal * @return array */ public function getGroupMemberSet($principal) { // TODO: for now the group principal has only one member, the user itself list($prefix, $name) = URLUtil::splitPath($principal); - $principal = $this->getPrincipalByPath($prefix); + $principal = $this->getPrincipalByPath($principal); if (!$principal) throw new Exception('Principal not found'); return array( $prefix ); } /** * Returns the list of groups a principal is a member of * * @param string $principal * @return array */ public function getGroupMembership($principal) { list($prefix,$name) = URLUtil::splitPath($principal); $group_membership = array(); if ($prefix == 'principals') { $principal = $this->getPrincipalByPath($principal); if (!$principal) throw new Exception('Principal not found'); // TODO: implement full calendar delegation (with information from LDAP kolabDelegate) return array( // Calendar delegation is not supported by our backend //'principals/'.$name.'/calendar-proxy-read', //'principals/'.$name.'/calendar-proxy-write', // The addressbook groups are not supported in Sabre, // see http://groups.google.com/group/sabredav-discuss/browse_thread/thread/ef2fa9759d55f8c#msg_5720afc11602e753 //'principals/'.$name.'/addressbook-proxy-read', //'principals/'.$name.'/addressbook-proxy-write', ); } return $group_membership; } /** * Updates the list of group members for a group principal. * * The principals should be passed as a list of uri's. * * @param string $principal * @param array $members * @return void */ public function setGroupMemberSet($principal, array $members) { throw new Exception('Setting members of the group is not supported yet'); } - function updatePrincipal($path, $mutations) + function updatePrincipal($path, \Sabre\DAV\PropPatch $propPatch) { return 0; } /** * This method is used to search for principals matching a set of * properties. * * This search is specifically used by RFC3744's principal-property-search * REPORT. You should at least allow searching on * http://sabredav.org/ns}email-address. * * The actual search should be a unicode-non-case-sensitive search. The * keys in searchProperties are the WebDAV property names, while the values * are the property values to search on. * * If multiple properties are being searched on, the search should be * AND'ed. * * This method should simply return an array with full principal uri's. * * If somebody attempted to search on a property the backend does not * support, you should simply return 0 results. * * @param string $prefixPath * @param array $searchProperties + * @param string $test * @return array */ - function searchPrincipals($prefixPath, array $searchProperties) + function searchPrincipals($prefixPath, array $searchProperties, $test = 'allof') { console(__METHOD__, $prefixPath, $searchProperties); - $email = null; + $email = null; $name = null; $results = array(); $current_user = $this->getCurrentUser(); - foreach($searchProperties as $property => $value) { + foreach ($searchProperties as $property => $value) { // check search property against the current user if ($current_user[$property] == $value) { $results[] = $current_user['uri']; continue; } switch($property) { case '{http://sabredav.org/ns}email-address': $email = $value; break; case '{DAV:}displayname': + $name = $value; + break; + default : // Unsupported property return array(); } } - // we only support search by email - if (!empty($email)) { - // TODO: search via LDAP + // search users via LDAP + if (empty($results) && (!empty($email) || !empty($name))) { + foreach (kolab_storage::search_users($email ?: $name, 2, array('email'), 10) as $user) { + $results[] = 'principals/' . $user['email']; + } } return array_unique($results); } } diff --git a/lib/Kolab/Utils/DAVBackend.php b/lib/Kolab/Utils/DAVBackend.php index 615d6b8..a556a56 100644 --- a/lib/Kolab/Utils/DAVBackend.php +++ b/lib/Kolab/Utils/DAVBackend.php @@ -1,261 +1,291 @@ * * Copyright (C) 2013, 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\Utils; use \rcube; use \kolab_storage; use \rcube_utils; use \rcube_charset; use Sabre\DAV; /** * */ class DAVBackend { const IMAP_UID_KEY = '/shared/vendor/kolab/uniqueid'; const IMAP_UID_KEY_PRIVATE = '/private/vendor/kolab/uniqueid'; const IMAP_UID_KEY_CYRUS = '/shared/vendor/cmu/cyrus-imapd/uniqueid'; public static $caldav_type_component_map = array('event' => 'VEVENT', 'task' => 'VTODO'); /** * Getter for a kolab_storage_folder with the given UID * * @param string Folder UID (saved in annotation) * @param string Kolab folder type (for selecting candidates) * @return object \kolab_storage_folder instance */ public static function get_storage_folder($uid, $type) { foreach (kolab_storage::get_folders($type, false) as $folder) { if ($folder->get_uid() == $uid) { self::check_storage_folder($folder); return $folder; } } self::check_storage_folder(null); } /** * Check the given storage folder instance for validity and throw * the right exceptions according to the error state. */ public static function check_storage_folder($folder) { if ($folder == null) { throw new DAV\Exception\NotFound('The requested collection was not found'); } if (!$folder->valid || $folder->get_error()) { $error = $folder->get_error(); if ($error === kolab_storage::ERROR_IMAP_CONN) { throw new DAV\Exception\ServiceUnavailable('The service is temporarily unavailable (Storage failure)'); } else if ($error === kolab_storage::ERROR_CACHE_DB) { throw new DAV\Exception\ServiceUnavailable('The service is temporarily unavailable (Cache failure)'); } else if ($error === kolab_storage::ERROR_NO_PERMISSION) { throw new DAV\Exception\Forbidden('Access to this collection is not permitted'); } else if ($error === kolab_storage::ERROR_INVALID_FOLDER) { throw new DAV\Exception\NotFound('The requested collection was not found'); } throw new DAV\Exception('Internal Server Error'); } } /** * Build an absolute URL with the given parameters */ public static function abs_url($parts = array()) { $schema = 'http'; $default_port = 80; if (rcube_utils::https_check()) { $schema = 'https'; $default_port = 443; } $url = $schema . '://' . $_SERVER['HTTP_HOST']; if ($_SERVER['SERVER_PORT'] != $default_port) $url .= ':' . $_SERVER['SERVER_PORT']; if (dirname($_SERVER['SCRIPT_NAME']) != '/') $url .= dirname($_SERVER['SCRIPT_NAME']); $url .= '/' . join('/', array_map('urlencode', $parts)); return $url; } + /** + * Set callback handler for property changes on the given folder + * + * @param object $folder kolab_storage_folder instance to operate on + * @param oject $propPatch PropPatch instance with the property updates + */ + public static function handle_propatch($folder, \Sabre\DAV\PropPatch $propPatch) + { + $propPatch->handle( + array('{DAV:}displayname','{http://apple.com/ns/ical/}calendar-color'), + function($mutations) use ($folder) { + $result = DAVBackend::folder_update($folder, $mutations); + if (is_array($result)) { + $ret = array(); + foreach ($result as $code => $props) { + foreach (array_keys($props) as $prop) { + $ret[$prop] = $code; + } + } + } + else { + $ret = $result; + } + return $ret; + }); + + // silently accept the other/non-supported properties + $propPatch->setRemainingResultCode(204); + } + /** * Updates properties for a recourse (kolab folder) * * The mutations array uses the propertyName in clark-notation as key, * and the array value for the property value. In the case a property * should be deleted, the property value will be null. * * This method must be atomic. If one property cannot be changed, the * entire operation must fail. * * If the operation was successful, true is returned. * If the operation failed, detailed information about any * failures is returned. * * @param object $folder kolab_storage_folder instance to operate on - * @param array $mutations Hash array with propeties to change - * @return bool|array + * @param object $mutations Hash array with propeties to change + * @return void */ public static function folder_update($folder, array $mutations) { $errors = array(); $updates = array(); foreach ($mutations as $prop => $val) { switch ($prop) { case '{DAV:}displayname': // abort if name didn't change if ($val == html_entity_decode($folder->get_name(), ENT_COMPAT, RCUBE_CHARSET)) { break; } // restrict renaming to personal folders only if ($folder->get_namespace() == 'personal') { $parts = preg_split('!(\s*/\s*|\s+[»:]\s+)!', $val); $updates['oldname'] = $folder->name; $updates['name'] = array_pop($parts); $updates['parent'] = join('/', $parts); } else { $updates['displayname'] = $val; } break; case '{http://apple.com/ns/ical/}calendar-color': $newcolor = substr(trim($val, '#'), 0, 6); if (strcasecmp($newcolor, $folder->get_color())) { $updates['color'] = $newcolor; } break; case '{urn:ietf:params:xml:ns:caldav}calendar-description': default: // unsupported property $errors[403][$prop] = null; } } // execute folder update if (!empty($updates)) { // 'name' and 'parent' properties are always required if (empty($updates['name'])) { $parts = explode('/', $folder->name); $updates['name'] = rcube_charset::convert(array_pop($parts), 'UTF7-IMAP'); $updates['parent'] = join('/', $parts); $updates['oldname'] = $folder->name; } if (!kolab_storage::folder_update($updates)) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error updating properties for folder $folder->name:" . kolab_storage::$last_error), true, false); return false; } } return empty($errors) ? true : $errors; } /** * Creates a new resource (i.e. IMAP folder) of a given type * * If the creation was a success, an id must be returned that can be used to reference * this resource in other methods. * * @param array $properties * @param string $type * @param string $uid * @return false|string */ public function folder_create($type, array $properties, $uid) { $props = array( 'type' => $type, 'name' => '', 'subscribed' => true, ); foreach ($properties as $prop => $val) { switch ($prop) { case '{DAV:}displayname': $parts = explode('/', $val); $props['name'] = array_pop($parts); $props['parent'] = join('/', $parts); break; case '{http://apple.com/ns/ical/}calendar-color': $props['color'] = substr(trim($val, '#'), 0, 6); break; case '{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set': $type_map = array_flip(self::$caldav_type_component_map); $comp_types = $val->getValue(); $comp_type = $comp_types[0]; if (!empty($type_map[$comp_type])) $props['type'] = $type = $type_map[$comp_type]; break; case '{urn:ietf:params:xml:ns:caldav}calendar-description': default: // unsupported property } } // use UID as name if it doesn't seem to be a real UID // TODO: append number to default "Untitled" folder name if one already exists if (empty($props['name'])) { $props['name'] = strlen($uid) < 16 ? $uid : 'Untitled'; } if (!($fname = kolab_storage::folder_update($props))) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error creating a new $type folder '$props[name]':" . kolab_storage::$last_error), true, false); return false; } // save UID in folder annotations if ($folder = kolab_storage::get_folder($fname)) { $folder->set_uid($uid); } return $uid; } } diff --git a/lib/Kolab/Utils/DAVLogger.php b/lib/Kolab/Utils/DAVLogger.php index 4cbbcb2..b35641c 100644 --- a/lib/Kolab/Utils/DAVLogger.php +++ b/lib/Kolab/Utils/DAVLogger.php @@ -1,221 +1,220 @@ * * Copyright (C) 2013, 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\Utils; use \rcube; use Sabre\DAV; use Kolab\DAV\Auth\HTTPBasic; /** * Utility class to log debug information about processed DAV requests */ class DAVLogger extends DAV\ServerPlugin { const CONSOLE = 1; const HTTP_REQUEST = 2; const HTTP_RESPONSE = 4; private $rcube; private $server; private $method; private $loglevel; /** * Default constructor */ public function __construct($level = 1) { $this->rcube = rcube::get_instance(); $this->loglevel = $level; } /** * This initializes the plugin. * This method should set up the required event subscriptions. * * @param Server $server */ public function initialize(DAV\Server $server) { $this->server = $server; - $server->subscribeEvent('beforeMethod', array($this, '_beforeMethod'), 15); - $server->subscribeEvent('exception', array($this, '_exception')); - $server->subscribeEvent('exit', array($this, '_exit')); + $server->on('beforeMethod', array($this, '_beforeMethod'), 15); + $server->on('exception', array($this, '_exception')); + $server->on('exit', array($this, '_exit')); // replace $server->httpResponse with a derived class that can do logging $server->httpResponse = new HTTPResponse(); } /** * Handler for 'beforeMethod' events */ - public function _beforeMethod($method, $uri) + public function _beforeMethod($request, $response) { - $this->method = $method; + $this->method = $request->getMethod(); // turn on per-user http logging if the destination file exists if ($this->loglevel < 2 && $this->rcube->config->get('kolabdav_user_debug', false) && ($log_dir = $this->user_log_dir()) && file_exists($log_dir . '/httpraw')) { $this->loglevel |= (self::HTTP_REQUEST | self::HTTP_RESPONSE); } // log full HTTP request data if ($this->loglevel & self::HTTP_REQUEST) { - $request = $this->server->httpRequest; - $content_type = $request->getHeader('CONTENT_TYPE'); + $content_type = $request->getHeader('Content-Type'); if (strpos($content_type, 'text/') === 0 || strpos($content_type, 'application/xml') === 0) { - $http_body = $request->getBody(true); + $http_body = $request->getBodyAsString(); // Hack for reading php:://input because that stream can only be read once. // This is why we re-populate the request body with the existing data. $request->setBody($http_body); } else if (!empty($content_type)) { $http_body = '[binary data]'; } // catch all headers $http_headers = array(); foreach ($this->get_request_headers() as $hdr => $value) { if (strtolower($hdr) == 'authorization') { $method = preg_match('/^((basic|digest)\s+)/i', $value, $m) ? $m[1] : ''; $value = $method . str_repeat('*', strlen($value) - strlen($method)); } $http_headers[$hdr] = "$hdr: $value"; } - $this->write_log('httpraw', $request->getMethod() . ' ' . $request->getUri() . ' ' . $_SERVER['SERVER_PROTOCOL'] . "\n" . + $this->write_log('httpraw', $request->getMethod() . ' ' . $request->getUrl() . ' ' . $_SERVER['SERVER_PROTOCOL'] . "\n" . join("\n", $http_headers) . "\n\n" . $http_body); } // log to console if ($this->loglevel & self::CONSOLE) { - $this->write_log('console', $method . ' ' . $uri); + $this->write_log('console', $this->method . ' ' . $request->getUrl()); } } /* * Wrapper function in case apache_request_headers() is not available * * @return array */ public function get_request_headers() { if (function_exists('apache_request_headers')) { return apache_request_headers(); } $return = array(); foreach ($_SERVER as $key => $value) { if (preg_match('/^HTTP_(.*)/',$key,$regs)) { // restore original letter case $key = str_replace('_',' ',$regs[1]); $key = ucwords(strtolower($key)); $key = str_replace(' ','-',$key); // build return array $return[$key] = $value; } } return $return; } /** * Handler for 'exception' events */ public function _exception($e) { // log to console $this->console(get_class($e) . ' (EXCEPTION)', $e->getMessage() /*, $e->getTraceAsString()*/); } /** * Handler for 'exit' events */ public function _exit() { if ($this->loglevel & self::CONSOLE) { $time = microtime(true) - KOLAB_DAV_START; if (function_exists('memory_get_usage')) $mem = round(memory_get_usage() / 1024 / 1024, 1) . 'MB'; if (function_exists('memory_get_peak_usage')) $mem .= '/' . round(memory_get_peak_usage() / 1024 / 1024, 1) . 'MB'; $this->write_log('console', sprintf("/%s: %0.4f sec; %s", $this->method, $time, $mem)); } // log full HTTP reponse if ($this->loglevel & self::HTTP_RESPONSE) { $this->write_log('httpraw', "RESPONSE: " . $this->server->httpResponse->dump()); } } /** * Wrapper for rcube::cosole() to write per-user logs */ public function console(/* ... */) { if ($this->loglevel & self::CONSOLE) { $msg = array(); foreach (func_get_args() as $arg) { $msg[] = !is_string($arg) ? var_export($arg, true) : $arg; } $this->write_log('console', join(";\n", $msg)); } } /** * Wrapper for rcube::write_log() that can write per-user logs */ public function write_log($filename, $msg) { // dump data per user if ($this->rcube->config->get('kolabdav_user_debug', false)) { if ($this->user_log_dir()) { $filename = HTTPBasic::$current_user . '/' . $filename; } else { return; // don't log } } rcube::write_log($filename, $msg); } /** * Get the per-user log directory */ private function user_log_dir() { $log_dir = $this->rcube->config->get('log_dir', RCUBE_INSTALL_PATH . 'logs'); $user_log_dir = $log_dir . '/' . HTTPBasic::$current_user; return HTTPBasic::$current_user && is_writable($user_log_dir) ? $user_log_dir : false; } } \ No newline at end of file diff --git a/lib/Kolab/Utils/HTTPResponse.php b/lib/Kolab/Utils/HTTPResponse.php index 4f4e72d..0c98576 100644 --- a/lib/Kolab/Utils/HTTPResponse.php +++ b/lib/Kolab/Utils/HTTPResponse.php @@ -1,92 +1,51 @@ * * Copyright (C) 2013, 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\Utils; /** * This class represents a HTTP response. */ class HTTPResponse extends \Sabre\HTTP\Response { - private $status; - private $body = ''; - private $headers = array(); - - /** - * Sends an HTTP status header to the client. - * - * @param int $code HTTP status code - * @return bool - */ - public function sendStatus($code) - { - $this->status = $this->getStatusMessage($code, $this->defaultHttpVersion); - return parent::sendStatus($code); - } - - /** - * Sets an HTTP header for the response - * - * @param string $name - * @param string $value - * @param bool $replace - * @return bool - */ - public function setHeader($name, $value, $replace = true) { - $this->headers[$name] = $value; - return parent::setHeader($name, $value, $replace); - } - - /** - * Sends the entire response body - * - * This method can accept either an open filestream, or a string. - * - * @param mixed $body - * @return void - */ - public function sendBody($body) - { - if (is_resource($body)) { - fpassthru($body); - $this->body = '[binary data]'; - } - else { - echo $body; - $this->body .= $body; - } - } - /** * Dump the response data for logging */ public function dump() { $result_headers = ''; foreach ($this->headers as $hdr => $value) { - $result_headers .= "\n$hdr: " . $value; + $result_headers .= "\n$value[0]: " . $this->getHeader($hdr); + } + + $body = $this->body; + + // get response body as string for text/* data + if (is_resource($this->body) && strpos($this->getHeader('content-type'), 'text/') === 0) { + @fseek($this->body, 0); + $body = stream_get_contents($this->body); } - return $this->status . $result_headers . "\n\n" . $this->body; + return $this->status . " " . $this->statusText . $result_headers . "\n\n" . $body; } } \ No newline at end of file diff --git a/lib/Kolab/Utils/VObjectUtils.php b/lib/Kolab/Utils/VObjectUtils.php index 82f934b..ad88be9 100644 --- a/lib/Kolab/Utils/VObjectUtils.php +++ b/lib/Kolab/Utils/VObjectUtils.php @@ -1,84 +1,98 @@ * * Copyright (C) 2013, 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\Utils; use Sabre\VObject\Property; /** * Helper class proviting utility functions for VObject data encoding */ class VObjectUtils { /** * Convert an object URI into a valid UID value */ public static function uri2uid($uri, $suffix = '') { $base = basename($uri, $suffix); $uid = strtr($base, array('%2F' => '/')); // assume full URL encoding if (preg_match('/%[A-F0-9]{2}/', $uid)) { return urldecode($base); } return $uid; } /** * Encode an object UID into a valid URI */ public static function uid2uri($uid, $suffix = '') { $encode = strpos($uid, '/') !== false; return ($encode ? urlencode($uid) : $uid) . $suffix; } /** * Create a Sabre\VObject\Property instance from a PHP DateTime object * * @param string Property name * @param object DateTime */ - public static function datetime_prop($name, $dt, $utc = false) + public static function datetime_prop($root, $name, $dt, $utc = false) { - $vdt = new Property\DateTime($name); - $vdt->setDateTime($dt, $dt->_dateonly ? Property\DateTime::DATE : ($utc ? Property\DateTime::UTC : Property\DateTime::LOCALTZ)); + if ($utc) { + $dt->setTimeZone(new \DateTimeZone('UTC')); + } + + $vdt = $root->createProperty($name, null, null, $dt->_dateonly ? 'DATE' : 'DATE-TIME'); + $value = $dt; + + if ($dt->_dateonly) { + // $vdt['VALUE'] = 'DATE'; + // set date value as string as a temporary fix for + // https://github.com/fruux/sabre-vobject/issues/217 + $value = $dt->format('Y-m-d'); + } + + $vdt->setValue($value); + return $vdt; } /** * Copy values from one hash array to another using a key-map */ public static function map_keys($values, $map) { $out = array(); foreach ($map as $from => $to) { if (isset($values[$from])) $out[$to] = $values[$from]; } return $out; } } \ No newline at end of file diff --git a/public_html/index.php b/public_html/index.php index cfebb82..2919763 100644 --- a/public_html/index.php +++ b/public_html/index.php @@ -1,197 +1,197 @@ * * 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 . */ // define some environment variables used throughout the app and libraries define('KOLAB_DAV_ROOT', realpath('../')); -define('KOLAB_DAV_VERSION', '0.3.0'); +define('KOLAB_DAV_VERSION', '0.4-dev'); define('KOLAB_DAV_START', microtime(true)); define('RCUBE_INSTALL_PATH', KOLAB_DAV_ROOT . '/'); define('RCUBE_CONFIG_DIR', KOLAB_DAV_ROOT . '/config/'); define('RCUBE_PLUGINS_DIR', KOLAB_DAV_ROOT . '/lib/plugins/'); // suppress error notices ini_set('error_reporting', E_ALL &~ E_NOTICE &~ E_STRICT); /** * Mapping PHP errors to exceptions. * * While this is not strictly needed, it makes a lot of sense to do so. If an * E_NOTICE or anything appears in your code, this allows SabreDAV to intercept * the issue and send a proper response back to the client (HTTP/1.1 500). */ function exception_error_handler($errno, $errstr, $errfile, $errline ) { throw new ErrorException($errstr, 0, $errno, $errfile, $errline); } //set_error_handler("exception_error_handler"); // use composer's autoloader for dependencies $loader = require_once(KOLAB_DAV_ROOT . '/vendor/autoload.php'); $loader->set('Kolab', array(KOLAB_DAV_ROOT . '/lib')); // register iRony namespace(s) $loader->setUseIncludePath(true); // enable include_path to load PEAR classes from their default location // load the Roundcube framework with its autoloader require_once KOLAB_DAV_ROOT . '/lib/Roundcube/bootstrap.php'; // Roundcube framework initialization $rcube = rcube::get_instance(rcube::INIT_WITH_DB | rcube::INIT_WITH_PLUGINS); $rcube->config->load_from_file(RCUBE_CONFIG_DIR . 'dav.inc.php'); // Load plugins $plugins = (array)$rcube->config->get('kolabdav_plugins', array('kolab_auth')); $required = array('libkolab', 'libcalendaring'); $rcube->plugins->init($rcube); $rcube->plugins->load_plugins($plugins, $required); // enable logger if ($rcube->config->get('kolabdav_console') || $rcube->config->get('kolabdav_user_debug')) { $logger = new \Kolab\Utils\DAVLogger((\Kolab\Utils\DAVLogger::CONSOLE | $rcube->config->get('kolabdav_http_log', 0))); } // convenience function, you know it well :-) function console() { global $logger; if ($logger) { call_user_func_array(array($logger, 'console'), func_get_args()); } } // Make sure this setting is turned on and reflects the root url of the *DAV server. $base_uri = $rcube->config->get('base_uri', slashify(substr(dirname($_SERVER['SCRIPT_FILENAME']), strlen($_SERVER['DOCUMENT_ROOT'])))); // add filename to base URI when called without mod_rewrite (e.g. /dav/index.php/calendar) if (strpos($_SERVER['REQUEST_URI'], 'index.php')) $base_uri .= 'index.php/'; // create the various backend instances $auth_backend = new \Kolab\DAV\Auth\HTTPBasic(); $principal_backend = new \Kolab\DAVACL\PrincipalBackend(); $services = array(); foreach (array('CALDAV','CARDDAV','WEBDAV') as $skey) { if (getenv($skey)) $services[$skey] = 1; } // no config means *all* services if (empty($services)) $services = array('CALDAV' => 1, 'CARDDAV' => 1, 'WEBDAV' => 1); // add chwala directories to include path for autoloading if ($services['WEBDAV']) { $include_path = ini_get('include_path') . PATH_SEPARATOR; $include_path .= KOLAB_DAV_ROOT . '/lib/FileAPI' . PATH_SEPARATOR; $include_path .= KOLAB_DAV_ROOT . '/lib/FileAPI/kolab' . PATH_SEPARATOR; $include_path .= KOLAB_DAV_ROOT . '/lib/FileAPI/ext'; set_include_path($include_path); } // Build the directory tree // This is an array which contains the 'top-level' directories in the WebDAV server. if ($services['CALDAV'] || $services['CARDDAV']) { $nodes = array( new \Kolab\CalDAV\Principal\Collection($principal_backend), ); if ($services['CALDAV']) { $caldav_backend = new \Kolab\CalDAV\CalendarBackend(); $caldav_backend->setUserAgent($_SERVER['HTTP_USER_AGENT']); $nodes[] = new \Kolab\CalDAV\CalendarRootNode($principal_backend, $caldav_backend); } if ($services['CARDDAV']) { $carddav_backend = new \Kolab\CardDAV\ContactsBackend(); $carddav_backend->setUserAgent($_SERVER['HTTP_USER_AGENT']); $nodes[] = new \Kolab\CardDAV\AddressBookRoot($principal_backend, $carddav_backend); } if ($services['WEBDAV']) { $nodes[] = new \Kolab\DAV\Collection(\Kolab\DAV\Collection::ROOT_DIRECTORY); } } // register WebDAV service as root else if ($services['WEBDAV']) { $nodes = new \Kolab\DAV\Collection(''); } // the object tree needs in turn to be passed to the server class $server = new \Sabre\DAV\Server($nodes); $server->setBaseUri($base_uri); // connect logger if (is_object($logger)) { $server->addPlugin($logger); } // register some plugins $server->addPlugin(new \Sabre\DAV\Auth\Plugin($auth_backend, 'KolabDAV')); $server->addPlugin(new \Sabre\DAVACL\Plugin()); if ($services['CALDAV']) { - $caldav_plugin = new \Kolab\CalDAV\Plugin(); - $caldav_plugin->setIMipHandler(new \Kolab\CalDAV\IMip()); - $server->addPlugin($caldav_plugin); + $server->addPlugin(new \Kolab\CalDAV\Plugin()); + $server->addPlugin(new \Kolab\CalDAV\SchedulePlugin()); + $server->addPlugin(new \Kolab\CalDAV\IMipPlugin('')); } if ($services['CARDDAV']) { $server->addPlugin(new \Kolab\CardDAV\Plugin()); } if ($services['WEBDAV']) { // the lock manager is responsible for making sure users don't overwrite each others changes. $locks_backend = new \Kolab\DAV\Locks\Chwala(\Kolab\DAV\Collection::ROOT_DIRECTORY); $server->addPlugin(new \Sabre\DAV\Locks\Plugin($locks_backend)); // intercept some of the garbage files operation systems tend to generate when mounting a WebDAV share $server->addPlugin(new \Kolab\DAV\TempFilesPlugin(KOLAB_DAV_ROOT . '/temp')); } // HTML UI for browser-based access (recommended only for development) if (getenv('DAVBROWSER')) { $server->addPlugin(new \Sabre\DAV\Browser\Plugin()); } // log exceptions in iRony error log -$server->subscribeEvent('exception', function($e){ +$server->on('exception', function($e){ if (!($e instanceof \Sabre\DAV\Exception) || $e->getHTTPCode() == 500) { rcube::raise_error(array( 'code' => 500, 'type' => 'php', 'file' => $e->getFile(), 'line' => $e->getLine(), 'message' => $e->getMessage() . " (error 500)\n" . $e->getTraceAsString(), ), true, false); } }); // finally, process the request $server->exec(); // trigger log -$server->broadcastEvent('exit', array()); +$server->emit('exit', array()); diff --git a/test/Resource/CalDAV/scheduleprops/1.xml b/test/Resource/CalDAV/scheduleprops/1.xml new file mode 100644 index 0000000..64d8c08 --- /dev/null +++ b/test/Resource/CalDAV/scheduleprops/1.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/test/Resource/CardDAV/vcardput/1.vcf b/test/Resource/CardDAV/vcardput/1.vcf index 0e578ff..5353d8e 100644 --- a/test/Resource/CardDAV/vcardput/1.vcf +++ b/test/Resource/CardDAV/vcardput/1.vcf @@ -1,10 +1,10 @@ BEGIN:VCARD VERSION:3.0 N:Thompson;Default;;; FN:Default Thompson -EMAIL;TYPE=INTERNET;TYPE=WORK:lthompson@example.com +EMAIL;TYPE=INTERNET,WORK:lthompson@example.com TEL;TYPE=WORK:1-555-555-5555 TEL;TYPE=CELL:1-444-444-4444 ADR;TYPE=WORK:;;1245 Test;Sesame Street;California;11111;USA UID:1 END:VCARD diff --git a/test/Resource/CardDAV/vcardput/10.vcf b/test/Resource/CardDAV/vcardput/10.vcf index 161c2d7..bb1e5b9 100644 --- a/test/Resource/CardDAV/vcardput/10.vcf +++ b/test/Resource/CardDAV/vcardput/10.vcf @@ -1,25 +1,25 @@ BEGIN:VCARD VERSION:3.0 N:Here;Custom;Fields;All; FN:All Custom Fields Here NICKNAME:custome ORG:Major League Co.;Macosx server group TITLE:QA Engineer -EMAIL;type=INTERNET;type=WORK;type=pref:custom@example.com +EMAIL;type=INTERNET,WORK;type=pref:custom@example.com TEL;type=WORK;type=pref:777-777-7777 TEL;type=CELL:8888888888 item1.ADR;type=WORK;type=pref:;;1 Goroku St.;Mountain Top;CA;99999;USA item1.X-ABADR:us NOTE: Many customer fields are added item2.URL;type=pref:http://www.example.com/~magic item2.X-ABLabel:_$!!$_ BDAY;value=date:1999-03-18 X-AIM;type=WORK;type=pref:custom@example.com item3.X-ABDATE;type=pref:1995-05-21 item3.X-ABLabel:_$!!$_ item4.X-ABRELATEDNAMES;type=pref:Aho Sak item4.X-ABLabel:_$!!$_ item5.X-ABRELATEDNAMES:Sanma item5.X-ABLabel:_$!!$_ UID:AFBB77B8-0438-4825-A1DB-A75D76B6C3A8:ABPerson END:VCARD diff --git a/test/Resource/CardDAV/vcardput/11.vcf b/test/Resource/CardDAV/vcardput/11.vcf index 1b55319..0a5896e 100644 --- a/test/Resource/CardDAV/vcardput/11.vcf +++ b/test/Resource/CardDAV/vcardput/11.vcf @@ -1,11 +1,11 @@ BEGIN:VCARD VERSION:3.0 N:Thompson;Default;;; FN:Default Thompson -EMAIL;TYPE=INTERNET;TYPE=WORK:lthompson@example.com +EMAIL;TYPE=INTERNET,WORK:lthompson@example.com TEL;TYPE=WORK:1-555-555-5555 TEL;TYPE=CELL:1-444-444-4444 ADR;TYPE=WORK:;;1245\, Test;Sesame Street\; On The Hill;California;11111;USA UID:11 END:VCARD diff --git a/test/Resource/CardDAV/vcardput/12.vcf b/test/Resource/CardDAV/vcardput/12.vcf index 48abb4d..8538465 100644 --- a/test/Resource/CardDAV/vcardput/12.vcf +++ b/test/Resource/CardDAV/vcardput/12.vcf @@ -1,11 +1,11 @@ BEGIN:VCARD VERSION:3.0 N:Thompson;Default;;; FN:Default Thompson -EMAIL;TYPE=INTERNET;TYPE=WORK:lthompson@example.com +EMAIL;TYPE=INTERNET,WORK:lthompson@example.com TEL;TYPE=WORK:1-555-555-5555 TEL;TYPE=CELL:1-444-444-4444 ADR;TYPE=WORK:;;1245\, Test;Sesame Street\nOn The Hill;California;11111;USA UID:12 END:VCARD diff --git a/test/Resource/CardDAV/vcardput/13.vcf b/test/Resource/CardDAV/vcardput/13.vcf index 02c8b82..6038ef6 100644 --- a/test/Resource/CardDAV/vcardput/13.vcf +++ b/test/Resource/CardDAV/vcardput/13.vcf @@ -1,13 +1,13 @@ BEGIN:VCARD VERSION:3.0 N:Thompson;Default;;; FN:Default Thompson -EMAIL;TYPE=INTERNET;TYPE=WORK:lthompson@example.com +EMAIL;TYPE=INTERNET,WORK:lthompson@example.com TEL;TYPE=WORK:1-555-555-5555 TEL;TYPE=CELL:1-444-444-4444 ADR;TYPE=WORK:;;1245 Test;Sesame Street;California;11111;USA X-APPLE-STRUCTURED-LOCATION;VALUE=URI:geo:123.123,123.123 X-Test:Just some text\, <- here. X-Test:geo:123.123,123.123 UID:13 END:VCARD diff --git a/test/Resource/CardDAV/vcardput/14.vcf b/test/Resource/CardDAV/vcardput/14.vcf index e55f806..abdcec1 100644 --- a/test/Resource/CardDAV/vcardput/14.vcf +++ b/test/Resource/CardDAV/vcardput/14.vcf @@ -1,11 +1,11 @@ BEGIN:VCARD VERSION:3.0 N:Thompson;Default;;; FN:Default Thompson -EMAIL;TYPE=INTERNET;TYPE=WORK:lthompson@example.com +EMAIL;TYPE=INTERNET,WORK:lthompson@example.com TEL;TYPE=WORK:1-555-555-5555 TEL;TYPE=CELL:1-444-444-4444 ADR;TYPE=WORK:;;1245 Test;Sesame Street;California;11111;USA UID:14 X-Test;CN=Hello^'World^' want a ^?:test END:VCARD diff --git a/test/Resource/CardDAV/vcardput/14a.vcf b/test/Resource/CardDAV/vcardput/14a.vcf index 8ba1746..b64ff66 100644 --- a/test/Resource/CardDAV/vcardput/14a.vcf +++ b/test/Resource/CardDAV/vcardput/14a.vcf @@ -1,11 +1,11 @@ BEGIN:VCARD VERSION:3.0 N:Thompson;Default;;; FN:Default Thompson -EMAIL;TYPE=INTERNET;TYPE=WORK:lthompson@example.com +EMAIL;TYPE=INTERNET,WORK:lthompson@example.com TEL;TYPE=WORK:1-555-555-5555 TEL;TYPE=CELL:1-444-444-4444 ADR;TYPE=WORK:;;1245 Test;Sesame Street;California;11111;USA UID:14 X-Test;CN=Hello^'World^' want a ^^?:test END:VCARD diff --git a/test/Resource/CardDAV/vcardput/18.vcf b/test/Resource/CardDAV/vcardput/18.vcf index 2a130d1..ee96787 100644 --- a/test/Resource/CardDAV/vcardput/18.vcf +++ b/test/Resource/CardDAV/vcardput/18.vcf @@ -1,10 +1,10 @@ BEGIN:VCARD VERSION:3.0 N:Thompson;Non-default;;; FN:Non-default Thompson -EMAIL;type=INTERNET;type=WORK;type=pref:nlthompson@example.com +EMAIL;type=INTERNET,WORK;type=pref:nlthompson@example.com TEL;type=WORK;type=pref:1-555-555-5555 TEL;type=CELL:1-444-444-4444 ADR;type=WORK;type=pref:;;12456 Test;Sesame Street;California;11111;USA UID:16 END:VCARD diff --git a/test/Resource/CardDAV/vcardput/2.vcf b/test/Resource/CardDAV/vcardput/2.vcf index 719d422..2c9ce4d 100644 --- a/test/Resource/CardDAV/vcardput/2.vcf +++ b/test/Resource/CardDAV/vcardput/2.vcf @@ -1,16 +1,16 @@ BEGIN:VCARD VERSION:3.0 PRODID:-//Apple Inc.//AddressBook 6.1//EN N:Contact;Mulberry;;; FN:Mulberry Contact NICKNAME:mulberry ORG:Apple Inc.; -EMAIL;TYPE=INTERNET;TYPE=WORK:mulberry@example.com +EMAIL;TYPE=INTERNET,WORK:mulberry@example.com TEL;TYPE=HOME:777-777-7777 TEL;TYPE=WORK:8888888888 TEL;TYPE=FAX:5555555555 ADR;TYPE=WORK:;;1234 Infinite Circle;Exampletino\, CA 99999;USA;; NOTE:This is a contact created in Mulberry. URL;TYPE=HOMEPAGE:http://www.example.com/~magic UID:2 END:VCARD diff --git a/test/Resource/CardDAV/vcardput/3.vcf b/test/Resource/CardDAV/vcardput/3.vcf index e7c0055..4213c9c 100644 --- a/test/Resource/CardDAV/vcardput/3.vcf +++ b/test/Resource/CardDAV/vcardput/3.vcf @@ -1,11 +1,11 @@ BEGIN:VCARD VERSION:3.0 N:Kawado;Saeko;;; FN:Snow Leopard ORG:Snow Leopard; -EMAIL;TYPE=INTERNET;TYPE=WORK:snowleopard@example.com +EMAIL;TYPE=INTERNET,WORK:snowleopard@example.com TEL;TYPE=WORK:777-777-7777 ADR;TYPE=WORK:;;1 Fidel Ave. Suite 100;Mountain Top;CA;99999;USA X-ABSHOWAS:COMPANY UID:3 END:VCARD diff --git a/test/Resource/CardDAV/vcardput/5.vcf b/test/Resource/CardDAV/vcardput/5.vcf index bc5ccbc..c889541 100644 --- a/test/Resource/CardDAV/vcardput/5.vcf +++ b/test/Resource/CardDAV/vcardput/5.vcf @@ -1,18 +1,18 @@ BEGIN:VCARD VERSION:3.0 N:InfoIn;All;;; FN:All InfoIn ORG:allinfo Company; -EMAIL;TYPE=INTERNET;TYPE=WORK:allinfomationin@example.com +EMAIL;TYPE=INTERNET,WORK:allinfomationin@example.com TEL;TYPE=WORK:777-777-7777 TEL;TYPE=CELL:8888888888 ADR;TYPE=WORK:;;1 Gally Street Apt #2;Mountain Top;CA;99999;USA X-YAHOO:saeko.where@example.com X-YAHOO-ID:saeko.where@example.com -item1.X-ABLabel:_$!!$_ -item2.X-ABLabel:_$!!$_ +item1.X-ABLABEL:_$!!$_ +item2.X-ABLABEL:_$!!$_ item1.X-ABRELATEDNAMES:Shane item2.X-ABRELATEDNAMES:Mayumi Yan NOTE: Address book server test contact that hsa note field filled in. UID:5 END:VCARD diff --git a/test/Resource/CardDAV/vcardput/6.vcf b/test/Resource/CardDAV/vcardput/6.vcf index d0d70a1..394b0d5 100644 --- a/test/Resource/CardDAV/vcardput/6.vcf +++ b/test/Resource/CardDAV/vcardput/6.vcf @@ -1,992 +1,992 @@ BEGIN:VCARD VERSION:3.0 N:Picture;With;;; FN:With Picture -EMAIL;TYPE=INTERNET;TYPE=WORK:withpicture@example.com +EMAIL;TYPE=INTERNET,WORK:withpicture@example.com TEL;TYPE=WORK:777-777-7777 TEL;TYPE=CELL:8888888888 ADR;TYPE=WORK:;;1234 Golly Street;Sunnyside;CA;99999;USA PHOTO;BASE64: /9j/4AAQSkZJRgABAQAAAQABAAD/7QA8UGhvdG9zaG9wIDMuMAA4QklNBAQAAAAAAB8cAVoAAx sl RxwCAAACAAIcAhkAC1Bob3RvIEJvb3RoAP/iG6hJQ0NfUFJPRklMRQABAQAAG5hhcHBsAgA AAG1u dHJSR0IgWFlaIAfaAAEAEwAJADEABGFjc3BBUFBMAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAD2 1gABAAAAANMtYXBwbFYcEOZVYuhIRg5LwLIi62wAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAA AAAAEXJYWVoAAAFQAAAAFGdYWVoAAAFkAAAAFGJYWVoAAAF4AAAAFHd0cHQAAA GMAAAAFGNoYWQA AAGgAAAALHJUUkMAAAHMAAAIDGdUUkMAAAnYAAAIDGJUUkMAABHkAAAIDGF hcmcAABnwAAAAIGFh Z2cAABoQAAAAIGFhYmcAABowAAAAIHZjZ3QAABpQAAAAMG5kaW4AABqA AAAAOGRlc2MAABq4AAAA ZGRzY20AABscAAAALm1tb2QAABtMAAAAKGNwcnQAABt0AAAAJFhZW iAAAAAAAAB7vQAAQXsAAAJL WFlaIAAAAAAAAFYqAACp0AAAFF9YWVogAAAAAAAAJO8AABS1AA C8glhZWiAAAAAAAADz2AABAAAA ARYIc2YzMgAAAAAAAQu3AAAFlv//81cAAAcpAAD91///+7f ///2mAAAD2gAAwPZjdXJ2AAAAAAAA BAAAAAAFAAoADwAUABkAHgAjACgALQAyADcAOwBAAEUA SgBPAFQAWQBeAGMAaABtAHIAdwB8AIEA hgCLAJAAlQCaAJ8ApACpAK4AsgC3ALwAwQDGAMsA0 ADVANoA4ADlAOoA8AD1APsBAQEHAQwBEgEY AR4BJQErATEBOAE+AUUBSwFSAVkBYAFmAW0BdQ F8AYMBigGSAZkBoQGoAbABuAHAAcgB0AHYAeAB 6QHxAfoCAgILAhQCHAIlAi4CNwJAAkoCUwJ cAmYCcAJ5AoMCjQKXAqECqwK1Ar8CygLUAt8C6gL0 Av8DCgMVAyADKwM3A0IDTQNZA2UDcAN8 A4gDlAOgA6wDuQPFA9ID3gPrA/gEBAQRBB4ELAQ5BEYE VARhBG8EfASKBJgEpgS0BMIE0QTfB O4E/AULBRoFKAU3BUcFVgVlBXQFhAWTBaMFswXDBdMF4wXz BgMGFAYkBjUGRQZWBmcGeAaJBp oGqwa9Bs4G4AbyBwMHFQcnBzkHTAdeB3AHgweWB6gHuwfOB+EH 9AgICBsILwhCCFYIagh+CJI Ipgi6CM4I4wj3CQwJIQk2CUsJYAl1CYoJoAm1CcsJ4An2CgwKIgo5 Ck8KZQp8CpIKqQrACtcK 7gsFCx0LNAtLC2MLewuTC6sLwwvbC/MMDAwkDD0MVgxuDIcMoQy6DNMM 7Q0GDSANOg1UDW4Ni A2iDbwN1w3xDgwOJw5CDl0OeA6TDq8Oyg7mDwIPHg86D1YPcg+OD6sPyA/k EAEQHhA7EFgQdh CTELEQzhDsEQoRKBFGEWQRgxGhEcAR3xH+Eh0SPBJbEnoSmhK5EtkS+RMZEzkT WRN6E5oTuxP bE/wUHRQ+FF8UgRSiFMQU5RUHFSkVSxVtFZAVshXVFfcWGhY9FmAWgxanFsoW7hcS FzUXWRd9 F6IXxhfqGA8YNBhZGH0YoxjIGO0ZExk4GV4ZhBmqGdAZ9hodGkMaahqQGrca3hsGGy0b VBt8G 6MbyxvzHBscQxxsHJQcvRzmHQ4dNx1gHYodsx3dHgYeMB5aHoQerh7YHwMfLR9YH4Mfrh/Z IA QgMCBbIIcgsyDeIQohNyFjIY8hvCHpIhUiQiJwIp0iyiL4IyUjUyOBI68j3SQMJDokaSSXJMYk 9SUkJVQlgyWzJeImEiZCJnImoybTJwMnNCdlJ5Ynxyf4KCooWyiNKL4o8CkiKVUphym5KewqHy pS KoUquCrrKx4rUiuGK7or7iwiLFYsiiy/LPQtKS1eLZMtyC39LjMuaS6eLtQvCy9BL3cvri/ kMBsw UjCJMMEw+DEwMWcxnzHXMg8ySDKAMrgy8TMqM2MznDPVNA80SDSCNLw09jUwNWo1pTXf Nho2VTaQ Nss3BjdCN343uTf1ODE4bTiqOOY5IzlgOZ052joXOlQ6kjrPOw07SzuJO8c8BjxEP IM8wj0BPUA9 fz2/Pf4+Pj5+Pr4+/j8/P38/wEAAQEFAgkDEQQVBR0GIQcpCDEJOQpFC00MWQ1 hDm0PeRCFEZUSo ROxFMEV0RbhF/EZARoVGykcOR1NHmUfeSCNIaUivSPVJO0mBScdKDkpVSpt K4ksqS3FLuEwATEhM kEzYTSBNaE2xTfpOQk6MTtVPHk9nT7FP+1BFUI9Q2VEkUW5RuVIEUk9S mlLlUzFTfFPIVBRUYFSt VPlVRlWSVd9WLFZ6VsdXFFdiV7BX/lhMWJpY6Vk4WYZZ1VokWnRaw 1sTW2NbslwDXFNco1z0XURd lV3mXjdeiV7aXyxffl/QYCJgdGDHYRlhbGG/YhJiZWK5YwxjYG O0ZAhkXGSxZQVlWmWvZgRmWWav ZwRnWmewaAZoXGiyaQlpX2m2ag1qZGq8axNra2vDbBtsc2z LbSNtfG3Vbi5uh27gbzpvk2/tcEdw oXD7cVZxsHILcmZywXMcc3hz03QvdIt053VDdaB1/HZZ drZ3E3dwd854K3iJeOd5RXmjegJ6YHq/ ex57fXvcfDx8m3z7fVt9u34bfnx+3H89f55//4Bgg MKBI4GFgeeCSYKrgw6DcIPThDaEmYT8hWCF w4YnhouG74dUh7iIHYiBiOaJTImxihaKfIrii0 iLrowUjHuM4o1Ija+OF45+juWPTY+1kB2QhZDu kVaRv5IokpGS+pNkk82UN5ShlQuVdZXglkq WtZcgl4uX95himM6ZOpmmmhKafprrm1ebxJwxnJ+d DJ15neeeVZ7DnzGfoKAPoH2g7KFbocui OqKqoxqjiqP6pGqk26VMpbymLqafpxCngqf0qGWo2KlK qbyqL6qiqxWriKv7rG+s461WrcuuP 66zryivnbARsIew/LFxseeyXbLTs0mzv7Q2tK21JLWbthK2 ibcBt3m38bhpuOG5WrnSuku6xL s+u7e8MLyqvSS9nr4ZvpO/Dr+JwATAf8D6wXbB8cJtwunDZsPi xF/E3MVZxdbGU8bRx07HzMh KyMnJR8nGykXKxMtDy8LMQszBzUHNwc5CzsLPQ8/D0ETQxtFH0cjS StLM007T0NRT1NbVWNXb 1l7W4tdl1+nYbdjx2XXZ+tp/2wPbiNwO3JPdGd2e3iTeqt8x37fgPuDF 4Uzh0+Ja4uLjauPy5 HrlAuWL5hPmnOcl56/oOOjC6Uzp1upg6urrdev/7IrtFu2h7izuuO9E79Dw XPDp8XXyAvKP8x zzqvQ39MX1U/Xh9m/2/veM+Bv4qvk5+cn6Wfro+3j8CPyZ/Sn9uv5L/tz/bmN1 cnYAAAAAAAA EAAAAAAUACgAPABQAGQAeACMAKAAtADIANwA7AEAARQBKAE8AVABZAF4AYwBoAG0A cgB3AHwA gQCGAIsAkACVAJoAnwCkAKkArgCyALcAvADBAMYAywDQANUA2gDgAOUA6gDwAPUA+wEB AQcBD AESARgBHgElASsBMQE4AT4BRQFLAVIBWQFgAWYBbQF1AXwBgwGKAZIBmQGhAagBsAG4AcAB yA HQAdgB4AHpAfEB+gICAgsCFAIcAiUCLgI3AkACSgJTAlwCZgJwAnkCgwKNApcCoQKrArUCvwLK AtQC3wLqAvQC/wMKAxUDIAMrAzcDQgNNA1kDZQNwA3wDiAOUA6ADrAO5A8UD0gPeA+sD+AQEBB EE HgQsBDkERgRUBGEEbwR8BIoEmASmBLQEwgTRBN8E7gT8BQsFGgUoBTcFRwVWBWUFdAWEBZM FowWz BcMF0wXjBfMGAwYUBiQGNQZFBlYGZwZ4BokGmgarBr0GzgbgBvIHAwcVBycHOQdMB14H cAeDB5YH qAe7B84H4Qf0CAgIGwgvCEIIVghqCH4IkgimCLoIzgjjCPcJDAkhCTYJSwlgCXUJi gmgCbUJywng CfYKDAoiCjkKTwplCnwKkgqpCsAK1wruCwULHQs0C0sLYwt7C5MLqwvDC9sL8w wMDCQMPQxWDG4M hwyhDLoM0wztDQYNIA06DVQNbg2IDaINvA3XDfEODA4nDkIOXQ54DpMOrw7 KDuYPAg8eDzoPVg9y D44Pqw/ID+QQARAeEDsQWBB2EJMQsRDOEOwRChEoEUYRZBGDEaERwBHf Ef4SHRI8ElsSehKaErkS 2RL5ExkTORNZE3oTmhO7E9sT/BQdFD4UXxSBFKIUxBTlFQcVKRVLF W0VkBWyFdUV9xYaFj0WYBaD FqcWyhbuFxIXNRdZF30XohfGF+oYDxg0GFkYfRijGMgY7RkTGT gZXhmEGaoZ0Bn2Gh0aQxpqGpAa txreGwYbLRtUG3wboxvLG/McGxxDHGwclBy9HOYdDh03HWA dih2zHd0eBh4wHloehB6uHtgfAx8t H1gfgx+uH9kgBCAwIFsghyCzIN4hCiE3IWMhjyG8Ieki FSJCInAinSLKIvgjJSNTI4EjryPdJAwk OiRpJJckxiT1JSQlVCWDJbMl4iYSJkImciajJtMnA yc0J2UnlifHJ/goKihbKI0ovijwKSIpVSmH Kbkp7CofKlIqhSq4KusrHitSK4YruivuLCIsVi yKLL8s9C0pLV4tky3ILf0uMy5pLp4u1C8LL0Ev dy+uL+QwGzBSMIkwwTD4MTAxZzGfMdcyDzJ IMoAyuDLxMyozYzOcM9U0DzRINII0vDT2NTA1ajWl Nd82GjZVNpA2yzcGN0I3fje5N/U4MTht OKo45jkjOWA5nTnaOhc6VDqSOs87DTtLO4k7xzwGPEQ8 gzzCPQE9QD1/Pb89/j4+Pn4+vj7+P z8/fz/AQABAQUCCQMRBBUFHQYhBykIMQk5CkULTQxZDWEOb Q95EIURlRKhE7EUwRXRFuEX8Rk BGhUbKRw5HU0eZR95II0hpSK9I9Uk7SYFJx0oOSlVKm0riSypL cUu4TABMSEyQTNhNIE1oTbF N+k5CToxO1U8eT2dPsU/7UEVQj1DZUSRRblG5UgRST1KaUuVTMVN8 U8hUFFRgVK1U+VVGVZJV 31YsVnpWx1cUV2JXsFf+WExYmljpWThZhlnVWiRadFrDWxNbY1uyXANc U1yjXPRdRF2VXeZeN 16JXtpfLF9+X9BgImB0YMdhGWFsYb9iEmJlYrljDGNgY7RkCGRcZLFlBWVa Za9mBGZZZq9nBG daZ7BoBmhcaLJpCWlfabZqDWpkarxrE2tra8NsG2xzbMttI218bdVuLm6HbuBv Om+Tb+1wR3C hcPtxVnGwcgtyZnLBcxxzeHPTdC90i3TndUN1oHX8dll2tncTd3B3zngreIl453lF eaN6Anpg er97Hnt9e9x8PHybfPt9W327fht+fH7cfz1/nn//gGCAwoEjgYWB54JJgquDDoNwg9OE NoSZh PyFYIXDhieGi4bvh1SHuIgdiIGI5olMibGKFop8iuKLSIuujBSMe4zijUiNr44Xjn6O5Y9N j7 WQHZCFkO6RVpG/kiiSkZL6k2STzZQ3lKGVC5V1leCWSpa1lyCXi5f3mGKYzpk6maaaEpp+muub V5vEnDGcn50MnXmd555VnsOfMZ+goA+gfaDsoVuhy6I6oqqjGqOKo/qkaqTbpUylvKYupp+nEK eC p/SoZajYqUqpvKovqqKrFauIq/usb6zjrVaty64/rrOvKK+dsBGwh7D8sXGx57JdstOzSbO /tDa0 rbUktZu2EraJtwG3ebfxuGm44blaudK6S7rEuz67t7wwvKq9JL2evhm+k78Ov4nABMB/ wPrBdsHx wm3C6cNmw+LEX8TcxVnF1sZTxtHHTsfMyErIyclHycbKRcrEy0PLwsxCzMHNQc3Bz kLOws9Dz8PQ RNDG0UfRyNJK0szTTtPQ1FPU1tVY1dvWXtbi12XX6dht2PHZddn62n/bA9uI3A 7ck90Z3Z7eJN6q 3zHft+A+4MXhTOHT4lri4uNq4/LkeuUC5YvmE+ac5yXnr+g46MLpTOnW6mD q6ut16//siu0W7aHu LO6470Tv0PBc8OnxdfIC8o/zHPOq9Df0xfVT9eH2b/b+94z4G/iq+Tn5 yfpZ+uj7ePwI/Jn9Kf26 /kv+3P9uY3VydgAAAAAAAAQAAAAABQAKAA8AFAAZAB4AIwAoAC0AM gA3ADsAQABFAEoATwBUAFkA XgBjAGgAbQByAHcAfACBAIYAiwCQAJUAmgCfAKQAqQCuALIAtw C8AMEAxgDLANAA1QDaAOAA5QDq APAA9QD7AQEBBwEMARIBGAEeASUBKwExATgBPgFFAUsBUgF ZAWABZgFtAXUBfAGDAYoBkgGZAaEB qAGwAbgBwAHIAdAB2AHgAekB8QH6AgICCwIUAhwCJQIu AjcCQAJKAlMCXAJmAnACeQKDAo0ClwKh AqsCtQK/AsoC1ALfAuoC9AL/AwoDFQMgAysDNwNCA 00DWQNlA3ADfAOIA5QDoAOsA7kDxQPSA94D 6wP4BAQEEQQeBCwEOQRGBFQEYQRvBHwEigSYBK YEtATCBNEE3wTuBPwFCwUaBSgFNwVHBVYFZQV0 BYQFkwWjBbMFwwXTBeMF8wYDBhQGJAY1BkU GVgZnBngGiQaaBqsGvQbOBuAG8gcDBxUHJwc5B0wH XgdwB4MHlgeoB7sHzgfhB/QICAgbCC8I QghWCGoIfgiSCKYIugjOCOMI9wkMCSEJNglLCWAJdQmK CaAJtQnLCeAJ9goMCiIKOQpPCmUKf AqSCqkKwArXCu4LBQsdCzQLSwtjC3sLkwurC8ML2wvzDAwM JAw9DFYMbgyHDKEMugzTDO0NBg 0gDToNVA1uDYgNog28DdcN8Q4MDicOQg5dDngOkw6vDsoO5g8C Dx4POg9WD3IPjg+rD8gP5BA BEB4QOxBYEHYQkxCxEM4Q7BEKESgRRhFkEYMRoRHAEd8R/hIdEjwS WxJ6EpoSuRLZEvkTGRM5 E1kTehOaE7sT2xP8FB0UPhRfFIEUohTEFOUVBxUpFUsVbRWQFbIV1RX3 FhoWPRZgFoMWpxbKF u4XEhc1F1kXfReiF8YX6hgPGDQYWRh9GKMYyBjtGRMZOBleGYQZqhnQGfYa HRpDGmoakBq3Gt 4bBhstG1QbfBujG8sb8xwbHEMcbByUHL0c5h0OHTcdYB2KHbMd3R4GHjAeWh6E Hq4e2B8DHy0 fWB+DH64f2SAEIDAgWyCHILMg3iEKITchYyGPIbwh6SIVIkIicCKdIsoi+CMlI1Mj gSOvI90k DCQ6JGkklyTGJPUlJCVUJYMlsyXiJhImQiZyJqMm0ycDJzQnZSeWJ8cn+CgqKFsojSi+ KPApI ilVKYcpuSnsKh8qUiqFKrgq6yseK1Irhiu6K+4sIixWLIosvyz0LSktXi2TLcgt/S4zLmku ni 7ULwsvQS93L64v5DAbMFIwiTDBMPgxMDFnMZ8x1zIPMkgygDK4MvEzKjNjM5wz1TQPNEg0gjS8 NPY1MDVqNaU13zYaNlU2kDbLNwY3Qjd+N7k39TgxOG04qjjmOSM5YDmdOdo6FzpUOpI6zzsNO0 s7 iTvHPAY8RDyDPMI9AT1APX89vz3+Pj4+fj6+Pv4/Pz9/P8BAAEBBQIJAxEEFQUdBiEHKQgx CTkKR QtNDFkNYQ5tD3kQhRGVEqETsRTBFdEW4RfxGQEaFRspHDkdTR5lH3kgjSGlIr0j1STtJ gUnHSg5K VUqbSuJLKktxS7hMAExITJBM2E0gTWhNsU36TkJOjE7VTx5PZ0+xT/tQRVCPUNlRJ FFuUblSBFJP UppS5VMxU3xTyFQUVGBUrVT5VUZVklXfVixWelbHVxRXYlewV/5YTFiaWOlZOF mGWdVaJFp0WsNb E1tjW7JcA1xTXKNc9F1EXZVd5l43Xole2l8sX35f0GAiYHRgx2EZYWxhv2I SYmViuWMMY2BjtGQI ZFxksWUFZVplr2YEZllmr2cEZ1pnsGgGaFxosmkJaV9ptmoNamRqvGsT a2trw2wbbHNsy20jbXxt 1W4ubodu4G86b5Nv7XBHcKFw+3FWcbByC3JmcsFzHHN4c9N0L3SLd Od1Q3Wgdfx2WXa2dxN3cHfO eCt4iXjneUV5o3oCemB6v3see3173Hw8fJt8+31bfbt+G358ft x/PX+ef/+AYIDCgSOBhYHngkmC q4MOg3CD04Q2hJmE/IVghcOGJ4aLhu+HVIe4iB2IgYjmiUy JsYoWinyK4otIi66MFIx7jOKNSI2v jheOfo7lj02PtZAdkIWQ7pFWkb+SKJKRkvqTZJPNlDeU oZULlXWV4JZKlrWXIJeLl/eYYpjOmTqZ ppoSmn6a65tXm8ScMZyfnQydeZ3nnlWew58xn6CgD 6B9oOyhW6HLojqiqqMao4qj+qRqpNulTKW8 pi6mn6cQp4Kn9KhlqNipSqm8qi+qoqsVq4ir+6 xvrOOtVq3Lrj+us68or52wEbCHsPyxcbHnsl2y 07NJs7+0NrSttSS1m7YStom3Abd5t/G4abj huVq50rpLusS7Pru3vDC8qr0kvZ6+Gb6Tvw6/icAE wH/A+sF2wfHCbcLpw2bD4sRfxNzFWcXW xlPG0cdOx8zISsjJyUfJxspFysTLQ8vCzELMwc1BzcHO Qs7Cz0PPw9BE0MbRR9HI0krSzNNO0 9DUU9TW1VjV29Ze1uLXZdfp2G3Y8dl12fraf9sD24jcDtyT 3Rndnt4k3qrfMd+34D7gxeFM4d PiWuLi42rj8uR65QLli+YT5pznJeev6DjowulM6dbqYOrq63Xr /+yK7Rbtoe4s7rjvRO/Q8Fz w6fF18gLyj/Mc86r0N/TF9VP14fZv9v73jPgb+Kr5OfnJ+ln66Pt4 /Aj8mf0p/br+S/7c/25w YXJhAAAAAAADAAAAAmZmAADypwAADVkAABPQAAALA3BhcmEAAAAAAAMA AAACZmYAAPKnAAANW QAAE9AAAAsDcGFyYQAAAAAAAwAAAAJmZgAA8qcAAA1ZAAAT0AAACwN2Y2d0 AAAAAAAAAAEAAQ AAAAAAAAABAAAAAQAAAAAAAAABAAAAAQAAAAAAAAABAABuZGluAAAAAAAAADAA AKPAAABXwAA ASsAAAJ5AAAAlQAAAEwAAAFBAAABUQAACMzMAAjMzAAIzM2Rlc2MAAAAAAAAACkNp bmVtYSBI RAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABtbHVjAAAAAAAAAAEAAAAMZW5VUwAAABIAAAAc AE MAaQBuAGUAbQBhACAASABEAABtbW9kAAAAAAAABhAAAJIjAgAqqcBCT4AAAAAAAAAAAAAAAAAA AAAAdGV4dAAAAABDb3B5cmlnaHQgQXBwbGUsIEluYy4sIDIwMTAA/+EAQEV4aWYAAE1NACoAAA AI AAGHaQAEAAAAAQAAABoAAAAAAAKgAgAEAAAAAQAAAoCgAwAEAAAAAQAAAeAAAAAA/9sAQwA CAgIC AgECAgICAgICAwMGBAMDAwMHBQUEBggHCAgIBwgICQoNCwkJDAoICAsPCwwNDg4ODgkL EBEPDhEN Dg4O/9sAQwECAgIDAwMGBAQGDgkICQ4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4OD g4ODg4ODg4O Dg4ODg4ODg4ODg4ODg4O/8AAEQgB4AKAAwEiAAIRAQMRAf/EAB8AAAEFAQEBAQ EBAAAAAAAAAAAB AgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhN RYQcicRQygZGhCCNC scEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RV VldYWVpjZGVmZ2hpanN0 dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4u brCw8TFxsfIycrS09TV1tfY 2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQ EAAAAAAAABAgMEBQYHCAkKC//E ALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXE TIjKBCBRCkaGxwQkjM1LwFWJy0QoW JDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZX WFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWG h4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5u sLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp 6vLz9PX29/j5+v/aAAwDAQACEQMRAD8A+To438 n5HVipGCR1q/Em6JiwOc9PrUEKbY8KCd3SrsUZ UfNk7sZA9ayje2jCbjHboWoh5gIJCsp71PH ErQltrDcfXrTQvz4I4zwAeasLE8bcA4HTNNU07u+o oKLd0yaJFEWQCGwCD6CtJBFsOCSx6nNV wuxE+Q5HC59KsxxuACNufUjpWjp33ZLm0TwpsdgnO3HG fWrSjEnG7nr+VQRIWYqQ3J4q7GMNn OCTilaNxzaT1Y+NWSJQw6Lipo13RM3pgfjUa5RzuO5asxKi kEkYA6VqtI+pLaWhZZRJFGDlyM kY71MgUIecgjn61HbguCjoyg9+lSxoVbDHbnkkjpWaj0fQldri g52+4yaFj2y8AqvbmlCls4O DnAJ9Ksqylxxz0Fa81mrIt3jHTqKkW/DZ5PJxUgQhmByXBz+lAAUA k9D2qcbAFZTuPcntUOUm 7Iq17SYigNGoYbiF7UhGJfQAZz2zUiliduRwdtTkYQhSFXd1NDk927lR Scit85kG3gnHFLhwA FHOc/Wp4wNuVHHc1IACxB7D16VE2+a6QX3uiEJlD8pyTkj0p2394QQSQMYF SnOSMhe3Sm7Cjq mecck1fNeOpMoprRgFOCOetOcMSrHg9elPAxIN2MHkcdqcRltuDg85rG0m9EFu zI/LRWwAScZ Jz1qdYQ6EkYZe1KiFnG0qPXNPwSQpJI7Y705yfRltSe5XdHLbu2enpT1HzorHOBjH tUmxhkkP j+VTJGwYFgNp6k9qXncIzTViAxgAjsPU0Kn4euas+WcOx4GaBHk7sL1GM96q65Xd3KjF W0ZFj aSQQ425NSrGSARwTkA9s0qRqCSenQVZCrsO0MOelRKSTSSM3q9Csy5Us3LnH51IYyYs5zg8 U4 JyTtJwanCsEwMHDZxjkD3q4JNle8rscgySOM/xe9O2sMdTjJx9KmSJt7FgASevtT1j3MCucg9/ SpcnfVGcVGz1K5A3K5545+tChshh1PUmrYjUxlQCy57elBSQPwowDjmm9VuNP3WV+EYEhsZwD7 1J 5J8hPmBPU+9WFBEhLAcgHkd6MFQvTI45PQVV9dUOzbuiqy7QY14wc59qV41LYHJz61Oqqck 5Ck55 70BAH3N+B9aXMloWm4+RD5fBU8Z70/CGIIRkgCpH+UZYZUd6REClRz64PUVUWum5Ls3e 40KOWPJJ ySO1O2ARk7SecjHrUx4G7aCuc1IIt5G3PqwpXfVA1FSRXXPkruO3PApArlxhgFAw1 WOA4UgLzyT/ ACppUuu3B2selWvd3RDTTd1Yr/OZM54PU+1QqDu5OwDocdauldockfTNQAttBl AKdiB1pdW2i3Hm V0tCA9HwDgNjFVHAM24Da2CRn0q80eWPGFIz9DVdo+u75h0yKiN4PRgrRV7 3KgDbl24B5xnvUbB9 x2jAH86uBBgsM5XvnpUBDEE7lJ7itLoEkiIKwO5Dgn1HWlbywxDk5HQ+ 9TE7odqDaucjPUVGw+Ut wxPYCsVBbBy8xG65jcHJycDHeq4j3LuHAA+6fX1q9jeMgDJ6HFQbS YsZ6frXRBNJ3JbtuisBiTK8 8jj3pXZyjsOCCOoqfaDKCo+975pxVhHtwAMYJNZ631FzK+xS2h gxYcqvr1qIYDbiCMj86uGPDBWH TuO9Q+UCgC/fz1NaXhs2a+0SVkQBR5me5/Wo2CkMGyvHU/W rRTM23DZzyewprKwKbgSMdMVlzLWx GiaZWZjgncMdAKh2MZPu4wME5qw6cL8v3RSNteQqnUD5 qSduhUndaFJ0zDg9R1xUBjDJtwdpXIIq 4yb3L7H9wTTHXbBtTHzdCaq/K77icVoomeYiybHO0 d6rSJt6EEdiexrTKtxsXLY5Paq7RMJgSAV9 AO9aRloxwhzbsoup2LzkHjFV5EJCg4Cgg5J71o vHvZQT8oyenJqJok3spy2RkUmna19yNFKyZmkF Qp77TkfWmYAUKwK854rQ8tGiBLLnuPU01kX G/cCc8nHFRey1KbVrWM4oPvAhWB4J7etV2RfkUckN lauunm4JAHJOMVXlj2gEHoM5qKcYq/mV Dkdmys20naecZGRVcqQAduATyPStJAB8q7Xz6dxULsm0 gRk49T1pTcl0M5rXRGaYwqAschhgj pz2pohVSH5DAYPPerk4Aj2k8KPkJqMqhVsjA+8aykoyV1qy KrbdmcPbIxtyuVDk8fnU6rIGwd ofkdO3rTIInVztB4HrV6AMW3Pznnnr9K0Ti/e6lxS2JI0Cpuxu I6Cre1WXBIyM8jtUaBWcFM4 PcnirO35gvOSMgY61cZrZjcHGWwi7iuCcj+Ee1X9hXaW4GOcnvUSq DtAIYDgEVbRCBubJwcVS kk77EX5unyFQnapVjwfWrigjkHDcHJ5qFVIjD4G0jgkd6srGGm5IU4ye a05+idg93axKCN6kr vBPIFWU2iR8grn7opIIwTtDAgc4qYRESj5DhelJVOgRkh6K5X5uFHT3NWM5 D56ds0kSkCMEdu hqVEEm4ZAb+lNpLV7DhK716CAgAZIXb6ipCSUYfx54I9KcEGdzfMO3vUwUsQdp 9zUOSvciDs7 iRhfJAI3AEDOeasEqVZQhB3YzTFXksFOMAjPQ1KuWl5GOetOUrlNSXoBQHaOjcfnU ka5U8YHb PenlOeDnjipowcruCliMAAVkqivoTKWtyNFwrZHy+3rTRlnYpww9RVlk25yehAxTdv8A dGQD0 qlO+xoprls9xrMCB8wyf0pCuWHfHenCPMuzGOeuKmSM7kBIZTyfX6VF9GPVOxCvLZPOD27V KE bIJypB4PtTyquWCgkcdOtSohG35WbB4FKLk7hLyIwrGU7umck461OqnIBABxxgUoV15QBhyRxU 6eYeoyMfLxRe1rpBza3bsQZQjcfu9TUygsrkKewB7U7CBcbcZOOaeyB9wG4ZYdDilLlRLV43aI TG uPmJ6cD0p21sBODjgcdqnxmPIU8cD6UuTtw2D6cYzQ46FySk1cjCfu8MMgHjFAQlNoGVz1q 4qghS VyT2oC4QYVjk5JHalC9rijOMVpoVkUhyQC4x0FWQB5bYG3d1Y/yp+z+JAct/KpnX5Azj bz0NNTaY k4vcr7ZNoyQwHT61NtO44OM/p7U5doVdx5IPSpgCEAGCTxWmluxUdFqyMAhcHO3GK cSfJORkAjp6 U9cFgMH/AGs96fszGV2n1xUzXSwKdyIE7GUjjqPWmMMjoTk8+9TjG7ZyzY5FLt 3KNwwR29KXw9Ac bNELITCVOGbsQKaAxkI6AdzVkgliFU7R3pdqm4YlSoOMZp82gcyk7EGNyjc QwxggU0A+aXCYY+tW lTO3b3PXFKEKJwCcVSfvWsVztIgbe23cPy70/Y2CBnPXr0qVfmBVl4Jy MU9IwAR0HbJ6Ucz6IzTa 1kyLYMDncWHTHU0NkAKFxjjOOlP2Ethn4HpSAbnz144BPNNy1CMdd dSsWy43jdj26UhH948jsBUp QtIwxtIPT0pWX94GUHO386huI3Ho0UXjV3ZS5Uk5AzTRGFkJOc dvarGBvxgk7eD6VHs3RKTuDZwc 96J3ve5bTatcpMSrPgD73PFJJHnHG47cjjvVh4w82GO3NN2 lmbkbRwvrRGpZak8qbK4RdoPc56U1 Yssc8Lj8an2FWA2kDrmjaSJF7Acgd6qE2nZAoroVzkRh MHryR601lLK/RecdKsbVQhs5BOMZppUD LDoWyM/zpuUn5lUuVN6FFDhsgcKecUOQVODhifu1b KjcSBt9TUJQqRlcE8Z60m1fUUpq2xAQxG4Y yM5yKiY8jcV6YOBirexgvIyvUkVXKhotuNp7E+ 9J27ExlddyJkzGqA8g4+opgQ+YTuyoOfc1MQm/ axIIXtSIqHDchgp4zS5ujJcXazKnl43NnIL fjTCWBJ+UZzxire0OMgYXnPNRMpZQNpx9KtSuh3Kp QH0BA4Gar/NuJVlK5wAfSr5HAJwQOw6i oiBsJI2nODx1pqL6FOS27lRlj3ZUnntmogqtuyCBwTV4 qBDwPusB7moggxgMApOSKz5o3sDir XKJiGAD/A3B9cVG4zCCAADkZx61oFi2F2NtyeSKg6wfKowD 0I6+9a03fWRn71ym0YEW3AyOp9 aq7Au4ckdh6f8A160Xj/dDdnPbNQeSCDuJIz69TVWuro0VmUHC 7AuGG3pVfyQ1xt3YBB4NaDQ 7Mt1LHHNRybWK7mULjhhwBUPmbuiZtRdkzLwF2quA5GR9KrvG29lY ckdfStJkJj3BVYLxkVCV dUBxuycnik5a3BN82hQ2c/N8317VAY2JZuMY45q+6b0Jyc+npVRl2Abu F6hu2ayjPmlpv6BNy scWkR3jy2LAnB9quLjesZZX+nGDTY1jLkYZT6E9asDHnMccqeKelryZTjL1 FjYKFB7cYq2u1S u70xn29KYqLlTtPIz06VONznDHCYHUd61cby00RKcXezJgQASANuMgDrU6FimE yVwBmmIMbVy B2JI4qwmVOM454GOoqXZIlOUVoTJhjtY8A4/GrCqC+FPze/NV0CEMNxGfSrKAL8oy W65PaiKT WzE27WluWgxVtnG4kc46+1Wgrn7o4zwT2qqh+QO3UNjOPWrhP7kBWUELzSu001oRy20S J0VVG 7JYHoKkjAAdz8pNNjKsFVVZiF69qVmDBAM5x2rSMeZe87lXT0JFXmMs4GB/KnLkRhgxyR2/ Wl 2guoxyeeakj27UGQp64PaovJO6Qk3HbUWP7/Xv8ue9TkOzPtA4PpQEBnDg8Dr6GpSPkLA8Yxij nTZq7qwJnYA+SSOwxzUwVlkG44OM/SowMptTjB6mpwoZSTnI7Z7VTb6iWj2H5wDlctjIPpTRyw zn 3xT1RSRklcdc1IFCsGzjI9O9ZxUNbAnbWxXCKThSR3GT0zU5XaVXOWPp3pwX93uCk/NyalR RyWyp BOM9jROS66E8zvoNU4I+Vl55JqULnc/IYniiMeZHuHQHk9jT1x5mARzzjHIp8umxq3ro PXBVQMgj v2qQLu3jBUfWpPLOVw67SvI9akXaRxlm74PWnLTUjpdkIiHnsDkgHIqdFYxFmVcjv ilCsMSLxz1N SspDc4xjJFQotyuLV69CGMkPyOnftineWwkIC5FPKDY69SDSKjFzjKgf3jRbRt sbgl5Cbdy4YEYA BqTaCjIOdvB9alVG8xsLlcYA9akCYIAOecDNPmu97FJx7kYwD042kcdqVc7 djfMAOB3p3zbgOPfj rUobLDK9tpx2pRi7aohNX1GgKAoIB9Tj9Kbty6gZHGf/AK1WNoQhscA0 9VHILA5746VVkXGViEr8 4VO1WAFADt0xg+1TBQCCRnjOaDGrx/KOSOM9KhN3DmVyodyvlRkg5 6cmnsN8gYqykjBx61N5W5lH LEccdTStGQQAhD9GJqrpyDroQfO0ez15/AUpOTt2HINSiLPAzu we9PIIBADZI4/xofI1sNMhKYkB /g6/So1VPMU/MCw/vVcyWhIBDY4IqIgAqMHjP+RUwV2xvfc RP9Y3QkYwaXG+ZW3Acf8A6qGUlAFw CD940Z287d+ey0cvYjldrpjH+/k/dJzSIcHd/CR8pqZQ Cxdl3Hdzjpmoir8jrk4A9PWtFcE00MIA BLN8xP6VEdwZW4wPxqc4Uj5fl7Z7VEyjcSu7BPU9A aUtgcrtXehCQxcE460wuRKqEdRgVOR84Bx0 /P3prYAUYDY6kVCk7lOGlyFolCbm5btUBBVs42 qvT6mpMNwwbryBUwAywyCSfyrR3jZkxauVAoK4 bOM8HNC4yybfmPUipNuUAYZOfzoCsZAOAev /ANamo33L+FXW5WZFEhHX0BpxXzHcgDdx19BUzEbi WXBJPHeo0UtJvCsgHBU+lTL4dTOfe5Xd B5pZuMHIUd6iYZbvgkVbkG1TznHf0qFkzI46D+92qeZ7 iSRWcZn2nIbODzTCpGSMHB61YZf3b ZGAO+eTVd4yA2MnPvQk927DVlKxG6jBJwcnk+lQmMFTjJA7 9KlkBWLnO3pinEfIQpHT8qpNtI NU79Cru2odvA6NSMjcBTkY61JtJhDNjlhkAdKeU+UgOFbPJP8A KrejuOL10RVK7IyGI64B9Kj 2Ah9wPTKk1O0bFgjnbu6d6btAdhtLc4P+NLRa9yb62bKm3EQDA4xk ketMaM43Y/i6Y5FW2UHG Bmo34PAy9KU7i1asirIGKY7kjgVEQSp3HbjjPqKsSKQTj0BNMPzbsYLd 89Kq+qsVfuQk5YFun QAjioyp2KQDyc1aZThkG3JHSo/4FTBOD8xH6UlG6egnLQolN2dvQDnNVGBC 4AUg9citd1YLjK nJxgcVUkiUQMG4YccU4ya06Cg0tWZ3lLu+X7uM4BqrInzttcH6VpHcG/2T271W aMoF55Kk+9S k3rsaK6bszPkiITczAFuQPQVUMSlPmOQvH1rSePMALZLH37VWkiXJ2HaOvNVzWRPN b1OQ4YLt VTjrxU8cQMiylcDrnHenIvzljjcTgAd6nXIjYN0B5XuKxTd9URHR3S3ARE5wAARmpo1Q hiQ2R j5T1BpQF25DfLwQfapFXDuFBwTz64rW11ZsfM7bCoMHOCTjn61OpUv8yjJ6EdqbyoI5JPPW pl GG46EdamMW90Wo6PUkCAN/CBnJOKsKp2fKC7dGqNYgmCT1NXIwPIC9VPP4+lVTcWmupM5K2jJI 1O1QBwTnj6VZjj2jcWyccj3qJUwqYJXnvVlVUBSCBk9D2qV7quZubY5RkEDIAHGKlVeMbSpzzU aq AQc4GOBVhAH4JwelP3+UpxaW+g+NldSDw+MEU5QWlBAx2xjrTEVftDY5I61Nyr5IJPt0pcj TLj7z 0JkY45IBI6YqQr8gHUDpxUaIABtB5PP1q7tGPccYNS0oscFyvQYnzH5cZPTFTKORwRxw PWkC5BOM g8DFSrncFPUcim1bUUnfW4g+VMhSc9c1IY8y7ucA9DQY9/GTz3HSplAEOQDknvT3W gruyY1FyhLn AHIx61LtBjDf7XNNCtySMZ6gU5cnae/92okubqNJLYkCbVIGMY605OeSAPl60r DYM9MYxzTwMvgH APX0ojZ7scZN6MlVCyKXGAv4VKFUSsSQBnK+4qJA7Bg2Qo6L6fWrqrGdxUb l449KblyqzYWdvIjV RvwPnDcn8KmweODgccinEYlAwMdqUoTIoLdOabWt2Jt3syHbmTcOucED uaVtoVQ3BPGTUxjIz6hs 5qTblG+Xjd+lU5K1xrTV7FYJljlzgHOe1TbeSVVs49e9O4JcsMKT0 FSKQrjB4789awblbRAoys2Q KSrD5Tk8gnpVhUAKkLuGCTShN69yw6Uqn5ioBU9+atJuzsN2+H uC5VyzggehqVETaBtzz69RTkiM krPj5SvQ84qRlOQxI2jsKeknbqNWTsmO2AH3HamfMpXcBnG Tx0qTkwED0zn0pu3OCRnjinFa6kNN qzDsc8DI5pwB35Jy3b3p3BTIBA6nNAQl+CM96hrW76By WtYjA+YKpw3XB600LIsvIOO4P6VM0Yxu AO49TT8HA7GqUU3ce22hCMkDPDdzSYUjef8AgIqXy y2Rnae9DR7EHoOopJR72BRs1Z7kecJxt56i onGEOB8zDtUyoAvBOKQ7BKvXJAzzTh1sCa2K4f BVf4R0A/nTF5OC+M9qtMIyWO3B6A1CF3bcA8Lx xThNrQJLaxExHmE9RxgetM25cAErg8ZqfGE IYYbFRH5SgK5yOPXNTzeYN3diEjAOBnLcYprIRIVY YGOtT7S67sYYjcaZ5TYJIOc4BpJWepLT tbYYRmIKFI3dKhIw6jnuM9quKH/dgjgc5xUG3Mm/rk/d x0q0+bQtVJR0RFyGVSQcDgUMAXkJU gk5OP5VPtDOcckHtUcyEoSAVJPA9Kq8X6kc3NIr7cyAMDgj jHtRkrICSAT7VMuPkJyG757Gkb PJK5J6YpO70YSd20VHUvHu3AnPboajbCOFOArHBq2w27VAHTp6 UEApjAPf6VU2r2HotEUnU7m Kjfnt6VGwcnbt2kDnjp71aIDMVOdxOQe2Ka6kZ+916VLp3KXLzLuZ +Nr84Y+9H7sx4XhzyRVn y9z/ACglsY3f1qLYBDjGCT1o91oLK+4xwTGGjAbPUCoMZi+YgOOntUxy nVie4x2pzKu7jJDHG aHZLREu97FSSNmdVLDdnAIFMbchCEAsBzVqRS0eSOfT+tRTZMTv6kA4pqVk KMddSmR8wDZwR8 xqPgP0zxx71ZCKpyQcBe571AB1yRuHFVKSauXBLUjI5BK4Lc4PVaj2neVcbV6H AqVVLMcfNxy TTSDuBJ68GpgrPUma7kZCMDjIG7hqgWT5dp5OCOP51aZVdODx6VF5ahSxI3H5sY7V cWr+YrNL QiK8DeOT0AqHY2/Zt4znJqwSx9MY4JFRYYHrhRyfrR7NpPQUm7FKdRvHXKjk+tVH+b7o IbsfQ VpMv7sBgOhzkVAy/IW4AwO1EZpoqLcUrGbJBjb1YdiOlVJUGG2Ho2AOta7BQW6nB5qjKWZA Rh cHJOOvvSjNteRK35n+Jx2zLkFgB1x3qwvlvCSDknuPT3pkKctv+ZmH5VbWFfKP8K+1S3Hlu0VT UktBiKcxqMHaMfWpxuMhJGMcYNBVdyYyScZ9qlCjJOfl3Zya0m9UiHT1fMJ/GyhSnOSakRQck5 JH 8IPSnCIhiSmSGxmpYgysWABOemKlRvKyKUktiVUVlIySOuM81NHGpmj+96gg96EXeylDjHH Tp9au Ip3cYP0p8zQRu9ESAHY2SBhh19KeFbaVHzKCOlNwjxkMD8w5we9Tqu3Z8hPfg011E2kt h3lg4JyA Dz7VKuQcfLkHPPYUKMyZJ2jqM96kCq4BAZiTyajTVMvVR1YKpbBHIz27VaWMKvJye 3vUIiKybRke v+NWgCFBPJxzTlJP4SVGSSdxIwjOWyRnselWE+Y8fNkcUxAxVTtB+g6Va8sYIT txnNRdKSTKbYCJ ieMrgjgVKsZYNjIG7IzzRGDu4BG08GpVXMiu+evABpzk2LXdkUauHOCMHqK mUbZguCR0JPalYEKw PUdcCnxgNEzEYI9TWU00hWd7oQJ8mCc4HBqQIqKpzu29/Q0oBOFwATzU xBCMduOeRUtONrlNydk7 EcQDIykEtnj29qmaPJXAK4680KOFbBJI7elPCbo1wduFJwfSq91dC rPo7Eq/eUBflxxUwABOcgf1 pqACFN5UsecipkAMg3Hg88UoyVr2JVm7Dgm9t3UEZFP2hFBJxu 55pFB3gKrFgOlPKMV3N3PB7VTv a7GoJvYRow+5lYDP5mlXIZOp4pdjBFUtlRnGKkERBBIbnt3 FO91ua83LFJ7BtT5jwBnFO2jPsB19 KkCkgLtO3GfrSgEvgkAEdMcmkrcy0FGzW+gxQCoKgtzj ilwROARySefapgm04UfNnnFKfvqSvXpU 3voTe70/EWNZd3P44qRwfOAAHPUYpEVmkYDpnjH6i pwrB9nU4x9KmWjJu1Ig8vLAFTg9DninKCV2 gd+CalUk8A/MTmpFIbKt1rbm01RTjK9yAAyOfu qRwB604ghAuMMcGpU6kDGD3I5FIqfMWJwVOMno aj3t0hTjeRCsZXJbGPrSYPmYP3SMZqcbiow uQvf8aUhCVycEH7o61oovqGlyAwhFzye3JpPvNjnb jg1Ic/eLA59+lIMeaG2nG39ahxa3KbaV 2RqpPzbhjBycVEUxGoI5PB561YOzgdCOvvTijMSy8qDk AGs3U5CFzXuykw7DnHQe1NHCHjBzz U+MlQwwCOPemMm98DGM9a1TuhtQZAdzEuudpOee1K8ZIjA4 GcZqVdzPtBG0j06CkMTAbVy2Dk k0ot37F1IrS7IGDrnHQjFOKtt4BJH8qs+WPLKnOexzTFVlQKCc k9x0FJz0uZOloVlGZc5xx0P WmlNrLkH1B9qteWeD0JGaULx1DN2HpVRWliZR5VdbFXauQAOvJwaJ NocgDdnpk/pVp4gTkgjn gd6Ro2YLhQB6nnmnyor3U1YoFPn5ZeuAaYYzgAjLDg81om3KgMSOpxgV EFBfKc84PPSn7ttAU le5RcEsdq/IMc1AEZpGypxnj3960XSQEkqcZwABTGDIuSuG7E96b006im2m upRMYDE49vrTcE 5bBzjvVzbwCwJpCpCBQMjHQ1jJNNXHvdGeRj5lznOPlqNVUMhUqX27SDVpk53Y YAVDJEzSDkK DVJSW5KtaxTkwBgEE9+KjI4AwTkVZljjyfm+YDGPemmPKKB/31VruXzqBUGCp2ggA bagkTajD kAt69/SrTnAcFW4P51WkHAbady9vWkpMKl3a5G2DHs7tzioZFbzNoAHPYfrUrhjhlO1Q e9MZR 5rLuJY9MdqqTWmolFpX6EGMZAYDuD60MCVBf0z9aTB3KpHzECjDAEMQCtJQ1LuyMhsZUcdO el JsUplWAYDGPUVKcHpjgcj1NLjMK7gFbkkdOKpS+8ht21Kzrh/7xxx6YpOAuOhz0xUuR5YGRkcH im7kGSfWhQbQlq7MpFSZNxOBjvUbkBdxGQTzV0puk44P9KqyREbSMYBPXvWnkJK7V9ikwwrDac no KoyRjA7FupNarRsAxIJdT+BqnIoHJwxNYqd37o2r2OSVAH4GTjC+9SiIlP4hgYPPWiNd77O hJzU6 AeYcnkeveiEWndijKXzGoCHGep5H0qdAAEfHBHBqRFH3mU4fJ4pUA2hdp5Hr0q/djqhR ldXGkjyj jOSct7VYgwfmZl4OD6Gh1Uop2MrVaSNBGcDHPU9DRNqMdQ91LUBh5o3UjgnIq0oKH LL8nG0+ntUK Bvk3LtYrk8dCKuqSQC/3OpBpJ6aIbTi7t7iCE+XkcEHOB6VNGgx0ZVbqSc0Ko8 xtpLcYODVlVxDw 2OOQR0qm77j1T1ZGisFYZQgcDAqzHt2lMYx/OnIMrx2qxGAJMbcEc896xem w48sVdoYqc8DIIx15 pQgXDEcHg896kMbs8gzhQePpUmzKkcsMnJPrVQlpZDjo9BYlcxsqgZHT PcetWUXy4+oJzSxx4hQn IYggCp0T5cBSXHOaiSbIkuXYYiK7cH5icnBqQja4KA4VtuMVKNq4b GWyMYqRkPmAg9fmxVySSByf qVwuGJbI78+lPXaTtwMZ/KnbSzgYYnOfrTRw6sw+XoR3xWerRV lF3e5MFKjOw4HJz1p+A8oySccq PX60IPlDBWwTzk9KaquFJXnBwPU1Lbej0Jv5jWB8racx5xg +gqeMkZx82FI3evtTQvmJgttbpzTx Gylg2CMYAFOyvqbuCfUkiX92rZGO6+lW1TIwQduetQxD 5WVvbOfSreAm0cgYzyaOfo2TNxi9NRMv t447fWpduIvusefu55FQ5YMDj3z1Bq0qsTubPsBVq KWz0Ks762EjjBwwPc5zUvJcAggAZ3HtQPvk 8BfSpQrllHXNS7bszlve43nb6+9TKp2EgBsDOA OaRYz9OM4zV+9vZb65SaZY1YRqg8pNowPp396i bk9LaepajzPRmfg5BKnlue1TMmZcAHJ5APp T+NoCdz370pG1x/Hx2pqLbuK1t9xgRkXeCCCecVN5 b+Q/UH1oQMEVWUk54o2s0yqrMuBg5PU1 pZ6CteWoKo3biMBTyaVTmMhyFOPSgxs0PzffHWpApxyM Y4FTy8q3KlFNakQiJYdQQeT/ACqRB hQuQMg4BGakAyoyOB3po5OSmMjr7VTTb1Rnz31vcRQNmRyp 600xqJ+clgORnmpAMoB1Q8ijaD 8zHH+16099UNR1uV2QbuhHp/jQFXaC/BwenFWFUFJG5PHY1Hsd WBbgEdetZ7p3ZUrIiCEZC9y MsfaoWOZCMhc9fSrbgEsGVjxke9QDbnLAL8vfvRG27VxKz2IlTOD0 471GUUkHIABq0QGTABPP H+FIsW4LkHimhQ5lqyExlV29s5yKcFcENwDg9qlGPPK5G0E4HrQYyeit nPXNXSWqvsZ2VyFjn eo4bIIzT1jTbvY4PYetTGIh8hRtH51IqEE5TaeM0pRXQ1bKeHIPy5xx0p6R F2Bx1HDdqvqik5 cbDg/Q0rRhIDg4GOtQ5JWJk/spbmf5Q3Hb3PGaaqDewPYY/Grvl7lDZAyMA4pp UGfnhh7VUUK a7lVe7MM7fWqzrlchcAHJxWg0YGdoJWhYSVK4ySMnPtWqilqNNJ7FBl+fAzzVdoix GQSoHb1r U8k5Axs5PXtUHlPg8fMvUe9C0d0yYyd7GcIct3z6VE8e5dwyW9PSr5+7u2kjdULJzuCk HJyPW p13Zo7bGcVLSsM/MePpUEifeHBx1NX3Tjbzu/vYqCQARAEEsfQ1knF7MUU07FCRG3AhR24x z7 1BJGBvYZX6mtArlivO4HNUpPvsCOR3q1JPboHNLmKkoUx7sbiRyAagkGYgeSw4xVvZhRgZbHT2 qsy7mIH8XQ0Jx7g+V9SEgDkc+tRBSCHHHPGe9Tsq5ywO7HrUHP8AF8wWko3ZCiloiJgGdjkfSg nI baARxjingMYCP4euMc0mEIB3gY+8K1lBRHzJ6kIUs2NuHz29aCrdcZI4/OnHGVfJPGBj+dO KEqVA IyecnvTSW9ynOzTSGPGViAwCOnHWoXTbg5Hr9atlGVCHXPriq7cI2MZDDnqKE3bUUOaW xHtJTBHI NV5CMgEgHkirjcy/eBJ64qPYm8qCG5y2am9tQta6KbqcbsZz1xWdKFXLMpJA4wa0Z G/dfM2FJzmq L7TMSGAX0PUirjFv3rCglbU5gI4j4252nGBT448oqgdfzpVJVmABGehNTopfGC MjP41Ki0op7E87 WiSBFILqc8HP41Oqh2OGAOMn61AgKguMlu/vVqMAptcYz07YFU6cbrW4NRX QkWPFxgkMAD+NTeSS BlwOfTpTWXaVyCX3Yx7VaUOzZUcZwKlT00exMVZjfKZ12hup/MVaUMAd zKADjBFLs4I6joAvUmpo 1fG35ST1z0xVJprc0cm9LhkqAFAzj045qZIzu5OSq80KAQC4LKRzg 4xU6D98Aepx1pN8q0H7vKHl k7XBwAatJy24gknoaiWMMw56enSrMarGuQ3bvzUWctLi0+YBVz xn73Q9c1LHDlgTnGfyqeJN5DYA 559qkCncBjAPT3odrWW5MXK46OPERJIJAyfzqXy2EkpJxkZ XHepY9oX58cHp6VJtVwMkjBznPWiD aRUpcysiDYcAYIPoamcr5XzIyYbrUwUljkg5GcntTfvb gAGX3qGtSoK2+xERgccseSSOBTNi7Mry QclR71Y45DEc+1Q+VsACFsk561UU+4rx6gpIifeQA TwKQ7vOKFW2Zxu9Klk+cfLjrxxSkseA3XkV KvLYPdWohUxlVGNucZqXbk5GB3570iYYDJyOuK lUZJwQDnafapd47sFG7HwuGcHIIJqYrtUEjLHr UIj+f3x0FWAm2QJnIA7mhRb1iNQSJljUgFs 57c1Y2ttByB/eqCIh5VHXvVlQFOfU80Wa3KkmtEOw fLCLHjA9OvNSiP7rbjkjkUcHAwSMcVYV Ay7gu0k8Amna7uhX8hnlqNp5zjBz0zT/AC+eF6DvUywn eRn5QevvTzFzuAI7EUdRwik73KqJw OwBxzUm3EXylQcd6mCOFYcD5snPenhVVtxIJJ5HoablO+hT ty2GBGf72MZyGFBDbUyoGfQVaA O3gAY60hVyCSFDdv8AGjfcmMYy1IgvGADnPfvUvlZbHY5PFAUF l3blyM9elLsbJJyOMGs5pqV 0LlTVrkBQNnnDdMZpWGCB9457DrUoQE4OOBTdjbeBuOeNv86pK8bi Sa0uR45XgAE8CkIBf7wH J4x0q0wIAUqOnJxTAozyRhTx70lrrsDepDtG0IWGc9cU0g+bjBbH86nO 3eSRnB4pAp39MN/Oh WauKz2ZAc4XbjJ5INQFQxOEJIHGKuH74yVB9cVGw67APbFXDUFHl1KoG+P7 rEg9qeiHYeMHPe pwhRctyp7U8gKv3TkcDjrmhtDjdvQhaLMZKDcQc5pyIVZOOCMkGpEVs9CFxyB6 1KxJcFQMdia SvYSp3umRffBKr3wMVIsMjElmGSMkY6elShQsauDkk8gdKlCKWGCSV6gd6Iu2liL8 qsVvLDqF 79/rR5DNK2WBAGDnpVsR8YwQcdaapYAZIPvjrT1GpaalIoVwB0757U7ZldxXqcVa2jyv xxz3p AoVsO6sMZzVSiujLeq0KjRfMQAVPoaaAWHVSPbg1awAz91PWo9i7cdT1wKztzego766FYqd 2W bPrxUZRSd24sOTnNWNowzFSOw5poT5cDA7E1qlrvYTSKDoVL7cbcA59feqzLuwWyfpxmr5DZIT BUdPpUUiZB2kYbr7VMnfdjaa3RmvtbHynHtVSRFwDtJwOMelakiquFIKkjqaqSg+Ruxls4z2xU cz a0KppddTKnD4QqOW6VVmQguA3zZwR71pN0wc5zwKqug2sfmOTkVV7eQNqKM6XIjBHBIxVdg Rkg5I 4Aq267gcsCR0A71Ds3SMWXaCRn61T1WoorS7Km1uMjGBTTwhJXBPXjrUroVbuU3du9I+ Fl6qeMjN Du3ZA4EDDahYnAI6U0xlWOFGcDPvT8HY2c7u2akWMeXnlznrmqlLRGclGG/UrBAXY N6jgdqD8vQE 89TUmAXYchup96UHjdkYA446U1d9DW15e9sNyfNAxkE/pUPAOBjGcgYqdchieK YynfkjOWolG8bh F2TRXIJyAueM8VAAAzNnDn9assA4OzcSc57Ux1VoY/UDqO1EWuVXFdJGfKo LfN2GMVVdQI/ubsdV xzV+VSCwAPzEduaqyZIZsFfqaqN09xt30RzGAJMLjG3gVLGEEMW/qD1F RjcAHI/H1qYHLsevoPT1 p662MG4t2JT8+CFIUHjijZnag5GOfpUwLBlxjaVwvFCEHIzyp61nG pNPRF35rpIlJbJPbjFWowRG Cx6dPU1WBLI2Msc1OoHkDJI9vSnyXVy46R2LCKwk4X5BjBqym4 Lk/Xiod6MwyG4461OAw5ByB2NK K0ZEo/zDtuUKg7geRg1ZjU+WeMgcD1piA+YdwGVHHFTRjbt ZeMjBz70NN2Vxuy2JwihcNkEdcd81 OBGG+ZgMdSKjjyVxtYe5HWrJVMJ0PsOtTfQbtzWHqFWJ eCDmrCK0sakcbT0oVB5TKcbhknNWYubf BOCcEKOopw1iDVtLD0QmIsANucY71Kik4yuec0kSF pHboB2p20E919M1K6hyXYIvzcZYHoe30pyx gBU+73P0qZFORtIyD0ppVmlbAANKTb6g1LoyB+ 5Tpu7imMjYJzyeRgdqtDhcFc4HT0ppHHHzZ6Yq nIlS0sQlSqk8YA/Ko/KbKMCPKUZNWCreWpz kEcj3pVLbSqr94c8dKhKUXdMcFf0GgDfgsPy6VIqj YAx9/rS7dibQCQeckU5fvBQBnPU1HNdm junoPCrvycBck4FTKrFchhgnqeabG2VZTjgYB9aspGdg A9OnaiLktBx3HJGFB44z1xViNCbgF VO0DAz2poBOAAcdqtqTvUMM5AB7Uc0ktyLvcUJ83TcAOcVM qAzEnK46Aninhf3hO3p39akZN8 vPyj1p01Lc0i9fUaqkZ6lieBUyBgpyj+1SxodqrjBweT3qVIiQ OowepqrX1sZyiQ4byk2jnHc U5YlLtvGT2IqYxsYiFOSDg0oRliA4PPPrTXky3tqRIMlh2xT3Rtqc YGOM09FDOF6Lg80/6AkA 49aOV7ia1uV9u9tv3mI5pAjE/N0I5qwvysTlVOeQe9GwBiOpxnFG2iJc tdNCHywGIJGDxkd6Z tZQWC7WHBH1qyCAjAcjOeabyMbs5I5JrJxaditdSEqdoUDH071GinkjDDIz kdasBVY/KrA4OM 96Ty+VxnC9MGr5rKxMo2SZAIyMgDqeSe1OdSBwuQDwKslQfYAdD1pgjxyTwOAK FZopR01Kyod 58wbe3P8AOoWAVioBGF5Iq+yhYMN14yfemsmC3HOaIvleocz3K+C2FI4zmnYJjAIz /M1KEIVe Rzxn0p6ALJubOBkCqnZvTUGVwu1WDKQM+vNIFO8hmG32FW3BHJwf6UhwULdeelLmsiYu T6EeB nIDA+hFCBstg/NiplT5iMb365BqTO2MkDDg5YU4zsu4KWpCBzywGCM5qUL83IBUDjFOdV3D 5h 83r2qMKu9s8jtg0KSlqx8rlqgKl5QUX5TyM1HtGWPAOec1OMoMZIA6AdqaQoRjz9aLvoK1tyP5 JeGwvFQuqqgIP3jx7VPgHAPOB2ppTn5iMKck4qnZE31K7Rgjbn5aidh5RIyGLVaPKjjqQTgVA4 GT gYPai19Ane5WkwUO3GSvPvVUjEOHDZP3jVzcykEKHGeRUEjP8oIzlc5os27Ar7dDPlUBDyT gd6qh Q4DE5GOlaLKpBBbnghu2KpOGbG3AwcYx1pabLc1im42RVZQMAfMQccVRdWBPB4/WtHcC zYwMYxkV UcNhgSFPv1NRJyi1clU0tDPkj+QsoxjoRVKUtvywAbPKitBowoIfIBHFQSKu7JUlg vBFVfTVaj52 ml0KZA8rHUkVWdNx4OCSO1XnRwm47QPYVHngYUgjgn6UczW24Nte8iu0P7/ZnO By3bNI5bdtVtuD gD2qVl3KnUk54B5qEI7H5R8o4IPWq1t7zH5takYBUMAQ3PIApuTsG5SOMmp 2iCsSScDrTCcp9OmK 0g7aolct9iONgQoZSM8g4psgCjG8D0OamyA2DgAdD601gwTGwnjk1Upa 3JUnFpWKyjM2Mjj0HFQs NpUNkGrMqb4s459qrEMgALDnnkVKSfW7E3e8iGQ5Dbhxk45qkcM+5 RkYzzVxtxJBxVSRvLj27eB2 xzTUWjVL3fM5bdu5cnGOmasjBb5Qc/xH0qoqhQyuQ4H3CKsJIq hVJwT61PNFrTc5X8PmTqHDFj8w xxjvUy/eUkblzVbcywkEgNnj3qdJGdQw2/THH1ocnbUtO9r lsMoBKgc9DntU0aYLfOOeTxVdFVnC qdwPX2q2VUZ/hI6AnNSk0xNJK1ydAqbSRxyOKmjznAO5 umPSqy8sMg7Satp/ruy55zitG0tLjUep MnDHnBJwc1aViCxwuMDjFVwvPykEnnpVmI/MOMA9c 9jUTit3qhuS5kmizHubO4AAHGMVJ5W6bI4x 1zTVA8zkkDA6dc1PuYBduGDdRilG0dEaJuOqWp biIZUyMnGAR3FWlCrKMc5HNQxJt5bk9QasY3Ho cHr60Kd2Qvi2FRMLnc3XkZqwi55JyAM9OlM wwwCNzZxVpcKi5BII5NJspxaIwGCAhSvzCkO4NIcY GR19anZQ0eAxGeQKiAwzNznPSmttUSo6 7jDhkI54HNR5CDBU4PerI3BMHBweOKMA4LY9MGlJ8ysy tnsVuSS33jjj0pVA8oZBJ6ilB2DC4 INOB/eqTzgHJ7VlPRWG4ybsNwwXJztqVI1eQnngc+9NJY8h SwUZwKmjBUHaDu6Gm9tB8slG1h 8cYAVlIPHI9KsqpUsRjnqPSkAywBG0Hmp40Y4YDA60r31Y7skT HIPHNWSmTnBOaIugAUk44Pr VgKCRnuOT2FF1zaISs2EasEAYc/xfWrKAZGcHmnImX5yVx2qcLjrg 8gCtZVL6Gb3uMjAMmATk dasAMFORwMjNKoDBmA4AxkCngcKAwx1JxUX1vsauOl0AjcL1JJ9KFG0Y bIXvkVJltz5I+Xoe2 KeD8/zY2gdxQrtXHPoQhQWODz2o34l+bgjqcdak48wgKeRUY5yx6elVGCl8 RUrdxMKzNn74pp B5ZSCdv+RUu1g7BsEY5I44p4iw5wGOzGMd6TcY7shuKGbRtweh6cUzZuBxk461 OVBUNyAOcUg VA+TwGHOegqVKz0E3G2pA0Z8xSMgEYBpmwGIBQwYnAFWvLyowR14B707YWkDKBn17 VV0yeezv cg8vJUhcr60YViQcepx2qTBE2F+YL05qdkUpv4B6ips2y29SiYmfaoOR700pubGCSPvE VcyNm Rgtn8hQyhWyMHjPHen72w9ehRUMp4AGBzTtpJZccfSrZ2luAWyM8DrUQD4JOeveq3VxWSaZ Bz zuXk80pUByGyAeKsnHA3AknnjimOiqAyEhQO/NRe+yFomRB9qrtBye9OC7lc8nJAFLwpC53ev1 qVscgpgZ7etVDcmWm5HgkHLKoPHIpiqozk5Iz0p2wZY88nIBpM7fw4OKculgcLIUlQvUMM8Uxs bQ B0J6UhHXdxj7tHCt8x68jntUR0eg9JdAfaCSDjB/OmMOuDkdzUoG5GJI44qDJDY4xyTQr7d Qcbaj PmKEH2PSmO373joD1p5kyDzk9uKiOdhGCxYZAHatNiZJW0KsknynYnUHr2qEhfKcsSTg Cp8DhQck dRUDhxk4BBIzipUo27AovRFaR1wV2NtJ49qruy4wePWrxUkglehPJPBqiVLMpdG+b 7px6U5W0sSr NWuVSueR8ueSTVaVSJDjGR2PXFWn+8wY8fyqq4ypK5OTyc9qvlu9TXZ3KjKTzz z0qu4ZigVSMAZP rVp0G9gAwBxwarMWG0cHA/GsrXbS/EpQbTdyuyHYp2tg9vaoVAOBwT3qSQM yr1X09qjbakxIBAzx n9avl7mKg2vQYygTIRkgdcetN2/OCGUDrjH60/c+8k4BPI96coACse+d wqm31HV5lYjZSWzncoFQ uA+8N8oB6nip+fLZsgI3AJpjnEm3cuTgn2qU+wlK1m27orMCFUBSx x+VNbMkZDvhQw6cVZbAcAq3 3aikAyApHyjn1NUpN6JWDmTXvIrOjAMVO4KePeo5DuAyMjPQda mLAqSoJBODz+tVmz5WBx6moa7h K60KrszHgcjrVd0weeHHUE9amcMMAAnjPFR7gG3OTuz3rdt pJi5mk9NDjY1Ckclhk4qXc2NyqDt9 qrhpRMWG0fUVKoLBN5Kk4wKiCVtWh6W1ZIrSyFQw9uB6 1cVNiYPHTr61WCDBJfkn5cfrVtGVsDna RkUpaPRFqXRLQtRYCAMRz3FTRsr8569/Wq4XbsBwy Afw9asqMOjDHoBRrvuQ5R5diYKckndhTxir kYJfBPbA9qpBf3ipuIx6981eiwG69u9DslqSk7 E8ZXcSf4atxIoB5OOvTvVVQQdv97HarUZ+RgCB kd6Tu9i7K9rFkDKqGJyeQfWrUa4b1I6jv9a rK24qehA/KrW3d1ID4AIHem22yox0szSiI81iFBIG M468VYRfnXBHIyQfWqUHAByx46DrV5cG 3yMdce9Q6coshLWxajUAZHJx6Ui8rypIJp4+VVLNjIwf rSlHVSgOQeh9Kuj5lJ20FH3nIxnPH HQU+OMAgbx170mweuOak2bHBYnJPWsp2u9SddiCQAyqOCQM getRyBW4UFcdfxq2RkhsDHqKjc BztIyM8047JNGvw6opjG3aMEr0p467SAwJ6etTCNR04zR067Sf aiatr1BSSV+hCissnB6jlT1 xVuLcVCYBJ6eppoXBUgdanjC5zv29xU3VtiZrm1QqowYHGOO4zVmM MiNkYAIH1qOMLvwWOSep q2MK23IDDgZp8y2aKtZEkZBwRkgdhU678BAvPeo1Q7hhGUnnHtWiqsij GDgUKTWwJxWlhY0Uq ufl9asKijhQTzknPFRhTjnk+1Tx8NnaQQO1G+7EpNdAC4fKjqeRTzy7FlAz 0FSMoDhiflJ5FI yoZFzk4HUVSSkwbjfmeg4YECqy+5NOIBU4GfWgKwiOG3YNOLED5lJ4OQDUcqWy CPLuiLYdvA2 +uaCoD9Rn0xUm7jHQ459qYUYliGUn2PJptoq4m1TgnJLdvSpNrA5UbgBg4pyYwO2R wTQEGeG3 AHHB70tGzPnSViHJdQudqmjywAOSeOD2qUtiYkqRxT02lVyeAMe5qrS3aNZaLyGKpEee G7ChE lckMdo7YFTortIG424x0p4HBA7fr7VHMldEvV6WINiqAARuxz7+9KBxlVJI7VYSIPg9Oxpw T5 SF7HiiTRL1ZXEZZCSoUHpTNjM53AD+oq0uPKAz17UeUcdOn6ZpN2dmVJJPVFOOPapY7d2etMZN x6g55NWyCpyB8gqJl3AsMD1HcVUVuwk9SuF8sZUA7h82e1MJAZQG3ADk4q75bmMg7QD047VWZd pb ONhPFQnd2QWTYwEmMFgHOeMDt60jH5G2jvnHvTmXLjnGeDjvRsweOctiqtswirDQDu+cgHr UDKPM IU85qUgrGAw7kdaXaSm4FQT7d6ak4vUhy1uRKocc5ODUe07sEEkH5cmpTGynIdeOuPWm kqOW6ZOM VUXqW72uMDsuflBye3Smg5U9h796kC/IvB55qJicjAx6ZqHHrbUl6sj2YZm3A/3eK h+UPnIyM5NT uGB7HsDjrUL8IitsPckU0iZOxXZ/vBRlgM8dSarNllBww4yee9Wn3NI23A4yOK idiJFBHUdaFo9E XIqtln28mPqaqPvxkk+w7c1eGwyDaedvIzUMoIySc5I6DpTcrbg5JOyM9lG 7aMbcYyahZG3ZwVxV mYMQfmG44NVnL7MZ4GOPem17oc7lqyo4PlhmPJPTvUG0AvjnuAetWJFb eCQWAOKrudr5b5cjj6UO zVjRp21e5AyDb5jbunK1FIC8KnGORnI6VZO10DFiD2FRE7l2nqR0q rS00MG0upVIyhZM5AOc0pGY 9+04IyamG0M3XpyMd6Yy/uxgNucDPoKTTk7GvM7bkW0AxlsgDj BNI4ILYVSfUjpVoRAHghiOlMyN r7sY5FVdOWphKzd2yg24yE5zkYxUbxnJIzx0NWnQhAcgnHG B1FNO4xkk4XGF9aG23p0HzRVuUqMC FIGM9yRxWczvgkD25H51fdWWQ5yQx6elQOVKkAAkrkjH elGV9C9tTPJJU/KzkH17VCwBRhkjPTNX MAnBBGRyAarSZ2hgQMcEYpxbb1By10RxAR2DHaw2n vU4wEDclx0FRZPnbVHB65NSr/q/mwSPSnaa 3WhnGMpOxIkYcqSSM9BVpOAAgyqnGfWokRTJye /SrUaFM9CSc1LlZ2RUn/Kx6D5k3E46H1q2QTMv zDnt6UyPY0jZHfr6U9UbILevH0rSTbI5dUi wIxuDHd+BqyobrjPNQxbVcjdk+/NTICrBdrgE5JJr K8upspa7k6glsAkHHFXUXDIQM4B/Gq0b KNvRmHJx9atBQNrBjgHOParcrWZLm5aNXLShCFYkg55A q6FGdzHn+9VWMANuySAMgetXI+Y8k YO4Yz6U7q17ii2mWkAVVbgn1FX0j4Udx6iqaj7gHJ3VeiBc 5OSOq4qJau6HfXUsIw2gn5yemO lWUzk4Xgd6gj4QYUdenpVgYJYsTszwB1py2JaSe4w/cGfv/Snq uSpY8gZ5HWpAAyBipCA85PW m8Ky7eR79qhtWdhJdLDFUsxIZcdcYoCnBJUkAcU/bmQtwVHXbQwBD Ku7pwM1Tir6s0TuiLAJw x6DnFRNwTwc9qsBMQvkEnIwaTaRJkjGBSja9nqJct9AHysw4J7HHFSIm HHTBHFNXAycZOOpNW YwrsgByMZIrOTsrlNW+YRhUm5G8Hgn3qxg7iDgcYyajYbQchWXqNvepskjb jt8opxSdmPVsmj JyAWB9eOlaKKTlgRgcEVSjGcHaScfSr6qCPQEZNXNx6E3k1ZE4BXBYZHQU7LC4 BYZBPUUiqRG AgOc5INToEIGckg4UZ6VKskNNpa7j2GSBjjGQaYNocEhvb3pyZUYLAH3puO+B6del LZgiTIEn Q7j2NJ87yMT0xmmgsOh4A5yOTTgrErjOP50k3uUoqIMGRiw+6Rkk01sja5xu64p0mTEQ 3AzTC 6tHySCOo9adkyYRfVCnJ6nnPrT14kBBAznioYwc7gc46A1KhJcswBXODijW+5vbSyQ/hijb Xb Pp2qVVTzM+vWmqpUZ3LjPSpgqFxjj+9k03boZbxtIMLtAyQc+tSIAYzn5cdz3pCwLYHJ7Yp4AL bW4XPPNZuPViau0mRgMsxKsGHoKmCEkYU8ZIHpT9pU7sYXPFNAdZyVILMc/hVKTa3LsmtBCpOF AA 9OKWVBv2jPIyTmpI4280biAR2oCKfvE49aUY31bM07S1ICi8ZyQOvNQ7EycffPUE9KsnAfB ySTz7 VGUVXbB3MT070WaepSVr3ZWdWCbGJwOgqJjgJ3HHFWnDOw5xyOMUwqMtjHDdT60NpbkS baRX24wQ jYJORTXIwoIwR0+vensjEAjPShozgFCpwDmnyxHBJdSAoG+YZb0GajcELjcAMdKsY /dsCME4Ix2p N2YBjacdeOarm122G073IQqrGzFWwegJpjBSwYKcE8CrBi/dAksuB0NDLk9cY6 ij3G7CUnzXuVDu EgBztHTimEDByxBPSrRGXwrDH8xVXYJGAPU84pytYaW5HJneMsCOPzqFhuY p1OcD6VaaLGOMAdjT HCBgWIBApSfREN32Kflny8HOcYB71DJEzHjqBz9atOPnznGDnk9KgYP5 Xy/Mfapemty48zXmUplI A2qzNjGRVd1eNRvUirrABn6g8d6pS7i+SSf5E04663G7oqspI3EHn mqzFVG4DGD3qydwbaPSoJSu SvGT2xVySJnLVXKxJLKvzZ5JNV2A8vjax28DFWJOm3BJPTFV8f vOeBSslcE7FfgpknbjtSAF5c4y CPpxVna2SdoIz3FRHaGyM5zx70k09A50lfYiKBo/pwDTQCG yvOGxx2qQ7NuQrZDcinMP3jHGMnkV TSSVyXKzSRE2M7h1PXHemeWrAF+GI4FSbQhAOPahm4x0 JPGe1OVraAo2d7laSNsIy8YHQiq0iHLs WAK4wParxU8gsT61CwBWQMM/Lx70SlqiXOy5Uigy7 pGLbj0JPaqjhWkbI2kdavHeocYx04NUyjA8 nODyfU04xS2ZTs7vqUnwp3AgfhVaQFXzjapGST V+URhlGeRnI9OKpH51XJ4C8+1XGTetxKz1OGRH XcDx9asx7gCQBuxk8UkSs7Efe+lWYlZRtI3 Ang+tSut3sZ36WEVC0W98KCQRjirKFgQMlhu5zTNp Jz2z8q5596sRxEfJyxJyOcmlLk5WaWtH XYcrMJNvUHjA7VaUoyqSTlSAfc1GigTjIx681ZQASKcZ BGcinFWjYVknZDgpzvZhjdgECrfBA ywztzjHeokUgbsrgcnI71IpJVeV3d+KUWzRt3LUWBGDxuPT /CrKhmlIJGR6DqKgQgkbcZA54q ypDMrEgHGc1lZpkJ66Muop2oCQCO/Y1cQfKpPfpz2qpGcBQCNn bPfNaMIUJn5S49appvcce7L MKhSGHIJAq3GoTGc/n0qpHxKp6gnIFX41LMGOMHnFJvuOTV7lvomF GQCSakKkrljnIzxUXcbT nd2FS4G1T/CB07inTTRCg1G6Q4DCZVlYDOOKcf3sYIHJ75oiXfvDEKM8 U4K2xQqkjtVcqva+o S27jUTG49e4pxUKvuRUyjLnbkAg5yKaDlScZGCOnNZ2uaJ63IGTjklh/CV7 077rDb1I5BpzKT EqnjIwKbtZVGeDuwM85qVDogatuAWNwDnHHSnLgElTtBPODTiCGXJUIO+KcQuM cHb7VUlbR7C +1zE6BSGGak27skZHGefamAEFdoABGcVJEH5LE4PAzUpMaSaumWYlZpc89KuqSpxg A9PpVeKN hGfmJz6VOi5JwNpB6k048vQNd2WflKhs8qe3enqh3lhwe5ojAEahirDHHvQFY4YHK45q k7PQN 3qSMVyCoJbvzQ5yFDEKew70oAIBIPB4pCSc5HIPU0nFNXQknsMD4O1OWxRhiAd4JHtSqB5i j2 60joAcp1+vWqtdGrS5rCgk5JwfrSkArzgE0iqQ5I5AGKcuHwOgrFtqW4SsnoMUjzAVOTnpUyqG JwCPX61EmFbI5yOanVVZCyMc5zyf0qpRd7kys3cVIyRyGGeetTlcgEYz1I9qSPlQ3HTgCpQPl4 wW 6fhQpS26GbkubXoL1YN1xx0p2wALngE8jv8AWm/MHx3qwqq0ZKZLdDROTXU21vZAflwFcNg 88dKV hsdWxkbevvSbEaIspJYHOD3FO/e+YMKOnSiLu9EDbuG0GNmw27sD3poG3g5IzkAU8OPL Kty9O2bc EDBxnk1V7XQa21GsmSrE4JPP0qDy/wB4SMjHUntU+zd1O0DjOaTYFLMx9MUrPl3Mp S7MpucDLDq2 aaykJkcgmp9mVbODgcH2qEAhMA9+/an00NY2IAJGyCpOc5x2oZWBOVJ+Xip1yM jIBPrTWLAgsCeM ZqVo7ESqW2RXwfLz91elRONuBjIq0yfNkg8DpTCAxUbWHGDRfUV0lcjHCjq fQGmhOehJY1JJuWba vX0IzTdpI3Ecn+VDWgOOt9rkBB5JGDjBqLBRzgEsB1q2R8nIO4+1NbJb aCuc8gindsV1LS5TzkkS McgVE8fI+Uk9B6GrLIxkZuFxycimFQYyRnccY5q7NLRjjZ7FNlLFt q4GOaruDtUc5A4Aq0V4JAY5 zxUcnyxqXAXAqLaA+XoUXHOTgMB0qjJkSABgcDpjmtGQ5XcF5I wapgNuycZHBJq1dxLc5LqUmH38 kBic1E5LNkgA1cypUsu0MQeTVNwBGScnPIqJxu1czTUpabl BlIkKkcN055qHC72KqST2PNWySGJ6 ZHGRUJQEHBye+PSteW6syp9rkZHyg7wdvHHeowu5VB+X 6npUhIJxyF7+xpm3EuQS6Zx71PK72bJj OVmhNuEO5gR600xksfm3dKlUAsVwCucj3oCBo22sR g0c043JipRd0MCoVBAbKnp61CoBlJ6kt0qx 5alzgkY5+tMkTanyDJ3ZOPXFOD08hyV3vqViWD MMADtUAAMbnDcNzV4gBFU/ewMmq7HAYdM0SfMy HorWKTJukZ+QO/1qlInPJBbqeKuurByM4H8 6glRfMwW249aXO47Mr2fZmcY/nbcpJbuKptxPg4K4 xj3rSk3IWwQwHT2qhIrlCRg554HSrU0x p6WbOTRcKu0jp2p4R3dQWGcZwBjFNRW81t3HHIFW/LO1 WBz+FVCavqzBSEKuBlyBsOOPepVGE B3HjgGlBA2ZXI6kZ61MFOSI+M8gHtUxkn0L1vqCLgjcCy9R irMbbhnAwTUfV1Gff6VMgJjOSM 54IpuF1sXZrccBhsn0xU6hWCtnPHWmqNxIALHpx3qVFAAUgg9R T0tpuVa7J9gRMqu7ng1cjBf kId+MYqBcjb0VcdxVpEODIWAHQYqOawrW1ZcjRvKAPQfdJFXIlKx9 MYPaq0R/dBScqOM/yq6u 4qQDjP3ie1SnKXkTF9WW4wjELnHynbmrUKu6Zx04OPWqgTJ3dyOFFWEY jOcnHUDvRGBV7rQux qI5h1IFSnIQHI46+hqDkhSowD1zUysFbOM9unQ1ST1ZKvfclU7HyTtB7GpT kP8AKcHGc9qiLo 8Y5B569Kk+UF9z5AXFZ6PdGkeZvTQAoZSQ3OOMVKoHA+YZ96jXbs4buB0p+AFT HT1zV811a5k m0mMdVDKc5YcAf1pz7gABjOeR60qjdIWOGbOOBUgHO0927dai7RbnZLuQqp8ojg5P GR0qRVzk cYByPWgrmRuRweacOH9QAMkUpO6Ls2rdRyZaQEHoOKurlmxjKkn8KrxjlvlYgAbWHcVO AQ27J UEZxUyV3oQ4q+hOmAo6lhxyasCNkbkbh0z6VXADMVGMjn61bRSF+6SDxgdacUoaopavUkVf lD EEAHGKtqPkLbhtHU4qFVygKhgw65OalQ7WOcFcYx6Vo5NqyCK0EB+Qb+mc8Gnkcnn60KMLuJVu OlPADkAg5xSaT6FNkZjxFuORnktTGyd+CBg8Va2g5zzjpzUAYBSpPKjAx3rNNvVgm5q1iMbg+5 iM GnjY0nJxx60vCg7uv8vakCZBCg9eM9vam2xqKd7jesgUY+tSqm0gZKkN/OmquJV5+cE1MQS MBWIB 5x3o5ul7FbpxFO8QFRjcDgEDFTAd8980i8QscHr370oYttIwfUY6VUddzLWyViQEuwcL x0x71MIg sg3NhvQVAMEDBBqYN+93MpB6VN0ti32RIVO9FXovBNOZk8rvjOajDHL4VmPfnpQAo CyNknqc0Nu+ hDTtqA2vIxHy9+e9SM7PGGQY9vaoWc7vkA2njHpT2J4weh4IpN6mjSVhCwNtzk c8imhwW5yaeXV0 wfvHgimAgwbOMnnPpSdiXqtRjfMSNw44ppXBIII4AP1p3zFCSm0n1poAdwV JIHv1pxTfUUpRURjf MgGCec5HpTOOOScHpUw+U9Rnp9aiI+bB45qvIhNNJiEjzCOCc81XIJGS cKDxVlgmCCGwTkmmEHYT jgnkGpSHpazI2PAZjls5pnU4XIyM89qlbaoye3603PzBR6Von0BbX IzGVjB3fMcd6a6Ig3KCSelS KzZG5lJ9AKYzPvIUA49qmfNcabvZkRVmPrxVQgnGTsA447fWrW 87sdW9B6VC5UA9s9c1pG6bVimn flRXO7zc8kAdKjdQ/BwQRwakKbpAobnvzUZyCNvPH5Vb1ZF 7PzKpxxuBHWqMwMaHaR16H0q6yZbh sZHWqskZLbWHzMRg1DXW44pLR9ShKm0jZjOOAPTvUGxV XGc8cZPNaJUeaQB8o79aqsuCx2nJOR/9 al7Rtb6jnJJ2RU2ZTpxVTb5bHGSxIAJq6cknquByP eoTBlGG4jnijp7zMltuVCoZs45BxkHFMZSk m3ORjmrAjw4Qk8rwfemiP5sSAkY4x60pWS3NFN dWRk7uCNvYkDpQFMbcuNwbn3pxXClgSQH55qRg JFXbg85xRaL3FvsiJRuGQcDviggAHoV60rB vNwcbc9vamlDjCnIIyfarml1JnFt3RWLF5gwUehpr Iw+7gZ7kVO+PLHzDcBjAqEqWAUsQuKUo p67B73QpNgxnI46E1TYARu4+bB6E81fZFIAzuJ549qqS EFCF7cHjpVJLboS+YzphnG4jBPQCq sh4I5znn/CtB9rjGMcdfWqZCGFjyR156k0TjJx2L9pFnGxq fMyh3DoWzmr6HcQQQFAwR3Bquh CgkJtbPSrCcHOzAPIFOfvPbQlwdlbQWPltpzkDPPerQ2uQO55x moFyowV+dhj6CrMZBTGVz6i m77obTTv1HoFHyqMnv3qUR9DuBA6rimffjXt1JPSpVQqc57c81DXN G97DSVrtEyMQqEkKu3nj vUyguQeD6moE6fPkZXFW40AT7/A7DvUyShHmsTzpeoLndllZgep9Kuxf fycBTxj1qJcCIY6Ed KtoFKHbhmBHAosmjWUls0WQAYuAQc4q8v8Aqxz04J/CqcI2yNuGVbnirMfz L8wwrGny+RC3Vn oWoyAkec/1FWdzcEDI6Zx0quNm8YVsgY61ZjdmBjYqFz6cmla3vA9XsWBtaPqd 3bmgEp83U1G pAZeQewFKchGJKjFXzW6jhF7E4O0DcAR6YpxfdI+evrUIztyD9OetPjIYnOBjrxWc 5ajhuy4j qVPTcKC/Izg8c4qsjEYHr1yKnj3CbgruPXNTFxTuE421uSGTbGxOcZwR0pQ7bgzfIoGe e9MRC 7fMAFJ5Bp7YRxhhwMYNXzK7FzReg7ftckc5OPrTxnhR/EC2KRM8Zwdwz0xUyBAoAB3j37VL 5V uipTfzHxgkK2GxjHWrKLulYMCcjiohyAEBwD1qZQwAOwnI5GeRSbbegJ29SzHtwOhHtU652lhk A8CoUVi+1Qx4GKsxAlCjL3wKm7QNO5KNyE4yccE1MBhF4OGODxzSLtzhfQk89TU6nCrlcccE0+ Zl uOl7CkDYAuOOB60zHzgsCMDGalx3GOCOaNoJ6d+lUpaWIWzGlQ2WRgcDr6VAIv3zn7pz909 asNhV AAw3tSEjILAsfSqvJBzS2uQgLyGbBznrSgENkfTmnhwWOFGW9qMZbcSOOwqeWW7NNle4 nzHqvfqK lBwSCc5NICVQkkFhxSkAkEDnpUu7Rj1JFEag4ySQD1oJONvyqTzigY243DGfTrSg5 5OA3ao6XLjo rpEgwgCPtIJzkdqewPOc7ycDPtURIJyMA+lAJ2A9fTmjVoTpteQ9tyq2Dg/zpU IOM8nv6Co2bCqN wIHWmFsSkLycVbV1YqK5r3JgMHJ4BPp1pWkUbcMMDrxUCsW65GKU/KNhIPv ik4rqDi21Zkm8M5bg c0ib/M3ZGMYxTVwEw2cdqeWOMEZHqO1K6UtBuTfxEZb5iSdvfB70hJ3c MOOOO9KQpTK85PNLwW2j gHkE0732I0TuxXOFUNgjPaowuG9QelBY7SFBLHoKZLuZRs3Z6U1Cx UJ3W+4b2LfMDjPHsKc2S3yq eOtKuSfmUjaMGmM4XLhjz0FK6bIabdyPq3HU8nNBC4BwST6dqX n5W9RSbcgNjJIwMHGKvbUf2tQY plQDxVc56qeTxxU7ECPbtwRxn1qAqWIw23mnF9w212GFSkY cDJ796iZg7AYI7kVLkrGCxJOcYNVn G2HGCSD97NKMXJtsnrfqRsjLKCpGMdahYAZ67/6VZcFz klRxjmomAyOo9CD1q4yktFuaOStoVZFz GTg9MjHpVcjMmCSvy4BNWWO5sjdjoQDVeQMMEqxI4 Jq0/PYmWjViswKqRkMw64qiwyuOcZ4z3q8c FsqGI/izUEqKHXnk9ABWMnrsTblVmUjtV+Qy54 PNV2V/KbBHtVtwTKOhUHHAqux2uu4jGeKuyd+4 4SkVfmyoztz1zUhB8wc4UDNPZVKlgcYH5VA MqMhs/XuKHZ2Ha6GFS0yqxAXGSR3NIwJlBQYOMmpZ FJxnpmkUjadoOQehqua2qKsnoiMKqRtu 3EgcEGmFxJz82QMHFTgATkHJ7gE9qCdqA4C4PTFDehjz cujK2Ar9Dnjj1qOUhRgnqRyKsSAZI zyevtUDpiQK/OSM1EprqDty6FcqfNyD7/SqEjYkPOT0IFXZ AVZtp5J69uKrv5bFixPJ5x61p0 7jgktbGcyAuD0x0xVZowzlOq+1XziOTP3htwRjoaqSkHkcYGM1 HLK4ubojkPvFSdoBOSas7AE G0hhnINVQyliuGzjqDxU68lAXA44AFaJPe47qysTocN84wvYmpgBt +XrVc5zgncegx61OmTwS OmD9aSuVyJa3LKKWVWI9gRUirhMkr1PB54qJcLgAE+ozTxh2ySAc0byu LVq0noSInO7D8HFXc qrKuDuyfxqvgg4JHJ4xUi/NIm48jk570NXd29hP3tyyhOzcAF6EAjpV2I5l VhwCpJB61VjIyM 9CfzFXEJyWGBk4A9KzS966QnGz9S4p3Jwd3Ye1PQlY2DfezkVXjOG5DAjjd2q2 CDyPmboT2NU 48m5TdtCZJMKWB3ZGPpUgXMI2MRjjr1NV4353KoAqRcsSobGTnPvUweumg7u9yzuV IVJznNPV wR94bD0JFVSdu0EhlBwaeJPkZgv0HYVnpzXKUmuhYXJlYKSBjIzUqs3mKmVAI5qgJDuL Drjmn 7jhXJwCPTkVcXd2Bxv1NNJCi4Cgj37UAsxP971FUxk42tz2FWQcsDnaaizT0Iah0ZaB3oWd WJ xnIPFSId4XYhZsc+xzVRSuOjYX361ZR/3gI4B9KqT8tRtcsdUWN37wg/fLZAP8qnwS2SSv4VWU rHPl+hGAT0qwHYkuMYGOKrXroVzNapElvn5vfnPrVwN0KnnuPSq6BQ4Byccn2q0CPlLcA5/Cp5 rv QiW5OobOQTz29KtRYHBP3ujZqCLDtlQ2QOM1MgOz5x0PGKe+5SldpFsBTtwCOevapgmcr19 ahjIZ eFbC9h3qbpJ0OSPzqW+g1fqxBncV56/ypwIJUknOaQGTaQVBJ6cUuMR5wOOM4qXbqH2h 8h/djODg 4x3pmSX6hR6kcUobcSCp68e9DcnJ4wO9XDXQE9NXqMCFSem007arjKsowOlCnEXBy e3eg7SQccjj IqZKT6h7smOHC4PBPOaRi20fIVGOtPx8qgkHFBVi2T09qmLSdw5etyJC4TcQcZ qYAFjgZPUUDqVw fpSjIO3+8OvrRJ3Fa6GthGVQNxx1FNb50IB255Apejlt2frScoMDBOemKd0 hpW1AYCjcDzySaXHz LkjIprPztJwAOKRQTk8nPr3pNuw3orpbgN3mfMOvHBoBfcegUHAzRk7t vUA8HFRKWyQScn1quYnV u5M0jgHBBPXpTEOF+Y9cjFMwBwTlh70pY5zlcAdMUou1mSmuxIvTq SM9u1OWQZIYYOOCahVwEzkf 7VIXDBmAyQcGm3HZlpq6uWc5XaSBgdqrlmLDaQe5UCm4IkHzA8 c0xxhmBb5s8e1NJb7mkZa+RYZm DYztOO9J95sFlA+lRo+Y8MAcjJ9qRc7gwzjHc9KOVcphysn C4TBIJ6cVH0bdznpjNGcNjB/wppKs oKsAwoW4SWgz5wzEnoKbzuwSAByD9aduycAn+lNwfIO5 TnGAattpotJ7MhcsPQ9jx0ph3NCM7Tg4 HvTmHOPmI9B2NM+QykANjHek3Ym12J8ipuY5YdR61 XOCQcgDv7VY6jrtB65qu+ORxzyKT9NQd9iB yGZhk5A4A9ar7vMxnltozU7DJ3Y4Ze3aoSAIsj vxmqjazuVFt6FR2YSbVBIIzxUBVjggexBFWzgI eRnHBqrsJhUcjPOfWiTe6DmuyqyEKxbJOeB 3qFsHB2MMHknuasOSM5Ukg4TmoWGGIySOhyetOXMt yNempWPO5cYBppXC7QOVP5VI3DAAdev4 U1wGHOVLfeOajV69DRu6sR43HGfb6UwoVcZBwB1FThcM No3etRF3MhJVuTz9K0jonZkp6asGJ 6gD61Ccs5DKT6e1WAGw2VyQeKaWGSp6gVN0tLApLsVnBZkI IL4IpoX5NxYFyp21MQS3GMdc1A WLnHAYfnSvpfYznC+xA5xGobBJGRxVR24G5So7/Wr5Q7SrMAcd COao/el28HgHPrTg9BK1io4 VmByCx5J7VUkVckHOPbvV9ioUjgLyBntVNvv4PHcVd3fmKsrHCs4A JzgZ/WrMeUAYENniqabV y+dw6gVZQltwyFOKpT1aREoO1uxZGS2cFsnp6VaHXd2FV0w0KnOCDzip kxkgnPpUxipFddSwv LbtrKNufxqwpRh3BPP1NV494Qcg/hUi4Dlic44wDTabauO/ctIMoRjkdR70 DktuBJ2kU2Mkjd jaAck+9ShxtJ2nGaiKd9iabd9CaD5XxlSFOBmrkZUzAnIxyeaqJgK2whcc5Izi rS+WyI/8Q4I FTK6Zc1zKz3L4KKp3MuC1OQpuPPHb1NVlEXKHnHfPalVUMm4E+mKrlb6kq0VroW1K hyACMjgV JvZSMkZA9Kq4c5JcZB5x3qfefLBGCp7YqOZCV97DkcEAPkjrkU7IIJw3QnGagLjACgk9 8elER z8vTHB5qancqK5ndE8TAncrY453U9STMy9eeKqqymTAbkA4GamRmEgUZ6c+1S79zVxu9S+j EA MNp4yTUyliqsDwRVRGJj3YwmfwqxG6s+3BCf1pRm9yHpsifB3evParablTIxgjBz2qoGw+CDx3 NTxk4OMkHsa0c+rFzXLgctDtYAkdxU6nJJ45HPtVRFQg5OCe2atg7WPBA71CcFsOL5XoWULMdp Gc 9cHrVtSTyV9s+lUo5GVCzHHt6VajYSBSSVHrn3rSLb2QSve7LaEqCBkHpmrqBnChuD1qivM pI5bv zVlM7QxbDZwTUyfUvR77l0ElxnoBjjvSsZQpyQAvbFQopwDv5I4HpVgIOm73+tVFRQnK 2lwDOQu4 HI6gdxT+DyuWA5poOMNuDMBgAVIMNICflUjpWc22tQe1xSR8u0Y96XaQVx827nFRq ylM5LY6c9KA SGJXp/DSSsKy3Q8d/lxzxgUhQEv/AHjjimCbkECpl4O853N29KPeiXyu2ogwSM UpyJNrEEegphwT nawPQDNPUuGDHAOeuOtCVtSLu+40BSm3BGCSDSZO7g5OOfanNnftA3c9R0o OMt8vQYzVPcL3XcEC 9W4PrjioyzHKtnAPBFPCj7rHr71ExypAPvRC8gTtLVXHMGCbmXcN/FRj 7/3sAngUMSyKCTtHPB61 GrfvcnIB9fWlumXyt6Cl8ZCgkg4HvQCGIDDoeTmmM2COmF60ze2d4 6UPVE8rS1JSwK8Yz3JoJ2jI PamEfPnIwByaVFCpkHdgnrTjZLUNtRCyliBkg9hT1wApXHI5qM AGZmORTcAcjI9OetVdNBKVlZD2 bDdB1496UMG6kEDvQHyvPODzx2pnmDAIHOKSetlqTZtbD94 4BXHbPrTgxZsggKBgjFV925+AQONw PWpMp3DdOvrVyaTVg9nJLUkZcg5fIJ4+lIGb5sLxnGaY 7sQMD5T3xSKxZckMOefShXaHeyuLnEpU jk8k0g3k/eGMU0yKxwMjPIJqHe3lY/DP8qOVvoFlL R6ExJD5TaOMHioWIDLgjOMU4EBipbf6ketR yKu4NtIbriohDUGmpWSGEkqVZgSOtQORuAU4PY VK23ZuIYHBzUJx5anBGRyaq73CU2RFzu29s8kd 6rktjpgZqd3BO0YA3Yz61DKWCAYAI5px7Eq pJ+RHKoVMnG4DkVUkOIQR261MzfvWOC2Tk88VH+7U 53cEcjvTk+hUYpLUiYq0obepxwBVZ2Bc hlzj0qaTBwwBHQAmoHUMc9+MAGs7q7uJWIcrszg49M00 kH72eRz7VMyAOFHT161C3DgkgjIAq opJ3KcvdtEQBOmcHd601lRZGwe2BmnkHeTzuBz9aQkSOR1G 0+1PmSepOuxGM5x3HvUL5DtgDI +YHHWrGQkJbGD71Fu3tvAbrg47U4xdr20HKKWr0IZMiMltxBPH PIpgUKpZc8HP1FPk3FVCklS agAMjKORj360ON1uRb3dxj/MCWIAJ4XviqzIFlPUMOOv6VYZguAyM AD96q5ZndiQpHUcc+9Uk 76E3sUpN2/bwRnioZVxl3bPGAF71ZfiWQH5cEYPYmqsvzYK9O496HC5b fVs84R2AYbcYOFwOt XI2yyMM4Ixu9aqLtL71O4dMmrkbjcowvfpTSM38VlqWo/uBSWUE8GrUYVYz 13AcelVY8BWx85 x8oqePJwSu0AHH0qlrpcat1LYHyqADgHjmphjaQeR3xwahRlEOcHOelPicknO3 g4pxTWpa12R cGQcDC80qsFnJwORxTIySoB6E8kdqmVdvyfKMEjLd6ykv5jO6UtSRPM2nbgKfUVMv yocHAzjJ FQJ8seSx9zUyNubJ5XPSqjF9Byk2ix0ZmXG3PrVpWROSBheCfU1XQuWZSVUDse9SDIJY gHIII Hb0rKXM3YrSyvuKwypKZVsjK+tSOSFyGyR1wOlKq/KRnnHBoEacqWJPpnpU86e41JOOpHuV UG 0Zz0yakRgRjODnJHemupOdowMcH3pEG6QgZB6YNNxTSuyNL7kvfK4BHUkVYiO5m3lcD8KhWPLg kZ9hVlQHKlcHPUd6hJPRG0dOpImAdgJXsQasxjawAGMnkGq6lVnJxnnH0q3zkjPfGarpsRK6aT ZN jhufoT0p6SNtwe3GcVCqknaCCAM4qYcKwzuz0AoXK99y5rSxbUq8jZbGDkVYT98nU7emapR D5iww uF6N3q3CwEG0NnDU5abGMrpouZIk6ggdqtRsocgkbQetU+d+TgjHFW1I2ZI4OM02rW7F u76FtHUZ AxgHHH86tR4xk8gjNUozk/KeM81YXk/Id3baPSlyrqLmaL4KhAnDDqMVIpz8wByD6 1TUsspzjHGB irHKwBjkY5PpSa6I1cLJXe5L0bOQAO38qcSdyjrtPJxUR8tlC5OCMHB704AZXL EgHnnvT6ahovUc NoZiCMN93NOVy0Q2j5sc0HhBgZOO3egHK4zjNRZ9NQUtbsXy1IOVx2z2Bpx YiM45IOKZvJbB4GOD mlj5AJbJx0Bq9be8TNO97kjcw5CkECpVKeWrMGJpMAZIIwRSbcBiTkbe 3SpWqNJO6sKANrMemaib 3O0HvmnAtnnOBxStngEgVPK76C5bakIyvzDP3T15pBhgMEBunNLkg YKlU7saGIVc8Z6gVdnuydLX IwSHbcQRTWcA4bkAce9O6zcDJpSMYbIwexHUVOtx8sXKxGCDtI AzjPPvTGxtH97HSrG1WTnqTyem KgUDcSwJYHr2qU3dg1Faobt/dkK2455poyJFye3SnbSJSRy SM8dKMDhwwIwQRWkX3JlorDRsaQBm IGOcGmlSAQvOemKGCbG2LknjOaQBgpGeBjH+FCknqNKy 0Yi/LhS3Q8GpG7kYAHQ0w5CnC5oPmEjI CjuSOKaSbuym2noxQABnrntUaSFUwcjnBzSkgnCk4 xnPpSAkldwHQ9qclbSxMpRa11JztI+U9+/r TWwS3BHPTPeoySytIBkdsUwlSmWbIx196m7urF RejfQA2JFO0/WnbhkgLgYz7ZpOkQbjpjJ70xiG CjPA5JrR8vVC01Y8bcBdrHb6Hk1GTtbC88c k9qeCQTg9fX1qNmEfmF1znHA71mnYzbTZE+OVyTnt moWYlAQOg4FSN9/c+cnOMdqhyuOWyQO1 XZNjjdvQY3IC7MN14qFiRIBgZxzUhwW3Bvm9PWoiSykA c5wKlvUbkra7EGQf4TkDFQE4dhlcH n8KmLDldrKCeCTVZnLYLHg8VTTew4yitRsg+UbTu9PYmo3A DKBj1OKexAcEHPXP0pGZnjGAAo GCSOtDctmRyIiJXlSDuzzjtSPFkDq2R1HSl5ySwJHTI/lQ27dw VAz0PahSl00HGTjezIG/1QQ NkmmbFVjyW7A1Lg7MdRnP0qPYCHCkqpbrnpTcrIUX2InG6Tg4cdc9 KEyqjcCpPB96kbAcgr0H B9aYR+7BwxGM4pSldFtXepAWKv1xlc4qAtnayDDemanckS/dzjp9KiZQ qgMTg+ntVJkVHFWTd iuQxQn5iOpx2qB8/MAwBY9D1/CpnPyl92Ez09aquTztb/cNJe90DlS1uR5U qM5J7+1Z5YbiGB 9jVuRm5jUZ+YZPr61TZAFDbScngD0pyavYSTtc87ixsQKeec1bGFkAABHXFVVE eGVD1PAHarE IXzVdcjj5iTVuV7a3Q4zsrXL0ZGwFR0PHtVhRluCc5yQD2qqpyfkOBnknvVuJwpAB DNmjmdrk pxT0JwvzblBOexqwuSuCAORg1GxYxZACkHrUiBccg8UuZ213CKb1J4wc7WPy+pHWpesi gnAx3 7mqyHMyhmyAMfWrAwYgwHI9KLNOzHLe5MpZiFA2/L0I6VMpfy92Ax74qFEK3GSTyKl2soAG QW Hc0khtxvYsfwDOemCfepg42luhJxk1AjbSdxUknke9TKA2A3A5ODWeq0Y07bCgsfunJ9RUqFto wQSSckimxhAxxuAx1oVgZAT8v8PNOydgt0SFCfMRlyoGSe1SAbWUg7gOtOU5+RjjJ/SpG3kHao Ax 6VEm7ibb0sEe5pl9WP6VPG6rMAOQM9KbhgDgcjgZFS42MQMZzSdt0OmlLRk6KNnbGD1qfLG PCkHA HBqBAzIvb1qwF4AXk45xTstFcTjrrqSKuCOeCcilCsJFJI46cdRSD7ucccZqQuCOCMk9 xT1T0Qcz Ww/7pJDAsTVoEBME8DgEVAGAO9zyFySasRhfL6gA9aTk1rYuMu5YTaxXBx3ye9Wt2 EUDOD1NVkOY 0Cge9WAm9VVux4NTz2SbBSu0pFuNj5pXv296mVtrj5s8cYqpGnIOcsTwAasoGO 1gvfJ47VpHlLdk X42/ixn6mp9w5YHPYiqiq52v0XH51KuTwpyAMfWhW2M5RVxYx84fIwRwDVl eFAAzk5qNUBReMEDF OG7ewOQc5z2oqT1Vyp3k7IcMSDAzx+pp5RgrOoBbHI9cVEnXjIXNWTnp 0PbNEk4kt9GRkBsDjjqO 9TZ4Hy9+/eoVADswyTmrC/NtJBA7kdqzfmXJJWHgfJ0Aye/anAMmV fBBHQd6a2FOSS3vS9cAKWAH WoSuHKnsxrcIOABngnvTAGLDPzZPb0qXAaPAwwz1xSqsm8sF2g DtWnoKM+UY4zDuOcg4AqJgzDJA Crz05qwpyQGIHHpUZ3BieAOmCKIprcIbtkeDuBxgdeRTScg 579KmGSF/rTHUjAGCR6UpJXHFvqQy sVxgY4wajA3YBOPpUx5cAjnsKT58j5Rj6U0tBOViA/cA 2sB2+lLtJ3K65QnjHepmXbGV5bPSohu2 YY5XOFIq17y2Ji7bETDkAYVccEimlWEYCjJBqT5WB G7GTwaQ/e8vuO9Zv3XsU5PsNziIE5zmo9+S 3JxnkUuG3AE5UnimswHyMMA9CKtJkpxTIyq/L8 56dzTkILfN8x6AU18bhyO/5Uwbig5ByOaJ2a3N LpbEzPhBheD61EwJwSvy5xigMxIUMoXqCaY pVW+ZiRjjB604NWtYjl0JQ5CFcj5egIpuAYznAx2P Wm5J3E8YOM0EER5B+b0qWS7XGFxtLc4J xj0puQckndmlCtyTnHVhTAp2sBjHqa0TQ1roGVMuVJK+ lQyJG+4rzxzg0YAPI4J4NNYDeckjn J96lysEbt77ERDBVJ2kN7VAygMfmIbPNSSBmjyT3zxUZXA4 OeRyfemtdWJu6K8pG3uSeeDUWc lQpUkL1x0qRvlySRuP3jUBKHAGAGPrUt23GrdCJmcPuK9BgDFO WQNER8qjOeaR/vZznj0/Wq+ D5oKk4xkVbUXZFOKepIW+YgnHtTCW3HjoKUDdzjnuKQLiQYbBXjBo uuUTmrWGniMdeTk+9MJ4 OMnNSjHkr8pHXgnkVEWO7GKaV9h0+wmNqKW6kdTTONjYJAx1PQU4McKv 3hg5FI20dTgHsam7u ZXalZlUsSm3BbnOc9qZIpB2McjBqVyQ3TAH3ahYhw5LY+tDjLmukO22hA4A jC5DA8darZzCVU g4qZ/kUMMEHggVBjO48DPFVLSxMdXcpyttQ8nPt+tVXBK5ByoH61ckC7diqSPX PaqTDGRk4z0 9qLrSxS+E873DDLjkHr61ZBAZTn0yBVNPMSLLkMw68VbQLnewbLeh9KttWeu5MlFL YtxZMf8A cye9XE27lLEDA7VWAX95huBg/jVhcmXPDLu6+lZX1Ha6si+CGfIzsPr61KhYgchWxjFV k++Cc jP6VYUETbu2Oc09X1M9k0TbNhwPvZ/Onpy5Cgpz0NMT95yxAAGOalj25JXcwzyaqTdrGu6e pY yWPXHTBqQAsm7dkZOKQGP+LIAqQhfLYLu3LgVClZp2JtrqIiuYyM5I6mpSVZGL5OBxikBwxUL1 pGJUggZBGD35pNu+ptT31LDBBIGbcvy9/wCVOxmJeCfm5FNAKSGRnVs44IqcDLqVOBu5FHM1pc mT UXYWJW8ggkZB+WpxH8oIz7/X0pvUlcEYPapVA3kFuOg96h3uKKbTuKu8ybenPBqyPmZecHG fwFRK p8sgDBLcH1qwEIwTwONpppsG0x6lQOCORkj0qVeUBGTnsOtIEyoUDgdDUu0cc7ferc4/ ChSn1uOU MMhuB6HqKcqgHBJPNCr8ud2cjJPvUrR4iXJ5HXFTKdnbuNSkxVCiFSTu9vSpxgfNn BBweKhjDNE4 4GSCPcVJgBSCQ+DzSTsF3HZlhJdhK5VgQSMCpkyQobrgc+9VyoI3cg8AAVYi++ RgMq9xSvFJ2RSU Wty4vEn38EDr71bUs6hQSQOpqmoBw4PzA4YZq0GGFVSPT/8AXTitBa6WLPI UnPBqwDsKFSpK9cVX TOFB5wKlVl8s4GT0U+9PltoNp2SLOTuPoTjpRywPPfrUKu2BgEjOalU/ Jg/LzjFTblKcZfMmxggE fMOTin7v3hAy2T+VIuGVW3bh6ipo1IBLAZxwacprRii0tGhgzgjHO 4U8u+WG3cM/dWl24+Y04ZKc MBkVDs9UFpIcqqFBLKWPYnpinDIbbuyG9KaFyvBUHGfpT0IT5g u4nsaNWOT6CEqVA6Y7CmsHLDaa njjDOcgHnkCmvHj5lO0981UJpj92/L37jX5iwAcetMVJATu +nSnlmA5KrkdMdabglQRyP4setONl qOV4dhpyOMd/yphA5K8MDx78VO5UJnPzMckZqA8rnBAP SktRNcqIlU4GcjnrT2+UsecZ4NLtHmIr HaOetOCsVyQdopylsTe0tSPBJAOGGDUDcr2AHarB7 YOc9hUfJcgEKO5I71PNbcSTTuQNjaNqkf7W KjOBOSzAqR+dSSKVLbRkgjimsOWPdTjGKZUOVO 7GMdx+XqOeahLDOD97vmpNoL8sOnzVD97cMDOe TVWSiHKlsN35KnaRtGMnpTc8sScZ6cVL90f ezngccVFsHkkNnJPrUc0dWyelkIfkUMRz0znpUZ3Z P8QJ7dqe2ApVidh6+1R4USPubGcc1am7 rqSko6XBV+TLHgHtQeMlckgfMc0mVDE8njof0pSNifK2 T/EAaTlc0vZaiFiQo6Meuab8vz8+m OafuUkZ4brVZztAJHQYFCvs0TFJ6j5JFEeGXnOD61DLJhQM Yz09hTmysW7GTjnio2BJBOMnnG OlOLsEbbkLMyhj2A/WomJIU4PQcVLksXyNvOCCKrmTpubsCcUW bb6icbIhD/vgS2QTnHrUTcS c4Ck8EipJGDSbeAP61AVyqqwPc7aqST0M5JrXYhYsVJYgYPXFKxOz jGGFMJCyDP3M8j09ajZg pDLz754FG0i0uVXHKF3jduyDliDTiwMvUDPTNR5bJLDHYUAZTLYyD0FW pNi57eg4l/MHQL61E 5bIAHyjnipC22M54FBcCQggEgUlUaaVgTTVkMVnk5VMHPTHNMfJX7w3HrxT s/6QCFJ9SDS9mA AIzxmolP3hSdtCsC7Lt28k5we1QSMPL5UlurAVJJncSAxHIAHeoDgDoTxVx01K dmiLg78gjJy BVNiRJlgd3UDPSrbLsUEA8jpmqLuASwDHI70Xv0IptJtdCCRz5uw4UpyWHQ1TkGSC HBLA5C+l XsgIzMADjvWax3YUIw56miM0nsXDV3jsefgsdyqA2CMmrYIJBQ9OoPrVE8bemDx8tWou GYEHn APtVSXKm0ZO+7Lkb87cjsT71fUdSCAx5wRVJFXaB/FjrVuHzDArEZOMKfWlOF2pJmr6P8y/ CM k5G4ntUw7gg5OKrRsGbK/fU5zVsllJOMZpRSvqZuVxdoB2qee2R1q1Fnapwc96gQ9CQeCfxqVS ehO3PSrUtGW4uSLIO1/mUN6CpF3NMyg7ecmokGIyZDnI5PvU+R5KgZzt59aye+w3Lp3H4AgHyN vI 496OhRcEHoc+tIUyuDkDqM9qfGr/ADcBiT37U6clcUb8trljaXkYsjEHgVJGCMEABc8imAk 8dV9q mCfLwGU+9EovUHK2iJV2gSEg5zzzUyqiDPUkcVCnO5SQRjJqUZDoPLPy8DIrNRaZpfrc kxu43fLn irURJTDjPcCmooDcYbdzUyFVHGS2cE0m76WJVncevy52thsd6kT5W5xkjqaAuHI6g 9falLELgYLd qqEuZWQpaj8YQ/KRxgUuFCjG4kfeye9NXeXxnJ6gEdalDqwwQWOMDFDlYfNdWF QcKSpBPGc0+NC6 ttZeeo9KaC2wK3GetKMK+A4I9Kh33Qoxdrkq7gy57DOfWrKoFAIJBPqaqDI GMHaauhFIyT8w6iql JrdFyjH0J02qSeemTzVuL7o+6Sc4/wAKpKMsArAnuvtVwMoIO3kdqfK7 DUL6J2LYKiNQCUYdQak4 bIVl3dsVCq7hllKn1PvU8S+UVbaWAGMUaJeZLvF6EyAiLYzADvx3q RQ5Vdy7sDkAVBltxYLu5B9a urnduHLEdBScrPUavFkijg49cY9KeciPackn3pi7mXGw89frUy 5ITIPHU0l7u4pNSWgg2hQvJ5pP 48EEc5Ap5X5/lIOeRUjACIc8HuaTbsKLaV7jVXqcc5wBU2H BALIGbtiq4DBCSAQORzT18wgMWXP0 5FO19bml2KU2S7MnB64NOHQgAbS2eaaH/eZyC+O/ak3M 2FwPU4FJvUqbskrgAPNy2ScVLujSMdTn oO31qJQyynOCMc5605l6HJOeAPaok13M5STdhPmbI KgDPWmncI8feHsKk3nagTHTkmo3yF3D8QKa auJybeoE7iWyDTSV8gZbOcZA9akDAgnYQ2PmNR Md6gEr1p7ly9NBhUFyfmGcVWJAdN3G5Sx9varT kAnad1QO6nICgYwCTVQeu1xOV9LXRA25lDt yQe1RcKflbLHn1qct+76dT2qry78HC4xnFO7e+xSl aOpICokwcEc9BUPl7W4PGM5zS5AJCtjt zSFl3lmPTr2FKO9kzNq5Ex2oFTO71J4NRlgX5baQ2SKl b7vp+FRyDcAcgYHJIpy8h82uhG+Sv Xv+dIMrIQenFOJPlhQM49RmmseORgk5B9KTTaDV3EO7Chuu MZHalAKklRnHQ0jOhHIYkYyaaA AM5LZGeDxT3uNu0dR2CS2c5x26VAyjJGCRwMelTDduy+BG1RBR v45GeTnpSunsRGdncQ4VQoB LBehNQMWdQVG3Pb0qRwDyxII6EVC5XYBuG4ngU0+ZBZXuyJziY9el VpCpi3A855qeRT5hySST 1qCQgDYwwM9cVUddAT8yFyoj3kc54yeoqq0iAfezj3qZshcHHI4qux3D sW6dKpIm1yLPRTkA5 xmlfDRDhT601gUTaTvXoAOpNRD/AFIYqwGfm56VN0nqU+e+iHdQGyMU9Xyh ABHPU1CcrMQeRn IPajcxK7gADyMd6q/NuOUko7DiwAAI5I70mSI0zxnjB60qYxjGD15pA+ZMMVPH 5UOavawlG+o 8sBCdqncO/vTH3gglgvHUU1nGNwJb6Um/12kHqTWV12FJtLRaCZOAVB5PXqKrswGA e/A4qRid pAHocCq8m4nkAHvmtORNh53IflDFdxyF71Ucose7nkcVO/31BBBJqCVVI2Kc+nvRdpak ucb7l Vy5baBkd8VRlYHhvlIORirjMSwAypA6mqLsvmY43E8H0pWQk9Tz8KpjJDFgO3pVqHAtx8pL E5 BzUChIpujONw4FW12F8E4I4rZWWjRKqPlS3LaffXH3ic5q1HzGrfN16dhVKHkxkZy3I5rSjc4J I4J5A7VHLJbMq6jqSqQANox6+1XomDIN3JAPPUVUTmRQoAJHQirCLhlGCAOtFr+opJPUlBUqrD Ab r7VYXPDsASTnj2qCM/OwwAM8ZGashQ5BcgqAfu+tN8yfkaaLUdGHkUZxnGasqo2jLbiQcKO tRKmR hRgYJ68inqSsiY+7twKE+bWxklfW5YjJ+VcEt/dNTKMIG3Luzk8dPaoVBV+Dn196lUDa 3t/D9KV0 nojWy2JMDbyQzHONtTooG4knHHNQJ83JUDPIOKtpvEI3Lyeg/pR0stxxajoJySxwT zjIq1FxKuQe mMGoUwQepOMfSnqCZPXuSKmWq1Y3YljO6VVIKnGB9KtooE45xkY57VXUkyLkj7 tWgcle+BzUXtsT JvZDt+JTnJGe1TrzGVAJXvnqKrZ6dAOpzT45SBuyDn06VVna6C2mhOA20FT vA9OopSxVidnBHGO9 RlnKrg4PAp+T0JyAODjrSk4p6iV7oAflUkdD3NTHbkA9AB0FRBXJUYYn HFSP9xF3Ddijmje472ej JshpAoI2jgipRu3KVyQDzVSMb1O1stnJJqzGxKk5xxwaUk7jcGti0 rLn7uCOM1ZyXG48jPFUlYlF Lde4Aq2kgEcfqRyKIxafcIt3ukWs7Y8iTI3DrVtQfmzzz1qih4 AwMdR71dDkNwcelKo5dR2luiwM LwBxjgk1OnyKQoJPc5qIEFACBjP409VyMgj3oik9bDb93sX EwARzxwOaUfK5xkIetRxklxjr+hqT d8w6An1ovyuxLbt5D9+F6cdD9KVgNw3nGRkc05mwoPyk 49Ka+PL+YZ44OKEwjN20QDiU+XkjPc9a d8y5bB3E0ijcvAKnGeOhoEjPnkL+FQm3ctvqG1DI2 44yOMUb1BAGSSOQO1DGMg8nIOevWmgHYflx 82QfarUrIIO6Ji29BjAwcc+lDEeQCOe4qBR+/w C4X1p+cuMlR7VD3FyNvUXdhSPvetB3CPkYY8ke lIWZlzlR+HWmM2VGQSc4x7U1eW5Suh7PkAA 84wfeo88KBgtio2YuxXBPTBpWxwOVApyVlqLlVtRj MzEHG1c1G2OR8pB/SnM/zYbkED8KjYnb kcjuaq7W+hU1MhLgkgnA7ZFRMyD937Zp8o7gYOM4JppC qSxxnGckcVNnYhxttqRA5k6gEdc0O +EVtpIPX2prHKliQD14FIZCUyFHsDVKydxxetxh6sXIOeQB 2pn8POQemeooOGl3g4bFMBONzZ HfHpmktdWKT5XYT5WRc5B9QakPEHPXvTHIkIKnnoP60gLNuwVJ zTTuKb2YnO3HGO9KcKByOBk H1qPc3CjB5546UbS7ZzznI+lVJdxTaaJWc/LyvTn2qsTjncCM9hUj KB909ByM1GwG5iAC23JW iDgtCIciegh5UAj+HOf8arSKoZT0zz9KezNtzjaCBkU1pQFJzyOoHaqb lH4TTkb0WxGzNhQAC MZ5qkfkQ5+YZ4PrU5kZyw+ZT6mqrnaFUnJ5zU6MXLb1GM5zlVLehqEswTgd uuOKeS6ttGCeB0 qs7Yf7rZ579az0voC0VmhpYEEpyAc47ioeWYgA46nmnOxDKyjjZ27mmBSGyCCc c4q5WWw7voR lHKgHAyN2acmQoO7JOce1NO5txPUnJA9qe7jyVClSx7DtTdSTtZD57jix8tS5AJOS MUpaPYxC nkY96rjO5QQWJ/KkYsEyeB3I70pQ6tEQsP6SgjB5GRS5bzmGR6jiogf3bHIB/XFMZ2ST KsOec EVad2kiUkk2JJuaYOHGc9qa7Ycg84OCKc+DEoCkAdvX3qq0owxwPcGh2Za1WiB2ONxB3AcE 9q ol9wH8QBzuFTOTI20KykcnP8qqMCAQflwOhppxvZkxtfXcYSqtnJYEdfT0qhNuMrbQCoHX1qy7 rsIRh1/Sq0j7V27i2eeB0NCdpbWEna7OBjLrcgyqT34q1GvKNtYk8cGq0QO/5zyw5J9Par8ZKQ 4O OeFNaKKixSUlui1Ep+UYPX8h6VdiQjoG5PQ1SjOUwuR6k96vh2CrgjJ65FJyadkEd9CdOHH zZPoO 1WFKYBBye9V1XEoKsGJ9quRgA7SoBx19aXNd6blOcbk65CBSQT16danwR8qg5POagVm2 qrLjaMA1 bQqqhiRzwKcrp6oIpLzQ7bv27Th8/NS/MzgNkbR9MilC7SCp5H86k2tkM4JyP1oTX cjn7f8ABJUK mJwGABHyk09B8rBcnA596ZCRtA6sOOKl5Ac7eSeah26LUpSbRYiZSNjIV2jjNT DhVPU44HpVYOS3 KHgdh14qbBaMGs0rajknFpskTJkPG0A9+9T4POM7SaroMRbG5HHNT7hyw3d fWolZsaqST10JsgKN 3UcVKpCSYBPoc1WDYOeWBxUpbc20AMc9/Wrd4vTQcnZ2ROhyWx90HmhW B42j1BHpTchF54Oe5pqn bHzwTVpXQ7LcslsJk5G09RSo75IBAye4qBWIX5sjihWxkEgqTkGs7 LZ7iastNS3ucyZJyMYGKAxW VlCsx3dahYgIdvXsKlUkrgHBbrSdkitbX2LEfJ+VgpI6Y61MgH mEBlyBwPWqu4hl4yvX3qWJjjad pbGOByKLahHa5cQncp64HfvVxVBTIK4Pas9XPC5B9asoWUA 44oS0ugvZdjQUgyEggLwB7VaQlgwO Ce+KqIyBgRk9yD0NWFbdHlQQSMginH3mRz3diynKEnJJ 6j0qwvRMZyOoHeqiHG7nBzzmp0fadwPT pnvUSUraGlu7Lqv84HAOQcCpFTPLNli35VSUBRuOd zcA5zVkNgYzk5GTWlraoU79CT+IqzAk+lOG DjPJHUCoULeYN3DE9COtSBwDsIAbqTRPunqTKo 3uPBZQT37Ugww+Vc8cgUxm+fgHavSkLYOTgYqI pmlpJXH7gCFw24npSbgVIJOOn1phb5g4PX7 xNIpBlZ/4c4ApuKWqTJfw3bJozxtY5WkYgHK5Oema YDjIU8Z545oYFkUYJwucipi7SuxqUn6D 95Em08EHnjv6Um853bScnr6U0H58nrjvSFvlBwVB7Vcm mKKtqOBbcCMNgc0yXnaPmzu9aQnK7 kYEHoaYzhXGWww6A0t3oHQa7EggjAA4Pf6UxORxuzt6mnkk RAZU46Co2ZSSQ4HajWzByfLYgl B6McA9aYPvYGcEkYNSO4XGQSe1QNJlkHXvn1oTk42Y76CEcjJI I9aYx+ckEnjIx6U44bkHnJx moJTtAABxkHNS7kryEcsWLAjoBjvSlMKScn3qMsN5zxk9D2pu5t5x 86YzxTk5dNAVO7JFPy9V zt49qUYCYHPTkVAhJhztOfbtS5AMgXOd2atxSbQSbegrsAMLxzxmgFsj CnkZyD0PpTfTuBjJp ruTFlfuk4oa6WFKAfMcsx9M8UwkdCc9uO9Mzt+UHqcZ9qiB2uTtNEvImEls h7FtoA5UdCB1qA suWGC307VI0hOcjAYZzmq2TlyF6EfjSd+U1jGUU7sVmLP2UDnJFVnALYIJ460r 5KjqSfQVCzA uCxGSOMdqmDa0Iv1GEnc2OhqBlHzbvl9z2pzOvGC27GDVc5GSx3Y4xVxWlx2k1dDM qBvHHPy8 00u2SMKG3dMdKRSGB9M5xUJIOCTknk+xqFHmTZTk0tR2SsuQMAr1NIULOjA/MwzxUTZV 9ucZ7 ntR5gV9xJwea0SaWhE6bvpuSMWWTHLEelM8xS6gZzjBz3o85TICSMAHFRNIPLD8ccAYqEm9 ZI E7rRakm4lmXCnnrURxj5/vZqMFcsTkEnkZxikkC79yt823nPNN6MnlvuSNJk8YHHB9aqM20tuO 4Me3YCpN/wA7AgAYyc1XLsycYwy9SKuF9hW5XqhrFN+QTtHU5qvM3ICjJbgnOcUjEH5FPINRMQ xA OVLcYB5qrK97jvHQgZPk7YI7dAaqsCsjgHB75q1ICq8ZfmqTjc53EqCKOfm0voEXdO7OOUj zF+cE dh9KvoWZo2ypXb0xWdGSQPfjNXot4QKE4J3DNae0bCSdrMuwhS6hsEYyAK0Yvmj2kEkd CKpxkM46 YI5AFXEUmIEMN4GM1EbvYybu9CULmJeAFBwR0NXN5BH8LDgk1XjVSSVyST8wzU0RG 4koTim02+Yt O/QskZB2LtVj1z1qyhOeQox61XRsDplQcfn3qyAQxkwS3AI/rSumrMWpOQxLbg Cc4BoX7+WcD19q i39CPqQaeOHPQkgcVlGLvfsEJ6PsTL94lTjI6irEbkPh0bb3NU1CrErZIP8 AFz3q0h3x8sc7uRVS lfRAn1LC8KDnHf6UsbjyyRwM/eqKM8HJOOQOakT5QCOVzihrqUlH5lkN lV6EYORilUkgbSD9KgDB 3AY4bGB71LnfGAg24GBUxTiU5cr0Jy6CNskAjoM0oBOSGHTg1CcEB iMPtAyacSQgZSG5xTdltcLt 6LQduywyS+DnOaejfvgx5zke1V1ZuQBk+1OVgHJUcAHNNyfUuN +m5cJJZRjnufpSYJCsB823oKgD 5YkkA4596AxKHJwAO1TTk73YlK3Uubl8vkbGyAMmpI+ZgDk ADHJqmADLw+H9D3qRXy4HIyOppyin 1J3TLSkh/vcY71KrfviRnnrz1qupDfNnvU6fMVGPmB7C lJdy+ZblxXQsDjCscD2q4gG1MsDxjGf0 rOU4IBx1/KrcQIj5OBUPR2XUlzvHVl5MmTDcY6GrU U3zFTngZGKoxv8Au++OhA61ZR8OuVx7049g 1toaMUihQzKST61JGCy7eAR0zVIM2Bt+bnGM1Y DAxnIII75oQ4rtoXg2FI9B19KkjI8xx3H61VQq eTnb9alXaXwThQM5HehWdkNJJWJvNcxgsrA 5wD6CnqGWQh+gXvUcZDKFB/E05WzknIIPQ1T0dloR a6FBUgDPvTyxzkABQOtQh1VjgcHOBSKw 8woT9PpQrvVjlAnRlCHHzHdTSPkLcjB4/CmsF8vjOAOv rQFQIM7sEHPNZ8yvuCjyq7GrIS2Oz HJp5kCH5mHFRLsU4zyBwfamsCAG3AnFaJxbsXzJsl+8MFgx BHSnbsSAEEjHGahRv3OSQWzk4p u4MSec9AM1m1d3uNkiyElRwo7DFMO4jAIZsZBo6qG4zSAZdWJH XnFXzaaEuKirsRuyngk5zUT OhdCFIwMZ9akdzvPTj261AwO08HcOnHFKyW+j9SkrLcj5YAqRjFMw pXcGAYHAqQ8n5QNpHFQk FkBQAE8nNCm0gspDWUFm5xnmomBO7d0B59qlwA5Qg9Ofr7VCTtRiSfoe 9SpXVkyXroMccEHHT J4pmQY1XcFwMk0u0MysTheuKiPRiULHGMA1XLrqxqSTSaAyeXFxjrT92Y2X bgdSR2qDBO4MNo B4pwZmwQQvrx1FVaSsyZWtckLZTtx6VE4PlkZwueBipN4VT0AHTPpVZiruMucd cE4pRm7jWm4 0nbCQxIPIFNZgB8xzwKQMecjg9CaiLDCknGOufSm5N9NAlKLsx+4spG4Edj6VE7bl U5LAc5X6 0jN8+ARsx1A601G29BgHORUpNq4p8rSbImIR2AYgHqPSoWJ3YAPrT3YEMRjrmoCQS+GI JpXuh +VyNiS/ODt4+tRZwgxktnk1IwYsF3Dbjiq0nB2c4BBq4S3QOXQa/CnjC54yOajI/d5AP5VI ZC FcfeUtkH6VBJJIXDBTt9BUpyetiOW+vRDB8wCk5HXIppAMTBSODhaQvmQj7vFQOcgqeBjPHHPp VR10RXs5Rt9485wSBgg85/nR5gORj5VqKNS0LKTgHpzSNjIXJVfX6UNO+opqO7HeYfMACk57+t IW QuScHA7U3eABxyehqsTlAAGxuzk+lOe3mRTS5idpBtDYZc9agbhz1Cg8Uudm4daiZgAoYdO BikuZ 7lp+RDkl8bSoHrUL7V2u2ckZ4PepGV+X5yemKruchQykHr7YrX7VhcuuhHIwzlTgexql Ix2Nhhz0 Bq2xBBGRuPI4qhKxDk4yB2p00kyVHXU5WIPgA/Kd3TFacbHYM9CeMCscvm4AVmZFP PbNaceMgsTg t096cIppSe4oQdveZfhU+YeeM8CrUf3mYfdPvVeIncdw4Pp2qyqho9xOPl49qa 31B1d7F2LiIgkZ xjHerYOVAY4XntyKpL9xVzweCcd6tDccLwAG5JqJKzUuwKVtbEqn5gUxgcc +lWI/u5LZ5yBnrVcB t+Bjk849KsIACUK4zycGkpNsi2t7DwckqMktzmnD5hnkc4564qLJLjgh exFPBbaMnopyfeqcXpct Su/dHsSzsNwx64qxESyBcgKO4qqB8vUcDp3qdXAI4KkfeqbJ+Zb8y yrNsYgfP7VOrYUBmJY/rVJW RQ5JIG4Y96lWQPGNnDdBnvUTve2we0didWywC7SM5b2NWFbMi/ 3vSqQLo42kBe4I5NO4JZt25uow ad76kc3NKzLgIIO7JK/ewabuwMgg5GcHtVbAC5IIGRg5608 Hk4H+9mk7l1Hd9hxyuMnGTz9alU4P tnJNV0O1/mDHJyfapI2UrlclT696mV2hy12J94BYYLEn I9qkJTyzhtxJ6f1qtyZSHODnqKdgAk5z 7UJx0voJSTdkWS6hyQcfhT4h+9y2eDwKpqGExYsCM 8CrCtgEEng9fU1F7PRluLtYthgpcjIOe9Tx bsh0OCfWqZYspPXBH4VYVzs+8oDD8jWqatohpO yRfUlQTkAk4GatRnIznORgVnIExgszccc1Yhbk FidvQKDWTjzEWT2NKNmO4NxgdRVgHLrwTn0 qipWRv3bDGOc1aTy9u1m556U3ZO1hve5cUtuKqOCc VbVSAehPQ1TiK+UD2xVuMk9hgqKcm3r0 QStLoTJklSwzxjC1Lg7lAIVQOM1ArFMsMdOB7VIrgxqS ec8j0qk29hWcWTpkKMMB6e9SEneDk HnBqEFdoK9hkCnZLLlVbJGcmm2+oJXWmhPG+ZmIC8c8iohg yjcBk9KcG2Rg8ZxyPSkDqcDgMe pFTFNXaCKiODYO1jgLwR60KN8h+b5cZ+tMYswXaBz0NMLFc5Bc AYODilyaaGyjpZMmZflxwoH eoxjaf4mB4pT+8AIOBtzg1AASCoO0g9SaairE+9Lcn2gcgH5l7dBT VZApbhiDjg0zls5bjouO KamDn5WH9fepkronybJfMAk5+Yd81FuYEkHPPbpQW/esAMHbQMFkG5Sc Z4pOKaG1FbiYfYGYk U08qPmKgDn2pWJVflHbpmoHLAAjPv71XvSe4K+iYhOGyOw6CkyMhiMEjqPW lXOct1DcZpjDL9 T1zUpp+odbXGM2wl1OXPvUBILIT09aectk4/D0prAbQOrY6elKLj1FfR3ISylT jJCjFRMGVXJ OB1B9amcEJtUbsjJA9fSmHLR/Mp+lOUtdBR03GphHLHJZhjFNdnyNoAH0pGKonck/ 3jSO7Lyg GAvI9qbb0E0ubUiZ8SHcSQTgYHFNJBBUsu7AIGKc3zbWUnGM/SqxY88jIPHHJzTSG7dx zNghi eAOMDrUTsrSA/eHTA707dmM7uD0AqEKxXIXAB60PVXvYmLsrLccVUIpOc9jnioyYtuBk8fN z3 pxb5twO4jjHamEfeIGB/OhOyTuDhciZsqQoBNMJznO0euRTyPnycAZ60xuWAJGAfSnv0KurWKs jFcYPA4zTGwTubn/ABqdii43Ju5/CoGBEXHXGcelSlfQr2t0QSMVGMAsB0qEO5UnIz6YqTlosD rx zUMshUkDH9aqTSurERtuiOTb5YycMDjNV9xwTkccHFSuysfm7rkc4qH5TDwCCPyoVt2Cm3o MGQD1 BPIJody7M2cLn86YzKYs8l88AGneYwjw6YHvROVlexXNqMZkYKqdcVG+WTb368GgqPu5 wB3qMD94 R3xkGtoqDs0wtZWuPATGMtk9CTTC43MAR+NGAB7fyqJt2PlGQeR71D1l7pDV72Gqj LIdxyR0qJ2O MLgt/EfSnklfvZHrmoyDypXhl7VMoO+pm5JNMrsMxkrgtjvWZNkQspPzAf5NXi eOAwYDnFUJs5Zc 5OevrW9JvpsXFOMrtHNwKAFBYMScjHetCF87Wbam0YOfSstFO7cSPp6VfVs NgDdu5OPSs4qLd0Ja q5qxvldxwUzzVhY2Mqsp3DHOKrQqRlScjsPWrilhGQN3J/Orje/MiG0r 8rLqgbQeQccg9jUiFjuG CTuAxUMZwuHwSefepkJDll4c0ScloKCstSVMA8ghgeecUoblMZwR6 9aiAORgFjnluxp5faQGG054 PrSUXzaDdtEiZT8uPm5PQ9qkz8uN25h1+tRIMyckZzjJp7IpkL Ek5ORipvFsttbk5kAx90NSugd9 24rzwPUU0FQG3dB1NM3dGB+mO9KnJ3skCT3uTO2U2EfKp6m pgVUgJ1B4J71W3YOMFjn9KlKn76hv qe1Nyv5BBPUsB3Eg+RmKipN4+V8HHt2qDLBSQQSOpp4I P8SjPNRFue5UoqLLDOcKu0AkdaaNzcEg rjFMUEDcpL8/L3NOyrEhmAA6g0pNJ3QLmXmKWY5+Y eoxTg4C9Qfp61CVBG9enYCpDgKTtx9fypXY udOKDzB5I5781OHEjEoM88VWiKbyB+JNTK+0n5 MDoDVW622CKS0sS+ZsfdkYPQd6nV2JGcdPTqaq 7htUnle/t7ipVQkKwJ2rkY96W+5bk3HYvq3 yqdw3Z5WrMcnzdm4x0qjGNoBYjGeo71bDDIUfpURm m7WEloWlbDYxwO9SIwyrAH3qBSNp3ckH oPWrMZ3rgYJBzVRnZbBbsi3HgkscYPAAq2iN8wAHHVjV FFZl5YDvirIBwu3K/Ng5NEn5iXxWu aSsoVFBwvcCrKEAtnP0qggJjKnbkDOcdasoDsUg9PWnN3Wj HZbEjt/CDwecjtVlSBncPmA5NQ BcFQVOegp4Y43ngHr7Uoy7FxkraEylcHHBzyDVne7TFVIA61VB woB4PBI9alz1ZelEo66slyb RNht+5iMY4wKe+1o9xPscDqarA7WQYzxzz3pzEhwGUnjnFFpJlXF3 7DtByMjHrim7ShJHIJzz 3oY7WG7oB6UhfAIyNmeDTadrIcJauw7cDxtJUDk/0qPy24xnceoPamBg rHJy3UUquTIxJx7+t JOUdkEnLuOG3zfmDeoxSfLuY/NjPODTfMCyHnIIwaY2GYkZLEYIBoctrkPV 6inAYsMkAcjPNI rKr+uO9NHQEEbv4hmgFsDcoGGyaHJNWKkiRmQrnkccGmE/KAOVApCQCoYgZ7Zq PI3YXcDnj2p uy3Jd2tGDZGC2cHrn1pgYgZPrn1pc8sXwwzjHpUXykswOc9qS5Wxy0Qrk7QFYMSOv qaiJUD+8 3c04hD1JJzxg9KRVz0IwTyPSiVSCTfYhy2ZDIu3ocE8ioy2EAY4APX3qRvl2AfMp61Vc HcB94 fw+1SrSdmXGLkxjjdIkhODjilIOCScHOPanHBAOctTWweeDx+taXb2JmrvcR8KjBcg9OarF WB 5wf73tUzMSncc9fWoGJJIUEnHBPem/dVib2dxkpbegUAn1HekYZbkbexx0p45kUlh6sMVHklWO Cc96zUrrQtSXYa0ZTJUg4681EWcOA3TsfWnO+IgT1xyKaSdv3SCeMU+l5BJNIRmAYgjd6H1phK rG ASMAdCacQPN5YEY4xUUgHBIJA6e9OEbbmfuN2sV3zv4OVJPJ7Uxcqwyy7duQcdanLNvZSpw AOKrn DBsrsGOAaOddTXV+hXZgcYYFgckAdagchs7Rk9Qo61OxCyA8MT1qttOeCO+D60JrbuDV newwg7SQ Ac8cjpUBkJypAU55OKldiqbXHU5qqQGlY5znGMd6laXRiovqIwKuwIwAfvAVCPMAI fLMTkGldynG QADgmohvZevGOfrWkNVdm0bN3X/DjxzGcMAT3Peo844OSxHPtS7ioIABY8gYpA Bt3Akdcj3ppva+ gOpaLQ0BgAOdw6/jSO+F2hTkcDmlLsY1XK/722ojlZgQMjj5vU1XNd3fQzu t2DszSnI2gDkY61Dy Mj+NutWS+ZCRjOOpqgWBdwFJOeTQouXQatezRC5LHpgDg8VSc7Fz6nqa tyA/Pk5x29aoykkHd8oL fLQuW2oSbscvEXB3N0HUHvWhEAATjHGV5rNgceUzltwPQetXomAUM Qxz94U3F3aasT8PwmxGSU3b vmOMe9XEfaoHKjOBjnis+3G4ggjYq45/lV5CCwyvy579qFCysh Xu7dS6Dld2R14NS5CEHIJJ4qoA d3UYHBFWVIbjac4x9fehwT3ZK0JFdgeOFzyalCKTnDH0qDG Fznv0qSMFgRg8HjNVL+6Va71Jd7KR 8wJPQVKWAYAdCcE+hqujqJRwNygjPapAGweMemai10KS 8rEgQK2c8Hkg+tKPuEkY5qNVwCS3U5NS bCBgklQeRUu17FRaHn5lxzn+I+tSoSFIBwB6+tVVf ORjBzhfcd6njUM5xuwf1pTWlnsOcblnduxu UghsfWkOBM67h97oBSKTGCf4u+ecUiBmf5uAe9 EYJq/RCT0vHYsLuIBJKrgjPYGmtIwTG0DI5OKX DDojAY6noTTWfcF44OaElo0aQemmogB24BO Ac/SpWHDBWzznB71Dvw5wCASMj3p7EOcjIYcnHpV2 bJcFLqSEKZAo4HU49KcBtAcE4xzk0wMh JxnJ64peUCYO5AORWEZu9gndLyLOAxwMFs8VLGWwvXB6 1SXesi844I5qaORiegwBjNU4y5bvY bskncuq245XO0Hmrsajdy3zHkc9KooPlJ6BQB9TU8LZ5IJI bn3FTvpHYpu0dy6rkoqjBz6CrM YbaT3PcVVTZ1Ugk9u9TI58xQcL/e9qpXtYWltGaEO5lJIG7PQd atKAY1bsRwKqL9xQA/Xr61Z ickfwlQeKXJLdMSRbT5ARnag6GrAJLp/e9+9VUfeCcHnpxU5diBu5 7Ad6qFuZ33HFSUtiz5jb 23HhjwKeoVQwLfMBjGelVjzjJwOwqUMdw45NJJLbQNVt1LQZWIODtI5N P3oMYI56k9BVNZOgJ z3x61MAShYY+hokrEyunqywHUD5myCeo705STjHQAgfWq2/quDjscUuS020 EjA5Oe9EdtirW2 Jzgq28YYjjPpTcbcDdxjkVG0mTg8sMA+1EcqLMSQxAPr1os+XQlybdkOZwJPmX K5GPpTGYLJx 1I9aN5KjcFVevNMbeu45UjORx+lCkirJhkiHaMZ9x1pCMMmWGTyCO1RF22MxTPWgP iE8AEf5x R1KvfdKw7euCcMMDFMySmwsGJ6GoSSAOhXv7GgsxYlfl9eKTTuHLtaxMSWy5IJHA9qYC dzHID DpxUO7Oed3c4oDgxkkgMBjFVJMlwWo9m3AABgAevqaZtYMTn5c8UEqDhSffmoncbM5AGMcV F7 6WFboPXBlHOcGkLnO3BxUGSGU5Bx0AphdnYnjj0qlRT2Y4au9ywx+U8/w9PxqNiDEW6cimLKS4 LbdvUkd6Vj5iuSAVJ7UJNOwnF3SYwcg/UEVGzH94QdpzgDFPHTYDt96TehfO5cdTQ3dkR0ukVi Qs Pynvj3pN/wC5YL1zg56ipyEaL1btioXVgzbSvXoKlyj3EktmyAqythTzjmkYsiK2Rk4PSpM KEOSd x/Wq5yHAAyB0qU5PU2u7DmZc5wCfWo+THubnBwMUH+7kDI4yKR2xGAOfWr5Laoydo2Eb arBiSCeM VCzFmQHsMfWpc8c9e2KjfcTkOARV3TKtdDcgjdzkngVUk28lsgnoKmztnbecY+6Kj b5n3EcEcmpb irNAlZ67FVmUM7gDBHeqzE+XnrjJwPrVqZx8uQSuB+VU2fdJgKetJy5TS7b22K 7kc43FTzzVd5dq Y4Ge+OtTSBQ4CnHt61C5VtpAwAeAeorRxW7J5o3vYPl3hB+vvULZIC7Tx1N K2dmWyQDmkVkZujZ6 4rNcqRN1zXEEny8DvlcjtURkG4b/AJVPb0pzkYK5yWGQF7VCS0jbSR26 itb2d7D5Fa6HEkMSD26e ntTS+dwB2kcn2PpTG2iUjOPm7/zpp/iLE57+5qtRJXsmSFsBGP3jz VRzk+hxyfWpBvaLocVE5UPG OhBxTp3TEmkRuwE2Wxg+lULggN1wMA81ckZQxU4K5+UiqExOeR uQjrjrQtdNrgpdzlLcLt2nue/r V5CDcKT17g1TVmEYBKqSCcEdKnjyp6goRxn1pxmpRV9xcrR sqSIwFwcHnFWY2YcBwQKzF/eR9Tw2 G9quptC5Vvm4xnuB1qFbW7ErPQ1g4OC3c4qZJcSbkPAH f+VUIwSmVPfuasglUzgjjOe1WlGWjMuV bssmYmFRgcYzxzUivuIXlXHBqHLBjnAA6kipQqgoc 8k4J9aJuNti+aNuxOFUHO3cR90jvUse0yY2 sGAHWq4DKxDAlQ3B9KkQcr13Dk1k0mm2UpO2g5 QDPySd2fyoO7eu4nBFI3EhOcL0zT2zxyDjnFCY 7dmPGDjAO5epqQf3s8YxioEJ8tTzyOR3zUg OUwzYHXP0qbu9rijpLQnX7xLHginKwMeXGCTUKA7Q Mg7jU552pj5geBim7LSxd3fcUM28bySN pwBSfOCAD25GKjLOW4+THHI6ikjU/OfmLA8VUYpapgpv XUnBAj+YZYnj2pW2FxtIA24z60Fw2 Mqxz/FTCVYZA2gHPX9KNXrYyTe9iRGRdxYEgDjFODqUyAWV uuOxqH5ZGbAIBHTPenKxCJzg9R 7Vnyxi79S0kt1qyePGdrA5xjJqWNV285GTjmojk5crtxUqkG2Y k7z14FP2lxxeti4BmE4557V ZR8EYZeOnpioIyFOBgAjBz3qUDg8AjHbtWSnG9jaLi4l8EBV6MT/d 71YABbI45qjCFb7pIGOR 6VawAB97d654q+ZNkpR2vY0oxmHk8dRnvU0WDJllwM96pRD5gCe3HPtV 5WXCBlOSOue1Jc1tD Nx6LUn8shixb5cZODUytmIdMDoTVZDuBwdwJzipWKiLGCCCCaHZ9Q12LHmf N8oUEAcmpFLlmx jAPp0quDuUkDL5FTLkPgZG73pylG4XdrJC5AHzcMD1/pUqOcDLDkflUDZMzIeA uDk96RDgtxw OgzV8nPqXJpKzJiWLAnB9cVIZG3HLA9s1AN23Bz/hSY2p3Yk5Yil7t0Q5XsiZD++D MwPHPvSg nBGB1yCahyHwRkDHQinA/MduSDyD6CqcmU273uSbsyKcbgTSOxy2eFx61Hu3IMHJA59q iJBY5 BIPIJrJJp3C33Em4mDryp549aZuA/2Wx0Paos4yM9emKXdn+JQQOBjvTkne4pX7A0uZXB5R ue D0pA2EBDHA61Az5IIQ5Jwab0Hy/Pxzmh9UEGm9SYuBk5xjj61BG4JI7Z/E0zcMckEn17VCXKue wzwR0rS9o2YOyLJkG4rjn69KYGCtlhnnj0quXbeSB8vGfembgWVhnJPc1HvdGOfTm1sWgyqAc5 5z SkqVDD5SeeKr72YLhcADoKRSzsRnr93j86Ol7hJR6FjLAAgDIyMAU3ewjQFSF7ketRlmwOm WPIpy ksm0YUA5we9Ta24003ddBWcA5/p0NIrAKC4UkjsKQkLxtLHdwR6U6QgQBgCAOORVwu1a xKvIYGHz Y5HsaiJcyZCkc80rn5xkgITkgdqjLeYCoODn8aU421Y5JSkrobI53YZCee1QgrwcN 05Ge9PYqsIU /M/cUw5ZAQRVr3V6kPYYxAm7n1wcUEgRMU4U+v8AOgsMKWIzjkVE6kng4Hfnis 5PYbswZioBOHx3 FR7gz4Gdx7D2okKiJuTnoTUAbEhAPAAJwOa2i9DSUdE0xzk7y2OAeMjp9aq vuZAM9QMAVYEm5Sg6 EZqAgeVgj5T3704zsmmjL3272sRHgnLDaowc1VdiR8hHP3j3xUrqC2GY sSc9euO1VmYZZh8ox0NZ JXC+mpAzjaFK98Bqru2A5XGCcZxxUhy0o+7jGM9qiYbot3Bwegq52 SL1lZCMvzjJ+UDmoCxzkrgA 8mlYtglgctyfbFJIVXf8w3HHFZp9EJaaDfNydwPU56U1RmZstt PPXvTQSFHzDI6D1p24hgGGTtNW 9CXGw1SAdg5XPJpPlDFEJPuaa20LlGPH60Fj8zgj3x2oe+r G72uMbgkk5QnjFR5DjAPQ9+tBYHcx Ofl4AqM+oPPc+tW5XVzJS11K8u3e/GFU9aoyktje6g5/ MetWJGYI2RuGc/Sq0pVTlFJ479jVOTlG yZautf8AI5SMtKRwR6A96uIMRkvztzgVQi3ZJzkMc DHarS+aCfYDGRSg76tk2cn2NGDLg46Edv4q tIWBIUFRjlj/ACrOjkOGz94A4CjGKtJlgNxIXP AzTs7astrlgaSEh1O4qDyCatpuEqh2Jfb09az0 KeV3yDgjPSraksDI5Jx6U4prXoRFrYuId0o BJDHOM96srgJt5z3HrVIK7Mr5AYcirEeTnuTgDAqb LdBbXcsB2aZfmO2p1yIjtOXzwB1NVeQw QnA5O4jgVMpwwYn5QOtHu9LBp3JlbABCtjHPFNLfJtJy yjkAc89KGZxvAPyjHNIoOZGLBvXFV G6V7jSVtCdSuxcNtcgcmn/dIUAMevFVQ5WLOFHapFLBtzDg 8E9vrWbpu92aOF47FknCphfwBp 4bMYf5s98/yqJPvbicANjrSNIFyAOjc0lByZMVJqyROwVlO0nr gUAKrhQ2GJyMmo8hWYtnO7j 3pw+djgEknjFTy9wimtCQFi/JGAccjrSgkvkfdB6VF1YAnjvz0pFP +kDdwo6Z7Vak0i5OKehI h/dEcbi3GO9Skny2+XjcMYqsXCttB6HginjcioSCV7+9Z1IpPzGnqtCy WDMx3deasBsY2jO0Y PvVPzE3jIyuKmhYs+Typ9qhpJik4vVFoHJ4ywPbNXkPyYbIU/mazyMNuALD 2PWrYKkDkqfQ+l Obi2mNSfQvRHPygMBmrCu+/DLuAbg/1qou/goRluoNWkIWXG8EDgiiKT95jcW0 y7ESQSPvdDV xQUVQysR0+maoQPjb1GRmrMbnbnJGPXmh/F5Ecr6F4Hy4Tzk9x3pUJfGSMDqMVEGx uZj6dafv XaQrAgcHHWk0gu7WJsjzRtJI9qN37veu4E9aqpIN2Oq59amG5WY7sqT0rT4dRqNupYST OflJx xUhZVjU4JzwKqxHI3MduTkink/u2wQcdDU/MTUWyRmZioDdjg0FyBjBJI7VArDcCM7s4p4J bB bIbg1EnYNVp0LGW2bgdxxzx3pAxL9QGPXiq4O0sOeT60iFlLbs4/h9abcXqg0ZMZSIsdTjBIHF DkeXGD90Dj3quzjeQVYgjpS5XYDg5B6Z6Umr6hyajzIWITjIYYNM5MjAnoRzTC5eY7SMk/Lx+V Re Zg8tznJq3toilFW0JJCGIAb5hy3tVdnKMGwfmPTPanMAZHYA5LDAFQOT5uCc4PGaFq7MqPY eSDnI ZQOBk1A7nkxlQMdSP1p5ZjGSRk96hZ9q/Njbjn1xTjduxKuhWcsgBK9QRjvSbgH6Ajoc DvUGcqCO gPSlyhcBj3zwalvoyZJvRonZgAAThgeSPWlVgp6E88c1XEuG+ccnue9CnccqRgdhT vrqwTsixE37 3kEHOeTU/mZyEA39c1TEkaz7gWz2p6kFgUYBei+tDjZ3a0CUepbA+QkMCB3xUR J3krx7Z4qNiyFl PcY49aUMhUZ4yPWnDmtcLisQVLEdeMmmvnawXHHem4DHhjjPAph8wgYyfXF S9VqNOMhJCSEPAzxU OSHb+HnPPSpSNpyPTjPrUEj/ACsAOeBTbVhJdENdhtTZtb+8RUO4AlSe e1K4ZITk44qDqSw+4OAe tVpYdl0ZKzA5yTioy2NwyMjofam9X5yQR096iZmUsTjavAoTjazHK 0WrClhuwOucZHpTHD7Gw459 ulIz/ug2BuIOAOtQblRck/Nt6ZqeRPYUY3I2l+ZdpUgDP4VXMg MZAUnBJBpAzCViFyOhpu/94eR0 xjFXyRWlhcq16jH24Vty4B6Adc1UkkI3JhQAe4709w/PKbB 3xVZ93m/Ng88E0RjHVFQ31FILxgZA 44GKrF1D/Nz1GfU1K+4jeO3FRsBtAce+aatorkyu3zDQ Dx37kU0LtZiSSccc08ZUqOQDn/8AVTXO 11BGPX/ClOWtgc3JaC7gykg59qiB4yWUKRnn0pec4 4A6moZWG0EAYB5I6CiGl0jPmauhQY1QYPP6 VGZG344A9xTSf3TBCAcdD2pj/KQcj0OPWnNt6W uNxtqROWxhsbjxnHBqnL5iySE8j+H2qVjtUt82 BkcnpVZyzEnO0gZwTUrnirrcqk3FnMoFZSM nIPPsam3MqZyc5G6qyFDtByB61KSwK7fulT1Nb02p PkbFCfOXBOD1YZ+nXFXbeUmND/F/Ks1N vm4PzY4AFWomKjA4IGeaThFaW1JaSdjTjZSCFHG45zzj 61MjgySDJOOlZsMrgeUFwauI24ggB eOR60rSStYIxcXroaCy5QY5bp+FWklGAOQV61nAbnyCQe5q VQiyHcwI4ORRypPbUtST3NMN8h JU5wM5qRJS3yMPlAwDjrVNGBJ35H49qkRwQevqvtVciT0Kv5bF osQNucZ7U9WY4I645I7mq6k EAntyc08PlvMAxyciqTSexE2nsSxmQjgKTnk4qT5sNuPfp61WL/IM kkjsKkTax3fMWHbNZOLS ci2tidB90BiQRkg1YVhjoMZ9KqAgAqM5wATUwRRCGLcdeKzlNaLqTKLW o/zMR53oVye3NPDNs 4YAbutV2UeWQAVwRtzTztb5NxY5HT+VaqXLG44vlSaHnaYiQQW3cnPFCnbJ nIJ9xTPlRDkHIy eaQujfN944zgVF27dhqzW1yQyq2xM5OetOUzSOSBhe31qDzNpBMZweoxzVjcfL BQrzyBUybto S5vsSA4ZiSd2fwqyPmiYAhXBqiCS+Xz6nHaplY4CgEg1EU76Cv1ZfRxsIZieRg1bj dTM2RgDu azI5CJAxPy9GJHQ1c3Hy02+mC2M5ob63K5tDQibDgEH5hn6VZjI6Lwcck1RR+FUN8xGc elWVY 7h3GODRF2K23Reib94MOMhcgYq6shBIY8HI+tZiY3jHLY5x2qwrbiRnqauotnuKW2rLgkYf KC ckdDVhjj5Mc7dwqisgG2Rh16D+dTGTc6nhvQ09biU1cs+YDtBHG3kjvThKMb9rECqxZhHy6g/T ofSnBm8plbkg8epNFkDkm7ItiRWdgSMgYwO9GflxkYYVAM7gDwSckgU7cAcFx0NZKLvfcXXQlO 0K Q3zD245pDNu2kcHOPoKrmRQwRiXGM8HrTy2JMAqAOuacoq1maKyWo9WZXBYgnHSpeDzkjnH Xiq6u zA4BJHT3pWkURnggZ5FKdrabmUtWmTl03D+Jj+mKiZsPkKykng9qg3MjoY1Zwe4/lQST FvOd3YZp xSTsy2rInQZJzjI6c1FvVAey+4/Wog5zvORk460oCnJORgc+/NUopJpkKbUrsVpAE 65weTUb8kk4 B64PWlDYBPBXd0qu7MA2GXI6Aj9KlRUmXFtrQC7MeCMY4pCcsSQ3A7UbiAASOn YUxuFBJ4H605WS CU76XGNjgYO3tUeflyGUYPzE9qCQyEbu361BuInA5IAzz3qqabjdFNN2J8/ KOdx7VIoB+YEe1VlI LKd2MHjPepuTu4ycZPNQp3eo5/DclHI5OR1z/SnoQuGGNpORVeMHAJwC vT2pzMwj3ZJx6Cq5r3I2 67lnfucjIzjOD3pPMbLJ8u/OR/hUO5XjQ888/WlDK7Es2DnPPYYo0 S1JSTeqJTy69jjioSSzHk4z g4pxfMTHjp8opm44APB6kd6Oa6uO3RBIQGGM4Ax1qBypJy4Az0 70rP8AJ1yQ3PtUZfJwAMepHein a9hra41i+0Asufeo9xMJXIUDBzimtJkAnp1J9aY7Mq4JB7c d6bTfQGlLqIznLZ7ntwTUDOC3cknI 9qldm8oAKC3fFVVYb2JIyetSo21auIWORTKQWzkHmoWw ASGzx1NTOyiM44B6MB0qoVAlJzv+Xrmq VrdhOXYQsHQ4wr+lQH5lY5A5HSib5ckZBGDTVfgsS o6np1qpNdCYySdrETAFT83IPrVZidzNkHtj FTtIARhSQc81A58uMOgJJ4ORRdtFvfyItwYspB OB2prL/o4LHljnGeKU4YnjBx9KgYqI+pLA560J pNJEtdEDsfl5AGaRtvmMfmPI6GmuS6rgZwc emTSrw4O5cE/NmrltZEpaNtjQ5MZPJB7Y5qFlxGzI cHuD3pWYtKcDJPSq7FgwQsGIB6Ci2uhX PZWHO+bfI4yO45FRO2QQx4OMmnsy8YOFPXNRMwwU424y OKm7a02Jb6WK7kqzBsHBwM96rvgxb jnrxUhO6Rh94YBzmqUhbywp+bk4Naq0ZJX1Hfn0OaVmBw2O OelSCUNtKHcvIqrHiQ78nKnnnr VxDhfkwPTIpKKb5uoc0bNrqTq+VYDO4kfjVrBJzzjg7qrKSrkM ue4x2qwjbshc4PJJ9aG1cSl ZbFtWCyEg5weoqyGBjHXLHNUlZFBAYccEVZRiHxjgD07VD91Dbk1c vrgOBndyCMVZYgMB1GDt OKpoTgBCCMfL/jVgblxlgSDyxHFCfVCk3uWkYr8p5JGcjoanJwPTjnmq qEgKQRycCnqxySxUj 2FUtWJa6l778RAYcenehThixIK5waqKQV3Ipz9aCxymDjHUUkpWZcWnp0L2 7AZGxjtx1oBHlk LgHpzVXeTtZ+BgYAp4kB+bgc85ocJLYOVK6SLqkbwrNyVxxSkjYOcDIyKqiQ7g WxwMcDvS7+j jle4qfZtvUtKz3LIYgkHLAHpUuWRC4AA3DHvVQElcHC89TR5hGd2WA5FW7WaQra/o WncswLDA waiB+fPPHpQZAzEHAGR/+qotxwAMH09qhb6ExbtZqxM8hwzbScfrSJJ1G5Rxx7VXL/Ju H5e1K N4fGzI7nFTy2+I2SsXQxfcSdw3DO2pEdhls/TFU1fYjKDwx9KsIxchPup9OtTNSvqZKbitC 5F Io2jgluevFXRKTIEJwO2Ky4yAcKQVBJJx0qzC28lyDg5/Cl7jfMLmcmaQbCb8jhueeatLIzHrw elZqksSCpVeM81ZSQKMD5gXz9KSSvoyoq2xphwp8wn5c4wOoqZWbPDDJ5OOlZccn77BG5Tzx7V dV wMEcZGTU8/Ky57abl9GUjG7d7elPWTqvJJ6Gs9HbqBxg4zU0LuwUEEgLkt6GtL2V2yaitZp F4N9z JJyccVMCobnkY79RVIvhVPTj73anKxKgnJ5GeetO8rXuKMVqjQV/lPzDJqMygwg7SWHB qMSKof5c 5OMelJhd+7kDvRdaihZbkhZty52gZ9Ke0gyeRjFQAOJSeo4xn0ppYYAP0pWTVjRS1 0LBmIfCHHAJ JoeXEhLdevHvVbdjnHQflSblKN1YD35NDjGJPLbct7yqqPfJpTKM4PUHt3quGV iOchvSmJgH7rHa MHmp5Y31E9XqtiXeS5JXqeMUrKzSAbdox92oXZnU+x4pu5mdxnGDketXEpw 2bH7nLjO3AFNdSpPB L9aYWGGJPWo5pG2BOcj86Uua+hV76iuRvBz0HamBizNwRt9+tBx8rbXA 2VGwKsPmOxuQauLurDbW 0RJCBIpz82OcUAEHhgVNMBO4MT14z6VG2clFDDkEZqeS79A5+ZrXY mfgLjv+lSEEREr8pz371CCu SGBwB0qMj90x5GW4PahxStYlSu73LasScMw47Cl8zJTuuccGqw bDh8hmyQRinq6iNhkKO+exqGnb QN3ZFkkLEMck9famkgEvkF81XD8rn1/KpGkVkJKkDuaTi1o Db2JCB5YGTu6ioncHaeQT1/wpuT5K tzwcnJqMOzBtykKTnPvVJcruhRl+A5mC8DJz3pm9lG11 IZumaU4AJYhR1x3qJ2BbeMgD5sH071Sd 9LCnNRFYKVCg9Bgiq7MFlIc4HT6Uu9ch+vHTvUBYt IzldyZArWF0rkpNK4M5KEg9DnH0qENmDpli Tx60skp35UYUcEe1RO4weOMYX1qJPsK1la2orE BNmd2R3/hqvwAVB4xkihpBkDn0YUw4ZGyMccYq oRvuNq71GHexJHzY9aiJf5m4PTIpzMyOCAW Ug/yqPK+WE2NnBwc0QulsU7Kz6EW0+SwLZ9R+NRs3 y4ZsnPBFOH3/AJQRxyDURwM78Ar2pv3m J2bI3I3MAQA3vyKiLHJC8sODTz87EEHnmo1IBw3IzQ2k tR8sVqwHACBucc+1Rs23O0n3zTcru Y5O4DkZpnmbc56+9K2l0Q5/IduZW+XHGSeKruQWDZySOR6V MMKBkj3NVzlpCSyZI+UAdKFbuV HRa7jG27cLxnrk1CThWHJwOQKkcbtuMFfb3qu0qguFU5HynFXL l5dTOd1qxgYfaS4U4KY4NUJA23byzdMDrVl2D2+5c9gT71Rk++cB85yMmiEXF2Jb5ndH/9k= UID:6 END:VCARD diff --git a/test/Resource/CardDAV/vcardput/8.vcf b/test/Resource/CardDAV/vcardput/8.vcf index ae9c0aa..3f36dd7 100644 --- a/test/Resource/CardDAV/vcardput/8.vcf +++ b/test/Resource/CardDAV/vcardput/8.vcf @@ -1,10 +1,10 @@ BEGIN:VCARD VERSION:3.0 N:Śuterry;HiAscii;;; FN:HiAscii Śuterry -EMAIL;TYPE=INTERNET;TYPE=WORK;TYPE=pref:hiascii@example.com +EMAIL;TYPE=INTERNET,WORK;TYPE=pref:hiascii@example.com TEL;TYPE=WORK;TYPE=pref:777-777-7777 TEL;TYPE=CELL:8888888888 ADR;TYPE=WORK;TYPE=pref:;;1 ïlena;Paris;Paris;77777;France UID:8 END:VCARD diff --git a/test/Resource/CardDAV/vcardput/9.vcf b/test/Resource/CardDAV/vcardput/9.vcf index 016f2ed..2bff011 100644 --- a/test/Resource/CardDAV/vcardput/9.vcf +++ b/test/Resource/CardDAV/vcardput/9.vcf @@ -1,11 +1,11 @@ BEGIN:VCARD VERSION:3.0 N:バイト;ダブル;;; FN:ダブル バイト -EMAIL;TYPE=INTERNET;TYPE=WORK:doublebytes@example.com +EMAIL;TYPE=INTERNET,WORK:doublebytes@example.com TEL;TYPE=WORK:777-777-7777 TEL;TYPE=CELL:8888888888 ADR;TYPE=WORK:;;1-23-4 Irohacho #2;Tokyo;Japan;33-3333;Japan NOTE:日本ですよ。 UID:9 END:VCARD diff --git a/test/runtests.sh b/test/runtests.sh index e9082c2..574335c 100755 --- a/test/runtests.sh +++ b/test/runtests.sh @@ -1,36 +1,37 @@ #!/bin/bash # # Copyright (C) 2013, 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 . ## # Initializes the CalDAVTester environment and runs all test scripts # CALDAVTESTER=$1 PYTHONPATH=`$CALDAVTESTER/run.py -p` export PYTHONPATH="$PYTHONPATH:$CALDAVTESTER/../pycalendar/src" $CALDAVTESTER/testcaldav.py --print-details-onfail -s serverinfo.xml \ CardDAV/current-user-principal.xml \ CardDAV/ab-client.xml \ CardDAV/propfind.xml \ CardDAV/put.xml \ CardDAV/directory-gateway.xml \ CalDAV/current-user-principal.xml \ CalDAV/caldavIOP.xml \ CalDAV/ctag.xml \ - CalDAV/attachments.xml + CalDAV/attachments.xml \ + CalDAV/scheduleprops.xml diff --git a/test/scripts/tests/CalDAV/caldavIOP.xml b/test/scripts/tests/CalDAV/caldavIOP.xml index 0665bda..74beb6d 100644 --- a/test/scripts/tests/CalDAV/caldavIOP.xml +++ b/test/scripts/tests/CalDAV/caldavIOP.xml @@ -1,360 +1,367 @@ caldav Create new single-instance meeting titled "Meeting 1.1" with the location "Durham". PUT $calendarpath1:/1-1_1.ics text/calendar; charset=utf-8 Resource/CalDAV/caldaviop/1/1_1.txt statusCode Create new meeting titled "Meeting 1.2" recurring every Monday from 10:00 AM to 11:00 AM for 4 weeks. PUT $calendarpath1:/1-1_2.ics text/calendar; charset=utf-8 Resource/CalDAV/caldaviop/1/1_2.txt statusCode Create new single-instance meeting titled "Meeting 1.3" with 2 other attendees. PUT $calendarpath1:/1-1_3.ics text/calendar; charset=utf-8 Resource/CalDAV/caldaviop/1/1_3.txt statusCode Create new single-instance meeting titled "Meeting 1.4" with an alarm set to trigger 15 minutes prior to the schedule time of the meeting. PUT - $calendarpath1:/1-1_4.ics + $calendarpath1:/1-1_4-new.ics text/calendar; charset=utf-8 Resource/CalDAV/caldaviop/1/1_4.txt statusCode + + header + + Location + $calendarpath1:/1-1_4.ics + + Modify the title of meeting "Meeting 1.1" to "Meeting 1.1bis". PUT $calendarpath1:/1-1_1.ics text/calendar; charset=utf-8 Resource/CalDAV/caldaviop/2/2_1.txt statusCode Modify the location of the meeting "Meeting 1.1bis" to "Seattle bis". PUT $calendarpath1:/1-1_1.ics text/calendar; charset=utf-8 Resource/CalDAV/caldaviop/2/2_2.txt statusCode Reschedule meeting "Meeting 1.1bis" to the next day. PUT $calendarpath1:/1-1_1.ics text/calendar; charset=utf-8 Resource/CalDAV/caldaviop/2/2_3.txt statusCode Add an attendee to "Meeting 1.1bis". PUT $calendarpath1:/1-1_1.ics text/calendar; charset=utf-8 Resource/CalDAV/caldaviop/2/2_4.txt statusCode Add an alarm to "Meeting 1.1bis". PUT $calendarpath1:/1-1_1.ics text/calendar; charset=utf-8 Resource/CalDAV/caldaviop/2/2_5.txt statusCode Modify the title of the 1st instance of the recurring meeting created in 1.2. PUT $calendarpath1:/1-1_2.ics text/calendar; charset=utf-8 Resource/CalDAV/caldaviop/2/2_6.txt statusCode Modify the participation status of 1st instance to DECLINED. PUT $calendarpath1:/1-1_3.ics text/calendar; charset=utf-8 Resource/CalDAV/caldaviop/2/2_7.txt statusCode Cancel the 4th instance of the recurring meeting created in 1.2. PUT $calendarpath1:/1-1_2.ics text/calendar; charset=utf-8 Resource/CalDAV/caldaviop/2/2_8.txt statusCode One client changes "Meeting 1.1bis" to a different time, second client 'refreshes' its display to see the modification. PUT $calendarpath1:/1-1_1.ics text/calendar; charset=utf-8 Resource/CalDAV/caldaviop/2/2_9.txt statusCode Delete a single non-recurring meeting. PUT $calendarpath1:/4-4_1.ics text/calendar; charset=utf-8 Resource/CalDAV/caldaviop/4/4_1.txt statusCode DELETE $calendarpath1:/4-4_1.ics statusCode Delete a single recurring meeting with no overridden instances. PUT $calendarpath1:/4-4_2.ics text/calendar; charset=utf-8 Resource/CalDAV/caldaviop/4/4_2.txt statusCode DELETE $calendarpath1:/4-4_2.ics statusCode Delete a single recurring meeting with overridden instances. PUT $calendarpath1:/4-4_3.ics text/calendar; charset=utf-8 Resource/CalDAV/caldaviop/4/4_3.txt statusCode DELETE $calendarpath1:/4-4_3.ics statusCode Delete a non-overridden instance of a recurring meeting. PUT $calendarpath1:/4-4_4.ics text/calendar; charset=utf-8 Resource/CalDAV/caldaviop/4/4_4a.txt statusCode PUT $calendarpath1:/4-4_4.ics text/calendar; charset=utf-8 Resource/CalDAV/caldaviop/4/4_4b.txt statusCode Delete an overridden instance of a recurring meeting. PUT $calendarpath1:/4-4_5.ics text/calendar; charset=utf-8 Resource/CalDAV/caldaviop/4/4_5a.txt statusCode PUT $calendarpath1:/4-4_5.ics text/calendar; charset=utf-8 Resource/CalDAV/caldaviop/4/4_5b.txt statusCode DELETEALL $calendarpath1:/ DELETEALL $inboxpath1:/ DELETEALL $calendarpath2:/ DELETEALL $inboxpath2:/ DELETEALL $calendarpath3:/ DELETEALL $inboxpath3:/ diff --git a/test/scripts/tests/CalDAV/scheduleprops.xml b/test/scripts/tests/CalDAV/scheduleprops.xml new file mode 100644 index 0000000..cc00c37 --- /dev/null +++ b/test/scripts/tests/CalDAV/scheduleprops.xml @@ -0,0 +1,255 @@ + + + + + + + + Test properties defined by caldav-schedule + + + caldav + + + + + + + PROPFIND inbox/outbox URLs + + PROPFIND + $principal1: +
+ Depth + 0 +
+ + text/xml; charset=utf-8 + Resource/CalDAV/scheduleprops/1.xml + + + propfindItems + + okprops + {urn:ietf:params:xml:ns:caldav}schedule-inbox-URL + {urn:ietf:params:xml:ns:caldav}schedule-outbox-URL + {urn:ietf:params:xml:ns:caldav}calendar-user-address-set + + +
+
+
+ + + + PROPFIND inbox + + PROPFIND + $inboxpath1:/ +
+ Depth + 0 +
+ + text/xml; charset=utf-8 + Resource/CalDAV/scheduleprops/2.xml + + + propfindItems + + okprops + $calendarpath1:/]]> + + +
+
+ + PROPPATCH valid calendar + + PROPPATCH + $inboxpath1:/ + + text/xml; charset=utf-8 + Resource/CalDAV/scheduleprops/3.xml + + + propfindItems + + okprops + {urn:ietf:params:xml:ns:caldav}calendar-free-busy-set + + + + + + PROPPATCH invalid calendar + + PROPPATCH + $inboxpath1:/ + + text/xml; charset=utf-8 + Resource/CalDAV/scheduleprops/4.xml + + + propfindItems + + badprops + {urn:ietf:params:xml:ns:caldav}calendar-free-busy-set + + + + + + PROPPATCH delete it - fails + + PROPPATCH + $inboxpath1:/ + + text/xml; charset=utf-8 + Resource/CalDAV/scheduleprops/5.xml + + + propfindItems + + badprops + {urn:ietf:params:xml:ns:caldav}calendar-free-busy-set + + + + + + PROPPATCH valid calendar - reset it + + PROPPATCH + $inboxpath1:/ + + text/xml; charset=utf-8 + Resource/CalDAV/scheduleprops/3.xml + + + propfindItems + + okprops + {urn:ietf:params:xml:ns:caldav}calendar-free-busy-set + + + + + + Setup some calendars + + MKCALENDAR + $calendarhome1:/test1/ + + statusCode + + + + MKCALENDAR + $calendarhome1:/test2/ + + statusCode + + + + + PROPPATCH - valid + + PROPPATCH + $inboxpath1:/ + + text/xml; charset=utf-8 + Resource/CalDAV/scheduleprops/6.xml + + + propfindItems + + okprops + {urn:ietf:params:xml:ns:caldav}calendar-free-busy-set + + + + + + Delete a calendar + + DELETE + $calendarhome1:/test2/ + + statusCode + + + + + PROPPATCH - valid new calendar, invalid existing + + PROPPATCH + $inboxpath1:/ + + text/xml; charset=utf-8 + Resource/CalDAV/scheduleprops/7.xml + + + propfindItems + + badprops + {urn:ietf:params:xml:ns:caldav}calendar-free-busy-set + + + + + + PROPPATCH - invalid new calendar, invalid existing + + PROPPATCH + $inboxpath1:/ + + text/xml; charset=utf-8 + Resource/CalDAV/scheduleprops/8.xml + + + propfindItems + + badprops + {urn:ietf:params:xml:ns:caldav}calendar-free-busy-set + + + + + + PROPPATCH valid calendar - reset it + + PROPPATCH + $inboxpath1:/ + + text/xml; charset=utf-8 + Resource/CalDAV/scheduleprops/3.xml + + + propfindItems + + okprops + {urn:ietf:params:xml:ns:caldav}calendar-free-busy-set + + + + +
+ + + +
diff --git a/test/serverinfo.xml b/test/serverinfo.xml index d53e00f..4ab96fb 100644 --- a/test/serverinfo.xml +++ b/test/serverinfo.xml @@ -1,815 +1,815 @@ localhost 80 443 basic COPY Method MOVE Method Extended MKCOL ctag current-user-principal well-known caldav - schedule-inbox --> + schedule-inbox carddav directory-gateway $root: /iRony/ $multistatus-response-prefix: /{DAV:}multistatus/{DAV:}response $multistatus-href-prefix: /{DAV:}multistatus/{DAV:}response/{DAV:}href $verify-response-prefix: {DAV:}response/{DAV:}propstat/{DAV:}prop $verify-property-prefix: /{DAV:}multistatus/{DAV:}response/{DAV:}propstat/{DAV:}prop $verify-bad-response: /{DAV:}multistatus/{DAV:}response/{DAV:}status $verify-error-response: /{DAV:}multistatus/{DAV:}response/{DAV:}error $CALDAV: urn:ietf:params:xml:ns:caldav $CARDDAV: urn:ietf:params:xml:ns:carddav $CS: http://calendarserver.org/ns/ $principalcollection: $root:principals/ $uidstype: __uids__ $userstype: users $groupstype: groups $locationstype: locations $resourcestype: resources $principals_uids: $principalcollection:$uidstype:/ $principals_users: $principalcollection:$userstype:/ $principals_groups: $principalcollection:$groupstype:/ $principals_resources: $principalcollection:$resourcestype:/ $principals_locations: $principalcollection:$locationstype:/ $calendars: $root:calendars/ $calendars_uids: $calendars:$uidstype:/ $calendars_users: $calendars:$userstype:/ $calendars_groups: $calendars:$groupstype:/ $calendars_resources: $calendars:$resourcestype:/ $calendars_locations: $calendars:$locationstype:/ $calendar: Calendar $tasks: Tasks $inbox: inbox $outbox: outbox $dropbox: dropbox $attachments: dropbox $notification: notification $freebusy: freebusy $servertoserver: $root:inbox $timezoneservice: $root:timezones $timezonestdservice: $root:stdtimezones $addressbooks: $root:addressbooks/ $addressbooks_uids: $addressbooks:$uidstype:/ $addressbooks_users: $addressbooks:$userstype:/ $addressbooks_groups: $addressbooks:$groupstype:/ $addressbook: Contacts $global_addressbook: global-addressbook $directory: $addressbooks:ldap-directory/ $add-member: ;add-member $useradmin: admin $useradminguid: admin $pswdadmin: admin $principal_admin: $principalcollection:$useradmin:/ $principaluri_admin: $principalcollection:$useradminguid:/ $userapprentice: apprentice $userapprenticeguid: apprentice $pswdapprentice: apprentice $principal_apprentice: $principalcollection:$userapprentice:/ $principaluri_apprentice: $principalcollection:$userapprenticeguid:/ $userproxy: superuser $pswdproxy: superuser $userid%d: dav.user%02d@example.org $userguid%d: $userid%d: $username%d: DAV User%02d $username-encoded%d: DAV%%20User%02d $firstname%d: DAV $lastname%d: User %02d $pswd%d: 12345 $principal%d: $principalcollection:$userid%d:/ $principaluri%d: - $principalcollection:dav.user%02d%%40example.org/ + $principalcollection:dav.user%02d@example.org/ $principal%dnoslash: $principalcollection:$userid%d: $calendarhome%d: $calendars:$userguid%d: $calendarhomealt%d: $calendars:$userid%d: $calendarpath%d: $calendarhome%d:/$calendar: $calendarpathalt%d: $calendarhomealt%d:/$calendar: $taskspath%d: $calendarhome%d:/$tasks: $inboxpath%d: $calendarhome%d:/$inbox: $outboxpath%d: $calendarhome%d:/$outbox: $dropboxpath%d: $calendarhome%d:/$dropbox: $notificationpath%d: $calendarhome%d:/$notification: $freebusypath%d: $calendarhome%d:/$freebusy: $email%d: $userid%d: $cuaddr%d: mailto:$email%d: $cuaddralt%d: $principaluri%d: $cuaddraltnoslash%d: $principals_uids:$userguid%d: $cuaddrurn%d: urn:uuid:$userguid%d: $addressbookhome%d: $addressbooks:$userguid%d: $addressbookpath%d: $addressbooks:$userguid%d:/$addressbook: $directory%d: $addressbooks:$userguid%d:/ldap-directory/ $publicuserid%d: public%02d $publicuserguid%d: public%02d $publicusername%d: Public %02d $publicpswd%d: public%02d $publicprincipal%d: $principals_users:$publicuserid%d:/ $publicprincipaluri%d: $principals_uids:$publicuserguid%d:/ $publiccalendarhome%d: $calendars:$publicuserguid%d: $publiccalendarpath%d: $calendars:$publicuserguid%d:/$calendar: $publicemail%d: $publicuserid%d:@example.com $publiccuaddr%d: mailto:$publicemail%d: $publiccuaddralt%d: $publicprincipaluri%d: $publiccuaddrurn%d: urn:uuid:$publicuserguid%d: $resourceid%d: resource%02d $resourceguid%d: resource%02d $resourcename%d: Resource %02d $rcalendarhome%d: $calendars:$resourceguid%d: $rcalendarpath%d: $calendars:$resourceguid%d:/$calendar: $rinboxpath%d: $calendars:$resourceguid%d:/$inbox: $routboxpath%d: $calendars:$resourceguid%d:/$outbox: $rprincipal%d: $principals_resources:$resourceid%d:/ $rprincipaluri%d: $principals_uids:$resourceguid%d:/ $rcuaddralt%d: $rprincipaluri%d: $rcuaddrurn%d: urn:uuid:$resourceguid%d: $locationid%d: location%02d $locationguid%d: location%02d $locationname%d: Location %02d $lcalendarhome%d: $calendars:$locationguid%d: $lcalendarpath%d: $calendars:$locationguid%d:/$calendar: $linboxpath%d: $calendars:$locationguid%d:/$inbox: $loutboxpath%d: $calendars:$locationguid%d:/$outbox: $lprincipal%d: $principals_resources:$locationid%d:/ $lprincipaluri%d: $principals_uids:$locationguid%d:/ $lcuaddralt%d: $lprincipaluri%d: $lcuaddrurn%d: urn:uuid:$locationguid%d: $groupid%d: group%02d $groupguid%d: group%02d $groupname%d: Group %02d $gprincipal%d: $principals_resources:$groupid%d:/ $gprincipaluri%d: $principals_uids:$groupguid%d:/ $gcuaddralt%d: $gprincipaluri%d: $gcuaddrurn%d: urn:uuid:$groupguid%d: $i18nid: i18nuser $i18nguid: i18nuser $i18nname: まだ $i18npswd: i18nuser $i18ncalendarpath: $calendars:$i18nguid:/$calendar: $i18nemail: $i18nid:@example.com $i18ncuaddr: mailto:$i18nemail: $i18ncuaddrurn: urn:uuid:$i18nguid: $principaldisabled: $principals_groups:disabledgroup/ $principaluridisabled: $principals_uids:disabledgroup/ $cuaddrdisabled: $principals_uids:disabledgroup/ $cuaddr2: MAILTO:$email2: