Make sure we return last modified time to Collabora so that it handles correctly race conditions

This commit is contained in:
bohwaz 2024-04-15 00:30:56 +02:00
parent ac3d269a79
commit 7559286d38
3 changed files with 101 additions and 30 deletions

View file

@ -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));

View file

@ -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;
}
/**

View file

@ -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),
];
}