From 681b15b9f09d66ebee81ea195bb9551efa8f171d Mon Sep 17 00:00:00 2001 From: David Woloszyn Date: Tue, 24 Jun 2025 15:07:58 +1000 Subject: [PATCH 1/7] MDL-83519 core: Support for short links Co-authored-by: Andrew Lyons --- public/lib/classes/route/shortlink.php | 109 +++++ public/lib/classes/router.php | 13 + public/lib/classes/router/require_login.php | 18 +- public/lib/classes/router/route_loader.php | 41 ++ .../classes/router/route_loader_interface.php | 9 +- public/lib/classes/router/util.php | 7 +- public/lib/classes/shortlink.php | 385 ++++++++++++++++++ public/lib/classes/shortlink_handler.php | 40 ++ .../classes/shortlink_handler_interface.php | 47 +++ public/lib/db/install.xml | 20 +- public/lib/db/upgrade.php | 30 ++ public/lib/tests/shortlink_test.php | 347 ++++++++++++++++ public/version.php | 2 +- 13 files changed, 1053 insertions(+), 15 deletions(-) create mode 100644 public/lib/classes/route/shortlink.php create mode 100644 public/lib/classes/shortlink.php create mode 100644 public/lib/classes/shortlink_handler.php create mode 100644 public/lib/classes/shortlink_handler_interface.php create mode 100644 public/lib/tests/shortlink_test.php diff --git a/public/lib/classes/route/shortlink.php b/public/lib/classes/route/shortlink.php new file mode 100644 index 00000000000..7ae24d6548f --- /dev/null +++ b/public/lib/classes/route/shortlink.php @@ -0,0 +1,109 @@ +. + +namespace core\route; + +use core\exception\coding_exception; +use core\router\route; +use core\router\util; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; + +/** + * Shortlink route handler. + * + * Shortlinks are shorter URLs that redirect to longer Moodle URLs. + * E.g. http://mymoodle.com/s/AbCd => http://mymoodle.com/course/view.php?id=11. + * + * @package core + * @copyright Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class shortlink { + /** + * Handle a user-specific Moodle shortlink. + * + * @param \Psr\Http\Message\ServerRequestInterface $request + * @param \Psr\Http\Message\ResponseInterface $response + * @param string $shortcode + * @param \moodle_database $db + * @param \core\shortlink $manager + * @return ResponseInterface + */ + #[route( + path: '/s/{shortcode}', + pathtypes: [ + new \core\router\schema\parameters\path_parameter( + name: 'shortcode', + type: \core\param::ALPHANUMEXT, + ), + ], + requirelogin: new \core\router\require_login( + requirelogin: true, + autologinguest: false, + ), + )] + public function handle_shortlink( + ServerRequestInterface $request, + ResponseInterface $response, + string $shortcode, + \moodle_database $db, + \core\shortlink $manager, + ): ResponseInterface { + try { + $url = $manager->fetch_url_for_shortcode(true, $shortcode); + return util::redirect($response, $url); + } catch (coding_exception $e) { + // Shortlink not found. + return util::throw_page_not_found($request, $response); + } + } + + /** + * Global shortlinks do not require login, but the point that they redirect to may. + * + * @param \Psr\Http\Message\ServerRequestInterface $request + * @param \Psr\Http\Message\ResponseInterface $response + * @param string $shortcode + * @param \moodle_database $db + * @param \core\shortlink $manager + * @return ResponseInterface + */ + #[route( + path: '/p/{shortcode}', + pathtypes: [ + new \core\router\schema\parameters\path_parameter( + name: 'shortcode', + type: \core\param::ALPHANUMEXT, + ), + ], + )] + public function handle_public_shortlink( + ServerRequestInterface $request, + ResponseInterface $response, + string $shortcode, + \moodle_database $db, + \core\shortlink $manager, + ): ResponseInterface { + try { + $url = $manager->fetch_url_for_shortcode(false, $shortcode); + return util::redirect($response, $url); + } catch (coding_exception $e) { + // Shortlink not found. + return util::throw_page_not_found($request, $response, $e->getMessage(), $e); + } + } +} diff --git a/public/lib/classes/router.php b/public/lib/classes/router.php index 3a5e70bea0a..ad7b3bb3194 100644 --- a/public/lib/classes/router.php +++ b/public/lib/classes/router.php @@ -232,6 +232,7 @@ class router { route_loader_interface::ROUTE_GROUP_API => $this->configure_api_route($collection), route_loader_interface::ROUTE_GROUP_PAGE => $this->configure_standard_route($collection), route_loader_interface::ROUTE_GROUP_SHIM => $this->configure_shim_route($collection), + route_loader_interface::ROUTE_GROUP_SHORTLINK => array_walk($collection, [$this, 'configure_shortlink_route']), default => null, }; } @@ -273,6 +274,18 @@ class router { ->add(di::get(validation_middleware::class)); } + /** + * Configure the Short link Route Middleware. + * + * @param RouteGroupInterface $group + */ + protected function configure_shortlink_route($group): void { + $group + ->add(di::get(moodle_authentication_middleware::class)) + ->add(di::get(validation_middleware::class)); + } + + /** * Configure caching for the routes. */ diff --git a/public/lib/classes/router/require_login.php b/public/lib/classes/router/require_login.php index 971a1ac2656..ce2e1fd6721 100644 --- a/public/lib/classes/router/require_login.php +++ b/public/lib/classes/router/require_login.php @@ -34,13 +34,13 @@ class require_login { * @throws \InvalidArgumentException */ public function __construct( - /** @var bool Whether login is required */ - public bool $requirelogin = false, - /** @var bool Whether a course login is required */ + /** @var bool Whether to require login or not */ + public bool $requirelogin = true, + /** @var bool Whether to require course login or not */ public bool $requirecourselogin = false, - /** @var bool The name of the route attribute that the course object can be found in */ + /** @var string|null The route attribute name used for the course */ protected ?string $courseattributename = null, - /** @var bool Whether to automatically log in as guest */ + /** @var bool Whether to autologin guest users */ public bool $autologinguest = true, ) { if ($requirelogin && $requirecourselogin) { @@ -49,11 +49,13 @@ class require_login { } /** - * Get the course attribute name. + * Get the attribute name used for the course. * - * @return string + * A null value is returned if the course attribute name is not set. + * + * @return null|string */ - public function get_course_attribute_name(): string { + public function get_course_attribute_name(): ?string { return $this->courseattributename; } diff --git a/public/lib/classes/router/route_loader.php b/public/lib/classes/router/route_loader.php index aec10df4184..ee1cea286a0 100644 --- a/public/lib/classes/router/route_loader.php +++ b/public/lib/classes/router/route_loader.php @@ -16,6 +16,7 @@ namespace core\router; +use core\route\shortlink; use Slim\App; use Slim\Interfaces\RouteGroupInterface; use Slim\Interfaces\RouteInterface; @@ -35,6 +36,7 @@ class route_loader extends abstract_route_loader implements route_loader_interfa route_loader_interface::ROUTE_GROUP_API => $this->configure_api_routes($app, route_loader_interface::ROUTE_GROUP_API), route_loader_interface::ROUTE_GROUP_PAGE => $this->configure_standard_routes($app), route_loader_interface::ROUTE_GROUP_SHIM => $this->configure_shim_routes($app), + route_loader_interface::ROUTE_GROUP_SHORTLINK => $this->configure_shortlink_routes($app), ]; } @@ -92,6 +94,23 @@ class route_loader extends abstract_route_loader implements route_loader_interfa }); } + /** + * Configure all shortlink routes. + * + * @param App $app + * @return RouteInterface[] + */ + protected function configure_shortlink_routes(App $app): array { + return array_map( + function (array $moodleroute) use ($app): RouteInterface { + $slimroute = $app->map(...$moodleroute); + $this->set_route_name_for_callable($slimroute, $moodleroute['callable']); + return $slimroute; + }, + $this->get_all_shortlink_routes(), + ); + } + /** * Fetch all API routes. * @@ -174,4 +193,26 @@ class route_loader extends abstract_route_loader implements route_loader_interfa return $cachedata; } + + /** + * Fetch all shortlink routes. + * + * Note: This method caches results in MUC. + * + * @return array[] + */ + protected function get_all_shortlink_routes(): array { + $cache = \cache::make('core', 'routes'); + + if (!($cachedata = $cache->get('shortlink_routes'))) { + $cachedata = $this->get_all_routes_in_class( + componentpath: '/', + classinfo: new \ReflectionClass(shortlink::class), + ); + + $cache->set('shortlink_routes', $cachedata); + } + + return $cachedata; + } } diff --git a/public/lib/classes/router/route_loader_interface.php b/public/lib/classes/router/route_loader_interface.php index c5ab18c41da..92fad2c0bbc 100644 --- a/public/lib/classes/router/route_loader_interface.php +++ b/public/lib/classes/router/route_loader_interface.php @@ -31,14 +31,17 @@ interface route_loader_interface { /** @var string The route path prefix to use for API calls */ public const ROUTE_GROUP_API = '/api/rest/v2'; - /** @var string The route path prefix to use for API calls */ + /** @var string The route path prefix to use for shims */ public const ROUTE_GROUP_SHIM = 'shim'; - /** @var string The route path prefix to use for API calls */ + /** @var string The route path prefix to use for page calls */ public const ROUTE_GROUP_PAGE = '/'; + /** @var string The route path prefix to use for shortlinks */ + public const ROUTE_GROUP_SHORTLINK = '/s/'; + /** - * Configure all routes for the Application. + * Configure all routes for the application. * * This method returns a set of RouteGroupInterface instances for each route prefix. * diff --git a/public/lib/classes/router/util.php b/public/lib/classes/router/util.php index eada7c17d8b..e616ed58983 100644 --- a/public/lib/classes/router/util.php +++ b/public/lib/classes/router/util.php @@ -102,14 +102,18 @@ class util { * * @param ServerRequestInterface $request * @param ResponseInterface $response + * @param string|null $message + * @param \Throwable|null $previous * @return ResponseInterface * @throws \Slim\Exception\HttpNotFoundException */ public static function throw_page_not_found( ServerRequestInterface $request, ResponseInterface $response, + ?string $message = null, + ?\Throwable $previous = null, ): ResponseInterface { - throw new \Slim\Exception\HttpNotFoundException($request); + throw new \Slim\Exception\HttpNotFoundException($request, $message, $previous); } /** @@ -128,7 +132,6 @@ class util { ->withHeader('Location', (string) $url); } - /** * Get the route name for the specified callable. * diff --git a/public/lib/classes/shortlink.php b/public/lib/classes/shortlink.php new file mode 100644 index 00000000000..37d35e95078 --- /dev/null +++ b/public/lib/classes/shortlink.php @@ -0,0 +1,385 @@ +. + +namespace core; + +use core\exception\coding_exception; + +/** + * Shortlink manager for Moodle. + * + * @package core + * @copyright Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class shortlink { + /** @var int The minimum shortcode length */ + private const SHORTCODE_MIN_LENGTH = 2; + + /** @var int The maximum shortcode length */ + private const SHORTCODE_MAX_LENGTH = 8; + + /** @var string The list of possible characters */ + private const SHORTCODE_CHARS = 'ABCDEFGHJKLMNOPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz0123456789_-'; + + /** @var int The number of shortcodes to generate at once */ + private const GENERATE_COUNT = 30; + + /** + * Constructor for the shortlink manager. + * + * @param \moodle_database $db + */ + public function __construct( + /** @var \moodle_database The DB handler */ + private \moodle_database $db, + ) { + } + + /** + * Fetch the URL for the specified shortcode. + * + * @param bool $isuserspecific + * @param string $shortcode + * @throws coding_exception + * @return url + */ + public function fetch_url_for_shortcode( + bool $isuserspecific, + string $shortcode, + ): url { + global $USER; + + $result = $this->db->get_record('shortlink', [ + 'shortcode' => $shortcode, + 'userid' => $isuserspecific ? $USER->id : 0, + ]); + + if ($result === false) { + throw new coding_exception("Shortlink not found for shortcode {$shortcode}"); + } + + $component = $result->component; + $linktype = $result->linktype; + $identifier = $result->identifier; + + $handler = $this->get_shortlink_handler($component); + $this->validate_linktype($component, $handler, $linktype); + + $link = $handler->process_shortlink($linktype, $identifier); + if ($link === null) { + throw new coding_exception("No URL found for shortcode {$shortcode}"); + } + + return $link; + } + + /** + * Create a shortlink URL for the specified user. + * + * To create a public shortlink, a userid of 0 can be used. + * + * @param string $component + * @param string $linktype + * @param int|string $identifier + * @param int $userid + * @param int $minlength + * @param int $maxlength + * @throws coding_exception + * @return url + */ + public function create_shortlink( + string $component, + string $linktype, + int|string $identifier, + int $userid, + int $minlength = self::SHORTCODE_MIN_LENGTH, + int $maxlength = self::SHORTCODE_MAX_LENGTH, + ): url { + if ($userid === 0) { + return $this->create_public_shortlink( + component: $component, + linktype: $linktype, + identifier: $identifier, + minlength: $minlength, + maxlength: $maxlength, + ); + } + $shortcode = $this->generate_and_store_shortcode( + userids: $userid, + component: $component, + linktype: $linktype, + identifier: $identifier, + minlength: $minlength, + maxlength: $maxlength, + ); + + $url = \core\router\util::get_path_for_callable( + callable: [\core\route\shortlink::class, 'handle_shortlink'], + params: ['shortcode' => $shortcode], + ); + + return $url; + } + + /** + * Create a public shortlink URL. + * + * @param string $component + * @param string $linktype + * @param int|string $identifier + * @param int $minlength + * @param int $maxlength + * @throws coding_exception + * @return url + */ + public function create_public_shortlink( + string $component, + string $linktype, + int|string $identifier, + int $minlength = self::SHORTCODE_MIN_LENGTH, + int $maxlength = self::SHORTCODE_MAX_LENGTH, + ): url { + $shortcode = $this->generate_and_store_shortcode( + userids: 0, + component: $component, + linktype: $linktype, + identifier: $identifier, + minlength: $minlength, + maxlength: $maxlength, + ); + + $url = \core\router\util::get_path_for_callable( + callable: [\core\route\shortlink::class, 'handle_public_shortlink'], + params: ['shortcode' => $shortcode], + ); + + return $url; + } + + /** + * Create a shortlink for a set of users. + * + * The shortlink returned will be the same for all specified users. + * + * @param string $component + * @param string $linktype + * @param string $identifier + * @param array $userids + * @param int $minlength + * @param int $maxlength + * @throws coding_exception + * @return url + */ + public function create_shortlink_for_users( + string $component, + string $linktype, + int|string $identifier, + array $userids, + int $minlength = self::SHORTCODE_MIN_LENGTH, + int $maxlength = self::SHORTCODE_MAX_LENGTH, + ): url { + // Ensure none of the user IDs are 0. + if (in_array(0, $userids, true)) { + throw new coding_exception('User-specific short links cannot be created for user ID 0.'); + } + + $shortcode = $this->generate_and_store_shortcode( + userids: $userids, + component: $component, + linktype: $linktype, + identifier: $identifier, + minlength: $minlength, + maxlength: $maxlength, + ); + + $url = \core\router\util::get_path_for_callable( + callable: [\core\route\shortlink::class, 'handle_shortlink'], + params: ['shortcode' => $shortcode], + ); + + return $url; + } + + /** + * Generate and store a shortcode. + * + * @param string $component + * @param string $linktype + * @param int|string $identifier + * @param int|array $userids + * @param int $minlength + * @param int $maxlength + * @return string + */ + private function generate_and_store_shortcode( + string $component, + string $linktype, + int|string $identifier, + int|array $userids, + int $minlength, + int $maxlength, + ): string { + $handler = $this->get_shortlink_handler($component); + $this->validate_linktype($component, $handler, $linktype); + + $transaction = $this->db->start_delegated_transaction(); + + $userid = is_int($userids) ? $userids : 0; + $userids = is_int($userids) ? [$userids] : $userids; + $shortcode = $this->get_unused_shortcode( + userid: $userid, + minlength: $minlength, + maxlength: $maxlength, + ); + $linkdata = [ + 'component' => $component, + 'linktype' => $linktype, + 'identifier' => $identifier, + 'shortcode' => $shortcode, + 'timecreated' => time(), + ]; + // Add in user ids. + $linkdata = array_map( + fn (int $userid) => (object) array_merge($linkdata, ['userid' => $userid]), + $userids, + ); + + $this->db->insert_records('shortlink', $linkdata); + $transaction->allow_commit(); + + return $shortcode; + } + + /** + * Get an unused shortcode suitable for this user. + * + * @param int $userid + * @param int $minlength + * @param int $maxlength + * @return string + */ + private function get_unused_shortcode( + int $userid, + int $minlength = self::SHORTCODE_MIN_LENGTH, + int $maxlength = self::SHORTCODE_MAX_LENGTH, + ): string { + do { + $shortcodes = array_map( + fn() => $this->generate_shortcode( + minlength: $minlength, + maxlength: $maxlength, + ), + array_fill(0, self::GENERATE_COUNT, null), + ); + + [$sql, $params] = $this->db->get_in_or_equal($shortcodes, SQL_PARAMS_NAMED); + + if ($userid === 0) { + // Global shortcodes must be entirely unique. + $existing = $this->db->get_records_select_menu( + table: 'shortlink', + select: "shortcode {$sql}", + params: $params, + fields: 'id, shortcode', + ); + } else { + // User-specific shortcodes may be re-used by other users, but not by the same user, and not globally. + $params['userid'] = $userid; + $existing = $this->db->get_records_select_menu( + table: 'shortlink', + select: "(userid = 0 OR userid = :userid) AND shortcode $sql", + params: $params, + fields: 'id, shortcode', + ); + } + + $unused = array_diff($shortcodes, $existing); + if (count($unused) === 0) { + // If we didn't find any unused shortcodes, increase the max length before trying again. + $maxlength++; + } + } while (count($unused) === 0); + + return array_shift($unused); + } + + /** + * Generate a shortcode. + * + * @param int $minlength + * @param int $maxlength + * @return string + */ + private function generate_shortcode( + int $minlength = self::SHORTCODE_MIN_LENGTH, + int $maxlength = self::SHORTCODE_MAX_LENGTH, + ): string { + if ($minlength < 1 || $maxlength < 1 || $minlength > $maxlength) { + throw new coding_exception('Invalid min/max length for shortcode generation'); + } + + if ($minlength === $maxlength) { + $data = array_fill(0, $minlength, null); + } else { + $data = array_fill(0, rand($minlength, $maxlength), null); + } + + return implode('', array_map( + fn (): string => self::SHORTCODE_CHARS[rand(0, strlen(self::SHORTCODE_CHARS) - 1)], + $data, + )); + } + + /** + * Get the shortlink handler for the specified component. + * + * @param string $component + * @throws coding_exception + * @return shortlink_handler_interface + */ + private function get_shortlink_handler(string $component): shortlink_handler_interface { + try { + $handler = \core\di::get("{$component}\\shortlink_handler"); + } catch (\DI\NotFoundException $e) { + throw new coding_exception("No shortlink handler found for component {$component}"); + } + + if (!$handler instanceof shortlink_handler_interface) { + throw new coding_exception("Shortlink handler for component {$component} must implement shortlink_handler_interface"); + } + + return $handler; + } + + /** + * Validate a link type for the specified handler. + * + * @param string $component + * @param shortlink_handler_interface $handler + * @param string $linktype + * @throws coding_exception + */ + private function validate_linktype( + string $component, + shortlink_handler_interface $handler, + string $linktype, + ): void { + if (!in_array($linktype, $handler->get_valid_linktypes(), true)) { + throw new coding_exception("Invalid link type {$linktype} for component {$component}"); + } + } +} diff --git a/public/lib/classes/shortlink_handler.php b/public/lib/classes/shortlink_handler.php new file mode 100644 index 00000000000..7a3f2cbdf55 --- /dev/null +++ b/public/lib/classes/shortlink_handler.php @@ -0,0 +1,40 @@ +. + +namespace core; + +/** + * Shortlink handler for the Moodle core subsystem. + * + * @package core + * @copyright Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class shortlink_handler implements shortlink_handler_interface { + #[\Override] + public function get_valid_linktypes(): array { + return [ + ]; + } + + #[\Override] + public function process_shortlink( + string $type, + string $identifier, + ): ?\core\url { + return null; + } +} diff --git a/public/lib/classes/shortlink_handler_interface.php b/public/lib/classes/shortlink_handler_interface.php new file mode 100644 index 00000000000..90f7d30265f --- /dev/null +++ b/public/lib/classes/shortlink_handler_interface.php @@ -0,0 +1,47 @@ +. + +namespace core; + +/** + * Class shortlink_handler_interface. + * + * @package core + * @copyright 2025 Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +interface shortlink_handler_interface { + /** + * Get the valid link types for this handler. + * + * @return array + */ + public function get_valid_linktypes(): array; + + /** + * Handle processing of a shortlink, returning the relevant URL. + * + * If no valid URL is found, this method should return null. + * + * @param string $type + * @param string $identifier + * @return null|\core\url + */ + public function process_shortlink( + string $type, + string $identifier, + ): ?\core\url; +} diff --git a/public/lib/db/install.xml b/public/lib/db/install.xml index 5408fecccc9..938761f5e09 100644 --- a/public/lib/db/install.xml +++ b/public/lib/db/install.xml @@ -1,5 +1,5 @@ - @@ -5021,5 +5021,23 @@ + + + + + + + + + + + + + + + + + +
diff --git a/public/lib/db/upgrade.php b/public/lib/db/upgrade.php index a5d773fdc90..5749e0ec833 100644 --- a/public/lib/db/upgrade.php +++ b/public/lib/db/upgrade.php @@ -1996,5 +1996,35 @@ function xmldb_main_upgrade($oldversion) { upgrade_main_savepoint(true, 2025073100.01); } + if ($oldversion < 2025081900.01) { + + // Define table shortlink to be created. + $table = new xmldb_table('shortlink'); + + // Adding fields to table shortlink. + $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null); + $table->add_field('shortcode', XMLDB_TYPE_CHAR, '12', null, XMLDB_NOTNULL, null, null); + $table->add_field('userid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, 0); + $table->add_field('component', XMLDB_TYPE_CHAR, '100', null, XMLDB_NOTNULL, null, null); + $table->add_field('linktype', XMLDB_TYPE_CHAR, '100', null, XMLDB_NOTNULL, null, null); + $table->add_field('identifier', XMLDB_TYPE_CHAR, '1333', null, XMLDB_NOTNULL, null, null); + + // Adding keys to table shortlink. + $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']); + $table->add_key('userid', XMLDB_KEY_FOREIGN, ['userid'], 'user', ['id']); + + // Adding indexes to table shortlink. + $table->add_index('shortcode_userid', XMLDB_INDEX_UNIQUE, ['userid', 'shortcode']); + $table->add_index('shortcode', XMLDB_INDEX_NOTUNIQUE, ['shortcode']); + + // Conditionally launch create table for shortlink. + if (!$dbman->table_exists($table)) { + $dbman->create_table($table); + } + + // Main savepoint reached. + upgrade_main_savepoint(true, 2025081900.01); + } + return true; } diff --git a/public/lib/tests/shortlink_test.php b/public/lib/tests/shortlink_test.php new file mode 100644 index 00000000000..412f1d4c779 --- /dev/null +++ b/public/lib/tests/shortlink_test.php @@ -0,0 +1,347 @@ +. + +namespace core; + +/** + * Tests for the short link manager. + * + * @package core + * @category test + * @copyright Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \core\shortlink + */ +final class shortlink_test extends \advanced_testcase { + public function test_create_public_shortlink(): void { + $this->resetAfterTest(); + + // Mock the handler. + $this->mock_handler('mod_example', 'submit', '123'); + + // Create a public short link. + $manager = \core\di::get(\core\shortlink::class); + $link = $manager->create_public_shortlink('mod_example', 'submit', 123); + + $this->assertNotEmpty($link); + $this->assertInstanceOf(\core\url::class, $link); + + // Extract the shortcode. + $parts = explode('/', (string) $link); + $shortcode = end($parts); + + // Fetch the URL. + $url = $manager->fetch_url_for_shortcode(false, $shortcode); + $this->assertEquals('https://example.com', (string) $url); + } + + public function test_create_user_shortlink(): void { + $this->resetAfterTest(); + + $user = $this->getDataGenerator()->create_user(); + + // Mock the handler. + $this->mock_handler('mod_example', 'submit', '123'); + + // Create a public short link. + $manager = \core\di::get(\core\shortlink::class); + $link = $manager->create_shortlink('mod_example', 'submit', 123, $user->id); + + $this->assertNotEmpty($link); + $this->assertInstanceOf(\core\url::class, $link); + + // Extract the shortcode. + $parts = explode('/', (string) $link); + $shortcode = end($parts); + + // Fetching the URL as a user link should return the URL. + $this->setUser($user); + $url = $manager->fetch_url_for_shortcode(true, $shortcode); + $this->assertEquals('https://example.com', (string) $url); + + // Fetching the URL as someone else should not return the URL. + $otheruser = $this->getDataGenerator()->create_user(); + $this->setUser($otheruser); + $this->expectException(\core\exception\coding_exception::class); + $manager->fetch_url_for_shortcode(true, $shortcode); + } + + public function test_create_users_shortlink(): void { + $this->resetAfterTest(); + + $user = $this->getDataGenerator()->create_user(); + $otheruser = $this->getDataGenerator()->create_user(); + + // Mock the handler. + $this->mock_handler('mod_example', 'submit', '123'); + + // Create a public short link. + $manager = \core\di::get(\core\shortlink::class); + $link = $manager->create_shortlink_for_users('mod_example', 'submit', 123, [$user->id, $otheruser->id]); + + $this->assertNotEmpty($link); + $this->assertInstanceOf(\core\url::class, $link); + + // Extract the shortcode. + $parts = explode('/', (string) $link); + $shortcode = end($parts); + + // Fetching the URL as a user link should return the URL. + $this->setUser($user); + $url = $manager->fetch_url_for_shortcode(true, $shortcode); + $this->assertEquals('https://example.com', (string) $url); + + $this->setUser($otheruser); + $url = $manager->fetch_url_for_shortcode(true, $shortcode); + $this->assertEquals('https://example.com', (string) $url); + + $yetanotheruser = $this->getDataGenerator()->create_user(); + $this->setUser($yetanotheruser); + $this->expectException(\core\exception\coding_exception::class); + $manager->fetch_url_for_shortcode(true, $shortcode); + } + + public function test_handler_deleted_after_creation(): void { + $this->resetAfterTest(); + + $user = $this->getDataGenerator()->create_user(); + $otheruser = $this->getDataGenerator()->create_user(); + + // Mock the handler. + $this->mock_handler('mod_example', 'submit', '123'); + + // Create a public short link. + $manager = \core\di::get(\core\shortlink::class); + $link = $manager->create_shortlink_for_users('mod_example', 'submit', 123, [$user->id, $otheruser->id]); + + $this->assertNotEmpty($link); + $this->assertInstanceOf(\core\url::class, $link); + + // Extract the shortcode. + $parts = explode('/', (string) $link); + $shortcode = end($parts); + + // Delete the handler. + \core\di::set(\mod_example\shortlink_handler::class, null); + + // Fetching the URL as someone else should not return the URL. + $this->setUser($user); + $this->expectException(\core\exception\coding_exception::class); + $manager->fetch_url_for_shortcode(true, $shortcode); + } + + /** + * Ensure that a link type deleted after creation is not accessible. + */ + public function test_linktype_deleted_after_creation(): void { + global $DB; + + $this->resetAfterTest(); + + $user = $this->getDataGenerator()->create_user(); + $otheruser = $this->getDataGenerator()->create_user(); + + // Mock the handler. + $this->mock_handler('mod_example', 'submit', '123'); + + // Create a public short link. + $manager = \core\di::get(\core\shortlink::class); + $link = $manager->create_shortlink_for_users('mod_example', 'submit', 123, [$user->id, $otheruser->id]); + + $this->assertNotEmpty($link); + $this->assertInstanceOf(\core\url::class, $link); + + // Extract the shortcode. + $parts = explode('/', (string) $link); + $shortcode = end($parts); + + // Simulate the link type being deleted by changing it in the DB. + $DB->set_field('shortlink', 'linktype', 'invalid'); + + $this->setUser($user); + $this->expectException(\core\exception\coding_exception::class); + $manager->fetch_url_for_shortcode(true, $shortcode); + } + + public function test_identifier_changed_after_creation(): void { + global $DB; + + $this->resetAfterTest(); + + $user = $this->getDataGenerator()->create_user(); + $otheruser = $this->getDataGenerator()->create_user(); + + // Mock the handler but have it return null for this URL + $this->mock_handler('mod_example', 'submit', '123', null); + + // Create a public short link. + $manager = \core\di::get(\core\shortlink::class); + $link = $manager->create_shortlink_for_users('mod_example', 'submit', 123, [$user->id, $otheruser->id]); + + $this->assertNotEmpty($link); + $this->assertInstanceOf(\core\url::class, $link); + + // Extract the shortcode. + $parts = explode('/', (string) $link); + $shortcode = end($parts); + + $this->setUser($user); + $this->expectException(\core\exception\coding_exception::class); + $manager->fetch_url_for_shortcode(true, $shortcode); + } + + public function test_unknown_handler(): void { + $manager = \core\di::get(\core\shortlink::class); + + $this->expectException(\core\exception\coding_exception::class); + $this->expectExceptionMessageMatches('/No shortlink handler found for component mod_example$/'); + + $manager->create_shortlink('mod_example', 'invalid', 123, 0); + } + + public function test_invalid_handler(): void { + \core\di::set(\mod_example\shortlink_handler::class, new class {}); + $manager = \core\di::get(\core\shortlink::class); + + $this->expectException(\core\exception\coding_exception::class); + $this->expectExceptionMessageMatches('/Shortlink handler for component mod_example must implement shortlink_handler_interface/'); + + $manager->create_shortlink('mod_example', 'invalid', 123, 0); + } + + public function test_invalid_linktype(): void { + // Mock the handler. + $handler = $this->createMock(\core\shortlink_handler_interface::class); + $handler->method('get_valid_linktypes') + ->willReturn([]); + + \core\di::set(\mod_example\shortlink_handler::class, $handler); + + $manager = \core\di::get(\core\shortlink::class); + + $this->expectException(\core\exception\coding_exception::class); + $this->expectExceptionMessageMatches('/Invalid link type submit for component mod_example/'); + + $manager->create_shortlink('mod_example', 'submit', 123, 0); + } + + public function test_creation_for_users_including_public(): void { + $this->resetAfterTest(); + $user = $this->getDataGenerator()->create_user(); + + // Create a public short link. + $manager = \core\di::get(\core\shortlink::class); + $this->expectException(\core\exception\coding_exception::class); + $manager->create_shortlink_for_users('mod_example', 'submit', 123, [$user->id, 0]); + } + + /** + * @dataProvider valid_min_max_length_provider + */ + public function test_creation_min_max_length( + int $minlength, + int $maxlength, + int $actualminlengthvalue, + ): void { + $this->resetAfterTest(); + + // Mock the handler. + $this->mock_handler('mod_example', 'submit', '123'); + + $manager = \core\di::get(\core\shortlink::class); + + $link = $manager->create_shortlink('mod_example', 'submit', 123, 0, 1, 1); + $parts = explode('/', (string) $link); + $shortcode = end($parts); + + $this->assertLessThanOrEqual($actualminlengthvalue, strlen($shortcode)); + } + + public static function valid_min_max_length_provider(): \Iterator { + // One char. + yield [1, 1, 1]; + yield [1, 2, 1]; + yield [2, 2, 2]; + yield [4, 4, 4]; + yield [4, 40, 4]; + } + + /** + * @dataProvider invalid_min_max_length_provider + */ + public function test_creation_invalid_min_max_length( + int $minlength, + int $maxlength, + ): void { + $this->resetAfterTest(); + $this->mock_handler('mod_example', 'submit', '123'); + + $manager = \core\di::get(\core\shortlink::class); + $this->expectException(\core\exception\coding_exception::class); + $manager->create_shortlink('mod_example', 'submit', 123, 0, $minlength, $maxlength); + } + + public static function invalid_min_max_length_provider(): \Iterator { + // Empty. + yield [0, 0]; + // Min greater than max. + yield [1, 0]; + // Min greater than max. + yield [2, 1]; + } + + private function mock_handler( + string $component, + string $linktype, + string $identifier, + ?string $response = 'https://example.com', + ): void { + // Mock the handler. + $handler = $this->createMock(\core\shortlink_handler_interface::class); + $handler->method('get_valid_linktypes') + ->willReturn(['submit']); + $handler->method('process_shortlink') + ->with( + $this->equalTo($linktype), + $this->equalTo($identifier), + ) + ->willReturn($response ? new \core\url($response) : null); + \core\di::set("{$component}\\shortlink_handler", $handler); + } + + public function test_creation_run_out_of_characters(): void { + $this->resetAfterTest(); + + // Mock the handler. + $this->mock_handler('mod_example', 'submit', '123'); + + $manager = \core\di::get(\core\shortlink::class); + + // We know that there are at least 25 + 25 + 10 + 2 possible character combinations for the default URL. + // Generate 100 short links and check that they are all unique. + $shortcodes = []; + for ($i = 0; $i < 100; $i++) { + $link = $manager->create_public_shortlink('mod_example', 'submit', 123, 1, 1); + $parts = explode('/', (string) $link); + $shortcode = end($parts); + $this->assertGreaterThanOrEqual(1, strlen($shortcode)); + + $shortcodes[$shortcode] = $shortcode; + } + + $this->assertCount(100, $shortcodes); + } +} diff --git a/public/version.php b/public/version.php index 095edb77e6f..6dbe35fbb24 100644 --- a/public/version.php +++ b/public/version.php @@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2025081900.00; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2025081900.01; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes. $release = '5.1dev (Build: 20250819)'; // Human-friendly version name From 253a38486d03b8ed340de10d8e358367040bce67 Mon Sep 17 00:00:00 2001 From: David Woloszyn Date: Wed, 25 Jun 2025 17:47:03 +1000 Subject: [PATCH 2/7] MDL-83519 core_sms: Add shortlinks to SMS manager Co-authored-by: Andrew Lyons --- public/sms/classes/manager.php | 57 ++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/public/sms/classes/manager.php b/public/sms/classes/manager.php index ad2ef73f92d..b644de4765e 100644 --- a/public/sms/classes/manager.php +++ b/public/sms/classes/manager.php @@ -18,6 +18,7 @@ namespace core_sms; use Generator; use stdClass; +use core\url; /** * SMS manager. @@ -440,4 +441,60 @@ class manager { return $phonenumber; } + + /** + * Create a shortlink for the specified user. + * + * To create a global shortlink, a userid of 0 can be used. + * + * @param string $component + * @param string $linktype + * @param int|string $identifier + * @param int $userid + * @throws \coding_exception + * @return url + */ + public static function create_shortlink( + string $component, + string $linktype, + null|int|string $identifier, + int $userid, + ): url { + return \core\di::get(\core\shortlink::class)->create_shortlink( + component: $component, + linktype: $linktype, + identifier: $identifier, + userid: $userid, + minlength: 4, + maxlength: 4, + ); + } + + /** + * Create a shortlink for a set of users. + * + * The shortlink returned will be the same for all specified users. + * + * @param string $component + * @param string $linktype + * @param string $identifier + * @param array $userids + * @throws \coding_exception + * @return url + */ + public static function create_shortlink_for_users( + string $component, + string $linktype, + string $identifier, + array $userids, + ): url { + return \core\di::get(\core\shortlink::class)->create_shortlink_for_users( + component: $component, + linktype: $linktype, + identifier: $identifier, + userids: $userids, + minlength: 4, + maxlength: 4, + ); + } } From 2e114a04f95e8baaec7983cdec00de49ba60e864 Mon Sep 17 00:00:00 2001 From: David Woloszyn Date: Thu, 26 Jun 2025 12:18:00 +1000 Subject: [PATCH 3/7] MDL-83519 mod_assign: Add shortlink handler Co-authored-by: Andrew Lyons --- .../mod/assign/classes/shortlink_handler.php | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 public/mod/assign/classes/shortlink_handler.php diff --git a/public/mod/assign/classes/shortlink_handler.php b/public/mod/assign/classes/shortlink_handler.php new file mode 100644 index 00000000000..5d1503166db --- /dev/null +++ b/public/mod/assign/classes/shortlink_handler.php @@ -0,0 +1,48 @@ +. + +namespace mod_assign; + +use core\shortlink_handler_interface; + +/** + * Shortlink handler for mod_assign. + * + * @package mod_assign + * @copyright 2025 Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class shortlink_handler implements shortlink_handler_interface { + #[\Override] + public function get_valid_linktypes(): array { + return [ + 'view', + ]; + } + + #[\Override] + public function process_shortlink( + string $type, + string $identifier, + ): ?\core\url { + return match ($type) { + 'view' => new \core\url('/mod/assign/view.php', [ + 'id' => $identifier, + ]), + default => null, + }; + } +} From ba54e467b5a76d24e6149f338e40d1efee1d9744 Mon Sep 17 00:00:00 2001 From: David Woloszyn Date: Thu, 26 Jun 2025 16:54:16 +1000 Subject: [PATCH 4/7] MDL-83519 mod_assign: Use shortlinks in SMS notifications --- .../assign/classes/notification_helper.php | 31 ++++++++++++++++++- public/mod/assign/lang/en/assign.php | 4 +-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/public/mod/assign/classes/notification_helper.php b/public/mod/assign/classes/notification_helper.php index addca2913b5..9acd4ca9f04 100644 --- a/public/mod/assign/classes/notification_helper.php +++ b/public/mod/assign/classes/notification_helper.php @@ -351,12 +351,21 @@ class notification_helper { ]; $url = new \moodle_url('/mod/assign/view.php', $urlparams); + // Shortlink for SMS. + $shortlink = \core_sms\manager::create_shortlink_for_users( + component: 'mod_assign', + linktype: 'view', + identifier: $assignmentobj->get_course_module()->id, + userids: [$userid], + ); + $stringparams = [ 'firstname' => $user->firstname, 'assignmentname' => $assignmentobj->get_instance()->name, 'coursename' => $assignmentobj->get_course()->fullname, 'duedate' => userdate($duedate), 'url' => $url, + 'shortlink' => $shortlink, ]; $messagedata = [ @@ -439,6 +448,14 @@ class notification_helper { ]; $url = new \moodle_url('/mod/assign/view.php', $urlparams); + // Shortlink for SMS. + $shortlink = \core_sms\manager::create_shortlink_for_users( + component: 'mod_assign', + linktype: 'view', + identifier: $assignmentobj->get_course_module()->id, + userids: [$userid], + ); + // Prepare the cut-off date html string. $snippet = ''; if (!empty($cutoffdate)) { @@ -452,6 +469,7 @@ class notification_helper { 'duedate' => userdate($duedate), 'url' => $url, 'cutoffsnippet' => $snippet, + 'shortlink' => $shortlink, ]; $messagedata = [ @@ -530,11 +548,22 @@ class notification_helper { 'id' => $assignmentobj->get_course_module()->id, 'action' => 'view', ]; + $url = new \moodle_url('/mod/assign/view.php', $urlparams); + + // Shortlink for SMS. + $shortlink = \core_sms\manager::create_shortlink_for_users( + component: 'mod_assign', + linktype: 'view', + identifier: $assignmentobj->get_course_module()->id, + userids: [$userid], + ); + $assignmentsfordigest[$assignment->id] = [ 'assignmentname' => $assignmentobj->get_instance()->name, 'coursename' => $assignmentobj->get_course()->fullname, 'duetime' => userdate($duedate, get_string('strftimetime12', 'langconfig')), - 'url' => new \moodle_url('/mod/assign/view.php', $urlparams), + 'url' => $url, + 'shortlink' => $shortlink, ]; } $assignments->close(); diff --git a/public/mod/assign/lang/en/assign.php b/public/mod/assign/lang/en/assign.php index 3a1738666fb..783a4feab65 100644 --- a/public/mod/assign/lang/en/assign.php +++ b/public/mod/assign/lang/en/assign.php @@ -79,12 +79,12 @@ $string['assignmentduesoonhtml'] = '

Hi {$a->firstname},

The assignment {$a->assignmentname} in course {$a->coursename} is due soon.

Due: {$a->duedate}

Go to activity

'; -$string['assignmentduesoonsms'] = 'Your assignment {$a->assignmentname} is due on {$a->duedate}: {$a->url}'; +$string['assignmentduesoonsms'] = 'Your assignment {$a->assignmentname} is due on {$a->duedate}: {$a->shortlink}'; $string['assignmentoverduehtml'] = '

Hi {$a->firstname},

{$a->assignmentname} in course {$a->coursename} was due on {$a->duedate}.

You might still be able to submit your assignment{$a->cutoffsnippet}, but your submission will be marked as late.

Go to activity

'; -$string['assignmentoverduesms'] = 'Your assignment {$a->assignmentname} is overdue on {$a->duedate}: {$a->url}'; +$string['assignmentoverduesms'] = 'Your assignment {$a->assignmentname} is overdue on {$a->duedate}: {$a->shortlink}'; $string['assignmentoverduehtmlcutoffsnippet'] = ' by {$a->cutoffdate}'; $string['assignmentduesoonsubject'] = 'Due on {$a->duedate}: {$a->assignmentname}'; $string['assignmentoverduesubject'] = 'Overdue: {$a->assignmentname}'; From 0eaf3b13b79ce590af2857b70f850b18053ae7ea Mon Sep 17 00:00:00 2001 From: David Woloszyn Date: Fri, 27 Jun 2025 11:57:09 +1000 Subject: [PATCH 5/7] MDL-83519 core_sms: Remove URLs from long messages --- public/sms/classes/gateway.php | 13 ++++++++ public/sms/tests/gateway_test.php | 50 +++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/public/sms/classes/gateway.php b/public/sms/classes/gateway.php index 1dba5c58802..5f01f4dd06f 100644 --- a/public/sms/classes/gateway.php +++ b/public/sms/classes/gateway.php @@ -139,6 +139,19 @@ abstract class gateway { * @return string The truncated message. */ public function truncate_message(string $message): string { + if (strlen($message) > static::MESSAGE_LENGTH_LIMIT) { + $message = self::remove_urls_from_message($message); + } return \core_text::substr($message, 0, static::MESSAGE_LENGTH_LIMIT); } + + /** + * Remove URLs from the message. + * + * @param string $message The message to check. + * @return string The updated message. + */ + public static function remove_urls_from_message(string $message): string { + return trim(preg_replace('/https?:\/\/\S+/', '', $message)); + } } diff --git a/public/sms/tests/gateway_test.php b/public/sms/tests/gateway_test.php index b66226d81ea..b5bb04e741a 100644 --- a/public/sms/tests/gateway_test.php +++ b/public/sms/tests/gateway_test.php @@ -79,4 +79,54 @@ final class gateway_test extends \advanced_testcase { $this->expectException(\coding_exception::class); $othergw->update_message_status($message); } + + /** + * Test truncation of messages. + * + * @dataProvider get_truncation_strings + * @param string $original + * @param string $truncated + */ + public function test_truncate_message( + string $content, + string $expected, + ): void { + $this->resetAfterTest(); + + $manager = \core\di::get(\core_sms\manager::class); + $config = new \stdClass(); + $config->api_key = 'test_api_key'; + + $gw = $manager->create_gateway_instance( + classname: \smsgateway_dummy\gateway::class, + name: 'dummy', + enabled: true, + config: $config, + ); + + $truncated = $gw->truncate_message($content); + $this->assertSame($expected, $truncated); + } + + /** + * Data provider for test_truncate_message. + * + * @return array + */ + public static function get_truncation_strings(): array { + return [ + 'Over limit with URL' => [ + 'content' => 'Moodle is a flexible, open-source learning platform designed to help educators deliver online courses and manage student progress effectively. Visit https://moodle.org', + 'expected' => 'Moodle is a flexible, open-source learning platform designed to help educators deliver online courses and manage student progress effectively. Visit', + ], + 'Over limit' => [ + 'content' => 'Moodle is a widely used open-source learning platform that empowers educators to build customizable online courses, track student performance, and foster collaborative digital learning.', + 'expected' => 'Moodle is a widely used open-source learning platform that empowers educators to build customizable online courses, track student performance, and foster collab', + ], + 'Under limit' => [ + 'content' => 'Moodle is a widely used open-source learning platform that empowers educators.', + 'expected' => 'Moodle is a widely used open-source learning platform that empowers educators.', + ], + ]; + } } From e385176ffa7a98c816ce0aea618690a6534fb76d Mon Sep 17 00:00:00 2001 From: David Woloszyn Date: Fri, 27 Jun 2025 15:01:44 +1000 Subject: [PATCH 6/7] MDL-83519 router: Checks to routerconfigured are less strict --- public/lib/classes/router.php | 2 +- public/lib/classes/url.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/lib/classes/router.php b/public/lib/classes/router.php index ad7b3bb3194..270e9224f17 100644 --- a/public/lib/classes/router.php +++ b/public/lib/classes/router.php @@ -112,7 +112,7 @@ class router { } } - if ($CFG->routerconfigured !== true || $userphp) { + if ($CFG->routerconfigured != true || $userphp) { $scriptroot .= '/r.php'; } diff --git a/public/lib/classes/url.php b/public/lib/classes/url.php index f390760a240..5e0dcb0295c 100644 --- a/public/lib/classes/url.php +++ b/public/lib/classes/url.php @@ -684,7 +684,7 @@ All values that are not arrays should be a string.'); ): self { global $CFG; - if ($CFG->routerconfigured !== true) { + if ($CFG->routerconfigured != true) { $path = '/r.php/' . ltrim($path, '/'); } $url = new self($path, $params, $anchor); From 5a25d876c77ff9538336185527eeaf91d2921e90 Mon Sep 17 00:00:00 2001 From: Huong Nguyen Date: Fri, 22 Aug 2025 08:35:18 +0700 Subject: [PATCH 7/7] MDL-83519 core: Add shortlink privacy provider --- public/lang/en/moodle.php | 6 ++ public/lib/classes/privacy/provider.php | 56 +++++++++-- public/lib/tests/privacy/provider_test.php | 110 +++++++++++++++++++-- 3 files changed, 158 insertions(+), 14 deletions(-) diff --git a/public/lang/en/moodle.php b/public/lang/en/moodle.php index 5eb4402730f..1555ac98ff0 100644 --- a/public/lang/en/moodle.php +++ b/public/lang/en/moodle.php @@ -1759,6 +1759,12 @@ $string['privacy:metadata:oauth2_refresh_token:token'] = 'The refresh token for $string['privacy:metadata:oauth2_refresh_token:timecreated'] = 'The time when the token was created'; $string['privacy:metadata:oauth2_refresh_token:timemodified'] = 'The time when the token was last updated'; $string['privacy:metadata:oauth2_refresh_token:userid'] = 'The ID of the user to whom the token corresponds'; +$string['privacy:metadata:shortlink'] = 'Shortlink URL details'; +$string['privacy:metadata:shortlink:shortcode'] = 'The shortcode to use for the shortlink'; +$string['privacy:metadata:shortlink:userid'] = 'The ID of the user associated with the shortlink'; +$string['privacy:metadata:shortlink:component'] = 'The component associated with the shortlink'; +$string['privacy:metadata:shortlink:linktype'] = 'The type of link the shortlink can be identified as'; +$string['privacy:metadata:shortlink:identifier'] = 'The unique identifier associated with the shortlink'; $string['privacy:metadata:task_adhoc'] = 'The status of ad hoc tasks.'; $string['privacy:metadata:task_adhoc:component'] = 'The component owning the task.'; $string['privacy:metadata:task_adhoc:nextruntime'] = 'The earliest time to run this task.'; diff --git a/public/lib/classes/privacy/provider.php b/public/lib/classes/privacy/provider.php index 7f9de0f851b..565bf542c5a 100644 --- a/public/lib/classes/privacy/provider.php +++ b/public/lib/classes/privacy/provider.php @@ -142,6 +142,15 @@ class provider implements 'resourceurl' => 'privacy:metadata:moodlenet_share_progress:resourceurl', ], 'privacy:metadata:moodlenet_share_progress'); + // The shortlink table includes data that associates a user with a shortlink URL. + $collection->add_database_table('shortlink', [ + 'shortcode' => 'privacy:metadata:shortlink:shortcode', + 'userid' => 'privacy:metadata:shortlink:userid', + 'component' => 'privacy:metadata:shortlink:component', + 'linktype' => 'privacy:metadata:shortlink:linktype', + 'identifier' => 'privacy:metadata:shortlink:identifier', + ], 'privacy:metadata:shortlink'); + return $collection; } @@ -163,6 +172,15 @@ class provider implements $params = ['userid' => $userid, 'contextlevel' => CONTEXT_USER]; $contextlist->add_from_sql($sql, $params); + // Shortlink. + $sql = "SELECT ctx.id + FROM {context} ctx + JOIN {shortlink} sl ON ctx.instanceid = sl.userid + AND ctx.contextlevel = :contextlevel + WHERE sl.userid = :userid"; + $params = ['userid' => $userid, 'contextlevel' => CONTEXT_USER]; + $contextlist->add_from_sql($sql, $params); + return $contextlist; } @@ -172,7 +190,6 @@ class provider implements * @param userlist $userlist The userlist containing the list of users who have data in this context/plugin combination. */ public static function get_users_in_context(userlist $userlist) { - // Except for moodlenet_share_progress, don't add any users. $context = $userlist->get_context(); // MoodleNet share progress uses the user context. @@ -184,6 +201,16 @@ class provider implements $params = ['userid' => $context->instanceid]; $userlist->add_from_sql('userid', $sql, $params); } + + // Shortlink. + if ($context->contextlevel == CONTEXT_USER) { + // Get all distinct userids from the table. + $sql = "SELECT DISTINCT userid + FROM {shortlink} + WHERE userid = :userid"; + $params = ['userid' => $context->instanceid]; + $userlist->add_from_sql('userid', $sql, $params); + } } /** @@ -192,16 +219,20 @@ class provider implements * @param approved_contextlist $contextlist The approved contexts to export information for. */ public static function export_user_data(approved_contextlist $contextlist) { - // Except for moodlenet_share_progress, none of the core tables should be exported. + // Except for moodlenet_share_progress and shortlink, none of the core tables should be exported. global $DB; foreach ($contextlist as $context) { - // MoodleNet share progress uses the user context. if ($context->contextlevel == CONTEXT_USER && $context->instanceid == $contextlist->get_user()->id) { // Get the user's MoodleNet share progress data. $sharedata = $DB->get_records('moodlenet_share_progress', ['userid' => $context->instanceid]); $subcontext = get_string('privacy:metadata:moodlenet_share_progress', 'moodle'); writer::with_context($context)->export_data([$subcontext], (object) $sharedata); + + // Get the user's shortlink data. + $shortlinkdata = $DB->get_records('shortlink', ['userid' => $context->instanceid]); + $subcontext = get_string('privacy:metadata:shortlink', 'moodle'); + writer::with_context($context)->export_data([$subcontext], (object) $shortlinkdata); } } } @@ -212,13 +243,18 @@ class provider implements * @param \context $context The specific context to delete data for. */ public static function delete_data_for_all_users_in_context(\context $context) { - // Except for moodlenet_share_progress, none of the the data from these tables should be deleted. + // Except for moodlenet_share_progress and shortlink, none of the data from these tables should be deleted. global $DB; // MoodleNet share progress uses the user context. if ($context->contextlevel == CONTEXT_USER) { $DB->delete_records('moodlenet_share_progress', ['userid' => $context->instanceid]); } + + // Shortlink. + if ($context->contextlevel == CONTEXT_USER) { + $DB->delete_records('shortlink', ['userid' => $context->instanceid]); + } } /** @@ -227,15 +263,17 @@ class provider implements * @param approved_contextlist $contextlist The approved contexts and user information to delete information for. */ public static function delete_data_for_user(approved_contextlist $contextlist) { - // Except for moodlenet_share_progress, none of the the data from these tables should be deleted. + // Except for moodlenet_share_progress and shortlink, none of the data from these tables should be deleted. // Note: Although it may be tempting to delete the adhoc task data, do not do so. // The delete process is run as an adhoc task. global $DB; foreach ($contextlist as $context) { - // MoodleNet share progress uses the user context. if ($context->contextlevel == CONTEXT_USER && $context->instanceid == $contextlist->get_user()->id) { + // MoodleNet share progress uses the user context. $DB->delete_records('moodlenet_share_progress', ['userid' => $context->instanceid]); + // Shortlink. + $DB->delete_records('shortlink', ['userid' => $context->instanceid]); } } } @@ -246,7 +284,7 @@ class provider implements * @param approved_userlist $userlist The approved context and user information to delete information for. */ public static function delete_data_for_users(approved_userlist $userlist) { - // Except for moodlenet_share_progress, none of the the data from these tables should be deleted. + // Except for moodlenet_share_progress and shortlink, none of the data from these tables should be deleted. // Note: Although it may be tempting to delete the adhoc task data, do not do so. // The delete process is run as an adhoc task. global $DB; @@ -257,9 +295,11 @@ class provider implements return; } - // MoodleNet share progress uses the user context. if ($context->contextlevel == CONTEXT_USER) { + // MoodleNet share progress uses the user context. $DB->delete_records('moodlenet_share_progress', ['userid' => $context->instanceid]); + // Shortlink. + $DB->delete_records('shortlink', ['userid' => $context->instanceid]); } } } diff --git a/public/lib/tests/privacy/provider_test.php b/public/lib/tests/privacy/provider_test.php index 05b5b8e0ea5..041efd09bca 100644 --- a/public/lib/tests/privacy/provider_test.php +++ b/public/lib/tests/privacy/provider_test.php @@ -40,20 +40,30 @@ final class provider_test extends provider_testcase { */ public function test_get_contexts_for_userid(): void { $this->resetAfterTest(); - $user = $this->getDataGenerator()->create_user(); + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); // Check that there are no contexts used for the user yet. - $this->assertEmpty(provider::get_contexts_for_userid($user->id)); + $this->assertEmpty(provider::get_contexts_for_userid($user1->id)); // Insert a record. - $this->insert_dummy_moodlenet_share_progress_record($user->id); + $this->insert_dummy_moodlenet_share_progress_record($user1->id); + $this->insert_dummy_shortlink_record($user2->id); - // Check that we only get back one context. - $contextlist = provider::get_contexts_for_userid($user->id); + // Check that we only get back one context for user1. + $contextlist = provider::get_contexts_for_userid($user1->id); $this->assertCount(1, $contextlist); // Check that the context returned is the expected one. - $usercontext = \context_user::instance($user->id); + $usercontext = \context_user::instance($user1->id); + $this->assertEquals($usercontext->id, $contextlist->get_contextids()[0]); + + // Check that we only get back one context for user2. + $contextlist = provider::get_contexts_for_userid($user2->id); + $this->assertCount(1, $contextlist); + + // Check that the context returned is the expected one. + $usercontext = \context_user::instance($user2->id); $this->assertEquals($usercontext->id, $contextlist->get_contextids()[0]); } @@ -68,8 +78,10 @@ final class provider_test extends provider_testcase { // Create some users. $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); + $user3 = $this->getDataGenerator()->create_user(); $usercontext1 = \context_user::instance($user1->id); $usercontext2 = \context_user::instance($user2->id); + $usercontext3 = \context_user::instance($user3->id); // Get userlists and check they are empty for now. $userlist1 = new \core_privacy\local\request\userlist($usercontext1, 'core'); @@ -80,9 +92,14 @@ final class provider_test extends provider_testcase { provider::get_users_in_context($userlist2); $this->assertCount(0, $userlist2); + $userlist3 = new \core_privacy\local\request\userlist($usercontext3, 'core'); + provider::get_users_in_context($userlist3); + $this->assertCount(0, $userlist3); + // Insert records for both users. $this->insert_dummy_moodlenet_share_progress_record($user1->id); $this->insert_dummy_moodlenet_share_progress_record($user2->id); + $this->insert_dummy_shortlink_record($user3->id); // Check the userlists contain the correct users. $userlist1 = new \core_privacy\local\request\userlist($usercontext1, 'core'); @@ -94,6 +111,11 @@ final class provider_test extends provider_testcase { provider::get_users_in_context($userlist2); $this->assertCount(1, $userlist2); $this->assertEquals($user2->id, $userlist2->get_userids()[0]); + + $userlist3 = new \core_privacy\local\request\userlist($usercontext3, 'core'); + provider::get_users_in_context($userlist3); + $this->assertCount(1, $userlist3); + $this->assertEquals($user3->id, $userlist3->get_userids()[0]); } /** @@ -108,10 +130,12 @@ final class provider_test extends provider_testcase { // Create some users. $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); + $user3 = $this->getDataGenerator()->create_user(); // Insert a record for each user. $this->insert_dummy_moodlenet_share_progress_record($user1->id); $this->insert_dummy_moodlenet_share_progress_record($user2->id); + $this->insert_dummy_shortlink_record($user3->id); $subcontexts = [ get_string('privacy:metadata:moodlenet_share_progress', 'moodle') @@ -140,6 +164,32 @@ final class provider_test extends provider_testcase { $this->assertEquals($userdata->timecreated, reset($data)->timecreated); $this->assertEquals($userdata->resourceurl, reset($data)->resourceurl); $this->assertEquals($userdata->status, reset($data)->status); + + $subcontexts = [ + get_string('privacy:metadata:shortlink', 'moodle') + ]; + + // Check if user3 has any exported data yet. + $usercontext3 = \context_user::instance($user3->id); + $writer = writer::with_context($usercontext3); + $this->assertFalse($writer->has_any_data()); + + // Export user3's data and check the count. + $approvedlist = new approved_contextlist($user3, 'core', [$usercontext3->id]); + provider::export_user_data($approvedlist); + $data = (array)$writer->get_data($subcontexts); + $this->assertCount(1, $data); + + // Get the inserted data. + $userdata = $DB->get_record('shortlink', ['userid' => $user3->id]); + + // Check exported data against the inserted data. + $this->assertEquals($userdata->id, reset($data)->id); + $this->assertEquals($userdata->shortcode, reset($data)->shortcode); + $this->assertEquals($userdata->userid, reset($data)->userid); + $this->assertEquals($userdata->component, reset($data)->component); + $this->assertEquals($userdata->linktype, reset($data)->linktype); + $this->assertEquals($userdata->identifier, reset($data)->identifier); } /** @@ -158,10 +208,14 @@ final class provider_test extends provider_testcase { // Insert a record for each user. $this->insert_dummy_moodlenet_share_progress_record($user1->id); $this->insert_dummy_moodlenet_share_progress_record($user2->id); + $this->insert_dummy_shortlink_record($user1->id); + $this->insert_dummy_shortlink_record($user2->id); // Get all users' data. $usersdata = $DB->get_records('moodlenet_share_progress', []); $this->assertCount(2, $usersdata); + $usersdata = $DB->get_records('shortlink', []); + $this->assertCount(2, $usersdata); // Delete everything for a user1 in context. $usercontext1 = \context_user::instance($user1->id); @@ -171,6 +225,9 @@ final class provider_test extends provider_testcase { $usersdata = $DB->get_records('moodlenet_share_progress', []); $this->assertCount(1, $usersdata); $this->assertEquals($user2->id, reset($usersdata)->userid); + $usersdata = $DB->get_records('shortlink', []); + $this->assertCount(1, $usersdata); + $this->assertEquals($user2->id, reset($usersdata)->userid); } /** @@ -189,10 +246,14 @@ final class provider_test extends provider_testcase { // Insert a record for each user. $this->insert_dummy_moodlenet_share_progress_record($user1->id); $this->insert_dummy_moodlenet_share_progress_record($user2->id); + $this->insert_dummy_shortlink_record($user1->id); + $this->insert_dummy_shortlink_record($user2->id); // Get all users' data. $usersdata = $DB->get_records('moodlenet_share_progress', []); $this->assertCount(2, $usersdata); + $usersdata = $DB->get_records('shortlink', []); + $this->assertCount(2, $usersdata); // Delete everything for user1. $usercontext1 = \context_user::instance($user1->id); @@ -203,6 +264,9 @@ final class provider_test extends provider_testcase { $usersdata = $DB->get_records('moodlenet_share_progress', []); $this->assertCount(1, $usersdata); $this->assertEquals($user2->id, reset($usersdata)->userid); + $usersdata = $DB->get_records('shortlink', []); + $this->assertCount(1, $usersdata); + $this->assertEquals($user2->id, reset($usersdata)->userid); } /** @@ -223,16 +287,22 @@ final class provider_test extends provider_testcase { // Insert a record for each user. $this->insert_dummy_moodlenet_share_progress_record($user1->id); $this->insert_dummy_moodlenet_share_progress_record($user2->id); + $this->insert_dummy_shortlink_record($user1->id); + $this->insert_dummy_shortlink_record($user2->id); // Check the count on all user's data. $usersdata = $DB->get_records('moodlenet_share_progress', []); $this->assertCount(2, $usersdata); + $usersdata = $DB->get_records('shortlink', []); + $this->assertCount(2, $usersdata); // Attempt to delete data for user1 using user2's context (should have no effect). $approvedlist = new approved_userlist($usercontext2, 'core', [$user1->id]); provider::delete_data_for_users($approvedlist); $usersdata = $DB->get_records('moodlenet_share_progress', []); $this->assertCount(2, $usersdata); + $usersdata = $DB->get_records('shortlink', []); + $this->assertCount(2, $usersdata); // Delete data for user1 using its correct context. $approvedlist = new approved_userlist($usercontext1, 'core', [$user1->id]); @@ -242,6 +312,9 @@ final class provider_test extends provider_testcase { $usersdata = $DB->get_records('moodlenet_share_progress', []); $this->assertCount(1, $usersdata); $this->assertEquals($user2->id, reset($usersdata)->userid); + $usersdata = $DB->get_records('shortlink', []); + $this->assertCount(1, $usersdata); + $this->assertEquals($user2->id, reset($usersdata)->userid); } /** @@ -255,4 +328,29 @@ final class provider_test extends provider_testcase { $cmid = 456; share_recorder::insert_share_progress($sharetype, $userid, $courseid, $cmid); } + + /** + * Helper function to insert a shortlink record for use in the tests. + * + * @param int $userid The ID of the user to link the record to. + */ + protected function insert_dummy_shortlink_record( + int $userid, + ): void { + // Mock the handler. + $handler = $this->createMock(\core\shortlink_handler_interface::class); + $handler->method('get_valid_linktypes') + ->willReturn(['view']); + $handler->method('process_shortlink') + ->with( + $this->equalTo('view'), + $this->equalTo(123), + ) + ->willReturn(new \core\url('https://example.com')); + \core\di::set("mod_example\\shortlink_handler", $handler); + + // Create a shortlink for the user. + $manager = \core\di::get(\core\shortlink::class); + $manager->create_shortlink('mod_example', 'view', 123, $userid); + } }