diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2ea0ef8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +webdav.css +webdav.js +lib/ \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a2b83f0 --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +all: clean index.php + +deps: + @-mkdir -p lib/KD2/WebDAV + wget -O lib/KD2/WebDAV/Server.php '${KD2FW_URL}WebDAV/Server.php' + wget -O lib/KD2/WebDAV/AbstractStorage.php '${KD2FW_URL}WebDAV/AbstractStorage.php' + wget -O webdav.js https://raw.githubusercontent.com/kd2org/webdav-manager.js/main/webdav.js + wget -O webdav.css https://raw.githubusercontent.com/kd2org/webdav-manager.js/main/webdav.css + +clean: + rm -f index.php + +index.php: + php make.php \ No newline at end of file diff --git a/index.php b/index.php index c159b4b..4bd9560 100644 --- a/index.php +++ b/index.php @@ -2,52 +2,9 @@ namespace KD2\WebDAV { - /* - This file is part of KD2FW -- - - Copyright (c) 2001-2022+ BohwaZ - All rights reserved. - - KD2FW is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - KD2FW is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with KD2FW. If not, see . - */ - + class Exception extends \RuntimeException {} - /** - * This is a minimal, lightweight, and self-supported WebDAV server - * it does not require anything out of standard PHP, not even an XML library. - * This makes it more secure by design, and also faster and lighter. - * - * - supports PROPFIND custom properties - * - supports HTTP ranges for GET requests - * - supports GZIP encoding for GET - * - * You have to extend the AbstractStorage class and implement all the abstract methods to - * get a class-1 and 2 compliant server. - * - * By default, locking is simulated: nothing is really locked, like - * in https://docs.rs/webdav-handler/0.2.0/webdav_handler/fakels/index.html - * - * You also have to implement the actual storage of properties for - * PROPPATCH requests, by extending the 'setProperties' method. - * But it's not required for WebDAV file storage, only for CardDAV/CalDAV. - * - * Differences with SabreDAV and RFC: - * - If-Match, If-Range are not implemented - * - * @author BohwaZ - */ class Server { // List of basic DAV properties that you should return if $requested_properties is NULL @@ -69,30 +26,20 @@ namespace KD2\WebDAV ]; // Custom properties - /** - * File MD5 hash - * Your implementation should return the hexadecimal encoded MD5 hash of the file - */ + const PROP_DIGEST_MD5 = 'urn:karadav:digest_md5'; - /** - * Empty value if you want to have the property found and empty, return this constant - */ const EMPTY_PROP_VALUE = 'DAV::empty'; const SHARED_LOCK = 'shared'; const EXCLUSIVE_LOCK = 'exclusive'; - /** - * Base server URI (eg. "/index.php/webdav/") - */ protected string $base_uri; - /** - * Original URI passed to route() before trim - */ public string $original_uri; + public string $prefix = ''; + protected AbstractStorage $storage; public function setStorage(AbstractStorage $storage) @@ -110,6 +57,15 @@ namespace KD2\WebDAV $this->base_uri = rtrim($uri, '/') . '/'; } + protected function _prefix(string $uri): string + { + if (!$this->prefix) { + return $uri; + } + + return rtrim(rtrim($this->prefix, '/') . '/' . ltrim($uri, '/'), '/'); + } + protected function html_directory(string $uri, iterable $list): ?string { // Not a file: let's serve a directory listing if you are browsing with a web browser @@ -180,7 +136,6 @@ namespace KD2\WebDAV return $out; } - public function http_delete(string $uri): ?string { // check RFC 2518 Section 9.2, last paragraph @@ -188,6 +143,8 @@ namespace KD2\WebDAV throw new Exception('We can only delete to infinity', 400); } + $uri = $this->_prefix($uri); + $this->checkLock($uri); $this->storage->delete($uri); @@ -234,6 +191,8 @@ namespace KD2\WebDAV $hash = bin2hex(base64_decode($_SERVER['HTTP_CONTENT_MD5'])); } + $uri = $this->_prefix($uri); + $this->checkLock($uri); if (!empty($_SERVER['HTTP_IF_MATCH'])) { @@ -272,6 +231,8 @@ namespace KD2\WebDAV public function http_head(string $uri, array &$props = []): ?string { + $uri = $this->_prefix($uri); + $requested_props = self::BASIC_PROPERTIES; $requested_props[] = 'DAV::getetag'; @@ -326,6 +287,8 @@ namespace KD2\WebDAV $props = []; $this->http_head($uri, $props); + $uri = $this->_prefix($uri); + $is_collection = !empty($props['DAV::resourcetype']) && $props['DAV::resourcetype'] == 'collection'; $out = ''; @@ -418,7 +381,6 @@ namespace KD2\WebDAV $end = $length; } - http_response_code(206); header(sprintf('Content-Range: bytes %s-%s/%s', $start, $end - 1, $length)); $file['content'] = substr($file['content'], $start, $end - $start); @@ -524,6 +486,8 @@ namespace KD2\WebDAV protected function _http_copymove(string $uri, string $method): ?string { + $uri = $this->_prefix($uri); + $destination = $_SERVER['HTTP_DESTINATION'] ?? null; $depth = $_SERVER['HTTP_DEPTH'] ?? 1; @@ -582,17 +546,13 @@ namespace KD2\WebDAV throw new Exception('Unsupported body for MKCOL', 415); } + $uri = $this->_prefix($uri); $this->storage->mkcol($uri); http_response_code(201); return null; } - /** - * Return a list of requested properties, if any. - * We are using regexp as we don't want to depend on a XML module here. - * Your are free to re-implement this using a XML parser if you wish - */ protected function extractRequestedProperties(string $body): ?array { // We only care about properties if the client asked for it @@ -620,7 +580,7 @@ namespace KD2\WebDAV $dav_ns = $ns['DAV:'] . ':'; } - $regexp = '/<(' . $dav_ns . 'prop(?!find))[^>]*?>(.*?)<\/\1\s*>/s'; + $regexp = '/<(' . $dav_ns . 'prop(?!find))[^>]*(.*?)<\/\1\s*>/s'; if (!preg_match($regexp, $body, $match)) { return null; } @@ -657,6 +617,7 @@ namespace KD2\WebDAV // We only support depth of 0 and 1 $depth = isset($_SERVER['HTTP_DEPTH']) && empty($_SERVER['HTTP_DEPTH']) ? 0 : 1; + $uri = $this->_prefix($uri); $body = file_get_contents('php://input'); if (false !== strpos($body, ''; + $out = ' $alias) { @@ -757,7 +718,12 @@ namespace KD2\WebDAV foreach ($items as $uri => $item) { $e = ''; - $path = '/' . str_replace('%2F', '/', rawurlencode(trim($this->base_uri . $uri, '/'))); + if ($this->prefix) { + $uri = substr($uri, strlen($this->prefix)); + } + + $uri = trim(rtrim($this->base_uri, '/') . '/' . ltrim($uri, '/'), '/'); + $path = '/' . str_replace('%2F', '/', rawurlencode($uri)); if (($item['DAV::resourcetype'] ?? null) == 'collection') { $path .= '/'; @@ -930,6 +896,7 @@ namespace KD2\WebDAV public function http_proppatch(string $uri): ?string { + $uri = $this->_prefix($uri); $this->checkLock($uri); $body = file_get_contents('php://input'); @@ -940,7 +907,7 @@ namespace KD2\WebDAV header('HTTP/1.1 207 Multi-Status', true); header('Content-Type: application/xml; charset=utf-8'); - $out = '' . "\n"; + $out = ''; $out .= ''; @@ -949,6 +916,7 @@ namespace KD2\WebDAV public function http_lock(string $uri): ?string { + $uri = $this->_prefix($uri); // We don't use this currently, but maybe later? //$depth = !empty($this->_SERVER['HTTP_DEPTH']) ? 1 : 0; //$timeout = isset($_SERVER['HTTP_TIMEOUT']) ? explode(',', $_SERVER['HTTP_TIMEOUT']) : []; @@ -1022,7 +990,7 @@ namespace KD2\WebDAV header('Content-Type: application/xml; charset=utf-8'); header(sprintf('Lock-Token: <%s>', $token)); - $out = '' . "\n"; + $out = ''; $out .= ''; @@ -1040,6 +1008,7 @@ namespace KD2\WebDAV public function http_unlock(string $uri): ?string { + $uri = $this->_prefix($uri); $token = $this->getLockToken(); if (!$token) { @@ -1056,9 +1025,6 @@ namespace KD2\WebDAV return null; } - /** - * Return current lock token supplied by client - */ protected function getLockToken(): ?string { if (isset($_SERVER['HTTP_LOCK_TOKEN']) @@ -1074,10 +1040,6 @@ namespace KD2\WebDAV } } - /** - * Check if the resource is protected - * @throws Exception if the resource is locked - */ protected function checkLock(string $uri, ?string $token = null): void { if ($token === null) { @@ -1170,6 +1132,7 @@ namespace KD2\WebDAV } $uri = substr($uri, strlen($this->base_uri)); + $uri = $this->_prefix($uri); return $uri; } @@ -1261,12 +1224,9 @@ namespace KD2\WebDAV header('Content-Type: application/xml; charset=utf-8', true); - printf('%s', htmlspecialchars($e->getMessage(), ENT_XML1)); + printf('%s', htmlspecialchars($e->getMessage(), ENT_XML1)); } - /** - * Utility function to create HMAC hash of data, useful for NextCloud and WOPI - */ static public function hmac(array $data, string $key = '') { // Protect against length attacks by pre-hashing data @@ -1278,158 +1238,49 @@ namespace KD2\WebDAV } - abstract class AbstractStorage + abstract class AbstractStorage { - /** - * Return the requested resource - * - * @param string $uri Path to resource - * @return null|array An array containing one of those keys: - * path => Full filesystem path to a local file, it will be streamed directly to the client - * resource => a PHP resource (eg. returned by fopen) that will be streamed directly to the client - * content => a string that will be returned - * or NULL if the resource cannot be returned (404) - * - * It is recommended to use X-SendFile inside this method to make things faster. - * @see https://tn123.org/mod_xsendfile/ - */ + abstract public function get(string $uri): ?array; - /** - * Return TRUE if the requested resource exists, or FALSE - * - * @param string $uri - * @return bool - */ abstract public function exists(string $uri): bool; - /** - * Return the requested resource properties - * - * This method is used for HEAD requests, for PROPFIND, and other places - * - * @param string $uri Path to resource - * @param null|array $requested_properties Properties requested by the client, NULL if all available properties are requested, - * or if specific properties are requested, each item will be a key, - * like 'namespace_url:property_name', eg. 'DAV::getcontentlength' or 'http://owncloud.org/ns:size' - * See Server::BASIC_PROPERTIES for default properties. - * @param int $depth Depth, can be 0 or 1 - * @return null|array An array containing the requested properties, each item must have a key - * of the same form as the requested properties. - * - * This method MUST return NULL if the resource does not exist. - * Or it MUST return an array, where the keys are 'namespace_url:property_name' tuples, - * and the value is the content of the property tag. - */ abstract public function properties(string $uri, ?array $requested_properties, int $depth): ?array; - /** - * Store resource properties - * @param string $uri - * @param string $body XML PROPPATCH request, parsing it is up to you - */ public function setProperties(string $uri, string $body): void { // By default, properties are not saved } - /** - * Create or replace a resource - * @param string $uri Path to resource - * @param resource $pointer A PHP file resource containing the sent data (note that this might not always be seekable) - * @param null|string $hash A MD5 hash of the resource to store, if it is supplied, - * this method should fail with a 400 code WebDAV exception and not proceed to store the resource. - * @param null|int $mtime The modification timestamp to set on the file - * @return bool Return TRUE if the resource has been created, or FALSE it has just been updated. - */ abstract public function put(string $uri, $pointer, ?string $hash, ?int $mtime): bool; - /** - * Delete a resource - * @param string $uri - * @return void - */ abstract public function delete(string $uri): void; - /** - * Copy a resource from $uri to $destination - * @param string $uri - * @param string $destination - * @return bool TRUE if the destination has been overwritten - */ abstract public function copy(string $uri, string $destination): bool; - /** - * Move (rename) a resource from $uri to $destination - * @param string $uri - * @param string $destination - * @return bool TRUE if the destination has been overwritten - */ abstract public function move(string $uri, string $destination): bool; - /** - * Create collection of resources (eg. a directory) - * @param string $uri - * @return void - */ abstract public function mkcol(string $uri): void; - /** - * Return a list of resources for target $uri - * - * @param string $uri - * @param array $properties List of properties requested by client (see ::properties) - * @return iterable An array or other iterable (eg. a generator) - * where each item has a key string containing the name of the resource (eg. file name), - * and the value being an array of properties, or NULL. - * - * If the array value IS NULL, then a subsequent call to properties() will be issued for each element. - */ abstract public function list(string $uri, array $properties): iterable; - /** - * Lock the requested resource - * @param string $uri Requested resource - * @param string $token Unique token given to the client for this resource - * @param string $scope Locking scope, either ::SHARED_LOCK or ::EXCLUSIVE_LOCK constant - * @return void - */ public function lock(string $uri, string $token, string $scope): void { // By default locking is not implemented } - /** - * Unlock the requested resource - * @param string $uri Requested resource - * @param string $token Unique token sent by the client - * @return void - */ public function unlock(string $uri, string $token): void { // By default locking is not implemented } - /** - * If $token is supplied, this method MUST return ::SHARED_LOCK or ::EXCLUSIVE_LOCK - * if the resource is locked with this token. If the resource is unlocked, or if it is - * locked with another token, it MUST return NULL. - * - * If $token is left NULL, then this method must return ::EXCLUSIVE_LOCK if there is any - * exclusive lock on the resource. If there are no exclusive locks, but one or more - * shared locks, it MUST return ::SHARED_LOCK. If the resource has no lock, it MUST - * return NULL. - * - * @param string $uri - * @param string|null $token - * @return string|null - */ public function getLock(string $uri, ?string $token = null): ?string { // By default locking is not implemented, so NULL is always returned return null; } } + } namespace NanoKaraDAV @@ -1506,7 +1357,18 @@ namespace NanoKaraDAV case 'DAV::resourcetype': return is_dir($target) ? 'collection' : ''; case 'DAV::getlastmodified': - return new \DateTime('@' . filemtime($target)); + if (!$uri && $depth == 0 && is_dir($target)) { + $mtime = self::getDirectoryMTime($target); + } + else { + $mtime = filemtime($target); + } + + if (!$mtime) { + return null; + } + + return new \DateTime('@' . $mtime); case 'DAV::displayname': return basename($target); case 'DAV::ishidden': @@ -1715,6 +1577,30 @@ namespace NanoKaraDAV mkdir($target, 0770); } + + static public function getDirectoryMTime(string $path): int + { + $last = 0; + $path = rtrim($path, '/'); + + foreach (glob($path . '/*', GLOB_NOSORT) as $f) { + if (is_dir($f)) { + $m = self::getDirectoryMTime($f); + + if ($m > $last) { + $last = $m; + } + } + + $m = filemtime($f); + + if ($m > $last) { + $last = $m; + } + } + + return $last; + } } class Server extends \KD2\WebDAV\Server @@ -1765,12 +1651,12 @@ namespace { $fp = fopen(__FILE__, 'r'); if ($relative_uri == 'webdav.js') { - fseek($fp, 49434, SEEK_SET); + fseek($fp, 43642, SEEK_SET); echo fread($fp, 24229); } else { - fseek($fp, 49434 + 24229, SEEK_SET); - echo fread($fp, 6728); + fseek($fp, 43642 + 24229, SEEK_SET); + echo fread($fp, 6752); } fclose($fp); @@ -2578,6 +2464,7 @@ th, td { padding: .5em; text-align: left; border: 2px solid var(--g2-color); + word-break: break-all; } td.thumb { diff --git a/make.php b/make.php index 25d039f..c74625a 100644 --- a/make.php +++ b/make.php @@ -2,8 +2,21 @@ $out = fopen('index.php', 'w'); +function clean_php_source(string $file): string +{ + $php = file_get_contents($file); + $php = preg_replace('/^namespace\s+.*;\s*$/m', '', $php); + $php = preg_replace('/<\?php\s*|\s*\?>/', '', $php); + $php = preg_replace(';/\*(?!\*/).*?\*/;s', '', $php); + $php = preg_replace('/^/m', "\t", $php); + $php = preg_replace('/^\s*$/m', "", $php); + return $php; +} + $php = file_get_contents('server.php'); $php = strtr($php, [ + '//__KD2\WebDAV\Server__' => clean_php_source('lib/KD2/WebDAV/Server.php'), + '//__KD2\WebDAV\AbstractStorage__' => clean_php_source('lib/KD2/WebDAV/AbstractStorage.php'), '__JS_SIZE__' => filesize('webdav.js'), '__CSS_SIZE__' => filesize('webdav.css'), ]); diff --git a/server.php b/server.php index bc0286e..44ffc46 100644 --- a/server.php +++ b/server.php @@ -2,1434 +2,9 @@ namespace KD2\WebDAV { - /* - This file is part of KD2FW -- + //__KD2\WebDAV\Server__ - Copyright (c) 2001-2022+ BohwaZ - All rights reserved. - - KD2FW is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - KD2FW is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with KD2FW. If not, see . - */ - - class Exception extends \RuntimeException {} - - /** - * This is a minimal, lightweight, and self-supported WebDAV server - * it does not require anything out of standard PHP, not even an XML library. - * This makes it more secure by design, and also faster and lighter. - * - * - supports PROPFIND custom properties - * - supports HTTP ranges for GET requests - * - supports GZIP encoding for GET - * - * You have to extend the AbstractStorage class and implement all the abstract methods to - * get a class-1 and 2 compliant server. - * - * By default, locking is simulated: nothing is really locked, like - * in https://docs.rs/webdav-handler/0.2.0/webdav_handler/fakels/index.html - * - * You also have to implement the actual storage of properties for - * PROPPATCH requests, by extending the 'setProperties' method. - * But it's not required for WebDAV file storage, only for CardDAV/CalDAV. - * - * Differences with SabreDAV and RFC: - * - If-Match, If-Range are not implemented - * - * @author BohwaZ - */ - class Server - { - // List of basic DAV properties that you should return if $requested_properties is NULL - const BASIC_PROPERTIES = [ - 'DAV::resourcetype', // should be empty for files, and 'collection' for directories - 'DAV::getcontenttype', // MIME type - 'DAV::getlastmodified', // File modification date (must be \DateTimeInterface) - 'DAV::getcontentlength', // file size - 'DAV::displayname', // File name for display - ]; - - const EXTENDED_PROPERTIES = [ - 'DAV::getetag', - 'DAV::creationdate', - 'DAV::lastaccessed', - 'DAV::ishidden', // Microsoft thingy - 'DAV::quota-used-bytes', - 'DAV::quota-available-bytes', - ]; - - // Custom properties - /** - * File MD5 hash - * Your implementation should return the hexadecimal encoded MD5 hash of the file - */ - const PROP_DIGEST_MD5 = 'urn:karadav:digest_md5'; - - /** - * Empty value if you want to have the property found and empty, return this constant - */ - const EMPTY_PROP_VALUE = 'DAV::empty'; - - const SHARED_LOCK = 'shared'; - const EXCLUSIVE_LOCK = 'exclusive'; - - /** - * Base server URI (eg. "/index.php/webdav/") - */ - protected string $base_uri; - - /** - * Original URI passed to route() before trim - */ - public string $original_uri; - - protected AbstractStorage $storage; - - public function setStorage(AbstractStorage $storage) - { - $this->storage = $storage; - } - - public function getStorage(): AbstractStorage - { - return $this->storage; - } - - public function setBaseURI(string $uri): void - { - $this->base_uri = rtrim($uri, '/') . '/'; - } - - protected function html_directory(string $uri, iterable $list): ?string - { - // Not a file: let's serve a directory listing if you are browsing with a web browser - if (substr($this->original_uri, -1) != '/') { - http_response_code(301); - header(sprintf('Location: /%s/', trim($this->base_uri . $uri, '/')), true); - return null; - } - - $out = sprintf('', '/' . ltrim($this->base_uri, '/')); - - $out .= sprintf('%s

