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