aboutsummaryrefslogtreecommitdiffstats
path: root/vendor/sabre/dav/lib/DAV/Server.php
diff options
context:
space:
mode:
Diffstat (limited to 'vendor/sabre/dav/lib/DAV/Server.php')
-rw-r--r--vendor/sabre/dav/lib/DAV/Server.php1627
1 files changed, 1627 insertions, 0 deletions
diff --git a/vendor/sabre/dav/lib/DAV/Server.php b/vendor/sabre/dav/lib/DAV/Server.php
new file mode 100644
index 000000000..b37652812
--- /dev/null
+++ b/vendor/sabre/dav/lib/DAV/Server.php
@@ -0,0 +1,1627 @@
+<?php
+
+namespace Sabre\DAV;
+
+use Sabre\Event\EventEmitter;
+use Sabre\HTTP;
+use Sabre\HTTP\RequestInterface;
+use Sabre\HTTP\ResponseInterface;
+use Sabre\HTTP\URLUtil;
+use Sabre\Uri;
+
+/**
+ * Main DAV server class
+ *
+ * @copyright Copyright (C) fruux GmbH (https://fruux.com/)
+ * @author Evert Pot (http://evertpot.com/)
+ * @license http://sabre.io/license/ Modified BSD License
+ */
+class Server extends EventEmitter {
+
+ /**
+ * Infinity is used for some request supporting the HTTP Depth header and indicates that the operation should traverse the entire tree
+ */
+ const DEPTH_INFINITY = -1;
+
+ /**
+ * XML namespace for all SabreDAV related elements
+ */
+ const NS_SABREDAV = 'http://sabredav.org/ns';
+
+ /**
+ * The tree object
+ *
+ * @var Sabre\DAV\Tree
+ */
+ public $tree;
+
+ /**
+ * The base uri
+ *
+ * @var string
+ */
+ protected $baseUri = null;
+
+ /**
+ * httpResponse
+ *
+ * @var Sabre\HTTP\Response
+ */
+ public $httpResponse;
+
+ /**
+ * httpRequest
+ *
+ * @var Sabre\HTTP\Request
+ */
+ public $httpRequest;
+
+ /**
+ * PHP HTTP Sapi
+ *
+ * @var Sabre\HTTP\Sapi
+ */
+ public $sapi;
+
+ /**
+ * The list of plugins
+ *
+ * @var array
+ */
+ protected $plugins = [];
+
+ /**
+ * This property will be filled with a unique string that describes the
+ * transaction. This is useful for performance measuring and logging
+ * purposes.
+ *
+ * By default it will just fill it with a lowercased HTTP method name, but
+ * plugins override this. For example, the WebDAV-Sync sync-collection
+ * report will set this to 'report-sync-collection'.
+ *
+ * @var string
+ */
+ public $transactionType;
+
+ /**
+ * This is a list of properties that are always server-controlled, and
+ * must not get modified with PROPPATCH.
+ *
+ * Plugins may add to this list.
+ *
+ * @var string[]
+ */
+ public $protectedProperties = [
+
+ // RFC4918
+ '{DAV:}getcontentlength',
+ '{DAV:}getetag',
+ '{DAV:}getlastmodified',
+ '{DAV:}lockdiscovery',
+ '{DAV:}supportedlock',
+
+ // RFC4331
+ '{DAV:}quota-available-bytes',
+ '{DAV:}quota-used-bytes',
+
+ // RFC3744
+ '{DAV:}supported-privilege-set',
+ '{DAV:}current-user-privilege-set',
+ '{DAV:}acl',
+ '{DAV:}acl-restrictions',
+ '{DAV:}inherited-acl-set',
+
+ // RFC3253
+ '{DAV:}supported-method-set',
+ '{DAV:}supported-report-set',
+
+ // RFC6578
+ '{DAV:}sync-token',
+
+ // calendarserver.org extensions
+ '{http://calendarserver.org/ns/}ctag',
+
+ // sabredav extensions
+ '{http://sabredav.org/ns}sync-token',
+
+ ];
+
+ /**
+ * This is a flag that allow or not showing file, line and code
+ * of the exception in the returned XML
+ *
+ * @var bool
+ */
+ public $debugExceptions = false;
+
+ /**
+ * This property allows you to automatically add the 'resourcetype' value
+ * based on a node's classname or interface.
+ *
+ * The preset ensures that {DAV:}collection is automatically added for nodes
+ * implementing Sabre\DAV\ICollection.
+ *
+ * @var array
+ */
+ public $resourceTypeMapping = [
+ 'Sabre\\DAV\\ICollection' => '{DAV:}collection',
+ ];
+
+ /**
+ * This property allows the usage of Depth: infinity on PROPFIND requests.
+ *
+ * By default Depth: infinity is treated as Depth: 1. Allowing Depth:
+ * infinity is potentially risky, as it allows a single client to do a full
+ * index of the webdav server, which is an easy DoS attack vector.
+ *
+ * Only turn this on if you know what you're doing.
+ *
+ * @var bool
+ */
+ public $enablePropfindDepthInfinity = false;
+
+ /**
+ * Reference to the XML utility object.
+ *
+ * @var Xml\Service
+ */
+ public $xml;
+
+ /**
+ * If this setting is turned off, SabreDAV's version number will be hidden
+ * from various places.
+ *
+ * Some people feel this is a good security measure.
+ *
+ * @var bool
+ */
+ static $exposeVersion = true;
+
+ /**
+ * Sets up the server
+ *
+ * If a Sabre\DAV\Tree object is passed as an argument, it will
+ * use it as the directory tree. If a Sabre\DAV\INode is passed, it
+ * will create a Sabre\DAV\Tree and use the node as the root.
+ *
+ * If nothing is passed, a Sabre\DAV\SimpleCollection is created in
+ * a Sabre\DAV\Tree.
+ *
+ * If an array is passed, we automatically create a root node, and use
+ * the nodes in the array as top-level children.
+ *
+ * @param Tree|INode|array|null $treeOrNode The tree object
+ */
+ function __construct($treeOrNode = null) {
+
+ if ($treeOrNode instanceof Tree) {
+ $this->tree = $treeOrNode;
+ } elseif ($treeOrNode instanceof INode) {
+ $this->tree = new Tree($treeOrNode);
+ } elseif (is_array($treeOrNode)) {
+
+ // If it's an array, a list of nodes was passed, and we need to
+ // create the root node.
+ foreach ($treeOrNode as $node) {
+ if (!($node instanceof INode)) {
+ throw new Exception('Invalid argument passed to constructor. If you\'re passing an array, all the values must implement Sabre\\DAV\\INode');
+ }
+ }
+
+ $root = new SimpleCollection('root', $treeOrNode);
+ $this->tree = new Tree($root);
+
+ } elseif (is_null($treeOrNode)) {
+ $root = new SimpleCollection('root');
+ $this->tree = new Tree($root);
+ } else {
+ 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');
+ }
+
+ $this->xml = new Xml\Service();
+ $this->sapi = new HTTP\Sapi();
+ $this->httpResponse = new HTTP\Response();
+ $this->httpRequest = $this->sapi->getRequest();
+ $this->addPlugin(new CorePlugin());
+
+ }
+
+ /**
+ * Starts the DAV Server
+ *
+ * @return void
+ */
+ function exec() {
+
+ try {
+
+ // If nginx (pre-1.2) is used as a proxy server, and SabreDAV as an
+ // origin, we must make sure we send back HTTP/1.0 if this was
+ // requested.
+ // This is mainly because nginx doesn't support Chunked Transfer
+ // Encoding, and this forces the webserver SabreDAV is running on,
+ // to buffer entire responses to calculate Content-Length.
+ $this->httpResponse->setHTTPVersion($this->httpRequest->getHTTPVersion());
+
+ // Setting the base url
+ $this->httpRequest->setBaseUrl($this->getBaseUri());
+ $this->invokeMethod($this->httpRequest, $this->httpResponse);
+
+ } catch (\Exception $e) {
+
+ try {
+ $this->emit('exception', [$e]);
+ } catch (\Exception $ignore) {
+ }
+ $DOM = new \DOMDocument('1.0', 'utf-8');
+ $DOM->formatOutput = true;
+
+ $error = $DOM->createElementNS('DAV:', 'd:error');
+ $error->setAttribute('xmlns:s', self::NS_SABREDAV);
+ $DOM->appendChild($error);
+
+ $h = function($v) {
+
+ return htmlspecialchars($v, ENT_NOQUOTES, 'UTF-8');
+
+ };
+
+ if (self::$exposeVersion) {
+ $error->appendChild($DOM->createElement('s:sabredav-version', $h(Version::VERSION)));
+ }
+
+ $error->appendChild($DOM->createElement('s:exception', $h(get_class($e))));
+ $error->appendChild($DOM->createElement('s:message', $h($e->getMessage())));
+ if ($this->debugExceptions) {
+ $error->appendChild($DOM->createElement('s:file', $h($e->getFile())));
+ $error->appendChild($DOM->createElement('s:line', $h($e->getLine())));
+ $error->appendChild($DOM->createElement('s:code', $h($e->getCode())));
+ $error->appendChild($DOM->createElement('s:stacktrace', $h($e->getTraceAsString())));
+ }
+
+ if ($this->debugExceptions) {
+ $previous = $e;
+ while ($previous = $previous->getPrevious()) {
+ $xPrevious = $DOM->createElement('s:previous-exception');
+ $xPrevious->appendChild($DOM->createElement('s:exception', $h(get_class($previous))));
+ $xPrevious->appendChild($DOM->createElement('s:message', $h($previous->getMessage())));
+ $xPrevious->appendChild($DOM->createElement('s:file', $h($previous->getFile())));
+ $xPrevious->appendChild($DOM->createElement('s:line', $h($previous->getLine())));
+ $xPrevious->appendChild($DOM->createElement('s:code', $h($previous->getCode())));
+ $xPrevious->appendChild($DOM->createElement('s:stacktrace', $h($previous->getTraceAsString())));
+ $error->appendChild($xPrevious);
+ }
+ }
+
+
+ if ($e instanceof Exception) {
+
+ $httpCode = $e->getHTTPCode();
+ $e->serialize($this, $error);
+ $headers = $e->getHTTPHeaders($this);
+
+ } else {
+
+ $httpCode = 500;
+ $headers = [];
+
+ }
+ $headers['Content-Type'] = 'application/xml; charset=utf-8';
+
+ $this->httpResponse->setStatus($httpCode);
+ $this->httpResponse->setHeaders($headers);
+ $this->httpResponse->setBody($DOM->saveXML());
+ $this->sapi->sendResponse($this->httpResponse);
+
+ }
+
+ }
+
+ /**
+ * Sets the base server uri
+ *
+ * @param string $uri
+ * @return void
+ */
+ function setBaseUri($uri) {
+
+ // If the baseUri does not end with a slash, we must add it
+ if ($uri[strlen($uri) - 1] !== '/')
+ $uri .= '/';
+
+ $this->baseUri = $uri;
+
+ }
+
+ /**
+ * Returns the base responding uri
+ *
+ * @return string
+ */
+ function getBaseUri() {
+
+ if (is_null($this->baseUri)) $this->baseUri = $this->guessBaseUri();
+ return $this->baseUri;
+
+ }
+
+ /**
+ * This method attempts to detect the base uri.
+ * Only the PATH_INFO variable is considered.
+ *
+ * If this variable is not set, the root (/) is assumed.
+ *
+ * @return string
+ */
+ function guessBaseUri() {
+
+ $pathInfo = $this->httpRequest->getRawServerValue('PATH_INFO');
+ $uri = $this->httpRequest->getRawServerValue('REQUEST_URI');
+
+ // If PATH_INFO is found, we can assume it's accurate.
+ if (!empty($pathInfo)) {
+
+ // We need to make sure we ignore the QUERY_STRING part
+ if ($pos = strpos($uri, '?'))
+ $uri = substr($uri, 0, $pos);
+
+ // PATH_INFO is only set for urls, such as: /example.php/path
+ // in that case PATH_INFO contains '/path'.
+ // Note that REQUEST_URI is percent encoded, while PATH_INFO is
+ // not, Therefore they are only comparable if we first decode
+ // REQUEST_INFO as well.
+ $decodedUri = URLUtil::decodePath($uri);
+
+ // A simple sanity check:
+ if (substr($decodedUri, strlen($decodedUri) - strlen($pathInfo)) === $pathInfo) {
+ $baseUri = substr($decodedUri, 0, strlen($decodedUri) - strlen($pathInfo));
+ return rtrim($baseUri, '/') . '/';
+ }
+
+ throw new Exception('The REQUEST_URI (' . $uri . ') did not end with the contents of PATH_INFO (' . $pathInfo . '). This server might be misconfigured.');
+
+ }
+
+ // The last fallback is that we're just going to assume the server root.
+ return '/';
+
+ }
+
+ /**
+ * Adds a plugin to the server
+ *
+ * For more information, console the documentation of Sabre\DAV\ServerPlugin
+ *
+ * @param ServerPlugin $plugin
+ * @return void
+ */
+ function addPlugin(ServerPlugin $plugin) {
+
+ $this->plugins[$plugin->getPluginName()] = $plugin;
+ $plugin->initialize($this);
+
+ }
+
+ /**
+ * Returns an initialized plugin by it's name.
+ *
+ * This function returns null if the plugin was not found.
+ *
+ * @param string $name
+ * @return ServerPlugin
+ */
+ function getPlugin($name) {
+
+ if (isset($this->plugins[$name]))
+ return $this->plugins[$name];
+
+ return null;
+
+ }
+
+ /**
+ * Returns all plugins
+ *
+ * @return array
+ */
+ function getPlugins() {
+
+ return $this->plugins;
+
+ }
+
+ /**
+ * Handles a http request, and execute a method based on its name
+ *
+ * @param RequestInterface $request
+ * @param ResponseInterface $response
+ * @param $sendResponse Whether to send the HTTP response to the DAV client.
+ * @return void
+ */
+ function invokeMethod(RequestInterface $request, ResponseInterface $response, $sendResponse = true) {
+
+ $method = $request->getMethod();
+
+ if (!$this->emit('beforeMethod:' . $method, [$request, $response])) return;
+ if (!$this->emit('beforeMethod', [$request, $response])) return;
+
+ if (self::$exposeVersion) {
+ $response->setHeader('X-Sabre-Version', Version::VERSION);
+ }
+
+ $this->transactionType = strtolower($method);
+
+ if (!$this->checkPreconditions($request, $response)) {
+ $this->sapi->sendResponse($response);
+ return;
+ }
+
+ if ($this->emit('method:' . $method, [$request, $response])) {
+ if ($this->emit('method', [$request, $response])) {
+ $exMessage = "There was no plugin in the system that was willing to handle this " . $method . " method.";
+ if ($method === "GET") {
+ $exMessage .= " Enable the Browser plugin to get a better result here.";
+ }
+
+ // Unsupported method
+ throw new Exception\NotImplemented($exMessage);
+ }
+ }
+
+ if (!$this->emit('afterMethod:' . $method, [$request, $response])) return;
+ if (!$this->emit('afterMethod', [$request, $response])) return;
+
+ if ($response->getStatus() === null) {
+ throw new Exception('No subsystem set a valid HTTP status code. Something must have interrupted the request without providing further detail.');
+ }
+ if ($sendResponse) {
+ $this->sapi->sendResponse($response);
+ $this->emit('afterResponse', [$request, $response]);
+ }
+
+ }
+
+ // {{{ HTTP/WebDAV protocol helpers
+
+ /**
+ * Returns an array with all the supported HTTP methods for a specific uri.
+ *
+ * @param string $path
+ * @return array
+ */
+ function getAllowedMethods($path) {
+
+ $methods = [
+ 'OPTIONS',
+ 'GET',
+ 'HEAD',
+ 'DELETE',
+ 'PROPFIND',
+ 'PUT',
+ 'PROPPATCH',
+ 'COPY',
+ 'MOVE',
+ 'REPORT'
+ ];
+
+ // The MKCOL is only allowed on an unmapped uri
+ try {
+ $this->tree->getNodeForPath($path);
+ } catch (Exception\NotFound $e) {
+ $methods[] = 'MKCOL';
+ }
+
+ // We're also checking if any of the plugins register any new methods
+ foreach ($this->plugins as $plugin) $methods = array_merge($methods, $plugin->getHTTPMethods($path));
+ array_unique($methods);
+
+ return $methods;
+
+ }
+
+ /**
+ * Gets the uri for the request, keeping the base uri into consideration
+ *
+ * @return string
+ */
+ function getRequestUri() {
+
+ return $this->calculateUri($this->httpRequest->getUrl());
+
+ }
+
+ /**
+ * Turns a URI such as the REQUEST_URI into a local path.
+ *
+ * This method:
+ * * strips off the base path
+ * * normalizes the path
+ * * uri-decodes the path
+ *
+ * @param string $uri
+ * @throws Exception\Forbidden A permission denied exception is thrown whenever there was an attempt to supply a uri outside of the base uri
+ * @return string
+ */
+ function calculateUri($uri) {
+
+ if ($uri[0] != '/' && strpos($uri, '://')) {
+
+ $uri = parse_url($uri, PHP_URL_PATH);
+
+ }
+
+ $uri = Uri\normalize(str_replace('//', '/', $uri));
+ $baseUri = Uri\normalize($this->getBaseUri());
+
+ if (strpos($uri, $baseUri) === 0) {
+
+ return trim(URLUtil::decodePath(substr($uri, strlen($baseUri))), '/');
+
+ // A special case, if the baseUri was accessed without a trailing
+ // slash, we'll accept it as well.
+ } elseif ($uri . '/' === $baseUri) {
+
+ return '';
+
+ } else {
+
+ throw new Exception\Forbidden('Requested uri (' . $uri . ') is out of base uri (' . $this->getBaseUri() . ')');
+
+ }
+
+ }
+
+ /**
+ * Returns the HTTP depth header
+ *
+ * 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
+ * It is possible to supply a default depth value, which is used when the depth header has invalid content, or is completely non-existent
+ *
+ * @param mixed $default
+ * @return int
+ */
+ function getHTTPDepth($default = self::DEPTH_INFINITY) {
+
+ // If its not set, we'll grab the default
+ $depth = $this->httpRequest->getHeader('Depth');
+
+ if (is_null($depth)) return $default;
+
+ if ($depth == 'infinity') return self::DEPTH_INFINITY;
+
+
+ // If its an unknown value. we'll grab the default
+ if (!ctype_digit($depth)) return $default;
+
+ return (int)$depth;
+
+ }
+
+ /**
+ * Returns the HTTP range header
+ *
+ * This method returns null if there is no well-formed HTTP range request
+ * header or array($start, $end).
+ *
+ * The first number is the offset of the first byte in the range.
+ * The second number is the offset of the last byte in the range.
+ *
+ * If the second offset is null, it should be treated as the offset of the last byte of the entity
+ * If the first offset is null, the second offset should be used to retrieve the last x bytes of the entity
+ *
+ * @return array|null
+ */
+ function getHTTPRange() {
+
+ $range = $this->httpRequest->getHeader('range');
+ if (is_null($range)) return null;
+
+ // Matching "Range: bytes=1234-5678: both numbers are optional
+
+ if (!preg_match('/^bytes=([0-9]*)-([0-9]*)$/i', $range, $matches)) return null;
+
+ if ($matches[1] === '' && $matches[2] === '') return null;
+
+ return [
+ $matches[1] !== '' ? $matches[1] : null,
+ $matches[2] !== '' ? $matches[2] : null,
+ ];
+
+ }
+
+ /**
+ * Returns the HTTP Prefer header information.
+ *
+ * The prefer header is defined in:
+ * http://tools.ietf.org/html/draft-snell-http-prefer-14
+ *
+ * This method will return an array with options.
+ *
+ * Currently, the following options may be returned:
+ * [
+ * 'return-asynch' => true,
+ * 'return-minimal' => true,
+ * 'return-representation' => true,
+ * 'wait' => 30,
+ * 'strict' => true,
+ * 'lenient' => true,
+ * ]
+ *
+ * This method also supports the Brief header, and will also return
+ * 'return-minimal' if the brief header was set to 't'.
+ *
+ * For the boolean options, false will be returned if the headers are not
+ * specified. For the integer options it will be 'null'.
+ *
+ * @return array
+ */
+ function getHTTPPrefer() {
+
+ $result = [
+ // can be true or false
+ 'respond-async' => false,
+ // Could be set to 'representation' or 'minimal'.
+ 'return' => null,
+ // Used as a timeout, is usually a number.
+ 'wait' => null,
+ // can be 'strict' or 'lenient'.
+ 'handling' => false,
+ ];
+
+ if ($prefer = $this->httpRequest->getHeader('Prefer')) {
+
+ $result = array_merge(
+ $result,
+ \Sabre\HTTP\parsePrefer($prefer)
+ );
+
+ } elseif ($this->httpRequest->getHeader('Brief') == 't') {
+ $result['return'] = 'minimal';
+ }
+
+ return $result;
+
+ }
+
+
+ /**
+ * Returns information about Copy and Move requests
+ *
+ * This function is created to help getting information about the source and the destination for the
+ * WebDAV MOVE and COPY HTTP request. It also validates a lot of information and throws proper exceptions
+ *
+ * The returned value is an array with the following keys:
+ * * destination - Destination path
+ * * destinationExists - Whether or not the destination is an existing url (and should therefore be overwritten)
+ *
+ * @param RequestInterface $request
+ * @throws Exception\BadRequest upon missing or broken request headers
+ * @throws Exception\UnsupportedMediaType when trying to copy into a
+ * non-collection.
+ * @throws Exception\PreconditionFailed If overwrite is set to false, but
+ * the destination exists.
+ * @throws Exception\Forbidden when source and destination paths are
+ * identical.
+ * @throws Exception\Conflict When trying to copy a node into its own
+ * subtree.
+ * @return array
+ */
+ function getCopyAndMoveInfo(RequestInterface $request) {
+
+ // Collecting the relevant HTTP headers
+ if (!$request->getHeader('Destination')) throw new Exception\BadRequest('The destination header was not supplied');
+ $destination = $this->calculateUri($request->getHeader('Destination'));
+ $overwrite = $request->getHeader('Overwrite');
+ if (!$overwrite) $overwrite = 'T';
+ if (strtoupper($overwrite) == 'T') $overwrite = true;
+ elseif (strtoupper($overwrite) == 'F') $overwrite = false;
+ // We need to throw a bad request exception, if the header was invalid
+ else throw new Exception\BadRequest('The HTTP Overwrite header should be either T or F');
+
+ list($destinationDir) = URLUtil::splitPath($destination);
+
+ try {
+ $destinationParent = $this->tree->getNodeForPath($destinationDir);
+ if (!($destinationParent instanceof ICollection)) throw new Exception\UnsupportedMediaType('The destination node is not a collection');
+ } catch (Exception\NotFound $e) {
+
+ // If the destination parent node is not found, we throw a 409
+ throw new Exception\Conflict('The destination node is not found');
+ }
+
+ try {
+
+ $destinationNode = $this->tree->getNodeForPath($destination);
+
+ // If this succeeded, it means the destination already exists
+ // we'll need to throw precondition failed in case overwrite is false
+ if (!$overwrite) throw new Exception\PreconditionFailed('The destination node already exists, and the overwrite header is set to false', 'Overwrite');
+
+ } catch (Exception\NotFound $e) {
+
+ // Destination didn't exist, we're all good
+ $destinationNode = false;
+
+ }
+
+ $requestPath = $request->getPath();
+ if ($destination === $requestPath) {
+ throw new Exception\Forbidden('Source and destination uri are identical.');
+ }
+ if (substr($destination, 0, strlen($requestPath) + 1) === $requestPath . '/') {
+ throw new Exception\Conflict('The destination may not be part of the same subtree as the source path.');
+ }
+
+ // These are the three relevant properties we need to return
+ return [
+ 'destination' => $destination,
+ 'destinationExists' => !!$destinationNode,
+ 'destinationNode' => $destinationNode,
+ ];
+
+ }
+
+ /**
+ * Returns a list of properties for a path
+ *
+ * This is a simplified version getPropertiesForPath. If you aren't
+ * interested in status codes, but you just want to have a flat list of
+ * properties, use this method.
+ *
+ * Please note though that any problems related to retrieving properties,
+ * such as permission issues will just result in an empty array being
+ * returned.
+ *
+ * @param string $path
+ * @param array $propertyNames
+ */
+ function getProperties($path, $propertyNames) {
+
+ $result = $this->getPropertiesForPath($path, $propertyNames, 0);
+ if (isset($result[0][200])) {
+ return $result[0][200];
+ } else {
+ return [];
+ }
+
+ }
+
+ /**
+ * A kid-friendly way to fetch properties for a node's children.
+ *
+ * The returned array will be indexed by the path of the of child node.
+ * Only properties that are actually found will be returned.
+ *
+ * The parent node will not be returned.
+ *
+ * @param string $path
+ * @param array $propertyNames
+ * @return array
+ */
+ function getPropertiesForChildren($path, $propertyNames) {
+
+ $result = [];
+ foreach ($this->getPropertiesForPath($path, $propertyNames, 1) as $k => $row) {
+
+ // Skipping the parent path
+ if ($k === 0) continue;
+
+ $result[$row['href']] = $row[200];
+
+ }
+ return $result;
+
+ }
+
+ /**
+ * Returns a list of HTTP headers for a particular resource
+ *
+ * The generated http headers are based on properties provided by the
+ * resource. The method basically provides a simple mapping between
+ * DAV property and HTTP header.
+ *
+ * The headers are intended to be used for HEAD and GET requests.
+ *
+ * @param string $path
+ * @return array
+ */
+ function getHTTPHeaders($path) {
+
+ $propertyMap = [
+ '{DAV:}getcontenttype' => 'Content-Type',
+ '{DAV:}getcontentlength' => 'Content-Length',
+ '{DAV:}getlastmodified' => 'Last-Modified',
+ '{DAV:}getetag' => 'ETag',
+ ];
+
+ $properties = $this->getProperties($path, array_keys($propertyMap));
+
+ $headers = [];
+ foreach ($propertyMap as $property => $header) {
+ if (!isset($properties[$property])) continue;
+
+ if (is_scalar($properties[$property])) {
+ $headers[$header] = $properties[$property];
+
+ // GetLastModified gets special cased
+ } elseif ($properties[$property] instanceof Xml\Property\GetLastModified) {
+ $headers[$header] = HTTP\Util::toHTTPDate($properties[$property]->getTime());
+ }
+
+ }
+
+ return $headers;
+
+ }
+
+ /**
+ * Small helper to support PROPFIND with DEPTH_INFINITY.
+ *
+ * @param array[] $propFindRequests
+ * @param PropFind $propFind
+ * @return void
+ */
+ private function addPathNodesRecursively(&$propFindRequests, PropFind $propFind) {
+
+ $newDepth = $propFind->getDepth();
+ $path = $propFind->getPath();
+
+ if ($newDepth !== self::DEPTH_INFINITY) {
+ $newDepth--;
+ }
+
+ foreach ($this->tree->getChildren($path) as $childNode) {
+ $subPropFind = clone $propFind;
+ $subPropFind->setDepth($newDepth);
+ if ($path !== '') {
+ $subPath = $path . '/' . $childNode->getName();
+ } else {
+ $subPath = $childNode->getName();
+ }
+ $subPropFind->setPath($subPath);
+
+ $propFindRequests[] = [
+ $subPropFind,
+ $childNode
+ ];
+
+ if (($newDepth === self::DEPTH_INFINITY || $newDepth >= 1) && $childNode instanceof ICollection) {
+ $this->addPathNodesRecursively($propFindRequests, $subPropFind);
+ }
+
+ }
+ }
+
+ /**
+ * Returns a list of properties for a given path
+ *
+ * The path that should be supplied should have the baseUrl stripped out
+ * The list of properties should be supplied in Clark notation. If the list is empty
+ * 'allprops' is assumed.
+ *
+ * If a depth of 1 is requested child elements will also be returned.
+ *
+ * @param string $path
+ * @param array $propertyNames
+ * @param int $depth
+ * @return array
+ */
+ function getPropertiesForPath($path, $propertyNames = [], $depth = 0) {
+
+ // The only two options for the depth of a propfind is 0 or 1 - as long as depth infinity is not enabled
+ if (!$this->enablePropfindDepthInfinity && $depth != 0) $depth = 1;
+
+ $path = trim($path, '/');
+
+ $propFindType = $propertyNames ? PropFind::NORMAL : PropFind::ALLPROPS;
+ $propFind = new PropFind($path, (array)$propertyNames, $depth, $propFindType);
+
+ $parentNode = $this->tree->getNodeForPath($path);
+
+ $propFindRequests = [[
+ $propFind,
+ $parentNode
+ ]];
+
+ if (($depth > 0 || $depth === self::DEPTH_INFINITY) && $parentNode instanceof ICollection) {
+ $this->addPathNodesRecursively($propFindRequests, $propFind);
+ }
+
+ $returnPropertyList = [];
+
+ foreach ($propFindRequests as $propFindRequest) {
+
+ list($propFind, $node) = $propFindRequest;
+ $r = $this->getPropertiesByNode($propFind, $node);
+ if ($r) {
+ $result = $propFind->getResultForMultiStatus();
+ $result['href'] = $propFind->getPath();
+
+ // WebDAV recommends adding a slash to the path, if the path is
+ // a collection.
+ // Furthermore, iCal also demands this to be the case for
+ // principals. This is non-standard, but we support it.
+ $resourceType = $this->getResourceTypeForNode($node);
+ if (in_array('{DAV:}collection', $resourceType) || in_array('{DAV:}principal', $resourceType)) {
+ $result['href'] .= '/';
+ }
+ $returnPropertyList[] = $result;
+ }
+
+ }
+
+ return $returnPropertyList;
+
+ }
+
+ /**
+ * Returns a list of properties for a list of paths.
+ *
+ * The path that should be supplied should have the baseUrl stripped out
+ * The list of properties should be supplied in Clark notation. If the list is empty
+ * 'allprops' is assumed.
+ *
+ * The result is returned as an array, with paths for it's keys.
+ * The result may be returned out of order.
+ *
+ * @param array $paths
+ * @param array $propertyNames
+ * @return array
+ */
+ function getPropertiesForMultiplePaths(array $paths, array $propertyNames = []) {
+
+ $result = [
+ ];
+
+ $nodes = $this->tree->getMultipleNodes($paths);
+
+ foreach ($nodes as $path => $node) {
+
+ $propFind = new PropFind($path, $propertyNames);
+ $r = $this->getPropertiesByNode($propFind, $node);
+ if ($r) {
+ $result[$path] = $propFind->getResultForMultiStatus();
+ $result[$path]['href'] = $path;
+
+ $resourceType = $this->getResourceTypeForNode($node);
+ if (in_array('{DAV:}collection', $resourceType) || in_array('{DAV:}principal', $resourceType)) {
+ $result[$path]['href'] .= '/';
+ }
+ }
+
+ }
+
+ return $result;
+
+ }
+
+
+ /**
+ * Determines all properties for a node.
+ *
+ * This method tries to grab all properties for a node. This method is used
+ * internally getPropertiesForPath and a few others.
+ *
+ * It could be useful to call this, if you already have an instance of your
+ * target node and simply want to run through the system to get a correct
+ * list of properties.
+ *
+ * @param PropFind $propFind
+ * @param INode $node
+ * @return bool
+ */
+ function getPropertiesByNode(PropFind $propFind, INode $node) {
+
+ return $this->emit('propFind', [$propFind, $node]);
+
+ }
+
+ /**
+ * This method is invoked by sub-systems creating a new file.
+ *
+ * Currently this is done by HTTP PUT and HTTP LOCK (in the Locks_Plugin).
+ * It was important to get this done through a centralized function,
+ * allowing plugins to intercept this using the beforeCreateFile event.
+ *
+ * This method will return true if the file was actually created
+ *
+ * @param string $uri
+ * @param resource $data
+ * @param string $etag
+ * @return bool
+ */
+ function createFile($uri, $data, &$etag = null) {
+
+ list($dir, $name) = URLUtil::splitPath($uri);
+
+ if (!$this->emit('beforeBind', [$uri])) return false;
+
+ $parent = $this->tree->getNodeForPath($dir);
+ if (!$parent instanceof ICollection) {
+ throw new Exception\Conflict('Files can only be created as children of collections');
+ }
+
+ // It is possible for an event handler to modify the content of the
+ // body, before it gets written. If this is the case, $modified
+ // should be set to true.
+ //
+ // If $modified is true, we must not send back an ETag.
+ $modified = false;
+ if (!$this->emit('beforeCreateFile', [$uri, &$data, $parent, &$modified])) return false;
+
+ $etag = $parent->createFile($name, $data);
+
+ if ($modified) $etag = null;
+
+ $this->tree->markDirty($dir . '/' . $name);
+
+ $this->emit('afterBind', [$uri]);
+ $this->emit('afterCreateFile', [$uri, $parent]);
+
+ return true;
+ }
+
+ /**
+ * This method is invoked by sub-systems updating a file.
+ *
+ * This method will return true if the file was actually updated
+ *
+ * @param string $uri
+ * @param resource $data
+ * @param string $etag
+ * @return bool
+ */
+ function updateFile($uri, $data, &$etag = null) {
+
+ $node = $this->tree->getNodeForPath($uri);
+
+ // It is possible for an event handler to modify the content of the
+ // body, before it gets written. If this is the case, $modified
+ // should be set to true.
+ //
+ // If $modified is true, we must not send back an ETag.
+ $modified = false;
+ if (!$this->emit('beforeWriteContent', [$uri, $node, &$data, &$modified])) return false;
+
+ $etag = $node->put($data);
+ if ($modified) $etag = null;
+ $this->emit('afterWriteContent', [$uri, $node]);
+
+ return true;
+ }
+
+
+
+ /**
+ * This method is invoked by sub-systems creating a new directory.
+ *
+ * @param string $uri
+ * @return void
+ */
+ function createDirectory($uri) {
+
+ $this->createCollection($uri, new MkCol(['{DAV:}collection'], []));
+
+ }
+
+ /**
+ * Use this method to create a new collection
+ *
+ * @param string $uri The new uri
+ * @param MkCol $mkCol
+ * @return array|null
+ */
+ function createCollection($uri, MkCol $mkCol) {
+
+ list($parentUri, $newName) = URLUtil::splitPath($uri);
+
+ // Making sure the parent exists
+ try {
+ $parent = $this->tree->getNodeForPath($parentUri);
+
+ } catch (Exception\NotFound $e) {
+ throw new Exception\Conflict('Parent node does not exist');
+
+ }
+
+ // Making sure the parent is a collection
+ if (!$parent instanceof ICollection) {
+ throw new Exception\Conflict('Parent node is not a collection');
+ }
+
+ // Making sure the child does not already exist
+ try {
+ $parent->getChild($newName);
+
+ // If we got here.. it means there's already a node on that url, and we need to throw a 405
+ throw new Exception\MethodNotAllowed('The resource you tried to create already exists');
+
+ } catch (Exception\NotFound $e) {
+ // NotFound is the expected behavior.
+ }
+
+
+ if (!$this->emit('beforeBind', [$uri])) return;
+
+ if ($parent instanceof IExtendedCollection) {
+
+ /**
+ * If the parent is an instance of IExtendedCollection, it means that
+ * we can pass the MkCol object directly as it may be able to store
+ * properties immediately.
+ */
+ $parent->createExtendedCollection($newName, $mkCol);
+
+ } else {
+
+ /**
+ * If the parent is a standard ICollection, it means only
+ * 'standard' collections can be created, so we should fail any
+ * MKCOL operation that carries extra resourcetypes.
+ */
+ if (count($mkCol->getResourceType()) > 1) {
+ throw new Exception\InvalidResourceType('The {DAV:}resourcetype you specified is not supported here.');
+ }
+
+ $parent->createDirectory($newName);
+
+ }
+
+ // If there are any properties that have not been handled/stored,
+ // we ask the 'propPatch' event to handle them. This will allow for
+ // example the propertyStorage system to store properties upon MKCOL.
+ if ($mkCol->getRemainingMutations()) {
+ $this->emit('propPatch', [$uri, $mkCol]);
+ }
+ $success = $mkCol->commit();
+
+ if (!$success) {
+ $result = $mkCol->getResult();
+ // generateMkCol needs the href key to exist.
+ $result['href'] = $uri;
+ return $result;
+ }
+
+ $this->tree->markDirty($parentUri);
+ $this->emit('afterBind', [$uri]);
+
+ }
+
+ /**
+ * This method updates a resource's properties
+ *
+ * The properties array must be a list of properties. Array-keys are
+ * property names in clarknotation, array-values are it's values.
+ * If a property must be deleted, the value should be null.
+ *
+ * Note that this request should either completely succeed, or
+ * completely fail.
+ *
+ * The response is an array with properties for keys, and http status codes
+ * as their values.
+ *
+ * @param string $path
+ * @param array $properties
+ * @return array
+ */
+ function updateProperties($path, array $properties) {
+
+ $propPatch = new PropPatch($properties);
+ $this->emit('propPatch', [$path, $propPatch]);
+ $propPatch->commit();
+
+ return $propPatch->getResult();
+
+ }
+
+ /**
+ * This method checks the main HTTP preconditions.
+ *
+ * Currently these are:
+ * * If-Match
+ * * If-None-Match
+ * * If-Modified-Since
+ * * If-Unmodified-Since
+ *
+ * The method will return true if all preconditions are met
+ * The method will return false, or throw an exception if preconditions
+ * failed. If false is returned the operation should be aborted, and
+ * the appropriate HTTP response headers are already set.
+ *
+ * Normally this method will throw 412 Precondition Failed for failures
+ * related to If-None-Match, If-Match and If-Unmodified Since. It will
+ * set the status to 304 Not Modified for If-Modified_since.
+ *
+ * @param RequestInterface $request
+ * @param ResponseInterface $response
+ * @return bool
+ */
+ function checkPreconditions(RequestInterface $request, ResponseInterface $response) {
+
+ $path = $request->getPath();
+ $node = null;
+ $lastMod = null;
+ $etag = null;
+
+ if ($ifMatch = $request->getHeader('If-Match')) {
+
+ // If-Match contains an entity tag. Only if the entity-tag
+ // matches we are allowed to make the request succeed.
+ // If the entity-tag is '*' we are only allowed to make the
+ // request succeed if a resource exists at that url.
+ try {
+ $node = $this->tree->getNodeForPath($path);
+ } catch (Exception\NotFound $e) {
+ throw new Exception\PreconditionFailed('An If-Match header was specified and the resource did not exist', 'If-Match');
+ }
+
+ // Only need to check entity tags if they are not *
+ if ($ifMatch !== '*') {
+
+ // There can be multiple ETags
+ $ifMatch = explode(',', $ifMatch);
+ $haveMatch = false;
+ foreach ($ifMatch as $ifMatchItem) {
+
+ // Stripping any extra spaces
+ $ifMatchItem = trim($ifMatchItem, ' ');
+
+ $etag = $node instanceof IFile ? $node->getETag() : null;
+ if ($etag === $ifMatchItem) {
+ $haveMatch = true;
+ } else {
+ // Evolution has a bug where it sometimes prepends the "
+ // with a \. This is our workaround.
+ if (str_replace('\\"', '"', $ifMatchItem) === $etag) {
+ $haveMatch = true;
+ }
+ }
+
+ }
+ if (!$haveMatch) {
+ if ($etag) $response->setHeader('ETag', $etag);
+ throw new Exception\PreconditionFailed('An If-Match header was specified, but none of the specified the ETags matched.', 'If-Match');
+ }
+ }
+ }
+
+ if ($ifNoneMatch = $request->getHeader('If-None-Match')) {
+
+ // The If-None-Match header contains an ETag.
+ // Only if the ETag does not match the current ETag, the request will succeed
+ // The header can also contain *, in which case the request
+ // will only succeed if the entity does not exist at all.
+ $nodeExists = true;
+ if (!$node) {
+ try {
+ $node = $this->tree->getNodeForPath($path);
+ } catch (Exception\NotFound $e) {
+ $nodeExists = false;
+ }
+ }
+ if ($nodeExists) {
+ $haveMatch = false;
+ if ($ifNoneMatch === '*') $haveMatch = true;
+ else {
+
+ // There might be multiple ETags
+ $ifNoneMatch = explode(',', $ifNoneMatch);
+ $etag = $node instanceof IFile ? $node->getETag() : null;
+
+ foreach ($ifNoneMatch as $ifNoneMatchItem) {
+
+ // Stripping any extra spaces
+ $ifNoneMatchItem = trim($ifNoneMatchItem, ' ');
+
+ if ($etag === $ifNoneMatchItem) $haveMatch = true;
+
+ }
+
+ }
+
+ if ($haveMatch) {
+ if ($etag) $response->setHeader('ETag', $etag);
+ if ($request->getMethod() === 'GET') {
+ $response->setStatus(304);
+ return false;
+ } else {
+ throw new Exception\PreconditionFailed('An If-None-Match header was specified, but the ETag matched (or * was specified).', 'If-None-Match');
+ }
+ }
+ }
+
+ }
+
+ if (!$ifNoneMatch && ($ifModifiedSince = $request->getHeader('If-Modified-Since'))) {
+
+ // The If-Modified-Since header contains a date. We
+ // will only return the entity if it has been changed since
+ // that date. If it hasn't been changed, we return a 304
+ // header
+ // Note that this header only has to be checked if there was no If-None-Match header
+ // as per the HTTP spec.
+ $date = HTTP\Util::parseHTTPDate($ifModifiedSince);
+
+ if ($date) {
+ if (is_null($node)) {
+ $node = $this->tree->getNodeForPath($path);
+ }
+ $lastMod = $node->getLastModified();
+ if ($lastMod) {
+ $lastMod = new \DateTime('@' . $lastMod);
+ if ($lastMod <= $date) {
+ $response->setStatus(304);
+ $response->setHeader('Last-Modified', HTTP\Util::toHTTPDate($lastMod));
+ return false;
+ }
+ }
+ }
+ }
+
+ if ($ifUnmodifiedSince = $request->getHeader('If-Unmodified-Since')) {
+
+ // The If-Unmodified-Since will allow allow the request if the
+ // entity has not changed since the specified date.
+ $date = HTTP\Util::parseHTTPDate($ifUnmodifiedSince);
+
+ // We must only check the date if it's valid
+ if ($date) {
+ if (is_null($node)) {
+ $node = $this->tree->getNodeForPath($path);
+ }
+ $lastMod = $node->getLastModified();
+ if ($lastMod) {
+ $lastMod = new \DateTime('@' . $lastMod);
+ if ($lastMod > $date) {
+ throw new Exception\PreconditionFailed('An If-Unmodified-Since header was specified, but the entity has been changed since the specified date.', 'If-Unmodified-Since');
+ }
+ }
+ }
+
+ }
+
+ // Now the hardest, the If: header. The If: header can contain multiple
+ // urls, ETags and so-called 'state tokens'.
+ //
+ // Examples of state tokens include lock-tokens (as defined in rfc4918)
+ // and sync-tokens (as defined in rfc6578).
+ //
+ // The only proper way to deal with these, is to emit events, that a
+ // Sync and Lock plugin can pick up.
+ $ifConditions = $this->getIfConditions($request);
+
+ foreach ($ifConditions as $kk => $ifCondition) {
+ foreach ($ifCondition['tokens'] as $ii => $token) {
+ $ifConditions[$kk]['tokens'][$ii]['validToken'] = false;
+ }
+ }
+
+ // Plugins are responsible for validating all the tokens.
+ // If a plugin deemed a token 'valid', it will set 'validToken' to
+ // true.
+ $this->emit('validateTokens', [ $request, &$ifConditions ]);
+
+ // Now we're going to analyze the result.
+
+ // Every ifCondition needs to validate to true, so we exit as soon as
+ // we have an invalid condition.
+ foreach ($ifConditions as $ifCondition) {
+
+ $uri = $ifCondition['uri'];
+ $tokens = $ifCondition['tokens'];
+
+ // We only need 1 valid token for the condition to succeed.
+ foreach ($tokens as $token) {
+
+ $tokenValid = $token['validToken'] || !$token['token'];
+
+ $etagValid = false;
+ if (!$token['etag']) {
+ $etagValid = true;
+ }
+ // Checking the ETag, only if the token was already deamed
+ // valid and there is one.
+ if ($token['etag'] && $tokenValid) {
+
+ // The token was valid, and there was an ETag. We must
+ // grab the current ETag and check it.
+ $node = $this->tree->getNodeForPath($uri);
+ $etagValid = $node instanceof IFile && $node->getETag() == $token['etag'];
+
+ }
+
+
+ if (($tokenValid && $etagValid) ^ $token['negate']) {
+ // Both were valid, so we can go to the next condition.
+ continue 2;
+ }
+
+
+ }
+
+ // If we ended here, it means there was no valid ETag + token
+ // combination found for the current condition. This means we fail!
+ throw new Exception\PreconditionFailed('Failed to find a valid token/etag combination for ' . $uri, 'If');
+
+ }
+
+ return true;
+
+ }
+
+ /**
+ * This method is created to extract information from the WebDAV HTTP 'If:' header
+ *
+ * The If header can be quite complex, and has a bunch of features. We're using a regex to extract all relevant information
+ * The function will return an array, containing structs with the following keys
+ *
+ * * uri - the uri the condition applies to.
+ * * tokens - The lock token. another 2 dimensional array containing 3 elements
+ *
+ * Example 1:
+ *
+ * If: (<opaquelocktoken:181d4fae-7d8c-11d0-a765-00a0c91e6bf2>)
+ *
+ * Would result in:
+ *
+ * [
+ * [
+ * 'uri' => '/request/uri',
+ * 'tokens' => [
+ * [
+ * [
+ * 'negate' => false,
+ * 'token' => 'opaquelocktoken:181d4fae-7d8c-11d0-a765-00a0c91e6bf2',
+ * 'etag' => ""
+ * ]
+ * ]
+ * ],
+ * ]
+ * ]
+ *
+ * Example 2:
+ *
+ * If: </path/> (Not <opaquelocktoken:181d4fae-7d8c-11d0-a765-00a0c91e6bf2> ["Im An ETag"]) (["Another ETag"]) </path2/> (Not ["Path2 ETag"])
+ *
+ * Would result in:
+ *
+ * [
+ * [
+ * 'uri' => 'path',
+ * 'tokens' => [
+ * [
+ * [
+ * 'negate' => true,
+ * 'token' => 'opaquelocktoken:181d4fae-7d8c-11d0-a765-00a0c91e6bf2',
+ * 'etag' => '"Im An ETag"'
+ * ],
+ * [
+ * 'negate' => false,
+ * 'token' => '',
+ * 'etag' => '"Another ETag"'
+ * ]
+ * ]
+ * ],
+ * ],
+ * [
+ * 'uri' => 'path2',
+ * 'tokens' => [
+ * [
+ * [
+ * 'negate' => true,
+ * 'token' => '',
+ * 'etag' => '"Path2 ETag"'
+ * ]
+ * ]
+ * ],
+ * ],
+ * ]
+ *
+ * @param RequestInterface $request
+ * @return array
+ */
+ function getIfConditions(RequestInterface $request) {
+
+ $header = $request->getHeader('If');
+ if (!$header) return [];
+
+ $matches = [];
+
+ $regex = '/(?:\<(?P<uri>.*?)\>\s)?\((?P<not>Not\s)?(?:\<(?P<token>[^\>]*)\>)?(?:\s?)(?:\[(?P<etag>[^\]]*)\])?\)/im';
+ preg_match_all($regex, $header, $matches, PREG_SET_ORDER);
+
+ $conditions = [];
+
+ foreach ($matches as $match) {
+
+ // If there was no uri specified in this match, and there were
+ // already conditions parsed, we add the condition to the list of
+ // conditions for the previous uri.
+ if (!$match['uri'] && count($conditions)) {
+ $conditions[count($conditions) - 1]['tokens'][] = [
+ 'negate' => $match['not'] ? true : false,
+ 'token' => $match['token'],
+ 'etag' => isset($match['etag']) ? $match['etag'] : ''
+ ];
+ } else {
+
+ if (!$match['uri']) {
+ $realUri = $request->getPath();
+ } else {
+ $realUri = $this->calculateUri($match['uri']);
+ }
+
+ $conditions[] = [
+ 'uri' => $realUri,
+ 'tokens' => [
+ [
+ 'negate' => $match['not'] ? true : false,
+ 'token' => $match['token'],
+ 'etag' => isset($match['etag']) ? $match['etag'] : ''
+ ]
+ ],
+
+ ];
+ }
+
+ }
+
+ return $conditions;
+
+ }
+
+ /**
+ * Returns an array with resourcetypes for a node.
+ *
+ * @param INode $node
+ * @return array
+ */
+ function getResourceTypeForNode(INode $node) {
+
+ $result = [];
+ foreach ($this->resourceTypeMapping as $className => $resourceType) {
+ if ($node instanceof $className) $result[] = $resourceType;
+ }
+ return $result;
+
+ }
+
+ // }}}
+ // {{{ XML Readers & Writers
+
+
+ /**
+ * Generates a WebDAV propfind response body based on a list of nodes.
+ *
+ * If 'strip404s' is set to true, all 404 responses will be removed.
+ *
+ * @param array $fileProperties The list with nodes
+ * @param bool strip404s
+ * @return string
+ */
+ function generateMultiStatus(array $fileProperties, $strip404s = false) {
+
+ $xml = [];
+
+ foreach ($fileProperties as $entry) {
+
+ $href = $entry['href'];
+ unset($entry['href']);
+ if ($strip404s) {
+ unset($entry[404]);
+ }
+ $response = new Xml\Element\Response(
+ ltrim($href, '/'),
+ $entry
+ );
+ $xml[] = [
+ 'name' => '{DAV:}response',
+ 'value' => $response
+ ];
+
+ }
+ return $this->xml->write('{DAV:}multistatus', $xml, $this->baseUri);
+
+ }
+
+}