Overview

Packages

  • awl
    • caldav-client-v2
    • RRule
  • davical
    • authentication
      • drivers
    • caldav
    • DAViCalSession
    • DAVTicket
    • external-bind
    • feed
    • HTTPAuthSession
    • iSchedule
    • iSchedule-POST
    • logging
    • metrics
    • Principal
    • propfind
    • PublicSession
    • Request
    • Resource
    • tzservice
  • None
  • PHP

Classes

  • CalDAVRequest
  • Overview
  • Package
  • Class
  • Tree
  • Deprecated
  • Todo
   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: 
  17: require_once("AwlCache.php");
  18: require_once("XMLDocument.php");
  19: require_once("DAVPrincipal.php");
  20: require_once("DAVTicket.php");
  21: 
  22: define('DEPTH_INFINITY', 9999);
  23: 
  24: 
  25: /**
  26: * A class for collecting things to do with this request.
  27: *
  28: * @package   davical
  29: */
  30: class 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
 365: INSERT INTO collection ( user_no, parent_container, dav_name, dav_displayname, is_calendar, created, modified, dav_etag, resourcetypes )
 366:     VALUES( (SELECT user_no FROM usr WHERE username = text(:username)),
 367:             :parent_container, :dav_name,
 368:             (SELECT fullname FROM usr WHERE username = text(:username)) || :boxname,
 369:              FALSE, current_timestamp, current_timestamp, '1', :resourcetypes )
 370: EOSQL;
 371: 
 372:       $qry = new AwlQuery( $sql, $params );
 373:       $qry->Exec('caldav',__LINE__,__FILE__);
 374:       dbg_error_log( 'caldav', 'Created new collection as "%s".', trim($params[':boxname']) );
 375: 
 376:       // Uncache anything to do with the collection
 377:       $cache = getCacheInstance();
 378:       $cache->delete( 'collection-'.$params[':dav_name'], null );
 379:       $cache->delete( 'principal-'.$params[':parent_container'], null );
 380: 
 381:       $qry = new AwlQuery( "SELECT * FROM collection WHERE dav_name = :dav_name", array( ':dav_name' => $matches[1] ) );
 382:       if ( $qry->Exec('caldav',__LINE__,__FILE__) && $qry->rows() == 1 && ($row = $qry->Fetch()) ) {
 383:         $this->collection_id = $row->collection_id;
 384:         $this->collection_path = $matches[1];
 385:         $this->collection = $row;
 386:         $this->collection->type = $this->collection_type;
 387:       }
 388:     }
 389:     else if ( preg_match( '#^((/[^/]+/)calendar-proxy-(read|write))/?[^/]*$#', $this->path, $matches ) ) {
 390:       $this->collection_type = 'proxy';
 391:       $this->_is_proxy_request = true;
 392:       $this->proxy_type = $matches[3];
 393:       $this->collection_path = $matches[1].'/';  // Enforce trailling '/'
 394:       if ( $this->collection_path == $this->path."/" ) {
 395:         $this->path .= '/';
 396:         dbg_error_log( "caldav", "Path is actually a (proxy) collection - sending Content-Location header." );
 397:         header( "Content-Location: ".ConstructURL($this->path) );
 398:       }
 399:     }
 400:     else if ( $this->options['allow_by_email'] && preg_match( '#^/(\S+@\S+[.]\S+)/?$#', $this->path) ) {
 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:     $this->supported_methods = array(
 470:       'OPTIONS' => '',
 471:       'PROPFIND' => '',
 472:       'REPORT' => '',
 473:       'DELETE' => '',
 474:       'LOCK' => '',
 475:       'UNLOCK' => '',
 476:       'MOVE' => '',
 477:       'ACL' => ''
 478:     );
 479:     if ( $this->IsCollection() ) {
 480:       switch ( $this->collection_type ) {
 481:         case 'root':
 482:         case 'email':
 483:           // We just override the list completely here.
 484:           $this->supported_methods = array(
 485:             'OPTIONS' => '',
 486:             'PROPFIND' => '',
 487:             'REPORT' => ''
 488:           );
 489:           break;
 490:         case 'schedule-inbox':
 491:         case 'schedule-outbox':
 492:           $this->supported_methods = array_merge(
 493:             $this->supported_methods,
 494:             array(
 495:               'POST' => '', 'GET' => '', 'PUT' => '', 'HEAD' => '', 'PROPPATCH' => ''
 496:             )
 497:           );
 498:           break;
 499:         case 'calendar':
 500:           $this->supported_methods['GET'] = '';
 501:           $this->supported_methods['PUT'] = '';
 502:           $this->supported_methods['HEAD'] = '';
 503:           break;
 504:         case 'collection':
 505:         case 'principal':
 506:           $this->supported_methods['GET'] = '';
 507:           $this->supported_methods['PUT'] = '';
 508:           $this->supported_methods['HEAD'] = '';
 509:           $this->supported_methods['MKCOL'] = '';
 510:           $this->supported_methods['MKCALENDAR'] = '';
 511:           $this->supported_methods['PROPPATCH'] = '';
 512:           $this->supported_methods['BIND'] = '';
 513:           break;
 514:       }
 515:     }
 516:     else {
 517:       $this->supported_methods = array_merge(
 518:         $this->supported_methods,
 519:         array(
 520:           'GET' => '',
 521:           'HEAD' => '',
 522:           'PUT' => ''
 523:         )
 524:       );
 525:     }
 526: 
 527:     $this->supported_reports = array(
 528:       'DAV::principal-property-search' => '',
 529:       'DAV::expand-property' => '',
 530:       'DAV::sync-collection' => ''
 531:     );
 532:     if ( isset($this->collection) && $this->collection->is_calendar ) {
 533:       $this->supported_reports = array_merge(
 534:         $this->supported_reports,
 535:         array(
 536:           'urn:ietf:params:xml:ns:caldav:calendar-query' => '',
 537:           'urn:ietf:params:xml:ns:caldav:calendar-multiget' => '',
 538:           'urn:ietf:params:xml:ns:caldav:free-busy-query' => ''
 539:         )
 540:       );
 541:     }
 542:     if ( isset($this->collection) && $this->collection->is_addressbook ) {
 543:       $this->supported_reports = array_merge(
 544:         $this->supported_reports,
 545:         array(
 546:           'urn:ietf:params:xml:ns:carddav:addressbook-query' => '',
 547:           'urn:ietf:params:xml:ns:carddav:addressbook-multiget' => ''
 548:         )
 549:       );
 550:     }
 551: 
 552: 
 553:     /**
 554:     * If the content we are receiving is XML then we parse it here.  RFC2518 says we
 555:     * should reasonably expect to see either text/xml or application/xml
 556:     */
 557:     if ( isset($this->content_type) && preg_match( '#(application|text)/xml#', $this->content_type ) ) {
 558:       if ( !isset($this->raw_post) || $this->raw_post == '' ) {
 559:         $this->XMLResponse( 400, new XMLElement( 'error', new XMLElement('missing-xml'), array( 'xmlns' => 'DAV:') ) );
 560:       }
 561:       $xml_parser = xml_parser_create_ns('UTF-8');
 562:       $this->xml_tags = array();
 563:       xml_parser_set_option ( $xml_parser, XML_OPTION_SKIP_WHITE, 1 );
 564:       xml_parser_set_option ( $xml_parser, XML_OPTION_CASE_FOLDING, 0 );
 565:       $rc = xml_parse_into_struct( $xml_parser, $this->raw_post, $this->xml_tags );
 566:       if ( $rc == false ) {
 567:         dbg_error_log( 'ERROR', 'XML parsing error: %s at line %d, column %d',
 568:                     xml_error_string(xml_get_error_code($xml_parser)),
 569:                     xml_get_current_line_number($xml_parser), xml_get_current_column_number($xml_parser) );
 570:         $this->XMLResponse( 400, new XMLElement( 'error', new XMLElement('invalid-xml'), array( 'xmlns' => 'DAV:') ) );
 571:       }
 572:       xml_parser_free($xml_parser);
 573:       if ( count($this->xml_tags) ) {
 574:         dbg_error_log( "caldav", " Parsed incoming XML request body." );
 575:       }
 576:       else {
 577:         $this->xml_tags = null;
 578:         dbg_error_log( "ERROR", "Incoming request sent content-type XML with no XML request body." );
 579:       }
 580:     }
 581: 
 582:     /**
 583:     * Look out for If-None-Match or If-Match headers
 584:     */
 585:     if ( isset($_SERVER["HTTP_IF_NONE_MATCH"]) ) {
 586:       $this->etag_none_match = $_SERVER["HTTP_IF_NONE_MATCH"];
 587:       if ( $this->etag_none_match == '' ) unset($this->etag_none_match);
 588:     }
 589:     if ( isset($_SERVER["HTTP_IF_MATCH"]) ) {
 590:       $this->etag_if_match = $_SERVER["HTTP_IF_MATCH"];
 591:       if ( $this->etag_if_match == '' ) unset($this->etag_if_match);
 592:     }
 593:   }
 594: 
 595: 
 596:   /**
 597:   * Permissions are controlled as follows:
 598:   *  1. if the path is '/', the request has read privileges
 599:   *  2. if the requester is an admin, the request has read/write priviliges
 600:   *  3. if there is a <user name> component which matches the logged on user
 601:   *     then the request has read/write privileges
 602:   *  4. otherwise we query the defined relationships between users and use
 603:   *     the minimum privileges returned from that analysis.
 604:   *
 605:   * @param int $user_no The current user number
 606:   *
 607:   */
 608:   function setPermissions() {
 609:     global $c, $session;
 610: 
 611:     if ( $this->path == '/' || $this->path == '' ) {
 612:       $this->privileges = privilege_to_bits( array('read','read-free-busy','read-acl'));
 613:       dbg_error_log( "caldav", "Full read permissions for user accessing /" );
 614:     }
 615:     else if ( $session->AllowedTo("Admin") || $session->principal->user_no() == $this->user_no ) {
 616:       $this->privileges = privilege_to_bits('all');
 617:       dbg_error_log( "caldav", "Full permissions for %s", ( $session->principal->user_no() == $this->user_no ? "user accessing their own hierarchy" : "a systems administrator") );
 618:     }
 619:     else {
 620:       $this->privileges = 0;
 621:       if ( $this->IsPublic() ) {
 622:         $this->privileges = privilege_to_bits(array('read','read-free-busy'));
 623:         dbg_error_log( "caldav", "Basic read permissions for user accessing a public collection" );
 624:       }
 625:       else if ( isset($c->public_freebusy_url) && $c->public_freebusy_url ) {
 626:         $this->privileges = privilege_to_bits('read-free-busy');
 627:         dbg_error_log( "caldav", "Basic free/busy permissions for user accessing a public free/busy URL" );
 628:       }
 629: 
 630:       /**
 631:       * In other cases we need to query the database for permissions
 632:       */
 633:       $params = array( ':session_principal_id' => $session->principal->principal_id(), ':scan_depth' => $c->permission_scan_depth );
 634:       if ( isset($this->by_email) && $this->by_email ) {
 635:         $sql ='SELECT pprivs( :session_principal_id::int8, :request_principal_id::int8, :scan_depth::int ) AS perm';
 636:         $params[':request_principal_id'] = $this->principal_id;
 637:       }
 638:       else {
 639:         $sql = 'SELECT path_privs( :session_principal_id::int8, :request_path::text, :scan_depth::int ) AS perm';
 640:         $params[':request_path'] = $this->path;
 641:       }
 642:       $qry = new AwlQuery( $sql, $params );
 643:       if ( $qry->Exec('caldav',__LINE__,__FILE__) && $permission_result = $qry->Fetch() )
 644:         $this->privileges |= bindec($permission_result->perm);
 645: 
 646:       dbg_error_log( 'caldav', 'Restricted permissions for user accessing someone elses hierarchy: %s', decbin($this->privileges) );
 647:       if ( isset($this->ticket) && $this->ticket->MatchesPath($this->path) ) {
 648:         $this->privileges |= $this->ticket->privileges();
 649:         dbg_error_log( 'caldav', 'Applying permissions for ticket "%s" now: %s', $this->ticket->id(), decbin($this->privileges) );
 650:       }
 651:     }
 652: 
 653:     /** convert privileges into older style permissions */
 654:     $this->permissions = array();
 655:     $privs = bits_to_privilege($this->privileges);
 656:     foreach( $privs AS $k => $v ) {
 657:       switch( $v ) {
 658:         case 'DAV::all':    $type = 'abstract';   break;
 659:         case 'DAV::write':  $type = 'aggregate';  break;
 660:         default: $type = 'real';
 661:       }
 662:       $v = str_replace('DAV::', '', $v);
 663:       $this->permissions[$v] = $type;
 664:     }
 665: 
 666:   }
 667: 
 668: 
 669:   /**
 670:   * Checks whether the resource is locked, returning any lock token, or false
 671:   *
 672:   * @todo This logic does not catch all locking scenarios.  For example an infinite
 673:   * depth request should check the permissions for all collections and resources within
 674:   * that.  At present we only maintain permissions on a per-collection basis though.
 675:   */
 676:   function IsLocked() {
 677:     if ( !isset($this->_locks_found) ) {
 678:       $this->_locks_found = array();
 679: 
 680:       $sql = 'DELETE FROM locks WHERE (start + timeout) < current_timestamp';
 681:       $qry = new AwlQuery($sql);
 682:       $qry->Exec('caldav',__LINE__,__FILE__);
 683: 
 684:       /**
 685:       * Find the locks that might apply and load them into an array
 686:       */
 687:       $sql = 'SELECT * FROM locks WHERE :dav_name::text ~ (\'^\'||dav_name||:pattern_end_match)::text';
 688:       $qry = new AwlQuery($sql, array( ':dav_name' => $this->path, ':pattern_end_match' => ($this->IsInfiniteDepth() ? '' : '$') ) );
 689:       if ( $qry->Exec('caldav',__LINE__,__FILE__) ) {
 690:         while( $lock_row = $qry->Fetch() ) {
 691:           $this->_locks_found[$lock_row->opaquelocktoken] = $lock_row;
 692:         }
 693:       }
 694:       else {
 695:         $this->DoResponse(500,translate("Database Error"));
 696:         // Does not return.
 697:       }
 698:     }
 699: 
 700:     foreach( $this->_locks_found AS $lock_token => $lock_row ) {
 701:       if ( $lock_row->depth == DEPTH_INFINITY || $lock_row->dav_name == $this->path ) {
 702:         return $lock_token;
 703:       }
 704:     }
 705: 
 706:     return false;  // Nothing matched
 707:   }
 708: 
 709: 
 710:   /**
 711:   * Checks whether the collection is public
 712:   */
 713:   function IsPublic() {
 714:     if ( isset($this->collection) && isset($this->collection->publicly_readable) && $this->collection->publicly_readable == 't' ) {
 715:       return true;
 716:     }
 717:     return false;
 718:   }
 719: 
 720: 
 721:   private static function supportedPrivileges() {
 722:     return array(
 723:       'all' => array(
 724:         'read' => translate('Read the content of a resource or collection'),
 725:         'write' => array(
 726:           'bind' => translate('Create a resource or collection'),
 727:           'unbind' => translate('Delete a resource or collection'),
 728:           'write-content' => translate('Write content'),
 729:           'write-properties' => translate('Write properties')
 730:         ),
 731:         'urn:ietf:params:xml:ns:caldav:read-free-busy' => translate('Read the free/busy information for a calendar collection'),
 732:         'read-acl' => translate('Read ACLs for a resource or collection'),
 733:         'read-current-user-privilege-set' => translate('Read the details of the current user\'s access control to this resource.'),
 734:         'write-acl' => translate('Write ACLs for a resource or collection'),
 735:         'unlock' => translate('Remove a lock'),
 736: 
 737:         'urn:ietf:params:xml:ns:caldav:schedule-deliver' => array(
 738:           'urn:ietf:params:xml:ns:caldav:schedule-deliver-invite'=> translate('Deliver scheduling invitations from an organiser to this scheduling inbox'),
 739:           'urn:ietf:params:xml:ns:caldav:schedule-deliver-reply' => translate('Deliver scheduling replies from an attendee to this scheduling inbox'),
 740:           'urn:ietf:params:xml:ns:caldav:schedule-query-freebusy' => translate('Allow free/busy enquiries targeted at the owner of this scheduling inbox')
 741:         ),
 742: 
 743:         'urn:ietf:params:xml:ns:caldav:schedule-send' => array(
 744:           'urn:ietf:params:xml:ns:caldav:schedule-send-invite' => translate('Send scheduling invitations as an organiser from the owner of this scheduling outbox.'),
 745:           'urn:ietf:params:xml:ns:caldav:schedule-send-reply' => translate('Send scheduling replies as an attendee from the owner of this scheduling outbox.'),
 746:           'urn:ietf:params:xml:ns:caldav:schedule-send-freebusy' => translate('Send free/busy enquiries')
 747:         )
 748:       )
 749:     );
 750:   }
 751: 
 752:   /**
 753:   * Returns the dav_name of the resource in our internal namespace
 754:   */
 755:   function dav_name() {
 756:     if ( isset($this->path) ) return $this->path;
 757:     return null;
 758:   }
 759: 
 760: 
 761:   /**
 762:   * Returns the name for this depth: 0, 1, infinity
 763:   */
 764:   function GetDepthName( ) {
 765:     if ( $this->IsInfiniteDepth() ) return 'infinity';
 766:     return $this->depth;
 767:   }
 768: 
 769:   /**
 770:   * Returns the tail of a Regex appropriate for this Depth, when appended to
 771:   *
 772:   */
 773:   function DepthRegexTail( $for_collection_report = false) {
 774:     if ( $this->IsInfiniteDepth() ) return '';
 775:     if ( $this->depth == 0 && $for_collection_report ) return '[^/]+$';
 776:     if ( $this->depth == 0 ) return '$';
 777:     return '[^/]*/?$';
 778:   }
 779: 
 780:   /**
 781:   * Returns the locked row, either from the cache or from the database
 782:   *
 783:   * @param string $dav_name The resource which we want to know the lock status for
 784:   */
 785:   function GetLockRow( $lock_token ) {
 786:     if ( isset($this->_locks_found) && isset($this->_locks_found[$lock_token]) ) {
 787:       return $this->_locks_found[$lock_token];
 788:     }
 789: 
 790:     $qry = new AwlQuery('SELECT * FROM locks WHERE opaquelocktoken = :lock_token', array( ':lock_token' => $lock_token ) );
 791:     if ( $qry->Exec('caldav',__LINE__,__FILE__) ) {
 792:       $lock_row = $qry->Fetch();
 793:       $this->_locks_found = array( $lock_token => $lock_row );
 794:       return $this->_locks_found[$lock_token];
 795:     }
 796:     else {
 797:       $this->DoResponse( 500, translate("Database Error") );
 798:     }
 799: 
 800:     return false;  // Nothing matched
 801:   }
 802: 
 803: 
 804:   /**
 805:   * Checks to see whether the lock token given matches one of the ones handed in
 806:   * with the request.
 807:   *
 808:   * @param string $lock_token The opaquelocktoken which we are looking for
 809:   */
 810:   function ValidateLockToken( $lock_token ) {
 811:     if ( isset($this->lock_token) && $this->lock_token == $lock_token ) {
 812:       dbg_error_log( "caldav", "They supplied a valid lock token.  Great!" );
 813:       return true;
 814:     }
 815:     if ( isset($this->if_clause) ) {
 816:       dbg_error_log( "caldav", "Checking lock token '%s' against '%s'", $lock_token, $this->if_clause );
 817:       $tokens = preg_split( '/[<>]/', $this->if_clause );
 818:       foreach( $tokens AS $k => $v ) {
 819:         dbg_error_log( "caldav", "Checking lock token '%s' against '%s'", $lock_token, $v );
 820:         if ( 'opaquelocktoken:' == substr( $v, 0, 16 ) ) {
 821:           if ( substr( $v, 16 ) == $lock_token ) {
 822:             dbg_error_log( "caldav", "Lock token '%s' validated OK against '%s'", $lock_token, $v );
 823:             return true;
 824:           }
 825:         }
 826:       }
 827:     }
 828:     else {
 829:       @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 );
 830:     }
 831: 
 832:     return false;
 833:   }
 834: 
 835: 
 836:   /**
 837:   * Returns the DB object associated with a lock token, or false.
 838:   *
 839:   * @param string $lock_token The opaquelocktoken which we are looking for
 840:   */
 841:   function GetLockDetails( $lock_token ) {
 842:     if ( !isset($this->_locks_found) && false === $this->IsLocked() ) return false;
 843:     if ( isset($this->_locks_found[$lock_token]) ) return $this->_locks_found[$lock_token];
 844:     return false;
 845:   }
 846: 
 847: 
 848:   /**
 849:   * This will either (a) return false if no locks apply, or (b) return the lock_token
 850:   * which the request successfully included to open the lock, or:
 851:   * (c) respond directly to the client with the failure.
 852:   *
 853:   * @return mixed false (no lock) or opaquelocktoken (opened lock)
 854:   */
 855:   function FailIfLocked() {
 856:     if ( $existing_lock = $this->IsLocked() ) { // NOTE Assignment in if() is expected here.
 857:       dbg_error_log( "caldav", "There is a lock on '%s'", $this->path);
 858:       if ( ! $this->ValidateLockToken($existing_lock) ) {
 859:         $lock_row = $this->GetLockRow($existing_lock);
 860:         /**
 861:         * Already locked - deny it
 862:         */
 863:         $response[] = new XMLElement( 'response', array(
 864:             new XMLElement( 'href',   $lock_row->dav_name ),
 865:             new XMLElement( 'status', 'HTTP/1.1 423 Resource Locked')
 866:         ));
 867:         if ( $lock_row->dav_name != $this->path ) {
 868:           $response[] = new XMLElement( 'response', array(
 869:               new XMLElement( 'href',   $this->path ),
 870:               new XMLElement( 'propstat', array(
 871:                 new XMLElement( 'prop', new XMLElement( 'lockdiscovery' ) ),
 872:                 new XMLElement( 'status', 'HTTP/1.1 424 Failed Dependency')
 873:               ))
 874:           ));
 875:         }
 876:         $response = new XMLElement( "multistatus", $response, array('xmlns'=>'DAV:') );
 877:         $xmldoc = $response->Render(0,'<?xml version="1.0" encoding="utf-8" ?>');
 878:         $this->DoResponse( 207, $xmldoc, 'text/xml; charset="utf-8"' );
 879:         // Which we won't come back from
 880:       }
 881:       return $existing_lock;
 882:     }
 883:     return false;
 884:   }
 885: 
 886: 
 887:   /**
 888:   * Coerces the Content-type of the request into something valid/appropriate
 889:   */
 890:   function CoerceContentType() {
 891:     if ( isset($this->content_type) ) {
 892:       $type = explode( '/', $this->content_type, 2);
 893:       /** @todo: Perhaps we should look at the target collection type, also. */
 894:       if ( $type[0] == 'text' ) {
 895:         if ( !empty($type[1]) && ($type[1] == 'vcard' || $type[1] == 'calendar' || $type[1] == 'x-vcard') ) {
 896:           return;
 897:         }
 898:       }
 899:     }
 900: 
 901:     /** Null (or peculiar) content-type supplied so we have to try and work it out... */
 902:     $first_word = trim(substr( $this->raw_post, 0, 30));
 903:     $first_word = strtoupper( preg_replace( '/\s.*/s', '', $first_word ) );
 904:     switch( $first_word ) {
 905:       case '<?XML':
 906:         dbg_error_log( 'LOG WARNING', 'Application sent content-type of "%s" instead of "text/xml"',
 907:                                         (isset($this->content_type)?$this->content_type:'(null)') );
 908:         $this->content_type = 'text/xml';
 909:         break;
 910:       case 'BEGIN:VCALENDAR':
 911:         dbg_error_log( 'LOG WARNING', 'Application sent content-type of "%s" instead of "text/calendar"',
 912:                                         (isset($this->content_type)?$this->content_type:'(null)') );
 913:         $this->content_type = 'text/calendar';
 914:         break;
 915:       case 'BEGIN:VCARD':
 916:         dbg_error_log( 'LOG WARNING', 'Application sent content-type of "%s" instead of "text/vcard"',
 917:                                         (isset($this->content_type)?$this->content_type:'(null)') );
 918:         $this->content_type = 'text/vcard';
 919:         break;
 920:       default:
 921:         dbg_error_log( 'LOG NOTICE', 'Unusual content-type of "%s" and first word of content is "%s"',
 922:                                         (isset($this->content_type)?$this->content_type:'(null)'), $first_word );
 923:     }
 924:     if ( empty($this->content_type) ) $this->content_type = 'text/plain';
 925:   }
 926: 
 927: 
 928:   /**
 929:    * Returns true if the 'Prefer: return=minimal' or 'Brief: t' were present in the request headers.
 930:    */
 931:   function PreferMinimal() {
 932:     if ( empty($this->prefer) ) return false;
 933:     foreach( $this->prefer AS $v ) {
 934:       if ( $v == 'return=minimal' ) return true;
 935:       if ( $v == 'return-minimal' ) return true; // RFC7240 up until draft -15 (Oct 2012)
 936:     }
 937:     return false;
 938:   }
 939: 
 940:   /**
 941:   * Returns true if the URL referenced by this request points at a collection.
 942:   */
 943:   function IsCollection( ) {
 944:     if ( !isset($this->_is_collection) ) {
 945:       $this->_is_collection = preg_match( '#/$#', $this->path );
 946:     }
 947:     return $this->_is_collection;
 948:   }
 949: 
 950: 
 951:   /**
 952:   * Returns true if the URL referenced by this request points at a calendar collection.
 953:   */
 954:   function IsCalendar( ) {
 955:     if ( !$this->IsCollection() || !isset($this->collection) ) return false;
 956:     return $this->collection->is_calendar == 't';
 957:   }
 958: 
 959: 
 960:   /**
 961:   * Returns true if the URL referenced by this request points at an addressbook collection.
 962:   */
 963:   function IsAddressBook( ) {
 964:     if ( !$this->IsCollection() || !isset($this->collection) ) return false;
 965:     return $this->collection->is_addressbook == 't';
 966:   }
 967: 
 968: 
 969:   /**
 970:   * Returns true if the URL referenced by this request points at a principal.
 971:   */
 972:   function IsPrincipal( ) {
 973:     if ( !isset($this->_is_principal) ) {
 974:       $this->_is_principal = preg_match( '#^/[^/]+/$#', $this->path );
 975:     }
 976:     return $this->_is_principal;
 977:   }
 978: 
 979: 
 980:   /**
 981:   * Returns true if the URL referenced by this request is within a proxy URL
 982:   */
 983:   function IsProxyRequest( ) {
 984:     if ( !isset($this->_is_proxy_request) ) {
 985:       $this->_is_proxy_request = preg_match( '#^/[^/]+/calendar-proxy-(read|write)/?[^/]*$#', $this->path );
 986:     }
 987:     return $this->_is_proxy_request;
 988:   }
 989: 
 990: 
 991:   /**
 992:   * Returns true if the request asked for infinite depth
 993:   */
 994:   function IsInfiniteDepth( ) {
 995:     return ($this->depth == DEPTH_INFINITY);
 996:   }
 997: 
 998: 
 999:   /**
1000:   * Returns the ID of the collection of, or containing this request
1001:   */
1002:   function CollectionId( ) {
1003:     return $this->collection_id;
1004:   }
1005: 
1006: 
1007:   /**
1008:   * Returns the array of supported privileges converted into XMLElements
1009:   */
1010:   function BuildSupportedPrivileges( &$reply, $privs = null ) {
1011:     $privileges = array();
1012:     if ( $privs === null ) $privs = self::supportedPrivileges();
1013:     foreach( $privs AS $k => $v ) {
1014:       dbg_error_log( 'caldav', 'Adding privilege "%s" which is "%s".', $k, $v );
1015:       $privilege = new XMLElement('privilege');
1016:       $reply->NSElement($privilege,$k);
1017:       $privset = array($privilege);
1018:       if ( is_array($v) ) {
1019:         dbg_error_log( 'caldav', '"%s" is a container of sub-privileges.', $k );
1020:         $privset = array_merge($privset, $this->BuildSupportedPrivileges($reply,$v));
1021:       }
1022:       else if ( $v == 'abstract' ) {
1023:         dbg_error_log( 'caldav', '"%s" is an abstract privilege.', $v );
1024:         $privset[] = new XMLElement('abstract');
1025:       }
1026:       else if ( strlen($v) > 1 ) {
1027:         $privset[] = new XMLElement('description', $v);
1028:       }
1029:       $privileges[] = new XMLElement('supported-privilege',$privset);
1030:     }
1031:     return $privileges;
1032:   }
1033: 
1034: 
1035:   /**
1036:   * Are we allowed to do the requested activity
1037:   *
1038:   * +------------+------------------------------------------------------+
1039:   * | METHOD     | PRIVILEGES                                           |
1040:   * +------------+------------------------------------------------------+
1041:   * | MKCALENDAR | DAV:bind                                             |
1042:   * | REPORT     | DAV:read or CALDAV:read-free-busy (on all referenced |
1043:   * |            | resources)                                           |
1044:   * +------------+------------------------------------------------------+
1045:   *
1046:   * @param string $activity The activity we want to do.
1047:   */
1048:   function AllowedTo( $activity ) {
1049:     global $session;
1050:     dbg_error_log('caldav', 'Checking whether "%s" is allowed to "%s"', $session->principal->username(), $activity);
1051:     if ( isset($this->permissions['all']) ) return true;
1052:     switch( $activity ) {
1053:       case 'all':
1054:         return false; // If they got this far then they don't
1055:         break;
1056: 
1057:       case "CALDAV:schedule-send-freebusy":
1058:         return isset($this->permissions['read']) || isset($this->permissions['urn:ietf:params:xml:ns:caldav:read-free-busy']);
1059:         break;
1060: 
1061:       case "CALDAV:schedule-send-invite":
1062:         return isset($this->permissions['read']) || isset($this->permissions['urn:ietf:params:xml:ns:caldav:read-free-busy']);
1063:         break;
1064: 
1065:       case "CALDAV:schedule-send-reply":
1066:         return isset($this->permissions['read']) || isset($this->permissions['urn:ietf:params:xml:ns:caldav:read-free-busy']);
1067:         break;
1068: 
1069:       case 'freebusy':
1070:         return isset($this->permissions['read']) || isset($this->permissions['urn:ietf:params:xml:ns:caldav:read-free-busy']);
1071:         break;
1072: 
1073:       case 'delete':
1074:         return isset($this->permissions['write']) || isset($this->permissions['unbind']);
1075:         break;
1076: 
1077:       case 'proppatch':
1078:         return isset($this->permissions['write']) || isset($this->permissions['write-properties']);
1079:         break;
1080: 
1081:       case 'modify':
1082:         return isset($this->permissions['write']) || isset($this->permissions['write-content']);
1083:         break;
1084: 
1085:       case 'create':
1086:         return isset($this->permissions['write']) || isset($this->permissions['bind']);
1087:         break;
1088: 
1089:       case 'mkcalendar':
1090:       case 'mkcol':
1091:         if ( !isset($this->permissions['write']) || !isset($this->permissions['bind']) ) return false;
1092:         if ( $this->is_principal ) return false;
1093:         if ( $this->path == '/' ) return false;
1094:         break;
1095: 
1096:       default:
1097:         $test_bits = privilege_to_bits( $activity );
1098: //        dbg_error_log( 'caldav', 'request::AllowedTo("%s") (%s) against allowed "%s" => "%s" (%s)',
1099: //             (is_array($activity) ? implode(',',$activity) : $activity), decbin($test_bits),
1100: //             decbin($this->privileges), ($this->privileges & $test_bits), decbin($this->privileges & $test_bits) );
1101:         return (($this->privileges & $test_bits) > 0 );
1102:         break;
1103:     }
1104: 
1105:     return false;
1106:   }
1107: 
1108: 
1109: 
1110:   /**
1111:   * Return the privileges bits for the current session user to this resource
1112:   */
1113:   function Privileges() {
1114:     return $this->privileges;
1115:   }
1116: 
1117: 
1118:   /**
1119:    * Check that the incoming Etag matches the one for the existing (or non-existing) resource.
1120:    *
1121:    * @param boolean $exists Whether the destination exists
1122:    * @param string $dest_etag The etag for the destination.
1123:    */
1124:   function CheckEtagMatch( $exists, $dest_etag ) {
1125:     global $c;
1126: 
1127:     if ( ! $exists ) {
1128:       if ( (isset($this->etag_if_match) && $this->etag_if_match != '') ) {
1129:         /**
1130:         * RFC2068, 14.25:
1131:         * If none of the entity tags match, or if "*" is given and no current
1132:         * entity exists, the server MUST NOT perform the requested method, and
1133:         * MUST return a 412 (Precondition Failed) response.
1134:         */
1135:         $this->PreconditionFailed(412, 'if-match', translate('No resource exists at the destination.'));
1136:       }
1137:     }
1138:     else {
1139: 
1140:       if ( isset($c->strict_etag_checking) && $c->strict_etag_checking )
1141:          $trim_chars = '\'\\" ';
1142:       else
1143:         $trim_chars = ' ';
1144: 
1145:       if ( isset($this->etag_if_match) && $this->etag_if_match != '' && trim( $this->etag_if_match, $trim_chars) != trim( $dest_etag, $trim_chars ) ) {
1146:         /**
1147:         * RFC2068, 14.25:
1148:         * If none of the entity tags match, or if "*" is given and no current
1149:         * entity exists, the server MUST NOT perform the requested method, and
1150:         * MUST return a 412 (Precondition Failed) response.
1151:         */
1152:         $this->PreconditionFailed(412,'if-match',sprintf('Existing resource ETag of <<%s>> does not match <<%s>>', $dest_etag, $this->etag_if_match) );
1153:       }
1154:       else if ( isset($this->etag_none_match) && $this->etag_none_match != ''
1155:                    && ($this->etag_none_match == $dest_etag || $this->etag_none_match == '*') ) {
1156:         /**
1157:         * RFC2068, 14.26:
1158:         * If any of the entity tags match the entity tag of the entity that
1159:         * would have been returned in the response to a similar GET request
1160:         * (without the If-None-Match header) on that resource, or if "*" is
1161:         * given and any current entity exists for that resource, then the
1162:         * server MUST NOT perform the requested method.
1163:         */
1164:         $this->PreconditionFailed(412,'if-none-match', translate( 'Existing resource matches "If-None-Match" header - not accepted.'));
1165:       }
1166:     }
1167: 
1168:   }
1169: 
1170: 
1171:   /**
1172:   * Is the user has the privileges to do what is requested.
1173:   */
1174:   function HavePrivilegeTo( $do_what ) {
1175:     $test_bits = privilege_to_bits( $do_what );
1176: //    dbg_error_log( 'caldav', 'request::HavePrivilegeTo("%s") [%s] against allowed "%s" => "%s" (%s)',
1177: //             (is_array($do_what) ? implode(',',$do_what) : $do_what), decbin($test_bits),
1178: //              decbin($this->privileges), ($this->privileges & $test_bits), decbin($this->privileges & $test_bits) );
1179:     return ($this->privileges & $test_bits) > 0;
1180:   }
1181: 
1182: 
1183:   /**
1184:   * Sometimes it's a perfectly formed request, but we just don't do that :-(
1185:   * @param array $unsupported An array of the properties we don't support.
1186:   */
1187:   function UnsupportedRequest( $unsupported ) {
1188:     if ( isset($unsupported) && count($unsupported) > 0 ) {
1189:       $badprops = new XMLElement( "prop" );
1190:       foreach( $unsupported AS $k => $v ) {
1191:         // Not supported at this point...
1192:         dbg_error_log("ERROR", " %s: Support for $v:$k properties is not implemented yet", $this->method );
1193:         $badprops->NewElement(strtolower($k),false,array("xmlns" => strtolower($v)));
1194:       }
1195:       $error = new XMLElement("error", $badprops, array("xmlns" => "DAV:") );
1196: 
1197:       $this->XMLResponse( 422, $error );
1198:     }
1199:   }
1200: 
1201: 
1202:   /**
1203:   * Send a need-privileges error response.  This function will only return
1204:   * if the $href is not supplied and the current user has the specified
1205:   * permission for the request path.
1206:   *
1207:   * @param string $privilege The name of the needed privilege.
1208:   * @param string $href The unconstructed URI where we needed the privilege.
1209:   */
1210:   function NeedPrivilege( $privileges, $href=null ) {
1211:     if ( is_string($privileges) ) $privileges = array( $privileges );
1212:     if ( !isset($href) ) {
1213:       if ( $this->HavePrivilegeTo($privileges) ) return;
1214:       $href = $this->path;
1215:     }
1216: 
1217:     $reply = new XMLDocument( array('DAV:' => '') );
1218:     $privnodes = array( $reply->href(ConstructURL($href)), new XMLElement( 'privilege' ) );
1219:     // RFC3744 specifies that we can only respond with one needed privilege, so we pick the first.
1220:     $reply->NSElement( $privnodes[1], $privileges[0] );
1221:     $xml = new XMLElement( 'need-privileges', new XMLElement( 'resource', $privnodes) );
1222:     $xmldoc = $reply->Render('error',$xml);
1223:     $this->DoResponse( 403, $xmldoc, 'text/xml; charset="utf-8"' );
1224:     exit(0);  // Unecessary, but might clarify things
1225:   }
1226: 
1227: 
1228:   /**
1229:   * Send an error response for a failed precondition.
1230:   *
1231:   * @param int $status The status code for the failed precondition.  Normally 403
1232:   * @param string $precondition The namespaced precondition tag.
1233:   * @param string $explanation An optional text explanation for the failure.
1234:   */
1235:   function PreconditionFailed( $status, $precondition, $explanation = '', $xmlns='DAV:') {
1236:     $xmldoc = sprintf('<?xml version="1.0" encoding="utf-8" ?>
1237: <error xmlns="%s">
1238:   <%s/>%s
1239: </error>', $xmlns, str_replace($xmlns.':', '', $precondition), $explanation );
1240: 
1241:     $this->DoResponse( $status, $xmldoc, 'text/xml; charset="utf-8"' );
1242:     exit(0);  // Unecessary, but might clarify things
1243:   }
1244: 
1245: 
1246:   /**
1247:   * Send a simple error informing the client that was a malformed request
1248:   *
1249:   * @param string $text An optional text description of the failure.
1250:   */
1251:   function MalformedRequest( $text = 'Bad request' ) {
1252:     $this->DoResponse( 400, $text );
1253:     exit(0);  // Unecessary, but might clarify things
1254:   }
1255: 
1256: 
1257:   /**
1258:   * Send an XML Response.  This function will never return.
1259:   *
1260:   * @param int $status The HTTP status to respond
1261:   * @param XMLElement $xmltree An XMLElement tree to be rendered
1262:   */
1263:   function XMLResponse( $status, $xmltree ) {
1264:     $xmldoc = $xmltree->Render(0,'<?xml version="1.0" encoding="utf-8" ?>');
1265:     $etag = md5($xmldoc);
1266:     if ( !headers_sent() ) header("ETag: \"$etag\"");
1267:     $this->DoResponse( $status, $xmldoc, 'text/xml; charset="utf-8"' );
1268:     exit(0);  // Unecessary, but might clarify things
1269:   }
1270: 
1271:   public static function kill_on_exit() {
1272:     posix_kill( getmypid(), 28 );
1273:   }
1274: 
1275:   /**
1276:   * Utility function we call when we have a simple status-based response to
1277:   * return to the client.  Possibly
1278:   *
1279:   * @param int $status The HTTP status code to send.
1280:   * @param string $message The friendly text message to send with the response.
1281:   */
1282:   function DoResponse( $status, $message="", $content_type="text/plain; charset=\"utf-8\"" ) {
1283:     global $session, $c;
1284:     if ( !headers_sent() ) @header( sprintf("HTTP/1.1 %d %s", $status, getStatusMessage($status)) );
1285:     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) );
1286:     if ( !headers_sent() ) header( "Content-type: ".$content_type );
1287: 
1288:     if ( (isset($c->dbg['ALL']) && $c->dbg['ALL']) || (isset($c->dbg['response']) && $c->dbg['response'])
1289:          || $status == 400  || $status == 402 || $status == 403 || $status > 404 ) {
1290:       @dbg_error_log( "LOG ", 'Response status %03d for %s %s', $status, $this->method, $_SERVER['REQUEST_URI'] );
1291:       $lines = headers_list();
1292:       dbg_error_log( "LOG ", "***************** Response Header ****************" );
1293:       foreach( $lines AS $v ) {
1294:         dbg_error_log( "LOG headers", "-->%s", $v );
1295:       }
1296:       dbg_error_log( "LOG ", "******************** Response ********************" );
1297:       // Log the request in all it's gory detail.
1298:       $lines = preg_split( '#[\r\n]+#', $message);
1299:       foreach( $lines AS $v ) {
1300:         dbg_error_log( "LOG response", "-->%s", $v );
1301:       }
1302:     }
1303: 
1304:     $script_finish = microtime(true);
1305:     $script_time = $script_finish - $c->script_start_time;
1306:     $message_length = strlen($message);
1307:     if ( $message != '' ) {
1308:       if ( !headers_sent() ) header( "Content-Length: ".$message_length );
1309:       echo $message;
1310:     }
1311: 
1312:     if ( isset($c->dbg['caldav']) && $c->dbg['caldav'] ) {
1313:       if ( $message_length > 100 || strstr($message, "\n") ) {
1314:         $message = substr( preg_replace("#\s+#m", ' ', $message ), 0, 100) . ($message_length > 100 ? "..." : "");
1315:       }
1316: 
1317:       dbg_error_log("caldav", "Status: %d, Message: %s, User: %d, Path: %s", $status, $message, $session->principal->user_no(), $this->path);
1318:     }
1319:     if ( isset($c->dbg['statistics']) && $c->dbg['statistics'] ) {
1320:       $memory = '';
1321:       if ( function_exists('memory_get_usage') ) {
1322:         $memory = sprintf( ', Memory: %dk, Peak: %dk', memory_get_usage()/1024, memory_get_peak_usage(true)/1024);
1323:       }
1324:       @dbg_error_log("statistics", "Method: %s, Status: %d, Script: %5.3lfs, Queries: %5.3lfs, URL: %s%s",
1325:                          $this->method, $status, $script_time, $c->total_query_time, $this->path, $memory);
1326:     }
1327:     try {
1328:       @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();
1329:     }
1330:     catch( Exception $ignored ) {}
1331: 
1332:     if ( isset($c->metrics_style) && $c->metrics_style !== false ) {
1333:       $flush_time = microtime(true) - $script_finish;
1334:       $this->DoMetrics($status, $message_length, $script_time, $flush_time);
1335:     }
1336: 
1337:     if ( isset($c->exit_after_memory_exceeds) && function_exists('memory_get_peak_usage') && memory_get_peak_usage(true) > $c->exit_after_memory_exceeds ) { // 64M
1338:       @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());
1339:       register_shutdown_function( 'CalDAVRequest::kill_on_exit' );
1340:     }
1341: 
1342:     exit(0);
1343:   }
1344: 
1345: 
1346:   /**
1347:   * Record the metrics related to this request.
1348:   *
1349:   * @param status The HTTP status code for this response
1350:   * @param response_size The size of the response (bytes).
1351:   * @param script_time The time taken to generate the response (pre-sending)
1352:   * @param flush_time The time taken to send the response (buffers flushed)
1353:   */
1354:   function DoMetrics($status, $response_size, $script_time, $flush_time) {
1355:     global $c;
1356:     static $ns = 'metrics';
1357: 
1358:     $method = (empty($this->method) ? 'UNKNOWN' : $this->method);
1359: 
1360:     // If they want 'both' or 'all' or something then that's what they will get
1361:     // If they don't want counters, they must want to use memcache!
1362:     if ( $c->metrics_style != 'counters' ) {
1363:       $cache = getCacheInstance();
1364:       if ( $cache->isActive() ) {
1365: 
1366:         $base_key = $method.':';
1367:         $count_like_this = $cache->increment( $ns, $base_key.$status );
1368:         $cache->increment( $ns, $base_key.'size', $response_size );
1369:         $cache->increment( $ns, $base_key.'script_time', intval($script_time * 1000000) );
1370:         $cache->increment( $ns, $base_key.'flush_time', intval($flush_time * 1000000) );
1371:         $cache->increment( $ns, $base_key.'query_time', intval($c->total_query_time * 1000000) );
1372: 
1373:         if ( $count_like_this == 1 ) {
1374:           // We need to maintain a set of details regarding the methods and statuses we have
1375:           // encountered, so we know what to retrieve.  Since this is the first one like
1376:           // this, we add it to the index.
1377:           try {
1378:             $index = unserialize($cache->get($ns, 'index'));
1379:           } catch (Exception $e) {
1380:             $index = array('methods' => array(), 'statuses' => array());
1381:           }
1382:           $index['methods'][$method] = 1;
1383:           $index['statuses'][$status] = 1;
1384:           $cache->set($ns, 'index', serialize($index), 0);
1385:         }
1386:       }
1387:       else {
1388:         error_log("Full statistics are only available with a working Memcache configuration");
1389:       }
1390:     }
1391: 
1392:     // If they don't want memcache, they must want to use counters!
1393:     if ( $c->metrics_style != 'memcache' ) {
1394:       $qstring = "SELECT nextval('%s')";
1395:       switch( $method ) {
1396:         case 'OPTIONS':
1397:         case 'REPORT':
1398:         case 'PROPFIND':
1399:         case 'GET':
1400:         case 'PUT':
1401:         case 'HEAD':
1402:         case 'PROPPATCH':
1403:         case 'POST':
1404:         case 'MKCALENDAR':
1405:         case 'MKCOL':
1406:         case 'DELETE':
1407:         case 'MOVE':
1408:         case 'ACL':
1409:         case 'LOCK':
1410:         case 'UNLOCK':
1411:         case 'MKTICKET':
1412:         case 'DELTICKET':
1413:         case 'BIND':
1414:           $counter = strtolower($this->method);
1415:           break;
1416:         default:
1417:           $counter = 'unknown';
1418:           break;
1419:       }
1420:       $qry = new AwlQuery( "SELECT nextval('metrics_count_" . $counter . "')" );
1421:       $qry->Exec('always',__LINE__,__FILE__);
1422:     }
1423:   }
1424: }
1425: 
1426: 
DAViCal API documentation generated by ApiGen 2.8.0