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