1<?php
2/**
3* Functions that are needed for all CalDAV Requests
4*
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.
9*
10* @package   davical
11* @subpackage   Request
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
15*/
16
17require_once("AwlCache.php");
18require_once("XMLDocument.php");
19require_once("DAVPrincipal.php");
20require_once("DAVTicket.php");
21
22define('DEPTH_INFINITY', 9999);
23
24
25/**
26* A class for collecting things to do with this request.
27*
28* @package   davical
29*/
30class CalDAVRequest
31{
32  var $options;
33
34  /**
35  * The raw data sent along with the request
36  */
37  var $raw_post;
38
39  /**
40  * The HTTP request method: PROPFIND, LOCK, REPORT, OPTIONS, etc...
41  */
42  var $method;
43
44  /**
45  * The depth parameter from the request headers, coerced into a valid integer: 0, 1
46  * or DEPTH_INFINITY which is defined above.  The default is set per various RFCs.
47  */
48  var $depth;
49
50  /**
51  * The 'principal' (user/resource/...) which this request seeks to access
52  * @var DAVPrincipal
53  */
54  var $principal;
55
56  /**
57  * The 'current_user_principal_xml' the DAV:current-user-principal answer. An
58  * XMLElement object with an <href> or <unauthenticated> fragment.
59  */
60  var $current_user_principal_xml;
61
62  /**
63  * The user agent making the request.
64  */
65  var $user_agent;
66
67  /**
68  * The ID of the collection containing this path, or of this path if it is a collection
69  */
70  var $collection_id;
71
72  /**
73  * The path corresponding to the collection_id
74  */
75  var $collection_path;
76
77  /**
78  * The type of collection being requested:
79  *  calendar, schedule-inbox, schedule-outbox
80  */
81  var $collection_type;
82
83  /**
84  * The type of collection being requested:
85  *  calendar, schedule-inbox, schedule-outbox
86  */
87  protected $exists;
88
89  /**
90  * The value of any 'Destionation:' header, if present.
91  */
92  var $destination;
93
94  /**
95  * The decimal privileges allowed by this user to the identified resource.
96  */
97  protected $privileges;
98
99  /**
100  * A static structure of supported privileges.
101  */
102  var $supported_privileges;
103
104  /**
105  * A DAVTicket object, if there is a ?ticket=id or Ticket: id with this request
106  */
107  public $ticket;
108
109  /**
110   * An array of values from the 'Prefer' header.  At present only 'return=minimal' is acted on in any way - you
111   * can test that value with the PreferMinimal() method.
112   */
113  private $prefer;
114
115  /**
116  * Create a new CalDAVRequest object.
117  */
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
133    /**
134    * Our path is /<script name>/<user name>/<user controlled> if it ends in
135    * a trailing '/' then it is referring to a DAV 'collection' but otherwise
136    * it is referring to a DAV data item.
137    *
138    * Permissions are controlled as follows:
139    *  1. if there is no <user name> component, the request has read privileges
140    *  2. if the requester is an admin, the request has read/write priviliges
141    *  3. if there is a <user name> component which matches the logged on user
142    *     then the request has read/write privileges
143    *  4. otherwise we query the defined relationships between users and use
144    *     the minimum privileges returned from that analysis.
145    */
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
164    /** Allow a request for .../calendar.ics to translate into the calendar URL */
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
241    /**
242    * A variety of requests may set the "Depth" header to control recursion
243    */
244    if ( isset($_SERVER['HTTP_DEPTH']) ) {
245      $this->depth = $_SERVER['HTTP_DEPTH'];
246    }
247    else {
248      /**
249      * Per rfc2518, section 9.2, 'Depth' might not always be present, and if it
250      * is not present then a reasonable request-type-dependent default should be
251      * chosen.
252      */
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
273    /**
274    * MOVE/COPY use a "Destination" header and (optionally) an "Overwrite" one.
275    */
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
284    /**
285    * LOCK things use an "If" header to hold the lock in some cases, and "Lock-token" in others
286    */
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
292    /**
293    * Check for an access ticket.
294    */
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
302    /**
303    * LOCK things use a "Timeout" header to set a series of reducing alternative values
304    */
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
322    /**
323    * RFC2518, 5.2: URL pointing to a collection SHOULD end in '/', and if it does not then
324    * we SHOULD return a Content-location header with the correction...
325    *
326    * We therefore look for a collection which matches one of the following URLs:
327    *  - The exact request.
328    *  - If the exact request, doesn't end in '/', then the request URL with a '/' appended
329    *  - The request URL truncated to the last '/'
330    * The collection URL for this request is therefore the longest row in the result, so we
331    * can "... ORDER BY LENGTH(dav_name) DESC LIMIT 1"
332    */
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
365INSERT 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 )
370EOSQL;
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) ) {
401      /** @todo we should deprecate this now that Evolution 2.27 can do scheduling extensions */
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
431    /**
432    * Extract the user whom we are accessing
433    */
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
464    /**
465    * Evaluate our permissions for accessing the target
466    */
467    $this->setPermissions();
468
469
470    /**
471    * If the content we are receiving is XML then we parse it here.  RFC2518 says we
472    * should reasonably expect to see either text/xml or application/xml
473    */
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
499    /**
500    * Look out for If-None-Match or If-Match headers
501    */
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
513  /**
514  * Permissions are controlled as follows:
515  *  1. if the path is '/', the request has read privileges
516  *  2. if the requester is an admin, the request has read/write priviliges
517  *  3. if there is a <user name> component which matches the logged on user
518  *     then the request has read/write privileges
519  *  4. otherwise we query the defined relationships between users and use
520  *     the minimum privileges returned from that analysis.
521  *
522  * @param int $user_no The current user number
523  *
524  */
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
547      /**
548      * In other cases we need to query the database for permissions
549      */
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
570    /** convert privileges into older style permissions */
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
586  /**
587  * Checks whether the resource is locked, returning any lock token, or false
588  *
589  * @todo This logic does not catch all locking scenarios.  For example an infinite
590  * depth request should check the permissions for all collections and resources within
591  * that.  At present we only maintain permissions on a per-collection basis though.
592  */
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
601      /**
602      * Find the locks that might apply and load them into an array
603      */
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
627  /**
628  * Checks whether the collection is public
629  */
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
669  /**
670  * Returns the dav_name of the resource in our internal namespace
671  */
672  function dav_name() {
673    if ( isset($this->path) ) return $this->path;
674    return null;
675  }
676
677
678  /**
679  * Returns the name for this depth: 0, 1, infinity
680  */
681  function GetDepthName( ) {
682    if ( $this->IsInfiniteDepth() ) return 'infinity';
683    return $this->depth;
684  }
685
686  /**
687  * Returns the tail of a Regex appropriate for this Depth, when appended to
688  *
689  */
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
697  /**
698  * Returns the locked row, either from the cache or from the database
699  *
700  * @param string $dav_name The resource which we want to know the lock status for
701  */
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
721  /**
722  * Checks to see whether the lock token given matches one of the ones handed in
723  * with the request.
724  *
725  * @param string $lock_token The opaquelocktoken which we are looking for
726  */
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
753  /**
754  * Returns the DB object associated with a lock token, or false.
755  *
756  * @param string $lock_token The opaquelocktoken which we are looking for
757  */
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
765  /**
766  * This will either (a) return false if no locks apply, or (b) return the lock_token
767  * which the request successfully included to open the lock, or:
768  * (c) respond directly to the client with the failure.
769  *
770  * @return mixed false (no lock) or opaquelocktoken (opened lock)
771  */
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);
777        /**
778        * Already locked - deny it
779        */
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
804  /**
805  * Coerces the Content-type of the request into something valid/appropriate
806  */
807  function CoerceContentType() {
808    if ( isset($this->content_type) ) {
809      $type = explode( '/', $this->content_type, 2);
810      /** @todo: Perhaps we should look at the target collection type, also. */
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
818    /** Null (or peculiar) content-type supplied so we have to try and work it out... */
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
845  /**
846   * Returns true if the 'Prefer: return=minimal' or 'Brief: t' were present in the request headers.
847   */
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
857  /**
858  * Returns true if the URL referenced by this request points at a collection.
859  */
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
868  /**
869  * Returns true if the URL referenced by this request points at a calendar collection.
870  */
871  function IsCalendar( ) {
872    if ( !$this->IsCollection() || !isset($this->collection) ) return false;
873    return $this->collection->is_calendar == 't';
874  }
875
876
877  /**
878  * Returns true if the URL referenced by this request points at an addressbook collection.
879  */
880  function IsAddressBook( ) {
881    if ( !$this->IsCollection() || !isset($this->collection) ) return false;
882    return $this->collection->is_addressbook == 't';
883  }
884
885
886  /**
887  * Returns true if the URL referenced by this request points at a principal.
888  */
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
897  /**
898  * Returns true if the URL referenced by this request is within a proxy URL
899  */
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
908  /**
909  * Returns true if the request asked for infinite depth
910  */
911  function IsInfiniteDepth( ) {
912    return ($this->depth == DEPTH_INFINITY);
913  }
914
915
916  /**
917  * Returns the ID of the collection of, or containing this request
918  */
919  function CollectionId( ) {
920    return $this->collection_id;
921  }
922
923
924  /**
925  * Returns the array of supported privileges converted into XMLElements
926  */
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
952  /**
953  * Are we allowed to do the requested activity
954  *
955  * +------------+------------------------------------------------------+
956  * | METHOD     | PRIVILEGES                                           |
957  * +------------+------------------------------------------------------+
958  * | MKCALENDAR | DAV:bind                                             |
959  * | REPORT     | DAV:read or CALDAV:read-free-busy (on all referenced |
960  * |            | resources)                                           |
961  * +------------+------------------------------------------------------+
962  *
963  * @param string $activity The activity we want to do.
964  */
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
1027  /**
1028  * Return the privileges bits for the current session user to this resource
1029  */
1030  function Privileges() {
1031    return $this->privileges;
1032  }
1033
1034
1035  /**
1036   * Check that the incoming Etag matches the one for the existing (or non-existing) resource.
1037   *
1038   * @param boolean $exists Whether the destination exists
1039   * @param string $dest_etag The etag for the destination.
1040   */
1041  function CheckEtagMatch( $exists, $dest_etag ) {
1042    global $c;
1043
1044    if ( ! $exists ) {
1045      if ( (isset($this->etag_if_match) && $this->etag_if_match != '') ) {
1046        /**
1047        * RFC2068, 14.25:
1048        * If none of the entity tags match, or if "*" is given and no current
1049        * entity exists, the server MUST NOT perform the requested method, and
1050        * MUST return a 412 (Precondition Failed) response.
1051        */
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 ) ) {
1064        /**
1065        * RFC2068, 14.25:
1066        * If none of the entity tags match, or if "*" is given and no current
1067        * entity exists, the server MUST NOT perform the requested method, and
1068        * MUST return a 412 (Precondition Failed) response.
1069        */
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 == '*') ) {
1074        /**
1075        * RFC2068, 14.26:
1076        * If any of the entity tags match the entity tag of the entity that
1077        * would have been returned in the response to a similar GET request
1078        * (without the If-None-Match header) on that resource, or if "*" is
1079        * given and any current entity exists for that resource, then the
1080        * server MUST NOT perform the requested method.
1081        */
1082        $this->PreconditionFailed(412,'if-none-match', translate( 'Existing resource matches "If-None-Match" header - not accepted.'));
1083      }
1084    }
1085
1086  }
1087
1088
1089  /**
1090  * Is the user has the privileges to do what is requested.
1091  */
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
1101  /**
1102  * Sometimes it's a perfectly formed request, but we just don't do that :-(
1103  * @param array $unsupported An array of the properties we don't support.
1104  */
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
1120  /**
1121  * Send a need-privileges error response.  This function will only return
1122  * if the $href is not supplied and the current user has the specified
1123  * permission for the request path.
1124  *
1125  * @param string $privilege The name of the needed privilege.
1126  * @param string $href The unconstructed URI where we needed the privilege.
1127  */
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
1146  /**
1147  * Send an error response for a failed precondition.
1148  *
1149  * @param int $status The status code for the failed precondition.  Normally 403
1150  * @param string $precondition The namespaced precondition tag.
1151  * @param string $explanation An optional text explanation for the failure.
1152  */
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
1164  /**
1165  * Send a simple error informing the client that was a malformed request
1166  *
1167  * @param string $text An optional text description of the failure.
1168  */
1169  function MalformedRequest( $text = 'Bad request' ) {
1170    $this->DoResponse( 400, $text );
1171    exit(0);  // Unecessary, but might clarify things
1172  }
1173
1174
1175  /**
1176  * Send an XML Response.  This function will never return.
1177  *
1178  * @param int $status The HTTP status to respond
1179  * @param XMLElement $xmltree An XMLElement tree to be rendered
1180  */
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
1193  /**
1194  * Utility function we call when we have a simple status-based response to
1195  * return to the client.  Possibly
1196  *
1197  * @param int $status The HTTP status code to send.
1198  * @param string $message The friendly text message to send with the response.
1199  */
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
1264  /**
1265  * Record the metrics related to this request.
1266  *
1267  * @param status The HTTP status code for this response
1268  * @param response_size The size of the response (bytes).
1269  * @param script_time The time taken to generate the response (pre-sending)
1270  * @param flush_time The time taken to send the response (buffers flushed)
1271  */
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
1344