3 * Functions that are needed for all CalDAV Requests
5 * - Ascertaining the paths
6 * - Ascertaining the current user's permission to those paths.
7 * - Utility functions which we can use to decide whether this
8 * is a permitted activity for this user.
12 * @author Andrew McMillan <andrew@mcmillan.net.nz>
13 * @copyright Catalyst .Net Ltd, Morphoss Ltd
14 * @license http://gnu.org/copyleft/gpl.html GNU GPL v3 or later
17 require_once("AwlCache.php");
18 require_once("XMLDocument.php");
19 require_once("DAVPrincipal.php");
20 include("DAVTicket.php");
22 define('DEPTH_INFINITY', 9999);
25 * A class for collecting things to do with this request.
34 * The raw data sent along with the request
39 * The HTTP request method: PROPFIND, LOCK, REPORT, OPTIONS, etc...
44 * The depth parameter from the request headers, coerced into a valid integer: 0, 1
45 * or DEPTH_INFINITY which is defined above. The default is set per various RFCs.
50 * The 'principal' (user/resource/...) which this request seeks to access
56 * The 'current_user_principal_xml' the DAV:current-user-principal answer. An
57 * XMLElement object with an <href> or <unauthenticated> fragment.
59 var $current_user_principal_xml;
62 * The user agent making the request.
67 * The ID of the collection containing this path, or of this path if it is a collection
72 * The path corresponding to the collection_id
77 * The type of collection being requested:
78 * calendar, schedule-inbox, schedule-outbox
83 * The type of collection being requested:
84 * calendar, schedule-inbox, schedule-outbox
89 * The value of any 'Destionation:' header, if present.
94 * The decimal privileges allowed by this user to the identified resource.
96 protected $privileges;
99 * A static structure of supported privileges.
101 var $supported_privileges;
104 * A DAVTicket object, if there is a ?ticket=id or Ticket: id with this request
109 * Create a new CalDAVRequest object.
111 function __construct( $options = array() ) {
112 global $session, $c, $debugging;
114 $this->options
= $options;
115 if ( !isset($this->options
['allow_by_email']) ) $this->options
['allow_by_email'] = false;
118 * Our path is /<script name>/<user name>/<user controlled> if it ends in
119 * a trailing '/' then it is referring to a DAV 'collection' but otherwise
120 * it is referring to a DAV data item.
122 * Permissions are controlled as follows:
123 * 1. if there is no <user name> component, the request has read privileges
124 * 2. if the requester is an admin, the request has read/write priviliges
125 * 3. if there is a <user name> component which matches the logged on user
126 * then the request has read/write privileges
127 * 4. otherwise we query the defined relationships between users and use
128 * the minimum privileges returned from that analysis.
130 $this->path
= (isset($_SERVER['PATH_INFO']) ?
$_SERVER['PATH_INFO'] : "/");
131 $this->path
= rawurldecode($this->path
);
133 /** Allow a request for .../calendar.ics to translate into the calendar URL */
134 if ( preg_match( '#^(/[^/]+/[^/]+).ics$#', $this->path
, $matches ) ) {
135 $this->path
= $matches[1]. '/';
138 // dbg_error_log( "caldav", "Sanitising path '%s'", $this->path );
139 $bad_chars_regex = '/[\\^\\[\\(\\\\]/';
140 if ( preg_match( $bad_chars_regex, $this->path
) ) {
141 $this->DoResponse( 400, translate("The calendar path contains illegal characters.") );
143 if ( strstr($this->path
,'//') ) $this->path
= preg_replace( '#//+#', '/', $this->path
);
145 if ( !isset($c->raw_post
) ) $c->raw_post
= file_get_contents( 'php://input');
146 if ( isset($_SERVER['HTTP_CONTENT_ENCODING']) ) {
147 $encoding = $_SERVER['HTTP_CONTENT_ENCODING'];
148 @dbg_error_log
('caldav', 'Content-Encoding: %s', $encoding );
149 $encoding = preg_replace('{[^a-z0-9-]}i','',$encoding);
150 if ( ! ini_get('open_basedir') && (isset($c->dbg
['ALL']) ||
isset($c->dbg
['caldav'])) ) {
151 $fh = fopen('/tmp/encoded_data.'.$encoding,'w');
153 fwrite($fh,$c->raw_post
);
157 switch( $encoding ) {
159 $this->raw_post
= @gzdecode
($c->raw_post
);
162 $this->raw_post
= @gzinflate
($c->raw_post
);
165 $this->raw_post
= @gzuncompress
($c->raw_post
);
169 if ( empty($this->raw_post
) && !empty($c->raw_post
) ) {
170 $this->PreconditionFailed(415, 'content-encoding', sprintf('Unable to decode "%s" content encoding.', $_SERVER['HTTP_CONTENT_ENCODING']));
172 $c->raw_post
= $this->raw_post
;
175 $this->raw_post
= $c->raw_post
;
178 if ( isset($debugging) && isset($_GET['method']) ) {
179 $_SERVER['REQUEST_METHOD'] = $_GET['method'];
181 else if ( $_SERVER['REQUEST_METHOD'] == 'POST' && isset($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE']) ){
182 $_SERVER['REQUEST_METHOD'] = $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'];
184 $this->method
= $_SERVER['REQUEST_METHOD'];
185 if ( isset($_SERVER['CONTENT_LENGTH']) && $_SERVER['CONTENT_LENGTH'] > 7 ) {
186 $this->content_type
= (isset($_SERVER['CONTENT_TYPE']) ?
$_SERVER['CONTENT_TYPE'] : null);
187 if ( preg_match( '{^(\S+/\S+)\s*(;.*)?$}', $this->content_type
, $matches ) ) {
188 $this->content_type
= $matches[1];
190 if ( $this->method
== 'PROPFIND' ||
$this->method
== 'REPORT' ) {
191 if ( !preg_match( '{^(text|application)/xml$}', $this->content_type
) ) {
192 @dbg_error_log
( "LOG request", 'Request is "%s" but client set content-type to "%s". Assuming they meant XML!',
193 $request->method
, $this->content_type
);
194 $this->content_type
= 'text/xml';
197 else if ( $this->method
== 'PUT' ||
$this->method
== 'POST' ) {
198 $this->CoerceContentType();
201 $this->user_agent
= ((isset($_SERVER['HTTP_USER_AGENT']) ?
$_SERVER['HTTP_USER_AGENT'] : "Probably Mulberry"));
204 * A variety of requests may set the "Depth" header to control recursion
206 if ( isset($_SERVER['HTTP_DEPTH']) ) {
207 $this->depth
= $_SERVER['HTTP_DEPTH'];
211 * Per rfc2518, section 9.2, 'Depth' might not always be present, and if it
212 * is not present then a reasonable request-type-dependent default should be
215 switch( $this->method
) {
221 $this->depth
= 'infinity';
229 if ( $this->depth
== 'infinity' ) $this->depth
= DEPTH_INFINITY
;
230 $this->depth
= intval($this->depth
);
233 * MOVE/COPY use a "Destination" header and (optionally) an "Overwrite" one.
235 if ( isset($_SERVER['HTTP_DESTINATION']) ) {
236 $this->destination
= $_SERVER['HTTP_DESTINATION'];
237 if ( preg_match('{^(https?)://([a-z.-]+)(:[0-9]+)?(/.*)$}', $this->destination
, $matches ) ) {
238 $this->destination
= $matches[4];
241 $this->overwrite
= ( isset($_SERVER['HTTP_OVERWRITE']) && ($_SERVER['HTTP_OVERWRITE'] == 'F') ?
false : true ); // RFC4918, 9.8.4 says default True.
244 * LOCK things use an "If" header to hold the lock in some cases, and "Lock-token" in others
246 if ( isset($_SERVER['HTTP_IF']) ) $this->if_clause
= $_SERVER['HTTP_IF'];
247 if ( isset($_SERVER['HTTP_LOCK_TOKEN']) && preg_match( '#[<]opaquelocktoken:(.*)[>]#', $_SERVER['HTTP_LOCK_TOKEN'], $matches ) ) {
248 $this->lock_token
= $matches[1];
252 * Check for an access ticket.
254 if ( isset($_GET['ticket']) ) {
255 $this->ticket
= new DAVTicket($_GET['ticket']);
257 else if ( isset($_SERVER['HTTP_TICKET']) ) {
258 $this->ticket
= new DAVTicket($_SERVER['HTTP_TICKET']);
262 * LOCK things use a "Timeout" header to set a series of reducing alternative values
264 if ( isset($_SERVER['HTTP_TIMEOUT']) ) {
265 $timeouts = explode( ',', $_SERVER['HTTP_TIMEOUT'] );
266 foreach( $timeouts AS $k => $v ) {
267 if ( strtolower($v) == 'infinite' ) {
268 $this->timeout
= (isset($c->maximum_lock_timeout
) ?
$c->maximum_lock_timeout
: 86400 * 100);
271 elseif ( strtolower(substr($v,0,7)) == 'second-' ) {
272 $this->timeout
= min( intval(substr($v,7)), (isset($c->maximum_lock_timeout
) ?
$c->maximum_lock_timeout
: 86400 * 100) );
276 if ( ! isset($this->timeout
) ||
$this->timeout
== 0 ) $this->timeout
= (isset($c->default_lock_timeout
) ?
$c->default_lock_timeout
: 900);
279 $this->principal
= new Principal('path',$this->path
);
282 * RFC2518, 5.2: URL pointing to a collection SHOULD end in '/', and if it does not then
283 * we SHOULD return a Content-location header with the correction...
285 * We therefore look for a collection which matches one of the following URLs:
286 * - The exact request.
287 * - If the exact request, doesn't end in '/', then the request URL with a '/' appended
288 * - The request URL truncated to the last '/'
289 * The collection URL for this request is therefore the longest row in the result, so we
290 * can "... ORDER BY LENGTH(dav_name) DESC LIMIT 1"
292 $sql = "SELECT * FROM collection WHERE dav_name = :exact_name";
293 $params = array( ':exact_name' => $this->path
);
294 if ( !preg_match( '#/$#', $this->path
) ) {
295 $sql .= " OR dav_name = :truncated_name OR dav_name = :trailing_slash_name";
296 $params[':truncated_name'] = preg_replace( '#[^/]*$#', '', $this->path
);
297 $params[':trailing_slash_name'] = $this->path
."/";
299 $sql .= " ORDER BY LENGTH(dav_name) DESC LIMIT 1";
300 $qry = new AwlQuery( $sql, $params );
301 if ( $qry->Exec('caldav',__LINE__
,__FILE__
) && $qry->rows() == 1 && ($row = $qry->Fetch()) ) {
302 if ( $row->dav_name
== $this->path
."/" ) {
303 $this->path
= $row->dav_name
;
304 dbg_error_log( "caldav", "Path is actually a collection - sending Content-Location header." );
305 header( "Content-Location: ".ConstructURL($this->path
) );
308 $this->collection_id
= $row->collection_id
;
309 $this->collection_path
= $row->dav_name
;
310 $this->collection_type
= ($row->is_calendar
== 't' ?
'calendar' : 'collection');
311 $this->collection
= $row;
312 if ( preg_match( '#^((/[^/]+/)\.(in|out)/)[^/]*$#', $this->path
, $matches ) ) {
313 $this->collection_type
= 'schedule-'. $matches[3]. 'box';
315 $this->collection
->type
= $this->collection_type
;
317 else if ( preg_match( '{^( ( / ([^/]+) / ) \.(in|out)/ ) [^/]*$}x', $this->path
, $matches ) ) {
318 // The request is for a scheduling inbox or outbox (or something inside one) and we should auto-create it
319 $params = array( ':username' => $matches[3], ':parent_container' => $matches[2], ':dav_name' => $matches[1] );
320 $params[':boxname'] = ($matches[4] == 'in' ?
' Inbox' : ' Outbox');
321 $this->collection_type
= 'schedule-'. $matches[4]. 'box';
322 $params[':resourcetypes'] = sprintf('<DAV::collection/><urn:ietf:params:xml:ns:caldav:%s/>', $this->collection_type
);
324 INSERT INTO collection ( user_no, parent_container, dav_name, dav_displayname, is_calendar, created, modified, dav_etag, resourcetypes )
325 VALUES( (SELECT user_no FROM usr WHERE username = text(:username)),
326 :parent_container, :dav_name,
327 (SELECT fullname FROM usr WHERE username = text(:username)) || :boxname,
328 FALSE, current_timestamp, current_timestamp, '1', :resourcetypes )
331 $qry = new AwlQuery( $sql, $params );
332 $qry->Exec('caldav',__LINE__
,__FILE__
);
333 dbg_error_log( 'caldav', 'Created new collection as "%s".', trim($params[':boxname']) );
335 // Uncache anything to do with the collection
336 $cache = getCacheInstance();
337 $cache->delete( 'collection-'.$params[':dav_name'], null );
338 $cache->delete( 'principal-'.$params[':parent_container'], null );
340 $qry = new AwlQuery( "SELECT * FROM collection WHERE dav_name = :dav_name", array( ':dav_name' => $matches[1] ) );
341 if ( $qry->Exec('caldav',__LINE__
,__FILE__
) && $qry->rows() == 1 && ($row = $qry->Fetch()) ) {
342 $this->collection_id
= $row->collection_id
;
343 $this->collection_path
= $matches[1];
344 $this->collection
= $row;
345 $this->collection
->type
= $this->collection_type
;
348 else if ( preg_match( '#^((/[^/]+/)calendar-proxy-(read|write))/?[^/]*$#', $this->path
, $matches ) ) {
349 $this->collection_type
= 'proxy';
350 $this->_is_proxy_request
= true;
351 $this->proxy_type
= $matches[3];
352 $this->collection_path
= $matches[1].'/'; // Enforce trailling '/'
353 if ( $this->collection_path
== $this->path
."/" ) {
355 dbg_error_log( "caldav", "Path is actually a (proxy) collection - sending Content-Location header." );
356 header( "Content-Location: ".ConstructURL($this->path
) );
359 else if ( $this->options
['allow_by_email'] && preg_match( '#^/(\S+@\S+[.]\S+)/?$#', $this->path
) ) {
360 /** @todo we should deprecate this now that Evolution 2.27 can do scheduling extensions */
361 $this->collection_id
= -1;
362 $this->collection_type
= 'email';
363 $this->collection_path
= $this->path
;
364 $this->_is_principal
= true;
366 else if ( preg_match( '#^(/[^/]+)/?$#', $this->path
, $matches) ||
preg_match( '#^(/principals/[^/]+/[^/]+)/?$#', $this->path
, $matches) ) {
367 $this->collection_id
= -1;
368 $this->collection_path
= $matches[1].'/'; // Enforce trailling '/'
369 $this->collection_type
= 'principal';
370 $this->_is_principal
= true;
371 if ( $this->collection_path
== $this->path
."/" ) {
373 dbg_error_log( "caldav", "Path is actually a collection - sending Content-Location header." );
374 header( "Content-Location: ".ConstructURL($this->path
) );
376 if ( preg_match( '#^(/principals/[^/]+/[^/]+)/?$#', $this->path
, $matches) ) {
377 // Force a depth of 0 on these, which are at the wrong URL.
381 else if ( $this->path
== '/' ) {
382 $this->collection_id
= -1;
383 $this->collection_path
= '/';
384 $this->collection_type
= 'root';
387 if ( $this->collection_path
== $this->path
) $this->_is_collection
= true;
388 dbg_error_log( "caldav", " Collection '%s' is %d, type %s", $this->collection_path
, $this->collection_id
, $this->collection_type
);
391 * Extract the user whom we are accessing
393 $this->principal
= new DAVPrincipal( array( "path" => $this->path
, "options" => $this->options
) );
394 $this->user_no
= $this->principal
->user_no();
395 $this->username
= $this->principal
->username();
396 $this->by_email
= $this->principal
->byEmail();
397 $this->principal_id
= $this->principal
->principal_id();
399 if ( $this->collection_type
== 'principal' ||
$this->collection_type
== 'email' ||
$this->collection_type
== 'proxy' ) {
400 $this->collection
= $this->principal
->AsCollection();
401 if( $this->collection_type
== 'proxy' ) {
402 $this->collection
= $this->principal
->AsCollection();
403 $this->collection
->is_proxy
= 't';
404 $this->collection
->type
= 'proxy';
405 $this->collection
->proxy_type
= $this->proxy_type
;
406 $this->collection
->dav_displayname
= sprintf('Proxy %s for %s', $this->proxy_type
, $this->principal
->username() );
409 elseif( $this->collection_type
== 'root' ) {
410 $this->collection
= (object) array(
411 'collection_id' => 0,
413 'dav_etag' => md5($c->system_name
),
414 'is_calendar' => 'f',
415 'is_addressbook' => 'f',
416 'is_principal' => 'f',
418 'dav_displayname' => $c->system_name
,
420 'created' => date('Ymd\THis')
425 * Evaluate our permissions for accessing the target
427 $this->setPermissions();
429 $this->supported_methods
= array(
439 if ( $this->IsCollection() ) {
440 switch ( $this->collection_type
) {
443 // We just override the list completely here.
444 $this->supported_methods
= array(
450 case 'schedule-inbox':
451 case 'schedule-outbox':
452 $this->supported_methods
= array_merge(
453 $this->supported_methods
,
455 'POST' => '', 'GET' => '', 'PUT' => '', 'HEAD' => '', 'PROPPATCH' => ''
460 $this->supported_methods
['GET'] = '';
461 $this->supported_methods
['PUT'] = '';
462 $this->supported_methods
['HEAD'] = '';
466 $this->supported_methods
['GET'] = '';
467 $this->supported_methods
['PUT'] = '';
468 $this->supported_methods
['HEAD'] = '';
469 $this->supported_methods
['MKCOL'] = '';
470 $this->supported_methods
['MKCALENDAR'] = '';
471 $this->supported_methods
['PROPPATCH'] = '';
472 $this->supported_methods
['BIND'] = '';
477 $this->supported_methods
= array_merge(
478 $this->supported_methods
,
487 $this->supported_reports
= array(
488 'DAV::principal-property-search' => '',
489 'DAV::expand-property' => '',
490 'DAV::sync-collection' => ''
492 if ( isset($this->collection
) && $this->collection
->is_calendar
) {
493 $this->supported_reports
= array_merge(
494 $this->supported_reports
,
496 'urn:ietf:params:xml:ns:caldav:calendar-query' => '',
497 'urn:ietf:params:xml:ns:caldav:calendar-multiget' => '',
498 'urn:ietf:params:xml:ns:caldav:free-busy-query' => ''
502 if ( isset($this->collection
) && $this->collection
->is_addressbook
) {
503 $this->supported_reports
= array_merge(
504 $this->supported_reports
,
506 'urn:ietf:params:xml:ns:carddav:addressbook-query' => '',
507 'urn:ietf:params:xml:ns:carddav:addressbook-multiget' => ''
514 * If the content we are receiving is XML then we parse it here. RFC2518 says we
515 * should reasonably expect to see either text/xml or application/xml
517 if ( isset($this->content_type
) && preg_match( '#(application|text)/xml#', $this->content_type
) ) {
518 $xml_parser = xml_parser_create_ns('UTF-8');
519 $this->xml_tags
= array();
520 xml_parser_set_option ( $xml_parser, XML_OPTION_SKIP_WHITE
, 1 );
521 xml_parser_set_option ( $xml_parser, XML_OPTION_CASE_FOLDING
, 0 );
522 $rc = xml_parse_into_struct( $xml_parser, $this->raw_post
, $this->xml_tags
);
523 if ( $rc == false ) {
524 dbg_error_log( 'ERROR', 'XML parsing error: %s at line %d, column %d',
525 xml_error_string(xml_get_error_code($xml_parser)),
526 xml_get_current_line_number($xml_parser), xml_get_current_column_number($xml_parser) );
527 $this->XMLResponse( 400, new XMLElement( 'error', new XMLElement('invalid-xml'), array( 'xmlns' => 'DAV:') ) );
529 xml_parser_free($xml_parser);
530 if ( count($this->xml_tags
) ) {
531 dbg_error_log( "caldav", " Parsed incoming XML request body." );
534 $this->xml_tags
= null;
535 dbg_error_log( "ERROR", "Incoming request sent content-type XML with no XML request body." );
540 * Look out for If-None-Match or If-Match headers
542 if ( isset($_SERVER["HTTP_IF_NONE_MATCH"]) ) {
543 $this->etag_none_match
= $_SERVER["HTTP_IF_NONE_MATCH"];
544 if ( $this->etag_none_match
== '' ) unset($this->etag_none_match
);
546 if ( isset($_SERVER["HTTP_IF_MATCH"]) ) {
547 $this->etag_if_match
= $_SERVER["HTTP_IF_MATCH"];
548 if ( $this->etag_if_match
== '' ) unset($this->etag_if_match
);
554 * Permissions are controlled as follows:
555 * 1. if the path is '/', the request has read privileges
556 * 2. if the requester is an admin, the request has read/write priviliges
557 * 3. if there is a <user name> component which matches the logged on user
558 * then the request has read/write privileges
559 * 4. otherwise we query the defined relationships between users and use
560 * the minimum privileges returned from that analysis.
562 * @param int $user_no The current user number
565 function setPermissions() {
568 if ( $this->path
== '/' ||
$this->path
== '' ) {
569 $this->privileges
= privilege_to_bits( array('read','read-free-busy','read-acl'));
570 dbg_error_log( "caldav", "Full read permissions for user accessing /" );
572 else if ( $session->AllowedTo("Admin") ||
$session->principal
->user_no() == $this->user_no
) {
573 $this->privileges
= privilege_to_bits('all');
574 dbg_error_log( "caldav", "Full permissions for %s", ( $session->principal
->user_no() == $this->user_no ?
"user accessing their own hierarchy" : "a systems administrator") );
577 $this->privileges
= 0;
578 if ( $this->IsPublic() ) {
579 $this->privileges
= privilege_to_bits(array('read','read-free-busy'));
580 dbg_error_log( "caldav", "Basic read permissions for user accessing a public collection" );
582 else if ( isset($c->public_freebusy_url
) && $c->public_freebusy_url
) {
583 $this->privileges
= privilege_to_bits('read-free-busy');
584 dbg_error_log( "caldav", "Basic free/busy permissions for user accessing a public free/busy URL" );
588 * In other cases we need to query the database for permissions
590 $params = array( ':session_principal_id' => $session->principal
->principal_id(), ':scan_depth' => $c->permission_scan_depth
);
591 if ( isset($this->by_email
) && $this->by_email
) {
592 $sql ='SELECT pprivs( :session_principal_id::int8, :request_principal_id::int8, :scan_depth::int ) AS perm';
593 $params[':request_principal_id'] = $this->principal_id
;
596 $sql = 'SELECT path_privs( :session_principal_id::int8, :request_path::text, :scan_depth::int ) AS perm';
597 $params[':request_path'] = $this->path
;
599 $qry = new AwlQuery( $sql, $params );
600 if ( $qry->Exec('caldav',__LINE__
,__FILE__
) && $permission_result = $qry->Fetch() )
601 $this->privileges |
= bindec($permission_result->perm
);
603 dbg_error_log( 'caldav', 'Restricted permissions for user accessing someone elses hierarchy: %s', decbin($this->privileges
) );
604 if ( isset($this->ticket
) && $this->ticket
->MatchesPath($this->path
) ) {
605 $this->privileges |
= $this->ticket
->privileges();
606 dbg_error_log( 'caldav', 'Applying permissions for ticket "%s" now: %s', $this->ticket
->id(), decbin($this->privileges
) );
610 /** convert privileges into older style permissions */
611 $this->permissions
= array();
612 $privs = bits_to_privilege($this->privileges
);
613 foreach( $privs AS $k => $v ) {
615 case 'DAV::all': $type = 'abstract'; break;
616 case 'DAV::write': $type = 'aggregate'; break;
617 default: $type = 'real';
619 $v = str_replace('DAV::', '', $v);
620 $this->permissions
[$v] = $type;
627 * Checks whether the resource is locked, returning any lock token, or false
629 * @todo This logic does not catch all locking scenarios. For example an infinite
630 * depth request should check the permissions for all collections and resources within
631 * that. At present we only maintain permissions on a per-collection basis though.
633 function IsLocked() {
634 if ( !isset($this->_locks_found
) ) {
635 $this->_locks_found
= array();
637 $sql = 'DELETE FROM locks WHERE (start + timeout) < current_timestamp';
638 $qry = new AwlQuery($sql);
639 $qry->Exec('caldav',__LINE__
,__FILE__
);
642 * Find the locks that might apply and load them into an array
644 $sql = 'SELECT * FROM locks WHERE :dav_name::text ~ (\'^\'||dav_name||:pattern_end_match)::text';
645 $qry = new AwlQuery($sql, array( ':dav_name' => $this->path
, ':pattern_end_match' => ($this->IsInfiniteDepth() ?
'' : '$') ) );
646 if ( $qry->Exec('caldav',__LINE__
,__FILE__
) ) {
647 while( $lock_row = $qry->Fetch() ) {
648 $this->_locks_found
[$lock_row->opaquelocktoken
] = $lock_row;
652 $this->DoResponse(500,translate("Database Error"));
657 foreach( $this->_locks_found
AS $lock_token => $lock_row ) {
658 if ( $lock_row->depth
== DEPTH_INFINITY ||
$lock_row->dav_name
== $this->path
) {
663 return false; // Nothing matched
668 * Checks whether the collection is public
670 function IsPublic() {
671 if ( isset($this->collection
) && isset($this->collection
->publicly_readable
) && $this->collection
->publicly_readable
== 't' ) {
678 private static function supportedPrivileges() {
681 'read' => translate('Read the content of a resource or collection'),
683 'bind' => translate('Create a resource or collection'),
684 'unbind' => translate('Delete a resource or collection'),
685 'write-content' => translate('Write content'),
686 'write-properties' => translate('Write properties')
688 'urn:ietf:params:xml:ns:caldav:read-free-busy' => translate('Read the free/busy information for a calendar collection'),
689 'read-acl' => translate('Read ACLs for a resource or collection'),
690 'read-current-user-privilege-set' => translate('Read the details of the current user\'s access control to this resource.'),
691 'write-acl' => translate('Write ACLs for a resource or collection'),
692 'unlock' => translate('Remove a lock'),
694 'urn:ietf:params:xml:ns:caldav:schedule-deliver' => array(
695 'urn:ietf:params:xml:ns:caldav:schedule-deliver-invite'=> translate('Deliver scheduling invitations from an organiser to this scheduling inbox'),
696 'urn:ietf:params:xml:ns:caldav:schedule-deliver-reply' => translate('Deliver scheduling replies from an attendee to this scheduling inbox'),
697 'urn:ietf:params:xml:ns:caldav:schedule-query-freebusy' => translate('Allow free/busy enquiries targeted at the owner of this scheduling inbox')
700 'urn:ietf:params:xml:ns:caldav:schedule-send' => array(
701 'urn:ietf:params:xml:ns:caldav:schedule-send-invite' => translate('Send scheduling invitations as an organiser from the owner of this scheduling outbox.'),
702 'urn:ietf:params:xml:ns:caldav:schedule-send-reply' => translate('Send scheduling replies as an attendee from the owner of this scheduling outbox.'),
703 'urn:ietf:params:xml:ns:caldav:schedule-send-freebusy' => translate('Send free/busy enquiries')
710 * Returns the dav_name of the resource in our internal namespace
712 function dav_name() {
713 if ( isset($this->path
) ) return $this->path
;
719 * Returns the name for this depth: 0, 1, infinity
721 function GetDepthName( ) {
722 if ( $this->IsInfiniteDepth() ) return 'infinity';
727 * Returns the tail of a Regex appropriate for this Depth, when appended to
730 function DepthRegexTail() {
731 if ( $this->IsInfiniteDepth() ) return '';
732 if ( $this->depth
== 0 ) return '$';
737 * Returns the locked row, either from the cache or from the database
739 * @param string $dav_name The resource which we want to know the lock status for
741 function GetLockRow( $lock_token ) {
742 if ( isset($this->_locks_found
) && isset($this->_locks_found
[$lock_token]) ) {
743 return $this->_locks_found
[$lock_token];
746 $qry = new AwlQuery('SELECT * FROM locks WHERE opaquelocktoken = :lock_token', array( ':lock_token' => $lock_token ) );
747 if ( $qry->Exec('caldav',__LINE__
,__FILE__
) ) {
748 $lock_row = $qry->Fetch();
749 $this->_locks_found
= array( $lock_token => $lock_row );
750 return $this->_locks_found
[$lock_token];
753 $this->DoResponse( 500, translate("Database Error") );
756 return false; // Nothing matched
761 * Checks to see whether the lock token given matches one of the ones handed in
764 * @param string $lock_token The opaquelocktoken which we are looking for
766 function ValidateLockToken( $lock_token ) {
767 if ( isset($this->lock_token
) && $this->lock_token
== $lock_token ) {
768 dbg_error_log( "caldav", "They supplied a valid lock token. Great!" );
771 if ( isset($this->if_clause
) ) {
772 dbg_error_log( "caldav", "Checking lock token '%s' against '%s'", $lock_token, $this->if_clause
);
773 $tokens = preg_split( '/[<>]/', $this->if_clause
);
774 foreach( $tokens AS $k => $v ) {
775 dbg_error_log( "caldav", "Checking lock token '%s' against '%s'", $lock_token, $v );
776 if ( 'opaquelocktoken:' == substr( $v, 0, 16 ) ) {
777 if ( substr( $v, 16 ) == $lock_token ) {
778 dbg_error_log( "caldav", "Lock token '%s' validated OK against '%s'", $lock_token, $v );
785 @dbg_error_log
( "caldav", "Invalid lock token '%s' - not in Lock-token (%s) or If headers (%s) ", $lock_token, $this->lock_token
, $this->if_clause
);
793 * Returns the DB object associated with a lock token, or false.
795 * @param string $lock_token The opaquelocktoken which we are looking for
797 function GetLockDetails( $lock_token ) {
798 if ( !isset($this->_locks_found
) && false === $this->IsLocked() ) return false;
799 if ( isset($this->_locks_found
[$lock_token]) ) return $this->_locks_found
[$lock_token];
805 * This will either (a) return false if no locks apply, or (b) return the lock_token
806 * which the request successfully included to open the lock, or:
807 * (c) respond directly to the client with the failure.
809 * @return mixed false (no lock) or opaquelocktoken (opened lock)
811 function FailIfLocked() {
812 if ( $existing_lock = $this->IsLocked() ) { // NOTE Assignment in if() is expected here.
813 dbg_error_log( "caldav", "There is a lock on '%s'", $this->path
);
814 if ( ! $this->ValidateLockToken($existing_lock) ) {
815 $lock_row = $this->GetLockRow($existing_lock);
817 * Already locked - deny it
819 $response[] = new XMLElement( 'response', array(
820 new XMLElement( 'href', $lock_row->dav_name
),
821 new XMLElement( 'status', 'HTTP/1.1 423 Resource Locked')
823 if ( $lock_row->dav_name
!= $this->path
) {
824 $response[] = new XMLElement( 'response', array(
825 new XMLElement( 'href', $this->path
),
826 new XMLElement( 'propstat', array(
827 new XMLElement( 'prop', new XMLElement( 'lockdiscovery' ) ),
828 new XMLElement( 'status', 'HTTP/1.1 424 Failed Dependency')
832 $response = new XMLElement( "multistatus", $response, array('xmlns'=>'DAV:') );
833 $xmldoc = $response->Render(0,'<?xml version="1.0" encoding="utf-8" ?>');
834 $this->DoResponse( 207, $xmldoc, 'text/xml; charset="utf-8"' );
835 // Which we won't come back from
837 return $existing_lock;
844 * Coerces the Content-type of the request into something valid/appropriate
846 function CoerceContentType() {
847 if ( isset($this->content_type
) ) {
848 $type = explode( '/', $this->content_type
, 2);
849 /** @todo: Perhaps we should look at the target collection type, also. */
850 if ( $type[0] == 'text' ) {
851 if ( !empty($type[1]) && ($type[1] == 'vcard' ||
$type[1] == 'calendar' ||
$type[1] == 'x-vcard') ) {
857 /** Null (or peculiar) content-type supplied so we have to try and work it out... */
858 $first_word = trim(substr( $this->raw_post
, 0, 30));
859 $first_word = strtoupper( preg_replace( '/\s.*/s', '', $first_word ) );
860 switch( $first_word ) {
862 dbg_error_log( 'LOG WARNING', 'Application sent content-type of "%s" instead of "text/xml"',
863 (isset($this->content_type
)?
$this->content_type
:'(null)') );
864 $this->content_type
= 'text/xml';
866 case 'BEGIN:VCALENDAR':
867 dbg_error_log( 'LOG WARNING', 'Application sent content-type of "%s" instead of "text/calendar"',
868 (isset($this->content_type
)?
$this->content_type
:'(null)') );
869 $this->content_type
= 'text/calendar';
872 dbg_error_log( 'LOG WARNING', 'Application sent content-type of "%s" instead of "text/vcard"',
873 (isset($this->content_type
)?
$this->content_type
:'(null)') );
874 $this->content_type
= 'text/vcard';
877 dbg_error_log( 'LOG NOTICE', 'Unusual content-type of "%s" and first word of content is "%s"',
878 (isset($this->content_type
)?
$this->content_type
:'(null)'), $first_word );
880 if ( empty($this->content_type
) ) $this->content_type
= 'text/plain';
885 * Returns true if the URL referenced by this request points at a collection.
887 function IsCollection( ) {
888 if ( !isset($this->_is_collection
) ) {
889 $this->_is_collection
= preg_match( '#/$#', $this->path
);
891 return $this->_is_collection
;
896 * Returns true if the URL referenced by this request points at a calendar collection.
898 function IsCalendar( ) {
899 if ( !$this->IsCollection() ||
!isset($this->collection
) ) return false;
900 return $this->collection
->is_calendar
== 't';
905 * Returns true if the URL referenced by this request points at an addressbook collection.
907 function IsAddressBook( ) {
908 if ( !$this->IsCollection() ||
!isset($this->collection
) ) return false;
909 return $this->collection
->is_addressbook
== 't';
914 * Returns true if the URL referenced by this request points at a principal.
916 function IsPrincipal( ) {
917 if ( !isset($this->_is_principal
) ) {
918 $this->_is_principal
= preg_match( '#^/[^/]+/$#', $this->path
);
920 return $this->_is_principal
;
925 * Returns true if the URL referenced by this request is within a proxy URL
927 function IsProxyRequest( ) {
928 if ( !isset($this->_is_proxy_request
) ) {
929 $this->_is_proxy_request
= preg_match( '#^/[^/]+/calendar-proxy-(read|write)/?[^/]*$#', $this->path
);
931 return $this->_is_proxy_request
;
936 * Returns true if the request asked for infinite depth
938 function IsInfiniteDepth( ) {
939 return ($this->depth
== DEPTH_INFINITY
);
944 * Returns the ID of the collection of, or containing this request
946 function CollectionId( ) {
947 return $this->collection_id
;
952 * Returns the array of supported privileges converted into XMLElements
954 function BuildSupportedPrivileges( &$reply, $privs = null ) {
955 $privileges = array();
956 if ( $privs === null ) $privs = self
::supportedPrivileges();
957 foreach( $privs AS $k => $v ) {
958 dbg_error_log( 'caldav', 'Adding privilege "%s" which is "%s".', $k, $v );
959 $privilege = new XMLElement('privilege');
960 $reply->NSElement($privilege,$k);
961 $privset = array($privilege);
962 if ( is_array($v) ) {
963 dbg_error_log( 'caldav', '"%s" is a container of sub-privileges.', $k );
964 $privset = array_merge($privset, $this->BuildSupportedPrivileges($reply,$v));
966 else if ( $v == 'abstract' ) {
967 dbg_error_log( 'caldav', '"%s" is an abstract privilege.', $v );
968 $privset[] = new XMLElement('abstract');
970 else if ( strlen($v) > 1 ) {
971 $privset[] = new XMLElement('description', $v);
973 $privileges[] = new XMLElement('supported-privilege',$privset);
980 * Are we allowed to do the requested activity
982 * +------------+------------------------------------------------------+
983 * | METHOD | PRIVILEGES |
984 * +------------+------------------------------------------------------+
985 * | MKCALENDAR | DAV:bind |
986 * | REPORT | DAV:read or CALDAV:read-free-busy (on all referenced |
988 * +------------+------------------------------------------------------+
990 * @param string $activity The activity we want to do.
992 function AllowedTo( $activity ) {
994 dbg_error_log('caldav', 'Checking whether "%s" is allowed to "%s"', $session->principal
->username(), $activity);
995 if ( isset($this->permissions
['all']) ) return true;
996 switch( $activity ) {
998 return false; // If they got this far then they don't
1001 case "CALDAV:schedule-send-freebusy":
1002 return isset($this->permissions
['read']) ||
isset($this->permissions
['urn:ietf:params:xml:ns:caldav:read-free-busy']);
1005 case "CALDAV:schedule-send-invite":
1006 return isset($this->permissions
['read']) ||
isset($this->permissions
['urn:ietf:params:xml:ns:caldav:read-free-busy']);
1009 case "CALDAV:schedule-send-reply":
1010 return isset($this->permissions
['read']) ||
isset($this->permissions
['urn:ietf:params:xml:ns:caldav:read-free-busy']);
1014 return isset($this->permissions
['read']) ||
isset($this->permissions
['urn:ietf:params:xml:ns:caldav:read-free-busy']);
1018 return isset($this->permissions
['write']) ||
isset($this->permissions
['unbind']);
1022 return isset($this->permissions
['write']) ||
isset($this->permissions
['write-properties']);
1026 return isset($this->permissions
['write']) ||
isset($this->permissions
['write-content']);
1030 return isset($this->permissions
['write']) ||
isset($this->permissions
['bind']);
1035 if ( !isset($this->permissions
['write']) ||
!isset($this->permissions
['bind']) ) return false;
1036 if ( $this->is_principal
) return false;
1037 if ( $this->path
== '/' ) return false;
1041 $test_bits = privilege_to_bits( $activity );
1042 // dbg_error_log( 'caldav', 'request::AllowedTo("%s") (%s) against allowed "%s" => "%s" (%s)',
1043 // (is_array($activity) ? implode(',',$activity) : $activity), decbin($test_bits),
1044 // decbin($this->privileges), ($this->privileges & $test_bits), decbin($this->privileges & $test_bits) );
1045 return (($this->privileges
& $test_bits) > 0 );
1055 * Return the privileges bits for the current session user to this resource
1057 function Privileges() {
1058 return $this->privileges
;
1063 * Is the user has the privileges to do what is requested.
1065 function HavePrivilegeTo( $do_what ) {
1066 $test_bits = privilege_to_bits( $do_what );
1067 // dbg_error_log( 'caldav', 'request::HavePrivilegeTo("%s") [%s] against allowed "%s" => "%s" (%s)',
1068 // (is_array($do_what) ? implode(',',$do_what) : $do_what), decbin($test_bits),
1069 // decbin($this->privileges), ($this->privileges & $test_bits), decbin($this->privileges & $test_bits) );
1070 return ($this->privileges
& $test_bits) > 0;
1075 * Sometimes it's a perfectly formed request, but we just don't do that :-(
1076 * @param array $unsupported An array of the properties we don't support.
1078 function UnsupportedRequest( $unsupported ) {
1079 if ( isset($unsupported) && count($unsupported) > 0 ) {
1080 $badprops = new XMLElement( "prop" );
1081 foreach( $unsupported AS $k => $v ) {
1082 // Not supported at this point...
1083 dbg_error_log("ERROR", " %s: Support for $v:$k properties is not implemented yet", $this->method
);
1084 $badprops->NewElement(strtolower($k),false,array("xmlns" => strtolower($v)));
1086 $error = new XMLElement("error", $badprops, array("xmlns" => "DAV:") );
1088 $this->XMLResponse( 422, $error );
1094 * Send a need-privileges error response. This function will only return
1095 * if the $href is not supplied and the current user has the specified
1096 * permission for the request path.
1098 * @param string $privilege The name of the needed privilege.
1099 * @param string $href The unconstructed URI where we needed the privilege.
1101 function NeedPrivilege( $privileges, $href=null ) {
1102 if ( is_string($privileges) ) $privileges = array( $privileges );
1103 if ( !isset($href) ) {
1104 if ( $this->HavePrivilegeTo($privileges) ) return;
1105 $href = $this->path
;
1108 $reply = new XMLDocument( array('DAV:' => '') );
1109 $privnodes = array( $reply->href(ConstructURL($href)), new XMLElement( 'privilege' ) );
1110 // RFC3744 specifies that we can only respond with one needed privilege, so we pick the first.
1111 $reply->NSElement( $privnodes[1], $privileges[0] );
1112 $xml = new XMLElement( 'need-privileges', new XMLElement( 'resource', $privnodes) );
1113 $xmldoc = $reply->Render('error',$xml);
1114 $this->DoResponse( 403, $xmldoc, 'text/xml; charset="utf-8"' );
1115 exit(0); // Unecessary, but might clarify things
1120 * Send an error response for a failed precondition.
1122 * @param int $status The status code for the failed precondition. Normally 403
1123 * @param string $precondition The namespaced precondition tag.
1124 * @param string $explanation An optional text explanation for the failure.
1126 function PreconditionFailed( $status, $precondition, $explanation = '', $xmlns='DAV:') {
1127 $xmldoc = sprintf('<?xml version="1.0" encoding="utf-8" ?>
1130 </error>', $xmlns, str_replace($xmlns.':', '', $precondition), $explanation );
1132 $this->DoResponse( $status, $xmldoc, 'text/xml; charset="utf-8"' );
1133 exit(0); // Unecessary, but might clarify things
1138 * Send a simple error informing the client that was a malformed request
1140 * @param string $text An optional text description of the failure.
1142 function MalformedRequest( $text = 'Bad request' ) {
1143 $this->DoResponse( 400, $text );
1144 exit(0); // Unecessary, but might clarify things
1149 * Send an XML Response. This function will never return.
1151 * @param int $status The HTTP status to respond
1152 * @param XMLElement $xmltree An XMLElement tree to be rendered
1154 function XMLResponse( $status, $xmltree ) {
1155 $xmldoc = $xmltree->Render(0,'<?xml version="1.0" encoding="utf-8" ?>');
1156 $etag = md5($xmldoc);
1157 header("ETag: \"$etag\"");
1158 $this->DoResponse( $status, $xmldoc, 'text/xml; charset="utf-8"' );
1159 exit(0); // Unecessary, but might clarify things
1163 * Utility function we call when we have a simple status-based response to
1164 * return to the client. Possibly
1166 * @param int $status The HTTP status code to send.
1167 * @param string $message The friendly text message to send with the response.
1169 function DoResponse( $status, $message="", $content_type="text/plain; charset=\"utf-8\"" ) {
1170 global $session, $c;
1171 @header
( sprintf("HTTP/1.1 %d %s", $status, getStatusMessage($status)) );
1172 @header
( sprintf("X-DAViCal-Version: DAViCal/%d.%d.%d; DB/%d.%d.%d", $c->code_major
, $c->code_minor
, $c->code_patch
, $c->schema_major
, $c->schema_minor
, $c->schema_patch
) );
1173 @header
( "Content-type: ".$content_type );
1175 if ( (isset($c->dbg
['ALL']) && $c->dbg
['ALL']) ||
(isset($c->dbg
['response']) && $c->dbg
['response']) ||
$status > 399 ) {
1176 $lines = headers_list();
1177 dbg_error_log( "LOG ", "***************** Response Header ****************" );
1178 foreach( $lines AS $v ) {
1179 dbg_error_log( "LOG headers", "-->%s", $v );
1181 dbg_error_log( "LOG ", "******************** Response ********************" );
1182 // Log the request in all it's gory detail.
1183 $lines = preg_split( '#[\r\n]+#', $message);
1184 foreach( $lines AS $v ) {
1185 dbg_error_log( "LOG response", "-->%s", $v );
1189 header( "Content-Length: ".strlen($message) );
1192 if ( isset($c->dbg
['caldav']) && $c->dbg
['caldav'] ) {
1193 if ( strlen($message) > 100 ||
strstr($message, "\n") ) {
1194 $message = substr( preg_replace("#\s+#m", ' ', $message ), 0, 100) . (strlen($message) > 100 ?
"..." : "");
1197 dbg_error_log("caldav", "Status: %d, Message: %s, User: %d, Path: %s", $status, $message, $session->principal
->user_no(), $this->path
);
1199 if ( isset($c->dbg
['statistics']) && $c->dbg
['statistics'] ) {
1200 $script_time = microtime(true) - $c->script_start_time
;
1201 @dbg_error_log
("statistics", "Method: %s, Status: %d, Script: %5.3lfs, Queries: %5.3lfs, URL: %s",
1202 $this->method
, $status, $script_time, $c->total_query_time
, $this->path
);