DAViCal
CalDAVRequest.php
1<?php
17require_once("AwlCache.php");
18require_once("XMLDocument.php");
19require_once("DAVPrincipal.php");
20require_once("DAVTicket.php");
21
22define('DEPTH_INFINITY', 9999);
23
24
31{
32 var $options;
33
38
43
48 var $depth;
49
54 var $principal;
55
61
66
71
76
82
87 protected $exists;
88
93
97 protected $privileges;
98
103
107 public $ticket;
108
113 private $prefer;
114
115 /* These fields were being added dynmically. I initially tried making them
116 * private, but many tests failed in odd ways. Having them public matches
117 * the existing behaviour.
118 */
119 public $path;
120 public $content_type;
121 public $overwrite;
122 public $collection;
123 public $user_no;
124 public $username;
125 public $by_email;
126 public $principal_id;
127 public $permissions;
128 public $_is_collection;
129 public $_is_principal;
130 public $_is_proxy_request;
131 public $_locks_found;
132 public $xml_tags;
133 public $etag_if_match;
134 public $etag_none_match;
135 public $proxy_type;
136 public $if_clause;
137 public $lock_token;
138 public $timeout;
139
143 function __construct( $options = array() ) {
144 global $session, $c, $debugging;
145
146 $this->options = $options;
147 if ( !isset($this->options['allow_by_email']) ) $this->options['allow_by_email'] = false;
148
149 if ( isset($_SERVER['HTTP_PREFER']) ) {
150 $this->prefer = explode( ',', $_SERVER['HTTP_PREFER']);
151 }
152 else if ( isset($_SERVER['HTTP_BRIEF']) && (strtoupper($_SERVER['HTTP_BRIEF']) == 'T') ) {
153 $this->prefer = array( 'return=minimal');
154 }
155 else
156 $this->prefer = array();
157
171 if ( isset($_SERVER['PATH_INFO']) ) {
172 $this->path = $_SERVER['PATH_INFO'];
173 }
174 else {
175 $this->path = '/';
176 if ( isset($_SERVER['REQUEST_URI']) ) {
177 if ( preg_match( '{^(.*?\.php)([^?]*)}', $_SERVER['REQUEST_URI'], $matches ) ) {
178 $this->path = $matches[2];
179 if ( substr($this->path,0,1) != '/' )
180 $this->path = '/'.$this->path;
181 }
182 /* support php_fpm under Apache 2.4 */
183 else if ( isset($_SERVER['REQUEST_URI']) && isset($_SERVER['SCRIPT_NAME'] ) ) {
184 if ( preg_match( '{^(.*?\.php)([^?]*)}', $_SERVER['SCRIPT_NAME'] ) ) {
185 $this->path = $_SERVER['REQUEST_URI'];
186 }
187 }
188 else if ( $_SERVER['REQUEST_URI'] != '/' ) {
189 dbg_error_log('LOG', 'Server is not supplying PATH_INFO and REQUEST_URI does not include a PHP program. Wildly guessing "/"!!!');
190 }
191 }
192 }
193 $this->path = rawurldecode($this->path);
194
196 if ( preg_match( '#^(/[^/]+/[^/]+).ics$#', $this->path, $matches ) ) {
197 $this->path = $matches[1]. '/';
198 }
199
200 if ( isset($c->replace_path) && isset($c->replace_path['from']) && isset($c->replace_path['to']) ) {
201 $this->path = preg_replace($c->replace_path['from'], $c->replace_path['to'], $this->path);
202 }
203
204 // dbg_error_log( "caldav", "Sanitising path '%s'", $this->path );
205 $bad_chars_regex = '/[\\^\\[\\(\\\\]/';
206 if ( preg_match( $bad_chars_regex, $this->path ) ) {
207 $this->DoResponse( 400, translate("The calendar path contains illegal characters.") );
208 }
209 if ( strstr($this->path,'//') ) $this->path = preg_replace( '#//+#', '/', $this->path);
210
211 if ( !isset($c->raw_post) ) $c->raw_post = file_get_contents( 'php://input');
212 if ( isset($_SERVER['HTTP_CONTENT_ENCODING']) ) {
213 $encoding = $_SERVER['HTTP_CONTENT_ENCODING'];
214 @dbg_error_log('caldav', 'Content-Encoding: %s', $encoding );
215 $encoding = preg_replace('{[^a-z0-9-]}i','',$encoding);
216 if ( ! ini_get('open_basedir') && (isset($c->dbg['ALL']) || isset($c->dbg['caldav'])) ) {
217 $fh = fopen('/var/log/davical/encoded_data.debug'.$encoding,'w');
218 if ( $fh ) {
219 fwrite($fh,$c->raw_post);
220 fclose($fh);
221 }
222 }
223 switch( $encoding ) {
224 case 'gzip':
225 $this->raw_post = @gzdecode($c->raw_post);
226 break;
227 case 'deflate':
228 $this->raw_post = @gzinflate($c->raw_post);
229 break;
230 case 'compress':
231 $this->raw_post = @gzuncompress($c->raw_post);
232 break;
233 default:
234 }
235 if ( empty($this->raw_post) && !empty($c->raw_post) ) {
236 $this->PreconditionFailed(415, 'content-encoding', sprintf('Unable to decode "%s" content encoding.', $_SERVER['HTTP_CONTENT_ENCODING']));
237 }
238 $c->raw_post = $this->raw_post;
239 }
240 else {
241 $this->raw_post = $c->raw_post;
242 }
243
244 if ( isset($debugging) && isset($_GET['method']) ) {
245 $_SERVER['REQUEST_METHOD'] = $_GET['method'];
246 }
247 else if ( $_SERVER['REQUEST_METHOD'] == 'POST' && isset($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE']) ){
248 $_SERVER['REQUEST_METHOD'] = $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'];
249 }
250 $this->method = $_SERVER['REQUEST_METHOD'];
251 if (isset($_SERVER['CONTENT_TYPE'])) {
252 $this->content_type = $_SERVER['CONTENT_TYPE'];
253 if ( preg_match( '{^(\S+/\S+?)\s*(;.*)?$}', $this->content_type, $matches ) ) {
254 $this->content_type = $matches[1];
255 }
256 } else {
257 $this->content_type = null;
258 }
259 if ( strlen($c->raw_post) > 0 ) {
260 if ( $this->method == 'PROPFIND' || $this->method == 'REPORT' || $this->method == 'PROPPATCH' || $this->method == 'BIND' || $this->method == 'MKTICKET' || $this->method == 'ACL' ) {
261 if ( !preg_match( '{^(text|application)/xml$}', $this->content_type ) ) {
262 @dbg_error_log( "LOG request", 'Request is "%s" but client set content-type to "%s". Assuming they meant XML!',
263 $this->method, $this->content_type );
264 $this->content_type = 'text/xml';
265 }
266 }
267 else if ( $this->method == 'PUT' || $this->method == 'POST' ) {
268 $this->CoerceContentType();
269 }
270 }
271 else {
272 $this->content_type = 'text/plain';
273 }
274 $this->user_agent = ((isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : "Probably Mulberry"));
275
279 if ( isset($_SERVER['HTTP_DEPTH']) ) {
280 $this->depth = $_SERVER['HTTP_DEPTH'];
281 }
282 else {
288 switch( $this->method ) {
289 case 'DELETE':
290 case 'MOVE':
291 case 'COPY':
292 case 'LOCK':
293 $this->depth = 'infinity';
294 break;
295
296 case 'REPORT':
297 $this->depth = 0;
298 break;
299
300 case 'PROPFIND':
301 default:
302 $this->depth = 0;
303 }
304 }
305 if ( !is_int($this->depth) && "infinity" == $this->depth ) $this->depth = DEPTH_INFINITY;
306 $this->depth = intval($this->depth);
307
311 if ( isset($_SERVER['HTTP_DESTINATION']) ) {
312 $this->destination = $_SERVER['HTTP_DESTINATION'];
313 if ( preg_match('{^(https?)://([a-z.-]+)(:[0-9]+)?(/.*)$}', $this->destination, $matches ) ) {
314 $this->destination = $matches[4];
315 }
316 }
317 $this->overwrite = ( isset($_SERVER['HTTP_OVERWRITE']) && ($_SERVER['HTTP_OVERWRITE'] == 'F') ? false : true ); // RFC4918, 9.8.4 says default True.
318
322 if ( isset($_SERVER['HTTP_IF']) ) $this->if_clause = $_SERVER['HTTP_IF'];
323 if ( isset($_SERVER['HTTP_LOCK_TOKEN']) && preg_match( '#[<]opaquelocktoken:(.*)[>]#', $_SERVER['HTTP_LOCK_TOKEN'], $matches ) ) {
324 $this->lock_token = $matches[1];
325 }
326
330 if ( isset($_GET['ticket']) ) {
331 $this->ticket = new DAVTicket($_GET['ticket']);
332 }
333 else if ( isset($_SERVER['HTTP_TICKET']) ) {
334 $this->ticket = new DAVTicket($_SERVER['HTTP_TICKET']);
335 }
336
340 if ( isset($_SERVER['HTTP_TIMEOUT']) ) {
341 $timeouts = explode( ',', $_SERVER['HTTP_TIMEOUT'] );
342 foreach( $timeouts AS $k => $v ) {
343 if ( strtolower($v) == 'infinite' ) {
344 $this->timeout = (isset($c->maximum_lock_timeout) ? $c->maximum_lock_timeout : 86400 * 100);
345 break;
346 }
347 elseif ( strtolower(substr($v,0,7)) == 'second-' ) {
348 $this->timeout = min( intval(substr($v,7)), (isset($c->maximum_lock_timeout) ? $c->maximum_lock_timeout : 86400 * 100) );
349 break;
350 }
351 }
352 if ( ! isset($this->timeout) || $this->timeout == 0 ) $this->timeout = (isset($c->default_lock_timeout) ? $c->default_lock_timeout : 900);
353 }
354
355 $this->principal = new Principal('path',$this->path);
356
368 $sql = "SELECT * FROM collection WHERE dav_name = :exact_name";
369 $params = array( ':exact_name' => $this->path );
370 if ( !preg_match( '#/$#', $this->path ) ) {
371 $sql .= " OR dav_name = :truncated_name OR dav_name = :trailing_slash_name";
372 $params[':truncated_name'] = preg_replace( '#[^/]*$#', '', $this->path);
373 $params[':trailing_slash_name'] = $this->path."/";
374 }
375 $sql .= " ORDER BY LENGTH(dav_name) DESC LIMIT 1";
376 $qry = new AwlQuery( $sql, $params );
377 if ( $qry->Exec('caldav',__LINE__,__FILE__) && $qry->rows() == 1 && ($row = $qry->Fetch()) ) {
378 if ( $row->dav_name == $this->path."/" ) {
379 $this->path = $row->dav_name;
380 dbg_error_log( "caldav", "Path is actually a collection - sending Content-Location header." );
381 header( "Content-Location: ".ConstructURL($this->path) );
382 }
383
384 $this->collection_id = $row->collection_id;
385 $this->collection_path = $row->dav_name;
386 $this->collection_type = ($row->is_calendar == 't' ? 'calendar' : 'collection');
387 $this->collection = $row;
388 if ( preg_match( '#^((/[^/]+/)\.(in|out)/)[^/]*$#', $this->path, $matches ) ) {
389 $this->collection_type = 'schedule-'. $matches[3]. 'box';
390 }
391 $this->collection->type = $this->collection_type;
392 }
393 else if ( preg_match( '{^( ( / ([^/]+) / ) \.(in|out)/ ) [^/]*$}x', $this->path, $matches ) ) {
394 // The request is for a scheduling inbox or outbox (or something inside one) and we should auto-create it
395 $params = array( ':username' => $matches[3], ':parent_container' => $matches[2], ':dav_name' => $matches[1] );
396 $params[':boxname'] = ($matches[4] == 'in' ? ' Inbox' : ' Outbox');
397 $this->collection_type = 'schedule-'. $matches[4]. 'box';
398 $params[':resourcetypes'] = sprintf('<DAV::collection/><urn:ietf:params:xml:ns:caldav:%s/>', $this->collection_type );
399 $sql = <<<EOSQL
400INSERT INTO collection ( user_no, parent_container, dav_name, dav_displayname, is_calendar, created, modified, dav_etag, resourcetypes )
401 VALUES( (SELECT user_no FROM usr WHERE username = text(:username)),
402 :parent_container, :dav_name,
403 (SELECT fullname FROM usr WHERE username = text(:username)) || :boxname,
404 FALSE, current_timestamp, current_timestamp, '1', :resourcetypes )
405EOSQL;
406
407 $qry = new AwlQuery( $sql, $params );
408 $qry->Exec('caldav',__LINE__,__FILE__);
409 dbg_error_log( 'caldav', 'Created new collection as "%s".', trim($params[':boxname']) );
410
411 // Uncache anything to do with the collection
412 $cache = getCacheInstance();
413 $cache->delete( 'collection-'.$params[':dav_name'], null );
414 $cache->delete( 'principal-'.$params[':parent_container'], null );
415
416 $qry = new AwlQuery( "SELECT * FROM collection WHERE dav_name = :dav_name", array( ':dav_name' => $matches[1] ) );
417 if ( $qry->Exec('caldav',__LINE__,__FILE__) && $qry->rows() == 1 && ($row = $qry->Fetch()) ) {
418 $this->collection_id = $row->collection_id;
419 $this->collection_path = $matches[1];
420 $this->collection = $row;
421 $this->collection->type = $this->collection_type;
422 }
423 }
424 else if ( preg_match( '#^((/[^/]+/)calendar-proxy-(read|write))/?[^/]*$#', $this->path, $matches ) ) {
425 $this->collection_type = 'proxy';
426 $this->_is_proxy_request = true;
427 $this->proxy_type = $matches[3];
428 $this->collection_path = $matches[1].'/'; // Enforce trailling '/'
429 if ( $this->collection_path == $this->path."/" ) {
430 $this->path .= '/';
431 dbg_error_log( "caldav", "Path is actually a (proxy) collection - sending Content-Location header." );
432 header( "Content-Location: ".ConstructURL($this->path) );
433 }
434 }
435 else if ( $this->options['allow_by_email'] && preg_match( '#^/(\S+@\S+[.]\S+)/?$#', $this->path) ) {
437 $this->collection_id = -1;
438 $this->collection_type = 'email';
439 $this->collection_path = $this->path;
440 $this->_is_principal = true;
441 }
442 else if ( preg_match( '#^(/[^/?]+)/?$#', $this->path, $matches) || preg_match( '#^(/principals/[^/]+/[^/]+)/?$#', $this->path, $matches) ) {
443 $this->collection_id = -1;
444 $this->collection_path = $matches[1].'/'; // Enforce trailling '/'
445 $this->collection_type = 'principal';
446 $this->_is_principal = true;
447 if ( $this->collection_path == $this->path."/" ) {
448 $this->path .= '/';
449 dbg_error_log( "caldav", "Path is actually a collection - sending Content-Location header." );
450 header( "Content-Location: ".ConstructURL($this->path) );
451 }
452 if ( preg_match( '#^(/principals/[^/]+/[^/]+)/?$#', $this->path, $matches) ) {
453 // Force a depth of 0 on these, which are at the wrong URL.
454 $this->depth = 0;
455 }
456 }
457 else if ( $this->path == '/' ) {
458 $this->collection_id = -1;
459 $this->collection_path = '/';
460 $this->collection_type = 'root';
461 }
462
463 if ( $this->collection_path == $this->path ) $this->_is_collection = true;
464 dbg_error_log( "caldav", " Collection '%s' is %d, type %s", $this->collection_path, $this->collection_id, $this->collection_type );
465
469 $this->principal = new DAVPrincipal( array( "path" => $this->path, "options" => $this->options ) );
470 $this->user_no = $this->principal->user_no();
471 $this->username = $this->principal->username();
472 $this->by_email = $this->principal->byEmail();
473 $this->principal_id = $this->principal->principal_id();
474
475 if ( $this->collection_type == 'principal' || $this->collection_type == 'email' || $this->collection_type == 'proxy' ) {
476 $this->collection = $this->principal->AsCollection();
477 if( $this->collection_type == 'proxy' ) {
478 $this->collection->is_proxy = 't';
479 $this->collection->type = 'proxy';
480 $this->collection->proxy_type = $this->proxy_type;
481 $this->collection->dav_displayname = sprintf('Proxy %s for %s', $this->proxy_type, $this->principal->username() );
482 }
483 }
484 elseif( $this->collection_type == 'root' ) {
485 $this->collection = (object) array(
486 'collection_id' => 0,
487 'dav_name' => '/',
488 'dav_etag' => md5($c->system_name),
489 'is_calendar' => 'f',
490 'is_addressbook' => 'f',
491 'is_principal' => 'f',
492 'user_no' => 0,
493 'dav_displayname' => $c->system_name,
494 'type' => 'root',
495 'created' => date('Ymd\THis')
496 );
497 }
498
502 $this->setPermissions();
503
504
509 if ( isset($this->content_type) && preg_match( '#(application|text)/xml#', $this->content_type ) ) {
510 if ( !isset($this->raw_post) || $this->raw_post == '' ) {
511 $this->XMLResponse( 400, new XMLElement( 'error', new XMLElement('missing-xml'), array( 'xmlns' => 'DAV:') ) );
512 }
513 $xml_parser = xml_parser_create_ns('UTF-8');
514 $this->xml_tags = array();
515 xml_parser_set_option ( $xml_parser, XML_OPTION_SKIP_WHITE, 1 );
516 xml_parser_set_option ( $xml_parser, XML_OPTION_CASE_FOLDING, 0 );
517 $rc = xml_parse_into_struct( $xml_parser, $this->raw_post, $this->xml_tags );
518 if ( $rc == false ) {
519 dbg_error_log( 'ERROR', 'XML parsing error: %s at line %d, column %d',
520 xml_error_string(xml_get_error_code($xml_parser)),
521 xml_get_current_line_number($xml_parser), xml_get_current_column_number($xml_parser) );
522 $this->XMLResponse( 400, new XMLElement( 'error', new XMLElement('invalid-xml'), array( 'xmlns' => 'DAV:') ) );
523 }
524 xml_parser_free($xml_parser);
525 if ( count($this->xml_tags) ) {
526 dbg_error_log( "caldav", " Parsed incoming XML request body." );
527 }
528 else {
529 $this->xml_tags = null;
530 dbg_error_log( "ERROR", "Incoming request sent content-type XML with no XML request body." );
531 }
532 }
533
537 if ( isset($_SERVER["HTTP_IF_NONE_MATCH"]) ) {
538 $this->etag_none_match = $_SERVER["HTTP_IF_NONE_MATCH"];
539 if ( $this->etag_none_match == '' ) unset($this->etag_none_match);
540 }
541 if ( isset($_SERVER["HTTP_IF_MATCH"]) ) {
542 $this->etag_if_match = $_SERVER["HTTP_IF_MATCH"];
543 if ( $this->etag_if_match == '' ) unset($this->etag_if_match);
544 }
545 }
546
547
560 function setPermissions() {
561 global $c, $session;
562
563 if ( $this->path == '/' || $this->path == '' ) {
564 $this->privileges = privilege_to_bits( array('read','read-free-busy','read-acl'));
565 dbg_error_log( "caldav", "Full read permissions for user accessing /" );
566 }
567 else if ( $session->AllowedTo("Admin") || $session->principal->user_no() == $this->user_no ) {
568 $this->privileges = privilege_to_bits('all');
569 dbg_error_log( "caldav", "Full permissions for %s", ( $session->principal->user_no() == $this->user_no ? "user accessing their own hierarchy" : "a systems administrator") );
570 }
571 else {
572 $this->privileges = 0;
573 if ( $this->IsPublic() ) {
574 $this->privileges = privilege_to_bits(array('read','read-free-busy'));
575 dbg_error_log( "caldav", "Basic read permissions for user accessing a public collection" );
576 }
577 else if ( isset($c->public_freebusy_url) && $c->public_freebusy_url ) {
578 $this->privileges = privilege_to_bits('read-free-busy');
579 dbg_error_log( "caldav", "Basic free/busy permissions for user accessing a public free/busy URL" );
580 }
581
585 $params = array( ':session_principal_id' => $session->principal->principal_id(), ':scan_depth' => $c->permission_scan_depth );
586 if ( isset($this->by_email) && $this->by_email ) {
587 $sql ='SELECT pprivs( :session_principal_id::int8, :request_principal_id::int8, :scan_depth::int ) AS perm';
588 $params[':request_principal_id'] = $this->principal_id;
589 }
590 else {
591 $sql = 'SELECT path_privs( :session_principal_id::int8, :request_path::text, :scan_depth::int ) AS perm';
592 $params[':request_path'] = $this->path;
593 }
594 $qry = new AwlQuery( $sql, $params );
595 if ( $qry->Exec('caldav',__LINE__,__FILE__) && $permission_result = $qry->Fetch() ) {
596 $perm = $permission_result->perm;
597 if (isset($perm)) $this->privileges |= bindec($permission_result->perm);
598 }
599
600 dbg_error_log( 'caldav', 'Restricted permissions for user accessing someone elses hierarchy: %s', decbin($this->privileges) );
601 if ( isset($this->ticket) && $this->ticket->MatchesPath($this->path) ) {
602 $this->privileges |= $this->ticket->privileges();
603 dbg_error_log( 'caldav', 'Applying permissions for ticket "%s" now: %s', $this->ticket->id(), decbin($this->privileges) );
604 }
605 }
606
608 $this->permissions = array();
609 $privs = bits_to_privilege($this->privileges);
610 foreach( $privs AS $k => $v ) {
611 switch( $v ) {
612 case 'DAV::all': $type = 'abstract'; break;
613 case 'DAV::write': $type = 'aggregate'; break;
614 default: $type = 'real';
615 }
616 $v = str_replace('DAV::', '', $v);
617 $this->permissions[$v] = $type;
618 }
619
620 }
621
622
630 function IsLocked() {
631 if ( !isset($this->_locks_found) ) {
632 $this->_locks_found = array();
633
634 $sql = 'DELETE FROM locks WHERE (start + timeout) < current_timestamp';
635 $qry = new AwlQuery($sql);
636 $qry->Exec('caldav',__LINE__,__FILE__);
637
641 $sql = 'SELECT * FROM locks WHERE :dav_name::text ~ (\'^\'||dav_name||:pattern_end_match)::text';
642 $qry = new AwlQuery($sql, array( ':dav_name' => $this->path, ':pattern_end_match' => ($this->IsInfiniteDepth() ? '' : '$') ) );
643 if ( $qry->Exec('caldav',__LINE__,__FILE__) ) {
644 while( $lock_row = $qry->Fetch() ) {
645 $this->_locks_found[$lock_row->opaquelocktoken] = $lock_row;
646 }
647 }
648 else {
649 $this->DoResponse(500,translate("Database Error"));
650 // Does not return.
651 }
652 }
653
654 foreach( $this->_locks_found AS $lock_token => $lock_row ) {
655 if ( $lock_row->depth == DEPTH_INFINITY || $lock_row->dav_name == $this->path ) {
656 return $lock_token;
657 }
658 }
659
660 return false; // Nothing matched
661 }
662
663
667 function IsPublic() {
668 if ( isset($this->collection) && isset($this->collection->publicly_readable) && $this->collection->publicly_readable == 't' ) {
669 return true;
670 }
671 return false;
672 }
673
674
675 private static function supportedPrivileges() {
676 return array(
677 'all' => array(
678 'read' => translate('Read the content of a resource or collection'),
679 'write' => array(
680 'bind' => translate('Create a resource or collection'),
681 'unbind' => translate('Delete a resource or collection'),
682 'write-content' => translate('Write content'),
683 'write-properties' => translate('Write properties')
684 ),
685 'urn:ietf:params:xml:ns:caldav:read-free-busy' => translate('Read the free/busy information for a calendar collection'),
686 'read-acl' => translate('Read ACLs for a resource or collection'),
687 'read-current-user-privilege-set' => translate('Read the details of the current user\'s access control to this resource.'),
688 'write-acl' => translate('Write ACLs for a resource or collection'),
689 'unlock' => translate('Remove a lock'),
690
691 'urn:ietf:params:xml:ns:caldav:schedule-deliver' => array(
692 'urn:ietf:params:xml:ns:caldav:schedule-deliver-invite'=> translate('Deliver scheduling invitations from an organiser to this scheduling inbox'),
693 'urn:ietf:params:xml:ns:caldav:schedule-deliver-reply' => translate('Deliver scheduling replies from an attendee to this scheduling inbox'),
694 'urn:ietf:params:xml:ns:caldav:schedule-query-freebusy' => translate('Allow free/busy enquiries targeted at the owner of this scheduling inbox')
695 ),
696
697 'urn:ietf:params:xml:ns:caldav:schedule-send' => array(
698 'urn:ietf:params:xml:ns:caldav:schedule-send-invite' => translate('Send scheduling invitations as an organiser from the owner of this scheduling outbox.'),
699 'urn:ietf:params:xml:ns:caldav:schedule-send-reply' => translate('Send scheduling replies as an attendee from the owner of this scheduling outbox.'),
700 'urn:ietf:params:xml:ns:caldav:schedule-send-freebusy' => translate('Send free/busy enquiries')
701 )
702 )
703 );
704 }
705
709 function dav_name() {
710 if ( isset($this->path) ) return $this->path;
711 return null;
712 }
713
714
718 function GetDepthName( ) {
719 if ( $this->IsInfiniteDepth() ) return 'infinity';
720 return $this->depth;
721 }
722
727 function DepthRegexTail( $for_collection_report = false) {
728 if ( $this->IsInfiniteDepth() ) return '';
729 if ( $this->depth == 0 && $for_collection_report ) return '[^/]+$';
730 if ( $this->depth == 0 ) return '$';
731 return '[^/]*/?$';
732 }
733
739 function GetLockRow( $lock_token ) {
740 if ( isset($this->_locks_found) && isset($this->_locks_found[$lock_token]) ) {
741 return $this->_locks_found[$lock_token];
742 }
743
744 $qry = new AwlQuery('SELECT * FROM locks WHERE opaquelocktoken = :lock_token', array( ':lock_token' => $lock_token ) );
745 if ( $qry->Exec('caldav',__LINE__,__FILE__) ) {
746 $lock_row = $qry->Fetch();
747 $this->_locks_found = array( $lock_token => $lock_row );
748 return $this->_locks_found[$lock_token];
749 }
750 else {
751 $this->DoResponse( 500, translate("Database Error") );
752 }
753
754 return false; // Nothing matched
755 }
756
757
764 function ValidateLockToken( $lock_token ) {
765 if ( isset($this->lock_token) && $this->lock_token == $lock_token ) {
766 dbg_error_log( "caldav", "They supplied a valid lock token. Great!" );
767 return true;
768 }
769 if ( isset($this->if_clause) ) {
770 dbg_error_log( "caldav", "Checking lock token '%s' against '%s'", $lock_token, $this->if_clause );
771 $tokens = preg_split( '/[<>]/', $this->if_clause );
772 foreach( $tokens AS $k => $v ) {
773 dbg_error_log( "caldav", "Checking lock token '%s' against '%s'", $lock_token, $v );
774 if ( 'opaquelocktoken:' == substr( $v, 0, 16 ) ) {
775 if ( substr( $v, 16 ) == $lock_token ) {
776 dbg_error_log( "caldav", "Lock token '%s' validated OK against '%s'", $lock_token, $v );
777 return true;
778 }
779 }
780 }
781 }
782 else {
783 @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 );
784 }
785
786 return false;
787 }
788
789
795 function GetLockDetails( $lock_token ) {
796 if ( !isset($this->_locks_found) && false === $this->IsLocked() ) return false;
797 if ( isset($this->_locks_found[$lock_token]) ) return $this->_locks_found[$lock_token];
798 return false;
799 }
800
801
809 function FailIfLocked() {
810 if ( $existing_lock = $this->IsLocked() ) { // NOTE Assignment in if() is expected here.
811 dbg_error_log( "caldav", "There is a lock on '%s'", $this->path);
812 if ( ! $this->ValidateLockToken($existing_lock) ) {
813 $lock_row = $this->GetLockRow($existing_lock);
817 $response[] = new XMLElement( 'response', array(
818 new XMLElement( 'href', $lock_row->dav_name ),
819 new XMLElement( 'status', 'HTTP/1.1 423 Resource Locked')
820 ));
821 if ( $lock_row->dav_name != $this->path ) {
822 $response[] = new XMLElement( 'response', array(
823 new XMLElement( 'href', $this->path ),
824 new XMLElement( 'propstat', array(
825 new XMLElement( 'prop', new XMLElement( 'lockdiscovery' ) ),
826 new XMLElement( 'status', 'HTTP/1.1 424 Failed Dependency')
827 ))
828 ));
829 }
830 $response = new XMLElement( "multistatus", $response, array('xmlns'=>'DAV:') );
831 $xmldoc = $response->Render(0,'<?xml version="1.0" encoding="utf-8" ?>');
832 $this->DoResponse( 207, $xmldoc, 'text/xml; charset="utf-8"' );
833 // Which we won't come back from
834 }
835 return $existing_lock;
836 }
837 return false;
838 }
839
840
844 function CoerceContentType() {
845 if ( isset($this->content_type) ) {
846 $type = explode( '/', $this->content_type, 2);
848 if ( $type[0] == 'text' ) {
849 if ( !empty($type[1]) && ($type[1] == 'vcard' || $type[1] == 'calendar' || $type[1] == 'x-vcard') ) {
850 return;
851 }
852 }
853 }
854
856 $first_word = trim(substr( $this->raw_post, 0, 30));
857 $first_word = strtoupper( preg_replace( '/\s.*/s', '', $first_word ) );
858 switch( $first_word ) {
859 case '<?XML':
860 dbg_error_log( 'LOG WARNING', 'Application sent content-type of "%s" instead of "text/xml"',
861 (isset($this->content_type)?$this->content_type:'(null)') );
862 $this->content_type = 'text/xml';
863 break;
864 case 'BEGIN:VCALENDAR':
865 dbg_error_log( 'LOG WARNING', 'Application sent content-type of "%s" instead of "text/calendar"',
866 (isset($this->content_type)?$this->content_type:'(null)') );
867 $this->content_type = 'text/calendar';
868 break;
869 case 'BEGIN:VCARD':
870 dbg_error_log( 'LOG WARNING', 'Application sent content-type of "%s" instead of "text/vcard"',
871 (isset($this->content_type)?$this->content_type:'(null)') );
872 $this->content_type = 'text/vcard';
873 break;
874 default:
875 dbg_error_log( 'LOG NOTICE', 'Unusual content-type of "%s" and first word of content is "%s"',
876 (isset($this->content_type)?$this->content_type:'(null)'), $first_word );
877 }
878 if ( empty($this->content_type) ) $this->content_type = 'text/plain';
879 }
880
881
885 function PreferMinimal() {
886 if ( empty($this->prefer) ) return false;
887 foreach( $this->prefer AS $v ) {
888 if ( $v == 'return=minimal' ) return true;
889 if ( $v == 'return-minimal' ) return true; // RFC7240 up until draft -15 (Oct 2012)
890 }
891 return false;
892 }
893
897 function IsCollection( ) {
898 if ( !isset($this->_is_collection) ) {
899 $this->_is_collection = preg_match( '#/$#', $this->path );
900 }
901 return $this->_is_collection;
902 }
903
904
908 function IsCalendar( ) {
909 if ( !$this->IsCollection() || !isset($this->collection) ) return false;
910 return $this->collection->is_calendar == 't';
911 }
912
913
917 function IsAddressBook( ) {
918 if ( !$this->IsCollection() || !isset($this->collection) ) return false;
919 return $this->collection->is_addressbook == 't';
920 }
921
922
926 function IsPrincipal( ) {
927 if ( !isset($this->_is_principal) ) {
928 $this->_is_principal = preg_match( '#^/[^/]+/$#', $this->path );
929 }
930 return $this->_is_principal;
931 }
932
933
937 function IsProxyRequest( ) {
938 if ( !isset($this->_is_proxy_request) ) {
939 $this->_is_proxy_request = preg_match( '#^/[^/]+/calendar-proxy-(read|write)/?[^/]*$#', $this->path );
940 }
941 return $this->_is_proxy_request;
942 }
943
944
948 function IsInfiniteDepth( ) {
949 return ($this->depth == DEPTH_INFINITY);
950 }
951
952
956 function CollectionId( ) {
958 }
959
960
964 function BuildSupportedPrivileges( &$reply, $privs = null ) {
965 $privileges = array();
966 if ( $privs === null ) $privs = self::supportedPrivileges();
967 foreach( $privs AS $k => $v ) {
968 dbg_error_log( 'caldav', 'Adding privilege "%s" which is "%s".', $k, $v );
969 $privilege = new XMLElement('privilege');
970 $reply->NSElement($privilege,$k);
971 $privset = array($privilege);
972 if ( is_array($v) ) {
973 dbg_error_log( 'caldav', '"%s" is a container of sub-privileges.', $k );
974 $privset = array_merge($privset, $this->BuildSupportedPrivileges($reply,$v));
975 }
976 else if ( $v == 'abstract' ) {
977 dbg_error_log( 'caldav', '"%s" is an abstract privilege.', $v );
978 $privset[] = new XMLElement('abstract');
979 }
980 else if ( strlen($v) > 1 ) {
981 $privset[] = new XMLElement('description', $v);
982 }
983 $privileges[] = new XMLElement('supported-privilege',$privset);
984 }
985 return $privileges;
986 }
987
988
1002 function AllowedTo( $activity ) {
1003 global $session;
1004 dbg_error_log('caldav', 'Checking whether "%s" is allowed to "%s"', $session->principal->username(), $activity);
1005 if ( isset($this->permissions['all']) ) return true;
1006 switch( $activity ) {
1007 case 'all':
1008 return false; // If they got this far then they don't
1009 break;
1010
1011 case "CALDAV:schedule-send-freebusy":
1012 return isset($this->permissions['read']) || isset($this->permissions['urn:ietf:params:xml:ns:caldav:read-free-busy']);
1013 break;
1014
1015 case "CALDAV:schedule-send-invite":
1016 return isset($this->permissions['read']) || isset($this->permissions['urn:ietf:params:xml:ns:caldav:read-free-busy']);
1017 break;
1018
1019 case "CALDAV:schedule-send-reply":
1020 return isset($this->permissions['read']) || isset($this->permissions['urn:ietf:params:xml:ns:caldav:read-free-busy']);
1021 break;
1022
1023 case 'freebusy':
1024 return isset($this->permissions['read']) || isset($this->permissions['urn:ietf:params:xml:ns:caldav:read-free-busy']);
1025 break;
1026
1027 case 'delete':
1028 return isset($this->permissions['write']) || isset($this->permissions['unbind']);
1029 break;
1030
1031 case 'proppatch':
1032 return isset($this->permissions['write']) || isset($this->permissions['write-properties']);
1033 break;
1034
1035 case 'modify':
1036 return isset($this->permissions['write']) || isset($this->permissions['write-content']);
1037 break;
1038
1039 case 'create':
1040 return isset($this->permissions['write']) || isset($this->permissions['bind']);
1041 break;
1042
1043 case 'mkcalendar':
1044 case 'mkcol':
1045 if ( !isset($this->permissions['write']) || !isset($this->permissions['bind']) ) return false;
1046 if ( $this->is_principal ) return false;
1047 if ( $this->path == '/' ) return false;
1048 break;
1049
1050 default:
1051 $test_bits = privilege_to_bits( $activity );
1052// dbg_error_log( 'caldav', 'request::AllowedTo("%s") (%s) against allowed "%s" => "%s" (%s)',
1053// (is_array($activity) ? implode(',',$activity) : $activity), decbin($test_bits),
1054// decbin($this->privileges), ($this->privileges & $test_bits), decbin($this->privileges & $test_bits) );
1055 return (($this->privileges & $test_bits) > 0 );
1056 break;
1057 }
1058
1059 return false;
1060 }
1061
1062
1063
1067 function Privileges() {
1068 return $this->privileges;
1069 }
1070
1071
1078 function CheckEtagMatch( $exists, $dest_etag ) {
1079 global $c;
1080
1081 if ( ! $exists ) {
1082 if ( (isset($this->etag_if_match) && $this->etag_if_match != '') ) {
1089 $this->PreconditionFailed(412, 'if-match', translate('No resource exists at the destination.'));
1090 }
1091 }
1092 else {
1093
1094 if ( isset($c->strict_etag_checking) && $c->strict_etag_checking )
1095 $trim_chars = '\'\\" ';
1096 else
1097 $trim_chars = ' ';
1098
1099 if ( isset($this->etag_if_match) && $this->etag_if_match != '' && $this->etag_if_match != '*'
1100 && trim( $this->etag_if_match, $trim_chars) != trim( $dest_etag, $trim_chars ) ) {
1107 $this->PreconditionFailed(412,'if-match',sprintf('Existing resource ETag of %s does not match %s', $dest_etag, $this->etag_if_match) );
1108 }
1109 else if ( isset($this->etag_none_match) && $this->etag_none_match != ''
1110 && ($this->etag_none_match == $dest_etag || $this->etag_none_match == '*') ) {
1119 $this->PreconditionFailed(412,'if-none-match', translate( 'Existing resource matches "If-None-Match" header - not accepted.'));
1120 }
1121 }
1122
1123 }
1124
1125
1129 function HavePrivilegeTo( $do_what ) {
1130 $test_bits = privilege_to_bits( $do_what );
1131// dbg_error_log( 'caldav', 'request::HavePrivilegeTo("%s") [%s] against allowed "%s" => "%s" (%s)',
1132// (is_array($do_what) ? implode(',',$do_what) : $do_what), decbin($test_bits),
1133// decbin($this->privileges), ($this->privileges & $test_bits), decbin($this->privileges & $test_bits) );
1134 return ($this->privileges & $test_bits) > 0;
1135 }
1136
1137
1142 function UnsupportedRequest( $unsupported ) {
1143 if ( isset($unsupported) && count($unsupported) > 0 ) {
1144 $badprops = new XMLElement( "prop" );
1145 foreach( $unsupported AS $k => $v ) {
1146 // Not supported at this point...
1147 dbg_error_log("ERROR", " %s: Support for $v:$k properties is not implemented yet", $this->method );
1148 $badprops->NewElement(strtolower($k),false,array("xmlns" => strtolower($v)));
1149 }
1150 $error = new XMLElement("error", $badprops, array("xmlns" => "DAV:") );
1151
1152 $this->XMLResponse( 422, $error );
1153 }
1154 }
1155
1156
1165 function NeedPrivilege( $privileges, $href=null ) {
1166 if ( is_string($privileges) ) $privileges = array( $privileges );
1167 if ( !isset($href) ) {
1168 if ( $this->HavePrivilegeTo($privileges) ) return;
1169 $href = $this->path;
1170 }
1171
1172 $reply = new XMLDocument( array('DAV:' => '') );
1173 $privnodes = array( $reply->href(ConstructURL($href)), new XMLElement( 'privilege' ) );
1174 // RFC3744 specifies that we can only respond with one needed privilege, so we pick the first.
1175 $reply->NSElement( $privnodes[1], $privileges[0] );
1176 $xml = new XMLElement( 'need-privileges', new XMLElement( 'resource', $privnodes) );
1177 $xmldoc = $reply->Render('error',$xml);
1178 $this->DoResponse( 403, $xmldoc, 'text/xml; charset="utf-8"' );
1179 exit(0); // Unecessary, but might clarify things
1180 }
1181
1182
1190 function PreconditionFailed( $status, $precondition, $explanation = '', $xmlns='DAV:') {
1191 $xmldoc = sprintf('<?xml version="1.0" encoding="utf-8" ?>
1192<error xmlns="%s">
1193 <%s/>%s
1194</error>', $xmlns, str_replace($xmlns.':', '', $precondition), $explanation );
1195
1196 $this->DoResponse( $status, $xmldoc, 'text/xml; charset="utf-8"' );
1197 exit(0); // Unecessary, but might clarify things
1198 }
1199
1200
1206 function MalformedRequest( $text = 'Bad request' ) {
1207 $this->DoResponse( 400, $text );
1208 exit(0); // Unecessary, but might clarify things
1209 }
1210
1211
1218 function XMLResponse( $status, $xmltree ) {
1219 $xmldoc = $xmltree->Render(0,'<?xml version="1.0" encoding="utf-8" ?>');
1220 $etag = md5($xmldoc);
1221 if ( !headers_sent() ) header("ETag: \"$etag\"");
1222 $this->DoResponse( $status, $xmldoc, 'text/xml; charset="utf-8"' );
1223 exit(0); // Unecessary, but might clarify things
1224 }
1225
1226 public static function kill_on_exit() {
1227 posix_kill( getmypid(), 28 );
1228 }
1229
1237 function DoResponse( $status, $message="", $content_type="text/plain; charset=\"utf-8\"" ) {
1238 global $session, $c;
1239 if ( !headers_sent() ) @header( sprintf("HTTP/1.1 %d %s", $status, getStatusMessage($status)) );
1240 if ( !headers_sent() ) @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) );
1241 if ( !headers_sent() ) header( "Content-type: ".$content_type );
1242
1243 if ( (isset($c->dbg['ALL']) && $c->dbg['ALL']) || (isset($c->dbg['response']) && $c->dbg['response'])
1244 || $status == 400 || $status == 402 || $status == 403 || $status > 404 ) {
1245 @dbg_error_log( "LOG ", 'Response status %03d for %s %s', $status, $this->method, $_SERVER['REQUEST_URI'] );
1246 $lines = headers_list();
1247 dbg_error_log( "LOG ", "***************** Response Header ****************" );
1248 foreach( $lines AS $v ) {
1249 dbg_error_log( "LOG headers", "-->%s", $v );
1250 }
1251 dbg_error_log( "LOG", "******************** Response ********************" );
1252 // Log the request in all it's gory detail.
1253 $lines = preg_split( '#[\r\n]+#', $message);
1254 foreach( $lines AS $v ) {
1255 dbg_error_log( "LOG response", "-->%s", $v );
1256 }
1257 }
1258
1259 $script_finish = microtime(true);
1260 $script_time = $script_finish - $c->script_start_time;
1261 $message_length = strlen($message);
1262 if ( $message != '' ) {
1263 if ( !headers_sent() ) header( "Content-Length: ".$message_length );
1264 echo $message;
1265 }
1266
1267 if ( isset($c->dbg['caldav']) && $c->dbg['caldav'] ) {
1268 if ( $message_length > 100 || strstr($message, "\n") ) {
1269 $message = substr( preg_replace("#\s+#m", ' ', $message ), 0, 100) . ($message_length > 100 ? "..." : "");
1270 }
1271
1272 dbg_error_log("caldav", "Status: %d, Message: %s, User: %d, Path: %s", $status, $message, $session->principal->user_no(), $this->path);
1273 }
1274 if ( isset($c->dbg['statistics']) && $c->dbg['statistics'] ) {
1275 $memory = '';
1276 if ( function_exists('memory_get_usage') ) {
1277 $memory = sprintf( ', Memory: %dk, Peak: %dk', memory_get_usage()/1024, memory_get_peak_usage(true)/1024);
1278 }
1279 @dbg_error_log("statistics", "Method: %s, Status: %d, Script: %5.3lfs, Queries: %5.3lfs, URL: %s%s",
1280 $this->method, $status, $script_time, $c->total_query_time, $this->path, $memory);
1281 }
1282 try {
1283 @ob_flush(); // Seems like it should be better to do the following but is problematic on PHP5.3 at least: while ( ob_get_level() > 0 ) ob_end_flush();
1284 }
1285 catch( Exception $ignored ) {}
1286
1287 if ( isset($c->metrics_style) && $c->metrics_style !== false ) {
1288 $flush_time = microtime(true) - $script_finish;
1289 $this->DoMetrics($status, $message_length, $script_time, $flush_time);
1290 }
1291
1292 if ( isset($c->exit_after_memory_exceeds) && function_exists('memory_get_peak_usage') && memory_get_peak_usage(true) > $c->exit_after_memory_exceeds ) { // 64M
1293 @dbg_error_log("statistics", "Peak memory use exceeds %d bytes (%d) - killing process %d", $c->exit_after_memory_exceeds, memory_get_peak_usage(true), getmypid());
1294 register_shutdown_function( 'CalDAVRequest::kill_on_exit' );
1295 }
1296
1297 exit(0);
1298 }
1299
1300
1309 function DoMetrics($status, $response_size, $script_time, $flush_time) {
1310 global $c;
1311 static $ns = 'metrics';
1312
1313 $method = (empty($this->method) ? 'UNKNOWN' : $this->method);
1314
1315 // If they want 'both' or 'all' or something then that's what they will get
1316 // If they don't want counters, they must want to use memcache!
1317 if ( $c->metrics_style != 'counters' ) {
1318 $cache = getCacheInstance();
1319 if ( $cache->isActive() ) {
1320
1321 $base_key = $method.':';
1322 $count_like_this = $cache->increment( $ns, $base_key.$status );
1323 $cache->increment( $ns, $base_key.'size', $response_size );
1324 $cache->increment( $ns, $base_key.'script_time', intval($script_time * 1000000) );
1325 $cache->increment( $ns, $base_key.'flush_time', intval($flush_time * 1000000) );
1326 $cache->increment( $ns, $base_key.'query_time', intval($c->total_query_time * 1000000) );
1327
1328 if ( $count_like_this == 1 ) {
1329 // We need to maintain a set of details regarding the methods and statuses we have
1330 // encountered, so we know what to retrieve. Since this is the first one like
1331 // this, we add it to the index.
1332 try {
1333 $index = unserialize($cache->get($ns, 'index'));
1334 } catch (Exception $e) {
1335 $index = array('methods' => array(), 'statuses' => array());
1336 }
1337 $index['methods'][$method] = 1;
1338 $index['statuses'][$status] = 1;
1339 $cache->set($ns, 'index', serialize($index), 0);
1340 }
1341 }
1342 else {
1343 error_log("Full statistics are only available with a working Memcache configuration");
1344 }
1345 }
1346
1347 // If they don't want memcache, they must want to use counters!
1348 if ( $c->metrics_style != 'memcache' ) {
1349 $qstring = "SELECT nextval('%s')";
1350 switch( $method ) {
1351 case 'OPTIONS':
1352 case 'REPORT':
1353 case 'PROPFIND':
1354 case 'GET':
1355 case 'PUT':
1356 case 'HEAD':
1357 case 'PROPPATCH':
1358 case 'POST':
1359 case 'MKCALENDAR':
1360 case 'MKCOL':
1361 case 'DELETE':
1362 case 'MOVE':
1363 case 'ACL':
1364 case 'LOCK':
1365 case 'UNLOCK':
1366 case 'MKTICKET':
1367 case 'DELTICKET':
1368 case 'BIND':
1369 $counter = strtolower($this->method);
1370 break;
1371 default:
1372 $counter = 'unknown';
1373 break;
1374 }
1375 $qry = new AwlQuery( "SELECT nextval('metrics_count_" . $counter . "')" );
1376 $qry->Exec('always',__LINE__,__FILE__);
1377 }
1378 }
1379}
1380
GetLockDetails( $lock_token)
__construct( $options=array())
DepthRegexTail( $for_collection_report=false)
CheckEtagMatch( $exists, $dest_etag)
DoMetrics($status, $response_size, $script_time, $flush_time)
ValidateLockToken( $lock_token)
GetLockRow( $lock_token)
XMLResponse( $status, $xmltree)
PreconditionFailed( $status, $precondition, $explanation='', $xmlns='DAV:')
BuildSupportedPrivileges(&$reply, $privs=null)
AllowedTo( $activity)
DoResponse( $status, $message="", $content_type="text/plain; charset=\"utf-8\"")