Merge branch 'MDL-84399-main' of https://github.com/dpfahl-ms/moodle

This commit is contained in:
Huong Nguyen
2025-07-10 10:41:55 +07:00
3 changed files with 504 additions and 3 deletions
+168
View File
@@ -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
*
+68 -2
View File
@@ -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,
+268 -1
View File
@@ -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' => [],
],
];
}
}