%1$s

', htmlspecialchars($uri ? str_replace('/', ' / ', $uri) . ' - Files' : 'Files')); - - if (trim($uri)) { - $out .= ''; - } - - $props = null; - - foreach ($list as $file => $props) { - if (null === $props) { - $props = $this->storage->properties(trim($uri . '/' . $file, '/'), self::BASIC_PROPERTIES, 0); - } - - $collection = !empty($props['DAV::resourcetype']) && $props['DAV::resourcetype'] == 'collection'; - - if ($collection) { - $out .= sprintf('', rawurlencode($file), htmlspecialchars($file)); - } - else { - $size = $props['DAV::getcontentlength']; - - if ($size > 1024*1024) { - $size = sprintf('%d MB', $size / 1024 / 1024); - } - elseif ($size) { - $size = sprintf('%d KB', $size / 1024); - } - - $date = $props['DAV::getlastmodified']; - - if ($date instanceof \DateTimeInterface) { - $date = $date->format('d/m/Y H:i'); - } - - $out .= sprintf('', - rawurlencode($file), - htmlspecialchars($file), - $size, - $date - ); - } - } - - $out .= '
Back
[DIR]%s
%s%s%s
'; - - if (null === $props) { - $out .= '

This directory is empty.

'; - } - - $out .= ''; - - return $out; - } - - - public function http_delete(string $uri): ?string - { - // check RFC 2518 Section 9.2, last paragraph - if (isset($_SERVER['HTTP_DEPTH']) && $_SERVER['HTTP_DEPTH'] != 'infinity') { - throw new Exception('We can only delete to infinity', 400); - } - - $this->checkLock($uri); - - $this->storage->delete($uri); - - if ($token = $this->getLockToken()) { - $this->storage->unlock($uri, $token); - } - - http_response_code(204); - header('Content-Length: 0', true); - return null; - } - - public function http_put(string $uri): ?string - { - if (!empty($_SERVER['HTTP_CONTENT_TYPE']) && !strncmp($_SERVER['HTTP_CONTENT_TYPE'], 'multipart/', 10)) { - throw new Exception('Multipart PUT requests are not supported', 501); - } - - if (!empty($_SERVER['HTTP_CONTENT_ENCODING'])) { - if (false !== strpos($_SERVER['HTTP_CONTENT_ENCODING'], 'gzip')) { - // Might be supported later? - throw new Exception('Content Encoding is not supported', 501); - } - else { - throw new Exception('Content Encoding is not supported', 501); - } - } - - if (!empty($_SERVER['HTTP_CONTENT_RANGE'])) { - throw new Exception('Content Range is not supported', 501); - } - - // See SabreDAV CorePlugin for reason why OS/X Finder is buggy - if (isset($_SERVER['HTTP_X_EXPECTED_ENTITY_LENGTH'])) { - throw new Exception('This server is not compatible with OS/X finder. Consider using a different WebDAV client or webserver.', 403); - } - - $hash = null; - - // Support for checksum matching - // https://dcache.org/old/manuals/UserGuide-6.0/webdav.shtml#checksums - if (!empty($_SERVER['HTTP_CONTENT_MD5'])) { - $hash = bin2hex(base64_decode($_SERVER['HTTP_CONTENT_MD5'])); - } - - $this->checkLock($uri); - - if (!empty($_SERVER['HTTP_IF_MATCH'])) { - $etag = trim($_SERVER['HTTP_IF_MATCH'], '" '); - $prop = $this->storage->properties($uri, ['DAV::getetag'], 0); - - if (!empty($prop['DAV::getetag']) && $prop['DAV::getetag'] != $etag) { - throw new Exception('ETag did not match condition', 412); - } - } - - // Specific to NextCloud/ownCloud - $mtime = (int)($_SERVER['HTTP_X_OC_MTIME'] ?? 0) ?: null; - - if ($mtime) { - header('X-OC-MTime: accepted'); - } - - $created = $this->storage->put($uri, fopen('php://input', 'r'), $hash, $mtime); - - $prop = $this->storage->properties($uri, ['DAV::getetag'], 0); - - if (!empty($prop['DAV::getetag'])) { - $value = $prop['DAV::getetag']; - - if (substr($value, 0, 1) != '"') { - $value = '"' . $value . '"'; - } - - header(sprintf('ETag: %s', $value)); - } - - http_response_code($created ? 201 : 204); - return null; - } - - public function http_head(string $uri, array &$props = []): ?string - { - $requested_props = self::BASIC_PROPERTIES; - $requested_props[] = 'DAV::getetag'; - - // RFC 3230 https://www.rfc-editor.org/rfc/rfc3230.html - if (!empty($_SERVER['HTTP_WANT_DIGEST'])) { - $requested_props[] = self::PROP_DIGEST_MD5; - } - - $props = $this->storage->properties($uri, $requested_props, 0); - - if (!$props) { - throw new Exception('Resource Not Found', 404); - } - - http_response_code(200); - - if (isset($props['DAV::getlastmodified']) - && $props['DAV::getlastmodified'] instanceof \DateTimeInterface) { - header(sprintf('Last-Modified: %s', $props['DAV::getlastmodified']->format(\DATE_RFC7231))); - } - - if (!empty($props['DAV::getetag'])) { - $value = $props['DAV::getetag']; - - if (substr($value, 0, 1) != '"') { - $value = '"' . $value . '"'; - } - - header(sprintf('ETag: %s', $value)); - } - - if (empty($props['DAV::resourcetype']) || $props['DAV::resourcetype'] != 'collection') { - if (!empty($props['DAV::getcontenttype'])) { - header(sprintf('Content-Type: %s', $props['DAV::getcontenttype'])); - } - - if (!empty($props['DAV::getcontentlength'])) { - header(sprintf('Content-Length: %d', $props['DAV::getcontentlength'])); - header('Accept-Ranges: bytes'); - } - } - - if (!empty($props[self::PROP_DIGEST_MD5])) { - header(sprintf('Digest: md5=%s', base64_encode(hex2bin($props[self::PROP_DIGEST_MD5])))); - } - - return null; - } - - public function http_get(string $uri): ?string - { - $props = []; - $this->http_head($uri, $props); - - $is_collection = !empty($props['DAV::resourcetype']) && $props['DAV::resourcetype'] == 'collection'; - $out = ''; - - if ($is_collection) { - $list = $this->storage->list($uri, self::BASIC_PROPERTIES); - - if (!isset($_SERVER['HTTP_ACCEPT']) || false === strpos($_SERVER['HTTP_ACCEPT'], 'html')) { - $list = is_array($list) ? $list : iterator_to_array($list); - - if (!count($list)) { - return "Nothing in this collection\n"; - } - - return implode("\n", array_keys($list)); - } - - header('Content-Type: text/html; charset=utf-8', true); - - return $this->html_directory($uri, $list); - } - - $file = $this->storage->get($uri); - - if (!$file) { - throw new Exception('File Not Found', 404); - } - - // If the file was returned to the client by the storage backend, stop here - if (!empty($file['stop'])) { - return null; - } - - if (!isset($file['content']) && !isset($file['resource']) && !isset($file['path'])) { - throw new \RuntimeException('Invalid file array returned by ::get()'); - } - - $length = $start = $end = null; - $gzip = false; - - if (isset($_SERVER['HTTP_RANGE']) - && preg_match('/^bytes=(\d*)-(\d*)$/i', $_SERVER['HTTP_RANGE'], $match) - && $match[1] . $match[2] !== '') { - $start = $match[1] === '' ? null : (int) $match[1]; - $end = $match[2] === '' ? null : (int) $match[2]; - - if (null !== $start && $start < 0) { - throw new Exception('Start range cannot be satisfied', 416); - } - - if (isset($props['DAV::getcontentlength']) && $start > $props['DAV::getcontentlength']) { - throw new Exception('End range cannot be satisfied', 416); - } - - $this->log('HTTP Range requested: %s-%s', $start, $end); - } - elseif (isset($_SERVER['HTTP_ACCEPT_ENCODING']) - && false !== strpos($_SERVER['HTTP_ACCEPT_ENCODING'], 'gzip') - // Don't compress already compressed content - && !preg_match('/\.(?:mp4|m4a|zip|docx|xlsx|ods|odt|odp|7z|gz|bz2|rar|webm|ogg|mp3|ogm|flac|ogv|mkv|avi)$/i', $uri)) { - $gzip = true; - header('Content-Encoding: gzip', true); - } - - // Try to avoid common issues with output buffering and stuff - if (function_exists('apache_setenv')) - { - @apache_setenv('no-gzip', 1); - } - - @ini_set('zlib.output_compression', 'Off'); - - if (@ob_get_length()) { - @ob_clean(); - } - - if (isset($file['content'])) { - $length = strlen($file['content']); - - if ($start || $end) { - if (null !== $end && $end > $length) { - header('Content-Range: bytes */' . $length, true); - throw new Exception('End range cannot be satisfied', 416); - } - - if ($start === null) { - $start = $length - $end; - $end = $start + $end; - } - elseif ($end === null) { - $end = $length; - } - - - http_response_code(206); - header(sprintf('Content-Range: bytes %s-%s/%s', $start, $end - 1, $length)); - $file['content'] = substr($file['content'], $start, $end - $start); - $length = $end - $start; - } - - if ($gzip) { - $file['content'] = gzencode($file['content'], 9); - $length = strlen($file['content']); - } - - header('Content-Length: ' . $length, true); - echo $file['content']; - return null; - } - - if (isset($file['path'])) { - $file['resource'] = fopen($file['path'], 'rb'); - } - - $seek = fseek($file['resource'], 0, SEEK_END); - - if ($seek === 0) { - $length = ftell($file['resource']); - fseek($file['resource'], 0, SEEK_SET); - } - - if (($start || $end) && $seek === 0) { - if (null !== $end && $end > $length) { - header('Content-Range: bytes */' . $length, true); - throw new Exception('End range cannot be satisfied', 416); - } - - if ($start === null) { - $start = $length - $end; - $end = $start + $end; - } - elseif ($end === null) { - $end = $length; - } - - fseek($file['resource'], $start, SEEK_SET); - - http_response_code(206); - header(sprintf('Content-Range: bytes %s-%s/%s', $start, $end - 1, $length), true); - - $length = $end - $start; - $end -= $start; - } - elseif (null === $length && isset($file['path'])) { - $end = $length = filesize($file['path']); - } - - if ($gzip) { - $this->log('Using gzip output compression'); - $gzip = deflate_init(ZLIB_ENCODING_GZIP, ['level' => 9]); - - $fp = fopen('php://memory', 'wb'); - - while (!feof($file['resource'])) { - fwrite($fp, deflate_add($gzip, fread($file['resource'], 8192), ZLIB_NO_FLUSH)); - } - - fwrite($fp, deflate_add($gzip, '', ZLIB_FINISH)); - $length = ftell($fp); - rewind($fp); - fclose($file['resource']); - - $file['resource'] = $fp; - unset($fp); - } - - if (null !== $length) { - $this->log('Length: %s', $length); - header('Content-Length: ' . $length, true); - } - - while (!feof($file['resource']) && ($end === null || $end > 0)) { - $l = $end !== null ? min(8192, $end) : 8192; - - echo fread($file['resource'], $l); - flush(); - - if (null !== $end) { - $end -= 8192; - } - } - - fclose($file['resource']); - - return null; - } - - public function http_copy(string $uri): ?string - { - return $this->_http_copymove($uri, 'copy'); - } - - public function http_move(string $uri): ?string - { - return $this->_http_copymove($uri, 'move'); - } - - protected function _http_copymove(string $uri, string $method): ?string - { - $destination = $_SERVER['HTTP_DESTINATION'] ?? null; - $depth = $_SERVER['HTTP_DEPTH'] ?? 1; - - if (!$destination) { - throw new Exception('Destination not supplied', 400); - } - - $destination = $this->getURI($destination); - - if (trim($destination, '/') == trim($uri, '/')) { - throw new Exception('Cannot move file to itself', 403); - } - - $overwrite = ($_SERVER['HTTP_OVERWRITE'] ?? null) == 'T'; - - // Dolphin is removing the file name when moving to root directory - if (empty($destination)) { - $destination = basename($uri); - } - - $this->log('<= Destination: %s', $destination); - $this->log('<= Overwrite: %s (%s)', $overwrite ? 'Yes' : 'No', $_SERVER['HTTP_OVERWRITE'] ?? null); - - if (!$overwrite && $this->storage->exists($destination)) { - throw new Exception('File already exists and overwriting is disabled', 412); - } - - if ($method == 'move') { - $this->checkLock($uri); - } - - $this->checkLock($destination); - - // Moving/copy of directory to an existing destination and depth=0 - // should do just nothing, see 'depth_zero_copy' test in litmus - if ($depth == 0 - && $this->storage->exists($destination) - && current($this->storage->properties($destination, ['DAV::resourcetype'], 0)) == 'collection') { - $overwritten = $this->storage->exists($uri); - } - else { - $overwritten = $this->storage->$method($uri, $destination); - } - - if ($method == 'move' && ($token = $this->getLockToken())) { - $this->storage->unlock($uri, $token); - } - - http_response_code($overwritten ? 204 : 201); - return null; - } - - public function http_mkcol(string $uri): ?string - { - if (!empty($_SERVER['CONTENT_LENGTH'])) { - throw new Exception('Unsupported body for MKCOL', 415); - } - - $this->storage->mkcol($uri); - - http_response_code(201); - return null; - } - - /** - * Return a list of requested properties, if any. - * We are using regexp as we don't want to depend on a XML module here. - * Your are free to re-implement this using a XML parser if you wish - */ - protected function extractRequestedProperties(string $body): ?array - { - // We only care about properties if the client asked for it - // If not, we consider that the client just requested to get everything - if (!preg_match('!<(?:\w+:)?propfind!', $body)) { - return null; - } - - $ns = []; - $dav_ns = null; - $default_ns = null; - - if (preg_match('/]+xmlns="DAV:"/', $body)) { - $default_ns = 'DAV:'; - } - - preg_match_all('!xmlns:(\w+)\s*=\s*"([^"]+)"!', $body, $match, PREG_SET_ORDER); - - // Find all aliased xmlns - foreach ($match as $found) { - $ns[$found[2]] = $found[1]; - } - - if (isset($ns['DAV:'])) { - $dav_ns = $ns['DAV:'] . ':'; - } - - $regexp = '/<(' . $dav_ns . 'prop(?!find))[^>]*?>(.*?)<\/\1\s*>/s'; - if (!preg_match($regexp, $body, $match)) { - return null; - } - - // Find all properties - // Allow for empty namespace, see Litmus FAQ for propnullns - // https://github.com/tolsen/litmus/blob/master/FAQ - preg_match_all('!<([\w-]+)[^>]*xmlns="([^"]*)"|<(?:([\w-]+):)?([\w-]+)!', $match[2], $match, PREG_SET_ORDER); - - $properties = []; - - foreach ($match as $found) { - if (isset($found[4])) { - $url = array_search($found[3], $ns) ?: $default_ns; - $name = $found[4]; - } - else { - $url = $found[2]; - $name = $found[1]; - } - - $properties[$url . ':' . $name] = [ - 'name' => $name, - 'ns_alias' => $found[3] ?? null, - 'ns_url' => $url, - ]; - } - - return $properties; - } - - public function http_propfind(string $uri): ?string - { - // We only support depth of 0 and 1 - $depth = isset($_SERVER['HTTP_DEPTH']) && empty($_SERVER['HTTP_DEPTH']) ? 0 : 1; - - $body = file_get_contents('php://input'); - - if (false !== strpos($body, 'log('Requested depth: %s', $depth); - - // We don't really care about having a correct XML string, - // but we can get better WebDAV compliance if we do - if (isset($_SERVER['HTTP_X_LITMUS'])) { - if (false !== strpos($body, 'extractRequestedProperties($body); - $requested_keys = $requested ? array_keys($requested) : null; - - // Find root element properties - $properties = $this->storage->properties($uri, $requested_keys, $depth); - - if (null === $properties) { - throw new Exception('This does not exist', 404); - } - - $items = [$uri => $properties]; - - if ($depth) { - foreach ($this->storage->list($uri, $requested) as $file => $properties) { - $path = trim($uri . '/' . $file, '/'); - $properties = $properties ?? $this->storage->properties($path, $requested_keys, 0); - - if (!$properties) { - $this->log('!!! Cannot find "%s"', $path); - continue; - } - - $items[$path] = $properties; - } - } - - // http_response_code doesn't know the 207 status code - header('HTTP/1.1 207 Multi-Status', true); - $this->dav_header(); - header('Content-Type: application/xml; charset=utf-8'); - - $root_namespaces = [ - 'DAV:' => 'd', - // Microsoft Clients need this special namespace for date and time values (from PEAR/WebDAV) - 'urn:uuid:c2f41010-65b3-11d1-a29f-00aa00c14882/' => 'ns0', - ]; - - $i = 0; - $requested ??= []; - - foreach ($requested as $prop) { - if ($prop['ns_url'] == 'DAV:' || !$prop['ns_url']) { - continue; - } - - if (!array_key_exists($prop['ns_url'], $root_namespaces)) { - $root_namespaces[$prop['ns_url']] = $prop['ns_alias'] ?: 'rns' . $i++; - } - } - - foreach ($items as $properties) { - foreach ($properties as $name => $value) { - $pos = strrpos($name, ':'); - $ns = substr($name, 0, strrpos($name, ':')); - - // NULL namespace, see Litmus FAQ for propnullns - if (!$ns) { - continue; - } - - if (!array_key_exists($ns, $root_namespaces)) { - $root_namespaces[$ns] = 'rns' . $i++; - } - } - } - - $out = ''; - $out .= ' $alias) { - $out .= sprintf(' xmlns:%s="%s"', $alias, $url); - } - - $out .= '>'; - - foreach ($items as $uri => $item) { - $e = ''; - - $path = '/' . str_replace('%2F', '/', rawurlencode(trim($this->base_uri . $uri, '/'))); - - if (($item['DAV::resourcetype'] ?? null) == 'collection') { - $path .= '/'; - } - - $e .= sprintf('%s', htmlspecialchars($path, ENT_XML1)); - $e .= ''; - - foreach ($item as $name => $value) { - if (null === $value) { - continue; - } - - $pos = strrpos($name, ':'); - $ns = substr($name, 0, strrpos($name, ':')); - $tag_name = substr($name, strrpos($name, ':') + 1); - - $alias = $root_namespaces[$ns] ?? null; - $attributes = ''; - - // The ownCloud Android app doesn't like formatted dates, it makes it crash. - // so force it to have a timestamp - if ($name == 'DAV::creationdate' - && ($value instanceof \DateTimeInterface) - && false !== stripos($_SERVER['HTTP_USER_AGENT'] ?? '', 'owncloud')) { - $value = $value->getTimestamp(); - } - // ownCloud app crashes if mimetype is provided for a directory - // https://github.com/owncloud/android/issues/3768 - elseif ($name == 'DAV::getcontenttype' - && ($item['DAV::resourcetype'] ?? null) == 'collection') { - $value = null; - } - - if ($name == 'DAV::resourcetype' && $value == 'collection') { - $value = ''; - } - elseif ($name == 'DAV::getetag' && strlen($value) && $value[0] != '"') { - $value = '"' . $value . '"'; - } - elseif ($value instanceof \DateTimeInterface) { - // Change value to GMT - $value = clone $value; - $value->setTimezone(new \DateTimeZone('GMT')); - $value = $value->format(DATE_RFC7231); - } - elseif (is_array($value)) { - $attributes = $value['attributes'] ?? ''; - $value = $value['xml'] ?? null; - } - else { - $value = htmlspecialchars($value, ENT_XML1); - } - - // NULL namespace, see Litmus FAQ for propnullns - if (!$ns) { - $attributes .= ' xmlns=""'; - } - else { - $tag_name = $alias . ':' . $tag_name; - } - - if (null === $value || self::EMPTY_PROP_VALUE === $value) { - $e .= sprintf('<%s%s />', $tag_name, $attributes ? ' ' . $attributes : ''); - } - else { - $e .= sprintf('<%s%s>%s', $tag_name, $attributes ? ' ' . $attributes : '', $value); - } - } - - $e .= 'HTTP/1.1 200 OK' . "\n"; - - // Append missing properties - if (!empty($requested)) { - $missing_properties = array_diff($requested_keys, array_keys($item)); - - if (count($missing_properties)) { - $e .= ''; - - foreach ($missing_properties as $name) { - $pos = strrpos($name, ':'); - $ns = substr($name, 0, strrpos($name, ':')); - $name = substr($name, strrpos($name, ':') + 1); - $alias = $root_namespaces[$ns] ?? null; - - // NULL namespace, see Litmus FAQ for propnullns - if (!$alias) { - $e .= sprintf('<%s xmlns="" />', $name); - } - else { - $e .= sprintf('<%s:%s />', $alias, $name); - } - } - - $e .= 'HTTP/1.1 404 Not Found'; - } - } - - $e .= '' . "\n"; - $out .= $e; - } - - $out .= ''; - - return $out; - } - - static public function parsePropPatch(string $body): array - { - if (false !== strpos($body, 'getDocNameSpaces()))) { - $_ns = 'DAV:'; - } - - $out = []; - - // Process set/remove instructions in order (important) - foreach ($xml->children($_ns) as $child) { - foreach ($child->children($_ns) as $prop) { - $prop = $prop->children(); - if ($child->getName() == 'set') { - $ns = $prop->getNamespaces(true); - $ns = array_flip($ns); - $name = key($ns) . ':' . $prop->getName(); - - $attributes = $prop->attributes(); - $attributes = $attributes === null ? null : iterator_to_array($attributes); - - foreach ($ns as $xmlns => $alias) { - foreach (iterator_to_array($prop->attributes($alias)) as $key => $v) { - $attributes[$xmlns . ':' . $key] = $value; - } - } - - if ($prop->count() > 1) { - $text = ''; - - foreach ($prop->children() as $c) { - $text .= $c->asXML(); - } - } - else { - $text = (string)$prop; - } - - $out[$name] = ['action' => 'set', 'attributes' => $attributes ?: null, 'content' => $text ?: null]; - } - else { - $ns = $prop->getNamespaces(); - $name = current($ns) . ':' . $prop->getName(); - $out[$name] = ['action' => 'remove']; - } - } - } - - return $out; - } - - public function http_proppatch(string $uri): ?string - { - $this->checkLock($uri); - - $body = file_get_contents('php://input'); - - $this->storage->setProperties($uri, $body); - - // http_response_code doesn't know the 207 status code - header('HTTP/1.1 207 Multi-Status', true); - header('Content-Type: application/xml; charset=utf-8'); - - $out = '' . "\n"; - $out .= ''; - $out .= ''; - - return $out; - } - - public function http_lock(string $uri): ?string - { - // We don't use this currently, but maybe later? - //$depth = !empty($this->_SERVER['HTTP_DEPTH']) ? 1 : 0; - //$timeout = isset($_SERVER['HTTP_TIMEOUT']) ? explode(',', $_SERVER['HTTP_TIMEOUT']) : []; - //$timeout = array_map('trim', $timeout); - - if (empty($_SERVER['CONTENT_LENGTH']) && !empty($_SERVER['HTTP_IF'])) { - $token = $this->getLockToken(); - - if (!$token) { - throw new Exception('Invalid If header', 400); - } - - $info = null; - $ns = 'D'; - $scope = self::EXCLUSIVE_LOCK; - - $this->checkLock($uri, $token); - $this->log('Requesting LOCK refresh: %s = %s', $uri, $scope); - } - else { - $locked_scope = $this->storage->getLock($uri); - - if ($locked_scope == self::EXCLUSIVE_LOCK) { - throw new Exception('Cannot acquire another lock, resource is locked for exclusive use', 423); - } - - if ($locked_scope && $token = $this->getLockToken()) { - $token = $this->getLockToken(); - - if (!$token) { - throw new Exception('Missing lock token', 423); - } - - $this->checkLock($uri, $token); - } - - $xml = file_get_contents('php://input'); - - if (!preg_match('!<((?:(\w+):)?lockinfo)[^>]*>(.*?)!is', $xml, $match)) { - throw new Exception('Invalid XML', 400); - } - - $ns = $match[2]; - $info = $match[3]; - - // Quick and dirty UUID - $uuid = random_bytes(16); - $uuid[6] = chr(ord($uuid[6]) & 0x0f | 0x40); // set version to 0100 - $uuid[8] = chr(ord($uuid[8]) & 0x3f | 0x80); // set bits 6-7 to 10 - $uuid = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($uuid), 4)); - - $token = 'opaquelocktoken:' . $uuid; - $scope = false !== stripos($info, sprintf('<%sexclusive', $ns ? $ns . ':' : '')) ? self::EXCLUSIVE_LOCK : self::SHARED_LOCK; - - $this->log('Requesting LOCK: %s = %s', $uri, $scope); - } - - $this->storage->lock($uri, $token, $scope); - - $timeout = 60*5; - $info = sprintf(' - - - unknown - %d - Second-%d - %s - ', $scope, 1, $timeout, $token); - - http_response_code(200); - header('Content-Type: application/xml; charset=utf-8'); - header(sprintf('Lock-Token: <%s>', $token)); - - $out = '' . "\n"; - $out .= ''; - $out .= ''; - - $out .= $info; - - $out .= ''; - - if ($ns != 'D') { - $out = str_replace('D:', $ns ? $ns . ':' : '', $out); - $out = str_replace('xmlns:D', $ns ? 'xmlns:' . $ns : 'xmlns', $out); - } - - return $out; - } - - public function http_unlock(string $uri): ?string - { - $token = $this->getLockToken(); - - if (!$token) { - throw new Exception('Invalid Lock-Token header', 400); - } - - $this->log('<= Lock Token: %s', $token); - - $this->checkLock($uri, $token); - - $this->storage->unlock($uri, $token); - - http_response_code(204); - return null; - } - - /** - * Return current lock token supplied by client - */ - protected function getLockToken(): ?string - { - if (isset($_SERVER['HTTP_LOCK_TOKEN']) - && preg_match('/<(.*?)>/', trim($_SERVER['HTTP_LOCK_TOKEN']), $match)) { - return $match[1]; - } - elseif (isset($_SERVER['HTTP_IF']) - && preg_match('/\(<(.*?)>\)/', trim($_SERVER['HTTP_IF']), $match)) { - return $match[1]; - } - else { - return null; - } - } - - /** - * Check if the resource is protected - * @throws Exception if the resource is locked - */ - protected function checkLock(string $uri, ?string $token = null): void - { - if ($token === null) { - $token = $this->getLockToken(); - } - - // Trying to access using a parent directory - if (isset($_SERVER['HTTP_IF']) - && preg_match('/<([^>]+)>\s*\(<[^>]*>\)/', $_SERVER['HTTP_IF'], $match)) { - $root = $this->getURI($match[1]); - - if (0 !== strpos($uri, $root)) { - throw new Exception('Invalid "If" header path: ' . $root, 400); - } - - $uri = $root; - } - // Try to validate token - elseif (isset($_SERVER['HTTP_IF']) - && preg_match('/\(<([^>]*)>\s+\["([^""]+)"\]\)/', $_SERVER['HTTP_IF'], $match)) { - $token = $match[1]; - $request_etag = $match[2]; - $etag = current($this->storage->properties($uri, ['DAV::getetag'], 0)); - - if ($request_etag != $etag) { - throw new Exception('Resource is locked and etag does not match', 412); - } - } - - if ($token == 'DAV:no-lock') { - throw new Exception('Resource is locked', 412); - } - - // Token is valid - if ($token && $this->storage->getLock($uri, $token)) { - return; - } - elseif ($token) { - throw new Exception('Invalid token', 400); - } - // Resource is locked - elseif ($this->storage->getLock($uri)) { - throw new Exception('Resource is locked', 423); - } - } - - protected function dav_header() - { - header('DAV: 1, 2, 3'); - } - - public function http_options(): void - { - http_response_code(200); - $methods = 'GET HEAD PUT DELETE COPY MOVE PROPFIND MKCOL LOCK UNLOCK'; - - $this->dav_header(); - - header('Allow: ' . $methods); - header('Content-length: 0'); - header('Accept-Ranges: bytes'); - header('MS-Author-Via: DAV'); - } - - public function log(string $message, ...$params) - { - if (PHP_SAPI == 'cli-server') { - file_put_contents('php://stderr', vsprintf($message, $params) . "\n"); - } - } - - protected function getURI(string $source): string - { - $uri = parse_url($source, PHP_URL_PATH); - $uri = rawurldecode($uri); - $uri = rtrim($uri, '/'); - - if ($uri . '/' == $this->base_uri) { - $uri .= '/'; - } - - if (strpos($uri, $this->base_uri) !== 0) { - throw new Exception(sprintf('Invalid URI, "%s" is outside of scope "%s"', $uri, $this->base_uri), 400); - } - - $uri = preg_replace('!/{2,}!', '/', $uri); - - if (false !== strpos($uri, '..')) { - throw new Exception(sprintf('Invalid URI: "%s"', $uri), 403); - } - - $uri = substr($uri, strlen($this->base_uri)); - return $uri; - } - - public function route(?string $uri = null): bool - { - if (null === $uri) { - $uri = $_SERVER['REQUEST_URI'] ?? '/'; - } - - $this->original_uri = $uri; - - if ($uri . '/' == $this->base_uri) { - $uri .= '/'; - } - - if (0 === strpos($uri, $this->base_uri)) { - $uri = substr($uri, strlen($this->base_uri)); - } - else { - $this->log('<= %s is not a managed URL', $uri); - return false; - } - - // Add some extra-logging for Litmus tests - if (isset($_SERVER['HTTP_X_LITMUS']) || isset($_SERVER['HTTP_X_LITMUS_SECOND'])) { - $this->log('X-Litmus: %s', $_SERVER['HTTP_X_LITMUS'] ?? $_SERVER['HTTP_X_LITMUS_SECOND']); - } - - $method = $_SERVER['REQUEST_METHOD'] ?? null; - - header_remove('Expires'); - header_remove('Pragma'); - header_remove('Cache-Control'); - header('X-Server: KD2', true); - - // Stop and send reply to OPTIONS before anything else - if ($method == 'OPTIONS') { - $this->log('<= OPTIONS'); - $this->http_options(); - return true; - } - - $uri = rawurldecode($uri); - $uri = trim($uri, '/'); - $uri = preg_replace('!/{2,}!', '/', $uri); - - $this->log('<= %s /%s', $method, $uri); - - try { - if (false !== strpos($uri, '..')) { - throw new Exception(sprintf('Invalid URI: "%s"', $uri), 403); - } - - // Call 'http_method' class method - $method = 'http_' . strtolower($method); - - if (!method_exists($this, $method)) { - throw new Exception('Invalid request method', 405); - } - - $out = $this->$method($uri); - - $this->log('=> %d', http_response_code()); - - if (null !== $out) { - $this->log('=> %s', $out); - } - - echo $out; - } - catch (Exception $e) { - $this->error($e); - } - - return true; - } - - function error(Exception $e) - { - $this->log('=> %d - %s', $e->getCode(), $e->getMessage()); - - if ($e->getCode() == 423) { - // http_response_code doesn't know about 423 Locked - header('HTTP/1.1 423 Locked'); - } - else { - http_response_code($e->getCode()); - } - - header('Content-Type: application/xml; charset=utf-8', true); - - printf('%s', htmlspecialchars($e->getMessage(), ENT_XML1)); - } - - /** - * Utility function to create HMAC hash of data, useful for NextCloud and WOPI - */ - static public function hmac(array $data, string $key = '') - { - // Protect against length attacks by pre-hashing data - $data = array_map('sha1', $data); - $data = implode(':', $data); - - return hash_hmac('sha1', $data, sha1($key)); - } - } - - - abstract class AbstractStorage - { - /** - * Return the requested resource - * - * @param string $uri Path to resource - * @return null|array An array containing one of those keys: - * path => Full filesystem path to a local file, it will be streamed directly to the client - * resource => a PHP resource (eg. returned by fopen) that will be streamed directly to the client - * content => a string that will be returned - * or NULL if the resource cannot be returned (404) - * - * It is recommended to use X-SendFile inside this method to make things faster. - * @see https://tn123.org/mod_xsendfile/ - */ - abstract public function get(string $uri): ?array; - - /** - * Return TRUE if the requested resource exists, or FALSE - * - * @param string $uri - * @return bool - */ - abstract public function exists(string $uri): bool; - - /** - * Return the requested resource properties - * - * This method is used for HEAD requests, for PROPFIND, and other places - * - * @param string $uri Path to resource - * @param null|array $requested_properties Properties requested by the client, NULL if all available properties are requested, - * or if specific properties are requested, each item will be a key, - * like 'namespace_url:property_name', eg. 'DAV::getcontentlength' or 'http://owncloud.org/ns:size' - * See Server::BASIC_PROPERTIES for default properties. - * @param int $depth Depth, can be 0 or 1 - * @return null|array An array containing the requested properties, each item must have a key - * of the same form as the requested properties. - * - * This method MUST return NULL if the resource does not exist. - * Or it MUST return an array, where the keys are 'namespace_url:property_name' tuples, - * and the value is the content of the property tag. - */ - abstract public function properties(string $uri, ?array $requested_properties, int $depth): ?array; - - /** - * Store resource properties - * @param string $uri - * @param string $body XML PROPPATCH request, parsing it is up to you - */ - public function setProperties(string $uri, string $body): void - { - // By default, properties are not saved - } - - /** - * Create or replace a resource - * @param string $uri Path to resource - * @param resource $pointer A PHP file resource containing the sent data (note that this might not always be seekable) - * @param null|string $hash A MD5 hash of the resource to store, if it is supplied, - * this method should fail with a 400 code WebDAV exception and not proceed to store the resource. - * @param null|int $mtime The modification timestamp to set on the file - * @return bool Return TRUE if the resource has been created, or FALSE it has just been updated. - */ - abstract public function put(string $uri, $pointer, ?string $hash, ?int $mtime): bool; - - /** - * Delete a resource - * @param string $uri - * @return void - */ - abstract public function delete(string $uri): void; - - /** - * Copy a resource from $uri to $destination - * @param string $uri - * @param string $destination - * @return bool TRUE if the destination has been overwritten - */ - abstract public function copy(string $uri, string $destination): bool; - - /** - * Move (rename) a resource from $uri to $destination - * @param string $uri - * @param string $destination - * @return bool TRUE if the destination has been overwritten - */ - abstract public function move(string $uri, string $destination): bool; - - /** - * Create collection of resources (eg. a directory) - * @param string $uri - * @return void - */ - abstract public function mkcol(string $uri): void; - - /** - * Return a list of resources for target $uri - * - * @param string $uri - * @param array $properties List of properties requested by client (see ::properties) - * @return iterable An array or other iterable (eg. a generator) - * where each item has a key string containing the name of the resource (eg. file name), - * and the value being an array of properties, or NULL. - * - * If the array value IS NULL, then a subsequent call to properties() will be issued for each element. - */ - abstract public function list(string $uri, array $properties): iterable; - - /** - * Lock the requested resource - * @param string $uri Requested resource - * @param string $token Unique token given to the client for this resource - * @param string $scope Locking scope, either ::SHARED_LOCK or ::EXCLUSIVE_LOCK constant - * @return void - */ - public function lock(string $uri, string $token, string $scope): void - { - // By default locking is not implemented - } - - /** - * Unlock the requested resource - * @param string $uri Requested resource - * @param string $token Unique token sent by the client - * @return void - */ - public function unlock(string $uri, string $token): void - { - // By default locking is not implemented - } - - /** - * If $token is supplied, this method MUST return ::SHARED_LOCK or ::EXCLUSIVE_LOCK - * if the resource is locked with this token. If the resource is unlocked, or if it is - * locked with another token, it MUST return NULL. - * - * If $token is left NULL, then this method must return ::EXCLUSIVE_LOCK if there is any - * exclusive lock on the resource. If there are no exclusive locks, but one or more - * shared locks, it MUST return ::SHARED_LOCK. If the resource has no lock, it MUST - * return NULL. - * - * @param string $uri - * @param string|null $token - * @return string|null - */ - public function getLock(string $uri, ?string $token = null): ?string - { - // By default locking is not implemented, so NULL is always returned - return null; - } - } + //__KD2\WebDAV\AbstractStorage__ } namespace NanoKaraDAV @@ -1506,7 +81,18 @@ namespace NanoKaraDAV case 'DAV::resourcetype': return is_dir($target) ? 'collection' : ''; case 'DAV::getlastmodified': - return new \DateTime('@' . filemtime($target)); + if (!$uri && $depth == 0 && is_dir($target)) { + $mtime = self::getDirectoryMTime($target); + } + else { + $mtime = filemtime($target); + } + + if (!$mtime) { + return null; + } + + return new \DateTime('@' . $mtime); case 'DAV::displayname': return basename($target); case 'DAV::ishidden': @@ -1715,6 +301,30 @@ namespace NanoKaraDAV mkdir($target, 0770); } + + static public function getDirectoryMTime(string $path): int + { + $last = 0; + $path = rtrim($path, '/'); + + foreach (glob($path . '/*', GLOB_NOSORT) as $f) { + if (is_dir($f)) { + $m = self::getDirectoryMTime($f); + + if ($m > $last) { + $last = $m; + } + } + + $m = filemtime($f); + + if ($m > $last) { + $last = $m; + } + } + + return $last; + } } class Server extends \KD2\WebDAV\Server