Merge branch 'MDL-84399-main' of https://github.com/dpfahl-ms/moodle
This commit is contained in:
@@ -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
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' => [],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user