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