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 LoggerAwareTrait; 29 use WildcardEmitterTrait; 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 try { 1079 $parent = $this->tree->getNodeForPath($dir); 1080 } catch (Exception\NotFound $e) { 1081 throw new Exception\Conflict('Files cannot be created in non-existent collections'); 1082 } 1083 1084 if (!$parent instanceof ICollection) { 1085 throw new Exception\Conflict('Files can only be created as children of collections'); 1086 } 1087 1088 // It is possible for an event handler to modify the content of the 1089 // body, before it gets written. If this is the case, $modified 1090 // should be set to true. 1091 // 1092 // If $modified is true, we must not send back an ETag. 1093 $modified = false; 1094 if (!$this->emit('beforeCreateFile', [$uri, &$data, $parent, &$modified])) { 1095 return false; 1096 } 1097 1098 $etag = $parent->createFile($name, $data); 1099 1100 if ($modified) { 1101 $etag = null; 1102 } 1103 1104 $this->tree->markDirty($dir.'/'.$name); 1105 1106 $this->emit('afterBind', [$uri]); 1107 $this->emit('afterCreateFile', [$uri, $parent]); 1108 1109 return true; 1110 } 1111 1112 /** 1113 * This method is invoked by sub-systems updating a file. 1114 * 1115 * This method will return true if the file was actually updated 1116 * 1117 * @param string $uri 1118 * @param resource $data 1119 * @param string $etag 1120 * 1121 * @return bool 1122 */ 1123 public function updateFile($uri, $data, &$etag = null) 1124 { 1125 $node = $this->tree->getNodeForPath($uri); 1126 1127 // It is possible for an event handler to modify the content of the 1128 // body, before it gets written. If this is the case, $modified 1129 // should be set to true. 1130 // 1131 // If $modified is true, we must not send back an ETag. 1132 $modified = false; 1133 if (!$this->emit('beforeWriteContent', [$uri, $node, &$data, &$modified])) { 1134 return false; 1135 } 1136 1137 $etag = $node->put($data); 1138 if ($modified) { 1139 $etag = null; 1140 } 1141 $this->emit('afterWriteContent', [$uri, $node]); 1142 1143 return true; 1144 } 1145 1146 /** 1147 * This method is invoked by sub-systems creating a new directory. 1148 * 1149 * @param string $uri 1150 */ 1151 public function createDirectory($uri) 1152 { 1153 $this->createCollection($uri, new MkCol(['{DAV:}collection'], [])); 1154 } 1155 1156 /** 1157 * Use this method to create a new collection. 1158 * 1159 * @param string $uri The new uri 1160 * 1161 * @return array|null 1162 */ 1163 public function createCollection($uri, MkCol $mkCol) 1164 { 1165 list($parentUri, $newName) = Uri\split($uri); 1166 1167 // Making sure the parent exists 1168 try { 1169 $parent = $this->tree->getNodeForPath($parentUri); 1170 } catch (Exception\NotFound $e) { 1171 throw new Exception\Conflict('Parent node does not exist'); 1172 } 1173 1174 // Making sure the parent is a collection 1175 if (!$parent instanceof ICollection) { 1176 throw new Exception\Conflict('Parent node is not a collection'); 1177 } 1178 1179 // Making sure the child does not already exist 1180 try { 1181 $parent->getChild($newName); 1182 1183 // If we got here.. it means there's already a node on that url, and we need to throw a 405 1184 throw new Exception\MethodNotAllowed('The resource you tried to create already exists'); 1185 } catch (Exception\NotFound $e) { 1186 // NotFound is the expected behavior. 1187 } 1188 1189 if (!$this->emit('beforeBind', [$uri])) { 1190 return; 1191 } 1192 1193 if ($parent instanceof IExtendedCollection) { 1194 /* 1195 * If the parent is an instance of IExtendedCollection, it means that 1196 * we can pass the MkCol object directly as it may be able to store 1197 * properties immediately. 1198 */ 1199 $parent->createExtendedCollection($newName, $mkCol); 1200 } else { 1201 /* 1202 * If the parent is a standard ICollection, it means only 1203 * 'standard' collections can be created, so we should fail any 1204 * MKCOL operation that carries extra resourcetypes. 1205 */ 1206 if (count($mkCol->getResourceType()) > 1) { 1207 throw new Exception\InvalidResourceType('The {DAV:}resourcetype you specified is not supported here.'); 1208 } 1209 1210 $parent->createDirectory($newName); 1211 } 1212 1213 // If there are any properties that have not been handled/stored, 1214 // we ask the 'propPatch' event to handle them. This will allow for 1215 // example the propertyStorage system to store properties upon MKCOL. 1216 if ($mkCol->getRemainingMutations()) { 1217 $this->emit('propPatch', [$uri, $mkCol]); 1218 } 1219 $success = $mkCol->commit(); 1220 1221 if (!$success) { 1222 $result = $mkCol->getResult(); 1223 1224 $formattedResult = [ 1225 'href' => $uri, 1226 ]; 1227 1228 foreach ($result as $propertyName => $status) { 1229 if (!isset($formattedResult[$status])) { 1230 $formattedResult[$status] = []; 1231 } 1232 $formattedResult[$status][$propertyName] = null; 1233 } 1234 1235 return $formattedResult; 1236 } 1237 1238 $this->tree->markDirty($parentUri); 1239 $this->emit('afterBind', [$uri]); 1240 } 1241 1242 /** 1243 * This method updates a resource's properties. 1244 * 1245 * The properties array must be a list of properties. Array-keys are 1246 * property names in clarknotation, array-values are it's values. 1247 * If a property must be deleted, the value should be null. 1248 * 1249 * Note that this request should either completely succeed, or 1250 * completely fail. 1251 * 1252 * The response is an array with properties for keys, and http status codes 1253 * as their values. 1254 * 1255 * @param string $path 1256 * 1257 * @return array 1258 */ 1259 public function updateProperties($path, array $properties) 1260 { 1261 $propPatch = new PropPatch($properties); 1262 $this->emit('propPatch', [$path, $propPatch]); 1263 $propPatch->commit(); 1264 1265 return $propPatch->getResult(); 1266 } 1267 1268 /** 1269 * This method checks the main HTTP preconditions. 1270 * 1271 * Currently these are: 1272 * * If-Match 1273 * * If-None-Match 1274 * * If-Modified-Since 1275 * * If-Unmodified-Since 1276 * 1277 * The method will return true if all preconditions are met 1278 * The method will return false, or throw an exception if preconditions 1279 * failed. If false is returned the operation should be aborted, and 1280 * the appropriate HTTP response headers are already set. 1281 * 1282 * Normally this method will throw 412 Precondition Failed for failures 1283 * related to If-None-Match, If-Match and If-Unmodified Since. It will 1284 * set the status to 304 Not Modified for If-Modified_since. 1285 * 1286 * @return bool 1287 */ 1288 public function checkPreconditions(RequestInterface $request, ResponseInterface $response) 1289 { 1290 $path = $request->getPath(); 1291 $node = null; 1292 $lastMod = null; 1293 $etag = null; 1294 1295 if ($ifMatch = $request->getHeader('If-Match')) { 1296 // If-Match contains an entity tag. Only if the entity-tag 1297 // matches we are allowed to make the request succeed. 1298 // If the entity-tag is '*' we are only allowed to make the 1299 // request succeed if a resource exists at that url. 1300 try { 1301 $node = $this->tree->getNodeForPath($path); 1302 } catch (Exception\NotFound $e) { 1303 throw new Exception\PreconditionFailed('An If-Match header was specified and the resource did not exist', 'If-Match'); 1304 } 1305 1306 // Only need to check entity tags if they are not * 1307 if ('*' !== $ifMatch) { 1308 // There can be multiple ETags 1309 $ifMatch = explode(',', $ifMatch); 1310 $haveMatch = false; 1311 foreach ($ifMatch as $ifMatchItem) { 1312 // Stripping any extra spaces 1313 $ifMatchItem = trim($ifMatchItem, ' '); 1314 1315 $etag = $node instanceof IFile ? $node->getETag() : null; 1316 if ($etag === $ifMatchItem) { 1317 $haveMatch = true; 1318 } else { 1319 // Evolution has a bug where it sometimes prepends the " 1320 // with a \. This is our workaround. 1321 if (str_replace('\\"', '"', $ifMatchItem) === $etag) { 1322 $haveMatch = true; 1323 } 1324 } 1325 } 1326 if (!$haveMatch) { 1327 if ($etag) { 1328 $response->setHeader('ETag', $etag); 1329 } 1330 throw new Exception\PreconditionFailed('An If-Match header was specified, but none of the specified ETags matched.', 'If-Match'); 1331 } 1332 } 1333 } 1334 1335 if ($ifNoneMatch = $request->getHeader('If-None-Match')) { 1336 // The If-None-Match header contains an ETag. 1337 // Only if the ETag does not match the current ETag, the request will succeed 1338 // The header can also contain *, in which case the request 1339 // will only succeed if the entity does not exist at all. 1340 $nodeExists = true; 1341 if (!$node) { 1342 try { 1343 $node = $this->tree->getNodeForPath($path); 1344 } catch (Exception\NotFound $e) { 1345 $nodeExists = false; 1346 } 1347 } 1348 if ($nodeExists) { 1349 $haveMatch = false; 1350 if ('*' === $ifNoneMatch) { 1351 $haveMatch = true; 1352 } else { 1353 // There might be multiple ETags 1354 $ifNoneMatch = explode(',', $ifNoneMatch); 1355 $etag = $node instanceof IFile ? $node->getETag() : null; 1356 1357 foreach ($ifNoneMatch as $ifNoneMatchItem) { 1358 // Stripping any extra spaces 1359 $ifNoneMatchItem = trim($ifNoneMatchItem, ' '); 1360 1361 if ($etag === $ifNoneMatchItem) { 1362 $haveMatch = true; 1363 } 1364 } 1365 } 1366 1367 if ($haveMatch) { 1368 if ($etag) { 1369 $response->setHeader('ETag', $etag); 1370 } 1371 if ('GET' === $request->getMethod()) { 1372 $response->setStatus(304); 1373 1374 return false; 1375 } else { 1376 throw new Exception\PreconditionFailed('An If-None-Match header was specified, but the ETag matched (or * was specified).', 'If-None-Match'); 1377 } 1378 } 1379 } 1380 } 1381 1382 if (!$ifNoneMatch && ($ifModifiedSince = $request->getHeader('If-Modified-Since'))) { 1383 // The If-Modified-Since header contains a date. We 1384 // will only return the entity if it has been changed since 1385 // that date. If it hasn't been changed, we return a 304 1386 // header 1387 // Note that this header only has to be checked if there was no If-None-Match header 1388 // as per the HTTP spec. 1389 $date = HTTP\parseDate($ifModifiedSince); 1390 1391 if ($date) { 1392 if (is_null($node)) { 1393 $node = $this->tree->getNodeForPath($path); 1394 } 1395 $lastMod = $node->getLastModified(); 1396 if ($lastMod) { 1397 $lastMod = new \DateTime('@'.$lastMod); 1398 if ($lastMod <= $date) { 1399 $response->setStatus(304); 1400 $response->setHeader('Last-Modified', HTTP\toDate($lastMod)); 1401 1402 return false; 1403 } 1404 } 1405 } 1406 } 1407 1408 if ($ifUnmodifiedSince = $request->getHeader('If-Unmodified-Since')) { 1409 // The If-Unmodified-Since will allow allow the request if the 1410 // entity has not changed since the specified date. 1411 $date = HTTP\parseDate($ifUnmodifiedSince); 1412 1413 // We must only check the date if it's valid 1414 if ($date) { 1415 if (is_null($node)) { 1416 $node = $this->tree->getNodeForPath($path); 1417 } 1418 $lastMod = $node->getLastModified(); 1419 if ($lastMod) { 1420 $lastMod = new \DateTime('@'.$lastMod); 1421 if ($lastMod > $date) { 1422 throw new Exception\PreconditionFailed('An If-Unmodified-Since header was specified, but the entity has been changed since the specified date.', 'If-Unmodified-Since'); 1423 } 1424 } 1425 } 1426 } 1427 1428 // Now the hardest, the If: header. The If: header can contain multiple 1429 // urls, ETags and so-called 'state tokens'. 1430 // 1431 // Examples of state tokens include lock-tokens (as defined in rfc4918) 1432 // and sync-tokens (as defined in rfc6578). 1433 // 1434 // The only proper way to deal with these, is to emit events, that a 1435 // Sync and Lock plugin can pick up. 1436 $ifConditions = $this->getIfConditions($request); 1437 1438 foreach ($ifConditions as $kk => $ifCondition) { 1439 foreach ($ifCondition['tokens'] as $ii => $token) { 1440 $ifConditions[$kk]['tokens'][$ii]['validToken'] = false; 1441 } 1442 } 1443 1444 // Plugins are responsible for validating all the tokens. 1445 // If a plugin deemed a token 'valid', it will set 'validToken' to 1446 // true. 1447 $this->emit('validateTokens', [$request, &$ifConditions]); 1448 1449 // Now we're going to analyze the result. 1450 1451 // Every ifCondition needs to validate to true, so we exit as soon as 1452 // we have an invalid condition. 1453 foreach ($ifConditions as $ifCondition) { 1454 $uri = $ifCondition['uri']; 1455 $tokens = $ifCondition['tokens']; 1456 1457 // We only need 1 valid token for the condition to succeed. 1458 foreach ($tokens as $token) { 1459 $tokenValid = $token['validToken'] || !$token['token']; 1460 1461 $etagValid = false; 1462 if (!$token['etag']) { 1463 $etagValid = true; 1464 } 1465 // Checking the ETag, only if the token was already deemed 1466 // valid and there is one. 1467 if ($token['etag'] && $tokenValid) { 1468 // The token was valid, and there was an ETag. We must 1469 // grab the current ETag and check it. 1470 $node = $this->tree->getNodeForPath($uri); 1471 $etagValid = $node instanceof IFile && $node->getETag() == $token['etag']; 1472 } 1473 1474 if (($tokenValid && $etagValid) ^ $token['negate']) { 1475 // Both were valid, so we can go to the next condition. 1476 continue 2; 1477 } 1478 } 1479 1480 // If we ended here, it means there was no valid ETag + token 1481 // combination found for the current condition. This means we fail! 1482 throw new Exception\PreconditionFailed('Failed to find a valid token/etag combination for '.$uri, 'If'); 1483 } 1484 1485 return true; 1486 } 1487 1488 /** 1489 * This method is created to extract information from the WebDAV HTTP 'If:' header. 1490 * 1491 * The If header can be quite complex, and has a bunch of features. We're using a regex to extract all relevant information 1492 * The function will return an array, containing structs with the following keys 1493 * 1494 * * uri - the uri the condition applies to. 1495 * * tokens - The lock token. another 2 dimensional array containing 3 elements 1496 * 1497 * Example 1: 1498 * 1499 * If: (<opaquelocktoken:181d4fae-7d8c-11d0-a765-00a0c91e6bf2>) 1500 * 1501 * Would result in: 1502 * 1503 * [ 1504 * [ 1505 * 'uri' => '/request/uri', 1506 * 'tokens' => [ 1507 * [ 1508 * [ 1509 * 'negate' => false, 1510 * 'token' => 'opaquelocktoken:181d4fae-7d8c-11d0-a765-00a0c91e6bf2', 1511 * 'etag' => "" 1512 * ] 1513 * ] 1514 * ], 1515 * ] 1516 * ] 1517 * 1518 * Example 2: 1519 * 1520 * If: </path/> (Not <opaquelocktoken:181d4fae-7d8c-11d0-a765-00a0c91e6bf2> ["Im An ETag"]) (["Another ETag"]) </path2/> (Not ["Path2 ETag"]) 1521 * 1522 * Would result in: 1523 * 1524 * [ 1525 * [ 1526 * 'uri' => 'path', 1527 * 'tokens' => [ 1528 * [ 1529 * [ 1530 * 'negate' => true, 1531 * 'token' => 'opaquelocktoken:181d4fae-7d8c-11d0-a765-00a0c91e6bf2', 1532 * 'etag' => '"Im An ETag"' 1533 * ], 1534 * [ 1535 * 'negate' => false, 1536 * 'token' => '', 1537 * 'etag' => '"Another ETag"' 1538 * ] 1539 * ] 1540 * ], 1541 * ], 1542 * [ 1543 * 'uri' => 'path2', 1544 * 'tokens' => [ 1545 * [ 1546 * [ 1547 * 'negate' => true, 1548 * 'token' => '', 1549 * 'etag' => '"Path2 ETag"' 1550 * ] 1551 * ] 1552 * ], 1553 * ], 1554 * ] 1555 * 1556 * @return array 1557 */ 1558 public function getIfConditions(RequestInterface $request) 1559 { 1560 $header = $request->getHeader('If'); 1561 if (!$header) { 1562 return []; 1563 } 1564 1565 $matches = []; 1566 1567 $regex = '/(?:\<(?P<uri>.*?)\>\s)?\((?P<not>Not\s)?(?:\<(?P<token>[^\>]*)\>)?(?:\s?)(?:\[(?P<etag>[^\]]*)\])?\)/im'; 1568 preg_match_all($regex, $header, $matches, PREG_SET_ORDER); 1569 1570 $conditions = []; 1571 1572 foreach ($matches as $match) { 1573 // If there was no uri specified in this match, and there were 1574 // already conditions parsed, we add the condition to the list of 1575 // conditions for the previous uri. 1576 if (!$match['uri'] && count($conditions)) { 1577 $conditions[count($conditions) - 1]['tokens'][] = [ 1578 'negate' => $match['not'] ? true : false, 1579 'token' => $match['token'], 1580 'etag' => isset($match['etag']) ? $match['etag'] : '', 1581 ]; 1582 } else { 1583 if (!$match['uri']) { 1584 $realUri = $request->getPath(); 1585 } else { 1586 $realUri = $this->calculateUri($match['uri']); 1587 } 1588 1589 $conditions[] = [ 1590 'uri' => $realUri, 1591 'tokens' => [ 1592 [ 1593 'negate' => $match['not'] ? true : false, 1594 'token' => $match['token'], 1595 'etag' => isset($match['etag']) ? $match['etag'] : '', 1596 ], 1597 ], 1598 ]; 1599 } 1600 } 1601 1602 return $conditions; 1603 } 1604 1605 /** 1606 * Returns an array with resourcetypes for a node. 1607 * 1608 * @return array 1609 */ 1610 public function getResourceTypeForNode(INode $node) 1611 { 1612 $result = []; 1613 foreach ($this->resourceTypeMapping as $className => $resourceType) { 1614 if ($node instanceof $className) { 1615 $result[] = $resourceType; 1616 } 1617 } 1618 1619 return $result; 1620 } 1621 1622 // }}} 1623 // {{{ XML Readers & Writers 1624 1625 /** 1626 * Returns a callback generating a WebDAV propfind response body based on a list of nodes. 1627 * 1628 * If 'strip404s' is set to true, all 404 responses will be removed. 1629 * 1630 * @param array|\Traversable $fileProperties The list with nodes 1631 * @param bool $strip404s 1632 * 1633 * @return callable|string 1634 */ 1635 public function generateMultiStatus($fileProperties, $strip404s = false) 1636 { 1637 $w = $this->xml->getWriter(); 1638 if (self::$streamMultiStatus) { 1639 return function () use ($fileProperties, $strip404s, $w) { 1640 $w->openUri('php://output'); 1641 $this->writeMultiStatus($w, $fileProperties, $strip404s); 1642 $w->flush(); 1643 }; 1644 } 1645 $w->openMemory(); 1646 $this->writeMultiStatus($w, $fileProperties, $strip404s); 1647 1648 return $w->outputMemory(); 1649 } 1650 1651 /** 1652 * @param $fileProperties 1653 */ 1654 private function writeMultiStatus(Writer $w, $fileProperties, bool $strip404s) 1655 { 1656 $w->contextUri = $this->baseUri; 1657 $w->startDocument(); 1658 1659 $w->startElement('{DAV:}multistatus'); 1660 1661 foreach ($fileProperties as $entry) { 1662 $href = $entry['href']; 1663 unset($entry['href']); 1664 if ($strip404s) { 1665 unset($entry[404]); 1666 } 1667 $response = new Xml\Element\Response( 1668 ltrim($href, '/'), 1669 $entry 1670 ); 1671 $w->write([ 1672 'name' => '{DAV:}response', 1673 'value' => $response, 1674 ]); 1675 } 1676 $w->endElement(); 1677 $w->endDocument(); 1678 } 1679} 1680