* * This file is part of AgenDAV. * * AgenDAV is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * any later version. * * AgenDAV 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 General Public License for more details. * * You should have received a copy of the GNU General Public License * along with AgenDAV. If not, see . */ class Caldav { private $final_url; private $CI; private $client = null; private $http_auth_method; function __construct($params) { $this->CI =& get_instance(); $this->http_auth_method = $this->CI->config->item('caldav_http_auth_method'); // Empty string or FALSE if ($this->http_auth_method != CURLAUTH_BASIC && $this->http_auth_method != CURLAUTH_DIGEST) { $this->http_auth_method = CURLAUTH_BASIC | CURLAUTH_DIGEST; } // Light loading, for using some functions without loading the full // stack if (!isset($params['light']) || $params['light'] !== TRUE) { // Load ICS helper library $this->CI->load->library('icshelper'); require_once('caldav-client.php'); } } /** * * Returns FALSE on error */ function fetch_events( $user, $passwd, $start, $end, $calendar = 'calendario') { $this->prepare_client($user, $passwd, $calendar); $events = $this->client->GetEvents($start, $end); // Bogus CalDAV server if ($events === FALSE) { $this->CI->extended_logs->message('ERROR', 'Possible invalid CalDAV server'); } else { $this->CI->extended_logs->message('INTERNALS', 'Received ' . count($events) . ' event(s)'); } return $events; } /** * * Returns FALSE on error, null if no event was found */ function fetch_resource_by_uid( $user, $passwd, $uid, $calendar = 'calendario') { $this->prepare_client($user, $passwd, $calendar); $resource = array_values($this->client->GetEntryByUid($uid)); if (!is_array($resource) || count($resource) == 0) { $this->CI->extended_logs->message('INTERNALS', 'Search for uid=' . $uid . ' on calendar ' . $calendar . ' failed or returned more than one element ' .'['.count($resource).']'); return null; } else { return $resource[0]; } } /** * Deletes a resource * * Returns TRUE on success, or an i18n array (msg, [params]) * on failure */ function delete_resource( $user, $passwd, $href, $calendar = 'calendario', $etag = null) { $this->prepare_client($user, $passwd, $calendar); $resource = $this->build_calendar_url($user, $calendar, $href); $res = $this->client->DoDELETERequest($resource, $etag); // Did this operation succeed? $success = FALSE; $logmsg = ''; $usermsg = ''; $params = array(); switch ($res) { case '404': $logmsg = 'Element not found'; $usermsg = 'error_eventnotfound'; break; case '412': $logmsg = 'Element was modified while deleting'; $usermsg = 'error_eventchanged'; break; case '204': case '200': // Success $success = TRUE; break; default: $logmsg = "HTTP code: " . $res; $usermsg = 'error_unknownhttpcode'; $params = array('%res' => $res); break; } if ($success === FALSE) { $this->CI->extended_logs->message('INTERNALS', 'Delete failed for resource ' . $href . 'on calendar ' . $calendar .'. Reason: ' . $logmsg); return array($usermsg, $params); } else { $this->CI->extended_logs->message('INTERNALS', 'Deleted resource ' . $href .' from calendar ' . $calendar); return TRUE; } } /** * Puts a iCalendar resource (iCalComponent object) * * Returns etag of new resource, or FALSE if failed * * Use etag = '*' when adding new resources */ function put_resource( $user, $passwd, $href, $calendar = 'calendario', $icalendar, $etag = null) { $this->prepare_client($user, $passwd, $calendar); // Avoid strange problems with empty href and empty calendars if (!isset($href) || empty($href)) { $this->CI->extended_logs->message('ERROR', 'Discarding PUT' .' attempt without href specified'); return FALSE; } $url = $this->build_calendar_url($user, $calendar, $href); $ical_text = $icalendar->createCalendar(); $new_etag = $this->client->DoPUTRequest($url, $ical_text, $etag); switch ($this->client->GetHTTPResultCode()) { case '412': // ETag match failed $this->CI->extended_logs->message('INTERNALS', 'PUT with ETag=' . $etag . ' failed on ' .$url . '. Precondition failed.'); return FALSE; case '201': case '204': // All right $this->CI->extended_logs->message('INTERNALS', 'Successful PUT with ETag=' . $etag . ' on ' . $url); return $new_etag; default: $this->CI->extended_logs->message('INTERNALS', 'PUT with ETag=' . $etag . ' on ' . $url . ' returned ' . $this->client->GetHttpResultCode()); return FALSE; } } /** * Constructs the full CalDAV URL and client */ function prepare_client($user, $passwd, $calendar = 'home') { $this->final_url = $this->build_calendar_url($user, $calendar); $this->client = new CalDAVClient($this->final_url, $user, $passwd, array('auth' => $this->http_auth_method)); $this->client->SetUserAgent('AgenDAV v' . AGENDAV_VERSION); $this->client->SetCalendar($this->final_url); $this->client->PrincipalURL($this->final_url); $this->client->CalendarHomeSet($this->final_url); } /** * Is this a valid calendar resource? */ function is_valid_calendar($user, $passwd, $calendar) { $this->prepare_client($user, $passwd, $calendar); $url = $this->build_calendar_url($user, $calendar); $info = $this->client->GetCalendarDetailsByURL($url); if ($this->client->GetHttpResultCode() != '207') { // Resource not found (404) or no enough permissions (403) return FALSE; } else { return TRUE; } } /** * Check for correct authentication */ function check_server_authentication($user, $passwd) { $this->prepare_client($user, $passwd, ''); return $this->client->CheckValidCalDAV(); } /** * Gets last HTTP response in array form: * [http result code, http response headers, http response body] */ function get_last_response() { if (is_null($this->client)) { return array ('', '', ''); } else { return array( $this->client->GetHttpResultCode(), $this->client->GetResponseHeaders(), $this->client->GetResponseBody(), ); } } /** * Get a list of calendars owned by current user */ function get_own_calendars($user, $passwd) { $this->prepare_client($user, $passwd, ''); return $this->client->FindCalendars(); } /** * Get the properties of a calendar list * * @return Prepared data for browser, FALSE on error */ function get_shared_calendars_info($user, $passwd, $calendar_list) { $this->prepare_client($user, $passwd, ''); $tmpcals = array(); foreach ($calendar_list as $calid => $properties_on_db) { $url = $this->build_calendar_url($user, $calid); $info = $this->client->GetCalendarDetailsByURL($url); if (!is_array($info) || count($info) == 0) { // Something went really wrong in this calendar $this->CI->extended_logs->message('ERROR', 'Ignoring shared calendar ' . $url . '. PROPFIND yielded ' . $this->client->GetHttpResultCode()); continue; } $properties = $info[$calid]; // Give priority to previous data (user customizations?) $preserve = array('sid', 'user_from', 'color', 'displayname'); foreach ($preserve as $p) { if (isset($properties_on_db[$p])) { $properties->$p = $properties_on_db[$p]; } } $properties->shared = TRUE; $properties->write_access = $properties_on_db['write_access']; $tmpcals[$calid] = $properties; } return $tmpcals; } /** * Creates a new calendar inside a principal collection * * @return boolean TRUE on successful creation, i18n array (msg, * [params]) */ function mkcalendar( $user, $passwd, $calendar = '', $props = array()) { $this->prepare_client($user, $passwd, ''); // Preconditions $logmsg = ''; $usermsg = ''; $params = array(); // Empty calendar? if (empty($calendar)) { $logmsg = 'no internal name specified'; $usermsg = 'error_internalcalnamemissing'; } if (!isset($props['displayname'])) { $logmsg = 'no display name specified'; $usermsg = 'error_calnamemissing'; } if (!isset($props['color'])) { $logmsg = 'no color specified'; $usermsg = 'error_calcolormissing'; } if (!empty($logmsg)) { $this->CI->extended_logs->message('ERROR', 'Invalid call to mkcalendar(): ' . $logmsg); return array($usermsg, $params); } $url = $this->build_calendar_url($user, $calendar); // Create XML body $ns = array( 'DAV:' => '', 'urn:ietf:params:xml:ns:caldav' => 'C', 'http://apple.com/ns/ical/' => 'ical'); $xml = new XMLDocument($ns); $set = $xml->NewXMLElement('set'); $prop = $set->NewElement('prop'); $xml->NSElement($prop, 'displayname', $props['displayname']); $xml->NSElement($prop, 'http://apple.com/ns/ical/:calendar-color', $props['color']); // TODO: associate timezone? AWL doesn't like Render('C:mkcalendar', $set, null, 'http://apple.com/ns/ical/:calendar-color'); $res = $this->client->DoXMLRequest('MKCALENDAR', $xml_text, $url); $success = FALSE; $logmsg = ''; $usermsg = ''; $params = array(); switch ($this->client->GetHTTPResultCode()) { case '201': // OK $success = TRUE; break; case '207': // Error on parameters $logmsg = 'Invalid parameters (207)'; $usermsg = 'error_mkcalendar'; break; case '403': // Permission denied $logmsg = 'Access forbidden'; $usermsg = 'error_denied'; break; default: $code = $this->client->GetHttpResultCode(); $logmsg = "HTTP code: " . $code; $usermsg = 'error_unknownhttpcode'; $params = array('%res' => $code); } if ($success === FALSE) { $this->CI->extended_logs->message('INTERNALS', 'Calendar '.$calendar.' not created.' .' Reason: ' . $logmsg); return array($usermsg, $params); } else { $this->CI->extended_logs->message('INTERNALS', 'Calendar ' . $calendar . ' successfully created'); return TRUE; } } /** * Applies a properties change to a DAV resource * * @return boolean TRUE on successful creation, i18n array (msg, * [params]) otherwise */ function proppatch( $user, $passwd, $calendar = '', $props = array()) { $this->prepare_client($user, $passwd, ''); // Preconditions $logmsg = ''; $usermsg = ''; $params = array(); // Empty calendar? if (empty($calendar)) { $logmsg = 'no internal name specified'; $usermsg = 'error_internalcalnamemissing'; } if (!isset($props['displayname'])) { $logmsg = 'no display name specified'; $usermsg = 'error_calnamemissing'; } if (!isset($props['color'])) { $logmsg = 'no color specified'; $usermsg = 'error_calcolormissing'; } if (!empty($logmsg)) { $this->CI->extended_logs->message('ERROR', 'Invalid call to proppatch(): ' . $logmsg); return array($usermsg, $params); } $url = $this->build_calendar_url($user, $calendar); // Create XML body $ns = array( 'DAV:' => '', 'urn:ietf:params:xml:ns:caldav' => 'C', 'http://apple.com/ns/ical/' => 'ical'); $xml = new XMLDocument($ns); $set = $xml->NewXMLElement('set'); $prop = $set->NewElement('prop'); $xml->NSElement($prop, 'displayname', $props['displayname']); $xml->NSElement($prop, 'http://apple.com/ns/ical/:calendar-color', $props['color']); // TODO: associate timezone? AWL doesn't like Render('propertyupdate', $set, null, 'http://apple.com/ns/ical/:calendar-color'); $result = $this->client->DoPROPPATCH($xml_text, $url); $success = FALSE; $logmsg = ''; $usermsg = ''; if ($result === TRUE) { $success = TRUE; } else { $logmsg = $result; $usermsg = 'error_modfailed'; } if ($success === FALSE) { $this->CI->extended_logs->message('INTERNALS', 'Calendar '.$calendar.' not modified.' .' Found unexpected status on some properties: ' . $logmsg); return array($usermsg, $params); } else { $this->CI->extended_logs->message('INTERNALS', 'Calendar ' . $calendar . ' successfully modified'); return TRUE; } } /** * Sets ACL on a resource * * @return boolean TRUE on successful creation, i18n array (message, * [params]) otherwise */ function setacl( $user, $passwd, $calendar = '', $share_with = array()) { /** * Modification for Yunohost * * Radicale does not support ACL as Agendav implements it, * and this triggers an error 502. * * But Radicale is set up without ACL (because of the LDAP plugin), * so actually all users have read-write access to all other users * collections. * This is sufficient for us to enable the sharing of calendars in * AgenDAV. We simply skip the ACL request. Sharing status is * stored by AgenDAV in its db. * */ return TRUE; $this->prepare_client($user, $passwd, ''); // Preconditions $logmsg = ''; $usermsg = ''; $params = array(); // Empty calendar? if (empty($calendar)) { $logmsg = 'no internal name specified'; $usermsg = 'error_internalcalnamemissing'; } if (!empty($logmsg)) { $this->CI->extended_logs->message('ERROR', 'Call to setacl()' .' with no calendar!'); return array($usermsg, $params); } $url = $this->build_calendar_url($user, $calendar); // Create XML body $xmlbody = $this->generate_acl_xml($share_with); if ($xmlbody === FALSE) { $this->CI->extended_logs->message('ERROR', 'Call to setacl()' .' generated invalid XML code. Giving up.'); return array('error_internal', array()); } $res = $this->client->DoXMLRequest('ACL', $xmlbody, $url); $success = FALSE; $logmsg = ''; $usermsg = ''; $params = array(); switch ($this->client->GetHTTPResultCode()) { case '200': // OK $success = TRUE; break; default: $code = $this->client->GetHTTPResultCode(); $logmsg = "HTTP code: " . $code; $usermsg = 'error_unknownhttpcode'; $params = array('%res' => $code); } if ($success === FALSE) { $this->CI->extended_logs->message('INTERNALS', 'ACL for calendar '.$calendar.' not modified.' .' Reason: ' . $logmsg); return array($usermsg, $params); } else { $this->CI->extended_logs->message('INTERNALS', 'Successful modification of ACL for calendar ' . $calendar); return TRUE; } } /** * Searchs a principal based on passed conditions. */ function principal_property_search($user, $passwd, $dn = null, $user_address = null, $use_or = TRUE) { $this->prepare_client($user, $passwd, ''); if (is_null($dn) && is_null($user_address)) { $this->CI->extended_logs->message('ERROR', 'Call to principal_property_search ' .'with null dn and user_address'); return array('err_invalidinput', array()); } // Build XML $xml = ''; if (!is_null($dn)) { $xml .= ''; $xml .= ''; $xml .= '' . $dn . ''; } if (!is_null($user_address)) { $xml .= ''; $xml .= ''; $xml .= ''.$user_address.''; } $xml .= ''; // Do request $url = $this->build_principal_url($user); $res = $this->client->principal_property_search($xml, $url); // Extract usernames from $res $return_results = array(); foreach ($res as $elem) { $username = $this->extract_username_from_href($elem['href']); $elem['username'] = $username; $return_results[$username] = $elem; } // Remove current user, if present unset($return_results[$user]); // Sort by username ksort($return_results); return array( $this->client->GetHTTPResultCode(), $return_results); } /** * Returns the public CalDAV URL for a calendar * * @param $calendar String in the form 'user:calendar', or just * 'user' */ function construct_public_url($calendar = '') { $calendar = preg_replace('/:/', '/', $calendar); $url = preg_replace('/%s/', $calendar, $this->CI->config->item('public_caldav_url')); return $url; } /** * Converts a RGB hexadecimal string (#rrggbb or short #rgb) to full * RGBA */ function _rgb2rgba($s) { if (strlen($s) == '7') { return $s . 'ff'; } elseif (strlen($s) == '4') { $res = preg_match('/#(.)(.)(.)/', $s, $matches); return '#' . $matches[1] . $matches[1] . $matches[2] . $matches[2] . $matches[3] . $matches[3] . 'ff'; } else { // Unknown string return $s; } } /** * Generates a complete ACL to be set on a calendar * * @param $share_with Array of shares in the form: * [ [sid?, username, write_access], * [sid2?, username2, write_access2] ..] * * @return boolean TRUE if everything went ok, FALSE otherwise */ function generate_acl_xml($share_with = array()) { $ns = array( 'DAV:' => '', 'urn:ietf:params:xml:ns:caldav' => 'C', ); $xml = new XMLDocument($ns); $aces = array(); // Permissions $owner_perm = $this->CI->config->item('owner_permissions'); $r_perm = $this->CI->config->item('read_profile_permissions'); $rw_perm = $this->CI->config->item('read_write_profile_permissions'); $other_perm = $this->CI->config->item('default_permissions'); // Owner permissions $aces[] = $this->_ace_for($xml, null, $owner_perm, TRUE); // User which can access this calendar foreach ($share_with as $share) { $user_url = $this->build_principal_url($share['username']); $aces[] = $this->_ace_for($xml, $user_url, ($share['write_access'] == '1' ? $rw_perm : $r_perm)); } // Other users $aces[] = $this->_ace_for($xml, null, $other_perm, FALSE); return $xml->Render('acl', $aces); } /** * Generates an ACE element */ function _ace_for(&$xmldoc, $user = null, $perms = array(), $is_owner = FALSE) { $ace = $xmldoc->NewXMLElement('ace'); $principal = $ace->NewElement('principal'); if ($is_owner === TRUE) { $principal->NewElement('property')->NewElement('owner'); } elseif (is_null($user)) { $principal->NewElement('authenticated'); } else { $principal->NewElement('href', $user); } $grant = $ace->Newelement('grant'); foreach ($perms as $p) { $grant->NewElement('privilege')->NewElement($p); } return $ace; } /** * Builds a principal URL for a given username * * @param $user Username */ function build_principal_url($user) { $principal_url = $this->CI->config->item('caldav_principal_url'); $built = preg_replace('/%u/', $user, $principal_url); return $built; } /** * Builds an URL for a calendar or a resource included in a calendar * collection * * @param $user Username * @param $calendar Calendar name. It can be just a calendar name, * or a identified like 'user:calendar'. In that * case, URL will be built using these values * @param $href Optional href, which will be appended to the URL */ function build_calendar_url($user, $calendar, $href = '') { $calendar_url = $this->CI->config->item('caldav_calendar_url'); $pieces = preg_split('/:/', $calendar); if (count($pieces) == '1') { $use_principal = $user; } else { $use_principal = $pieces[0]; $calendar = $pieces[1]; } $replacement = $use_principal . (empty($calendar) ? '' : '/' . rawurlencode($calendar)); $built = preg_replace('/%s/', $replacement, $calendar_url) . $href; log_message('DEBUG', 'Built calendar URL: ' . $built); return $built; } /** * Extracts username from a principal URL */ function extract_username_from_href($href) { $tmp_href = parse_url($href); $href = $tmp_href['path']; $tmp_pattern_parsed = parse_url($this->CI->config->item('caldav_principal_url')); $pattern_path = $tmp_pattern_parsed['path']; // Build a pattern that matches href to extract just the %u part $extract_pattern = preg_replace( array( '/\/%u\//', '/\//'), array('/([^/]+)/', '\/'), $pattern_path); $matches = preg_match('/' . $extract_pattern . '/', $href, $fragments); if ($matches == 0) { $this->CI->extended_logs->message('ERROR', 'Trying to extract username from invalid ' .'href: ['.$href.']'); return ''; } else { return $fragments[1]; } } /** * Loads full list of calendars for current user */ function all_user_calendars($user, $passwd) { $ret = array(); // TODO order $own_calendars = $this->get_own_calendars($user, $passwd); $ret = $own_calendars; if ($this->CI->config->item('enable_calendar_sharing')) { // Add sharing information for this calendar foreach ($ret as $calendar) { $calendar->share_with = $this->CI->shared_calendars->users_with_access_to($calendar->calendar); } // Look for shared calendars $tmp_shared_calendars = $this->CI->shared_calendars->get_shared_with($user); if (is_array($tmp_shared_calendars) && count($tmp_shared_calendars) > 0) { $shared_calendars = $this->get_shared_calendars_info($user, $passwd, $tmp_shared_calendars); if ($shared_calendars === FALSE) { $this->CI->extended_logs->message('ERROR', 'Error reading shared calendars'); } else { $ret = array_merge($ret, $shared_calendars); } } } return $ret; } }