diff --git a/lib/classes/route/api/templates.php b/lib/classes/route/api/templates.php new file mode 100644 index 00000000000..867c145e026 --- /dev/null +++ b/lib/classes/route/api/templates.php @@ -0,0 +1,157 @@ +. + +namespace core\route\api; + +use core\exception; +use core\param; +use core\router\route; +use core\output\mustache_template_source_loader; +use core\router\schema\response\payload_response; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; + +/** + * Template Controller. + * + * @package core + * @copyright Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class templates { + use \core\router\route_controller; + + /** + * Fetch a single template for a component in a theme. + * + * @param ResponseInterface $response + * @param string $themename + * @param string $component + * @param null|string $identifier + * @return payload_response + */ + #[route( + path: '/templates/{themename}/{component}/{identifier}', + method: ['GET'], + title: 'Fetch a single template', + description: 'Fetch a single template for a component in a theme', + security: [], + pathtypes: [ + new \core\router\parameters\path_themename(), + new \core\router\parameters\path_component(), + new \core\router\schema\parameters\path_parameter( + name: 'identifier', + type: param::SAFEPATH, + ), + ], + queryparams: [ + new \core\router\schema\parameters\query_parameter( + name: 'includecomments', + type: param::BOOL, + description: 'Include comments in the template', + default: false, + ), + ], + headerparams: [ + new \core\router\parameters\header_language(), + ], + responses: [ + new \core\router\schema\response\response( + statuscode: 200, + description: 'OK', + content: [ + new \core\router\schema\response\content\json_media_type( + schema: new \core\router\schema\objects\schema_object( + content: [ + 'templates' => new \core\router\schema\objects\array_of_strings( + keyparamtype: param::TEXT, + valueparamtype: param::RAW, + ), + 'strings' => new \core\router\schema\objects\array_of_strings( + keyparamtype: param::TEXT, + valueparamtype: param::RAW, + ), + ], + ), + examples: [ + new \core\router\schema\example( + name: 'Single template value', + summary: 'A json response containing the template for a single template', + value: [ + 'templates' => [ + "mod_example/template_identifier" => "
Hello World
", + "mod_example/other_template" => "
Hello World
", + ], + 'strings' => [ + 'core/loading' => 'Loading', + ], + ], + ), + ] + ), + ], + ), + ], + )] + public function get_templates( + ServerRequestInterface $request, + ResponseInterface $response, + mustache_template_source_loader $loader, + string $themename, + string $component, + string $identifier, + ): payload_response { + global $PAGE; + + $PAGE->set_context(\core\context\system::instance()); + + $params = $request->getQueryParams(); + $comments = $params['includecomments']; + + try { + $dependencies = $loader->load_with_dependencies( + templatecomponent: $component, + templatename: $identifier, + themename: $themename, + includecomments: $comments, + lang: $request->getHeaderLine('language'), + ); + } catch (\moodle_exception $e) { + throw new exception\not_found_exception('template', "{$component}/{$identifier}"); + } + + $result = [ + 'templates' => [], + 'strings' => [], + ]; + + foreach ($dependencies['templates'] as $component => $templates) { + foreach ($templates as $template => $value) { + $result['templates']["{$component}/{$template}"] = $value; + } + } + foreach ($dependencies['strings'] as $component => $templates) { + foreach ($templates as $template => $value) { + $result['strings']["{$component}/{$template}"] = $value; + } + } + + return new payload_response( + payload: $result, + request: $request, + ); + } +} diff --git a/lib/tests/route/api/templates_test.php b/lib/tests/route/api/templates_test.php new file mode 100644 index 00000000000..3a189cc83fe --- /dev/null +++ b/lib/tests/route/api/templates_test.php @@ -0,0 +1,112 @@ +. + +namespace core\route\api; + +use core\tests\route_testcase; + +/** + * Tests for Templates API. + * + * @package core + * @category test + * @copyright 2024 Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \core\route\api\templates + */ +final class templates_test extends route_testcase { + /** + * Test fetching templates. + * + * Note: This is a risky test because it relies on data in other parts of Moodle. + * + * @dataProvider fetch_templates_provider + * @param string $path + * @param array $requiredtemplates + * @param array $requiredstrings + */ + public function test_fetch_known_templates( + string $path, + array $requiredtemplates, + array $requiredstrings, + ): void { + $this->add_class_routes_to_route_loader(\core\route\api\templates::class); + $response = $this->process_api_request('GET', "/templates/{$path}"); + + $this->assert_valid_response($response); + $payload = $this->decode_response($response, true); + + $this->assert_payload_contains($payload, $requiredtemplates, $requiredstrings); + } + + /** + * Data propvider for template tests. + * + * @return array + */ + public static function fetch_templates_provider(): array { + return [ + 'fetch single template' => [ + 'boost/core/modal', + ['core/modal'], + [], + ], + 'foo' => [ + 'boost/core/notification', + [ + 'core/notification', + 'core/notification_success', + 'core/notification_warning', + 'core/notification_error', + 'core/notification_info', + ], + [ + 'core/dismissnotification', + ], + ], + ]; + } + + /** + * Assertthat the payload contains the required templates and strings. + * + * @param array $payload + * @param array $requiredtemplates + * @param array $requiredstrings + */ + protected function assert_payload_contains( + array $payload, + array $requiredtemplates = [], + array $requiredstrings = [], + ): void { + $this->assertArrayHasKey('templates', $payload); + $this->assertArrayHasKey('strings', $payload); + + foreach ($requiredtemplates as $template) { + $this->assertArrayHasKey($template, $payload['templates']); + } + foreach ($requiredstrings as $string) { + $this->assertArrayHasKey($string, $payload['strings']); + } + } + + public function test_template_missing(): void { + $this->add_class_routes_to_route_loader(\core\route\api\templates::class); + $response = $this->process_api_request('GET', '/templates/boost/core/missing'); + + $this->assert_not_found_response($response); + } +} diff --git a/user/classes/route/api/preferences.php b/user/classes/route/api/preferences.php new file mode 100644 index 00000000000..ff30b37dc90 --- /dev/null +++ b/user/classes/route/api/preferences.php @@ -0,0 +1,284 @@ +. + +namespace core_user\route\api; + +use core\exception\coding_exception; +use core\exception\invalid_parameter_exception; +use core\param; +use core\router\route; +use core\router\schema\objects\scalar_type; +use core\router\schema\response\payload_response; +use core\router\schema\response\content\payload_response_type; +use core\router\schema\response\response_type; +use core\user; +use core_user\route\responses\user_preferences_response; +use stdClass; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; + +/** + * User preference API handler. + * + * @package core_user + * @copyright Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +#[route( + path: '/{user}/preferences', + pathtypes: [ + new \core\router\parameters\path_user(), + ], +)] +class preferences { + /** + * Fetch all user preferences, or a specific user preference. + * + * @param ResponseInterface $response + * @param ServerRequestInterface $request + * @param stdClass $user + * @param null|string $preference + * @return payload_response + */ + #[route( + path: '[/{preference}]', + title: 'Fetch user preferences', + description: 'Fetch one user preference, or all user preferences', + pathtypes: [ + new \core\router\schema\parameters\path_parameter( + name: 'preference', + type: param::RAW, + ), + ], + responses: [ + new user_preferences_response(), + ], + )] + public function get_preferences( + ResponseInterface $response, + ServerRequestInterface $request, + stdClass $user, + ?string $preference, + ): payload_response { + $this->check_user($user); + + $result = get_user_preferences( + name: $preference, + user: $user, + ); + + if (!is_array($result)) { + // Check if we received just one preference. + $result = [$preference => $result]; + } + + return new payload_response($result, $request, $response); + } + + /** + * Set a set of user preferences. + * + * @param ResponseInterface $response + * @param stdClass $user + * @return payload_response + */ + #[route( + method: ['POST'], + title: 'Set or update multiple user preferences', + requestbody: new \core\router\schema\request_body( + content: new payload_response_type( + schema: new \core\router\schema\objects\schema_object( + content: [ + 'preferences' => new \core\router\schema\objects\array_of_strings( + keyparamtype: param::TEXT, + valueparamtype: param::RAW, + ), + ], + ), + ), + ), + responses: [ + new user_preferences_response(), + ], + )] + public function set_preferences( + ResponseInterface $response, + ServerRequestInterface $request, + stdClass $user, + ): payload_response { + $this->check_user($user); + + $values = $request->getParsedBody(); + $preferences = $values['preferences'] ?? []; + + foreach ($preferences as $preference => $value) { + $this->set_single_preference($user, $preference, $value); + } + + $result = array_filter( + get_user_preferences( + user: $user, + ), + fn ($preference) => array_key_exists($preference, $preferences), + ARRAY_FILTER_USE_KEY, + ); + + return new payload_response($result, $request, $response); + } + + /** + * Set a single user preference. + * + * @param ResponseInterface $response + * @param string $themename + * @param string $component + * @param null|string $identifier + * @return response_type + */ + #[route( + path: '/{preference}', + method: ['POST'], + title: 'Set a single user preference', + description: 'Set a single user preference', + pathtypes: [ + new \core\router\schema\parameters\path_parameter( + name: 'preference', + type: param::RAW, + ), + ], + requestbody: new \core\router\schema\request_body( + content: new payload_response_type( + schema: new \core\router\schema\objects\schema_object( + content: [ + 'value' => new scalar_type(param::RAW), + ], + ), + ), + ), + responses: [ + new \core\router\schema\response\response( + statuscode: 200, + description: 'OK', + content: [ + new \core\router\schema\response\content\json_media_type( + schema: new \core\router\schema\objects\array_of_strings( + keyparamtype: param::TEXT, + valueparamtype: param::RAW, + ), + examples: [ + new \core\router\schema\example( + name: 'A single preference value', + summary: 'A json response containing a single preference', + value: [ + "drawers-open-index" => "1", + ], + ), + ] + ), + ], + ), + ], + )] + public function set_preference( + ResponseInterface $response, + ServerRequestInterface $request, + stdClass $user, + ?string $preference, + ): response_type { + $this->check_user($user); + + $values = $request->getParsedBody(); + $value = $values['value'] ?? null; + $this->set_single_preference($user, $preference, $value); + + return $this->get_preferences($response, $request, $user, $preference); + } + + /** + * Set a single user preference. + * + * @param \stdClass $user + * @param string $preference + * @param mixed $value + * @throws \core\exception\access_denied_exception + * @throws \invalid_parameter_exception + */ + protected function set_single_preference( + stdClass $user, + string $preference, + mixed $value, + ): void { + try { + $definition = user::get_preference_definition($preference); + } catch (coding_exception $e) { + throw new invalid_parameter_exception("Invalid preference '$preference'"); + } + + if (!user::can_edit_preference($preference, $user)) { + throw new \core\exception\access_denied_exception('You do not have permission to edit this preference.'); + } + + if (isset($definition['type'])) { + $type = param::from_type($definition['type']); + $value = $this->standardise_value($type, $value); + } + + $cleanvalue = user::clean_preference($value, $preference); + if ($cleanvalue !== $value) { + throw new \invalid_parameter_exception("Invalid value for preference '$preference': '{$value}'"); + } + $value = $cleanvalue; + set_user_preference($preference, $value, $user->id); + } + + /** + * Ensure that the requested user meets the requirements. + * + * @param stdClass $user + * @throws invalid_parameter_exception + */ + protected function check_user(stdClass $user): void { + global $USER; + + if ($user->id !== $USER->id) { + throw new \core\exception\access_denied_exception( + 'You do not have permission to view or edit preferences for other users.', + ); + } + } + + /** + * Standardise value based on type. + * + * Note: We cannot use \core\param here because we only want to cast some types. + * Requests do not have an inherent understanding of anything but strings. We need to be strict on typing of integers and bools. + * + * @param string param $type + * @param mixed $value + * @return mixed + */ + protected function standardise_value(param $type, mixed $value): mixed { + if (is_numeric($value) || is_bool($value)) { + switch ($type) { + case param::INT: + case param::BOOL: + $value = (int) $value; + } + } + + return $value; + } +} diff --git a/user/classes/route/responses/user_preferences_response.php b/user/classes/route/responses/user_preferences_response.php new file mode 100644 index 00000000000..8f5c5f8b72f --- /dev/null +++ b/user/classes/route/responses/user_preferences_response.php @@ -0,0 +1,61 @@ +. + +namespace core_user\route\responses; + +use core\param; +use core\router\schema\response\content\payload_response_type; + +/** + * A standard response for user preferences. + * + * @package core_user + * @copyright 2023 Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class user_preferences_response extends \core\router\schema\response\response { + /** + * Constructor for a standard user preference response. + */ + public function __construct() { + parent::__construct( + content: new payload_response_type( + schema: new \core\router\schema\objects\array_of_strings( + keyparamtype: param::TEXT, + valueparamtype: param::RAW, + ), + examples: [ + new \core\router\schema\example( + name: 'A single preference value', + summary: 'A json response containing a single preference', + value: [ + "drawers-open-index" => "1", + ], + ), + new \core\router\schema\example( + name: 'A set of preference values', + summary: 'A json response containing a set of preferences', + value: [ + "drawers-open-index" => "1", + "login_failed_count_since_success" => "1", + "coursesectionspreferences_2" => "{\"contentcollapsed\":[]}", + ], + ), + ] + ), + ); + } +} diff --git a/user/tests/route/api/preferences_test.php b/user/tests/route/api/preferences_test.php index 294d1f8a7f5..a12ec3f5ab4 100644 --- a/user/tests/route/api/preferences_test.php +++ b/user/tests/route/api/preferences_test.php @@ -16,24 +16,25 @@ namespace core_user\route\api; -use GuzzleHttp\Psr7\Response; +use core\tests\route_testcase; use GuzzleHttp\Psr7\Utils; /** * Tests for user preference API handler. * - * @package core - * @copyright 2023 Andrew Lyons + * @package core_user + * @copyright Andrew Lyons * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \core_user\route\api\preferences * @covers \core_user\route\responses\user_preferences_response */ -class preferences_test extends \route_testcase { +final class preferences_test extends route_testcase { /** * Ensure that preferences returned for a user without login are empty. */ public function test_preferences_no_login(): void { - $response = $this->process_request('GET', '/user/preferences'); + $this->add_class_routes_to_route_loader(preferences::class); + $response = $this->process_api_request('GET', '/current/preferences'); $this->assert_valid_response($response); $payload = $this->decode_response($response); @@ -47,50 +48,249 @@ class preferences_test extends \route_testcase { public function test_preferences_returned(): void { $this->resetAfterTest(); - $this->setAdminUser(); - set_user_preference('testpreference', 'testvalue'); + $this->add_class_routes_to_route_loader(preferences::class); - $response = $this->process_request('GET', '/user/preferences'); + $this->setAdminUser(); + set_user_preference('filemanager_recentviewmode', 1); + + $response = $this->process_api_request('GET', '/current/preferences'); $this->assert_valid_response($response); $payload = $this->decode_response($response); - $this->assertObjectHasAttribute('testpreference', $payload); - $this->assertEquals('testvalue', $payload->testpreference); + $this->assertObjectHasProperty('filemanager_recentviewmode', $payload); + $this->assertEquals(1, $payload->filemanager_recentviewmode); } public function test_preference_returned(): void { $this->resetAfterTest(); - $this->setAdminUser(); - set_user_preference('testpreference', 'testvalue'); + $this->add_class_routes_to_route_loader(preferences::class); - $response = $this->process_request('GET', '/user/preferences/testpreference'); + $this->setAdminUser(); + set_user_preference('filemanager_recentviewmode', 1); + + $response = $this->process_api_request('GET', '/current/preferences/filemanager_recentviewmode'); $this->assert_valid_response($response); $payload = $this->decode_response($response); - $this->assertObjectHasAttribute('testpreference', $payload); - $this->assertEquals('testvalue', $payload->testpreference); + $this->assertObjectHasProperty('filemanager_recentviewmode', $payload); + $this->assertEquals(1, $payload->filemanager_recentviewmode); } public function test_preferences_set(): void { $this->resetAfterTest(); - $request = $this->create_request( + $this->add_class_routes_to_route_loader(preferences::class); + + $this->setAdminUser(); + + $response = $this->process_api_request( 'POST', - '/user/preferences', - )->withBody( - Utils::streamFor(json_encode([ + '/current/preferences', + body: Utils::streamFor(json_encode([ 'preferences' => [ - 'testpreference' => 'someothervalue', + 'filemanager_recentviewmode' => 2, + 'drawer-open-index' => 1, ], ])), ); - $app = $this->get_app(); - $response = $app->handle($request); + $this->assert_valid_response($response); + + // Check that the response contained the updtaed parameter. + $payload = (object) $this->decode_response($response); + $this->assertObjectHasProperty('filemanager_recentviewmode', $payload); + $this->assertObjectHasProperty('drawer-open-index', $payload); + $this->assertEquals(2, $payload->filemanager_recentviewmode); + + // Check that the preference was updated. + $this->assertEquals(2, get_user_preferences('filemanager_recentviewmode')); + $this->assertEquals(1, get_user_preferences('drawer-open-index')); + } + + /** + * Test that an invalid preference is rejected. + */ + public function test_preferences_set_invalid_value(): void { + $this->resetAfterTest(); + + $this->add_class_routes_to_route_loader(preferences::class); + + $this->setAdminUser(); + + $response = $this->process_api_request( + 'POST', + '/current/preferences', + body: Utils::streamFor(json_encode([ + 'preferences' => [ + 'filemanager_recentviewmode' => 4, + ], + ])), + ); + + $this->assert_invalid_parameter_response($response); + $payload = $this->decode_response($response); + $this->assertStringContainsString('filemanager_recentviewmode', $payload->message); + } + + /** + * Test that a preference the user does not have permission to is rejected. + */ + public function test_preferences_set_not_permitted_valid_login(): void { + $this->resetAfterTest(); + + $this->add_class_routes_to_route_loader(preferences::class); + + $this->setAdminUser(); + + $response = $this->process_api_request( + 'POST', + '/current/preferences', + body: Utils::streamFor(json_encode([ + 'preferences' => [ + 'auth_forcepasswordchange' => 4, + ], + ])), + ); + + $this->assert_access_denied_response($response); + } + + public function test_preference_set(): void { + $this->resetAfterTest(); + + $this->add_class_routes_to_route_loader(preferences::class); + + $this->setAdminUser(); + + $response = $this->process_api_request( + 'POST', + '/current/preferences/filemanager_recentviewmode', + body: Utils::streamFor(json_encode([ + 'value' => 2, + ])), + ); + + $this->assert_valid_response($response); + + // Check that the response contained the updtaed parameter. + $payload = $this->decode_response($response); + $this->assertObjectHasProperty('filemanager_recentviewmode', $payload); + $this->assertEquals(2, $payload->filemanager_recentviewmode); + + // Check that the preference was updated. + $this->assertEquals(2, get_user_preferences('filemanager_recentviewmode')); + } + + /** + * Test that an invalid preference is rejected. + */ + public function test_preference_set_invalid_value(): void { + $this->resetAfterTest(); + + $this->add_class_routes_to_route_loader(preferences::class); + + $this->setAdminUser(); + + $response = $this->process_api_request( + 'POST', + '/current/preferences/filemanager_recentviewmode', + body: Utils::streamFor(json_encode([ + 'value' => 4, + ])), + ); + + $this->assert_invalid_parameter_response($response); + $payload = $this->decode_response($response); + $this->assertStringContainsString('filemanager_recentviewmode', $payload->message); + } + + /** + * Test that an invalid preference inentifier is rejected. + */ + public function test_preference_set_invalid_preference(): void { + $this->resetAfterTest(); + + $this->add_class_routes_to_route_loader(preferences::class); + + $this->setAdminUser(); + + $response = $this->process_api_request( + 'POST', + '/current/preferences/what_a_fake', + body: Utils::streamFor(json_encode([ + 'value' => 4, + ])), + ); + + $this->assert_invalid_parameter_response($response); + $payload = $this->decode_response($response); + $this->assertStringContainsString('what_a_fake', $payload->message); + } + + /** + * Test that a preference the user does not have permission to is rejected. + */ + public function test_preference_set_not_permitted_valid_login(): void { + $this->resetAfterTest(); + + $this->add_class_routes_to_route_loader(preferences::class); + + $this->setAdminUser(); + + $response = $this->process_api_request( + 'POST', + '/current/preferences/auth_forcepasswordchange', + body: Utils::streamFor(json_encode([ + 'value' => 4, + ])), + ); + + $this->assert_access_denied_response($response); + } + + /** + * A user cannot get or set preferences for anothe ruser. + */ + public function test_preference_get_other_user(): void { + $this->resetAfterTest(); + $this->setAdminUser(); + $user = $this->getDataGenerator()->create_user(); + + $this->add_class_routes_to_route_loader(preferences::class); + + // Get all preferences. + $response = $this->process_api_request('GET', "/{$user->id}/preferences"); + $this->assert_access_denied_response($response); + + // Get one preference. + $response = $this->process_api_request('GET', "/{$user->id}/preferences/example"); + $this->assert_access_denied_response($response); + + // Set all preferences. + $response = $this->process_api_request( + 'POST', + "/{$user->id}/preferences/filemanager_recentviewmode", + body: Utils::streamFor(json_encode([ + 'value' => 4, + ])), + ); + $this->assert_access_denied_response($response); + + // Get all preferences. + $response = $this->process_api_request( + 'POST', + "/{$user->id}/preferences", + body: Utils::streamFor(json_encode([ + 'preferences' => [ + 'filemanager_recentviewmode' => 2, + ], + ])), + ); + $this->assert_access_denied_response($response); } }