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