diff --git a/Makefile b/Makefile index 94b87fa..d884e92 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,9 @@ KD2FW_URL := https://fossil.kd2.org/kd2fw/doc/trunk/src/lib/KD2/ deps: @-mkdir -p lib/KD2/WebDAV + @-mkdir -p lib/KD2/HTTP + wget -O lib/KD2/HTTP/Server.php '${KD2FW_URL}HTTP/Server.php' + wget -O lib/KD2/WebDAV/Exception.php '${KD2FW_URL}WebDAV/Exception.php' 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 diff --git a/README.md b/README.md index 252ba01..30bc7f0 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ If you drop the [`index.php`](https://fossil.kd2.org/picodav/doc/trunk/index.php) file in a directory of your web-server, it will make the contents of this directory available via WebDAV, and will also provide a nice web UI to manage the files, using [WebDAV Manager.js](https://fossil.kd2.org/webdav-manager/). -![Web UI screenshot](https://raw.githubusercontent.com/kd2org/webdav-manager.js/main/scr_desktop.png) +![Web UI screenshot](scr.webp) -* Single-file WebDAV server! Only 85 KB! +* Single-file WebDAV server! Only 110 KB! * No database! * Very fast and lightweight! * Compatible with tons of apps! diff --git a/index.php b/index.php index fb6e1b0..6f44439 100644 --- a/index.php +++ b/index.php @@ -1,10 +1,242 @@ $size) { + throw new \LogicException('End range cannot be satisfied', 416); + } + } + elseif ($allow_gzip + && isset($_SERVER['HTTP_ACCEPT_ENCODING']) + && false !== strpos($_SERVER['HTTP_ACCEPT_ENCODING'], 'gzip') + && isset($size, $name) + // Don't compress if size is larger than 8 MiB + && $size < 8*1024*1024 + // Don't compress already compressed content + && !preg_match('/\.(?:cbz|cbr|cb7|mp4|m4a|zip|docx|xlsx|pptx|ods|odt|odp|7z|gz|bz2|lzma|lz|xz|apk|dmg|jar|rar|webm|ogg|mp3|ogm|flac|ogv|mkv|avi)$/i', $name)) { + $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'); + + // Clean buffer, just in case + if (@ob_get_length()) { + @ob_clean(); + } + + if (isset($content)) { + $length = strlen($content); + + if ($start || $end) { + if (null !== $end && $end > $length) { + header('Content-Range: bytes */' . $length, true); + throw new \LogicException('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)); + $content = substr($content, $start, $end - $start); + $length = $end - $start; + } + + if ($gzip) { + $content = gzencode($content, 9); + $length = strlen($content); + } + + if (!$disable_length) { + header('Content-Length: ' . $length, true); + header('Accept-Ranges: bytes'); + } + + echo $content; + return; + } + + if (isset($path)) { + $resource = fopen($path, 'rb'); + } + + $seek = fseek($resource, 0, SEEK_END); + + if ($seek === 0) { + $length = ftell($resource); + fseek($resource, 0, SEEK_SET); + } + + http_response_code(200); + + if (($start || $end) && $seek === 0) { + if (null !== $end && $end > $length) { + header('Content-Range: bytes */' . $length, true); + throw new \LogicException('End range cannot be satisfied', 416); + } + + if ($start === null) { + $start = $length - $end; + $end = $start + $end; + } + elseif ($end === null) { + $end = $length; + } + + fseek($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($path)) { + $end = $length = filesize($path); + } + + if ($gzip) { + $gzip = deflate_init(ZLIB_ENCODING_GZIP); + + $fp = fopen('php://temp', 'wb'); + + while (!feof($resource)) { + fwrite($fp, deflate_add($gzip, fread($resource, 8192), ZLIB_NO_FLUSH)); + } + + fwrite($fp, deflate_add($gzip, '', ZLIB_FINISH)); + $length = ftell($fp); + rewind($fp); + unset($resource); + + $resource = $fp; + unset($fp); + } + + if (null !== $length && !$disable_length) { + header('Content-Length: ' . $length, true); + header('Accept-Ranges: bytes'); + } + + $block_size = 8192*4; + + while (!feof($resource) && ($end === null || $end > 0)) { + $l = $end !== null ? min($block_size, $end) : $block_size; + + echo fread($resource, $l); + flush(); + + if (null !== $end) { + $end -= $block_size; + } + } + } + } + +} + namespace KD2\WebDAV { class Exception extends \RuntimeException {} + + + use KD2\HTTP\Server as HTTP_Server; + class Server { // List of basic DAV properties that you should return if $requested_properties is NULL @@ -25,8 +257,6 @@ namespace KD2\WebDAV 'DAV::quota-available-bytes', ]; - const PROP_NAMESPACE_MICROSOFT = 'urn:schemas-microsoft-com:'; - const MODIFICATION_TIME_PROPERTIES = [ 'DAV::lastmodified', 'DAV::creationdate', @@ -44,6 +274,8 @@ namespace KD2\WebDAV const SHARED_LOCK = 'shared'; const EXCLUSIVE_LOCK = 'exclusive'; + const DATE_RFC7231 = "D, d M Y H:i:s \\G\\M\\T"; + protected bool $enable_gzip = true; protected string $base_uri; @@ -54,6 +286,24 @@ namespace KD2\WebDAV protected AbstractStorage $storage; + protected array $headers; + + public function __construct() + { + $this->headers = apache_request_headers(); + $this->headers = array_change_key_case($this->headers, \CASE_LOWER); + } + + public function getHeader(string $name): ?string + { + return $this->headers[strtolower($name)] ?? null; + } + + public function setHeader(string $name, string $value): void + { + $this->headers[strtolower($name)] = $value; + } + public function setStorage(AbstractStorage $storage) { $this->storage = $storage; @@ -183,12 +433,16 @@ namespace KD2\WebDAV public function http_put(string $uri): ?string { - if (!empty($_SERVER['HTTP_CONTENT_TYPE']) && !strncmp($_SERVER['HTTP_CONTENT_TYPE'], 'multipart/', 10)) { + $content_type = $this->getHeader('Content-Type'); + + if ($content_type && !strncmp($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')) { + $content_encoding = $this->getHeader('Content-Encoding'); + + if ($content_encoding) { + if (false !== strpos($content_encoding, 'gzip')) { // Might be supported later? throw new Exception('Content Encoding is not supported', 501); } @@ -197,12 +451,12 @@ namespace KD2\WebDAV } } - if (!empty($_SERVER['HTTP_CONTENT_RANGE'])) { + if ($this->getHeader('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'])) { + if ($this->getHeader('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); } @@ -211,14 +465,14 @@ namespace KD2\WebDAV // 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'])); + if ($hash = $this->getHeader('Content-MD5')) { + $hash = bin2hex(base64_decode($hash)); $hash_algo = 'MD5'; } // Support for ownCloud/NextCloud checksum // https://github.com/owncloud-archive/documentation/issues/2964 - elseif (!empty($_SERVER['HTTP_OC_CHECKSUM']) - && preg_match('/MD5:[a-f0-9]{32}|SHA1:[a-f0-9]{40}/', $_SERVER['HTTP_OC_CHECKSUM'], $match)) { + elseif (($checksum = $this->getHeader('OC-Checksum')) + && preg_match('/MD5:[a-f0-9]{32}|SHA1:[a-f0-9]{40}/', $checksum, $match)) { $hash_algo = strtok($match[0], ':'); $hash = strtok(''); } @@ -227,8 +481,8 @@ namespace KD2\WebDAV $this->checkLock($uri); - if (!empty($_SERVER['HTTP_IF_MATCH'])) { - $etag = trim($_SERVER['HTTP_IF_MATCH'], '" '); + if ($match = $this->getHeader('If-Match')) { + $etag = trim($match, '" '); $prop = $this->storage->propfind($uri, ['DAV::getetag'], 0); if (!empty($prop['DAV::getetag']) && $prop['DAV::getetag'] != $etag) { @@ -236,9 +490,19 @@ namespace KD2\WebDAV } } + if ($date = $this->getHeader('If-Unmodified-Since')) { + $date = \DateTime::createFromFormat(self::DATE_RFC7231, $date); + $prop = $this->storage->propfind($uri, ['DAV::getlastmodified'], 0); + if ($date && $prop && $prop instanceof \DateTimeInterface) { + if ($date != $prop) { + throw new Exception('File was modified since "If-Unmodified-Since" condition', 412); + } + } + } + // Specific to NextCloud/ownCloud, to allow setting file mtime // This expects a UNIX timestamp - $mtime = (int)($_SERVER['HTTP_X_OC_MTIME'] ?? 0) ?: null; + $mtime = intval($this->getHeader('X-OC-MTime')) ?: null; $this->extendExecutionTime(); @@ -246,7 +510,7 @@ namespace KD2\WebDAV // mod_fcgid <= 2.3.9 doesn't handle chunked transfer encoding for PUT requests // see https://github.com/kd2org/picodav/issues/6 - if (strstr($_SERVER['HTTP_TRANSFER_ENCODING'] ?? '', 'chunked') && PHP_SAPI == 'fpm-fcgi') { + if (strstr($this->getHeader('Transfer-Encoding') ?? '', 'chunked') && PHP_SAPI == 'fpm-fcgi') { // We can't seek here // see https://github.com/php/php-src/issues/9441 $l = strlen(fread($stream, 1)); @@ -307,7 +571,7 @@ namespace KD2\WebDAV if (isset($props['DAV::getlastmodified']) && $props['DAV::getlastmodified'] instanceof \DateTimeInterface) { - header(sprintf('Last-Modified: %s', $props['DAV::getlastmodified']->format(\DATE_RFC7231))); + header(sprintf('Last-Modified: %s', $props['DAV::getlastmodified']->format(self::DATE_RFC7231))); } if (!empty($props['DAV::getetag'])) { @@ -346,7 +610,6 @@ namespace KD2\WebDAV $uri = $this->_prefix($uri); $is_collection = !empty($props['DAV::resourcetype']) && $props['DAV::resourcetype'] == 'collection'; - $out = ''; if ($is_collection) { $list = $this->storage->list($uri, self::BASIC_PROPERTIES); @@ -377,163 +640,29 @@ namespace KD2\WebDAV return null; } - if (!isset($file['content']) && !isset($file['resource']) && !isset($file['path'])) { - throw new \RuntimeException('Invalid file array returned by ::get(): ' . print_r($file, true)); + try { + HTTP_Server::serveFile( + $file['content'] ?? null, + $file['path'] ?? null, + $file['resource'] ?? null, + [ + 'gzip' => $this->enable_gzip, + 'ranges' => true, + 'xsendfile' => false, + 'name' => $uri, + 'size' => $props['DAV::getcontentlength'], + ] + ); } - - $this->extendExecutionTime(); - - $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); + catch (\LogicException $e) { + throw new Exception($e->getMessage(), $e->getCode()); } - elseif ($this->enable_gzip - && isset($_SERVER['HTTP_ACCEPT_ENCODING']) - && false !== strpos($_SERVER['HTTP_ACCEPT_ENCODING'], 'gzip') - && isset($props['DAV::getcontentlength']) - // Don't compress if size is larger than 8 MiB - && $props['DAV::getcontentlength'] < 8*1024*1024 - // Don't compress already compressed content - && !preg_match('/\.(?:cbz|cbr|cb7|mp4|m4a|zip|docx|xlsx|pptx|ods|odt|odp|7z|gz|bz2|lzma|lz|xz|apk|dmg|jar|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); - - $fp = fopen('php://temp', '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); - } - - $block_size = 8192*4; - - while (!feof($file['resource']) && ($end === null || $end > 0)) { - $l = $end !== null ? min($block_size, $end) : $block_size; - - echo fread($file['resource'], $l); - flush(); - - if (null !== $end) { - $end -= $block_size; + finally { + if (isset($file['resource'])) { + fclose($file['resource']); } } - fclose($file['resource']); - return null; } @@ -687,7 +816,7 @@ namespace KD2\WebDAV throw new Exception('Invalid XML', 400); } - $this->log('Requested depth: %s', $depth); + $this->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 @@ -727,7 +856,7 @@ namespace KD2\WebDAV $properties = $properties ?? $this->storage->propfind($path, $requested_keys, 0); if (!$properties) { - $this->log('!!! Cannot find "%s"', $path); + $this->log('!! Cannot find "%s"', $path); continue; } @@ -807,17 +936,18 @@ namespace KD2\WebDAV } $pos = strrpos($name, ':'); - $ns = substr($name, 0, strrpos($name, ':')); - $tag_name = substr($name, strrpos($name, ':') + 1); + $ns = substr($name, 0, $pos); + $tag_name = substr($name, $pos + 1); $alias = $root_namespaces[$ns] ?? null; $attributes = ''; - // The ownCloud Android app doesn't like formatted dates, it makes it crash. + // The ownCloud/OpenCloud Android app doesn't like formatted dates, it makes it crash. // so force it to have a timestamp + // see https://github.com/opencloud-eu/android/issues/74 if ($name == 'DAV::creationdate' && ($value instanceof \DateTimeInterface) - && false !== stripos($_SERVER['HTTP_USER_AGENT'] ?? '', 'owncloud')) { + && false !== preg_match('/owncloud|opencloud/', $_SERVER['HTTP_USER_AGENT'] ?? '')) { $value = $value->getTimestamp(); } // ownCloud app crashes if mimetype is provided for a directory @@ -837,7 +967,7 @@ namespace KD2\WebDAV // Change value to GMT $value = clone $value; $value->setTimezone(new \DateTimeZone('GMT')); - $value = $value->format(DATE_RFC7231); + $value = $value->format(self::DATE_RFC7231); } elseif (is_array($value)) { $attributes = $value['attributes'] ?? ''; @@ -874,8 +1004,8 @@ namespace KD2\WebDAV foreach ($missing_properties as $name) { $pos = strrpos($name, ':'); - $ns = substr($name, 0, strrpos($name, ':')); - $name = substr($name, strrpos($name, ':') + 1); + $ns = substr($name, 0, $pos); + $name = substr($name, $pos + 1); $alias = $root_namespaces[$ns] ?? null; // NULL namespace, see Litmus FAQ for propnullns @@ -909,7 +1039,7 @@ namespace KD2\WebDAV $xml = @simplexml_load_string($body); if (false === $xml) { - throw new WebDAV_Exception('Invalid XML', 400); + throw new Exception('Invalid XML', 400); } $_ns = null; @@ -1003,11 +1133,11 @@ namespace KD2\WebDAV $ts = '@' . $ts; } - $set_time = new \DateTime($value['content']); + $set_time = new \DateTime($ts); $set_time_name = $name; } - $prefix .= sprintf(">\n\n %s\n", htmlspecialchars($url, ENT_XML1)); + $prefix .= sprintf(">\n\n %s\n", htmlspecialchars($uri, ENT_XML1)); // http_response_code doesn't know the 207 status code header('HTTP/1.1 207 Multi-Status', true); @@ -1023,7 +1153,7 @@ namespace KD2\WebDAV $return = $this->storage->proppatch($uri, $properties); - if ($set_time && $this->touch($uri, $set_time)) { + if ($set_time && $this->storage->touch($uri, $set_time)) { $return[$set_time_name] = 200; } @@ -1072,7 +1202,6 @@ namespace KD2\WebDAV throw new Exception('Invalid If header', 400); } - $info = null; $ns = 'D'; $scope = self::EXCLUSIVE_LOCK; @@ -1086,7 +1215,7 @@ namespace KD2\WebDAV throw new Exception('Cannot acquire another lock, resource is locked for exclusive use', 423); } - if ($locked_scope && $token = $this->getLockToken()) { + if ($locked_scope) { $token = $this->getLockToken(); if (!$token) { @@ -1254,6 +1383,19 @@ namespace KD2\WebDAV } } + public function validateURI(string $uri): string + { + $uri = preg_replace('!/{2,}!', '/', $uri); + $uri = str_replace('\\', '/', $uri); + + // Protect against path traversal + if (preg_match('!(?:^|/)\.\.(?:$|/)!', $uri)) { + throw new Exception(sprintf('Invalid URI: "%s"', $uri), 403); + } + + return $uri; + } + protected function getURI(string $source): string { $uri = parse_url($source, PHP_URL_PATH); @@ -1269,11 +1411,7 @@ namespace KD2\WebDAV 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 = $this->validateURI($uri); $uri = substr($uri, strlen($this->base_uri)); $uri = $this->_prefix($uri); @@ -1297,7 +1435,7 @@ namespace KD2\WebDAV $uri = substr($uri, strlen($this->base_uri)); } else { - $this->log('<= %s is not a managed URL (%s)', $uri, $this->base_uri); + $this->log('=> %s is not a managed URL (%s)', $uri, $this->base_uri); return false; } @@ -1327,9 +1465,7 @@ namespace KD2\WebDAV $this->log('<= %s /%s', $method, $uri); try { - if (false !== strpos($uri, '..')) { - throw new Exception(sprintf('Invalid URI: "%s"', $uri), 403); - } + $uri = $this->validateURI($uri); // Call 'http_method' class method $method = 'http_' . strtolower($method); @@ -1386,8 +1522,13 @@ namespace KD2\WebDAV abstract class AbstractStorage { + const PUT_IGNORE_PATTERN = '!^~|~$|^~.*tmp$|^Thumbs\.db$|^desktop\.ini$|\.unison$|^My Saved Places' + . '|^\.(lock\.|_|DS_Store|DocumentRevisions|directory|Trash|Temp|fseventsd|apdisk|synkron|sync|symform|fuse|nfs)!i'; + abstract public function get(string $uri): ?array; + abstract public function fetch(string $uri): ?string; + abstract public function exists(string $uri): bool; abstract public function propfind(string $uri, ?array $requested_properties, int $depth): ?array; @@ -1395,6 +1536,13 @@ namespace KD2\WebDAV public function proppatch(string $uri, array $properties): array { // By default, properties are not saved + $out = []; + + foreach ($properties as $key => $value) { + $out[$key] = 200; + } + + return $out; } abstract public function put(string $uri, $pointer, ?string $hash_algo, ?string $hash): bool; @@ -1409,7 +1557,7 @@ namespace KD2\WebDAV abstract public function list(string $uri, array $properties): iterable; - abstract public function touch(string $uri, \DateTimeInterface $timestamp): bool; + abstract public function touch(string $uri, \DateTimeInterface $datetime): bool; public function lock(string $uri, string $token, string $scope): void { @@ -1482,10 +1630,28 @@ namespace PicoDAV return true; } - static protected function glob(string $path, string $pattern = '', int $flags = 0): array + static protected function glob(string $path, int $flags = 0): array { - $path = preg_replace('/[\*\?\[\]]/', '\\\\$0', $path); - return glob($path . $pattern, $flags); + if (file_exists($path) && !is_dir($path)) { + return [$path]; + } + + $dir = dir($path); + $out = []; + + while ($file = $dir->read()) { + if ($file === '.' || $file === '..') { + continue; + } + + if (($flags & GLOB_ONLYDIR) && !is_dir($path . '/' . $file)) { + continue; + } + + $out[] = $path . '/' . $file; + } + + return $out; } public function canRead(string $uri): bool @@ -1577,12 +1743,12 @@ namespace PicoDAV //throw new WebDAV_Exception('Access forbidden', 403); } - $dirs = self::glob($this->path . $uri, '/*', \GLOB_ONLYDIR); + $dirs = self::glob($this->path . $uri, \GLOB_ONLYDIR); $dirs = array_map('basename', $dirs); $dirs = array_filter($dirs, fn($a) => $this->canRead(ltrim($uri . '/' . $a, '/') . '/')); natcasesort($dirs); - $files = self::glob($this->path . $uri, '/*'); + $files = self::glob($this->path . $uri); $files = array_map('basename', $files); $files = array_diff($files, $dirs); @@ -1612,6 +1778,23 @@ namespace PicoDAV return ['path' => $path]; } + public function fetch(string $uri): ?string + { + $r = $this->get($uri); + + if (!$r) { + return null; + } + + $r = file_get_contents($r['path']); + + if ($r === false) { + return null; + } + + return $r; + } + public function exists(string $uri): bool { return file_exists($this->path . $uri); @@ -1634,7 +1817,7 @@ namespace PicoDAV return is_dir($target) ? 'collection' : ''; case 'DAV::getlastmodified': $mtime = filemtime($target); - + if (!$mtime) { return null; } @@ -1790,7 +1973,7 @@ namespace PicoDAV } if (is_dir($target)) { - foreach (self::glob($target, '/*') as $file) { + foreach (self::glob($target) as $file) { $this->delete(substr($file, strlen($this->path))); } @@ -1851,8 +2034,6 @@ namespace PicoDAV } else { $method($source, $target); - - $this->getResourceProperties($uri)->move($destination); } return $overwritten; @@ -1980,6 +2161,14 @@ namespace { if (!empty($_SERVER['SERVER_SOFTWARE']) && stristr($_SERVER['SERVER_SOFTWARE'], 'apache') && !file_exists($self_dir . '/.htaccess')) { file_put_contents($self_dir . '/.htaccess', str_replace('index.php', basename($self), 'DirectoryIndex disabled + + = 2.4.13> + # Force Apache to pass the Authorization header to PHP + # See https://github.com/kd2org/picodav/issues/14 + CGIPassAuth On + + + RedirectMatch 404 \\.picodav\\.ini RewriteEngine On @@ -2014,12 +2203,12 @@ RewriteRule ^.*$ /index.php [END] $fp = fopen(__FILE__, 'r'); if ($relative_uri == '.webdav/webdav.js') { - fseek($fp, 55024, SEEK_SET); - echo fread($fp, 27891); + fseek($fp, 58962, SEEK_SET); + echo fread($fp, 33651); } else { - fseek($fp, 55024 + 27891, SEEK_SET); - echo fread($fp, 7004); + fseek($fp, 58962 + 33651, SEEK_SET); + echo fread($fp, 16767); } fclose($fp); @@ -2110,49 +2299,74 @@ const WebDAVNavigator = (url, options) => { 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 rename_button = ``; - const delete_button = ``; + const rename_button = ``; + const delete_button = ``; - const edit_button = ``; + 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 markdown_dialog = `
    `; const delete_dialog = `

    ${_('Confirm delete?')}

    `; - const wopi_dialog = ``; const dialog_tpl = `

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

    %title%

    -
    - - + const body_tpl = ` +
    +
    + + +
    - %table%
    `; + + + + + + + + + + + %table% +
    `; - const create_buttons = ` - - - `; + const create_buttons = ` + + + `; - const dir_row_tpl = `%icon%%name%%modified%
    `; - const file_row_tpl = `%icon%%name%%size_bytes%%modified%`; + const dir_row_tpl = ` + + %thumb% %name% + %modified% +
    + `; + + const file_row_tpl = ` + + %thumb% %name% + %size_bytes% + %modified% + + `; + + const icon_tpl = `%icon%`; + const root_url = url.replace(/(?`; const propfind_tpl = '<'+ `?xml version="1.0" encoding="UTF-8"?> @@ -2164,7 +2378,7 @@ const WebDAVNavigator = (url, options) => { const wopi_propfind_tpl = '<' + `?xml version="1.0" encoding="UTF-8"?> - + `; @@ -2181,21 +2395,27 @@ const WebDAVNavigator = (url, options) => { }).then(str => new window.DOMParser().parseFromString(str, "text/xml")); }; + const reqHandler = (r, c) => { + 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); + }); + } + window.setTimeout(c, 200); + return r; + }; + const reqAndReload = (method, url, body, headers) => { animateLoading(); - req(method, url, body, headers).then(r => { + req(method, url, body, headers).then(r => reqHandler(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 => { + })).catch(e => { console.error(e); alert(e); }); @@ -2242,6 +2462,32 @@ const WebDAVNavigator = (url, options) => { return p; }; + const uploadFiles = (files) => { + animateLoading(); + + (async () => { + for (var i = 0; i < files.length; i++) { + var f = files[i]; + await reqOrError('PUT', current_url + encodeURIComponent(f.name), f); + } + + window.setTimeout(() => { + stopLoading(); + reloadListing(); + }, 500); + })(); + }; + + const reqOrError = (method, url, body) => { + return req(method, url, body).then(reqHandler).catch(e => { + console.error(e); + alert(e); + stopLoading(); + reloadListing(); + throw e; + }); + } + const get_url = async (url) => { var progress = (e) => { var p = $('progress'); @@ -2322,7 +2568,7 @@ const WebDAVNavigator = (url, options) => { 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 src = (a = properties.querySelector('wopi-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)); @@ -2409,14 +2655,18 @@ const WebDAVNavigator = (url, options) => { window.onbeforeunload = null; }; - const download_all = async () => { + const download_selected = async () => { + var items = document.querySelectorAll('tbody input[type=checkbox]:checked'); for (var i = 0; i < items.length; i++) { - var item = items[i]; - if (item.is_dir) { - continue; + var input = items[i]; + var row = input.parentNode.parentNode; + + // Skip directories + if (!row.dataset.mime) { + return; } - await download(item.name, item.size, item.uri) + await download(row.dataset.name, row.dataset.size, row.querySelector('th a').href); } }; @@ -2476,7 +2726,7 @@ const WebDAVNavigator = (url, options) => { return _('Yesterday, %s').replace(/%s/, date.toLocaleTimeString()); } - return date.toLocaleString(); + return date.toLocaleString([], {year: 'numeric', month: 'numeric', day: 'numeric'}); }; const openListing = (uri, push) => { @@ -2538,12 +2788,13 @@ const WebDAVNavigator = (url, options) => { const buildListing = (uri, xml) => { uri = normalizeURL(uri); - items = [[], []]; + items = []; var title = null; var root_permissions = null; xml.querySelectorAll('response').forEach((node) => { - var item_uri = normalizeURL(node.querySelector('href').textContent); + var path = node.querySelector('href').textContent; + var item_uri = normalizeURL(path); var props = null; node.querySelectorAll('propstat').forEach((propstat) => { @@ -2570,10 +2821,11 @@ const WebDAVNavigator = (url, options) => { } var is_dir = node.querySelector('resourcetype collection') ? true : false; - var index = sort_order == 'name' && is_dir ? 0 : 1; + var index = is_dir ? 0 : 1; - items[index].push({ + items.push({ 'uri': item_uri, + 'path': item_uri.substring(base_url.length), 'name': name, 'size': !is_dir && (prop = node.querySelector('getcontentlength')) ? parseInt(prop.textContent, 10) : null, 'mime': !is_dir && (prop = node.querySelector('getcontenttype')) ? prop.textContent : null, @@ -2583,39 +2835,35 @@ const WebDAVNavigator = (url, options) => { }); }); - if (sort_order == 'name') { - items[0].sort((a, b) => a.name.localeCompare(b.name)); - } - - items[1].sort((a, b) => { - if (sort_order == 'date') { - return b.modified - a.modified; + items.sort((a, b) => { + if (sort_order === 'date') { + return a.modified - b.modified; } - else if (sort_order == 'size') { - return b.size - a.size; + else if (sort_order === 'size') { + return a.size - b.size; } else { return a.name.localeCompare(b.name); } }); - if (sort_order == 'name') { + if (sort_order !== 'date') { // Sort with directories first - items = items[0].concat(items[1]); - } - else { - items = items[1]; + items.sort((a, b) => b.is_dir - a.is_dir); } + if (sort_order_desc) { + items = items.reverse(); + } var table = ''; var parent = uri.replace(/\/+$/, '').split('/').slice(0, -1).join('/') + '/'; if (parent.length >= base_url.length) { - table += template(dir_row_tpl, {'name': _('Back'), 'uri': parent, 'icon': '↲'}); + table += template(dir_row_tpl, {'name': _('Back'), 'uri': parent, 'class': 'parent', 'thumb': template(icon_tpl, {})}); } else { - title = 'My files'; + title = _('My files'); } items.forEach(item => { @@ -2627,32 +2875,93 @@ const WebDAVNavigator = (url, options) => { var row = item.is_dir ? dir_row_tpl : file_row_tpl; item.size_bytes = item.size !== null ? formatBytes(item.size).replace(/ /g, ' ') : null; - item.icon = item.is_dir ? '📁' : (item.uri.indexOf('.') > 0 ? item.uri.replace(/^.*\.(\w+)$/, '$1').toUpperCase() : ''); + + if (!item.is_dir && (pos = item.uri.lastIndexOf('.'))) { + var ext = item.uri.substr(pos+1).toUpperCase(); + + if (ext.length > 4) { + ext = ''; + } + } + + item.icon = ext || ''; + item.class = item.is_dir ? 'dir' : 'file'; item.modified = item.modified !== null ? formatDate(item.modified) : null; item.name = html(item.name); + + if (item.mime && item.mime.match(/^image\//) && options.nc_thumbnails) { + item.thumb = template(image_thumb_tpl, item); + } + else { + item.thumb = template(icon_tpl, item); + } + table += template(row, item); }); document.title = title; document.querySelector('main').innerHTML = template(body_tpl, {'title': html(document.title), 'base_url': base_url, 'table': table}); - var select = $('.sortorder'); - select.value = sort_order; - select.onchange = () => { - sort_order = select.value; - window.localStorage.setItem('sort_order', sort_order); + var parent_check = document.querySelector('tbody tr.parent .check'); + + if (parent_check) { + parent_check.innerHTML = ''; + } + + var column = document.querySelector('thead td[data-sort="' + sort_order + '"]').className += ' selected ' + (sort_order_desc ? 'desc' : 'asc'); + + document.querySelectorAll('thead td[data-sort] button').forEach(elm => elm.onclick = (e) => { + var new_sort_order = e.target.parentNode.dataset.sort; + + if (sort_order == new_sort_order) { + sort_order_desc = !sort_order_desc; + } + + sort_order = new_sort_order; + + window.localStorage.setItem('sort_order', new_sort_order); + window.localStorage.setItem('sort_order_desc', sort_order_desc ? '1' : '0'); reloadListing(); + }); + + document.querySelector('thead td.check input').onchange = (e) => { + document.querySelectorAll('tbody td.check input').forEach(i => i.checked = e.target.checked); }; if (!items.length) { - $('.download_all').disabled = true; + $('div.buttons .download').disabled = true; } else { - $('.download_all').onclick = download_all; + $('div.buttons .download').onclick = download_selected; } + $('div.buttons .delete').onclick = () => { + var l = document.querySelectorAll('input[name=delete]:checked'); + + if (!l.length) { + alert(_('No file is selected')); + return; + } + + openDialog(delete_dialog); + document.forms[0].onsubmit = () => { + animateLoading(); + + for (var i = 0; i < l.length; i++) { + reqOrError('DELETE', l[i].value); + } + + // Don't reload too fast + window.setTimeout(() => { + stopLoading(); + reloadListing(); + }, 500); + }; + + }; + if (!root_permissions || root_permissions.indexOf('C') != -1 || root_permissions.indexOf('K') != -1) { - $('.upload').insertAdjacentHTML('afterbegin', create_buttons); + $('.buttons').insertAdjacentHTML('beforeend', create_buttons); $('.mkdir').onclick = () => { openDialog(mkdir_dialog); @@ -2687,24 +2996,19 @@ const WebDAVNavigator = (url, options) => { var fi = $('input[type=file]'); - $('.uploadfile').onclick = () => fi.click(); + $('.upload').onclick = () => fi.click(); fi.onchange = () => { if (!fi.files.length) return; - var body = new Blob(fi.files); - var name = fi.files[0].name; - - name = encodeURIComponent(name); - - return reqAndReload('PUT', current_url + name, body); + uploadFiles(fi.files); }; } - Array.from($('table').rows).forEach((tr) => { + document.querySelectorAll('table tbody tr').forEach(tr => { var $$ = (a) => tr.querySelector(a); var file_url = $$('a').href; - var file_name = $$('a').innerText; + var file_name = tr.dataset.name; var dir = $$('[colspan]'); var mime = !dir ? tr.getAttribute('data-mime') : 'dir'; var buttons = $$('td.buttons div'); @@ -2778,16 +3082,26 @@ const WebDAVNavigator = (url, options) => { } var view_url, edit_url; + var allow_preview = false; + + if (mime.match(PREVIEW_TYPES) + || file_name.match(PREVIEW_EXTENSIONS)) { + allow_preview = true; + } // Don't preview PDF in mobile - if (mime.match(PREVIEW_TYPES) - && !(mime == 'application/pdf' && window.navigator.userAgent.match(/Mobi|Tablet|Android|iPad|iPhone/))) { + if ((mime == 'application/pdf' || file_name.match(/\.pdf/i)) + && window.navigator.userAgent.match(/Mobi|Tablet|Android|iPad|iPhone/)) { + allow_preview = false; + } + + if (allow_preview) { $$('a').onclick = () => { if (file_url.match(/\.md$/)) { openDialog('
    ', false); $('dialog').className = 'preview'; req('GET', file_url).then(r => r.text()).then(t => { - $('.md_preview').innerHTML = microdown.parse(html(t)); + $('.md_preview').innerHTML = microdown.parse(t); }); return false; } @@ -2809,7 +3123,7 @@ const WebDAVNavigator = (url, options) => { else if (user && password && !dir) { $$('a').onclick = () => { download(file_name, size, file_url); return false; }; } - else { + else if (!dir) { $$('a').download = file_name; } @@ -2820,16 +3134,97 @@ const WebDAVNavigator = (url, options) => { $$('.edit').onclick = (e) => { req('GET', file_url).then((r) => r.text().then((t) => { let md = file_url.match(/\.md$/); - openDialog(md ? markdown_dialog : edit_dialog); + var tpl = dialog_tpl.replace(/%b/, ''); + $('body').classList.add('dialog'); + $('body').insertAdjacentHTML('beforeend', tpl.replace(/%s/, md ? markdown_dialog : edit_dialog)); + + var tb = $('.close'); + tb.className = 'toolbar'; + tb.innerHTML = ` + + + `; + var txt = $('textarea[name=edit]'); txt.value = t; + var saved_status = $('.toolbar .status'); + var close_btn = $('.toolbar .close'); + var save_btn = $('.toolbar .save'); + var autosave = $('.toolbar .autosave'); + + var c = localStorage.getItem('autosave') ?? options.autosave; + autosave.checked = c == 1 || c === true; + autosave.onchange = () => { + localStorage.setItem('autosave', autosave.checked ? 1 : 0); + }; + + var preventClose = (e) => { + if (txt.value == t) { + return; + } + + e.preventDefault(); + e.returnValue = ''; + return true; + }; + + var close = () => { + if (txt.value !== t) { + if (!confirm(_('Your changes have not been saved. Do you want to cancel WITHOUT saving?'))) { + return; + } + } + + window.removeEventListener('beforeunload', preventClose, {capture: true}); + closeDialog(); + }; + + var save = () => { + reqOrError('PUT', file_url, txt.value); + t = txt.value; + updateSaveStatus(); + }; + + var updateSaveStatus = () => { + saved_status.innerHTML = txt.value !== t ? '⚠️ ' + _('Modified') : '✔️ ' + _('Saved'); + }; + + save_btn.onclick = () => { save(); close(); }; + close_btn.onclick = close; + + // Prevent close of tab if content has changed and is not saved + window.addEventListener('beforeunload', preventClose, { capture: true }); + + txt.onkeydown = (e) => { + if (e.ctrlKey && e.key == 's') { + save(); + e.preventDefault(); + return false; + } + else if (e.key === 'Escape') { + close(); + e.preventDefault(); + return false; + } + }; + + txt.onkeyup = (e) => { + updateSaveStatus(); + }; + + window.setInterval(() => { + if (autosave.checked && t != txt.value) { + save(); + } + }, 10000); + // Markdown editor if (md) { - let pre = $('#md'); + let pre = $('.md_preview'); txt.oninput = () => { - pre.innerHTML = microdown.parse(html(txt.value)); + pre.innerHTML = microdown.parse(txt.value); }; txt.oninput(); @@ -2880,9 +3275,11 @@ const WebDAVNavigator = (url, options) => { var evt, paste_upload, popstate_evt, temp_object_url; var sort_order = window.localStorage.getItem('sort_order') || 'name'; + var sort_order_desc = !!parseInt(window.localStorage.getItem('sort_order_desc'), 10); var wopi_mimes = {}, wopi_extensions = {}; const wopi_discovery_url = options.wopi_discovery_url || null; + options.autosave = options.autosave || false; document.querySelector('html').innerHTML = html_tpl; @@ -2957,52 +3354,48 @@ const WebDAVNavigator = (url, options) => { document.body.classList.remove('dragging'); dragcounter = 0; - const files = [...e.dataTransfer.items].map(item => item.getAsFile()); + var files = [...e.dataTransfer.items].map(item => item.getAsFile()); + + files = files.filter(f => f !== null); if (!files.length) return; - animateLoading(); - - (async () => { - for (var i = 0; i < files.length; i++) { - var f = files[i] - await req('PUT', current_url + encodeURIComponent(f.name), f); - } - - window.setTimeout(() => { - stopLoading(); - reloadListing(); - }, 500); - })(); + uploadFiles(files); }); }; if (url = document.querySelector('html').getAttribute('data-webdav-url')) { WebDAVNavigator(url, { 'wopi_discovery_url': document.querySelector('html').getAttribute('data-wopi-discovery-url'), + 'nc_thumbnails': document.querySelector('html').getAttribute('data-nc-thumbnails') ? true : false }); } :root { --bg-color: #fff; --fg-color: #000; - --g1-color: #eee; - --g2-color: #ccc; - --g3-color: #999; - --link-color: blue; - --visited-color: purple; - --active-color: darkred; + --g1-color: #f5f7f7; + --g2-color: #ccd8d6; + --g3-color: #7c9696; + --link-color: #2e3c3a; + --visited-color: #2e3c3a; + --active-color: #c47508; +} + +* { + margin: 0; + padding: 0; } body { text-align: center; - font-size: 1.1em; - font-family: Arial, Helvetica, sans-serif; + font-family: "Trebuchet MS", Arial, Helvetica, sans-serif; background: var(--bg-color); color: var(--fg-color); } a:link { color: var(--link-color); + text-decoration: none; } a:visited { @@ -3011,31 +3404,130 @@ a:visited { a:hover { color: var(--active-color); + text-decoration: underline; } table { - margin: 2em auto; + margin: 0 auto; border-collapse: collapse; - width: 90%; + width: 100%; } -th, td { - padding: .5em; +thead td { + height: 2em; + vertical-align: bottom; +} + +thead button { + width: 100%; + border: none; + text-align: inherit; + font: inherit; + background: none; + cursor: pointer; + font-size: .9em; + color: #999; + padding: .4em; +} + +thead .selected button::after { + content: "🠇"; + margin-left: .5em; +} + +thead .selected.desc button::after { + content: "🠅"; +} + +td.name, tbody th, td.date { text-align: left; - border: 2px solid var(--g2-color); } -th { +tbody tr:has(.check input:checked) { + background: #fdefdc; +} + +tbody th, tbody td { + padding: 0 .5em; + text-align: left; + border-top: 1px solid var(--g2-color); + border-bottom: 1px solid var(--g2-color); + vertical-align: middle; +} + +tbody th { word-break: break-all; + font-weight: normal; } -td.thumb { - width: 5%; +tbody th a { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 1em; +} + +tbody th img { + width: 2em; + height: 2em; + border-radius: .2em; +} + +tbody th .icon { + width: 2em; + height: 2em; + border-radius: .2em; + overflow: hidden; + background: no-repeat center center; + background-size: 100%; + position: relative; +} + +th .icon b { + font-size: 7pt; + font-weight: normal; + text-decoration: none; + display: inline-block; + background: #eee; + border-radius: .3em; + padding: 0 .2em; + color: #2e3c3a; + position: absolute; + bottom: 0; + right: 0; + border: 1px solid #2e3c3a; +} + +th .icon b:empty { + display: none; +} + +td.check { + width: 1.5em; + text-align: center; +} + +td.check input { + cursor: pointer; + width: 1.5em; + height: 1.5em; + border: 2px solid #999; + border-radius: .2em; + background: none; + appearance: none; + outline: 0; +} + +td.check input:checked { + box-shadow: 0px 0px 5px darkorange; + background: no-repeat center center; + background-image: url('data:image/svg+xml;utf8,'); + background-size: contain; } td.buttons { text-align: right; - width: 20em; + width: 5em; } td.buttons div { @@ -3043,63 +3535,68 @@ td.buttons div { flex-direction: row-reverse; } -table tr:nth-child(even) { +tbody tr:nth-child(even) { background: var(--g1-color); } -.icon { - width: 2.6em; - height: 2.6em; - display: block; - border-radius: .2em; - background:var(--g3-color); - overflow: hidden; - color: var(--bg-color); - text-align: center; +tbody th .icon { + background-image: url('data:image/svg+xml;utf8,'); } -.icon b { - font-weight: normal; - display: inline-block; - transform: rotate(-30deg); - line-height: 2.6rem; +th .icon.JPEG, th .icon.PNG, th .icon.JPG, th .icon.GIF, th .icon.SVG, th .icon.WEBP { + background-image: url('data:image/svg+xml;utf8,'); } -.icon.JPEG, .icon.PNG, .icon.JPG, .icon.GIF, .icon.SVG, .icon.WEBP { - background: #966; +th .icon.TXT, th .icon.MD { + background-image: url('data:image/svg+xml;utf8,'); } -.icon.TXT, .icon.MD { - background: var(--fg-color); +th .icon.MP4, th .icon.MKV, th .icon.MP3, th .icon.M4A, th .icon.WAV, th .icon.FLAC, th .icon.OGG, th .icon.OGV, th .icon.AAC, th .icon.WEBM { + background-image: url('data:image/svg+xml;utf8,'); } -.icon.MP4, .icon.MKV, .icon.MP3, .icon.M4A, .icon.WAV, .icon.FLAC, .icon.OGG, .icon.OGV, .icon.AAC, .icon.WEBM { - background: #669; +th .icon.document { + background-image: url('data:image/svg+xml;utf8,'); } -.icon.document { - background: #696; +th .icon.document b { + position: relative; + background: none; + border: none; + color: #fff; + width: 100%; + height: 100%; + padding: 0; + display: inline-flex; + justify-content: center; + align-items: center; } -.icon.PDF { - background: #969; +.dir th .icon { + background-image: url('data:image/svg+xml;utf8,'); } -.icon.dir { - background: var(--g2-color); - color: var(--fg-color); +.parent th .icon { + background-image: url('data:image/svg+xml;utf8,'); } -.icon.dir b { - font-size: 2em; - transform: none; +.dir th .icon b, .parent th .icon b { + display: none; } -.size { +td.size { text-align: right; + font-size: .9em; + color: #666; } -input[type=button], input[type=submit], .btn { +td.date { + font-size: .9em; + color: #666; + min-width: 9em; +} + +input[type=button], input[type=submit], .btn, a.btn { font-size: 1.2em; padding: .3em .5em; margin: .2em .3em; @@ -3108,7 +3605,7 @@ input[type=button], input[type=submit], .btn { border-radius: .2em; cursor: pointer; text-decoration: none; - color: var(--fg-color) !important; + color: var(--fg-color); font-family: inherit; } @@ -3134,7 +3631,7 @@ input:focus, textarea:focus { input[type=button]:hover, input[type=submit]:hover, .btn:hover { color: var(--active-color); text-decoration: underline; - background: var(--bg-color); + background-color: var(--bg-color); box-shadow: 0px 0px 5px var(--fg-color); } @@ -3143,10 +3640,34 @@ input[type=button]:hover, input[type=submit]:hover, .btn:hover { margin: 0; } -.close input { +input.close { font-size: .8em; } +.toolbar { + margin: 0; + display: flex; + justify-content: space-between; + align-items: center; +} + +.toolbar label { + padding: .5em; + background: var(--bg-color); + cursor: pointer; + border-radius: .5em; +} + +.toolbar .save { + background: DarkSlateBlue; + color: #fff; +} + +.toolbar .status { + width: 10em; + display: block; +} + input[type=submit] { float: right; } @@ -3170,8 +3691,31 @@ dialog form div { text-align: center; } -.upload { - margin: 1em 0; +div.buttons { + display: flex; + justify-content: flex-end; + margin: .5em; + margin-bottom: 0; +} + +div.buttons .selected { + margin-right: auto; + display: none; +} + +body:has(tbody .check input:checked) div.buttons .selected { + display: block; +} + +div.buttons input { + overflow: visible; + text-indent: 0; + width: auto; + height: auto; + background-position: .2em center; + padding: .3em .5em; + padding-left: 2em; + background-size: 1.3em; } #mdp div, #mdp textarea { @@ -3183,7 +3727,7 @@ dialog form div { margin: 0; } -#md { +#mdp .md_preview { overflow: hidden; overflow-x: auto; } @@ -3193,7 +3737,11 @@ dialog form div { grid-template-columns: 1fr 1fr; grid-gap: .2em; background: var(--g1-color); - height: 82vh; + height: 90vh; +} + +.md_preview img, .md_preview video, .md_preview iframe, .md_preview embed, .md_preview object { + max-width: 100%; } dialog.preview { @@ -3209,7 +3757,7 @@ dialog.preview { overflow: hidden; } -iframe, .md_preview { +iframe, dialog.preview .md_preview { overflow: auto; position: absolute; top: 0; @@ -3250,11 +3798,21 @@ iframe, iframe body, .md_preview { max-height: 100%; } -.md_preview { +dialog.preview .md_preview { width: calc(100vw - 2em); - height: calc(100vh - 2em); + height: calc(100vh - 4em); padding: 1em; text-align: left; + padding-bottom: 2em; +} + +.md_preview ul, .md_preview ol, .md_preview p, .md_preview blockquote, .md_preview pre, +.md_preview h1, .md_preview h2, .md_preview h3, .md_preview h4, .md_preview h5, .md_preview h6 { + margin: 1rem 0; +} + +.md_preview ul, .md_preview ol, .md_preview blockquote { + margin-left: 2em; } .preview .close { @@ -3343,6 +3901,19 @@ input[name=rename], input[name=paste_name] { dialog { transition: all .3s; + margin: auto; + padding: 1em; +} + +dialog:has(#mdp) { + margin: 0; + padding: .5em; + border-radius: none; + top: 0; + left: 0; + right: 0; + bottom: 0; + height: 100vh; } progress { @@ -3350,6 +3921,45 @@ progress { width: 90%; } +input.icon, .buttons a[download] { + background: #e0e6e6 no-repeat center center; + background-size: contain; + width: 1.8em; + height: 1.8em; + padding: 0; + overflow: hidden; + text-indent: 100em; + display: inline-block; +} + +input.delete { + background-image: url('data:image/svg+xml;utf8,'); +} + +input.rename { + background-image: url('data:image/svg+xml;utf8,'); +} + +.buttons a[download], input.download { + background-image: url('data:image/svg+xml;utf8,'); +} + +input.edit { + background-image: url('data:image/svg+xml;utf8,'); +} + +input.mkdir { + background-image: url('data:image/svg+xml;utf8,'); +} + +input.mkfile { + background-image: url('data:image/svg+xml;utf8,'); +} + +input.upload { + background-image: url('data:image/svg+xml;utf8,'); +} + @keyframes spin { to { transform: rotate(360deg); } } @media screen and (max-width: 800px) { @@ -3362,74 +3972,99 @@ progress { body { margin: 0; - font-size: 1em; + } + + div.buttons { + flex-wrap: wrap; + justify-content: center; + flex-direction: row; + font-size: .9em; + } + + div.buttons .selected { + margin: 0; + position: fixed; + top: 0; + left: 0; + right: 0; + padding: 1em; + background: var(--bg-color); + box-shadow: 0px 0px 2px 5px var(--g3-color); } table { - margin: 2em 0; width: 100%; display: flex; flex-direction: column; } - table tr { - display: block; - border-top: 5px solid var(--bg-color); + table tbody tr { + display: grid; + grid-template-columns: 3rem 1fr .3fr; + grid-template-areas: + "name name check" + "size date buttons"; padding: 0; - padding-left: 2em; - position: relative; - text-align: left; - min-height: 2.5em; } - table td, table th { + table tbody td, table tbody th { border: none; - display: inline-block; + display: block; padding: .2em .5em; + width: unset; } - table td.buttons { - display: block; - width: auto; - text-align: left; + table tbody th { + grid-area: name; } - td.buttons div { - display: inline-block; - } - - table td.thumb { - padding: 0; - width: 2em; - position: absolute; - left: 0; - top: 0; - bottom: 0; + table tbody td.check { + grid-area: check; + text-align: right; + width: unset; } - table th { - display: block; + table tbody td.buttons { + grid-area: buttons; + width: unset; } - .icon { - font-size: 12px; - height: 100%; - border-radius: 0; + table tbody td.date { + grid-area: date; + display: inline-block; + min-width: unset; } - .icon:not(.dir) b { - line-height: 3em; - display: block; - transform: translateX(-50%) translateY(-50%) rotate(-90deg); - font-size: 2em; - height: 3em; - position: absolute; - top: 50%; - left: 50%; + table tbody td.size { + grid-area: size; + font-size: .8em; + width: 3rem; + overflow: hidden; + text-align: center; } table th a { font-size: 1.2em; + gap: .5em; + } + + table thead tr { + display: block; + text-align: left; + } + + table thead tr td { + display: inline-block; + vertical-align: top; + } + + table thead td.check { + float: right; + margin-right: .4em; + } + + table thead button { + font-size: 1rem; } input[name=rename], input[name=paste_name] { diff --git a/make.php b/make.php index 8bb818f..07a6295 100644 --- a/make.php +++ b/make.php @@ -15,6 +15,8 @@ function clean_php_source(string $file): string $php = file_get_contents('server.php'); $php = strtr($php, [ + '//__KD2\HTTP\Server__' => clean_php_source('lib/KD2/HTTP/Server.php'), + '//__KD2\WebDAV\Exception__' => clean_php_source('lib/KD2/WebDAV/Exception.php'), '//__KD2\WebDAV\Server__' => clean_php_source('lib/KD2/WebDAV/Server.php'), '//__KD2\WebDAV\AbstractStorage__' => clean_php_source('lib/KD2/WebDAV/AbstractStorage.php'), '/*__HTACCESS__*/' => var_export(file_get_contents('.htaccess'), true), diff --git a/scr.webp b/scr.webp new file mode 100644 index 0000000..3770a69 Binary files /dev/null and b/scr.webp differ diff --git a/server.php b/server.php index 44e149c..d821788 100644 --- a/server.php +++ b/server.php @@ -1,7 +1,14 @@ $path]; } + public function fetch(string $uri): ?string + { + $r = $this->get($uri); + + if (!$r) { + return null; + } + + $r = file_get_contents($r['path']); + + if ($r === false) { + return null; + } + + return $r; + } + public function exists(string $uri): bool { return file_exists($this->path . $uri); @@ -446,8 +470,6 @@ namespace PicoDAV } else { $method($source, $target); - - $this->getResourceProperties($uri)->move($destination); } return $overwritten;