From 8096c34beffbca4235f42d4f35f81f0a20d27132 Mon Sep 17 00:00:00 2001 From: Ralf Becker Date: Thu, 20 Oct 2011 20:10:04 +0000 Subject: [PATCH] - fixed ORGANIZER/ATTENDEE in iCal for CalDAV: + do NOT use ORGANIZER for events without further participants or a different organizer + do not include event owner/ORGANIZER as participant in his own calendar, if he is only participant --> all other cases include ORGANZIER and additional as ATTENDEE (tested with iCal on iOS and OS X) - implemented schedule-tag and If-Schedule-Tag-Match header from CalDAV Scheduling - allow to change participant status and add/remove alarms with schedule-tag instead of ETag --> If-Schedule-Tag-Match header has precedence over If-Match (ETag) header, but limits changes to participant status and alarms --> ToDo: test accepting, rejecting recurrences --- calendar/inc/class.calendar_bo.inc.php | 7 +- calendar/inc/class.calendar_groupdav.inc.php | 118 +++++++++++++++++-- calendar/inc/class.calendar_ical.inc.php | 18 ++- phpgwapi/inc/class.groupdav_handler.inc.php | 5 +- 4 files changed, 126 insertions(+), 22 deletions(-) diff --git a/calendar/inc/class.calendar_bo.inc.php b/calendar/inc/class.calendar_bo.inc.php index a0edb75147..33783bddf0 100644 --- a/calendar/inc/class.calendar_bo.inc.php +++ b/calendar/inc/class.calendar_bo.inc.php @@ -1915,10 +1915,11 @@ class calendar_bo * Get the etag for an entry * * @param array|int|string $event array with event or cal_id, or cal_id:recur_date for virtual exceptions + * @param string &$schedule_tag=null on return schedule-tag (egw_cal.cal_id:egw_cal.cal_etag, no participant modifications!) * @param boolean $client_share_uid_excpetions Does client understand exceptions to be included in VCALENDAR component of series master sharing its UID * @return string|boolean string with etag or false */ - function get_etag($entry,$client_share_uid_excpetions=true) + function get_etag($entry, &$schedule_tag=null, $client_share_uid_excpetions=true) { if (!is_array($entry)) { @@ -1926,7 +1927,7 @@ class calendar_bo if (!$this->check_perms(EGW_ACL_FREEBUSY, $entry, 0, 'server')) return false; $entry = $this->read($entry, $recur_date, true, 'server'); } - $etag = $entry['id'].':'.$entry['etag']; + $etag = $schedule_tag = $entry['id'].':'.$entry['etag']; // use new MAX(modification date) of egw_cal_user table (deals with virtual exceptions too) if (isset($entry['max_user_modified'])) @@ -1955,7 +1956,7 @@ class calendar_bo { if ($recurrence['reference'] && $recurrence['id'] != $entry['id']) // ignore series master { - $etag .= ':'.$this->get_etag($recurrence); + $etag .= ':'.$this->get_etag($recurrence, $full_etag); } } } diff --git a/calendar/inc/class.calendar_groupdav.inc.php b/calendar/inc/class.calendar_groupdav.inc.php index 9802db4089..eea31e287b 100644 --- a/calendar/inc/class.calendar_groupdav.inc.php +++ b/calendar/inc/class.calendar_groupdav.inc.php @@ -238,18 +238,31 @@ class calendar_groupdav extends groupdav_handler foreach($events as $event) { $event['max_user_modified'] = $max_user_modified[$event['id']]; + $etag = $this->get_etag($event, $schedule_tag); //header('X-EGROUPWARE-EVENT-'.$event['id'].': '.$event['title'].': '.date('Y-m-d H:i:s',$event['start']).' - '.date('Y-m-d H:i:s',$event['end'])); $props = array( - 'getcontenttype' => HTTP_WebDAV_Server::mkprop('getcontenttype', $this->agent != 'kde' ? - 'text/calendar; charset=utf-8; component=VEVENT' : 'text/calendar'), + 'getcontenttype' => $this->agent != 'kde' ? 'text/calendar; charset=utf-8; component=VEVENT' : 'text/calendar', + 'getetag' => '"'.$etag.'"', + 'schedule-tag' => HTTP_WebDAV_Server::mkprop(groupdav::CALDAV, 'schedule-tag', '"'.$schedule_tag.'"'), ); //error_log(__FILE__ . __METHOD__ . "Calendar Data : $calendar_data"); if ($calendar_data) { $content = $this->iCal($event, $filter['users'], strpos($path, '/inbox/') !== false ? 'PUBLISH' : null); $props['getcontentlength'] = bytes($content); - $props[] = HTTP_WebDAV_Server::mkprop(groupdav::CALDAV,'calendar-data',$content); + $props['calendar-data'] = HTTP_WebDAV_Server::mkprop(groupdav::CALDAV,'calendar-data',$content); } + /* Calendarserver reports new events with schedule-changes: action: create, which iCal request + * adding it, unfortunately does not lead to showing the new event in the users inbox + if (strpos($path, '/inbox/') !== false && $this->groupdav->prop_requested('schedule-changes')) + { + $props['schedule-changes'] = HTTP_WebDAV_Server::mkprop(groupdav::CALENDARSERVER,'schedule-changes',array( + HTTP_WebDAV_Server::mkprop(groupdav::CALENDARSERVER,'dtstamp',gmdate('Ymd\THis',$event['created']).'Z'), + HTTP_WebDAV_Server::mkprop(groupdav::CALENDARSERVER,'action',array( + HTTP_WebDAV_Server::mkprop(groupdav::CALENDARSERVER,'create',''), + )), + )); + }*/ $files[] = $this->add_resource($path, $event, $props); } } @@ -393,7 +406,9 @@ class calendar_groupdav extends groupdav_handler $options['data'] = $this->iCal($event, $user, strpos($options['path'], '/inbox/') !== false ? 'PUBLISH' : null); $options['mimetype'] = 'text/calendar; charset=utf-8'; header('Content-Encoding: identity'); - header('ETag: "'.$this->get_etag($event).'"'); + header('ETag: "'.$this->get_etag($event, $schedule_tag).'"'); + header('Schedule-Tag: "'.$schedule_tag.'"'); + return true; } @@ -516,7 +531,8 @@ class calendar_groupdav extends groupdav_handler if (!$prefix) $user = null; // /infolog/ does not imply setting the current user (for new entries it's done anyway) $return_no_access = true; // as handled by importVCal anyway and allows it to set the status for participants - $oldEvent = $this->_common_get_put_delete('PUT',$options,$id,$return_no_access); + $oldEvent = $this->_common_get_put_delete('PUT',$options,$id,$return_no_access, + isset($_SERVER['HTTP_IF_SCHEDULE_TAG_MATCH'])); // dont fail with 412 Precondition Failed in that case if (!is_null($oldEvent) && !is_array($oldEvent)) { if ($this->debug) error_log(__METHOD__.': '.print_r($oldEvent,true).function_backtrace()); @@ -556,6 +572,39 @@ class calendar_groupdav extends groupdav_handler if (is_array($oldEvent)) { $eventId = $oldEvent['id']; + + // client specified a CalDAV Scheduling schedule-tag precondition + if (isset($_SERVER['HTTP_IF_SCHEDULE_TAG_MATCH'])) + { + $schedule_tag_match = $_SERVER['HTTP_IF_SCHEDULE_TAG_MATCH']; + if ($schedule_tag_match[0] == '"') $schedule_tag_match = substr($schedule_tag_match, 1, -1); + $this->get_etag($oldEvent, $schedule_tag); + + if ($schedule_tag_match !== $schedule_tag) + { + return '412 Precondition Failed'; + } + // update only participant status and alarms of current user + if (($events = $handler->icaltoegw($vCalendar))) + { + // todo check behavior for recuring events + foreach($events as $event) + { + if ($this->debug) error_log(__METHOD__."(, $id, $user, '$prefix') eventId=$eventId, user=$user, event=".array2string($event)); + if ($event['participants'][$user] != $oldEvent['participants'][$user] && + !$this->bo->set_status($eventId, $user, $event['participants'][$user], $event['recurrence'])) + { + if ($this->debug) error_log(__METHOD__."(,,$user) failed to set_status($eventId, $user, '{$event['participants'][$user]}')"); + return '403 Forbidden'; + } + if ($this->debug) error_log(__METHOD__."() set_status($eventId, $user, ".array2string($event['participants'][$user])." , $event[recurrence])"); + // import alarms + $this->sync_alarms($eventId, (array)$event['alarm'], (array)$oldEvent['alarm'], $user, $event['start']); + } + header('Schedule-Tag: "'.$schedule_tag.'"'); + return '204 No Content'; + } + } if ($return_no_access) { $retval = true; @@ -599,8 +648,10 @@ class calendar_groupdav extends groupdav_handler } } + $etag = $this->get_etag($cal_id, $schedule_tag); // we should not return an etag here, as we never store the PUT ical byte-by-byte - //header('ETag: "'.$this->get_etag($cal_id).'"'); + //header('ETag: "'.$etag.'"'); + header('Schedule-Tag: "'.$schedule_tag.'"'); // send GroupDAV Location header only if we dont use caldav_name as path-attribute if ($retval !== true && self::$path_attr != 'caldav_name') @@ -612,6 +663,49 @@ class calendar_groupdav extends groupdav_handler return $retval; } + /** + * Sync alarms of current user: add alarms added on client and remove the ones removed + * + * @param int $cal_id of event to set alarms + * @param array $alarms + * @param array $old_alarms + * @param int $user account_id of user to create alarm for + * @param int $start start-time of event + * @ToDo store other alarm properties like: ACTION, DESCRIPTION, X-WR-ALARMUID + */ + private function sync_alarms($cal_id, array $alarms, array $old_alarms, $user, $start) + { + if ($this->debug) error_log(__METHOD__."($cal_id, ".array2string($alarms).', '.array2string($old_alarms).", $user, $start)"); + // todo import alarms + foreach($alarms as $alarm) + { + if ($alarm['owner'] != $this->user) continue; // only import alarms of current user + + // check if alarm is already stored or from other users + foreach($old_alarms as $id => $old_alarm) + { + if ($old_alarm['owner'] != $user || $alarm['offset'] == $old_alarm['offset']) + { + unset($old_alarms[$id]); // remove alarms of other user, or already existing alarms + } + } + // alarm not found --> add it + if ($alarm['offset'] != $old_alarm['offset'] || $old_alarm['owner'] != $user) + { + $alarm['owner'] = $user; + $alarm['time'] = $start - $alarm['offset']; + if ($this->debug) error_log(__METHOD__."() adding new alarm from client ".array2string($alarm)); + $this->bo->save_alarm($cal_id, $alarm); + } + } + // remove all old alarms left from current user + foreach($old_alarms as $id => $old_alarm) + { + if ($this->debug) error_log(__METHOD__."() deleting alarm '$id' deleted on client ".array2string($old_alarm)); + $this->bo->delete_alarm($id); + } + } + /** * Handle post request for a schedule entry * @@ -982,14 +1076,14 @@ class calendar_groupdav extends groupdav_handler } /** - * Get the etag for an entry, reimplemented to include the participants and stati in the etag + * Get the etag for an entry * - * @param array/int $event array with event or cal_id - * @return string/boolean string with etag or false + * @param array|int $event array with event or cal_id + * @return string|boolean string with etag or false */ - function get_etag($entry) + function get_etag($entry, &$schedule_tag=null) { - $etag = $this->bo->get_etag($entry,$this->client_shared_uid_exceptions); + $etag = $this->bo->get_etag($entry, $schedule_tag, $this->client_shared_uid_exceptions); //error_log(__METHOD__ . "($entry[id] ($entry[etag]): $entry[title] --> etag=$etag"); return $etag; @@ -999,7 +1093,7 @@ class calendar_groupdav extends groupdav_handler * Check if user has the neccessary rights on an event * * @param int $acl EGW_ACL_READ, EGW_ACL_EDIT or EGW_ACL_DELETE - * @param array/int $event event-array or id + * @param array|int $event event-array or id * @return boolean null if entry does not exist, false if no access, true if access permitted */ function check_access($acl,$event) diff --git a/calendar/inc/class.calendar_ical.inc.php b/calendar/inc/class.calendar_ical.inc.php index ddda77bc1c..ca0a98df0c 100644 --- a/calendar/inc/class.calendar_ical.inc.php +++ b/calendar/inc/class.calendar_ical.inc.php @@ -423,13 +423,14 @@ class calendar_ical extends calendar_boupdate switch ($icalFieldName) { case 'ATTENDEE': - $attendees = count($event['participants']); foreach ((array)$event['participants'] as $uid => $status) { calendar_so::split_status($status, $quantity, $role); - if ($attendees == 1 && - $uid == $this->user && $status == 'A') continue; + // do not include event owner/ORGANIZER as participant in his own calendar, if he is only participant + if (count($event['participants']) == 1 && $event['owner'] == $uid) continue; + if (!($info = $this->resource_info($uid))) continue; + if ($this->log) { error_log(__FILE__.'['.__LINE__.'] '.__METHOD__ . @@ -521,7 +522,6 @@ class calendar_ical extends calendar_boupdate break; case 'ORGANIZER': - // according to iCalendar standard, ORGANIZER not used for events in the own calendar if (!$organizerCN) { $organizerCN = '"' . trim($GLOBALS['egw']->accounts->id2name($event['owner'],'account_firstname') @@ -551,7 +551,8 @@ class calendar_ical extends calendar_boupdate $parameters['ATTENDEE'][] = $options; } } - if ($this->productManufacturer != 'groupdav' || !$this->check_perms(EGW_ACL_EDIT,$event)) + // do NOT use ORGANIZER for events without further participants or a different organizer + if (count($event['participants']) > 1 || !isset($event['participants'][$event['owner']])) { $attributes['ORGANIZER'] = $organizerURL; $parameters['ORGANIZER']['CN'] = $organizerCN; @@ -1317,6 +1318,13 @@ class calendar_ical extends calendar_boupdate $event['owner'] = $event_info['stored_event']['owner']; } $event['caldav_name'] = $event_info['stored_event']['caldav_name']; + + // as we no longer export event owner/ORGANIZER as only participant, we have to re-add owner as participant + // to not loose him, as EGroupware knows events without owner/ORGANIZER as participant + if (isset($event_info['stored_event']['participants'][$event['owner']]) && !isset($event['participant'][$event['owner']])) + { + $event['participant'][$event['owner']] = $event_info['stored_event']['participants'][$event['owner']]; + } } else // common adjustments for new events { diff --git a/phpgwapi/inc/class.groupdav_handler.inc.php b/phpgwapi/inc/class.groupdav_handler.inc.php index d73422d1a4..4fad66d1a2 100644 --- a/phpgwapi/inc/class.groupdav_handler.inc.php +++ b/phpgwapi/inc/class.groupdav_handler.inc.php @@ -244,9 +244,10 @@ abstract class groupdav_handler * @param array &$options * @param int|string &$id on return self::$path_extension got removed * @param boolean &$return_no_access=false if set to true on call, instead of '403 Forbidden' the entry is returned and $return_no_access===false + * @param boolean $ignore_if_match=false if true, ignore If-Match precondition * @return array|string entry on success, string with http-error-code on failure, null for PUT on an unknown id */ - function _common_get_put_delete($method,&$options,&$id,&$return_no_access=false) + function _common_get_put_delete($method,&$options,&$id,&$return_no_access=false,$ignore_if_match=false) { if (self::$path_extension) $id = basename($id,self::$path_extension); @@ -275,7 +276,7 @@ abstract class groupdav_handler $etag = $this->get_etag($entry); // If the clients sends an "If-Match" header ($_SERVER['HTTP_IF_MATCH']) we check with the current etag // of the calendar --> on failure we return 412 Precondition failed, to not overwrite the modifications - if (isset($_SERVER['HTTP_IF_MATCH'])) + if (isset($_SERVER['HTTP_IF_MATCH']) && !$ignore_if_match) { $this->http_if_match = $_SERVER['HTTP_IF_MATCH']; // strip of quotes around etag, if they exist, that way we allow etag with and without quotes