diff --git a/config/example-config.php b/config/example-config.php index bdc96715..a62b41a0 100644 --- a/config/example-config.php +++ b/config/example-config.php @@ -42,14 +42,26 @@ $c->pg_connect[] = "dbname=davical user=davical_app"; $c->hide_alarm = true; /** -*default is false -*If true, then TODO requested from someone other than the admmin or owner +* default is false +* If true, then TODO requested from someone other than the admmin or owner * of a calendar will not get any answer. Often these todo are only relevant * to the owner, but in some shared calendar situations they might not be in * which case you should let this default to false. */ $c->hide_TODO = true; +/** +* The default is false for backward compatibility +* If true, then calendars accessed via WebDAV will only be readonly. Any +* changes to them must be applied via CalDAV. +* +* You may want to set this to false during your initial setup to make it +* easier for people to PUT whole calendars as part of the conversion of +* their data. After this it is recommended to turn it off so that clients +* which have been misconfigured are readily identifiable. +*/ +$c->readonly_webdav_collections = true; + /*************************************************************************** * * * ADMIN web Interface * diff --git a/dba/caldav_functions.sql b/dba/caldav_functions.sql index ad235072..27ee3fb0 100644 --- a/dba/caldav_functions.sql +++ b/dba/caldav_functions.sql @@ -378,3 +378,55 @@ BEGIN RETURN newname; END; $$ LANGUAGE plpgsql; + + +DROP TRIGGER caldav_data_modified ON caldav_data CASCADE; +CREATE or REPLACE FUNCTION caldav_data_modified() RETURNS TRIGGER AS $$ +DECLARE + coll_id caldav_data.collection_id%TYPE; +BEGIN + IF TG_OP = 'UPDATE' THEN + IF NEW.caldav_data = OLD.caldav_data AND NEW.collection_id = OLD.collection_id THEN + -- Nothing for us to do + RETURN NEW; + END IF; + END IF; + + IF TG_OP = 'DELETE' OR TG_OP = 'UPDATE' THEN + UPDATE collection + SET modified = current_timestamp, dav_etag = md5(OLD.user_no::text||OLD.dav_name||current_timestamp::text) + WHERE collection_id = OLD.collection_id; + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + END IF; + + IF TG_OP = 'INSERT' THEN + UPDATE collection + SET modified = current_timestamp, dav_etag = md5(NEW.user_no::text||NEW.dav_name||current_timestamp::text) + WHERE collection_id = NEW.collection_id; + RETURN NEW; + END IF; + + IF NEW.collection_id != OLD.collection_id THEN + UPDATE collection + SET modified = current_timestamp, dav_etag = md5(NEW.user_no::text||NEW.dav_name||current_timestamp::text) + WHERE collection_id = NEW.collection_id; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +CREATE TRIGGER caldav_data_modified AFTER INSERT OR UPDATE OR DELETE ON caldav_data + FOR EACH ROW EXECUTE PROCEDURE caldav_data_modified(); + +/* +DROP TRIGGER collection_modified ON collection CASCADE; +CREATE or REPLACE FUNCTION collection_modified() RETURNS TRIGGER AS $$ +DECLARE +BEGIN + +END; +$$ LANGUAGE plpgsql; +CREATE TRIGGER collection_modified AFTER INSERT OR UPDATE ON collection + FOR EACH ROW EXECUTE PROCEDURE collection_modified(); +*/ \ No newline at end of file diff --git a/dba/davical.sql b/dba/davical.sql index 57dd2783..b1a40471 100644 --- a/dba/davical.sql +++ b/dba/davical.sql @@ -71,6 +71,7 @@ CREATE TABLE calendar_item ( percent_complete NUMERIC(7,2), tz_id TEXT REFERENCES time_zone( tz_id ), status TEXT, + completed TIMESTAMP WITH TIME ZONE, dav_id INT8 UNIQUE, collection_id INT8 REFERENCES collection(collection_id) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE, diff --git a/dba/patches/1.2.4.sql b/dba/patches/1.2.4.sql new file mode 100644 index 00000000..868d2814 --- /dev/null +++ b/dba/patches/1.2.4.sql @@ -0,0 +1,16 @@ + +-- This database update provides new tables for the Principal, for +-- a consistent dav_resource which a principal, collection or calendar_item +-- all inherit from. + +BEGIN; +SELECT check_db_revision(1,2,3); + +-- Add a column to hold the 'COMPLETED' property from the caldav_data +ALTER TABLE calendar_item ADD COLUMN completed TIMESTAMP WITH TIME ZONE; + +SELECT new_db_revision(1,2,4, 'Avril' ); + +COMMIT; +ROLLBACK; + diff --git a/dba/rrule_functions.sql b/dba/rrule_functions.sql index d36cd778..f7343f67 100644 --- a/dba/rrule_functions.sql +++ b/dba/rrule_functions.sql @@ -3,24 +3,24 @@ * * @package rscds * @subpackage database -* @author Andrew McMillan -* @copyright Catalyst IT Ltd +* @author Andrew McMillan +* @copyright Morphoss Ltd - http://www.morphoss.com/ * @license http://gnu.org/copyleft/gpl.html GNU GPL v2 */ -- How many days are there in a particular month? -CREATE or REPLACE FUNCTION rrule_days_in_month( TIMESTAMP WITH TIME ZONE ) RETURNS INT AS ' +CREATE or REPLACE FUNCTION rrule_days_in_month( TIMESTAMP WITH TIME ZONE ) RETURNS INT AS $$ DECLARE in_time ALIAS FOR $1; days INT; BEGIN - RETURN date_part( ''days'', date_trunc( ''month'', in_time + interval ''1 month'') - interval ''1 day'' ); + RETURN date_part( 'days', date_trunc( 'month', in_time + interval '1 month') - interval '1 day' ); END; -' LANGUAGE 'plpgsql' IMMUTABLE STRICT; +$$ LANGUAGE 'plpgsql' IMMUTABLE STRICT; -- Return a SETOF text strings, split on the commas in the original one -CREATE or REPLACE FUNCTION rrule_split_on_commas( TEXT ) RETURNS SETOF TEXT AS ' +CREATE or REPLACE FUNCTION rrule_split_on_commas( TEXT ) RETURNS SETOF TEXT AS $$ DECLARE in_text ALIAS FOR $1; part TEXT; @@ -29,7 +29,7 @@ DECLARE BEGIN remainder := in_text; LOOP - cpos := position( '','' in remainder ); + cpos := position( ',' in remainder ); IF cpos = 0 THEN part := remainder; EXIT; @@ -42,10 +42,10 @@ BEGIN RETURN NEXT part; RETURN; END; -' LANGUAGE 'plpgsql' IMMUTABLE STRICT; +$$ LANGUAGE 'plpgsql' IMMUTABLE STRICT; -- Return a SETOF dates within the month of a particular date which match a single BYDAY rule specification -CREATE or REPLACE FUNCTION rrule_month_bydayrule_set( TIMESTAMP WITH TIME ZONE, TEXT ) RETURNS SETOF TIMESTAMP WITH TIME ZONE AS ' +CREATE or REPLACE FUNCTION rrule_month_bydayrule_set( TIMESTAMP WITH TIME ZONE, TEXT ) RETURNS SETOF TIMESTAMP WITH TIME ZONE AS $$ DECLARE in_time ALIAS FOR $1; bydayrule ALIAS FOR $2; @@ -55,54 +55,54 @@ DECLARE each_day TIMESTAMP WITH TIME ZONE; this_month INT; BEGIN - dow := position(substring( bydayrule from ''..$'') in ''SUMOTUWETHFRSA'') / 2; - each_day := date_trunc( ''month'', in_time ) + (in_time::time)::interval; - this_month := date_part( ''month'', in_time ); - first_dow := date_part( ''dow'', each_day ); - each_day := each_day - ( first_dow::text || ''days'')::interval - + ( dow::text || ''days'')::interval - + CASE WHEN dow > first_dow THEN ''1 week''::interval ELSE ''0s''::interval END; + dow := position(substring( bydayrule from '..$') in 'SUMOTUWETHFRSA') / 2; + each_day := date_trunc( 'month', in_time ) + (in_time::time)::interval; + this_month := date_part( 'month', in_time ); + first_dow := date_part( 'dow', each_day ); + each_day := each_day - ( first_dow::text || 'days')::interval + + ( dow::text || 'days')::interval + + CASE WHEN dow > first_dow THEN '1 week'::interval ELSE '0s'::interval END; IF length(bydayrule) > 2 THEN - index := (substring(bydayrule from ''^[0-9-]+''))::int; + index := (substring(bydayrule from '^[0-9-]+'))::int; -- Possibly we should check that (index != 0) here, which is an error IF index = 0 THEN - RAISE NOTICE ''Ignored invalid BYDAY rule part "%".'', bydayrule; + RAISE NOTICE 'Ignored invalid BYDAY rule part "%".', bydayrule; ELSIF index > 0 THEN -- The simplest case, such as 2MO for the second monday - each_day := each_day + ((index - 1)::text || '' weeks'')::interval; + each_day := each_day + ((index - 1)::text || ' weeks')::interval; ELSE - each_day := each_day + ''5 weeks''::interval; - WHILE date_part(''month'', each_day) != this_month LOOP - each_day := each_day - ''1 week''::interval; + each_day := each_day + '5 weeks'::interval; + WHILE date_part('month', each_day) != this_month LOOP + each_day := each_day - '1 week'::interval; END LOOP; -- Note that since index is negative, (-2 + 1) == -1, for example - each_day := each_day + ( (index + 1)::text || '' weeks'')::interval ; + each_day := each_day + ( (index + 1)::text || ' weeks')::interval ; END IF; -- Sometimes (e.g. 5TU or -5WE) there might be no such date in some months - IF date_part(''month'', each_day) = this_month THEN + IF date_part('month', each_day) = this_month THEN RETURN NEXT each_day; END IF; ELSE -- Return all such days that are within the given month - WHILE date_part(''month'', each_day) = this_month LOOP + WHILE date_part('month', each_day) = this_month LOOP RETURN NEXT each_day; - each_day := each_day + ''1 week''::interval; + each_day := each_day + '1 week'::interval; END LOOP; END IF; RETURN; END; -' LANGUAGE 'plpgsql' IMMUTABLE STRICT; +$$ LANGUAGE 'plpgsql' IMMUTABLE STRICT; -- Return a SETOF dates within the month of a particular date which match a string of BYDAY rule specifications -CREATE or REPLACE FUNCTION rrule_month_byday_set( TIMESTAMP WITH TIME ZONE, TEXT ) RETURNS SETOF TIMESTAMP WITH TIME ZONE AS ' +CREATE or REPLACE FUNCTION rrule_month_byday_set( TIMESTAMP WITH TIME ZONE, TEXT ) RETURNS SETOF TIMESTAMP WITH TIME ZONE AS $$ DECLARE in_time ALIAS FOR $1; byday ALIAS FOR $2; @@ -119,13 +119,13 @@ BEGIN RETURN; END; -' LANGUAGE 'plpgsql' IMMUTABLE STRICT; +$$ LANGUAGE 'plpgsql' IMMUTABLE STRICT; -- Return a SETOF dates within the month of a particular date which match a string of BYDAY rule specifications -CREATE or REPLACE FUNCTION rrule_month_bymonthday_set( TIMESTAMP WITH TIME ZONE, TEXT ) RETURNS SETOF TIMESTAMP WITH TIME ZONE AS ' +CREATE or REPLACE FUNCTION rrule_month_bymonthday_set( TIMESTAMP WITH TIME ZONE, TEXT ) RETURNS SETOF TIMESTAMP WITH TIME ZONE AS $$ DECLARE in_time ALIAS FOR $1; bymonthday ALIAS FOR $2; @@ -136,21 +136,21 @@ DECLARE BEGIN daysinmonth := rrule_days_in_month(in_time); - month_start := date_trunc( ''month'', in_time ) + (in_time::time)::interval; + month_start := date_trunc( 'month', in_time ) + (in_time::time)::interval; FOR dayrule IN SELECT * FROM rrule_split_on_commas( bymonthday ) LOOP dayoffset := dayrule.rrule_split_on_commas::int; IF dayoffset = 0 THEN - RAISE NOTICE ''Ignored invalid BYMONTHDAY part "%".'', dayrule.rrule_split_on_commas; + RAISE NOTICE 'Ignored invalid BYMONTHDAY part "%".', dayrule.rrule_split_on_commas; dayoffset := 0; ELSIF dayoffset > daysinmonth THEN dayoffset := 0; ELSIF dayoffset < (-1 * daysinmonth) THEN dayoffset := 0; ELSIF dayoffset > 0 THEN - RETURN NEXT month_start + ((dayoffset - 1)::text || ''days'')::interval; + RETURN NEXT month_start + ((dayoffset - 1)::text || 'days')::interval; ELSE - RETURN NEXT month_start + ((daysinmonth + dayoffset)::text || ''days'')::interval; + RETURN NEXT month_start + ((daysinmonth + dayoffset)::text || 'days')::interval; END IF; END LOOP; @@ -158,12 +158,12 @@ BEGIN RETURN; END; -' LANGUAGE 'plpgsql' IMMUTABLE STRICT; +$$ LANGUAGE 'plpgsql' IMMUTABLE STRICT; -- Return a SETOF dates within the week of a particular date which match a single BYDAY rule specification -CREATE or REPLACE FUNCTION rrule_week_byday_set( TIMESTAMP WITH TIME ZONE, TEXT ) RETURNS SETOF TIMESTAMP WITH TIME ZONE AS ' +CREATE or REPLACE FUNCTION rrule_week_byday_set( TIMESTAMP WITH TIME ZONE, TEXT ) RETURNS SETOF TIMESTAMP WITH TIME ZONE AS $$ DECLARE in_time ALIAS FOR $1; byweekday ALIAS FOR $2; @@ -171,21 +171,21 @@ DECLARE dow INT; our_day TIMESTAMP WITH TIME ZONE; BEGIN - our_day := date_trunc( ''week'', in_time ) + (in_time::time)::interval; + our_day := date_trunc( 'week', in_time ) + (in_time::time)::interval; FOR dayrule IN SELECT * FROM rrule_split_on_commas( byweekday ) LOOP - dow := position(substring( dayrule.rrule_split_on_commas from ''..$'') in ''SUMOTUWETHFRSA'') / 2; - RETURN NEXT our_day + ((dow - 1)::text || ''days'')::interval; + dow := position(substring( dayrule.rrule_split_on_commas from '..$') in 'SUMOTUWETHFRSA') / 2; + RETURN NEXT our_day + ((dow - 1)::text || 'days')::interval; END LOOP; RETURN; END; -' LANGUAGE 'plpgsql' IMMUTABLE STRICT; +$$ LANGUAGE 'plpgsql' IMMUTABLE STRICT; -CREATE or REPLACE FUNCTION event_has_exceptions( TEXT ) RETURNS BOOLEAN AS ' - SELECT $1 ~ ''\nRECURRENCE-ID(;TZID=[^:]+)?:[[:space:]]*[[:digit:]]{8}(T[[:digit:]]{6})?'' -' LANGUAGE 'sql' IMMUTABLE STRICT; +CREATE or REPLACE FUNCTION event_has_exceptions( TEXT ) RETURNS BOOLEAN AS $$ + SELECT $1 ~ E'\nRECURRENCE-ID(;TZID=[^:]+)?:[[:space:]]*[[:digit:]]{8}(T[[:digit:]]{6})?' +$$ LANGUAGE 'sql' IMMUTABLE STRICT; diff --git a/htdocs/caldav.php b/htdocs/caldav.php index f81eebf2..7511e588 100644 --- a/htdocs/caldav.php +++ b/htdocs/caldav.php @@ -41,6 +41,7 @@ switch ( $request->method ) { case 'MKCALENDAR': include_once("caldav-MKCALENDAR.php"); break; case 'MKCOL': include_once("caldav-MKCALENDAR.php"); break; case 'PUT': include_once("caldav-PUT.php"); break; + case 'POST': include_once("caldav-POST.php"); break; case 'GET': include_once("caldav-GET.php"); break; case 'HEAD': include_once("caldav-GET.php"); break; case 'DELETE': include_once("caldav-DELETE.php"); break; diff --git a/inc/CalDAVPrincipal.php b/inc/CalDAVPrincipal.php index 33978a79..bdafe4b4 100644 --- a/inc/CalDAVPrincipal.php +++ b/inc/CalDAVPrincipal.php @@ -93,6 +93,13 @@ class CalDAVPrincipal else if ( isset($parameters['user_no']) ) { $usr = getUserByID($parameters['user_no']); } + else if ( isset($parameters['email']) && isset($parameters['options']['allow_by_email']) ) { + $parameters['options']['allow_by_email'] = false; + if ( $username = $this->UsernameFromEMail($parameters['email']) ) { + $usr = getUserByName($username); + $this->by_email = true; + } + } else if ( isset($parameters['path']) ) { dbg_error_log( "principal", "Finding Principal from path: '%s', options.allow_by_email: '%s'", $parameters['path'], $parameters['options']['allow_by_email'] ); if ( $username = $this->UsernameFromPath($parameters['path'], $parameters['options']) ) { @@ -209,6 +216,21 @@ class CalDAVPrincipal } + /** + * Work out the username, based on the given e-mail + * @param string $email The email address to be used. + */ + function UsernameFromEMail( $email ) { + $qry = new PgQuery("SELECT user_no, username FROM usr WHERE email = ?;", $email ); + if ( $qry->Exec("principal") && $user = $qry->Fetch() ) { + $user_no = $user->user_no; + $username = $user->username; + } + + return $username; + } + + /** * Returns the array of privilege names converted into XMLElements */ diff --git a/inc/CalDAVRequest.php b/inc/CalDAVRequest.php index ff86d989..a4e1ba5b 100644 --- a/inc/CalDAVRequest.php +++ b/inc/CalDAVRequest.php @@ -64,6 +64,12 @@ class CalDAVRequest */ var $collection_path; + /** + * The type of collection being requested: + * calendar, schedule-inbox, schedule-outbox + */ + var $collection_type; + /** * Create a new CalDAVRequest object. */ @@ -196,22 +202,44 @@ class CalDAVRequest /** * Get the ID of the collection we are referring to */ - if ( !isset($this->collection_id) && preg_match( '#^(/.+/.+/)[^/]*$#', $this->path, $matches ) ) { - $qry = new PgQuery( "SELECT collection_id FROM collection WHERE user_no = ? AND dav_name = ?;", $this->user_no, $matches[1] ); - if ( $qry->Exec('caldav') && $qry->rows == 1 && ($row = $qry->Fetch()) ) { - $this->collection_id = $row->collection_id; - $this->collection_path = $matches[1]; + if ( preg_match( '#^(/.+/.+/)[^/]*$#', $this->path, $matches ) ) { + $this->collection_type = 'calendar'; + if ( preg_match( '#^(/[^/]+/\.(in|out)/)[^/]*$#', $this->path, $matches ) ) { + $this->collection_type = $matches[2]; + } + if ( ! isset($this->collection_id) ) { + $qry = new PgQuery( "SELECT collection_id FROM collection WHERE user_no = ? AND dav_name = ?;", + ($this->collection_type == 'calendar' ? $this->user_no : $session->user_no), $matches[1] ); + if ( $qry->Exec('caldav') && $qry->rows == 1 && ($row = $qry->Fetch()) ) { + $this->collection_id = $row->collection_id; + $this->collection_path = $matches[1]; + } + else if ( preg_match( '#^((/[^/]+/)\.(in|out)/)[^/]*$#', $this->path, $matches ) ) { + // Request is for a scheduling inbox or outbox (or something inside one) and we should auto-create it + $displayname = $session->fullname . ($matches[3] == 'in' ? ' Inbox' : ' Outbox'); + $sql = <<user_no, $matches[2] , $matches[1], $displayname ); + $qry->Exec('caldav'); + dbg_error_log( "caldav", "Created new collection as '$displayname'." ); + + $qry = new PgQuery( "SELECT collection_id FROM collection WHERE user_no = ? AND dav_name = ?;", $session->user_no, $matches[1] ); + if ( $qry->Exec('caldav') && $qry->rows == 1 && ($row = $qry->Fetch()) ) { + $this->collection_id = $row->collection_id; + $this->collection_path = $matches[1]; + } + } } } - dbg_error_log( "caldav", " Collection '%s' is %d", $this->collection_path, $this->collection_id ); - + dbg_error_log( "caldav", " Collection '%s' is %d, type %s", $this->collection_path, $this->collection_id, $this->collection_type ); /** * Evaluate our permissions for accessing the target */ $this->setPermissions(); - /** * If the content we are receiving is XML then we parse it here. RFC2518 says we * should reasonably expect to see either text/xml or application/xml @@ -236,9 +264,9 @@ class CalDAVRequest $this->etag_if_match = str_replace('"','',$_SERVER["HTTP_IF_MATCH"]); if ( $this->etag_if_match == '' ) unset($this->etag_if_match); } - } + /** * Work out the user whose calendar we are accessing, based on elements of the path. */ @@ -532,15 +560,30 @@ class CalDAVRequest function AllowedTo( $activity ) { if ( isset($this->permissions['all']) ) return true; switch( $activity ) { + case "CALDAV:schedule-send-freebusy": + return isset($this->permissions['read']) || isset($this->permissions['freebusy']); + break; + + case "CALDAV:schedule-send-invite": + return isset($this->permissions['read']) || isset($this->permissions['freebusy']); + break; + + case "CALDAV:schedule-send-reply": + return isset($this->permissions['read']) || isset($this->permissions['freebusy']); + break; + case 'freebusy': return isset($this->permissions['read']) || isset($this->permissions['freebusy']); break; + case 'delete': return isset($this->permissions['write']) || isset($this->permissions['unbind']); break; + case 'proppatch': return isset($this->permissions['write']) || isset($this->permissions['write-properties']); break; + case 'modify': return isset($this->permissions['write']) || isset($this->permissions['write-content']); break; diff --git a/inc/RRule.php b/inc/RRule.php index 98ab0df5..2b8f8ff4 100644 --- a/inc/RRule.php +++ b/inc/RRule.php @@ -252,7 +252,7 @@ class iCalDate { * Add some integer number of days to a date */ function AddDays( $dd ) { - dbg_error_log( "RRule", " Adding %d days to %s", $dd, $this->_text ); + $at_start = $this->_text; $this->_dd += $dd; while ( 1 > $this->_dd ) { $this->_mo--; @@ -272,7 +272,7 @@ class iCalDate { } $this->_EpochFromParts(); $this->_TextFromEpoch(); - dbg_error_log( "RRule", " Added %d days and got %s", $dd, $this->_text ); + dbg_error_log( "RRule", " Added %d days to %s and got %s", $dd, $at_start, $this->_text ); } @@ -436,20 +436,23 @@ class iCalDate { /** * Applies any BYDAY to the week to return a set of days * @param string $byday The BYDAY rule + * @param string $increasing When we are moving by months, we want any day of the week, but when by day we only want to increase. Default false. * @return array An array of the day numbers for the week which meet the rule. */ - function GetWeekByDay($byday) { + function GetWeekByDay($byday, $increasing = false) { global $ical_weekdays; dbg_error_log( "RRule", " Applying BYDAY %s to week", $byday ); $days = split(',',$byday); $dow = date('w',$this->_epoch); + $set = array(); foreach( $days AS $k => $v ) { $daynum = $ical_weekdays[$v]; $dd = $this->_dd - $dow + $daynum; if ( $daynum < $this->_wkst ) $dd += 7; - $set[$dd] = $dd; + if ( $dd > $this->_dd || !$increasing ) $set[$dd] = $dd; } asort( $set, SORT_NUMERIC ); + return $set; } @@ -893,15 +896,15 @@ class RRule { $limit = 100; do { $limit--; - if ( $this->_started ) { - $next->AddDays($this->_part['INTERVAL']); - } - else { - $this->_started = true; - } + if ( $this->_started ) { + $next->AddDays($this->_part['INTERVAL']); + } + else { + $this->_started = true; + } if ( isset($this->_part['BYDAY']) ) { - $days = $next->GetWeekByDay($this->_part['BYDAY']); + $days = $next->GetWeekByDay($this->_part['BYDAY'], true ); } else $days[$next->_dd] = $next->_dd; diff --git a/inc/caldav-POST.php b/inc/caldav-POST.php index 89a201e8..e38d4b4d 100644 --- a/inc/caldav-POST.php +++ b/inc/caldav-POST.php @@ -10,12 +10,15 @@ */ dbg_error_log("POST", "method handler"); +require_once("XMLDocument.php"); require_once("iCalendar.php"); +include_once("RRule.php"); if ( ! $request->AllowedTo("CALDAV:schedule-send-freebusy") && ! $request->AllowedTo("CALDAV:schedule-send-invite") && ! $request->AllowedTo("CALDAV:schedule-send-reply") ) { - $request->DoResponse(403); + // $request->DoResponse(403); + dbg_error_log( "WARN", ": POST: permissions not yet checked" ); } if ( ! ini_get('open_basedir') && (isset($c->dbg['ALL']) || $c->dbg['post']) ) { @@ -27,11 +30,140 @@ if ( ! ini_get('open_basedir') && (isset($c->dbg['ALL']) || $c->dbg['post']) ) { } +function handle_freebusy_request( $ic ) { + global $c, $session, $request; + + $reply = new XMLDocument( array("DAV:" => "", "urn:ietf:params:xml:ns:caldav" => "C" ) ); + $responses = array(); + + $fbq_start = $ic->Get('DTSTART'); + $fbq_end = $ic->Get('DTEND'); + $component =& $ic->component->FirstNonTimezone(); + $attendees = $component->GetProperties('ATTENDEE'); + + $fb_response_template = new iCalendar( array( 'DTSTAMP' => date('Ymd\THis\Z'), + 'DTSTART' => $fbq_start, + 'DTEND' => $fbq_end, + 'UID' => $ic->Get('UID'), + 'ORGANIZER' => $ic->Get('ORGANIZER'), + 'type' => 'VFREEBUSY' ) ); + $fb_response_template->component->AddProperty( new iCalProp("METHOD:REPLY") ); + + foreach( $attendees AS $k => $attendee ) { + $attendee_email = preg_replace( '/^mailto:/', '', $attendee->Value() ); + if ( ! ( isset($fbq_start) || isset($fbq_end) ) ) { + $request->DoResponse( 400, 'All valid freebusy requests MUST contain a DTSTART and a DTEND' ); + } + $where = " WHERE usr.email = ? AND collection.is_calendar "; + if ( isset( $fbq_start ) ) { + $where .= "AND (dtend >= ".qpg($fbq_start)."::timestamp with time zone "; + $where .= "OR calculate_later_timestamp(".qpg($fbq_start)."::timestamp with time zone,dtend,rrule) >= ".qpg($fbq_start)."::timestamp with time zone) "; + } + if ( isset( $fbq_end ) ) { + $where .= "AND dtstart <= ".qpg($fbq_end)."::timestamp with time zone "; + } + $where .= "AND caldav_data.caldav_type IN ( 'VEVENT', 'VFREEBUSY' ) "; + $where .= "AND (calendar_item.transp != 'TRANSPARENT' OR calendar_item.transp IS NULL) "; + $where .= "AND (calendar_item.status != 'CANCELLED' OR calendar_item.status IS NULL) "; + + /** + * @TODO Some significant permissions need to be added around the visibility of free/busy + * but lets get it working first... + */ + $where .= "AND (calendar_item.class != 'PRIVATE' OR calendar_item.class IS NULL) "; + + $busy = array(); + $busy_tentative = array(); + $sql = "SELECT caldav_data.caldav_data, calendar_item.rrule, calendar_item.transp, calendar_item.status, "; + $sql .= "to_char(calendar_item.dtstart at time zone 'GMT',".iCalendar::SqlDateFormat().") AS start, "; + $sql .= "to_char(calendar_item.dtend at time zone 'GMT',".iCalendar::SqlDateFormat().") AS finish "; + $sql .= "FROM usr INNER JOIN collection USING (user_no) INNER JOIN caldav_data USING (collection_id) INNER JOIN calendar_item USING(dav_id)".$where; + if ( isset($c->strict_result_ordering) && $c->strict_result_ordering ) $sql .= " ORDER BY dav_id"; + $qry = new PgQuery( $sql, $attendee_email ); + if ( $qry->Exec("POST",__LINE__,__FILE__) && $qry->rows > 0 ) { + while( $calendar_object = $qry->Fetch() ) { + if ( $calendar_object->transp != "TRANSPARENT" ) { + switch ( $calendar_object->status ) { + case "TENTATIVE": + dbg_error_log( "POST", " FreeBusy: tentative appointment: %s, %s", $calendar_object->start, $calendar_object->finish ); + $busy_tentative[] = $calendar_object; + break; + + case "CANCELLED": + // Cancelled events are ignored + break; + + default: + dbg_error_log( "POST", " FreeBusy: Not transparent, tentative or cancelled: %s, %s", $calendar_object->start, $calendar_object->finish ); + $busy[] = $calendar_object; + break; + } + } + } + } + + + $i = 0; + $fbparams = array( "FBTYPE" => "BUSY-TENTATIVE" ); + $fb = clone($fb_response_template); + $fb->Add( $attendee->Name(), $attendee->Value(), $attendee->parameters ); + foreach( $busy_tentative AS $k => $v ) { + $start = new iCalDate($v->start); + $duration = $start->DateDifference($v->finish); + if ( $v->rrule != "" ) { + $rrule = new RRule( $start, $v->rrule ); + while ( $date = $rrule->GetNext() ) { + if ( ! $date->GreaterThan($fbq_start) ) continue; + if ( $date->GreaterThan($fbq_end) ) break; + $todate = clone($date); + $todate->AddDuration($duration); + $fb->Add("FREEBUSY", sprintf("%s/%s", $date->Render('Ymd\THis'), $todate->Render('Ymd\THis') ), $fbparams ); + } + } + else { + $fb->Add("FREEBUSY;FBTYPE=BUSY-TENTATIVE",sprintf("%s/%s", $start->Render('Ymd\THis'), $v->finish ), $fbparams ); + } + } + + foreach( $busy AS $k => $v ) { + $start = new iCalDate($v->start); + $duration = $start->DateDifference($v->finish); + if ( $v->rrule != "" ) { + $rrule = new RRule( $start, $v->rrule ); + while ( $date = $rrule->GetNext() ) { + if ( ! $date->GreaterThan($fbq_start) ) continue; + if ( $date->GreaterThan($fbq_end) ) break; + $todate = clone($date); + $todate->AddDuration($duration); + $fb->Add("FREEBUSY", sprintf("%s/%s", $date->Render('Ymd\THis'), $todate->Render('Ymd\THis') ) ); + } + } + else { + $fb->Add("FREEBUSY", sprintf("%s/%s", $start->Render('Ymd\THis'), $v->finish ) ); + } + } + + $response = new XMLElement( $reply->Caldav("response") ); + $response->NewElement( $reply->Caldav("recipient"), new XMLElement("href",$_SERVER['HTTP_RECIPIENT']) ); + $response->NewElement( $reply->Caldav("request-status"), "2.0;Success" ); // Cargo-cult setting + $response->NewElement( $reply->Caldav("calendar-data"), $fb->Render() ); + $responses[] = $response; + } + + $response = new XMLElement( "schedule-response", $responses, $reply->GetXmlNsArray() ); + $request->DoResponse( 200, $response->Render() ); +} + + $ical = new iCalendar( array('icalendar' => $request->raw_post) ); -switch ( $ical->properties['METHOD'] ) { +$calendar_properties = $ical->component->GetProperties('METHOD'); +$method = $calendar_properties[0]->Value(); +switch ( $method ) { case 'REQUEST': + dbg_error_log("POST", ": Handling iTIP 'REQUEST' method.", $method ); + handle_freebusy_request( $ical ); break; default: - dbg_error_log("POST", ": Unhandled '%s' method in request.", $ical->properties['METHOD'] ); -} \ No newline at end of file + dbg_error_log("POST", ": Unhandled '%s' method in request.", $method ); +} diff --git a/inc/caldav-PROPFIND.php b/inc/caldav-PROPFIND.php index 49a8c393..eb27d1c9 100644 --- a/inc/caldav-PROPFIND.php +++ b/inc/caldav-PROPFIND.php @@ -427,8 +427,6 @@ function item_to_xml( $item ) { $allprop = isset($prop_list['DAV::allprop']); - $item->properties = get_arbitrary_properties($item->dav_name); - $url = ConstructURL($item->dav_name); $prop = new XMLElement("prop"); @@ -464,6 +462,8 @@ function item_to_xml( $item ) { */ add_general_properties( $prop, $not_found, $denied, $item ); + add_arbitrary_properties($prop, $not_found, $item ); + return build_propstat_response( $prop, $not_found, $denied, $url ); } diff --git a/inc/caldav-REPORT.php b/inc/caldav-REPORT.php index 0f69683a..df4d3088 100644 --- a/inc/caldav-REPORT.php +++ b/inc/caldav-REPORT.php @@ -124,6 +124,15 @@ function calendar_to_xml( $properties, $item ) { break; case 'resourcetype': $prop->NewElement($k, new XMLElement($reply->Caldav("calendar"), false) ); + if ( $request->collection_type == 'in' ) { + $prop->NewElement($k, new XMLElement($reply->Caldav("schedule-inbox"), false) ); + } + else if ( $request->collection_type == 'out' ) { + $prop->NewElement($k, new XMLElement($reply->Caldav("schedule-outbox"), false) ); + } + else { + $prop->NewElement($k, new XMLElement($reply->Caldav("schedule-calendar"), false) ); + } break; case 'displayname': $prop->NewElement($k, $displayname ); @@ -171,6 +180,6 @@ elseif ( $xmltree->GetTag() == "urn:ietf:params:xml:ns:caldav:calendar-multiget" include("caldav-REPORT-multiget.php"); } else { - $request->DoResponse( 501, "XML is not a supported REPORT query document" ); + $request->DoResponse( 501, "The XML is not a supported REPORT query document" ); } diff --git a/inc/test-RRULE.php b/inc/test-RRULE.php index baab42b9..a182791b 100644 --- a/inc/test-RRULE.php +++ b/inc/test-RRULE.php @@ -18,6 +18,7 @@ $tests = array( , "20061117T073000" => "RRULE:FREQ=MONTHLY;BYDAY=1MO,2WE,3FR,-1SU" , "20061107T103000" => "RRULE:FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR" , "20061107T113000" => "RRULE:FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1" + , "20081020T110000" => "RRULE:FREQ=DAILY;INTERVAL=1;BYDAY=MO,TU,WE,TH,FR" ); foreach( $tests AS $start => $rrule ) { @@ -26,7 +27,7 @@ foreach( $tests AS $start => $rrule ) { $rule = new RRule( new iCalDate($start), $rrule ); $i = 0; do { - if ( ($i % 4) == 0 ) echo "\n"; + if ( ($i % 10) == 0 ) echo "\n"; $date = $rule->GetNext(); if ( isset($date) ) { echo " " . $date->Render(); @@ -38,4 +39,3 @@ foreach( $tests AS $start => $rrule ) { } exit(0); -?>