diff --git a/apps/dav/lib/Capabilities.php b/apps/dav/lib/Capabilities.php index ec447d193fb5..a916cce07eba 100644 --- a/apps/dav/lib/Capabilities.php +++ b/apps/dav/lib/Capabilities.php @@ -30,7 +30,12 @@ public function getCapabilities() { return [ 'dav' => [ 'chunking' => '1.0', + 'zsync' => '1.0', + 'reports' => [ + 'search-files', + ], ] ]; } } + diff --git a/apps/dav/lib/Connector/Sabre/FilesSearchReportPlugin.php b/apps/dav/lib/Connector/Sabre/FilesSearchReportPlugin.php new file mode 100644 index 000000000000..2953bf660b47 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/FilesSearchReportPlugin.php @@ -0,0 +1,201 @@ + + * + * @copyright Copyright (c) 2018, 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\Connector\Sabre; + +use Sabre\DAV\Server as DavServer; +use Sabre\DAV\ServerPlugin; +use Sabre\DAV\PropFind; +use Sabre\DAV\Exception\BadRequest; +use Sabre\DAV\Exception\NotImplemented; +use OCA\DAV\Files\Xml\SearchRequest; +use OCP\ISearch; +use OC\Search\Result\File as FileResult; + +class FilesSearchReportPlugin extends ServerPlugin { + // namespace + const NS_OWNCLOUD = 'http://owncloud.org/ns'; + const REPORT_NAME = '{http://owncloud.org/ns}search-files'; + + /** + * Reference to main server object + * + * @var DavServer + */ + private $server; + + /** @var ISearch */ + private $searchService; + + public function __construct(ISearch $searchService) { + $this->searchService = $searchService; + } + + /** + * This initializes the plugin. + * + * This function is called by \Sabre\DAV\Server, after + * addPlugin is called. + * + * This method should set up the required event subscriptions. + * + * @param DavServer $server + * @return void + */ + public function initialize(DavServer $server) { + $server->xml->namespaceMap[self::NS_OWNCLOUD] = 'oc'; + + $server->xml->elementMap[self::REPORT_NAME] = SearchRequest::class; + + $this->server = $server; + $this->server->on('report', [$this, 'onReport']); + } + + /** + * Returns a list of reports this plugin supports. + * + * This will be used in the {DAV:}supported-report-set property. + * + * @param string $uri + * @return array + */ + public function getSupportedReportSet($uri) { + $reportTargetNode = $this->server->tree->getNodeForPath($uri); + if ($reportTargetNode instanceof Directory && $reportTargetNode->getPath() === '/') { + return [self::REPORT_NAME]; + } else { + return []; + } + } + + /** + * REPORT operations to look for files + * + * @param string $reportName + * @param mixed $report + * @param string $uri + * @return bool + * @throws BadRequest + * @throws NotImplemented + * @internal param $ [] $report + */ + public function onReport($reportName, $report, $uri) { + $reportTargetNode = $this->server->tree->getNodeForPath($uri); + if (!$reportTargetNode instanceof Directory || + $reportName !== self::REPORT_NAME) { + return; + } + + if ($reportTargetNode->getPath() !== '/') { + throw new NotImplemented('Search report only available in the root folder of the user'); + } + + $requestedProps = $report->properties; + $searchInfo = $report->searchInfo; + + if (!isset($searchInfo['pattern'])) { + throw new BadRequest('Search pattern cannot be empty'); + } + + $limit = 30; + if (isset($searchInfo['limit'])) { + $limit = $searchInfo['limit']; + } + + $searchResults = $this->searchService->searchPaged( + $searchInfo['pattern'], + ['files'], + 1, + $limit + ); + + $filesUri = $this->getFilesBaseUri($uri, $reportTargetNode->getPath()); + + $xml = $this->server->generateMultiStatus( + $this->getSearchResultIterator($filesUri, $searchResults, $requestedProps) + ); + $this->server->httpResponse->setStatus(207); + $this->server->httpResponse->setHeader( + 'Content-Type', + 'application/xml; charset=utf-8' + ); + $this->server->httpResponse->setBody($xml); + + return false; + } + + /** + * @param string $filesUri the base uri for this user's files directory, + * usually /files/username + * @param File[] $searchResults the results coming from the search service, + * within the files app + * @param array $requestedProps the list of requested webDAV properties + * @return \Generator a generator to traverse over the properties of the + * search result, suitable for server's multistatus response + */ + private function getSearchResultIterator($filesUri, $searchResults, $requestedProps) { + $paths = \array_map(function ($searchResult) use ($filesUri) { + return $filesUri . $searchResult->path; + }, $searchResults); + + $nodes = $this->server->tree->getMultipleNodes($paths); + + $propFindType = $requestedProps ? PropFind::NORMAL : PropFind::ALLPROPS; + + foreach ($nodes as $path => $node) { + $propFind = new PropFind( + $path, + $requestedProps, + 0, + $propFindType + ); + $this->server->getPropertiesByNode($propFind, $node); + + $result = $propFind->getResultForMultiStatus(); + $result['href'] = $propFind->getPath(); + yield $result; + } + } + + /** + * Returns the base uri of the files root by removing + * the subpath from the URI + * + * @param string $uri URI from this request + * @param string $subPath subpath to remove from the URI + * + * @return string files base uri + */ + private function getFilesBaseUri($uri, $subPath) { + $uri = \trim($uri, '/'); + $subPath = \trim($subPath, '/'); + if ($subPath === '') { + $filesUri = $uri; + } else { + $filesUri = \substr($uri, 0, \strlen($uri) - \strlen($subPath)); + } + $filesUri = \trim($filesUri, '/'); + if ($filesUri === '') { + return ''; + } + return '/' . $filesUri; + } +} diff --git a/apps/dav/lib/Connector/Sabre/ServerFactory.php b/apps/dav/lib/Connector/Sabre/ServerFactory.php index 1273ab11975b..e78d6c716a2f 100644 --- a/apps/dav/lib/Connector/Sabre/ServerFactory.php +++ b/apps/dav/lib/Connector/Sabre/ServerFactory.php @@ -180,6 +180,11 @@ public function createServer($baseUri, \OC::$server->getGroupManager(), $userFolder )); + $server->addPlugin( + new \OCA\DAV\Connector\Sabre\FilesSearchReportPlugin( + \OC::$server->getSearch() + ) + ); // custom properties plugin must be the last one $server->addPlugin( diff --git a/apps/dav/lib/Files/Xml/SearchRequest.php b/apps/dav/lib/Files/Xml/SearchRequest.php new file mode 100644 index 000000000000..d50541176edd --- /dev/null +++ b/apps/dav/lib/Files/Xml/SearchRequest.php @@ -0,0 +1,81 @@ + + * + * @copyright Copyright (c) 2018, 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\Files\Xml; + +use Sabre\Xml\Element\Base; +use Sabre\Xml\Element\KeyValue; +use Sabre\Xml\Reader; +use Sabre\Xml\XmlDeserializable; + +class SearchRequest implements XmlDeserializable { + /** + * An array with requested properties. + * + * @var array + */ + public $properties; + + /** + * @var array + */ + public $searchInfo; + + public static function xmlDeserialize(Reader $reader) { + $newProps = [ + 'properties' => [], + 'searchInfo' => null, + ]; + + $elems = (array)$reader->parseInnerTree([ + '{DAV:}prop' => KeyValue::class, + '{http://owncloud.org/ns}search' => KeyValue::class, + ]); + + if (!\is_array($elems)) { + $elems = []; + } + + foreach ($elems as $elem) { + switch ($elem['name']) { + case '{DAV:}prop': + $newProps['properties'] = \array_keys($elem['value']); + break; + case '{http://owncloud.org/ns}search': + $value = $elem['value']; + if (isset($value['{http://owncloud.org/ns}pattern'])) { + $newProps['searchInfo']['pattern'] = $value['{http://owncloud.org/ns}pattern']; + } + if (isset($value['{http://owncloud.org/ns}limit'])) { + $newProps['searchInfo']['limit'] = (int)$value['{http://owncloud.org/ns}limit']; + } + break; + } + } + + $obj = new self(); + foreach ($newProps as $key => $value) { + $obj->$key = $value; + } + + return $obj; + } +} diff --git a/apps/dav/lib/Server.php b/apps/dav/lib/Server.php index 7f9689fff50f..1dfd73d1ed83 100644 --- a/apps/dav/lib/Server.php +++ b/apps/dav/lib/Server.php @@ -250,7 +250,8 @@ public function __construct(IRequest $request, $baseUri) { \OC::$server->getCommentsManager(), $userSession )); - if (!is_null($view)) { + + if ($view !== null) { $this->server->addPlugin(new FilesReportPlugin( $this->server->tree, $view, @@ -262,6 +263,11 @@ public function __construct(IRequest $request, $baseUri) { $userFolder )); } + $this->server->addPlugin( + new \OCA\DAV\Connector\Sabre\FilesSearchReportPlugin( + \OC::$server->getSearch() + ) + ); } // register plugins from apps diff --git a/apps/dav/tests/unit/Connector/Sabre/FilesSearchReportPluginTest.php b/apps/dav/tests/unit/Connector/Sabre/FilesSearchReportPluginTest.php new file mode 100644 index 000000000000..d9579210c01c --- /dev/null +++ b/apps/dav/tests/unit/Connector/Sabre/FilesSearchReportPluginTest.php @@ -0,0 +1,321 @@ + + * + * @copyright Copyright (c) 2018, 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\Tests\unit\Connector\Sabre; + +use OCP\ISearch; +use OCA\DAV\Files\Xml\SearchRequest; +use OCA\DAV\Connector\Sabre\FilesSearchReportPlugin; +use OCA\DAV\Connector\Sabre\Directory; +use OCA\DAV\Connector\Sabre\File; +use OCA\DAV\Connector\Sabre\Node; +use OC\Search\Result\File as ResultFile; +use Sabre\DAV\Tree; +use Sabre\DAV\Server; +use Sabre\DAV\PropFind; + +class FilesSearchReportPluginTest extends \Test\TestCase { + /** @var Server|\PHPUnit_Framework_MockObject_MockObject */ + private $server; + + /** @var Tree|\PHPUnit_Framework_MockObject_MockObject */ + private $tree; + + /** @var ISearch|\PHPUnit_Framework_MockObject_MockObject */ + private $searchService; + + /** @var FilesSearchReportPlugin|\PHPUnit_Framework_MockObject_MockObject */ + private $plugin; + + public function setUp() { + parent::setUp(); + + $this->tree = $this->getMockBuilder(Tree::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->server = $this->getMockBuilder(Server::class) + ->setConstructorArgs([$this->tree]) + ->setMethods(['getRequestUri', 'getBaseUri', 'generateMultiStatus']) + ->getMock(); + + $this->server->expects($this->any()) + ->method('getBaseUri') + ->will($this->returnValue('http://example.com/owncloud/remote.php/dav')); + + $this->searchService = $this->getMockBuilder(ISearch::class) + ->disableOriginalConstructor() + ->getMock(); + $this->plugin = new FilesSearchReportPlugin($this->searchService); + } + + private function setupBaseTreeNode($path, Node $node) { + $this->tree->expects($this->any()) + ->method('getNodeForPath') + ->with($path) + ->will($this->returnValue($node)); + + $this->server->expects($this->any()) + ->method('getRequestUri') + ->will($this->returnValue($path)); + } + + public function testOnReportFileNode() { + $base = '/remote.php/dav/files/user'; + $nodePath = '/totally/unrelated/13'; + $path = "{$base}{$nodePath}"; + + $node = $this->createMock(File::class); + $node->method('getPath')->willReturn($nodePath); + + $this->setupBaseTreeNode($path, $node); + $this->plugin->initialize($this->server); + + $this->assertNull($this->plugin->onReport(FilesSearchReportPlugin::REPORT_NAME, [], $path)); + } + + public function testOnReportWrongReportName() { + $base = '/remote.php/dav/files/user'; + $nodePath = '/totally/unrelated/13'; + $path = "{$base}{$nodePath}"; + + $node = $this->createMock(Directory::class); + $node->method('getPath')->willReturn($nodePath); + + $this->setupBaseTreeNode($path, $node); + $this->plugin->initialize($this->server); + + $this->assertNull($this->plugin->onReport('this name should not exist', [], $path)); + } + + /** + * @expectedException \Sabre\DAV\Exception\NotImplemented + */ + public function testOnReportNotRoot() { + $base = '/remote.php/dav/files/user'; + $nodePath = '/totally/unrelated/13'; + $path = "{$base}{$nodePath}"; + + $node = $this->createMock(Directory::class); + $node->method('getPath')->willReturn($nodePath); + + $this->setupBaseTreeNode($path, $node); + $this->plugin->initialize($this->server); + + $this->plugin->onReport(FilesSearchReportPlugin::REPORT_NAME, [], $path); + } + + public function onReportNoSearchPatternProvider() { + return [ + [[], null], + [['{http://owncloud.org/ns}fileid','{DAV:}getcontentlength'], null], + [[], []], + [['{http://owncloud.org/ns}fileid','{DAV:}getcontentlength'], []], + [[], ['limit' => 50]], + [['{http://owncloud.org/ns}fileid','{DAV:}getcontentlength'], ['limit' => 50]], + ]; + } + + /** + * @dataProvider onReportNoSearchPatternProvider + * @expectedException \Sabre\DAV\Exception\BadRequest + */ + public function testOnReportNoSearchPattern($properties, $searchInfo) { + $base = '/remote.php/dav/files/user'; + $nodePath = '/'; + $path = "{$base}{$nodePath}"; + + $parameters = new SearchRequest(); + $parameters->properties = $properties; + $parameters->searchInfo = $searchInfo; + + $node = $this->createMock(Directory::class); + $node->method('getPath')->willReturn($nodePath); + + $this->setupBaseTreeNode($path, $node); + $this->plugin->initialize($this->server); + + $this->plugin->onReport(FilesSearchReportPlugin::REPORT_NAME, $parameters, $path); + } + + public function onReportProvider() { + return [ + [[], ['pattern' => 'go']], + [['{http://owncloud.org/ns}fileid','{DAV:}getcontentlength'], ['pattern' => 'go']], + [[], ['pattern' => 'se', 'limit' => 5]], + [['{http://owncloud.org/ns}fileid','{DAV:}getcontentlength'], ['pattern' => 'se', 'limit' => 5]], + ]; + } + + /** + * @dataProvider onReportProvider + */ + public function testOnReport($properties, $searchInfo) { + $base = '/remote.php/dav/files/user'; + $nodePath = '/'; + $path = "{$base}{$nodePath}"; + + $parameters = new SearchRequest(); + $parameters->properties = $properties; + $parameters->searchInfo = $searchInfo; + + $node = $this->createMock(Directory::class); + $node->method('getPath')->willReturn($nodePath); + + $expectedLimit = (isset($searchInfo['limit'])) ? $searchInfo['limit'] : 30; + $realLimit = \min($expectedLimit, 8); + + $searchList = $this->getSearchList($searchInfo['pattern'], $realLimit); + $searchListNodePaths = \array_map(function ($path) use ($base) { + return "{$base}{$path}"; + }, $searchList); + + $this->searchService->method('searchPaged') + ->with($searchInfo['pattern'], ['files'], 1, $expectedLimit) + ->will($this->returnCallback(function ($pattern, $apps, $page, $limit) use ($searchList) { + return \array_map(function ($value) { + $mock = $this->createMock(ResultFile::class); + $mock->path = $value; + return $mock; + }, $searchList); + })); + + $this->tree->method('getMultipleNodes') + ->with($searchListNodePaths) + ->will($this->returnCallback(function ($paths) use ($searchList) { + $nodes = []; + foreach ($paths as $key => $path) { + $mock = $this->createMock(Node::class); + $mock->method('getName')->willReturn($path); + $mock->method('getId')->willReturn($key); + $mock->method('getSize')->willReturn($key * 1024); + $mock->method('getEtag')->willReturn(\str_repeat($key, 8)); + $nodes[$path] = $mock; + } + return $nodes; + })); + + $responses = []; + $this->server->expects($this->once()) + ->method('generateMultiStatus') + ->will($this->returnCallback(function ($responsesArg) use (&$responses) { + foreach ($responsesArg as $responseArg) { + $responses[] = $responseArg; + } + }) + ); + + $this->setupBaseTreeNode($path, $node); + $this->plugin->initialize($this->server); + + // setup a propfind handler to fill some data + $this->server->on('propFind', function (Propfind $propfind, $node) { + $propfind->handle('{http://owncloud.org/ns}fileid', $node->getId()); + $propfind->handle('{DAV:}getcontentlength', $node->getSize()); + $propfind->handle('{DAV:}getetag', $node->getEtag()); + }); + + $this->plugin->onReport(FilesSearchReportPlugin::REPORT_NAME, $parameters, $path); + + $this->assertEquals($realLimit, \count($responses)); + if ($properties === []) { + foreach ($responses as $key => $response) { + // dav properties should be shown while non-dav properties won't appear + $this->assertEquals($key * 1024, $response[200]['{DAV:}getcontentlength']); + $this->assertEquals(\str_repeat($key, 8), $response[200]['{DAV:}getetag']); + $this->assertFalse(isset($response[200]['{http://owncloud.org/ns}fileid'])); + } + } else { + $propfindProperties = [ + '{http://owncloud.org/ns}fileid', + '{DAV:}getcontentlength', + '{DAV:}getetag', + ]; + $foundProperties = \array_intersect($properties, $propfindProperties); + $notFoundProperties = \array_diff($properties, $propfindProperties); + $notRequestedProperties = \array_diff($propfindProperties, $properties); + foreach ($responses as $key => $response) { + // only requested properties should appear + foreach ($foundProperties as $foundProperty) { + switch ($foundProperty) { + case '{DAV:}getcontentlength': + $this->assertEquals($key * 1024, $response[200]['{DAV:}getcontentlength']); + break; + case '{DAV:}getetag': + $this->assertEquals(\str_repeat($key, 8), $response[200]['{DAV:}getetag']); + break; + case '{http://owncloud.org/ns}fileid': + $this->assertEquals($key, $response[200]['{http://owncloud.org/ns}fileid']); + break; + } + } + foreach ($notFoundProperties as $notFoundProperty) { + $this->assertTrue(isset($response[400][$notFoundProperty])); + } + foreach ($notRequestedProperties as $notRequestedProperty) { + $this->assertFalse(isset($response[200][$notRequestedProperty])); + $this->assertFalse(isset($response[400][$notRequestedProperty])); + } + } + } + } + + private function getSearchList($search, $numberOfItems) { + $results = []; + $pathParts = []; + for ($i = 0 ; $i < $numberOfItems ; $i++) { + $pathParts[] = $search . \strval($i); + $results[] = '/' . \implode('/', $pathParts); + } + return $results; + } + + public function getSupportedReportSetProvider() { + return [ + ['/remote.php/dav/files/user', '/totally/unrelated/13'], + ['/remote.php/dav/files/user', '/'], + ['/remote.php/webdav', '/totally/unrelated/13'], + ['/remote.php/webdav', '/'], + ]; + } + + /** + * @dataProvider getSupportedReportSetProvider + */ + public function testGetSupportedReportSet($base, $nodePath) { + $path = "{$base}{$nodePath}"; + + $node = $this->createMock(Directory::class); + $node->method('getPath')->willReturn($nodePath); + + $this->setupBaseTreeNode($path, $node); + $this->plugin->initialize($this->server); + + if ($nodePath === '/') { + $this->assertEquals( + ['{http://owncloud.org/ns}search-files'], + $this->plugin->getSupportedReportSet($path) + ); + } else { + $this->assertEquals([], $this->plugin->getSupportedReportSet($path)); + } + } +}