From 5246702a1125b8691845d5586f76b68ec9394ccf Mon Sep 17 00:00:00 2001 From: Dennis Pfahl Date: Tue, 27 May 2025 20:43:54 +0200 Subject: [PATCH 1/2] MDL-84399 core: add WebDAV SEARCH support to webdavlib Co-authored-by: Lucas Gehrlein --- lib/webdavlib.php | 168 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) diff --git a/lib/webdavlib.php b/lib/webdavlib.php index 510644727d3..cdb9fa6ab75 100644 --- a/lib/webdavlib.php +++ b/lib/webdavlib.php @@ -791,6 +791,174 @@ class webdav_client { } + /** + * Search for files using WebDAV method. + * + * Implements the WebDAV SEARCH extension (RFC 5323). + * + * @param string $path Request path for the DAV path + * @param string $username Username from the repository user in the nextcloud + * @param string $query Search query + * @param array $properties Optional array of DAV properties to return + * @return array|bool Array of matching files/folders or false on error + */ + public function search(string $path, string $username, string $query, array $properties = []): array|false { + try { + $path = validate_param($path, PARAM_PATH, NULL_NOT_ALLOWED, 'WebDAV search path'); + $username = validate_param($username, PARAM_RAW_TRIMMED, NULL_NOT_ALLOWED, 'WebDAV search username'); + $query = validate_param($query, PARAM_RAW_TRIMMED, NULL_NOT_ALLOWED, 'WebDAV search query'); + + // Escape literal pattern signs in the SEARCH query to prevent issues. + $query = str_replace('\\', '\\\\', $query); + $query = str_replace("%", "\%", $query); + $query = str_replace("_", "\_", $query); + + if (empty($path)) { + debugging('Missing a path in WebDAV method search', DEBUG_DEVELOPER); + return false; + } + + if (empty($username)) { + debugging('Missing a username in WebDAV method search', DEBUG_DEVELOPER); + return false; + } + + if (empty($query)) { + debugging('Missing a query in WebDAV method search', DEBUG_DEVELOPER); + return false; + } + } catch (invalid_parameter_exception $e) { + debugging('Invalid parameter in WebDAV method search: ' . $e->getMessage(), DEBUG_DEVELOPER); + return false; + } + + $this->_path = $this->translate_uri($path); + + // Restrict search scope to user's personal files to enforce security boundaries. + $userpath = '/files/' . $username . '/'; + + $this->header_unset(); + $this->create_basic_request('SEARCH'); + $this->header_add('Content-type: text/xml'); + + // Provide sensible defaults to reduce API complexity for common use cases. + if (empty($properties)) { + $properties = [ + 'displayname', + 'getcontentlength', + 'getlastmodified', + 'resourcetype', + ]; + } + + // Build the SEARCH request XML using XMLWriter. + $xmlwriter = new XMLWriter(); + $xmlwriter->openMemory(); + $xmlwriter->startDocument('1.0', 'UTF-8'); + $xmlwriter->setIndent(true); + $xmlwriter->setIndentString(' '); + + $xmlwriter->startElement('d:searchrequest'); + $xmlwriter->writeAttribute('xmlns:d', 'DAV:'); + + $xmlwriter->startElement('d:basicsearch'); + + $xmlwriter->startElement('d:select'); + $xmlwriter->startElement('d:prop'); + foreach ($properties as $prop) { + $xmlwriter->writeElement('d:' . basename($prop)); + } + $xmlwriter->endElement(); + $xmlwriter->endElement(); + + $xmlwriter->startElement('d:from'); + $xmlwriter->startElement('d:scope'); + $xmlwriter->writeElement('d:href', $userpath); + $xmlwriter->writeElement('d:depth', 'infinity'); + $xmlwriter->endElement(); + $xmlwriter->endElement(); + + $xmlwriter->startElement('d:where'); + $xmlwriter->startElement('d:like'); + $xmlwriter->startElement('d:prop'); + $xmlwriter->writeElement('d:displayname'); + $xmlwriter->endElement(); + // Add wildcards to enable partial string matching. + $xmlwriter->writeElement('d:literal', '%' . $query . '%'); + $xmlwriter->endElement(); + $xmlwriter->endElement(); + + $xmlwriter->writeElement('d:orderby'); + + $xmlwriter->endElement(); + $xmlwriter->endElement(); + + $xml = $xmlwriter->outputMemory(); + + $this->header_add('Content-length: ' . strlen($xml)); + $this->send_request(); + debugging('WebDAV search XML request: ' . $xml, DEBUG_DEVELOPER); + fputs($this->sock, $xml); + $this->get_respond(); + $response = $this->process_respond(); + + // Parsing of the response is taking place in the same way as in the ls() implementation. + // Check http-version. + if ( + $response['status']['http-version'] === 'HTTP/1.1' || + $response['status']['http-version'] === 'HTTP/1.0' + ) { + // RFC 4918 requires 207 Multi-Status for successful SEARCH operations with multiple results. + if (strcmp($response['status']['status-code'], '207') === 0) { + // Ensure the response is valid XML before attempting to parse it. + if (preg_match('#(application|text)/xml;\s?charset=[\'\"]?utf-8[\'\"]?#i', $response['header']['Content-Type'])) { + // Create namespace-aware parser to handle DAV properties correctly. + $this->_parser = xml_parser_create_ns('UTF-8'); + $this->_parserid = $this->get_parser_id($this->_parser); + // Clear previous parsing results to prevent data contamination. + unset($this->_ls[$this->_parserid]); + unset($this->_xmltree[$this->_parserid]); + xml_parser_set_option($this->_parser, XML_OPTION_SKIP_WHITE, 0); + xml_parser_set_option($this->_parser, XML_OPTION_CASE_FOLDING, 0); + xml_set_element_handler($this->_parser, [$this, "_propfind_startElement"], [$this, "_endElement"]); + xml_set_character_data_handler($this->_parser, [$this, "_propfind_cdata"]); + + if (!xml_parse($this->_parser, $response['body'])) { + $errormessage = sprintf( + "XML parsing in WebDAV search failed: %s at line %d", + xml_error_string(xml_get_error_code($this->_parser)), + xml_get_current_line_number($this->_parser) + ); + + xml_parser_free($this->_parser); + debugging($errormessage, DEBUG_DEVELOPER); + return false; + } + + xml_parser_free($this->_parser); + $arr = $this->_ls[$this->_parserid]; + // Return empty array instead of false for no results to maintain consistent API behavior. + if (!is_array($arr)) { + debugging('WebDAV search: No results found or invalid response format.', DEBUG_DEVELOPER); + return []; + } + return $arr; + } else { + debugging('Missing Content-Type: text/xml header in response!', DEBUG_DEVELOPER); + return false; + } + } else { + debugging('WebDAV search: Unexpected HTTP status code: ' . $response['status']['status-code'] . + '. Expected 207 Multi-Status', DEBUG_NORMAL); + return false; + } + } + + debugging('WebDAV search: Invalid HTTP response version: ' . + ($response['status']['http-version'] ?? 'unknown'), DEBUG_NORMAL); + return false; + } + /** * Public method ls * From 22d7f46deb182fa475257c858a431b4607e5af29 Mon Sep 17 00:00:00 2001 From: Dennis Pfahl Date: Tue, 27 May 2025 20:49:13 +0200 Subject: [PATCH 2/2] MDL-84399 repository_nextcloud: Add search support using WebDAV Co-authored-by: Lucas Gehrlein --- repository/nextcloud/lib.php | 70 +++++- repository/nextcloud/tests/lib_test.php | 269 +++++++++++++++++++++++- 2 files changed, 336 insertions(+), 3 deletions(-) diff --git a/repository/nextcloud/lib.php b/repository/nextcloud/lib.php index c2580ba7f14..a503b4221db 100644 --- a/repository/nextcloud/lib.php +++ b/repository/nextcloud/lib.php @@ -621,6 +621,68 @@ class repository_nextcloud extends repository { } } + /** + * Search for files in the Nextcloud repository. + * + * @param string $searchtext The text to search for + * @param int $page Page number of results to fetch (Not used) + * @return array An array with format matching repository::get_listing() + */ + public function search($searchtext, $page = 0): array { + if (empty($searchtext)) { + return []; + } + + $ret = $this->get_listing_prepare_response('/'); + + if (empty($this->client)) { + debugging('OAuth client not initialized.', DEBUG_DEVELOPER); + return $ret; + } + + $this->initiate_webdavclient(); + if (!$this->dav->open()) { + debugging('Failed to open WebDAV connection.', DEBUG_DEVELOPER); + return $ret; + } + + // Extract the username from the oauth2 client. + // It is needed for the webdav search request and subsequent response conversion. + $userinfo = $this->client->get_userinfo(); + if (empty($userinfo) || !is_array($userinfo)) { + debugging('Nextcloud search: Unable to extract userinfo from OAuth2 client', DEBUG_DEVELOPER); + $this->dav->close(); + return $ret; + } + + $username = $userinfo['username']; + if (empty($username)) { + debugging('Nextcloud search: Unable to extract username from OAuth2 client userinfo', DEBUG_DEVELOPER); + $this->dav->close(); + return $ret; + } + + // Use native WebDAV search. + // Manually setting the dav endpoint different to the one from the nextcloud issuer. + // The original dav endpoint /remote.php/webdav does not support SEARCH. + $searchdavpath = '/remote.php/dav/'; + $results = $this->dav->search($searchdavpath, $username, $searchtext); + + if (is_array($results)) { + // Convert WebDAV results to Moodle repository format. + // Parameter endpoint url for proper href parsing, since they have a different + // base urls in the search request response, which includes the username. + $endpointurl = $searchdavpath . 'files/' . $username; + $ret['list'] = $this->get_listing_convert_response('/', $results, $endpointurl); + } else { + // If the search request failed. + debugging('Nextcloud search: WebDAV search request failed.', DEBUG_DEVELOPER); + } + + $this->dav->close(); + return $ret; + } + /** * Deletes the held Access Token and prints the Login window. * @@ -785,13 +847,17 @@ class repository_nextcloud extends repository { * * @param string $dirpath Relative (urlencoded) path of the folder of interest. * @param array $ls Output by WebDAV + * @param string $endpointurl Optional different endpoint URL to use for href parsing. * @return array Moodle-formatted list of directory contents; ready for use as $ret['list'] in get_listings */ - private function get_listing_convert_response($dirpath, $ls) { + private function get_listing_convert_response(string $dirpath, array $ls, string $endpointurl = ''): array { global $OUTPUT; $folders = array(); $files = array(); $parsedurl = issuer_management::parse_endpoint_url('webdav', $this->issuer); + if (!empty($endpointurl)) { + $parsedurl['path'] = $endpointurl; + } $basepath = rtrim('/' . ltrim($parsedurl['path'], '/ '), '/ '); foreach ($ls as $item) { @@ -878,7 +944,7 @@ class repository_nextcloud extends repository { $ret = [ // Fetch the list dynamically. An AJAX request is sent to the server as soon as the user opens a folder. 'dynload' => true, - 'nosearch' => true, // Disable search. + 'nosearch' => false, // Enable search. 'nologin' => false, // Provide a login link because a user logs into his/her private Nextcloud storage. 'path' => array([ // Contains all parent paths to the current path. 'name' => $this->get_meta()->name, diff --git a/repository/nextcloud/tests/lib_test.php b/repository/nextcloud/tests/lib_test.php index f8c21441dd7..d7b418b0a6b 100644 --- a/repository/nextcloud/tests/lib_test.php +++ b/repository/nextcloud/tests/lib_test.php @@ -23,8 +23,10 @@ */ namespace repository_nextcloud; +use PHPUnit\Framework\MockObject\MockObject; use repository; use repository_nextcloud; +use webdav_client; defined('MOODLE_INTERNAL') || die(); @@ -1013,7 +1015,7 @@ XML; protected function get_initialised_return_array() { $ret = array(); $ret['dynload'] = true; - $ret['nosearch'] = true; + $ret['nosearch'] = false; $ret['nologin'] = false; $ret['path'] = [ [ @@ -1029,4 +1031,269 @@ XML; return $ret; } + + /** + * Helper method to create a mock WebDAV client for search scenarios. + * + * @param bool $openreturn What the open() method should return + * @param array|bool|null $searchreturn What the search() method should return (null to skip expectation) + * @param string $searchpath Expected search path for search method + * @param string $searchuser Expected username for search method + * @param string $searchquery Expected query for search method + * @param bool $expectsearch Whether to expect search() to be called + * @return MockObject + */ + protected function create_webdav_mock(bool $openreturn = true, array|bool|null $searchreturn = null, + string $searchpath = '/remote.php/dav/', string $searchuser = 'testuser', string $searchquery = 'test', + bool $expectsearch = true): MockObject { + $mock = $this->createMock(webdav_client::class); + + $mock->expects($this->once()) + ->method('open') + ->willReturn($openreturn); + + // Only set search expectations when the connection succeeds and search is expected. + if ($searchreturn !== null && $expectsearch && $openreturn) { + $mock->expects($this->once()) + ->method('search') + ->with($searchpath, $searchuser, $searchquery) + ->willReturn($searchreturn); + } else if (!$expectsearch) { + $mock->expects($this->never()) + ->method('search'); + } + + // Only expect close() when connection was successful. + if ($openreturn) { + $mock->expects($this->once()) + ->method('close'); + } + + $this->set_private_property($mock, 'dav'); + + return $mock; + } + + /** + * Helper method to create a mock OAuth2 client for search scenarios. + * + * @param array $userinfo The userinfo to return from get_userinfo() + * @return MockObject + */ + protected function create_oauth_mock(array $userinfo = ['username' => 'testuser']): MockObject { + $mock = $this->createMock(\core\oauth2\client::class); + + $mock->expects($this->any()) + ->method('get_userinfo') + ->willReturn($userinfo); + + $this->set_private_property($mock, 'client'); + + return $mock; + } + + /** + * Test search functionality with various inputs using the WebDAV search endpoint. + * + * @dataProvider search_provider + * @param string $searchtext The text to search for + * @param array|bool $mockresponse The mock response from the webdav search method + * @param array $expectedlist The expected result list + * @covers ::search + */ + public function test_search(string $searchtext, array|bool $mockresponse, array $expectedlist): void { + $expected = $this->get_initialised_return_array(); + $expected['list'] = $expectedlist; + + $this->resetAfterTest(true); + $this->setUser(); + + $this->create_webdav_mock(true, $mockresponse, '/remote.php/dav/', 'testuser', $searchtext); + $this->create_oauth_mock(['username' => 'testuser']); + + $result = $this->repo->search($searchtext); + + // Verify error logging occurs for failed searches to help with debugging. + if ($mockresponse === false) { + $this->assertDebuggingCalled('Nextcloud search: WebDAV search request failed.', DEBUG_DEVELOPER); + } + + // Remove dynamic thumbnail values to enable reliable comparison. + if (isset($result['list'])) { + foreach ($result['list'] as &$item) { + if (isset($item['thumbnail'])) { + $item['thumbnail'] = null; + } + } + } + + $this->assertEquals($expected, $result); + } + + /** + * Test search error handling scenarios. + * + * @dataProvider search_error_provider + * @param string $searchtext The search query to test + * @param array|null $userinfo OAuth userinfo to mock (null for no client) + * @param string $expecteddebugging Expected debugging message + * @param bool $openreturn Whether WebDAV open should succeed + * @param bool $expectsearch Whether search method should be called + * @covers ::search + */ + public function test_search_errors(string $searchtext, array|null $userinfo, string $expecteddebugging, + bool $openreturn = true, bool $expectsearch = true): void { + $this->resetAfterTest(true); + $this->setUser(); + $expected = $this->get_initialised_return_array(); + + // Handle special case where no client is set to test initialization errors. + if ($userinfo === null) { + $this->set_private_property(null, 'client'); + } else { + $searchuser = $userinfo['username'] ?? ''; + $this->create_webdav_mock($openreturn, null, '/remote.php/dav/', + $searchuser, $searchtext, $expectsearch); + $this->create_oauth_mock($userinfo); + } + + $result = $this->repo->search($searchtext); + + // Verify appropriate debugging messages are logged for troubleshooting. + if (!empty($expecteddebugging)) { + $this->assertDebuggingCalled($expecteddebugging, DEBUG_DEVELOPER); + } + + $this->assertEquals($expected, $result); + } + + /** + * Data provider for search error scenarios. + * + * @return array + */ + public static function search_error_provider(): array { + return [ + 'WebDAV connection failure' => [ + 'searchtext' => 'test', + 'userinfo' => ['username' => 'testuser'], + 'expecteddebugging' => 'Failed to open WebDAV connection.', + 'openreturn' => false, + 'expectsearch' => false, + ], + 'Malformed WebDAV response' => [ + 'searchtext' => 'test', + 'userinfo' => ['username' => 'testuser'], + 'expecteddebugging' => 'Nextcloud search: WebDAV search request failed.', + ], + 'No OAuth client defined' => [ + 'searchtext' => 'test', + 'userinfo' => null, + 'expecteddebugging' => 'OAuth client not initialized.', + 'expectsearch' => false, + ], + 'Empty userinfo from OAuth client' => [ + 'searchtext' => 'test', + 'userinfo' => [], + 'expecteddebugging' => 'Nextcloud search: Unable to extract userinfo from OAuth2 client', + 'expectsearch' => false, + ], + 'Empty username from OAuth client' => [ + 'searchtext' => 'test', + 'userinfo' => ['username' => ''], + 'expecteddebugging' => 'Nextcloud search: Unable to extract username from OAuth2 client userinfo', + 'expectsearch' => false, + ], + ]; + } + + /** + * Data provider for search tests. + * + * @return array + */ + public static function search_provider(): array { + return [ + 'Basic search' => [ + 'searchtext' => 'test', + 'mockresponse' => [ + [ + 'href' => '/remote.php/dav/files/testuser/test.txt', + 'lastmodified' => 'Tue, 04 Mar 2025 11:35:29 GMT', + 'status' => 'HTTP/1.1 200 OK', + 'getcontentlength' => '123', + 'resourcetype' => '', + ], + ], + 'expectedlist' => [ + 'TEST.TXT' => [ + 'title' => 'test.txt', + 'size' => '123', + 'source' => '/test.txt', + 'thumbnail' => null, + 'datemodified' => 1741088129, + ], + ], + ], + 'Empty search results' => [ + 'searchtext' => 'notfound', + 'mockresponse' => [], + 'expectedlist' => [], + ], + 'Folder and file results' => [ + 'searchtext' => 'report', + 'mockresponse' => [ + [ + 'href' => '/remote.php/dav/files/testuser/Reports/', + 'lastmodified' => 'Tue, 04 Mar 2025 11:35:29 GMT', + 'status' => 'HTTP/1.1 200 OK', + 'getcontentlength' => '', + 'resourcetype' => 'collection', + ], + [ + 'href' => '/remote.php/dav/files/testuser/Reports/report-summary.txt', + 'lastmodified' => 'Tue, 04 Mar 2025 11:35:29 GMT', + 'status' => 'HTTP/1.1 200 OK', + 'getcontentlength' => '200', + 'resourcetype' => '', + ], + [ + 'href' => '/remote.php/dav/files/testuser/report-2023.pdf', + 'lastmodified' => 'Tue, 04 Mar 2025 11:35:29 GMT', + 'status' => 'HTTP/1.1 200 OK', + 'getcontentlength' => '1024', + 'resourcetype' => '', + ], + ], + 'expectedlist' => [ + 'REPORTS/' => [ + 'title' => 'Reports', + 'children' => [], + 'path' => '/Reports/', + 'thumbnail' => null, + 'datemodified' => 1741088129, + ], + 'REPORTS/REPORT-SUMMARY.TXT' => [ + 'title' => 'Reports/report-summary.txt', + 'size' => '200', + 'source' => '/Reports/report-summary.txt', + 'thumbnail' => null, + 'datemodified' => 1741088129, + ], + 'REPORT-2023.PDF' => [ + 'title' => 'report-2023.pdf', + 'size' => '1024', + 'source' => '/report-2023.pdf', + 'thumbnail' => null, + 'datemodified' => 1741088129, + ], + ], + ], + 'Failed search' => [ + 'searchtext' => 'error', + 'mockresponse' => false, + 'expectedlist' => [], + ], + ]; + } }