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;