From 7559286d389c45909019d84ea3073cfadb57070e Mon Sep 17 00:00:00 2001 From: bohwaz Date: Mon, 15 Apr 2024 00:30:56 +0200 Subject: [PATCH] Make sure we return last modified time to Collabora so that it handles correctly race conditions --- lib/KD2/WebDAV/Server.php | 58 ++++++++++++++++++++++++++++-------- lib/KD2/WebDAV/WOPI.php | 62 +++++++++++++++++++++++++++++++-------- lib/KaraDAV/Storage.php | 11 +++---- 3 files changed, 101 insertions(+), 30 deletions(-) diff --git a/lib/KD2/WebDAV/Server.php b/lib/KD2/WebDAV/Server.php index bfcee9c..63ffcff 100644 --- a/lib/KD2/WebDAV/Server.php +++ b/lib/KD2/WebDAV/Server.php @@ -120,6 +120,24 @@ class Server 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; @@ -253,12 +271,16 @@ class Server 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); } @@ -267,12 +289,12 @@ class Server } } - 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); } @@ -281,14 +303,14 @@ class Server // 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(''); } @@ -297,8 +319,8 @@ class Server $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) { @@ -306,9 +328,19 @@ class Server } } + if ($date = $this->getHeader('If-Unmodified-Since')) { + $date = \DateTime::createFromFormat(\DateTime::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(); @@ -316,7 +348,7 @@ class Server // 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)); diff --git a/lib/KD2/WebDAV/WOPI.php b/lib/KD2/WebDAV/WOPI.php index 78565bb..b2e302e 100644 --- a/lib/KD2/WebDAV/WOPI.php +++ b/lib/KD2/WebDAV/WOPI.php @@ -77,6 +77,7 @@ class WOPI const PROP_USER_NAME = self::NS . ':UserFriendlyName'; const PROP_USER_ID = self::NS . ':UserId'; const PROP_USER_AVATAR = self::NS . ':UserExtraInfo-Avatar'; + const PROP_LAST_MODIFIED = 'DAV::getlastmodified'; protected AbstractStorage $storage; protected Server $server; @@ -140,6 +141,8 @@ class WOPI $this->server->log('WOPI: => %s', $uri); + $return = null; + try { $auth_token = $this->getAuthToken(); @@ -175,26 +178,63 @@ class WOPI } $this->server->log('WOPI: => PutFile'); + + $lastmodified = $props[self::PROP_LAST_MODIFIED]; + $collabora_timestamp = $this->server->getHeader('X-COOL-WOPI-Timestamp'); + + // Collabora doesn't use WOPI lock/unlock + // See https://sdk.collaboraonline.com/docs/How_to_integrate.html#further-differences-to-wopi + if ($collabora_timestamp + && ($date = \DateTime::createFromFormat(\DateTime::ISO8601, $collabora_timestamp)) + && $lastmodified + && $lastmodified instanceof \DateTime + && $date->format('YmdHis') != $lastmodified->format('YmdHis')) + { + $this->server->log('WOPI: <= 409 (File was modified: client = %s, server = %s)', + $date->format('Y-m-d H:i:s'), + $lastmodified->format('Y-m-d H:i:s')); + http_response_code(409); + header('Content-Type: application/json', true); + echo '{"COOLStatusCode": 1010}'; + return true; + } + $this->server->http_put($uri); // In WOPI, HTTP response code 201/204 is not accepted, only 200 // or Collabora will fail saving http_response_code(200); + + // Useful for Collabora + if ($lastmodified) { + // Update last-modified time + $lastmodified = $this->storage->propfind($uri, ['DAV::getlastmodified'], 0)[self::PROP_LAST_MODIFIED] ?? null; + + if ($lastmodified && $lastmodified instanceof \DateTime) { + $return = ['LastModifiedTime' => $lastmodified->format(\DateTime::ISO8601)]; + } + } } // CheckFileInfo elseif (!$action && $method == 'GET') { $this->server->log('WOPI: => CheckFileInfo'); - $this->getInfo($uri, $props); + $return = $this->getInfo($uri, $props); } else { throw new Exception('Invalid URI', 404); } } catch (Exception $e) { - $this->server->log('WOPI: => %d: %s', $e->getCode(), $e->getMessage()); + $this->server->log('WOPI: <= %d: %s', $e->getCode(), $e->getMessage()); http_response_code($e->getCode()); + $return = ['error' => $e->getMessage()]; + } + + if ($return) { header('Content-Type: application/json', true); - echo json_encode(['error' => $e->getMessage()]); + $return = json_encode($return, JSON_PRETTY_PRINT); + echo $return; + $this->server->log('WOPI: <= %s', $return); } return true; @@ -203,12 +243,16 @@ class WOPI /** * Output file informations in JSON */ - protected function getInfo(string $uri, array $props): bool + protected function getInfo(string $uri, array $props): array { - $modified = !empty($props['DAV::getlastmodified']) ? $props['DAV::getlastmodified']->format(DATE_ISO8601) : null; + $modified = null; $size = $props['DAV::getcontentlength'] ?? null; $readonly = (bool) $props[self::PROP_READ_ONLY]; + if (isset($props[self::PROP_LAST_MODIFIED]) && $props[self::PROP_LAST_MODIFIED] instanceof \DateTimeInterface) { + $modified = $props[self::PROP_LAST_MODIFIED]->format(DATE_ISO8601); + } + $data = [ 'BaseFileName' => basename($uri), 'UserFriendlyName' => $props[self::PROP_USER_NAME] ?? 'User', @@ -234,14 +278,8 @@ class WOPI $data['LastModifiedTime'] = $modified; } - - $json = json_encode($data, JSON_PRETTY_PRINT); - $this->server->log('WOPI: => Info: %s', $json); - http_response_code(200); - header('Content-Type: application/json', true); - echo $json; - return true; + return $data; } /** diff --git a/lib/KaraDAV/Storage.php b/lib/KaraDAV/Storage.php index f6f7546..0d541dd 100644 --- a/lib/KaraDAV/Storage.php +++ b/lib/KaraDAV/Storage.php @@ -658,11 +658,12 @@ class Storage extends AbstractStorage implements TrashInterface $readonly = !is_writeable($path); return [ - WOPI::PROP_FILE_URI => $uri, - WOPI::PROP_READ_ONLY => $readonly, - WOPI::PROP_USER_NAME => $user->login, - WOPI::PROP_USER_ID => md5($user->login), - WOPI::PROP_USER_AVATAR => $user->avatar_url, + WOPI::PROP_FILE_URI => $uri, + WOPI::PROP_READ_ONLY => $readonly, + WOPI::PROP_USER_NAME => $user->login, + WOPI::PROP_USER_ID => md5($user->login), + WOPI::PROP_USER_AVATAR => $user->avatar_url, + WOPI::PROP_LAST_MODIFIED => $this->get_file_property($uri, WOPI::PROP_LAST_MODIFIED, 0), ]; }