diff --git a/index.php b/index.php new file mode 100644 index 0000000..4b8d679 --- /dev/null +++ b/index.php @@ -0,0 +1,2977 @@ + + + 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; + } + } +} + +namespace NanoKaraDAV +{ + use KD2\WebDAV\AbstractStorage; + use KD2\WebDAV\Exception as WebDAV_Exception; + + class Storage extends AbstractStorage + { + /** + * These file names will be ignored when doing a PUT + * as they are garbage, coming from some OS + */ + const PUT_IGNORE_PATTERN = '!^~(?:lock\.|^\._)|^(?:\.DS_Store|Thumbs\.db|desktop\.ini)$!'; + + protected string $path; + + public function __construct() + { + $this->path = __DIR__ . '/'; + } + + public function list(string $uri, ?array $properties): iterable + { + $dirs = glob($this->path . $uri . '/*', \GLOB_ONLYDIR); + $dirs = array_map('basename', $dirs); + natcasesort($dirs); + + $files = glob($this->path . $uri . '/*'); + $files = array_map('basename', $files); + $files = array_diff($files, $dirs); + + // Remove PHP files from listings + $files = array_filter($files, fn($a) => !preg_match('/\.(?:php\d?|phtml|phps)$/i', $a)); + + if (!$uri) { + $files = array_diff($files, ['webdav.js', 'webdav.css']); + } + + natcasesort($files); + + $files = array_flip(array_merge($dirs, $files)); + $files = array_map(fn($a) => null, $files); + return $files; + } + + public function get(string $uri): ?array + { + $path = $this->path . $uri; + + if (!file_exists($path)) { + return null; + } + + return ['path' => $path]; + } + + public function exists(string $uri): bool + { + return file_exists($this->path . $uri); + } + + public function get_file_property(string $uri, string $name, int $depth) + { + $target = $this->path . $uri; + + switch ($name) { + case 'DAV::getcontentlength': + return is_dir($target) ? null : filesize($target); + case 'DAV::getcontenttype': + // ownCloud app crashes if mimetype is provided for a directory + // https://github.com/owncloud/android/issues/3768 + return is_dir($target) ? null : mime_content_type($target); + case 'DAV::resourcetype': + return is_dir($target) ? 'collection' : ''; + case 'DAV::getlastmodified': + return new \DateTime('@' . filemtime($target)); + case 'DAV::displayname': + return basename($target); + case 'DAV::ishidden': + return basename($target)[0] == '.'; + case 'DAV::getetag': + $hash = filemtime($target) . filesize($target); + return md5($hash . $target); + case 'DAV::lastaccessed': + return new \DateTime('@' . fileatime($target)); + case 'DAV::creationdate': + return new \DateTime('@' . filectime($target)); + case WebDAV::PROP_DIGEST_MD5: + if (!is_file($target)) { + return null; + } + + return md5_file($target); + default: + break; + } + + return null; + } + + public function properties(string $uri, ?array $properties, int $depth): ?array + { + $target = $this->path . $uri; + + if (!file_exists($target)) { + return null; + } + + if (null === $properties) { + $properties = WebDAV::BASIC_PROPERTIES; + } + + $out = []; + + foreach ($properties as $name) { + $v = $this->get_file_property($uri, $name, $depth); + + if (null !== $v) { + $out[$name] = $v; + } + } + + return $out; + } + + public function put(string $uri, $pointer, ?string $hash, ?int $mtime): bool + { + if (preg_match(self::PUT_IGNORE_PATTERN, basename($uri))) { + return false; + } + + $target = $this->path . $uri; + $parent = dirname($target); + + if (is_dir($target)) { + throw new WebDAV_Exception('Target is a directory', 409); + } + + if (!file_exists($parent)) { + mkdir($parent, 0770, true); + } + + $new = !file_exists($target); + $delete = false; + $size = 0; + $quota = disk_free_space($this->path); + + $tmp_file = '.tmp.' . sha1($target); + $out = fopen($tmp_file, 'w'); + + while (!feof($pointer)) { + $bytes = fread($pointer, 8192); + $size += strlen($bytes); + + if ($size > $quota) { + $delete = true; + break; + } + + fwrite($out, $bytes); + } + + fclose($out); + fclose($pointer); + + if ($delete) { + @unlink($tmp_file); + throw new WebDAV_Exception('Your quota is exhausted', 403); + } + elseif ($hash && md5_file($tmp_file) != $hash) { + @unlink($tmp_file); + throw new WebDAV_Exception('The data sent does not match the supplied MD5 hash', 400); + } + else { + rename($tmp_file, $target); + } + + if ($mtime) { + @touch($target, $mtime); + } + + return $new; + } + + public function delete(string $uri): void + { + $target = $this->path . $uri; + + if (!file_exists($target)) { + throw new WebDAV_Exception('Target does not exist', 404); + } + + if (is_dir($target)) { + foreach (glob($target . '/*') as $file) { + $this->delete(substr($file, strlen($this->path))); + } + + rmdir($target); + } + else { + unlink($target); + } + } + + public function copymove(bool $move, string $uri, string $destination): bool + { + $source = $this->path . $uri; + $target = $this->path . $destination; + $parent = dirname($target); + + if (!file_exists($source)) { + throw new WebDAV_Exception('File not found', 404); + } + + $overwritten = file_exists($target); + + if (!is_dir($parent)) { + throw new WebDAV_Exception('Target parent directory does not exist', 409); + } + + if (false === $move) { + $quota = disk_free_space($this->path); + + if (filesize($source) > $quota) { + throw new WebDAV_Exception('Your quota is exhausted', 403); + } + } + + if ($overwritten) { + $this->delete($destination); + } + + $method = $move ? 'rename' : 'copy'; + + if ($method == 'copy' && is_dir($source)) { + @mkdir($target, 0770, true); + + foreach ($iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($source), \RecursiveIteratorIterator::SELF_FIRST) as $item) + { + if ($item->isDir()) { + @mkdir($target . DIRECTORY_SEPARATOR . $iterator->getSubPathname()); + } else { + copy($item, $target . DIRECTORY_SEPARATOR . $iterator->getSubPathname()); + } + } + } + else { + $method($source, $target); + + $this->getResourceProperties($uri)->move($destination); + } + + return $overwritten; + } + + public function copy(string $uri, string $destination): bool + { + return $this->copymove(false, $uri, $destination); + } + + public function move(string $uri, string $destination): bool + { + return $this->copymove(true, $uri, $destination); + } + + public function mkcol(string $uri): void + { + if (!disk_free_space($this->path)) { + throw new WebDAV_Exception('Your quota is exhausted', 403); + } + + $target = $this->path . $uri; + $parent = dirname($target); + + if (file_exists($target)) { + throw new WebDAV_Exception('There is already a file with that name', 405); + } + + if (!file_exists($parent)) { + throw new WebDAV_Exception('The parent directory does not exist', 409); + } + + mkdir($target, 0770); + } + } + + class Server extends \KD2\WebDAV\Server + { + protected function html_directory(string $uri, iterable $list): ?string + { + $out = parent::html_directory($uri, $list); + + if (null !== $out) { + $out = str_replace('', sprintf('', rtrim($this->base_uri, '/')), $out); + } + + return $out; + } + } +} + +namespace { + use NanoKaraDAV\Server; + use NanoKaraDAV\Storage; + + $uri = strtok($_SERVER['REQUEST_URI'], '?'); + $root = substr(__DIR__, strlen($_SERVER['DOCUMENT_ROOT'])); + + if (false !== strpos($uri, '..')) { + http_response_code(404); + die('Invalid URL'); + } + + $relative_uri = ltrim(substr($uri, strlen($root)), '/'); + + if ($relative_uri == 'webdav.js' || $relative_uri == 'webdav.css') { + http_response_code(200); + + if ($relative_uri == 'webdav.js') { + header('Content-Type: text/javascript', true); + } + else { + header('Content-Type: text/css', true); + } + + $seconds_to_cache = 3600 * 24 * 365; + $ts = gmdate("D, d M Y H:i:s", time() + $seconds_to_cache) . " GMT"; + header("Expires: " . $ts); + header("Pragma: cache"); + header("Cache-Control: max-age=" . $seconds_to_cache); + + $fp = fopen(__FILE__, 'r'); + + if ($relative_uri == 'webdav.js') { + fseek($fp, 49434, SEEK_SET); + echo fread($fp, 24077); + } + else { + fseek($fp, 49434 + 24077, SEEK_SET); + echo fread($fp, 6728); + } + + fclose($fp); + + exit; + } + + $dav = new Server; + $dav->setStorage(new Storage); + + $dav->setBaseURI($root); + + if (!$dav->route($uri)) { + http_response_code(404); + die('Invalid URL, sorry'); + } + + exit; + +?> +var css_url = document.currentScript.src.replace(/\/[^\/]+$/, '') + '/webdav.css'; + +const WebDAVNavigator = (url, options) => { + // Microdown + // https://github.com/commit-intl/micro-down + const microdown=function(){function l(n,e,r){return"<"+n+(r?" "+Object.keys(r).map(function(n){return r[n]?n+'="'+(a(r[n])||"")+'"':""}).join(" "):"")+">"+e+""}function c(n,e){return e=n.match(/^[+-]/m)?"ul":"ol",n?"<"+e+">"+n.replace(/(?:[+-]|\d+\.) +(.*)\n?(([ \t].*\n?)*)/g,function(n,e,r){return"
  • "+g(e+"\n"+(t=r||"").replace(new RegExp("^"+(t.match(/^\s+/)||"")[0],"gm"),"").replace(o,c))+"
  • ";var t})+"":""}function e(r,t,u,c){return function(n,e){return n=n.replace(t,u),l(r,c?c(n):n)}}function t(n,u){return f(n,[//g,"\x3c!--$1--\x3e",/^("""|```)(.*)\n((.*\n)*?)\1/gm,function(n,e,r,t){return'"""'===e?l("div",p(t,u),{class:r}):u&&u.preCode?l("pre",l("code",a(t),{class:r})):l("pre",a(t),{class:r})},/(^>.*\n?)+/gm,e("blockquote",/^> ?(.*)$/gm,"$1",r),/((^|\n)\|.+)+/g,e("table",/^.*(\n\|---.*?)?$/gm,function(n,t){return e("tr",/\|(-?)([^|]*)\1(\|$)?/gm,function(n,e,r){return l(e||t?"th":"td",g(r))})(n.slice(0,n.length-(t||"").length))}),o,c,/#\[([^\]]+?)]/g,'',/^(#+) +(.*)(?:$)/gm,function(n,e,r){return l("h"+e.length,g(r))},/^(===+|---+)(?=\s*$)/gm,"
    "],p,u)}var i=this,a=function(n){return n?n.replace(/"/g,""").replace(//g,">"):""},o=/(?:(^|\n)([+-]|\d+\.) +(.*(\n[ \t]+.*)*))+/g,g=function c(n,i){var o=[];return n=(n||"").trim().replace(/`([^`]*)`/g,function(n,e){return"\\"+o.push(l("code",a(e)))}).replace(/[!&]?\[([!&]?\[.*?\)|[^\]]*?)]\((.*?)( .*?)?\)|(\w+:\/\/[$\-.+!*'()/,\w]+)/g,function(n,e,r,t,u){return u?i?n:"\\"+o.push(l("a",u,{href:u})):"&"==n[0]?(e=e.match(/^(.+),(.+),([^ \]]+)( ?.+?)?$/),"\\"+o.push(l("iframe","",{width:e[1],height:e[2],frameborder:e[3],class:e[4],src:r,title:t}))):"\\"+o.push("!"==n[0]?l("img","",{src:r,alt:e,title:t}):l("a",c(e,1),{href:r,title:t}))}),n=function r(n){return n.replace(/\\(\d+)/g,function(n,e){return r(o[Number.parseInt(e)-1])})}(i?n:r(n))},r=function t(n){return f(n,[/([*_]{1,3})((.|\n)+?)\1/g,function(n,e,r){return e=e.length,r=t(r),1"],t)},f=function(n,e,r,t){for(var u,c=0;c typeof lang_strings != 'undefined' && key in lang_strings ? lang_strings[key] : key; + + const common_buttons = ` + `; + + const edit_button = ``; + + const mkdir_dialog = ``; + const mkfile_dialog = ``; + const rename_dialog = ``; + const paste_upload_dialog = `

    Upload this file?

    `; + const edit_dialog = ``; + const markdown_dialog = `
    `; + const delete_dialog = `

    ${_('Confirm delete?')}

    `; + const wopi_dialog = ``; + + const dialog_tpl = `

    %s
    %b
    `; + + const html_tpl = ` + Files +
    `; + + const body_tpl = `

    %title%

    +
    + + + + + +
    + %table%
    `; + + const dir_row_tpl = `%icon%%name%
    `; + const file_row_tpl = `%icon%%name%%size%%modified%`; + + const propfind_tpl = ` + + + + + `; + + const wopi_propfind_tpl = ` + + + + + `; + + const html = (unsafe) => { + return unsafe.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); + }; + + const reqXML = (method, url, body, headers) => { + return req(method, url, body, headers).then((r) => { + if (!r.ok) { + throw new Error(r.status + ' ' + r.statusText); + } + return r.text(); + }).then(str => new window.DOMParser().parseFromString(str, "text/xml")); + }; + + const reqAndReload = (method, url, body, headers) => { + animateLoading(); + req(method, url, body, headers).then(r => { + stopLoading(); + if (!r.ok) { + return r.text().then(t => { + var message; + if (a = t.match(/<((?:\w+:)?message)>(.*)<\/\1>/)) { + message = "\n" + a[2]; + } + + throw new Error(r.status + ' ' + r.statusText + message); }); + } + reloadListing(); + }).catch(e => { + console.error(e); + alert(e); + }); + return false; + }; + + const get_url = async (url) => { + if (temp_object_url) { + window.URL.revokeObjectURL(temp_object_url); + } + + return req('GET', url).then(r => r.blob()).then(blob => { + temp_object_url = window.URL.createObjectURL(blob); + return temp_object_url; + }); + } + + const req = (method, url, body, headers) => { + if (!headers) { + headers = {}; + } + + if (auth_header) { + headers.Authorization = auth_header; + } + + return fetch(url, {method, body, headers}); + }; + + const wopi_init = async () => { + if (!wopi_discovery_url) { + return; + } + + var d = await reqXML('GET', wopi_discovery_url); + + d.querySelectorAll('app').forEach(app => { + var mime = (a = app.getAttribute('name').match(/^.*\/.*$/)) ? a[0] : null; + wopi_mimes[mime] = {}; + + app.querySelectorAll('action').forEach(action => { + var ext = action.getAttribute('ext').toUpperCase(); + var url = action.getAttribute('urlsrc').replace(/<[^>]*&>/g, ''); + var name = action.getAttribute('name'); + + if (mime) { + wopi_mimes[mime][name] = url; + } + else { + if (!wopi_extensions.hasOwnProperty(ext)) { + wopi_extensions[ext] = {}; + } + + wopi_extensions[ext][name] = url; + } + }); + }); + + reloadListing(); + }; + + const wopi_getEditURL = (name, mime) => { + var file_ext = name.replace(/^.*\.(\w+)$/, '$1').toUpperCase(); + + if (wopi_mimes.hasOwnProperty(mime) && wopi_mimes[mime].hasOwnProperty('edit')) { + return wopi_mimes[mime].edit; + } + else if (wopi_extensions.hasOwnProperty(file_ext) && wopi_extensions[file_ext].hasOwnProperty('edit')) { + return wopi_extensions[file_ext].edit; + } + + return null; + }; + + const wopi_getViewURL = (name, mime) => { + var file_ext = name.replace(/^.*\.(\w+)$/, '$1').toUpperCase(); + + if (wopi_mimes.hasOwnProperty(mime) && wopi_mimes[mime].hasOwnProperty('view')) { + return wopi_mimes[mime].view; + } + else if (wopi_extensions.hasOwnProperty(file_ext) && wopi_extensions[file_ext].hasOwnProperty('view')) { + return wopi_extensions[file_ext].view; + } + + return wopi_getEditURL(name, mime); + }; + + const wopi_open = async (document_url, wopi_url) => { + var properties = await reqXML('PROPFIND', document_url, wopi_propfind_tpl, {'Depth': '0'}); + var src = (a = properties.querySelector('file-url')) ? a.textContent : null; + var token = (a = properties.querySelector('token')) ? a.textContent : null; + var token_ttl = (a = properties.querySelector('token-ttl')) ? a.textContent : +(new Date(Date.now() + 3600 * 1000)); + + if (!src || !token) { + alert('Cannot open document: WebDAV server did not return WOPI properties'); + } + + wopi_url += '&WOPISrc=' + encodeURIComponent(src); + + openDialog(wopi_dialog, false); + $('dialog').className = 'preview'; + + var f = $('dialog form'); + f.target = 'wopi_frame'; + f.action = wopi_url; + f.method = 'post'; + f.insertAdjacentHTML('beforeend', ``); + f.submit(); + }; + + const template = (tpl, params) => { + return tpl.replace(/%(\w+)%/g, (a, b) => { + return params[b]; + }); + }; + + const openDialog = (html, ok_btn = true) => { + var tpl = dialog_tpl.replace(/%b/, ok_btn ? `

    ` : ''); + $('body').classList.add('dialog'); + $('body').insertAdjacentHTML('beforeend', tpl.replace(/%s/, html)); + $('.close input').onclick = closeDialog; + evt = window.addEventListener('keyup', (e) => { + if (e.key != 'Escape') return; + closeDialog(); + return false; + }); + if (a = $('dialog form input, dialog form textarea')) a.focus(); + }; + + const closeDialog = (e) => { + $('body').classList.remove('dialog'); + if (!$('dialog')) return; + $('dialog').remove(); + window.removeEventListener('keyup', evt); + evt = null; + }; + + const download = async (name, url) => { + var url = await get_url(url); + const a = document.createElement('a'); + a.style.display = 'none'; + a.href = url; + a.download = name; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + a.remove(); + }; + + const preview = (type, url) => { + if (type.match(/^image\//)) { + openDialog(``, false); + } + else if (type.match(/^audio\//)) { + openDialog(`