1<?php 2 3namespace Sabre\DAV; 4 5use Sabre\Event\EventEmitter; 6use Sabre\HTTP; 7use Sabre\HTTP\RequestInterface; 8use Sabre\HTTP\ResponseInterface; 9use Sabre\HTTP\URLUtil; 10use Sabre\Uri; 11 12/** 13 * Main DAV server class 14 * 15 * @copyright Copyright (C) 2007-2015 fruux GmbH (https://fruux.com/). 16 * @author Evert Pot (http://evertpot.com/) 17 * @license http://sabre.io/license/ Modified BSD License 18 */ 19class Server extends EventEmitter { 20 21 /** 22 * Infinity is used for some request supporting the HTTP Depth header and indicates that the operation should traverse the entire tree 23 */ 24 const DEPTH_INFINITY = -1; 25 26 /** 27 * XML namespace for all SabreDAV related elements 28 */ 29 const NS_SABREDAV = 'http://sabredav.org/ns'; 30 31 /** 32 * The tree object 33 * 34 * @var Sabre\DAV\Tree 35 */ 36 public $tree; 37 38 /** 39 * The base uri 40 * 41 * @var string 42 */ 43 protected $baseUri = null; 44 45 /** 46 * httpResponse 47 * 48 * @var Sabre\HTTP\Response 49 */ 50 public $httpResponse; 51 52 /** 53 * httpRequest 54 * 55 * @var Sabre\HTTP\Request 56 */ 57 public $httpRequest; 58 59 /** 60 * PHP HTTP Sapi 61 * 62 * @var Sabre\HTTP\Sapi 63 */ 64 public $sapi; 65 66 /** 67 * The list of plugins 68 * 69 * @var array 70 */ 71 protected $plugins = []; 72 73 /** 74 * This property will be filled with a unique string that describes the 75 * transaction. This is useful for performance measuring and logging 76 * purposes. 77 * 78 * By default it will just fill it with a lowercased HTTP method name, but 79 * plugins override this. For example, the WebDAV-Sync sync-collection 80 * report will set this to 'report-sync-collection'. 81 * 82 * @var string 83 */ 84 public $transactionType; 85 86 /** 87 * This is a list of properties that are always server-controlled, and 88 * must not get modified with PROPPATCH. 89 * 90 * Plugins may add to this list. 91 * 92 * @var string[] 93 */ 94 public $protectedProperties = [ 95 96 // RFC4918 97 '{DAV:}getcontentlength', 98 '{DAV:}getetag', 99 '{DAV:}getlastmodified', 100 '{DAV:}lockdiscovery', 101 '{DAV:}supportedlock', 102 103 // RFC4331 104 '{DAV:}quota-available-bytes', 105 '{DAV:}quota-used-bytes', 106 107 // RFC3744 108 '{DAV:}supported-privilege-set', 109 '{DAV:}current-user-privilege-set', 110 '{DAV:}acl', 111 '{DAV:}acl-restrictions', 112 '{DAV:}inherited-acl-set', 113 114 // RFC3253 115 '{DAV:}supported-method-set', 116 '{DAV:}supported-report-set', 117 118 // RFC6578 119 '{DAV:}sync-token', 120 121 // calendarserver.org extensions 122 '{http://calendarserver.org/ns/}ctag', 123 124 // sabredav extensions 125 '{http://sabredav.org/ns}sync-token', 126 127 ]; 128 129 /** 130 * This is a flag that allow or not showing file, line and code 131 * of the exception in the returned XML 132 * 133 * @var bool 134 */ 135 public $debugExceptions = false; 136 137 /** 138 * This property allows you to automatically add the 'resourcetype' value 139 * based on a node's classname or interface. 140 * 141 * The preset ensures that {DAV:}collection is automatically added for nodes 142 * implementing Sabre\DAV\ICollection. 143 * 144 * @var array 145 */ 146 public $resourceTypeMapping = [ 147 'Sabre\\DAV\\ICollection' => '{DAV:}collection', 148 ]; 149 150 /** 151 * This property allows the usage of Depth: infinity on PROPFIND requests. 152 * 153 * By default Depth: infinity is treated as Depth: 1. Allowing Depth: 154 * infinity is potentially risky, as it allows a single client to do a full 155 * index of the webdav server, which is an easy DoS attack vector. 156 * 157 * Only turn this on if you know what you're doing. 158 * 159 * @var bool 160 */ 161 public $enablePropfindDepthInfinity = false; 162 163 /** 164 * Reference to the XML utility object. 165 * 166 * @var Xml\Service 167 */ 168 public $xml; 169 170 /** 171 * If this setting is turned off, SabreDAV's version number will be hidden 172 * from various places. 173 * 174 * Some people feel this is a good security measure. 175 * 176 * @var bool 177 */ 178 static $exposeVersion = true; 179 180 /** 181 * Sets up the server 182 * 183 * If a Sabre\DAV\Tree object is passed as an argument, it will 184 * use it as the directory tree. If a Sabre\DAV\INode is passed, it 185 * will create a Sabre\DAV\Tree and use the node as the root. 186 * 187 * If nothing is passed, a Sabre\DAV\SimpleCollection is created in 188 * a Sabre\DAV\Tree. 189 * 190 * If an array is passed, we automatically create a root node, and use 191 * the nodes in the array as top-level children. 192 * 193 * @param Tree|INode|array|null $treeOrNode The tree object 194 */ 195 function __construct($treeOrNode = null) { 196 197 if ($treeOrNode instanceof Tree) { 198 $this->tree = $treeOrNode; 199 } elseif ($treeOrNode instanceof INode) { 200 $this->tree = new Tree($treeOrNode); 201 } elseif (is_array($treeOrNode)) { 202 203 // If it's an array, a list of nodes was passed, and we need to 204 // create the root node. 205 foreach ($treeOrNode as $node) { 206 if (!($node instanceof INode)) { 207 throw new Exception('Invalid argument passed to constructor. If you\'re passing an array, all the values must implement Sabre\\DAV\\INode'); 208 } 209 } 210 211 $root = new SimpleCollection('root', $treeOrNode); 212 $this->tree = new Tree($root); 213 214 } elseif (is_null($treeOrNode)) { 215 $root = new SimpleCollection('root'); 216 $this->tree = new Tree($root); 217 } else { 218 throw new Exception('Invalid argument passed to constructor. Argument must either be an instance of Sabre\\DAV\\Tree, Sabre\\DAV\\INode, an array or null'); 219 } 220 221 $this->xml = new Xml\Service(); 222 $this->sapi = new HTTP\Sapi(); 223 $this->httpResponse = new HTTP\Response(); 224 $this->httpRequest = $this->sapi->getRequest(); 225 $this->addPlugin(new CorePlugin()); 226 227 } 228 229 /** 230 * Starts the DAV Server 231 * 232 * @return void 233 */ 234 function exec() { 235 236 try { 237 238 // If nginx (pre-1.2) is used as a proxy server, and SabreDAV as an 239 // origin, we must make sure we send back HTTP/1.0 if this was 240 // requested. 241 // This is mainly because nginx doesn't support Chunked Transfer 242 // Encoding, and this forces the webserver SabreDAV is running on, 243 // to buffer entire responses to calculate Content-Length. 244 $this->httpResponse->setHTTPVersion($this->httpRequest->getHTTPVersion()); 245 246 // Setting the base url 247 $this->httpRequest->setBaseUrl($this->getBaseUri()); 248 $this->invokeMethod($this->httpRequest, $this->httpResponse); 249 250 } catch (\Exception $e) { 251 252 try { 253 $this->emit('exception', [$e]); 254 } catch (\Exception $ignore) { 255 } 256 $DOM = new \DOMDocument('1.0', 'utf-8'); 257 $DOM->formatOutput = true; 258 259 $error = $DOM->createElementNS('DAV:', 'd:error'); 260 $error->setAttribute('xmlns:s', self::NS_SABREDAV); 261 $DOM->appendChild($error); 262 263 $h = function($v) { 264 265 return htmlspecialchars($v, ENT_NOQUOTES, 'UTF-8'); 266 267 }; 268 269 if (self::$exposeVersion) { 270 $error->appendChild($DOM->createElement('s:sabredav-version', $h(Version::VERSION))); 271 } 272 273 $error->appendChild($DOM->createElement('s:exception', $h(get_class($e)))); 274 $error->appendChild($DOM->createElement('s:message', $h($e->getMessage()))); 275 if ($this->debugExceptions) { 276 $error->appendChild($DOM->createElement('s:file', $h($e->getFile()))); 277 $error->appendChild($DOM->createElement('s:line', $h($e->getLine()))); 278 $error->appendChild($DOM->createElement('s:code', $h($e->getCode()))); 279 $error->appendChild($DOM->createElement('s:stacktrace', $h($e->getTraceAsString()))); 280 } 281 282 if ($this->debugExceptions) { 283 $previous = $e; 284 while ($previous = $previous->getPrevious()) { 285 $xPrevious = $DOM->createElement('s:previous-exception'); 286 $xPrevious->appendChild($DOM->createElement('s:exception', $h(get_class($previous)))); 287 $xPrevious->appendChild($DOM->createElement('s:message', $h($previous->getMessage()))); 288 $xPrevious->appendChild($DOM->createElement('s:file', $h($previous->getFile()))); 289 $xPrevious->appendChild($DOM->createElement('s:line', $h($previous->getLine()))); 290 $xPrevious->appendChild($DOM->createElement('s:code', $h($previous->getCode()))); 291 $xPrevious->appendChild($DOM->createElement('s:stacktrace', $h($previous->getTraceAsString()))); 292 $error->appendChild($xPrevious); 293 } 294 } 295 296 297 if ($e instanceof Exception) { 298 299 $httpCode = $e->getHTTPCode(); 300 $e->serialize($this, $error); 301 $headers = $e->getHTTPHeaders($this); 302 303 } else { 304 305 $httpCode = 500; 306 $headers = []; 307 308 } 309 $headers['Content-Type'] = 'application/xml; charset=utf-8'; 310 311 $this->httpResponse->setStatus($httpCode); 312 $this->httpResponse->setHeaders($headers); 313 $this->httpResponse->setBody($DOM->saveXML()); 314 $this->sapi->sendResponse($this->httpResponse); 315 316 } 317 318 } 319 320 /** 321 * Sets the base server uri 322 * 323 * @param string $uri 324 * @return void 325 */ 326 function setBaseUri($uri) { 327 328 // If the baseUri does not end with a slash, we must add it 329 if ($uri[strlen($uri) - 1] !== '/') 330 $uri .= '/'; 331 332 $this->baseUri = $uri; 333 334 } 335 336 /** 337 * Returns the base responding uri 338 * 339 * @return string 340 */ 341 function getBaseUri() { 342 343 if (is_null($this->baseUri)) $this->baseUri = $this->guessBaseUri(); 344 return $this->baseUri; 345 346 } 347 348 /** 349 * This method attempts to detect the base uri. 350 * Only the PATH_INFO variable is considered. 351 * 352 * If this variable is not set, the root (/) is assumed. 353 * 354 * @return string 355 */ 356 function guessBaseUri() { 357 358 $pathInfo = $this->httpRequest->getRawServerValue('PATH_INFO'); 359 $uri = $this->httpRequest->getRawServerValue('REQUEST_URI'); 360 361 // If PATH_INFO is found, we can assume it's accurate. 362 if (!empty($pathInfo)) { 363 364 // We need to make sure we ignore the QUERY_STRING part 365 if ($pos = strpos($uri, '?')) 366 $uri = substr($uri, 0, $pos); 367 368 // PATH_INFO is only set for urls, such as: /example.php/path 369 // in that case PATH_INFO contains '/path'. 370 // Note that REQUEST_URI is percent encoded, while PATH_INFO is 371 // not, Therefore they are only comparable if we first decode 372 // REQUEST_INFO as well. 373 $decodedUri = URLUtil::decodePath($uri); 374 375 // A simple sanity check: 376 if (substr($decodedUri, strlen($decodedUri) - strlen($pathInfo)) === $pathInfo) { 377 $baseUri = substr($decodedUri, 0, strlen($decodedUri) - strlen($pathInfo)); 378 return rtrim($baseUri, '/') . '/'; 379 } 380 381 throw new Exception('The REQUEST_URI (' . $uri . ') did not end with the contents of PATH_INFO (' . $pathInfo . '). This server might be misconfigured.'); 382 383 } 384 385 // The last fallback is that we're just going to assume the server root. 386 return '/'; 387 388 } 389 390 /** 391 * Adds a plugin to the server 392 * 393 * For more information, console the documentation of Sabre\DAV\ServerPlugin 394 * 395 * @param ServerPlugin $plugin 396 * @return void 397 */ 398 function addPlugin(ServerPlugin $plugin) { 399 400 $this->plugins[$plugin->getPluginName()] = $plugin; 401 $plugin->initialize($this); 402 403 } 404 405 /** 406 * Returns an initialized plugin by it's name. 407 * 408 * This function returns null if the plugin was not found. 409 * 410 * @param string $name 411 * @return ServerPlugin 412 */ 413 function getPlugin($name) { 414 415 if (isset($this->plugins[$name])) 416 return $this->plugins[$name]; 417 418 return null; 419 420 } 421 422 /** 423 * Returns all plugins 424 * 425 * @return array 426 */ 427 function getPlugins() { 428 429 return $this->plugins; 430 431 } 432 433 /** 434 * Handles a http request, and execute a method based on its name 435 * 436 * @param RequestInterface $request 437 * @param ResponseInterface $response 438 * @param $sendResponse Whether to send the HTTP response to the DAV client. 439 * @return void 440 */ 441 function invokeMethod(RequestInterface $request, ResponseInterface $response, $sendResponse = true) { 442 443 $method = $request->getMethod(); 444 445 if (!$this->emit('beforeMethod:' . $method, [$request, $response])) return; 446 if (!$this->emit('beforeMethod', [$request, $response])) return; 447 448 if (self::$exposeVersion) { 449 $response->setHeader('X-Sabre-Version', Version::VERSION); 450 } 451 452 $this->transactionType = strtolower($method); 453 454 if (!$this->checkPreconditions($request, $response)) { 455 $this->sapi->sendResponse($response); 456 return; 457 } 458 459 if ($this->emit('method:' . $method, [$request, $response])) { 460 if ($this->emit('method', [$request, $response])) { 461 // Unsupported method 462 throw new Exception\NotImplemented('There was no handler found for this "' . $method . '" method'); 463 } 464 } 465 466 if (!$this->emit('afterMethod:' . $method, [$request, $response])) return; 467 if (!$this->emit('afterMethod', [$request, $response])) return; 468 469 if ($sendResponse) { 470 $this->sapi->sendResponse($response); 471 $this->emit('afterResponse', [$request, $response]); 472 } 473 474 } 475 476 // {{{ HTTP/WebDAV protocol helpers 477 478 /** 479 * Returns an array with all the supported HTTP methods for a specific uri. 480 * 481 * @param string $path 482 * @return array 483 */ 484 function getAllowedMethods($path) { 485 486 $methods = [ 487 'OPTIONS', 488 'GET', 489 'HEAD', 490 'DELETE', 491 'PROPFIND', 492 'PUT', 493 'PROPPATCH', 494 'COPY', 495 'MOVE', 496 'REPORT' 497 ]; 498 499 // The MKCOL is only allowed on an unmapped uri 500 try { 501 $this->tree->getNodeForPath($path); 502 } catch (Exception\NotFound $e) { 503 $methods[] = 'MKCOL'; 504 } 505 506 // We're also checking if any of the plugins register any new methods 507 foreach ($this->plugins as $plugin) $methods = array_merge($methods, $plugin->getHTTPMethods($path)); 508 array_unique($methods); 509 510 return $methods; 511 512 } 513 514 /** 515 * Gets the uri for the request, keeping the base uri into consideration 516 * 517 * @return string 518 */ 519 function getRequestUri() { 520 521 return $this->calculateUri($this->httpRequest->getUrl()); 522 523 } 524 525 /** 526 * Calculates the uri for a request, making sure that the base uri is stripped out 527 * 528 * @param string $uri 529 * @throws Exception\Forbidden A permission denied exception is thrown whenever there was an attempt to supply a uri outside of the base uri 530 * @return string 531 */ 532 function calculateUri($uri) { 533 534 if ($uri[0] != '/' && strpos($uri, '://')) { 535 536 $uri = parse_url($uri, PHP_URL_PATH); 537 538 } 539 540 $uri = Uri\normalize(str_replace('//', '/', $uri)); 541 $baseUri = Uri\normalize($this->getBaseUri()); 542 543 if (strpos($uri, $baseUri) === 0) { 544 545 return trim(URLUtil::decodePath(substr($uri, strlen($baseUri))), '/'); 546 547 // A special case, if the baseUri was accessed without a trailing 548 // slash, we'll accept it as well. 549 } elseif ($uri . '/' === $baseUri) { 550 551 return ''; 552 553 } else { 554 555 throw new Exception\Forbidden('Requested uri (' . $uri . ') is out of base uri (' . $this->getBaseUri() . ')'); 556 557 } 558 559 } 560 561 /** 562 * Returns the HTTP depth header 563 * 564 * This method returns the contents of the HTTP depth request header. If the depth header was 'infinity' it will return the Sabre\DAV\Server::DEPTH_INFINITY object 565 * It is possible to supply a default depth value, which is used when the depth header has invalid content, or is completely non-existent 566 * 567 * @param mixed $default 568 * @return int 569 */ 570 function getHTTPDepth($default = self::DEPTH_INFINITY) { 571 572 // If its not set, we'll grab the default 573 $depth = $this->httpRequest->getHeader('Depth'); 574 575 if (is_null($depth)) return $default; 576 577 if ($depth == 'infinity') return self::DEPTH_INFINITY; 578 579 580 // If its an unknown value. we'll grab the default 581 if (!ctype_digit($depth)) return $default; 582 583 return (int)$depth; 584 585 } 586 587 /** 588 * Returns the HTTP range header 589 * 590 * This method returns null if there is no well-formed HTTP range request 591 * header or array($start, $end). 592 * 593 * The first number is the offset of the first byte in the range. 594 * The second number is the offset of the last byte in the range. 595 * 596 * If the second offset is null, it should be treated as the offset of the last byte of the entity 597 * If the first offset is null, the second offset should be used to retrieve the last x bytes of the entity 598 * 599 * @return array|null 600 */ 601 function getHTTPRange() { 602 603 $range = $this->httpRequest->getHeader('range'); 604 if (is_null($range)) return null; 605 606 // Matching "Range: bytes=1234-5678: both numbers are optional 607 608 if (!preg_match('/^bytes=([0-9]*)-([0-9]*)$/i', $range, $matches)) return null; 609 610 if ($matches[1] === '' && $matches[2] === '') return null; 611 612 return [ 613 $matches[1] !== '' ? $matches[1] : null, 614 $matches[2] !== '' ? $matches[2] : null, 615 ]; 616 617 } 618 619 /** 620 * Returns the HTTP Prefer header information. 621 * 622 * The prefer header is defined in: 623 * http://tools.ietf.org/html/draft-snell-http-prefer-14 624 * 625 * This method will return an array with options. 626 * 627 * Currently, the following options may be returned: 628 * [ 629 * 'return-asynch' => true, 630 * 'return-minimal' => true, 631 * 'return-representation' => true, 632 * 'wait' => 30, 633 * 'strict' => true, 634 * 'lenient' => true, 635 * ] 636 * 637 * This method also supports the Brief header, and will also return 638 * 'return-minimal' if the brief header was set to 't'. 639 * 640 * For the boolean options, false will be returned if the headers are not 641 * specified. For the integer options it will be 'null'. 642 * 643 * @return array 644 */ 645 function getHTTPPrefer() { 646 647 $result = [ 648 // can be true or false 649 'respond-async' => false, 650 // Could be set to 'representation' or 'minimal'. 651 'return' => null, 652 // Used as a timeout, is usually a number. 653 'wait' => null, 654 // can be 'strict' or 'lenient'. 655 'handling' => false, 656 ]; 657 658 if ($prefer = $this->httpRequest->getHeader('Prefer')) { 659 660 $result = array_merge( 661 $result, 662 \Sabre\HTTP\parsePrefer($prefer) 663 ); 664 665 } elseif ($this->httpRequest->getHeader('Brief') == 't') { 666 $result['return'] = 'minimal'; 667 } 668 669 return $result; 670 671 } 672 673 674 /** 675 * Returns information about Copy and Move requests 676 * 677 * This function is created to help getting information about the source and the destination for the 678 * WebDAV MOVE and COPY HTTP request. It also validates a lot of information and throws proper exceptions 679 * 680 * The returned value is an array with the following keys: 681 * * destination - Destination path 682 * * destinationExists - Whether or not the destination is an existing url (and should therefore be overwritten) 683 * 684 * @param RequestInterface $request 685 * @throws Exception\BadRequest upon missing or broken request headers 686 * @throws Exception\UnsupportedMediaType when trying to copy into a 687 * non-collection. 688 * @throws Exception\PreconditionFailed If overwrite is set to false, but 689 * the destination exists. 690 * @throws Exception\Forbidden when source and destination paths are 691 * identical. 692 * @throws Exception\Conflict When trying to copy a node into its own 693 * subtree. 694 * @return array 695 */ 696 function getCopyAndMoveInfo(RequestInterface $request) { 697 698 // Collecting the relevant HTTP headers 699 if (!$request->getHeader('Destination')) throw new Exception\BadRequest('The destination header was not supplied'); 700 $destination = $this->calculateUri($request->getHeader('Destination')); 701 $overwrite = $request->getHeader('Overwrite'); 702 if (!$overwrite) $overwrite = 'T'; 703 if (strtoupper($overwrite) == 'T') $overwrite = true; 704 elseif (strtoupper($overwrite) == 'F') $overwrite = false; 705 // We need to throw a bad request exception, if the header was invalid 706 else throw new Exception\BadRequest('The HTTP Overwrite header should be either T or F'); 707 708 list($destinationDir) = URLUtil::splitPath($destination); 709 710 try { 711 $destinationParent = $this->tree->getNodeForPath($destinationDir); 712 if (!($destinationParent instanceof ICollection)) throw new Exception\UnsupportedMediaType('The destination node is not a collection'); 713 } catch (Exception\NotFound $e) { 714 715 // If the destination parent node is not found, we throw a 409 716 throw new Exception\Conflict('The destination node is not found'); 717 } 718 719 try { 720 721 $destinationNode = $this->tree->getNodeForPath($destination); 722 723 // If this succeeded, it means the destination already exists 724 // we'll need to throw precondition failed in case overwrite is false 725 if (!$overwrite) throw new Exception\PreconditionFailed('The destination node already exists, and the overwrite header is set to false', 'Overwrite'); 726 727 } catch (Exception\NotFound $e) { 728 729 // Destination didn't exist, we're all good 730 $destinationNode = false; 731 732 } 733 734 $requestPath = $request->getPath(); 735 if ($destination === $requestPath) { 736 throw new Exception\Forbidden('Source and destination uri are identical.'); 737 } 738 if (substr($destination, 0, strlen($requestPath) + 1) === $requestPath . '/') { 739 throw new Exception\Conflict('The destination may not be part of the same subtree as the source path.'); 740 } 741 742 // These are the three relevant properties we need to return 743 return [ 744 'destination' => $destination, 745 'destinationExists' => !!$destinationNode, 746 'destinationNode' => $destinationNode, 747 ]; 748 749 } 750 751 /** 752 * Returns a list of properties for a path 753 * 754 * This is a simplified version getPropertiesForPath. 755 * if you aren't interested in status codes, but you just 756 * want to have a flat list of properties. Use this method. 757 * 758 * @param string $path 759 * @param array $propertyNames 760 */ 761 function getProperties($path, $propertyNames) { 762 763 $result = $this->getPropertiesForPath($path, $propertyNames, 0); 764 return $result[0][200]; 765 766 } 767 768 /** 769 * A kid-friendly way to fetch properties for a node's children. 770 * 771 * The returned array will be indexed by the path of the of child node. 772 * Only properties that are actually found will be returned. 773 * 774 * The parent node will not be returned. 775 * 776 * @param string $path 777 * @param array $propertyNames 778 * @return array 779 */ 780 function getPropertiesForChildren($path, $propertyNames) { 781 782 $result = []; 783 foreach ($this->getPropertiesForPath($path, $propertyNames, 1) as $k => $row) { 784 785 // Skipping the parent path 786 if ($k === 0) continue; 787 788 $result[$row['href']] = $row[200]; 789 790 } 791 return $result; 792 793 } 794 795 /** 796 * Returns a list of HTTP headers for a particular resource 797 * 798 * The generated http headers are based on properties provided by the 799 * resource. The method basically provides a simple mapping between 800 * DAV property and HTTP header. 801 * 802 * The headers are intended to be used for HEAD and GET requests. 803 * 804 * @param string $path 805 * @return array 806 */ 807 function getHTTPHeaders($path) { 808 809 $propertyMap = [ 810 '{DAV:}getcontenttype' => 'Content-Type', 811 '{DAV:}getcontentlength' => 'Content-Length', 812 '{DAV:}getlastmodified' => 'Last-Modified', 813 '{DAV:}getetag' => 'ETag', 814 ]; 815 816 $properties = $this->getProperties($path, array_keys($propertyMap)); 817 818 $headers = []; 819 foreach ($propertyMap as $property => $header) { 820 if (!isset($properties[$property])) continue; 821 822 if (is_scalar($properties[$property])) { 823 $headers[$header] = $properties[$property]; 824 825 // GetLastModified gets special cased 826 } elseif ($properties[$property] instanceof Xml\Property\GetLastModified) { 827 $headers[$header] = HTTP\Util::toHTTPDate($properties[$property]->getTime()); 828 } 829 830 } 831 832 return $headers; 833 834 } 835 836 /** 837 * Small helper to support PROPFIND with DEPTH_INFINITY. 838 * 839 * @param array[] $propFindRequests 840 * @param PropFind $propFind 841 * @return void 842 */ 843 private function addPathNodesRecursively(&$propFindRequests, PropFind $propFind) { 844 845 $newDepth = $propFind->getDepth(); 846 $path = $propFind->getPath(); 847 848 if ($newDepth !== self::DEPTH_INFINITY) { 849 $newDepth--; 850 } 851 852 foreach ($this->tree->getChildren($path) as $childNode) { 853 $subPropFind = clone $propFind; 854 $subPropFind->setDepth($newDepth); 855 if ($path !== '') { 856 $subPath = $path . '/' . $childNode->getName(); 857 } else { 858 $subPath = $childNode->getName(); 859 } 860 $subPropFind->setPath($subPath); 861 862 $propFindRequests[] = [ 863 $subPropFind, 864 $childNode 865 ]; 866 867 if (($newDepth === self::DEPTH_INFINITY || $newDepth >= 1) && $childNode instanceof ICollection) { 868 $this->addPathNodesRecursively($propFindRequests, $subPropFind); 869 } 870 871 } 872 } 873 874 /** 875 * Returns a list of properties for a given path 876 * 877 * The path that should be supplied should have the baseUrl stripped out 878 * The list of properties should be supplied in Clark notation. If the list is empty 879 * 'allprops' is assumed. 880 * 881 * If a depth of 1 is requested child elements will also be returned. 882 * 883 * @param string $path 884 * @param array $propertyNames 885 * @param int $depth 886 * @return array 887 */ 888 function getPropertiesForPath($path, $propertyNames = [], $depth = 0) { 889 890 // The only two options for the depth of a propfind is 0 or 1 - as long as depth infinity is not enabled 891 if (!$this->enablePropfindDepthInfinity && $depth != 0) $depth = 1; 892 893 $path = trim($path, '/'); 894 895 $propFindType = $propertyNames ? PropFind::NORMAL : PropFind::ALLPROPS; 896 $propFind = new PropFind($path, (array)$propertyNames, $depth, $propFindType); 897 898 $parentNode = $this->tree->getNodeForPath($path); 899 900 $propFindRequests = [[ 901 $propFind, 902 $parentNode 903 ]]; 904 905 if (($depth > 0 || $depth === self::DEPTH_INFINITY) && $parentNode instanceof ICollection) { 906 $this->addPathNodesRecursively($propFindRequests, $propFind); 907 } 908 909 $returnPropertyList = []; 910 911 foreach ($propFindRequests as $propFindRequest) { 912 913 list($propFind, $node) = $propFindRequest; 914 $r = $this->getPropertiesByNode($propFind, $node); 915 if ($r) { 916 $result = $propFind->getResultForMultiStatus(); 917 $result['href'] = $propFind->getPath(); 918 919 // WebDAV recommends adding a slash to the path, if the path is 920 // a collection. 921 // Furthermore, iCal also demands this to be the case for 922 // principals. This is non-standard, but we support it. 923 $resourceType = $this->getResourceTypeForNode($node); 924 if (in_array('{DAV:}collection', $resourceType) || in_array('{DAV:}principal', $resourceType)) { 925 $result['href'] .= '/'; 926 } 927 $returnPropertyList[] = $result; 928 } 929 930 } 931 932 return $returnPropertyList; 933 934 } 935 936 /** 937 * Returns a list of properties for a list of paths. 938 * 939 * The path that should be supplied should have the baseUrl stripped out 940 * The list of properties should be supplied in Clark notation. If the list is empty 941 * 'allprops' is assumed. 942 * 943 * The result is returned as an array, with paths for it's keys. 944 * The result may be returned out of order. 945 * 946 * @param array $paths 947 * @param array $propertyNames 948 * @return array 949 */ 950 function getPropertiesForMultiplePaths(array $paths, array $propertyNames = []) { 951 952 $result = [ 953 ]; 954 955 $nodes = $this->tree->getMultipleNodes($paths); 956 957 foreach ($nodes as $path => $node) { 958 959 $propFind = new PropFind($path, $propertyNames); 960 $r = $this->getPropertiesByNode($propFind, $node); 961 if ($r) { 962 $result[$path] = $propFind->getResultForMultiStatus(); 963 $result[$path]['href'] = $path; 964 965 $resourceType = $this->getResourceTypeForNode($node); 966 if (in_array('{DAV:}collection', $resourceType) || in_array('{DAV:}principal', $resourceType)) { 967 $result[$path]['href'] .= '/'; 968 } 969 } 970 971 } 972 973 return $result; 974 975 } 976 977 978 /** 979 * Determines all properties for a node. 980 * 981 * This method tries to grab all properties for a node. This method is used 982 * internally getPropertiesForPath and a few others. 983 * 984 * It could be useful to call this, if you already have an instance of your 985 * target node and simply want to run through the system to get a correct 986 * list of properties. 987 * 988 * @param PropFind $propFind 989 * @param INode $node 990 * @return bool 991 */ 992 function getPropertiesByNode(PropFind $propFind, INode $node) { 993 994 return $this->emit('propFind', [$propFind, $node]); 995 996 } 997 998 /** 999 * This method is invoked by sub-systems creating a new file. 1000 * 1001 * Currently this is done by HTTP PUT and HTTP LOCK (in the Locks_Plugin). 1002 * It was important to get this done through a centralized function, 1003 * allowing plugins to intercept this using the beforeCreateFile event. 1004 * 1005 * This method will return true if the file was actually created 1006 * 1007 * @param string $uri 1008 * @param resource $data 1009 * @param string $etag 1010 * @return bool 1011 */ 1012 function createFile($uri, $data, &$etag = null) { 1013 1014 list($dir, $name) = URLUtil::splitPath($uri); 1015 1016 if (!$this->emit('beforeBind', [$uri])) return false; 1017 1018 $parent = $this->tree->getNodeForPath($dir); 1019 if (!$parent instanceof ICollection) { 1020 throw new Exception\Conflict('Files can only be created as children of collections'); 1021 } 1022 1023 // It is possible for an event handler to modify the content of the 1024 // body, before it gets written. If this is the case, $modified 1025 // should be set to true. 1026 // 1027 // If $modified is true, we must not send back an ETag. 1028 $modified = false; 1029 if (!$this->emit('beforeCreateFile', [$uri, &$data, $parent, &$modified])) return false; 1030 1031 $etag = $parent->createFile($name, $data); 1032 1033 if ($modified) $etag = null; 1034 1035 $this->tree->markDirty($dir . '/' . $name); 1036 1037 $this->emit('afterBind', [$uri]); 1038 $this->emit('afterCreateFile', [$uri, $parent]); 1039 1040 return true; 1041 } 1042 1043 /** 1044 * This method is invoked by sub-systems updating a file. 1045 * 1046 * This method will return true if the file was actually updated 1047 * 1048 * @param string $uri 1049 * @param resource $data 1050 * @param string $etag 1051 * @return bool 1052 */ 1053 function updateFile($uri, $data, &$etag = null) { 1054 1055 $node = $this->tree->getNodeForPath($uri); 1056 1057 // It is possible for an event handler to modify the content of the 1058 // body, before it gets written. If this is the case, $modified 1059 // should be set to true. 1060 // 1061 // If $modified is true, we must not send back an ETag. 1062 $modified = false; 1063 if (!$this->emit('beforeWriteContent', [$uri, $node, &$data, &$modified])) return false; 1064 1065 $etag = $node->put($data); 1066 if ($modified) $etag = null; 1067 $this->emit('afterWriteContent', [$uri, $node]); 1068 1069 return true; 1070 } 1071 1072 1073 1074 /** 1075 * This method is invoked by sub-systems creating a new directory. 1076 * 1077 * @param string $uri 1078 * @return void 1079 */ 1080 function createDirectory($uri) { 1081 1082 $this->createCollection($uri, new MkCol(['{DAV:}collection'], [])); 1083 1084 } 1085 1086 /** 1087 * Use this method to create a new collection 1088 * 1089 * @param string $uri The new uri 1090 * @param MkCol $mkCol 1091 * @return array|null 1092 */ 1093 function createCollection($uri, MkCol $mkCol) { 1094 1095 list($parentUri, $newName) = URLUtil::splitPath($uri); 1096 1097 // Making sure the parent exists 1098 try { 1099 $parent = $this->tree->getNodeForPath($parentUri); 1100 1101 } catch (Exception\NotFound $e) { 1102 throw new Exception\Conflict('Parent node does not exist'); 1103 1104 } 1105 1106 // Making sure the parent is a collection 1107 if (!$parent instanceof ICollection) { 1108 throw new Exception\Conflict('Parent node is not a collection'); 1109 } 1110 1111 // Making sure the child does not already exist 1112 try { 1113 $parent->getChild($newName); 1114 1115 // If we got here.. it means there's already a node on that url, and we need to throw a 405 1116 throw new Exception\MethodNotAllowed('The resource you tried to create already exists'); 1117 1118 } catch (Exception\NotFound $e) { 1119 // NotFound is the expected behavior. 1120 } 1121 1122 1123 if (!$this->emit('beforeBind', [$uri])) return; 1124 1125 if ($parent instanceof IExtendedCollection) { 1126 1127 /** 1128 * If the parent is an instance of IExtendedCollection, it means that 1129 * we can pass the MkCol object directly as it may be able to store 1130 * properties immediately. 1131 */ 1132 $parent->createExtendedCollection($newName, $mkCol); 1133 1134 } else { 1135 1136 /** 1137 * If the parent is a standard ICollection, it means only 1138 * 'standard' collections can be created, so we should fail any 1139 * MKCOL operation that carries extra resourcetypes. 1140 */ 1141 if (count($mkCol->getResourceType()) > 1) { 1142 throw new Exception\InvalidResourceType('The {DAV:}resourcetype you specified is not supported here.'); 1143 } 1144 1145 $parent->createDirectory($newName); 1146 1147 } 1148 1149 // If there are any properties that have not been handled/stored, 1150 // we ask the 'propPatch' event to handle them. This will allow for 1151 // example the propertyStorage system to store properties upon MKCOL. 1152 if ($mkCol->getRemainingMutations()) { 1153 $this->emit('propPatch', [$uri, $mkCol]); 1154 } 1155 $success = $mkCol->commit(); 1156 1157 if (!$success) { 1158 $result = $mkCol->getResult(); 1159 // generateMkCol needs the href key to exist. 1160 $result['href'] = $uri; 1161 return $result; 1162 } 1163 1164 $this->tree->markDirty($parentUri); 1165 $this->emit('afterBind', [$uri]); 1166 1167 } 1168 1169 /** 1170 * This method updates a resource's properties 1171 * 1172 * The properties array must be a list of properties. Array-keys are 1173 * property names in clarknotation, array-values are it's values. 1174 * If a property must be deleted, the value should be null. 1175 * 1176 * Note that this request should either completely succeed, or 1177 * completely fail. 1178 * 1179 * The response is an array with properties for keys, and http status codes 1180 * as their values. 1181 * 1182 * @param string $path 1183 * @param array $properties 1184 * @return array 1185 */ 1186 function updateProperties($path, array $properties) { 1187 1188 $propPatch = new PropPatch($properties); 1189 $this->emit('propPatch', [$path, $propPatch]); 1190 $propPatch->commit(); 1191 1192 return $propPatch->getResult(); 1193 1194 } 1195 1196 /** 1197 * This method checks the main HTTP preconditions. 1198 * 1199 * Currently these are: 1200 * * If-Match 1201 * * If-None-Match 1202 * * If-Modified-Since 1203 * * If-Unmodified-Since 1204 * 1205 * The method will return true if all preconditions are met 1206 * The method will return false, or throw an exception if preconditions 1207 * failed. If false is returned the operation should be aborted, and 1208 * the appropriate HTTP response headers are already set. 1209 * 1210 * Normally this method will throw 412 Precondition Failed for failures 1211 * related to If-None-Match, If-Match and If-Unmodified Since. It will 1212 * set the status to 304 Not Modified for If-Modified_since. 1213 * 1214 * @param RequestInterface $request 1215 * @param ResponseInterface $response 1216 * @return bool 1217 */ 1218 function checkPreconditions(RequestInterface $request, ResponseInterface $response) { 1219 1220 $path = $request->getPath(); 1221 $node = null; 1222 $lastMod = null; 1223 $etag = null; 1224 1225 if ($ifMatch = $request->getHeader('If-Match')) { 1226 1227 // If-Match contains an entity tag. Only if the entity-tag 1228 // matches we are allowed to make the request succeed. 1229 // If the entity-tag is '*' we are only allowed to make the 1230 // request succeed if a resource exists at that url. 1231 try { 1232 $node = $this->tree->getNodeForPath($path); 1233 } catch (Exception\NotFound $e) { 1234 throw new Exception\PreconditionFailed('An If-Match header was specified and the resource did not exist', 'If-Match'); 1235 } 1236 1237 // Only need to check entity tags if they are not * 1238 if ($ifMatch !== '*') { 1239 1240 // There can be multiple ETags 1241 $ifMatch = explode(',', $ifMatch); 1242 $haveMatch = false; 1243 foreach ($ifMatch as $ifMatchItem) { 1244 1245 // Stripping any extra spaces 1246 $ifMatchItem = trim($ifMatchItem, ' '); 1247 1248 $etag = $node instanceof IFile ? $node->getETag() : null; 1249 if ($etag === $ifMatchItem) { 1250 $haveMatch = true; 1251 } else { 1252 // Evolution has a bug where it sometimes prepends the " 1253 // with a \. This is our workaround. 1254 if (str_replace('\\"', '"', $ifMatchItem) === $etag) { 1255 $haveMatch = true; 1256 } 1257 } 1258 1259 } 1260 if (!$haveMatch) { 1261 if ($etag) $response->setHeader('ETag', $etag); 1262 throw new Exception\PreconditionFailed('An If-Match header was specified, but none of the specified the ETags matched.', 'If-Match'); 1263 } 1264 } 1265 } 1266 1267 if ($ifNoneMatch = $request->getHeader('If-None-Match')) { 1268 1269 // The If-None-Match header contains an ETag. 1270 // Only if the ETag does not match the current ETag, the request will succeed 1271 // The header can also contain *, in which case the request 1272 // will only succeed if the entity does not exist at all. 1273 $nodeExists = true; 1274 if (!$node) { 1275 try { 1276 $node = $this->tree->getNodeForPath($path); 1277 } catch (Exception\NotFound $e) { 1278 $nodeExists = false; 1279 } 1280 } 1281 if ($nodeExists) { 1282 $haveMatch = false; 1283 if ($ifNoneMatch === '*') $haveMatch = true; 1284 else { 1285 1286 // There might be multiple ETags 1287 $ifNoneMatch = explode(',', $ifNoneMatch); 1288 $etag = $node instanceof IFile ? $node->getETag() : null; 1289 1290 foreach ($ifNoneMatch as $ifNoneMatchItem) { 1291 1292 // Stripping any extra spaces 1293 $ifNoneMatchItem = trim($ifNoneMatchItem, ' '); 1294 1295 if ($etag === $ifNoneMatchItem) $haveMatch = true; 1296 1297 } 1298 1299 } 1300 1301 if ($haveMatch) { 1302 if ($etag) $response->setHeader('ETag', $etag); 1303 if ($request->getMethod() === 'GET') { 1304 $response->setStatus(304); 1305 return false; 1306 } else { 1307 throw new Exception\PreconditionFailed('An If-None-Match header was specified, but the ETag matched (or * was specified).', 'If-None-Match'); 1308 } 1309 } 1310 } 1311 1312 } 1313 1314 if (!$ifNoneMatch && ($ifModifiedSince = $request->getHeader('If-Modified-Since'))) { 1315 1316 // The If-Modified-Since header contains a date. We 1317 // will only return the entity if it has been changed since 1318 // that date. If it hasn't been changed, we return a 304 1319 // header 1320 // Note that this header only has to be checked if there was no If-None-Match header 1321 // as per the HTTP spec. 1322 $date = HTTP\Util::parseHTTPDate($ifModifiedSince); 1323 1324 if ($date) { 1325 if (is_null($node)) { 1326 $node = $this->tree->getNodeForPath($path); 1327 } 1328 $lastMod = $node->getLastModified(); 1329 if ($lastMod) { 1330 $lastMod = new \DateTime('@' . $lastMod); 1331 if ($lastMod <= $date) { 1332 $response->setStatus(304); 1333 $response->setHeader('Last-Modified', HTTP\Util::toHTTPDate($lastMod)); 1334 return false; 1335 } 1336 } 1337 } 1338 } 1339 1340 if ($ifUnmodifiedSince = $request->getHeader('If-Unmodified-Since')) { 1341 1342 // The If-Unmodified-Since will allow allow the request if the 1343 // entity has not changed since the specified date. 1344 $date = HTTP\Util::parseHTTPDate($ifUnmodifiedSince); 1345 1346 // We must only check the date if it's valid 1347 if ($date) { 1348 if (is_null($node)) { 1349 $node = $this->tree->getNodeForPath($path); 1350 } 1351 $lastMod = $node->getLastModified(); 1352 if ($lastMod) { 1353 $lastMod = new \DateTime('@' . $lastMod); 1354 if ($lastMod > $date) { 1355 throw new Exception\PreconditionFailed('An If-Unmodified-Since header was specified, but the entity has been changed since the specified date.', 'If-Unmodified-Since'); 1356 } 1357 } 1358 } 1359 1360 } 1361 1362 // Now the hardest, the If: header. The If: header can contain multiple 1363 // urls, ETags and so-called 'state tokens'. 1364 // 1365 // Examples of state tokens include lock-tokens (as defined in rfc4918) 1366 // and sync-tokens (as defined in rfc6578). 1367 // 1368 // The only proper way to deal with these, is to emit events, that a 1369 // Sync and Lock plugin can pick up. 1370 $ifConditions = $this->getIfConditions($request); 1371 1372 foreach ($ifConditions as $kk => $ifCondition) { 1373 foreach ($ifCondition['tokens'] as $ii => $token) { 1374 $ifConditions[$kk]['tokens'][$ii]['validToken'] = false; 1375 } 1376 } 1377 1378 // Plugins are responsible for validating all the tokens. 1379 // If a plugin deemed a token 'valid', it will set 'validToken' to 1380 // true. 1381 $this->emit('validateTokens', [ $request, &$ifConditions ]); 1382 1383 // Now we're going to analyze the result. 1384 1385 // Every ifCondition needs to validate to true, so we exit as soon as 1386 // we have an invalid condition. 1387 foreach ($ifConditions as $ifCondition) { 1388 1389 $uri = $ifCondition['uri']; 1390 $tokens = $ifCondition['tokens']; 1391 1392 // We only need 1 valid token for the condition to succeed. 1393 foreach ($tokens as $token) { 1394 1395 $tokenValid = $token['validToken'] || !$token['token']; 1396 1397 $etagValid = false; 1398 if (!$token['etag']) { 1399 $etagValid = true; 1400 } 1401 // Checking the ETag, only if the token was already deamed 1402 // valid and there is one. 1403 if ($token['etag'] && $tokenValid) { 1404 1405 // The token was valid, and there was an ETag. We must 1406 // grab the current ETag and check it. 1407 $node = $this->tree->getNodeForPath($uri); 1408 $etagValid = $node instanceof IFile && $node->getETag() == $token['etag']; 1409 1410 } 1411 1412 1413 if (($tokenValid && $etagValid) ^ $token['negate']) { 1414 // Both were valid, so we can go to the next condition. 1415 continue 2; 1416 } 1417 1418 1419 } 1420 1421 // If we ended here, it means there was no valid ETag + token 1422 // combination found for the current condition. This means we fail! 1423 throw new Exception\PreconditionFailed('Failed to find a valid token/etag combination for ' . $uri, 'If'); 1424 1425 } 1426 1427 return true; 1428 1429 } 1430 1431 /** 1432 * This method is created to extract information from the WebDAV HTTP 'If:' header 1433 * 1434 * The If header can be quite complex, and has a bunch of features. We're using a regex to extract all relevant information 1435 * The function will return an array, containing structs with the following keys 1436 * 1437 * * uri - the uri the condition applies to. 1438 * * tokens - The lock token. another 2 dimensional array containing 3 elements 1439 * 1440 * Example 1: 1441 * 1442 * If: (<opaquelocktoken:181d4fae-7d8c-11d0-a765-00a0c91e6bf2>) 1443 * 1444 * Would result in: 1445 * 1446 * [ 1447 * [ 1448 * 'uri' => '/request/uri', 1449 * 'tokens' => [ 1450 * [ 1451 * [ 1452 * 'negate' => false, 1453 * 'token' => 'opaquelocktoken:181d4fae-7d8c-11d0-a765-00a0c91e6bf2', 1454 * 'etag' => "" 1455 * ] 1456 * ] 1457 * ], 1458 * ] 1459 * ] 1460 * 1461 * Example 2: 1462 * 1463 * If: </path/> (Not <opaquelocktoken:181d4fae-7d8c-11d0-a765-00a0c91e6bf2> ["Im An ETag"]) (["Another ETag"]) </path2/> (Not ["Path2 ETag"]) 1464 * 1465 * Would result in: 1466 * 1467 * [ 1468 * [ 1469 * 'uri' => 'path', 1470 * 'tokens' => [ 1471 * [ 1472 * [ 1473 * 'negate' => true, 1474 * 'token' => 'opaquelocktoken:181d4fae-7d8c-11d0-a765-00a0c91e6bf2', 1475 * 'etag' => '"Im An ETag"' 1476 * ], 1477 * [ 1478 * 'negate' => false, 1479 * 'token' => '', 1480 * 'etag' => '"Another ETag"' 1481 * ] 1482 * ] 1483 * ], 1484 * ], 1485 * [ 1486 * 'uri' => 'path2', 1487 * 'tokens' => [ 1488 * [ 1489 * [ 1490 * 'negate' => true, 1491 * 'token' => '', 1492 * 'etag' => '"Path2 ETag"' 1493 * ] 1494 * ] 1495 * ], 1496 * ], 1497 * ] 1498 * 1499 * @param RequestInterface $request 1500 * @return array 1501 */ 1502 function getIfConditions(RequestInterface $request) { 1503 1504 $header = $request->getHeader('If'); 1505 if (!$header) return []; 1506 1507 $matches = []; 1508 1509 $regex = '/(?:\<(?P<uri>.*?)\>\s)?\((?P<not>Not\s)?(?:\<(?P<token>[^\>]*)\>)?(?:\s?)(?:\[(?P<etag>[^\]]*)\])?\)/im'; 1510 preg_match_all($regex, $header, $matches, PREG_SET_ORDER); 1511 1512 $conditions = []; 1513 1514 foreach ($matches as $match) { 1515 1516 // If there was no uri specified in this match, and there were 1517 // already conditions parsed, we add the condition to the list of 1518 // conditions for the previous uri. 1519 if (!$match['uri'] && count($conditions)) { 1520 $conditions[count($conditions) - 1]['tokens'][] = [ 1521 'negate' => $match['not'] ? true : false, 1522 'token' => $match['token'], 1523 'etag' => isset($match['etag']) ? $match['etag'] : '' 1524 ]; 1525 } else { 1526 1527 if (!$match['uri']) { 1528 $realUri = $request->getPath(); 1529 } else { 1530 $realUri = $this->calculateUri($match['uri']); 1531 } 1532 1533 $conditions[] = [ 1534 'uri' => $realUri, 1535 'tokens' => [ 1536 [ 1537 'negate' => $match['not'] ? true : false, 1538 'token' => $match['token'], 1539 'etag' => isset($match['etag']) ? $match['etag'] : '' 1540 ] 1541 ], 1542 1543 ]; 1544 } 1545 1546 } 1547 1548 return $conditions; 1549 1550 } 1551 1552 /** 1553 * Returns an array with resourcetypes for a node. 1554 * 1555 * @param INode $node 1556 * @return array 1557 */ 1558 function getResourceTypeForNode(INode $node) { 1559 1560 $result = []; 1561 foreach ($this->resourceTypeMapping as $className => $resourceType) { 1562 if ($node instanceof $className) $result[] = $resourceType; 1563 } 1564 return $result; 1565 1566 } 1567 1568 // }}} 1569 // {{{ XML Readers & Writers 1570 1571 1572 /** 1573 * Generates a WebDAV propfind response body based on a list of nodes. 1574 * 1575 * If 'strip404s' is set to true, all 404 responses will be removed. 1576 * 1577 * @param array $fileProperties The list with nodes 1578 * @param bool strip404s 1579 * @return string 1580 */ 1581 function generateMultiStatus(array $fileProperties, $strip404s = false) { 1582 1583 $xml = []; 1584 1585 foreach ($fileProperties as $entry) { 1586 1587 $href = $entry['href']; 1588 unset($entry['href']); 1589 if ($strip404s) { 1590 unset($entry[404]); 1591 } 1592 $response = new Xml\Element\Response( 1593 ltrim($href, '/'), 1594 $entry 1595 ); 1596 $xml[] = [ 1597 'name' => '{DAV:}response', 1598 'value' => $response 1599 ]; 1600 1601 } 1602 return $this->xml->write('{DAV:}multistatus', $xml, $this->baseUri); 1603 1604 } 1605 1606} 1607