'{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; } /** * Returns the PSR-3 logger object. * * @return LoggerInterface */ function getLogger() { if (!$this->logger) { $this->logger = new NullLogger(); } return $this->logger; } /** * Handles a http request, and execute a method based on its name * * @param RequestInterface $request * @param ResponseInterface $response * @param bool $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, 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 * @return array */ 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 PropFind $propFind * @param array $yieldFirst * @return \Iterator */ private function generatePathNodes(PropFind $propFind, array $yieldFirst = null) { if ($yieldFirst !== null) { yield $yieldFirst; } $newDepth = $propFind->getDepth(); $path = $propFind->getPath(); if ($newDepth !== self::DEPTH_INFINITY) { $newDepth--; } $propertyNames = $propFind->getRequestedProperties(); $propFindType = !empty($propertyNames) ? PropFind::NORMAL : PropFind::ALLPROPS; foreach ($this->tree->getChildren($path) as $childNode) { if ($path !== '') { $subPath = $path . '/' . $childNode->getName(); } else { $subPath = $childNode->getName(); } $subPropFind = new PropFind($subPath, $propertyNames, $newDepth, $propFindType); yield [ $subPropFind, $childNode ]; if (($newDepth === self::DEPTH_INFINITY || $newDepth >= 1) && $childNode instanceof ICollection) { foreach ($this->generatePathNodes($subPropFind) as $subItem) { yield $subItem; } } } } /** * 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 * * @deprecated Use getPropertiesIteratorForPath() instead (as it's more memory efficient) * @see getPropertiesIteratorForPath() */ function getPropertiesForPath($path, $propertyNames = [], $depth = 0) { return iterator_to_array($this->getPropertiesIteratorForPath($path, $propertyNames, $depth)); } /** * 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 \Iterator */ function getPropertiesIteratorForPath($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) { $propFindRequests = $this->generatePathNodes(clone $propFind, current($propFindRequests)); } 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'] .= '/'; } yield $result; } } } /** * 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(); $formattedResult = [ 'href' => $uri, ]; foreach ($result as $propertyName => $status) { if (!isset($formattedResult[$status])) { $formattedResult[$status] = []; } $formattedResult[$status][$propertyName] = null; } return $formattedResult; } $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 deemed // 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: () * * Would result in: * * [ * [ * 'uri' => '/request/uri', * 'tokens' => [ * [ * [ * 'negate' => false, * 'token' => 'opaquelocktoken:181d4fae-7d8c-11d0-a765-00a0c91e6bf2', * 'etag' => "" * ] * ] * ], * ] * ] * * Example 2: * * If: (Not ["Im An ETag"]) (["Another ETag"]) (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.*?)\>\s)?\((?PNot\s)?(?:\<(?P[^\>]*)\>)?(?:\s?)(?:\[(?P[^\]]*)\])?\)/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|\Traversable $fileProperties The list with nodes * @param bool $strip404s * @return string */ function generateMultiStatus($fileProperties, $strip404s = false) { $w = $this->xml->getWriter(); $w->openMemory(); $w->contextUri = $this->baseUri; $w->startDocument(); $w->startElement('{DAV:}multistatus'); foreach ($fileProperties as $entry) { $href = $entry['href']; unset($entry['href']); if ($strip404s) { unset($entry[404]); } $response = new Xml\Element\Response( ltrim($href, '/'), $entry ); $w->write([ 'name' => '{DAV:}response', 'value' => $response ]); } $w->endElement(); return $w->outputMemory(); } }