From af1406a0a5ba63b271631ee7ed7e302467dbe39e Mon Sep 17 00:00:00 2001 From: Victor Dubiniuk Date: Mon, 27 Apr 2020 17:34:25 +0300 Subject: [PATCH 1/3] Complex webdav properties support --- .../Migrations/Version20200427142541.php | 56 +++++++++++++++++++ .../DAV/AbstractCustomPropertiesBackend.php | 52 ++++++++++++++++- .../lib/DAV/FileCustomPropertiesBackend.php | 31 +++++----- .../lib/DAV/MiscCustomPropertiesBackend.php | 23 ++++---- changelog/unreleased/37314 | 8 +++ .../setFileProperties.feature | 4 +- 6 files changed, 143 insertions(+), 31 deletions(-) create mode 100644 apps/dav/appinfo/Migrations/Version20200427142541.php create mode 100644 changelog/unreleased/37314 diff --git a/apps/dav/appinfo/Migrations/Version20200427142541.php b/apps/dav/appinfo/Migrations/Version20200427142541.php new file mode 100644 index 000000000000..2b5162d018fd --- /dev/null +++ b/apps/dav/appinfo/Migrations/Version20200427142541.php @@ -0,0 +1,56 @@ + + * + * @copyright Copyright (c) 2020, ownCloud GmbH + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace OCA\DAV\Migrations; + +use Doctrine\DBAL\Schema\Schema; +use Doctrine\DBAL\Types\Type; +use OCA\DAV\DAV\AbstractCustomPropertiesBackend; +use OCP\Migration\ISchemaMigration; + +/* + * Adds a new property type column to properties and dav_properties tables + */ +class Version20200427142541 implements ISchemaMigration { + + /** + * @param Schema $schema + * @param array $options + */ + public function changeSchema(Schema $schema, array $options) { + $prefix = $options['tablePrefix']; + $tableNames = ['properties', 'dav_properties']; + + foreach ($tableNames as $tableName) { + $table = $schema->getTable("{$prefix}{$tableName}"); + if ($table->hasColumn('propertytype') === false) { + $table->addColumn( + 'propertytype', + Type::SMALLINT, + [ + 'notnull' => true, + 'default' => AbstractCustomPropertiesBackend::VT_STRING, + ] + ); + } + } + } +} diff --git a/apps/dav/lib/DAV/AbstractCustomPropertiesBackend.php b/apps/dav/lib/DAV/AbstractCustomPropertiesBackend.php index eeceaedcd6fc..c65e2b045a2c 100644 --- a/apps/dav/lib/DAV/AbstractCustomPropertiesBackend.php +++ b/apps/dav/lib/DAV/AbstractCustomPropertiesBackend.php @@ -32,8 +32,23 @@ use Sabre\DAV\PropFind; use Sabre\DAV\PropPatch; use Sabre\DAV\Tree; +use Sabre\DAV\Xml\Property\Complex; abstract class AbstractCustomPropertiesBackend implements BackendInterface { + /** + * Value is stored as string. + */ + const VT_STRING = 1; + + /** + * Value is stored as XML fragment. + */ + const VT_XML = 2; + + /** + * Value is stored as a property object. + */ + const VT_OBJECT = 3; /** * Ignored properties @@ -230,7 +245,7 @@ protected function fetchProperties($sql, $whereValues, $whereTypes) { $props = []; while ($row = $result->fetch()) { - $props[$row['propertyname']] = $row['propertyvalue']; + $props[$row['propertyname']] = $this->decodeValue($row['propertyvalue'], (int) $row['propertytype']); } $result->closeCursor(); @@ -264,4 +279,39 @@ protected function getNodeForPath($path) { } return null; } + + /** + * @param mixed|Complex $value + * @return array + */ + protected function encodeValue($value) { + if (\is_scalar($value)) { + $valueType = self::VT_STRING; + } elseif ($value instanceof Complex) { + $valueType = self::VT_XML; + $value = $value->getXml(); + } else { + $valueType = self::VT_OBJECT; + $value = \serialize($value); + } + return [ + 'value' => $value, + 'type' => $valueType + ]; + } + + /** + * @param string $value + * @param int $valueType + * @return mixed|Complex + */ + protected function decodeValue($value, $valueType) { + if ($valueType === self::VT_STRING) { + return $value; + } elseif ($valueType === self::VT_XML) { + return new Complex($value); + } elseif ($valueType === self::VT_OBJECT) { + return \unserialize($value); + } + } } diff --git a/apps/dav/lib/DAV/FileCustomPropertiesBackend.php b/apps/dav/lib/DAV/FileCustomPropertiesBackend.php index ab62e5a3f7d3..bd865b8b6ad4 100644 --- a/apps/dav/lib/DAV/FileCustomPropertiesBackend.php +++ b/apps/dav/lib/DAV/FileCustomPropertiesBackend.php @@ -43,9 +43,9 @@ class FileCustomPropertiesBackend extends AbstractCustomPropertiesBackend { public const SELECT_BY_ID_STMT = 'SELECT * FROM `*PREFIX*properties` WHERE `fileid` = ?'; public const INSERT_BY_ID_STMT = 'INSERT INTO `*PREFIX*properties`' - . ' (`fileid`,`propertyname`,`propertyvalue`) VALUES(?,?,?)'; + . ' (`fileid`,`propertyname`,`propertyvalue`, `propertytype`) VALUES(?,?,?,?)'; public const UPDATE_BY_ID_AND_NAME_STMT = 'UPDATE `*PREFIX*properties`' - . ' SET `propertyvalue` = ? WHERE `fileid` = ? AND `propertyname` = ?'; + . ' SET `propertyvalue` = ?, `propertytype` = ? WHERE `fileid` = ? AND `propertyname` = ?'; public const DELETE_BY_ID_STMT = 'DELETE FROM `*PREFIX*properties` WHERE `fileid` = ?'; public const DELETE_BY_ID_AND_NAME_STMT = 'DELETE FROM `*PREFIX*properties`' . ' WHERE `fileid` = ? AND `propertyname` = ?'; @@ -168,9 +168,6 @@ protected function updateProperties($path, INode $node, $changedProperties) { $existingProperties = $this->getProperties($path, $node, []); '@phan-var \OCA\DAV\Connector\Sabre\Node $node'; $fileId = $node->getId(); - $deleteStatement = self::DELETE_BY_ID_AND_NAME_STMT; - $insertStatement = self::INSERT_BY_ID_STMT; - $updateStatement = self::UPDATE_BY_ID_AND_NAME_STMT; // TODO: use "insert or update" strategy ? $this->connection->beginTransaction(); @@ -179,7 +176,8 @@ protected function updateProperties($path, INode $node, $changedProperties) { // If it was null, we need to delete the property if ($propertyValue === null) { if ($propertyExists) { - $this->connection->executeUpdate($deleteStatement, + $this->connection->executeUpdate( + self::DELETE_BY_ID_AND_NAME_STMT, [ $fileId, $propertyName @@ -187,24 +185,23 @@ protected function updateProperties($path, INode $node, $changedProperties) { ); } } else { - // FIXME: PHP 7.4 handles object serialization differently so we store 'Object' here - // to keep old (wrong) behavior and fix Oracle failure - // see https://github.com/owncloud/core/issues/32670 - if ($propertyValue instanceof Complex) { - $propertyValue = 'Object'; - } + $propertyData = $this->encodeValue($propertyValue); if (!$propertyExists) { - $this->connection->executeUpdate($insertStatement, + $this->connection->executeUpdate( + self::INSERT_BY_ID_STMT, [ $fileId, $propertyName, - $propertyValue + $propertyData['value'], + $propertyData['type'] ] ); } else { - $this->connection->executeUpdate($updateStatement, + $this->connection->executeUpdate( + self::UPDATE_BY_ID_AND_NAME_STMT, [ - $propertyValue, + $propertyData['value'], + $propertyData['type'], $fileId, $propertyName ] @@ -266,7 +263,7 @@ protected function loadChildrenProperties(INode $node, $requestedProperties) { ); while ($row = $result->fetch()) { $props = $this->offsetGet($row['fileid']) ?? []; - $props[$row['propertyname']] = $row['propertyvalue']; + $props[$row['propertyname']] = $this->decodeValue($row['propertyvalue'], (int) $row['propertytype']); $this->offsetSet($row['fileid'], $props); } $result->closeCursor(); diff --git a/apps/dav/lib/DAV/MiscCustomPropertiesBackend.php b/apps/dav/lib/DAV/MiscCustomPropertiesBackend.php index e0706f84c203..f210fd50eab5 100644 --- a/apps/dav/lib/DAV/MiscCustomPropertiesBackend.php +++ b/apps/dav/lib/DAV/MiscCustomPropertiesBackend.php @@ -36,11 +36,11 @@ class MiscCustomPropertiesBackend extends AbstractCustomPropertiesBackend { const SELECT_BY_PATH_STMT = 'SELECT * FROM `*PREFIX*dav_properties` WHERE `propertypath` = ?'; const INSERT_BY_PATH_STMT = 'INSERT INTO `*PREFIX*dav_properties`' - . ' (`propertypath`, `propertyname`, `propertyvalue`) VALUES(?,?,?)'; + . ' (`propertypath`, `propertyname`, `propertyvalue`, `propertytype`) VALUES(?,?,?,?)'; const UPDATE_BY_PATH_STMT = 'UPDATE `*PREFIX*dav_properties`' . ' SET `propertypath` = ? WHERE `propertypath` = ?'; const UPDATE_BY_PATH_AND_NAME_STMT = 'UPDATE `*PREFIX*dav_properties` ' - . 'SET `propertyvalue` = ? WHERE `propertypath` = ? AND `propertyname` = ?'; + . 'SET `propertyvalue` = ?, `propertytype` = ? WHERE `propertypath` = ? AND `propertyname` = ?'; const DELETE_BY_PATH_STMT = 'DELETE FROM `*PREFIX*dav_properties` WHERE `propertypath` = ?'; const DELETE_BY_PATH_AND_NAME_STMT = 'DELETE FROM `*PREFIX*dav_properties`' . ' WHERE `propertypath` = ? AND `propertyname` = ?'; @@ -110,9 +110,6 @@ protected function getProperties($path, INode $node, array $requestedProperties) */ protected function updateProperties($path, INode $node, $changedProperties) { $existingProperties = $this->getProperties($path, $node, []); - $deleteStatement = self::DELETE_BY_PATH_AND_NAME_STMT; - $insertStatement = self::INSERT_BY_PATH_STMT; - $updateStatement = self::UPDATE_BY_PATH_AND_NAME_STMT; // TODO: use "insert or update" strategy ? $this->connection->beginTransaction(); @@ -121,7 +118,8 @@ protected function updateProperties($path, INode $node, $changedProperties) { // If it was null, we need to delete the property if ($propertyValue === null) { if ($propertyExists) { - $this->connection->executeUpdate($deleteStatement, + $this->connection->executeUpdate( + self::DELETE_BY_PATH_AND_NAME_STMT, [ $path, $propertyName @@ -129,18 +127,23 @@ protected function updateProperties($path, INode $node, $changedProperties) { ); } } else { + $propertyData = $this->encodeValue($propertyValue); if (!$propertyExists) { - $this->connection->executeUpdate($insertStatement, + $this->connection->executeUpdate( + self::INSERT_BY_PATH_STMT, [ $path, $propertyName, - $propertyValue + $propertyData['value'], + $propertyData['type'] ] ); } else { - $this->connection->executeUpdate($updateStatement, + $this->connection->executeUpdate( + self::UPDATE_BY_PATH_AND_NAME_STMT, [ - $propertyValue, + $propertyData['value'], + $propertyData['type'], $path, $propertyName ] diff --git a/changelog/unreleased/37314 b/changelog/unreleased/37314 new file mode 100644 index 000000000000..71c05b41b2d4 --- /dev/null +++ b/changelog/unreleased/37314 @@ -0,0 +1,8 @@ +Bugfix: Properly store complex Webdav properties + +Fixed: setting custom complex DAV property and reading it returned +just an 'Object' string instead of the original property value. + +https://github.com/owncloud/core/pull/37314 +https://github.com/owncloud/core/issues/32670 +https://github.com/owncloud/core/issues/37027 diff --git a/tests/acceptance/features/apiWebdavProperties/setFileProperties.feature b/tests/acceptance/features/apiWebdavProperties/setFileProperties.feature index 1219a905deaf..f76b26f3ed51 100644 --- a/tests/acceptance/features/apiWebdavProperties/setFileProperties.feature +++ b/tests/acceptance/features/apiWebdavProperties/setFileProperties.feature @@ -20,14 +20,12 @@ Feature: set file properties | old | | new | - @issue-32670 Scenario Outline: Setting custom complex DAV property and reading it Given using DAV path And user "user0" has uploaded file "filesForUpload/textfile.txt" to "/testcustomprop.txt" And user "user0" has set property "very-custom-prop" with namespace "x1='http://whatever.org/ns'" of file "/testcustomprop.txt" to "" When user "user0" gets a custom property "very-custom-prop" with namespace "x1='http://whatever.org/ns'" of file "/testcustomprop.txt" - Then the response should contain a custom "very-custom-prop" property with namespace "x1='http://whatever.org/ns'" and value "Object" - #Then the response should contain a custom "very-custom-prop" property with namespace "x1='http://whatever.org/ns'" and value "" + Then the response should contain a custom "very-custom-prop" property with namespace "x1='http://whatever.org/ns'" and value "" Examples: | dav_version | | old | From be4402f568e41aa490a3cdecc0d2bfe04b9e7821 Mon Sep 17 00:00:00 2001 From: Victor Dubiniuk Date: Wed, 29 Apr 2020 13:19:42 +0300 Subject: [PATCH 2/3] Update acceptance and unit test --- .../DAV/FileCustomPropertiesBackendTest.php | 7 ++++ .../setFileProperties.feature | 2 +- .../bootstrap/WebDavPropertiesContext.php | 40 +++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/apps/dav/tests/unit/DAV/FileCustomPropertiesBackendTest.php b/apps/dav/tests/unit/DAV/FileCustomPropertiesBackendTest.php index 5ee8bd6e4f4e..c3ef2c213294 100644 --- a/apps/dav/tests/unit/DAV/FileCustomPropertiesBackendTest.php +++ b/apps/dav/tests/unit/DAV/FileCustomPropertiesBackendTest.php @@ -36,6 +36,7 @@ use OCP\Files\Node; use Sabre\DAV\PropFind; use Sabre\DAV\SimpleCollection; +use Sabre\DAV\Xml\Property\Complex; /** * Class FileCustomPropertiesBackendTest @@ -146,6 +147,7 @@ private function applyDefaultProps($path = '/dummypath') { $propPatch = new \Sabre\DAV\PropPatch([ 'customprop' => 'value1', 'customprop2' => 'value2', + 'customprop3' => new Complex('') ]); $this->backend->propPatch( @@ -253,6 +255,7 @@ public function testSetGetPropertiesForFile() { [ 'customprop', 'customprop2', + 'customprop3', 'unsetprop', ], 0 @@ -265,6 +268,10 @@ public function testSetGetPropertiesForFile() { $this->assertEquals('value1', $propFind->get('customprop')); $this->assertEquals('value2', $propFind->get('customprop2')); + /** @var Complex $complexProp */ + $complexProp = $propFind->get('customprop3'); + $this->assertInstanceOf(Complex::class, $complexProp); + $this->assertEquals('', $complexProp->getXml()); $this->assertEquals(['unsetprop'], $propFind->get404Properties()); } diff --git a/tests/acceptance/features/apiWebdavProperties/setFileProperties.feature b/tests/acceptance/features/apiWebdavProperties/setFileProperties.feature index f76b26f3ed51..29ebfac0d1e0 100644 --- a/tests/acceptance/features/apiWebdavProperties/setFileProperties.feature +++ b/tests/acceptance/features/apiWebdavProperties/setFileProperties.feature @@ -25,7 +25,7 @@ Feature: set file properties And user "user0" has uploaded file "filesForUpload/textfile.txt" to "/testcustomprop.txt" And user "user0" has set property "very-custom-prop" with namespace "x1='http://whatever.org/ns'" of file "/testcustomprop.txt" to "" When user "user0" gets a custom property "very-custom-prop" with namespace "x1='http://whatever.org/ns'" of file "/testcustomprop.txt" - Then the response should contain a custom "very-custom-prop" property with namespace "x1='http://whatever.org/ns'" and value "" + Then the response should contain a custom "very-custom-prop" property with namespace "x1='http://whatever.org/ns'" and complex value "" Examples: | dav_version | | old | diff --git a/tests/acceptance/features/bootstrap/WebDavPropertiesContext.php b/tests/acceptance/features/bootstrap/WebDavPropertiesContext.php index 004c2c6eb148..2123869cf4ed 100644 --- a/tests/acceptance/features/bootstrap/WebDavPropertiesContext.php +++ b/tests/acceptance/features/bootstrap/WebDavPropertiesContext.php @@ -347,6 +347,46 @@ public function theResponseShouldContainACustomPropertyWithValue( ); } + /** + * @Then /^the response should contain a custom "([^"]*)" property with namespace "([^"]*)" and complex value "(([^"\\]|\\.)*)"$/ + * + * @param string $propertyName + * @param string $namespaceString + * @param string $propertyValue + * + * @return void + * @throws \Exception + */ + public function theResponseShouldContainACustomPropertyWithComplexValue( + $propertyName, $namespaceString, $propertyValue + ) { + // let's unescape quotes first + $propertyValue = \str_replace('\"', '"', $propertyValue); + $this->featureContext->setResponseXmlObject( + HttpRequestHelper::getResponseXml($this->featureContext->getResponse()) + ); + $responseXmlObject = $this->featureContext->getResponseXmlObject(); + //calculate the namespace prefix and namespace + $matches = []; + \preg_match("/^(.*)='(.*)'$/", $namespaceString, $matches); + $nameSpace = $matches[2]; + $nameSpacePrefix = $matches[1]; + $responseXmlObject->registerXPathNamespace( + $nameSpacePrefix, $nameSpace + ); + $xmlPart = $responseXmlObject->xpath( + "//d:prop/" . "$nameSpacePrefix:$propertyName" . "/*" + ); + Assert::assertArrayHasKey( + 0, $xmlPart, "Cannot find property \"$propertyName\"" + ); + Assert::assertEquals( + $propertyValue, $xmlPart[0]->asXML(), + "\"$propertyName\" has a value \"" . + $xmlPart[0]->asXML() . "\" but \"$propertyValue\" expected" + ); + } + /** * @Then the single response should contain a property :property with a child property :childProperty * From 3f38112a03cf9e1cb1a818c9f4d564ac8dbd78d7 Mon Sep 17 00:00:00 2001 From: Victor Dubiniuk Date: Mon, 4 May 2020 11:58:38 +0300 Subject: [PATCH 3/3] Bump dav app version to trigger migration --- apps/dav/appinfo/info.xml | 2 +- tests/Core/Command/Apps/AppsListTest.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/dav/appinfo/info.xml b/apps/dav/appinfo/info.xml index 125ff50f72bb..f722a3c732c3 100644 --- a/apps/dav/appinfo/info.xml +++ b/apps/dav/appinfo/info.xml @@ -5,7 +5,7 @@ ownCloud WebDAV endpoint AGPL owncloud.org - 0.5.0 + 0.6.0 true diff --git a/tests/Core/Command/Apps/AppsListTest.php b/tests/Core/Command/Apps/AppsListTest.php index 2d7b364088f2..72b93a36dfad 100644 --- a/tests/Core/Command/Apps/AppsListTest.php +++ b/tests/Core/Command/Apps/AppsListTest.php @@ -58,9 +58,9 @@ public function testCommandInput($input, $expectedOutput) { public function providesAppIds() { return [ [[], '- files: 1.5'], - [['--shipped' => 'true'], '- dav: 0.5.0'], + [['--shipped' => 'true'], '- dav: 0.6.0'], [['--shipped' => 'false'], '- comments:'], - [['search-pattern' => 'dav'], '- dav: 0.5.0'] + [['search-pattern' => 'dav'], '- dav: 0.6.0'] ]; } }