Merge branch 'MDL-83519-main' of https://github.com/HuongNV13/moodle

This commit is contained in:
Huong Nguyen
2025-08-22 08:37:13 +07:00
23 changed files with 1413 additions and 34 deletions
+6
View File
@@ -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.';
+48 -8
View File
@@ -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]);
}
}
}
+109
View File
@@ -0,0 +1,109 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
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 <[email protected]>
* @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);
}
}
}
+14 -1
View File
@@ -112,7 +112,7 @@ class router {
}
}
if ($CFG->routerconfigured !== true || $userphp) {
if ($CFG->routerconfigured != true || $userphp) {
$scriptroot .= '/r.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.
*/
+10 -8
View File
@@ -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;
}
@@ -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;
}
}
@@ -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.
*
+5 -2
View File
@@ -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.
*
+385
View File
@@ -0,0 +1,385 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core;
use core\exception\coding_exception;
/**
* Shortlink manager for Moodle.
*
* @package core
* @copyright Andrew Lyons <[email protected]>
* @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}");
}
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core;
/**
* Shortlink handler for the Moodle core subsystem.
*
* @package core
* @copyright Andrew Lyons <[email protected]>
* @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;
}
}
@@ -0,0 +1,47 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core;
/**
* Class shortlink_handler_interface.
*
* @package core
* @copyright 2025 Andrew Lyons <[email protected]>
* @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;
}
+1 -1
View File
@@ -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);
+19 -1
View File
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" ?>
<XMLDB PATH="lib/db" VERSION="20250528" COMMENT="XMLDB file for core Moodle tables"
<XMLDB PATH="lib/db" VERSION="20250629" COMMENT="XMLDB file for core Moodle tables"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="../../lib/xmldb/xmldb.xsd"
>
@@ -5021,5 +5021,23 @@
<KEY NAME="primary" TYPE="primary" FIELDS="id"/>
</KEYS>
</TABLE>
<TABLE NAME="shortlink" COMMENT="Short codes for user short links">
<FIELDS>
<FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
<FIELD NAME="shortcode" TYPE="char" LENGTH="12" NOTNULL="true" SEQUENCE="false" COMMENT="The shortcode to use for the short link"/>
<FIELD NAME="userid" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false"/>
<FIELD NAME="component" TYPE="char" LENGTH="100" NOTNULL="true" SEQUENCE="false"/>
<FIELD NAME="linktype" TYPE="char" LENGTH="100" NOTNULL="true" SEQUENCE="false"/>
<FIELD NAME="identifier" TYPE="char" LENGTH="1333" NOTNULL="true" SEQUENCE="false" COMMENT="An identifier for this component / type combination"/>
</FIELDS>
<KEYS>
<KEY NAME="primary" TYPE="primary" FIELDS="id"/>
<KEY NAME="userid" TYPE="foreign" FIELDS="userid" REFTABLE="user" REFFIELDS="id"/>
</KEYS>
<INDEXES>
<INDEX NAME="shortcode_userid" UNIQUE="true" FIELDS="userid, shortcode"/>
<INDEX NAME="shortcode" UNIQUE="false" FIELDS="shortcode"/>
</INDEXES>
</TABLE>
</TABLES>
</XMLDB>
+30
View File
@@ -2037,5 +2037,35 @@ function xmldb_main_upgrade($oldversion) {
upgrade_main_savepoint(true, 2025081900.03);
}
if ($oldversion < 2025081900.04) {
// 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.04);
}
return true;
}
+104 -6
View File
@@ -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);
}
}
+347
View File
@@ -0,0 +1,347 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core;
/**
* Tests for the short link manager.
*
* @package core
* @category test
* @copyright Andrew Lyons <[email protected]>
* @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);
}
}
@@ -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();
@@ -0,0 +1,48 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace mod_assign;
use core\shortlink_handler_interface;
/**
* Shortlink handler for mod_assign.
*
* @package mod_assign
* @copyright 2025 Andrew Lyons <[email protected]>
* @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,
};
}
}
+2 -2
View File
@@ -79,12 +79,12 @@ $string['assignmentduesoonhtml'] = '<p>Hi {$a->firstname},</p>
<p>The assignment <strong>{$a->assignmentname}</strong> in course {$a->coursename} is due soon.</p>
<p><strong>Due: {$a->duedate}</strong></p>
<p><a href="{$a->url}">Go to activity</a></p>';
$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'] = '<p>Hi {$a->firstname},</p>
<p><strong>{$a->assignmentname}</strong> in course {$a->coursename} was due on <strong>{$a->duedate}</strong>.</p>
<p>You might still be able to submit your assignment{$a->cutoffsnippet}, but your submission will be marked as late.</p>
<p><a href="{$a->url}">Go to activity</a></p>';
$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'] = ' <strong>by {$a->cutoffdate}</strong>';
$string['assignmentduesoonsubject'] = 'Due on {$a->duedate}: {$a->assignmentname}';
$string['assignmentoverduesubject'] = 'Overdue: {$a->assignmentname}';
+13
View File
@@ -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));
}
}
+57
View File
@@ -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,
);
}
}
+50
View File
@@ -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.',
],
];
}
}
+1 -1
View File
@@ -29,7 +29,7 @@
defined('MOODLE_INTERNAL') || die();
$version = 2025081900.03; // YYYYMMDD = weekly release date of this DEV branch.
$version = 2025081900.04; // 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