DAViCal
CalDAVRequest.php
1 <?php
17 require_once("AwlCache.php");
18 require_once("XMLDocument.php");
19 require_once("DAVPrincipal.php");
20 require_once("DAVTicket.php");
21 
22 define('DEPTH_INFINITY', 9999);
23 
24 
30 class CalDAVRequest
31 {
32  var $options;
33 
37  var $raw_post;
38 
42  var $method;
43 
48  var $depth;
49 
54  var $principal;
55 
60  var $current_user_principal_xml;
61 
65  var $user_agent;
66 
70  var $collection_id;
71 
75  var $collection_path;
76 
81  var $collection_type;
82 
87  protected $exists;
88 
92  var $destination;
93 
97  protected $privileges;
98 
102  var $supported_privileges;
103 
107  public $ticket;
108 
113  private $prefer;
114 
118  function __construct( $options = array() ) {
119  global $session, $c, $debugging;
120 
121  $this->options = $options;
122  if ( !isset($this->options['allow_by_email']) ) $this->options['allow_by_email'] = false;
123 
124  if ( isset($_SERVER['HTTP_PREFER']) ) {
125  $this->prefer = explode( ',', $_SERVER['HTTP_PREFER']);
126  }
127  else if ( isset($_SERVER['HTTP_BRIEF']) && (strtoupper($_SERVER['HTTP_BRIEF']) == 'T') ) {
128  $this->prefer = array( 'return=minimal');
129  }
130  else
131  $this->prefer = array();
132 
146  if ( isset($_SERVER['PATH_INFO']) ) {
147  $this->path = $_SERVER['PATH_INFO'];
148  }
149  else {
150  $this->path = '/';
151  if ( isset($_SERVER['REQUEST_URI']) ) {
152  if ( preg_match( '{^(.*?\.php)([^?]*)}', $_SERVER['REQUEST_URI'], $matches ) ) {
153  $this->path = $matches[2];
154  if ( substr($this->path,0,1) != '/' )
155  $this->path = '/'.$this->path;
156  }
157  else if ( $_SERVER['REQUEST_URI'] != '/' ) {
158  dbg_error_log('LOG', 'Server is not supplying PATH_INFO and REQUEST_URI does not include a PHP program. Wildly guessing "/"!!!');
159  }
160  }
161  }
162  $this->path = rawurldecode($this->path);
163 
165  if ( preg_match( '#^(/[^/]+/[^/]+).ics$#', $this->path, $matches ) ) {
166  $this->path = $matches[1]. '/';
167  }
168 
169  if ( isset($c->replace_path) && isset($c->replace_path['from']) && isset($c->replace_path['to']) ) {
170  $this->path = preg_replace($c->replace_path['from'], $c->replace_path['to'], $this->path);
171  }
172 
173  // dbg_error_log( "caldav", "Sanitising path '%s'", $this->path );
174  $bad_chars_regex = '/[\\^\\[\\(\\\\]/';
175  if ( preg_match( $bad_chars_regex, $this->path ) ) {
176  $this->DoResponse( 400, translate("The calendar path contains illegal characters.") );
177  }
178  if ( strstr($this->path,'//') ) $this->path = preg_replace( '#//+#', '/', $this->path);
179 
180  if ( !isset($c->raw_post) ) $c->raw_post = file_get_contents( 'php://input');
181  if ( isset($_SERVER['HTTP_CONTENT_ENCODING']) ) {
182  $encoding = $_SERVER['HTTP_CONTENT_ENCODING'];
183  @dbg_error_log('caldav', 'Content-Encoding: %s', $encoding );
184  $encoding = preg_replace('{[^a-z0-9-]}i','',$encoding);
185  if ( ! ini_get('open_basedir') && (isset($c->dbg['ALL']) || isset($c->dbg['caldav'])) ) {
186  $fh = fopen('/var/log/davical/encoded_data.debug'.$encoding,'w');
187  if ( $fh ) {
188  fwrite($fh,$c->raw_post);
189  fclose($fh);
190  }
191  }
192  switch( $encoding ) {
193  case 'gzip':
194  $this->raw_post = @gzdecode($c->raw_post);
195  break;
196  case 'deflate':
197  $this->raw_post = @gzinflate($c->raw_post);
198  break;
199  case 'compress':
200  $this->raw_post = @gzuncompress($c->raw_post);
201  break;
202  default:
203  }
204  if ( empty($this->raw_post) && !empty($c->raw_post) ) {
205  $this->PreconditionFailed(415, 'content-encoding', sprintf('Unable to decode "%s" content encoding.', $_SERVER['HTTP_CONTENT_ENCODING']));
206  }
207  $c->raw_post = $this->raw_post;
208  }
209  else {
210  $this->raw_post = $c->raw_post;
211  }
212 
213  if ( isset($debugging) && isset($_GET['method']) ) {
214  $_SERVER['REQUEST_METHOD'] = $_GET['method'];
215  }
216  else if ( $_SERVER['REQUEST_METHOD'] == 'POST' && isset($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE']) ){
217  $_SERVER['REQUEST_METHOD'] = $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'];
218  }
219  $this->method = $_SERVER['REQUEST_METHOD'];
220  $this->content_type = (isset($_SERVER['CONTENT_TYPE']) ? $_SERVER['CONTENT_TYPE'] : null);
221  if ( preg_match( '{^(\S+/\S+?)\s*(;.*)?$}', $this->content_type, $matches ) ) {
222  $this->content_type = $matches[1];
223  }
224  if ( strlen($c->raw_post) > 0 ) {
225  if ( $this->method == 'PROPFIND' || $this->method == 'REPORT' || $this->method == 'PROPPATCH' || $this->method == 'BIND' || $this->method == 'MKTICKET' || $this->method == 'ACL' ) {
226  if ( !preg_match( '{^(text|application)/xml$}', $this->content_type ) ) {
227  @dbg_error_log( "LOG request", 'Request is "%s" but client set content-type to "%s". Assuming they meant XML!',
228  $this->method, $this->content_type );
229  $this->content_type = 'text/xml';
230  }
231  }
232  else if ( $this->method == 'PUT' || $this->method == 'POST' ) {
233  $this->CoerceContentType();
234  }
235  }
236  else {
237  $this->content_type = 'text/plain';
238  }
239  $this->user_agent = ((isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : "Probably Mulberry"));
240 
244  if ( isset($_SERVER['HTTP_DEPTH']) ) {
245  $this->depth = $_SERVER['HTTP_DEPTH'];
246  }
247  else {
253  switch( $this->method ) {
254  case 'DELETE':
255  case 'MOVE':
256  case 'COPY':
257  case 'LOCK':
258  $this->depth = 'infinity';
259  break;
260 
261  case 'REPORT':
262  $this->depth = 0;
263  break;
264 
265  case 'PROPFIND':
266  default:
267  $this->depth = 0;
268  }
269  }
270  if ( !is_int($this->depth) && "infinity" == $this->depth ) $this->depth = DEPTH_INFINITY;
271  $this->depth = intval($this->depth);
272 
276  if ( isset($_SERVER['HTTP_DESTINATION']) ) {
277  $this->destination = $_SERVER['HTTP_DESTINATION'];
278  if ( preg_match('{^(https?)://([a-z.-]+)(:[0-9]+)?(/.*)$}', $this->destination, $matches ) ) {
279  $this->destination = $matches[4];
280  }
281  }
282  $this->overwrite = ( isset($_SERVER['HTTP_OVERWRITE']) && ($_SERVER['HTTP_OVERWRITE'] == 'F') ? false : true ); // RFC4918, 9.8.4 says default True.
283 
287  if ( isset($_SERVER['HTTP_IF']) ) $this->if_clause = $_SERVER['HTTP_IF'];
288  if ( isset($_SERVER['HTTP_LOCK_TOKEN']) && preg_match( '#[<]opaquelocktoken:(.*)[>]#', $_SERVER['HTTP_LOCK_TOKEN'], $matches ) ) {
289  $this->lock_token = $matches[1];
290  }
291 
295  if ( isset($_GET['ticket']) ) {
296  $this->ticket = new DAVTicket($_GET['ticket']);
297  }
298  else if ( isset($_SERVER['HTTP_TICKET']) ) {
299  $this->ticket = new DAVTicket($_SERVER['HTTP_TICKET']);
300  }
301 
305  if ( isset($_SERVER['HTTP_TIMEOUT']) ) {
306  $timeouts = explode( ',', $_SERVER['HTTP_TIMEOUT'] );
307  foreach( $timeouts AS $k => $v ) {
308  if ( strtolower($v) == 'infinite' ) {
309  $this->timeout = (isset($c->maximum_lock_timeout) ? $c->maximum_lock_timeout : 86400 * 100);
310  break;
311  }
312  elseif ( strtolower(substr($v,0,7)) == 'second-' ) {
313  $this->timeout = min( intval(substr($v,7)), (isset($c->maximum_lock_timeout) ? $c->maximum_lock_timeout : 86400 * 100) );
314  break;
315  }
316  }
317  if ( ! isset($this->timeout) || $this->timeout == 0 ) $this->timeout = (isset($c->default_lock_timeout) ? $c->default_lock_timeout : 900);
318  }
319 
320  $this->principal = new Principal('path',$this->path);
321 
333  $sql = "SELECT * FROM collection WHERE dav_name = :exact_name";
334  $params = array( ':exact_name' => $this->path );
335  if ( !preg_match( '#/$#', $this->path ) ) {
336  $sql .= " OR dav_name = :truncated_name OR dav_name = :trailing_slash_name";
337  $params[':truncated_name'] = preg_replace( '#[^/]*$#', '', $this->path);
338  $params[':trailing_slash_name'] = $this->path."/";
339  }
340  $sql .= " ORDER BY LENGTH(dav_name) DESC LIMIT 1";
341  $qry = new AwlQuery( $sql, $params );
342  if ( $qry->Exec('caldav',__LINE__,__FILE__) && $qry->rows() == 1 && ($row = $qry->Fetch()) ) {
343  if ( $row->dav_name == $this->path."/" ) {
344  $this->path = $row->dav_name;
345  dbg_error_log( "caldav", "Path is actually a collection - sending Content-Location header." );
346  header( "Content-Location: ".ConstructURL($this->path) );
347  }
348 
349  $this->collection_id = $row->collection_id;
350  $this->collection_path = $row->dav_name;
351  $this->collection_type = ($row->is_calendar == 't' ? 'calendar' : 'collection');
352  $this->collection = $row;
353  if ( preg_match( '#^((/[^/]+/)\.(in|out)/)[^/]*$#', $this->path, $matches ) ) {
354  $this->collection_type = 'schedule-'. $matches[3]. 'box';
355  }
356  $this->collection->type = $this->collection_type;
357  }
358  else if ( preg_match( '{^( ( / ([^/]+) / ) \.(in|out)/ ) [^/]*$}x', $this->path, $matches ) ) {
359  // The request is for a scheduling inbox or outbox (or something inside one) and we should auto-create it
360  $params = array( ':username' => $matches[3], ':parent_container' => $matches[2], ':dav_name' => $matches[1] );
361  $params[':boxname'] = ($matches[4] == 'in' ? ' Inbox' : ' Outbox');
362  $this->collection_type = 'schedule-'. $matches[4]. 'box';
363  $params[':resourcetypes'] = sprintf('<DAV::collection/><urn:ietf:params:xml:ns:caldav:%s/>', $this->collection_type );
364  $sql = <<<EOSQL
365 INSERT INTO collection ( user_no, parent_container, dav_name, dav_displayname, is_calendar, created, modified, dav_etag, resourcetypes )
366  VALUES( (SELECT user_no FROM usr WHERE username = text(:username)),
367  :parent_container, :dav_name,
368  (SELECT fullname FROM usr WHERE username = text(:username)) || :boxname,
369  FALSE, current_timestamp, current_timestamp, '1', :resourcetypes )
370 EOSQL;
371 
372  $qry = new AwlQuery( $sql, $params );
373  $qry->Exec('caldav',__LINE__,__FILE__);
374  dbg_error_log( 'caldav', 'Created new collection as "%s".', trim($params[':boxname']) );
375 
376  // Uncache anything to do with the collection
377  $cache = getCacheInstance();
378  $cache->delete( 'collection-'.$params[':dav_name'], null );
379  $cache->delete( 'principal-'.$params[':parent_container'], null );
380 
381  $qry = new AwlQuery( "SELECT * FROM collection WHERE dav_name = :dav_name", array( ':dav_name' => $matches[1] ) );
382  if ( $qry->Exec('caldav',__LINE__,__FILE__) && $qry->rows() == 1 && ($row = $qry->Fetch()) ) {
383  $this->collection_id = $row->collection_id;
384  $this->collection_path = $matches[1];
385  $this->collection = $row;
386  $this->collection->type = $this->collection_type;
387  }
388  }
389  else if ( preg_match( '#^((/[^/]+/)calendar-proxy-(read|write))/?[^/]*$#', $this->path, $matches ) ) {
390  $this->collection_type = 'proxy';
391  $this->_is_proxy_request = true;
392  $this->proxy_type = $matches[3];
393  $this->collection_path = $matches[1].'/'; // Enforce trailling '/'
394  if ( $this->collection_path == $this->path."/" ) {
395  $this->path .= '/';
396  dbg_error_log( "caldav", "Path is actually a (proxy) collection - sending Content-Location header." );
397  header( "Content-Location: ".ConstructURL($this->path) );
398  }
399  }
400  else if ( $this->options['allow_by_email'] && preg_match( '#^/(\S+@\S+[.]\S+)/?$#', $this->path) ) {
402  $this->collection_id = -1;
403  $this->collection_type = 'email';
404  $this->collection_path = $this->path;
405  $this->_is_principal = true;
406  }
407  else if ( preg_match( '#^(/[^/?]+)/?$#', $this->path, $matches) || preg_match( '#^(/principals/[^/]+/[^/]+)/?$#', $this->path, $matches) ) {
408  $this->collection_id = -1;
409  $this->collection_path = $matches[1].'/'; // Enforce trailling '/'
410  $this->collection_type = 'principal';
411  $this->_is_principal = true;
412  if ( $this->collection_path == $this->path."/" ) {
413  $this->path .= '/';
414  dbg_error_log( "caldav", "Path is actually a collection - sending Content-Location header." );
415  header( "Content-Location: ".ConstructURL($this->path) );
416  }
417  if ( preg_match( '#^(/principals/[^/]+/[^/]+)/?$#', $this->path, $matches) ) {
418  // Force a depth of 0 on these, which are at the wrong URL.
419  $this->depth = 0;
420  }
421  }
422  else if ( $this->path == '/' ) {
423  $this->collection_id = -1;
424  $this->collection_path = '/';
425  $this->collection_type = 'root';
426  }
427 
428  if ( $this->collection_path == $this->path ) $this->_is_collection = true;
429  dbg_error_log( "caldav", " Collection '%s' is %d, type %s", $this->collection_path, $this->collection_id, $this->collection_type );
430 
434  $this->principal = new DAVPrincipal( array( "path" => $this->path, "options" => $this->options ) );
435  $this->user_no = $this->principal->user_no();
436  $this->username = $this->principal->username();
437  $this->by_email = $this->principal->byEmail();
438  $this->principal_id = $this->principal->principal_id();
439 
440  if ( $this->collection_type == 'principal' || $this->collection_type == 'email' || $this->collection_type == 'proxy' ) {
441  $this->collection = $this->principal->AsCollection();
442  if( $this->collection_type == 'proxy' ) {
443  $this->collection->is_proxy = 't';
444  $this->collection->type = 'proxy';
445  $this->collection->proxy_type = $this->proxy_type;
446  $this->collection->dav_displayname = sprintf('Proxy %s for %s', $this->proxy_type, $this->principal->username() );
447  }
448  }
449  elseif( $this->collection_type == 'root' ) {
450  $this->collection = (object) array(
451  'collection_id' => 0,
452  'dav_name' => '/',
453  'dav_etag' => md5($c->system_name),
454  'is_calendar' => 'f',
455  'is_addressbook' => 'f',
456  'is_principal' => 'f',
457  'user_no' => 0,
458  'dav_displayname' => $c->system_name,
459  'type' => 'root',
460  'created' => date('Ymd\THis')
461  );
462  }
463 
467  $this->setPermissions();
468 
469 
474  if ( isset($this->content_type) && preg_match( '#(application|text)/xml#', $this->content_type ) ) {
475  if ( !isset($this->raw_post) || $this->raw_post == '' ) {
476  $this->XMLResponse( 400, new XMLElement( 'error', new XMLElement('missing-xml'), array( 'xmlns' => 'DAV:') ) );
477  }
478  $xml_parser = xml_parser_create_ns('UTF-8');
479  $this->xml_tags = array();
480  xml_parser_set_option ( $xml_parser, XML_OPTION_SKIP_WHITE, 1 );
481  xml_parser_set_option ( $xml_parser, XML_OPTION_CASE_FOLDING, 0 );
482  $rc = xml_parse_into_struct( $xml_parser, $this->raw_post, $this->xml_tags );
483  if ( $rc == false ) {
484  dbg_error_log( 'ERROR', 'XML parsing error: %s at line %d, column %d',
485  xml_error_string(xml_get_error_code($xml_parser)),
486  xml_get_current_line_number($xml_parser), xml_get_current_column_number($xml_parser) );
487  $this->XMLResponse( 400, new XMLElement( 'error', new XMLElement('invalid-xml'), array( 'xmlns' => 'DAV:') ) );
488  }
489  xml_parser_free($xml_parser);
490  if ( count($this->xml_tags) ) {
491  dbg_error_log( "caldav", " Parsed incoming XML request body." );
492  }
493  else {
494  $this->xml_tags = null;
495  dbg_error_log( "ERROR", "Incoming request sent content-type XML with no XML request body." );
496  }
497  }
498 
502  if ( isset($_SERVER["HTTP_IF_NONE_MATCH"]) ) {
503  $this->etag_none_match = $_SERVER["HTTP_IF_NONE_MATCH"];
504  if ( $this->etag_none_match == '' ) unset($this->etag_none_match);
505  }
506  if ( isset($_SERVER["HTTP_IF_MATCH"]) ) {
507  $this->etag_if_match = $_SERVER["HTTP_IF_MATCH"];
508  if ( $this->etag_if_match == '' ) unset($this->etag_if_match);
509  }
510  }
511 
512 
525  function setPermissions() {
526  global $c, $session;
527 
528  if ( $this->path == '/' || $this->path == '' ) {
529  $this->privileges = privilege_to_bits( array('read','read-free-busy','read-acl'));
530  dbg_error_log( "caldav", "Full read permissions for user accessing /" );
531  }
532  else if ( $session->AllowedTo("Admin") || $session->principal->user_no() == $this->user_no ) {
533  $this->privileges = privilege_to_bits('all');
534  dbg_error_log( "caldav", "Full permissions for %s", ( $session->principal->user_no() == $this->user_no ? "user accessing their own hierarchy" : "a systems administrator") );
535  }
536  else {
537  $this->privileges = 0;
538  if ( $this->IsPublic() ) {
539  $this->privileges = privilege_to_bits(array('read','read-free-busy'));
540  dbg_error_log( "caldav", "Basic read permissions for user accessing a public collection" );
541  }
542  else if ( isset($c->public_freebusy_url) && $c->public_freebusy_url ) {
543  $this->privileges = privilege_to_bits('read-free-busy');
544  dbg_error_log( "caldav", "Basic free/busy permissions for user accessing a public free/busy URL" );
545  }
546 
550  $params = array( ':session_principal_id' => $session->principal->principal_id(), ':scan_depth' => $c->permission_scan_depth );
551  if ( isset($this->by_email) && $this->by_email ) {
552  $sql ='SELECT pprivs( :session_principal_id::int8, :request_principal_id::int8, :scan_depth::int ) AS perm';
553  $params[':request_principal_id'] = $this->principal_id;
554  }
555  else {
556  $sql = 'SELECT path_privs( :session_principal_id::int8, :request_path::text, :scan_depth::int ) AS perm';
557  $params[':request_path'] = $this->path;
558  }
559  $qry = new AwlQuery( $sql, $params );
560  if ( $qry->Exec('caldav',__LINE__,__FILE__) && $permission_result = $qry->Fetch() )
561  $this->privileges |= bindec($permission_result->perm);
562 
563  dbg_error_log( 'caldav', 'Restricted permissions for user accessing someone elses hierarchy: %s', decbin($this->privileges) );
564  if ( isset($this->ticket) && $this->ticket->MatchesPath($this->path) ) {
565  $this->privileges |= $this->ticket->privileges();
566  dbg_error_log( 'caldav', 'Applying permissions for ticket "%s" now: %s', $this->ticket->id(), decbin($this->privileges) );
567  }
568  }
569 
571  $this->permissions = array();
572  $privs = bits_to_privilege($this->privileges);
573  foreach( $privs AS $k => $v ) {
574  switch( $v ) {
575  case 'DAV::all': $type = 'abstract'; break;
576  case 'DAV::write': $type = 'aggregate'; break;
577  default: $type = 'real';
578  }
579  $v = str_replace('DAV::', '', $v);
580  $this->permissions[$v] = $type;
581  }
582 
583  }
584 
585 
593  function IsLocked() {
594  if ( !isset($this->_locks_found) ) {
595  $this->_locks_found = array();
596 
597  $sql = 'DELETE FROM locks WHERE (start + timeout) < current_timestamp';
598  $qry = new AwlQuery($sql);
599  $qry->Exec('caldav',__LINE__,__FILE__);
600 
604  $sql = 'SELECT * FROM locks WHERE :dav_name::text ~ (\'^\'||dav_name||:pattern_end_match)::text';
605  $qry = new AwlQuery($sql, array( ':dav_name' => $this->path, ':pattern_end_match' => ($this->IsInfiniteDepth() ? '' : '$') ) );
606  if ( $qry->Exec('caldav',__LINE__,__FILE__) ) {
607  while( $lock_row = $qry->Fetch() ) {
608  $this->_locks_found[$lock_row->opaquelocktoken] = $lock_row;
609  }
610  }
611  else {
612  $this->DoResponse(500,translate("Database Error"));
613  // Does not return.
614  }
615  }
616 
617  foreach( $this->_locks_found AS $lock_token => $lock_row ) {
618  if ( $lock_row->depth == DEPTH_INFINITY || $lock_row->dav_name == $this->path ) {
619  return $lock_token;
620  }
621  }
622 
623  return false; // Nothing matched
624  }
625 
626 
630  function IsPublic() {
631  if ( isset($this->collection) && isset($this->collection->publicly_readable) && $this->collection->publicly_readable == 't' ) {
632  return true;
633  }
634  return false;
635  }
636 
637 
638  private static function supportedPrivileges() {
639  return array(
640  'all' => array(
641  'read' => translate('Read the content of a resource or collection'),
642  'write' => array(
643  'bind' => translate('Create a resource or collection'),
644  'unbind' => translate('Delete a resource or collection'),
645  'write-content' => translate('Write content'),
646  'write-properties' => translate('Write properties')
647  ),
648  'urn:ietf:params:xml:ns:caldav:read-free-busy' => translate('Read the free/busy information for a calendar collection'),
649  'read-acl' => translate('Read ACLs for a resource or collection'),
650  'read-current-user-privilege-set' => translate('Read the details of the current user\'s access control to this resource.'),
651  'write-acl' => translate('Write ACLs for a resource or collection'),
652  'unlock' => translate('Remove a lock'),
653 
654  'urn:ietf:params:xml:ns:caldav:schedule-deliver' => array(
655  'urn:ietf:params:xml:ns:caldav:schedule-deliver-invite'=> translate('Deliver scheduling invitations from an organiser to this scheduling inbox'),
656  'urn:ietf:params:xml:ns:caldav:schedule-deliver-reply' => translate('Deliver scheduling replies from an attendee to this scheduling inbox'),
657  'urn:ietf:params:xml:ns:caldav:schedule-query-freebusy' => translate('Allow free/busy enquiries targeted at the owner of this scheduling inbox')
658  ),
659 
660  'urn:ietf:params:xml:ns:caldav:schedule-send' => array(
661  'urn:ietf:params:xml:ns:caldav:schedule-send-invite' => translate('Send scheduling invitations as an organiser from the owner of this scheduling outbox.'),
662  'urn:ietf:params:xml:ns:caldav:schedule-send-reply' => translate('Send scheduling replies as an attendee from the owner of this scheduling outbox.'),
663  'urn:ietf:params:xml:ns:caldav:schedule-send-freebusy' => translate('Send free/busy enquiries')
664  )
665  )
666  );
667  }
668 
672  function dav_name() {
673  if ( isset($this->path) ) return $this->path;
674  return null;
675  }
676 
677 
681  function GetDepthName( ) {
682  if ( $this->IsInfiniteDepth() ) return 'infinity';
683  return $this->depth;
684  }
685 
690  function DepthRegexTail( $for_collection_report = false) {
691  if ( $this->IsInfiniteDepth() ) return '';
692  if ( $this->depth == 0 && $for_collection_report ) return '[^/]+$';
693  if ( $this->depth == 0 ) return '$';
694  return '[^/]*/?$';
695  }
696 
702  function GetLockRow( $lock_token ) {
703  if ( isset($this->_locks_found) && isset($this->_locks_found[$lock_token]) ) {
704  return $this->_locks_found[$lock_token];
705  }
706 
707  $qry = new AwlQuery('SELECT * FROM locks WHERE opaquelocktoken = :lock_token', array( ':lock_token' => $lock_token ) );
708  if ( $qry->Exec('caldav',__LINE__,__FILE__) ) {
709  $lock_row = $qry->Fetch();
710  $this->_locks_found = array( $lock_token => $lock_row );
711  return $this->_locks_found[$lock_token];
712  }
713  else {
714  $this->DoResponse( 500, translate("Database Error") );
715  }
716 
717  return false; // Nothing matched
718  }
719 
720 
727  function ValidateLockToken( $lock_token ) {
728  if ( isset($this->lock_token) && $this->lock_token == $lock_token ) {
729  dbg_error_log( "caldav", "They supplied a valid lock token. Great!" );
730  return true;
731  }
732  if ( isset($this->if_clause) ) {
733  dbg_error_log( "caldav", "Checking lock token '%s' against '%s'", $lock_token, $this->if_clause );
734  $tokens = preg_split( '/[<>]/', $this->if_clause );
735  foreach( $tokens AS $k => $v ) {
736  dbg_error_log( "caldav", "Checking lock token '%s' against '%s'", $lock_token, $v );
737  if ( 'opaquelocktoken:' == substr( $v, 0, 16 ) ) {
738  if ( substr( $v, 16 ) == $lock_token ) {
739  dbg_error_log( "caldav", "Lock token '%s' validated OK against '%s'", $lock_token, $v );
740  return true;
741  }
742  }
743  }
744  }
745  else {
746  @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 );
747  }
748 
749  return false;
750  }
751 
752 
758  function GetLockDetails( $lock_token ) {
759  if ( !isset($this->_locks_found) && false === $this->IsLocked() ) return false;
760  if ( isset($this->_locks_found[$lock_token]) ) return $this->_locks_found[$lock_token];
761  return false;
762  }
763 
764 
772  function FailIfLocked() {
773  if ( $existing_lock = $this->IsLocked() ) { // NOTE Assignment in if() is expected here.
774  dbg_error_log( "caldav", "There is a lock on '%s'", $this->path);
775  if ( ! $this->ValidateLockToken($existing_lock) ) {
776  $lock_row = $this->GetLockRow($existing_lock);
780  $response[] = new XMLElement( 'response', array(
781  new XMLElement( 'href', $lock_row->dav_name ),
782  new XMLElement( 'status', 'HTTP/1.1 423 Resource Locked')
783  ));
784  if ( $lock_row->dav_name != $this->path ) {
785  $response[] = new XMLElement( 'response', array(
786  new XMLElement( 'href', $this->path ),
787  new XMLElement( 'propstat', array(
788  new XMLElement( 'prop', new XMLElement( 'lockdiscovery' ) ),
789  new XMLElement( 'status', 'HTTP/1.1 424 Failed Dependency')
790  ))
791  ));
792  }
793  $response = new XMLElement( "multistatus", $response, array('xmlns'=>'DAV:') );
794  $xmldoc = $response->Render(0,'<?xml version="1.0" encoding="utf-8" ?>');
795  $this->DoResponse( 207, $xmldoc, 'text/xml; charset="utf-8"' );
796  // Which we won't come back from
797  }
798  return $existing_lock;
799  }
800  return false;
801  }
802 
803 
807  function CoerceContentType() {
808  if ( isset($this->content_type) ) {
809  $type = explode( '/', $this->content_type, 2);
811  if ( $type[0] == 'text' ) {
812  if ( !empty($type[1]) && ($type[1] == 'vcard' || $type[1] == 'calendar' || $type[1] == 'x-vcard') ) {
813  return;
814  }
815  }
816  }
817 
819  $first_word = trim(substr( $this->raw_post, 0, 30));
820  $first_word = strtoupper( preg_replace( '/\s.*/s', '', $first_word ) );
821  switch( $first_word ) {
822  case '<?XML':
823  dbg_error_log( 'LOG WARNING', 'Application sent content-type of "%s" instead of "text/xml"',
824  (isset($this->content_type)?$this->content_type:'(null)') );
825  $this->content_type = 'text/xml';
826  break;
827  case 'BEGIN:VCALENDAR':
828  dbg_error_log( 'LOG WARNING', 'Application sent content-type of "%s" instead of "text/calendar"',
829  (isset($this->content_type)?$this->content_type:'(null)') );
830  $this->content_type = 'text/calendar';
831  break;
832  case 'BEGIN:VCARD':
833  dbg_error_log( 'LOG WARNING', 'Application sent content-type of "%s" instead of "text/vcard"',
834  (isset($this->content_type)?$this->content_type:'(null)') );
835  $this->content_type = 'text/vcard';
836  break;
837  default:
838  dbg_error_log( 'LOG NOTICE', 'Unusual content-type of "%s" and first word of content is "%s"',
839  (isset($this->content_type)?$this->content_type:'(null)'), $first_word );
840  }
841  if ( empty($this->content_type) ) $this->content_type = 'text/plain';
842  }
843 
844 
848  function PreferMinimal() {
849  if ( empty($this->prefer) ) return false;
850  foreach( $this->prefer AS $v ) {
851  if ( $v == 'return=minimal' ) return true;
852  if ( $v == 'return-minimal' ) return true; // RFC7240 up until draft -15 (Oct 2012)
853  }
854  return false;
855  }
856 
860  function IsCollection( ) {
861  if ( !isset($this->_is_collection) ) {
862  $this->_is_collection = preg_match( '#/$#', $this->path );
863  }
864  return $this->_is_collection;
865  }
866 
867 
871  function IsCalendar( ) {
872  if ( !$this->IsCollection() || !isset($this->collection) ) return false;
873  return $this->collection->is_calendar == 't';
874  }
875 
876 
880  function IsAddressBook( ) {
881  if ( !$this->IsCollection() || !isset($this->collection) ) return false;
882  return $this->collection->is_addressbook == 't';
883  }
884 
885 
889  function IsPrincipal( ) {
890  if ( !isset($this->_is_principal) ) {
891  $this->_is_principal = preg_match( '#^/[^/]+/$#', $this->path );
892  }
893  return $this->_is_principal;
894  }
895 
896 
900  function IsProxyRequest( ) {
901  if ( !isset($this->_is_proxy_request) ) {
902  $this->_is_proxy_request = preg_match( '#^/[^/]+/calendar-proxy-(read|write)/?[^/]*$#', $this->path );
903  }
904  return $this->_is_proxy_request;
905  }
906 
907 
911  function IsInfiniteDepth( ) {
912  return ($this->depth == DEPTH_INFINITY);
913  }
914 
915 
919  function CollectionId( ) {
920  return $this->collection_id;
921  }
922 
923 
927  function BuildSupportedPrivileges( &$reply, $privs = null ) {
928  $privileges = array();
929  if ( $privs === null ) $privs = self::supportedPrivileges();
930  foreach( $privs AS $k => $v ) {
931  dbg_error_log( 'caldav', 'Adding privilege "%s" which is "%s".', $k, $v );
932  $privilege = new XMLElement('privilege');
933  $reply->NSElement($privilege,$k);
934  $privset = array($privilege);
935  if ( is_array($v) ) {
936  dbg_error_log( 'caldav', '"%s" is a container of sub-privileges.', $k );
937  $privset = array_merge($privset, $this->BuildSupportedPrivileges($reply,$v));
938  }
939  else if ( $v == 'abstract' ) {
940  dbg_error_log( 'caldav', '"%s" is an abstract privilege.', $v );
941  $privset[] = new XMLElement('abstract');
942  }
943  else if ( strlen($v) > 1 ) {
944  $privset[] = new XMLElement('description', $v);
945  }
946  $privileges[] = new XMLElement('supported-privilege',$privset);
947  }
948  return $privileges;
949  }
950 
951 
965  function AllowedTo( $activity ) {
966  global $session;
967  dbg_error_log('caldav', 'Checking whether "%s" is allowed to "%s"', $session->principal->username(), $activity);
968  if ( isset($this->permissions['all']) ) return true;
969  switch( $activity ) {
970  case 'all':
971  return false; // If they got this far then they don't
972  break;
973 
974  case "CALDAV:schedule-send-freebusy":
975  return isset($this->permissions['read']) || isset($this->permissions['urn:ietf:params:xml:ns:caldav:read-free-busy']);
976  break;
977 
978  case "CALDAV:schedule-send-invite":
979  return isset($this->permissions['read']) || isset($this->permissions['urn:ietf:params:xml:ns:caldav:read-free-busy']);
980  break;
981 
982  case "CALDAV:schedule-send-reply":
983  return isset($this->permissions['read']) || isset($this->permissions['urn:ietf:params:xml:ns:caldav:read-free-busy']);
984  break;
985 
986  case 'freebusy':
987  return isset($this->permissions['read']) || isset($this->permissions['urn:ietf:params:xml:ns:caldav:read-free-busy']);
988  break;
989 
990  case 'delete':
991  return isset($this->permissions['write']) || isset($this->permissions['unbind']);
992  break;
993 
994  case 'proppatch':
995  return isset($this->permissions['write']) || isset($this->permissions['write-properties']);
996  break;
997 
998  case 'modify':
999  return isset($this->permissions['write']) || isset($this->permissions['write-content']);
1000  break;
1001 
1002  case 'create':
1003  return isset($this->permissions['write']) || isset($this->permissions['bind']);
1004  break;
1005 
1006  case 'mkcalendar':
1007  case 'mkcol':
1008  if ( !isset($this->permissions['write']) || !isset($this->permissions['bind']) ) return false;
1009  if ( $this->is_principal ) return false;
1010  if ( $this->path == '/' ) return false;
1011  break;
1012 
1013  default:
1014  $test_bits = privilege_to_bits( $activity );
1015 // dbg_error_log( 'caldav', 'request::AllowedTo("%s") (%s) against allowed "%s" => "%s" (%s)',
1016 // (is_array($activity) ? implode(',',$activity) : $activity), decbin($test_bits),
1017 // decbin($this->privileges), ($this->privileges & $test_bits), decbin($this->privileges & $test_bits) );
1018  return (($this->privileges & $test_bits) > 0 );
1019  break;
1020  }
1021 
1022  return false;
1023  }
1024 
1025 
1026 
1030  function Privileges() {
1031  return $this->privileges;
1032  }
1033 
1034 
1041  function CheckEtagMatch( $exists, $dest_etag ) {
1042  global $c;
1043 
1044  if ( ! $exists ) {
1045  if ( (isset($this->etag_if_match) && $this->etag_if_match != '') ) {
1052  $this->PreconditionFailed(412, 'if-match', translate('No resource exists at the destination.'));
1053  }
1054  }
1055  else {
1056 
1057  if ( isset($c->strict_etag_checking) && $c->strict_etag_checking )
1058  $trim_chars = '\'\\" ';
1059  else
1060  $trim_chars = ' ';
1061 
1062  if ( isset($this->etag_if_match) && $this->etag_if_match != '' && $this->etag_if_match != '*'
1063  && trim( $this->etag_if_match, $trim_chars) != trim( $dest_etag, $trim_chars ) ) {
1070  $this->PreconditionFailed(412,'if-match',sprintf('Existing resource ETag of %s does not match %s', $dest_etag, $this->etag_if_match) );
1071  }
1072  else if ( isset($this->etag_none_match) && $this->etag_none_match != ''
1073  && ($this->etag_none_match == $dest_etag || $this->etag_none_match == '*') ) {
1082  $this->PreconditionFailed(412,'if-none-match', translate( 'Existing resource matches "If-None-Match" header - not accepted.'));
1083  }
1084  }
1085 
1086  }
1087 
1088 
1092  function HavePrivilegeTo( $do_what ) {
1093  $test_bits = privilege_to_bits( $do_what );
1094 // dbg_error_log( 'caldav', 'request::HavePrivilegeTo("%s") [%s] against allowed "%s" => "%s" (%s)',
1095 // (is_array($do_what) ? implode(',',$do_what) : $do_what), decbin($test_bits),
1096 // decbin($this->privileges), ($this->privileges & $test_bits), decbin($this->privileges & $test_bits) );
1097  return ($this->privileges & $test_bits) > 0;
1098  }
1099 
1100 
1105  function UnsupportedRequest( $unsupported ) {
1106  if ( isset($unsupported) && count($unsupported) > 0 ) {
1107  $badprops = new XMLElement( "prop" );
1108  foreach( $unsupported AS $k => $v ) {
1109  // Not supported at this point...
1110  dbg_error_log("ERROR", " %s: Support for $v:$k properties is not implemented yet", $this->method );
1111  $badprops->NewElement(strtolower($k),false,array("xmlns" => strtolower($v)));
1112  }
1113  $error = new XMLElement("error", $badprops, array("xmlns" => "DAV:") );
1114 
1115  $this->XMLResponse( 422, $error );
1116  }
1117  }
1118 
1119 
1128  function NeedPrivilege( $privileges, $href=null ) {
1129  if ( is_string($privileges) ) $privileges = array( $privileges );
1130  if ( !isset($href) ) {
1131  if ( $this->HavePrivilegeTo($privileges) ) return;
1132  $href = $this->path;
1133  }
1134 
1135  $reply = new XMLDocument( array('DAV:' => '') );
1136  $privnodes = array( $reply->href(ConstructURL($href)), new XMLElement( 'privilege' ) );
1137  // RFC3744 specifies that we can only respond with one needed privilege, so we pick the first.
1138  $reply->NSElement( $privnodes[1], $privileges[0] );
1139  $xml = new XMLElement( 'need-privileges', new XMLElement( 'resource', $privnodes) );
1140  $xmldoc = $reply->Render('error',$xml);
1141  $this->DoResponse( 403, $xmldoc, 'text/xml; charset="utf-8"' );
1142  exit(0); // Unecessary, but might clarify things
1143  }
1144 
1145 
1153  function PreconditionFailed( $status, $precondition, $explanation = '', $xmlns='DAV:') {
1154  $xmldoc = sprintf('<?xml version="1.0" encoding="utf-8" ?>
1155 <error xmlns="%s">
1156  <%s/>%s
1157 </error>', $xmlns, str_replace($xmlns.':', '', $precondition), $explanation );
1158 
1159  $this->DoResponse( $status, $xmldoc, 'text/xml; charset="utf-8"' );
1160  exit(0); // Unecessary, but might clarify things
1161  }
1162 
1163 
1169  function MalformedRequest( $text = 'Bad request' ) {
1170  $this->DoResponse( 400, $text );
1171  exit(0); // Unecessary, but might clarify things
1172  }
1173 
1174 
1181  function XMLResponse( $status, $xmltree ) {
1182  $xmldoc = $xmltree->Render(0,'<?xml version="1.0" encoding="utf-8" ?>');
1183  $etag = md5($xmldoc);
1184  if ( !headers_sent() ) header("ETag: \"$etag\"");
1185  $this->DoResponse( $status, $xmldoc, 'text/xml; charset="utf-8"' );
1186  exit(0); // Unecessary, but might clarify things
1187  }
1188 
1189  public static function kill_on_exit() {
1190  posix_kill( getmypid(), 28 );
1191  }
1192 
1200  function DoResponse( $status, $message="", $content_type="text/plain; charset=\"utf-8\"" ) {
1201  global $session, $c;
1202  if ( !headers_sent() ) @header( sprintf("HTTP/1.1 %d %s", $status, getStatusMessage($status)) );
1203  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) );
1204  if ( !headers_sent() ) header( "Content-type: ".$content_type );
1205 
1206  if ( (isset($c->dbg['ALL']) && $c->dbg['ALL']) || (isset($c->dbg['response']) && $c->dbg['response'])
1207  || $status == 400 || $status == 402 || $status == 403 || $status > 404 ) {
1208  @dbg_error_log( "LOG ", 'Response status %03d for %s %s', $status, $this->method, $_SERVER['REQUEST_URI'] );
1209  $lines = headers_list();
1210  dbg_error_log( "LOG ", "***************** Response Header ****************" );
1211  foreach( $lines AS $v ) {
1212  dbg_error_log( "LOG headers", "-->%s", $v );
1213  }
1214  dbg_error_log( "LOG ", "******************** Response ********************" );
1215  // Log the request in all it's gory detail.
1216  $lines = preg_split( '#[\r\n]+#', $message);
1217  foreach( $lines AS $v ) {
1218  dbg_error_log( "LOG response", "-->%s", $v );
1219  }
1220  }
1221 
1222  $script_finish = microtime(true);
1223  $script_time = $script_finish - $c->script_start_time;
1224  $message_length = strlen($message);
1225  if ( $message != '' ) {
1226  if ( !headers_sent() ) header( "Content-Length: ".$message_length );
1227  echo $message;
1228  }
1229 
1230  if ( isset($c->dbg['caldav']) && $c->dbg['caldav'] ) {
1231  if ( $message_length > 100 || strstr($message, "\n") ) {
1232  $message = substr( preg_replace("#\s+#m", ' ', $message ), 0, 100) . ($message_length > 100 ? "..." : "");
1233  }
1234 
1235  dbg_error_log("caldav", "Status: %d, Message: %s, User: %d, Path: %s", $status, $message, $session->principal->user_no(), $this->path);
1236  }
1237  if ( isset($c->dbg['statistics']) && $c->dbg['statistics'] ) {
1238  $memory = '';
1239  if ( function_exists('memory_get_usage') ) {
1240  $memory = sprintf( ', Memory: %dk, Peak: %dk', memory_get_usage()/1024, memory_get_peak_usage(true)/1024);
1241  }
1242  @dbg_error_log("statistics", "Method: %s, Status: %d, Script: %5.3lfs, Queries: %5.3lfs, URL: %s%s",
1243  $this->method, $status, $script_time, $c->total_query_time, $this->path, $memory);
1244  }
1245  try {
1246  @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();
1247  }
1248  catch( Exception $ignored ) {}
1249 
1250  if ( isset($c->metrics_style) && $c->metrics_style !== false ) {
1251  $flush_time = microtime(true) - $script_finish;
1252  $this->DoMetrics($status, $message_length, $script_time, $flush_time);
1253  }
1254 
1255  if ( isset($c->exit_after_memory_exceeds) && function_exists('memory_get_peak_usage') && memory_get_peak_usage(true) > $c->exit_after_memory_exceeds ) { // 64M
1256  @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());
1257  register_shutdown_function( 'CalDAVRequest::kill_on_exit' );
1258  }
1259 
1260  exit(0);
1261  }
1262 
1263 
1272  function DoMetrics($status, $response_size, $script_time, $flush_time) {
1273  global $c;
1274  static $ns = 'metrics';
1275 
1276  $method = (empty($this->method) ? 'UNKNOWN' : $this->method);
1277 
1278  // If they want 'both' or 'all' or something then that's what they will get
1279  // If they don't want counters, they must want to use memcache!
1280  if ( $c->metrics_style != 'counters' ) {
1281  $cache = getCacheInstance();
1282  if ( $cache->isActive() ) {
1283 
1284  $base_key = $method.':';
1285  $count_like_this = $cache->increment( $ns, $base_key.$status );
1286  $cache->increment( $ns, $base_key.'size', $response_size );
1287  $cache->increment( $ns, $base_key.'script_time', intval($script_time * 1000000) );
1288  $cache->increment( $ns, $base_key.'flush_time', intval($flush_time * 1000000) );
1289  $cache->increment( $ns, $base_key.'query_time', intval($c->total_query_time * 1000000) );
1290 
1291  if ( $count_like_this == 1 ) {
1292  // We need to maintain a set of details regarding the methods and statuses we have
1293  // encountered, so we know what to retrieve. Since this is the first one like
1294  // this, we add it to the index.
1295  try {
1296  $index = unserialize($cache->get($ns, 'index'));
1297  } catch (Exception $e) {
1298  $index = array('methods' => array(), 'statuses' => array());
1299  }
1300  $index['methods'][$method] = 1;
1301  $index['statuses'][$status] = 1;
1302  $cache->set($ns, 'index', serialize($index), 0);
1303  }
1304  }
1305  else {
1306  error_log("Full statistics are only available with a working Memcache configuration");
1307  }
1308  }
1309 
1310  // If they don't want memcache, they must want to use counters!
1311  if ( $c->metrics_style != 'memcache' ) {
1312  $qstring = "SELECT nextval('%s')";
1313  switch( $method ) {
1314  case 'OPTIONS':
1315  case 'REPORT':
1316  case 'PROPFIND':
1317  case 'GET':
1318  case 'PUT':
1319  case 'HEAD':
1320  case 'PROPPATCH':
1321  case 'POST':
1322  case 'MKCALENDAR':
1323  case 'MKCOL':
1324  case 'DELETE':
1325  case 'MOVE':
1326  case 'ACL':
1327  case 'LOCK':
1328  case 'UNLOCK':
1329  case 'MKTICKET':
1330  case 'DELTICKET':
1331  case 'BIND':
1332  $counter = strtolower($this->method);
1333  break;
1334  default:
1335  $counter = 'unknown';
1336  break;
1337  }
1338  $qry = new AwlQuery( "SELECT nextval('metrics_count_" . $counter . "')" );
1339  $qry->Exec('always',__LINE__,__FILE__);
1340  }
1341  }
1342 }
1343 
Principal
Definition: Principal.php:19
DAVPrincipal
Definition: DAVPrincipal.php:19
DAVTicket
Definition: DAVTicket.php:20