diff --git a/lib/KD2/ErrorManager.php b/lib/KD2/ErrorManager.php index e01fcd1..981f755 100644 --- a/lib/KD2/ErrorManager.php +++ b/lib/KD2/ErrorManager.php @@ -247,11 +247,8 @@ class ErrorManager } } - $report = self::makeReport($e); - $log = sprintf('=========== Error ref. %s ===========', $report->context->id) - . PHP_EOL . PHP_EOL . (string) $e . PHP_EOL . PHP_EOL - . '' . PHP_EOL . json_encode($report, \JSON_PRETTY_PRINT) - . PHP_EOL . '' . PHP_EOL; + extract(self::buildExceptionReport($e, false)); + unset($e); // Log exception to file if (ini_get('log_errors')) @@ -259,21 +256,12 @@ class ErrorManager error_log($log); } - $exception_title = $e->getMessage(); - unset($e); - // Disable any output if it was buffering if (ob_get_level()) { ob_end_clean(); } - $html_report = null; - - if (self::$enabled & self::DEVELOPMENT || self::$email_errors) { - $html_report = self::htmlReport($report); - } - $is_curl = 0 === strpos($_SERVER['HTTP_USER_AGENT'] ?? '', 'curl/'); $is_cli = PHP_SAPI == 'cli'; @@ -348,7 +336,7 @@ class ErrorManager // Log exception to email if (self::$email_errors) { - self::sendEmail($exception_title, $report, $log, $html_report); + self::sendEmail($title, $report, $log, $html_report); } // Send report to URL @@ -362,6 +350,21 @@ class ErrorManager } } + static public function reportExceptionSilent(\Throwable $e): void + { + extract(self::buildExceptionReport($e)); + + // Log exception to file + if (ini_get('log_errors')) + { + error_log($log); + } + + if (self::$email_errors) { + self::sendEmail($title, $report, $log, $html_report); + } + } + static protected function sendEmail(string $title, \stdClass $report, string $log, string $html): void { // From: sender @@ -419,6 +422,25 @@ class ErrorManager return $file; } + static public function buildExceptionReport(\Throwable $e, bool $force_html = false): array + { + $report = self::makeReport($e); + $log = sprintf('=========== Error ref. %s ===========', $report->context->id) + . PHP_EOL . PHP_EOL . (string) $e . PHP_EOL . PHP_EOL + . '' . PHP_EOL . json_encode($report, \JSON_PRETTY_PRINT) + . PHP_EOL . '' . PHP_EOL; + + $html_report = null; + + if ($force_html || self::$enabled & self::DEVELOPMENT || self::$email_errors) { + $html_report = self::htmlReport($report); + } + + $title = $e->getMessage(); + + return compact('report', 'log', 'html_report', 'title'); + } + /** * Generates a report from an exception */ @@ -743,6 +765,7 @@ class ErrorManager if (self::$enabled) return true; + self::$context['request_started'] = $_SERVER['REQUEST_TIME_FLOAT'] ?? microtime(true); self::$enabled = $type; @@ -800,8 +823,6 @@ class ErrorManager self::$context[$a] = $_SERVER[$b]; } } - - self::$context['request_started'] = microtime(true); } /** diff --git a/lib/KD2/WebDAV/NextCloud.php b/lib/KD2/WebDAV/NextCloud.php index 21e0cbe..b5ed285 100644 --- a/lib/KD2/WebDAV/NextCloud.php +++ b/lib/KD2/WebDAV/NextCloud.php @@ -175,6 +175,47 @@ abstract class NextCloud abstract public function assembleChunks(string $login, string $name, string $target, ?int $mtime): array; + abstract public function listChunks(string $login, string $name): array; + + /** + * Thumbnail API + * @param string $uri URI path to the file we want a thumbnail for + * @param int $width + * @param int $height + * @param bool $crop TRUE if a cropped image is desired + * @param bool $preview TRUE if the thumbnail is for preview (in NextCloud Android, see below) + */ + public function serveThumbnail(string $uri, int $width, int $height, bool $crop = false, bool $preview = false): void + { + if (!preg_match('/\.(?:jpe?g|gif|png|webp)$/', $uri)) { + http_response_code(404); + return; + } + + // We don't support thumbnails, but you are free to generate cropped thumbnails and send them to the HTTP client + if (!$preview) { + http_response_code(404); + return; + } + // On Android, the app is annoying and asks to download the image + // every time ("no resized image available") if no preview is available. + // So to avoid that you can just redirect to the file if it's not too large + // But you are free to extend this method and resize the image on the fly instead. + else { + $size = $this->server->getStorage()->properties($uri, ['DAV::getcontentlength'], 0); + $size = count($size) ? current($size) : null; + + if ($size > 1024*1024 || !$size) { + http_response_code(404); + return; + } + + $url = '/remote.php/dav/files/' . $uri; + $this->server->log('=> Preview: redirect to %s', $url); + header('Location: ' . $url); + } + } + // END OF ABSTRACT METHODS /** @@ -187,6 +228,9 @@ abstract class NextCloud 'remote.php/webdav/uploads/' => 'chunked', 'remote.php/dav/uploads/' => 'chunked', + // There's just 3 or 4 different endpoints for avatars, this is ridiculous + 'remote.php/dav/avatars/' => 'avatar', + // Main routes 'remote.php/webdav/' => 'webdav', // desktop client 'remote.php/dav' => 'webdav', // android client @@ -214,10 +258,20 @@ abstract class NextCloud 'index.php/avatar' => 'avatar', 'ocs/v2.php/apps/dav/api/v1/direct' => 'direct_url', 'remote.php/direct/' => 'direct', + 'avatars/' => 'avatar', ]; const AUTH_REDIRECT_URL = 'nc://login/server:%s&user:%s&password:%s'; + // *Every* different NextCloud/ownCloud app uses a different root path + // this is ridiculous. + // NC Desktop: /remote.php/dav/files/user// (note the double slash at the end) + // NC iOS: /remote.php/dav/files/user (note the missing end slash) + // ownCloud Android: /remote.php/dav/files/ (note the missing user part) + // -> only at first, then it queries the correct path, WTF + // See also https://github.com/nextcloud/server/issues/25867 + const WEBDAV_BASE_REGEXP = '~^.*remote\.php/(?:webdav/|dav/files/(?:(?:(?!/).)+(?:/+|$)|/*$))~'; + public function setRootURL(string $url) { $this->root_url = $url; @@ -241,6 +295,8 @@ abstract class NextCloud $uri = $_SERVER['REQUEST_URI'] ?? '/'; } + $uri = parse_url($uri, PHP_URL_PATH); + $uri = ltrim($uri, '/'); $uri = rawurldecode($uri); @@ -252,8 +308,6 @@ abstract class NextCloud $route = current($route); - header('Access-Control-Allow-Origin: *', true); - $method = $_SERVER['REQUEST_METHOD'] ?? null; $this->server->log('NC <= %s %s => routed to: %s', $method, $uri, $route); @@ -293,6 +347,8 @@ abstract class NextCloud $this->server->log("NC => Body:\n%s", $json); } + $this->server->log('NC Sent response: %d', http_response_code()); + return true; } @@ -329,6 +385,17 @@ abstract class NextCloud { $this->requireAuth(); + $method = $_SERVER['REQUEST_METHOD'] ?? ''; + + // ownCloud-Android is using a different preview API + // remote.php/dav/files/user/name.jpg?x=224&y=224&c=&preview=1 + if (!empty($_GET['preview'])) { + $x = (int) $_GET['x'] ?? 0; + $y = (int) $_GET['y'] ?? 0; + $this->serveThumbnail($uri, $x, $y, $x == $y); + return; + } + $base_uri = null; // Find out which route we are using and replace URI @@ -347,11 +414,10 @@ abstract class NextCloud throw new Exception('Invalid WebDAV URL', 404); } - // Android app is using "/remote.php/dav/files/user//" as root - // so let's alias that as well - // ownCloud Android is requesting just /dav/files/ - if (preg_match('!^' . preg_quote($base_uri, '!') . 'files/(?:[^/]+/+)?!', $uri, $match)) { - $base_uri = $match[0]; + $ua = $_SERVER['HTTP_USER_AGENT'] ?? ''; + + if (preg_match(self::WEBDAV_BASE_REGEXP, $uri, $match)) { + $base_uri = rtrim($match[0], '/') . '/'; } $this->server->prefix = $this->prefix; @@ -362,14 +428,23 @@ abstract class NextCloud public function nc_status(): array { + if (stristr($_SERVER['HTTP_USER_AGENT'], 'owncloud')) { + $name = 'ownCloud'; + $version = '10.11.0'; + } + else { + $name = 'NextCloud'; + $version = '24.0.4'; + } + return [ 'installed' => true, 'maintenance' => false, 'needsDbUpgrade' => false, - 'version' => '24.0.4.1', - 'versionstring' => '24.0.4', + 'version' => $version, + 'versionstring' => $version, 'edition' => '', - 'productname' => 'NextCloud', + 'productname' => $name, 'extendedSupport' => false, ]; } @@ -483,10 +558,10 @@ abstract class NextCloud $user = $this->getUserName() ?? 'null'; return $this->nc_ocs([ - 'id' => $user, + 'id' => sha1($user), 'enabled' => true, 'email' => null, - 'storageLocation' => '/secret/whoknows/' . $user, + 'storageLocation' => '/secret/whoknows/' . sha1($user), 'role' => '', 'display-name' => $user, 'quota' => [ @@ -509,7 +584,7 @@ abstract class NextCloud return $this->nc_ocs([]); } - protected function nc_avatar(): ?array + protected function nc_avatar(): void { throw new Exception('Not implemented', 404); } @@ -647,29 +722,10 @@ abstract class NextCloud $height = $_GET['y'] ?? null; $crop = !($_GET['a'] ?? null); - if (!preg_match('/\.(?:jpe?g|gif|png|webp)$/', $uri)) { - http_response_code(404); - return; - } - - // On Android, the app is annoying and asks to download the image - // every time ("no resized image available"). - // So to avoid that we will just redirect to the file if it is not too big. - // But you are free to extend this method and resize the image on the fly - $url = str_replace('%2F', '/', rawurlencode(rawurldecode($_GET['file'] ?? ''))); + $url = str_replace('%2F', '/', rawurldecode($_GET['file'] ?? '')); $url = ltrim($url, '/'); - $size = current($this->storage->properties($url, ['DAV::getcontentlenth'], 0)); - - // 1 MB is a large image - if ($size > 1024*1024) { - http_response_code(404); - return; - } - - $url = '/remote.php/dav/files/' . $url; - $this->server->log('=> Preview: redirect to %s', $url); - header('Location: ' . $url); + $this->serveThumbnail($url, (int) $width, (int) $height, $crop, true); } protected function nc_thumbnail(string $uri): void @@ -680,8 +736,7 @@ abstract class NextCloud list($width, $height, $uri) = array_pad(explode('/', $uri, 3), 3, null); - // We don't support this feature, but you are free to generate cropped thumbnails here - http_response_code(404); + $this->serveThumbnail($uri, (int)$width, (int)$height, $width == $height); } /** @@ -736,7 +791,7 @@ abstract class NextCloud } elseif ($method == 'MOVE') { $dest = $_SERVER['HTTP_DESTINATION']; - $dest = preg_replace('!^.*/remote.php/(?:web)?dav/(?:files/)?[^/]*/!', '', $dest); + $dest = preg_replace(self::WEBDAV_BASE_REGEXP, '', $dest); $dest = trim(rawurldecode($dest), '/'); if (false !== strpos($dest, '..') || false !== strpos($dest, '//')) { @@ -757,6 +812,8 @@ abstract class NextCloud header(sprintf('OC-ETag: "%s"', $return['etag'])); } + $this->server->log("=> Chunks assembled to: %s", $dest); + if (!empty($return['created'])) { http_response_code(201); } @@ -766,6 +823,26 @@ abstract class NextCloud } elseif ($method == 'DELETE' && !$part) { $this->deleteChunks($login, $dir); + $this->server->log("=> Deleted chunks"); + } + elseif ($method == 'PROPFIND') { + header('HTTP/1.1 207 Multi-Status', true); + $out = '' . PHP_EOL; + $out .= '' . PHP_EOL; + + foreach ($this->listChunks($login, $dir) as $chunk) { + $out .= '' . PHP_EOL; + $chunk = '/' . $uri . '/' . $chunk; + $out .= sprintf('%s', htmlspecialchars($chunk, ENT_XML1)) . PHP_EOL; + $out .= 'application/octet-streamHTTP/1.1 200 OK' . PHP_EOL; + $out .= '' . PHP_EOL; + } + + $out .= ''; + + echo $out; + + $this->server->log("=> Body:\n%s", $out); } else { throw new Exception('Invalid method for chunked upload', 400); diff --git a/lib/KD2/WebDAV/Server.php b/lib/KD2/WebDAV/Server.php index 1d6d528..3898df1 100644 --- a/lib/KD2/WebDAV/Server.php +++ b/lib/KD2/WebDAV/Server.php @@ -82,6 +82,13 @@ class Server const SHARED_LOCK = 'shared'; const EXCLUSIVE_LOCK = 'exclusive'; + /** + * Enable on-the-fly gzip compression + * This can use a large amount of resources + * @var boolean + */ + protected bool $enable_gzip = true; + /** * Base server URI (eg. "/index.php/webdav/") */ @@ -114,6 +121,19 @@ class Server $this->base_uri = rtrim($uri, '/') . '/'; } + /** + * Extend max_execution_time so that upload/download of files don't expire if connection is slow + */ + protected function extendExecutionTime(): void + { + if (false === strpos(@ini_get('disable_functions'), 'set_time_limit')) { + @set_time_limit(3600); + } + + @ini_set('max_execution_time', '3600'); + @ini_set('max_input_time', '3600'); + } + protected function _prefix(string $uri): string { if (!$this->prefix) { @@ -269,6 +289,8 @@ class Server header('X-OC-MTime: accepted'); } + $this->extendExecutionTime(); + $created = $this->storage->put($uri, fopen('php://input', 'r'), $hash, $mtime); $prop = $this->storage->properties($uri, ['DAV::getetag'], 0); @@ -383,6 +405,8 @@ class Server throw new \RuntimeException('Invalid file array returned by ::get()'); } + $this->extendExecutionTime(); + $length = $start = $end = null; $gzip = false; @@ -402,17 +426,20 @@ class Server $this->log('HTTP Range requested: %s-%s', $start, $end); } - elseif (isset($_SERVER['HTTP_ACCEPT_ENCODING']) + 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('/\.(?:mp4|m4a|zip|docx|xlsx|ods|odt|odp|7z|gz|bz2|rar|webm|ogg|mp3|ogm|flac|ogv|mkv|avi)$/i', $uri)) { + && !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')) - { + if (function_exists('apache_setenv')) { @apache_setenv('no-gzip', 1); } @@ -495,9 +522,9 @@ class Server if ($gzip) { $this->log('Using gzip output compression'); - $gzip = deflate_init(ZLIB_ENCODING_GZIP, ['level' => 9]); + $gzip = deflate_init(ZLIB_ENCODING_GZIP); - $fp = fopen('php://memory', 'wb'); + $fp = fopen('php://temp', 'wb'); while (!feof($file['resource'])) { fwrite($fp, deflate_add($gzip, fread($file['resource'], 8192), ZLIB_NO_FLUSH)); @@ -517,14 +544,16 @@ class Server header('Content-Length: ' . $length, true); } + $block_size = 8192*4; + while (!feof($file['resource']) && ($end === null || $end > 0)) { - $l = $end !== null ? min(8192, $end) : 8192; + $l = $end !== null ? min($block_size, $end) : $block_size; echo fread($file['resource'], $l); flush(); if (null !== $end) { - $end -= 8192; + $end -= $block_size; } } diff --git a/lib/KD2/WebDAV/WOPI.php b/lib/KD2/WebDAV/WOPI.php index 05d30d1..962021d 100644 --- a/lib/KD2/WebDAV/WOPI.php +++ b/lib/KD2/WebDAV/WOPI.php @@ -26,11 +26,17 @@ class WOPI const PROP_TOKEN = self::NS . ':token'; const PROP_TOKEN_TTL = self::NS . ':token-ttl'; const PROP_READ_ONLY = self::NS . ':ReadOnly'; - const PROP_USER_NAME = self::NS . ':FriendlyUserName'; + const PROP_USER_NAME = self::NS . ':UserFriendlyName'; + const PROP_USER_ID = self::NS . ':UserId'; protected AbstractStorage $storage; protected Server $server; + public function setStorage(AbstractStorage $storage) + { + $this->storage = $storage; + } + public function setServer(Server $server) { $this->storage = $server->getStorage(); @@ -88,20 +94,26 @@ class WOPI $this->server->log('WOPI: => Found doc_uri: %s', $uri); - + // GetFile if ($action == 'contents' && $method == 'GET') { + $this->server->log('WOPI: => GetFile'); $this->server->http_get($uri); } + // PutFile elseif ($action == 'contents' && $method == 'POST') { + $this->server->log('WOPI: => PutFile'); $this->server->http_put($uri); } + // CheckFileInfo elseif (!$action && $method == 'GET') { + $this->server->log('WOPI: => CheckFileInfo'); $this->getInfo($uri); } else { throw new Exception('Invalid URI', 404); } + $this->server->log('WOPI: <= 200'); http_response_code(200); // This is required for Collabora } catch (Exception $e) { @@ -121,6 +133,7 @@ class WOPI 'DAV::getetag', self::PROP_READ_ONLY, self::PROP_USER_NAME, + self::PROP_USER_ID, ], 0); $modified = !empty($props['DAV::getlastmodified']) ? $props['DAV::getlastmodified']->format(DATE_ISO8601) : null; @@ -128,9 +141,9 @@ class WOPI $data = [ 'BaseFileName' => basename($uri), - 'UserFriendlyName' => $props['DAV::PROP_USER_NAME'] ?? 'User', - 'OwnerId' => 1, - 'UserId' => 1, + 'UserFriendlyName' => $props[self::PROP_USER_NAME] ?? 'User', + 'OwnerId' => 0, + 'UserId' => $props[self::PROP_USER_ID] ?? 0, 'Size' => $size, 'Version' => $props['DAV::getetag'] ?? md5($uri . $size . $modified), ]; @@ -142,9 +155,16 @@ class WOPI $data['ReadOnly'] = $props['self::PROP_READ_ONLY'] ?? false; $data['UserCanWrite'] = !$data['ReadOnly']; $data['UserCanRename'] = !$data['ReadOnly']; + $data['DisableCopy'] = $data['ReadOnly']; + $data['UserCanNotWriteRelative'] = true; // This requires you to implement file name UI + //$data['DisablePrint'] = true; + + $json = json_encode($data, JSON_PRETTY_PRINT); + $this->server->log('WOPI: => Info: %s', $json); http_response_code(200); - echo json_encode($data, JSON_PRETTY_PRINT); + header('Content-Type: application/json', true); + echo $json; return true; } @@ -160,13 +180,13 @@ class WOPI * 'application/vnd.oasis.opendocument.presentation' => ['edit' => 'http://'...], * ]] */ - public function discover(string $url): array + static public function discover(string $url): array { if (function_exists('curl_init')) { $c = curl_init($url); curl_setopt($c, CURLOPT_RETURNTRANSFER, true); $r = curl_exec($c); - $code = curl_getinfo(CURLINFO_HTTP_CODE); + $code = curl_getinfo($c, CURLINFO_HTTP_CODE); if ($code != 200) { throw new \RuntimeException(sprintf("Discovery URL returned an error: %d\n%s", $code, $r)); @@ -269,7 +289,7 @@ class WOPI $url = parse_url($url); // Remove available options from URL - $url['query'] = preg_replace('/<(\w+)=(\w+)&>/i', '', $url['query']); + $url['query'] = preg_replace('/<(\w+)=(\w+)&>/i', '', $url['query'] ?? ''); // Set options parse_str($url['query'], $params); diff --git a/lib/KaraDAV/NextCloud.php b/lib/KaraDAV/NextCloud.php index e5d8599..18bb05c 100644 --- a/lib/KaraDAV/NextCloud.php +++ b/lib/KaraDAV/NextCloud.php @@ -134,6 +134,14 @@ class NextCloud extends WebDAV_NextCloud fclose($pointer); } + public function listChunks(string $login, string $name): array + { + $path = $this->temporary_chunks_path . '/' . $name; + $list = glob($path . '/*'); + $list = array_map(fn($a) => str_replace($path . '/', '', $a), $list); + return $list; + } + public function deleteChunks(string $login, string $name): void { $path = $this->temporary_chunks_path . '/' . $login . '/' . $name;