diff --git a/classes/exeonline/exeonline_redirector.php b/classes/exeonline/exeonline_redirector.php index b35aca9..f70b456 100644 --- a/classes/exeonline/exeonline_redirector.php +++ b/classes/exeonline/exeonline_redirector.php @@ -28,9 +28,20 @@ use mod_exeweb\exeonline\token_manager; use \moodle_url; +use \stdClass; class exeonline_redirector { + /** + * Module script eXeLearning sends the browser back to when the final destination is outside the module. + */ + private const RETURNTO_SCRIPT = '/mod/exeweb/returnto.php'; + + /** + * Query parameter carrying the final destination through RETURNTO_SCRIPT. + */ + public const RETURNTO_PARAM = 'returnurl'; + /** * Keeps if editing or adding instance. * @@ -55,18 +66,23 @@ public function __construct(string $action = 'edit', moodle_url $returnto = null * * @param integer $cmid * @param string|null $action - * @param moodle_url|null $returnto + * @param moodle_url|null $returnto Where the user's browser ends up once eXeLearning sends the + * package back. Defaults to the activity's course. * * @return moodle_url */ public static function get_redirection_url(int $cmid, moodle_url $returnto = null, string $action = null) { - global $CFG, $USER; + global $USER; $action = $action ?? self::$action; $target = $action === 'add' ? '/new_ode' : '/edit_ode'; - $returnto = $returnto ?? self::$returnto ?? new moodle_url($CFG->wwwroot); - // Ensure return url has a valid cmid if it is a module view url. - if (strpos($returnto->get_path(), 'mod/exeweb') !== false) { + $returnto = $returnto ?? self::$returnto; + if ($returnto !== null && strpos($returnto->get_path(), 'mod/exeweb') !== false) { + // Ensure return url has a valid cmid if it is a module view url. $returnto->params(['id' => $cmid]); + } else { + // Any other destination, and the no-destination case that lands on the activity's + // course, has to travel through the module's return script. + $returnto = self::get_returnto_url($cmid, $returnto); } // Get remote URL from config. $exeonlineurl = get_config('exeweb', 'exeonlinebaseuri'); @@ -94,6 +110,63 @@ public static function get_redirection_url(int $cmid, moodle_url $returnto = nul return new \moodle_url($url, $params); } + /** + * Wraps a destination outside the module into a return url eXeLearning can work with. + * + * eXeLearning derives its Moodle callback endpoints (get_ode.php / set_ode.php) by splitting the + * return url on the module path, so anything outside /mod/exeweb (the course page used by the + * "Edit on eXeLearning and return to course" button, for instance) aborts the send back with + * "Could not build platform integration URL from return URL". RETURNTO_SCRIPT does live under the + * module path, so it is accepted, and it forwards the browser to the real destination. + * + * A destination that is null, the bare site root, or outside this Moodle carries no parameter, + * which makes the return script fall back to the activity's course. + * + * @param integer $cmid + * @param moodle_url|null $returnto Final destination for the user's browser. + * @return moodle_url + */ + private static function get_returnto_url(int $cmid, moodle_url $returnto = null) { + global $CFG; + + $params = ['id' => $cmid]; + if ($returnto !== null) { + $destination = $returnto->out(false); + // Only a destination inside this Moodle can be forwarded to. Mirrors the roots accepted + // by moodle_url::out_as_local_url(), without using its exception as control flow. + foreach ([$CFG->wwwroot, str_replace('http://', 'https://', $CFG->wwwroot)] as $root) { + if (strpos($destination, $root . '/') === 0) { + $params[self::RETURNTO_PARAM] = substr($destination, strlen($root)); + break; + } + } + } + + return new \moodle_url(self::RETURNTO_SCRIPT, $params); + } + + /** + * Resolves the destination RETURNTO_SCRIPT has to forward the browser to. + * + * Counterpart of {@see get_returnto_url()}: it turns the carried parameter back into a url, and + * falls back to the activity's course when nothing usable arrived, either because the wrapping + * dropped a non-local destination or because the script was reached by hand. + * + * @param string $returnurl Value of the RETURNTO_PARAM parameter, already cleaned as PARAM_LOCALURL. + * @param stdClass $course Course the activity belongs to. + * @return moodle_url + */ + public static function resolve_returnto_url(string $returnurl, stdClass $course) { + global $CFG; + + if ($returnurl === '') { + require_once($CFG->dirroot . '/course/lib.php'); + + return course_get_url($course); + } + + return new \moodle_url($returnurl); + } /** * Hack to get redirected to eXeLearning Online by core's modedit. diff --git a/returnto.php b/returnto.php new file mode 100644 index 0000000..60eeb66 --- /dev/null +++ b/returnto.php @@ -0,0 +1,45 @@ +. + +/** + * Landing page used as the eXeLearning return url when the final destination is outside the module. + * + * eXeLearning only accepts return urls living under /mod/exeweb, so this script is the entry point + * that lets the "Edit on eXeLearning and return to course" button reach the course page. All the + * routing rules live in exeonline_redirector, which is the side that builds this url in the first + * place; this file is just the web-addressable dispatcher Moodle requires. + * + * @package mod_exeweb + * @copyright 2026 eXeLearning + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +use mod_exeweb\exeonline\exeonline_redirector; + +require_once(__DIR__ . '/../../config.php'); + +$id = required_param('id', PARAM_INT); // Course module ID. +// PARAM_LOCALURL empties anything pointing outside this Moodle, so this can't become an open redirect. +$returnurl = optional_param(exeonline_redirector::RETURNTO_PARAM, '', PARAM_LOCALURL); + +$cm = get_coursemodule_from_id('exeweb', $id, 0, false, MUST_EXIST); +$course = $DB->get_record('course', ['id' => $cm->course], '*', MUST_EXIST); + +$PAGE->set_url(new moodle_url('/mod/exeweb/returnto.php', ['id' => $cm->id])); + +require_login($course, false, $cm); + +redirect(exeonline_redirector::resolve_returnto_url($returnurl, $course)); diff --git a/tests/exeonline/exeonline_redirector_test.php b/tests/exeonline/exeonline_redirector_test.php new file mode 100644 index 0000000..8be710b --- /dev/null +++ b/tests/exeonline/exeonline_redirector_test.php @@ -0,0 +1,149 @@ +. + +/** + * Unit tests for the eXeLearning redirector. + * + * @package mod_exeweb + * @copyright 2026 eXeLearning + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_exeweb\exeonline; + +use moodle_url; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Tests for mod_exeweb\exeonline\exeonline_redirector. + * + * @covers \mod_exeweb\exeonline\exeonline_redirector + */ +class exeonline_redirector_test extends \advanced_testcase { + + /** @var int Arbitrary course module id; the redirector only forwards it, it never looks it up. */ + const CMID = 42; + + protected function setUp(): void { + parent::setUp(); + $this->resetAfterTest(true); + set_config('exeonlinebaseuri', 'https://exelearning.example.com', 'exeweb'); + set_config('hmackey1', 'testkey', 'exeweb'); + set_config('tokenexpiration', 300, 'exeweb'); + } + + /** + * Returns the returnurl claim of the JWT carried by a redirection url. + * + * @param moodle_url $url + * @return string + */ + protected function get_payload_returnurl(moodle_url $url): string { + $payload = token_manager::validate_jwt_token($url->param('jwt_token')); + $this->assertIsObject($payload, 'JWT could not be decoded: ' . (is_string($payload) ? $payload : '')); + + return $payload->returnurl; + } + + public function test_module_return_url_is_kept_and_gets_the_cmid(): void { + $url = exeonline_redirector::get_redirection_url( + self::CMID, + new moodle_url('/mod/exeweb/view.php', ['id' => 1, 'forceview' => 1]) + ); + + $returnurl = $this->get_payload_returnurl($url); + $this->assertStringContainsString('/mod/exeweb/view.php', $returnurl); + $this->assertStringContainsString('id=' . self::CMID, $returnurl); + } + + public function test_course_return_url_is_routed_through_the_module(): void { + global $CFG; + + $courseurl = new moodle_url('/course/view.php', ['id' => 7]); + $url = exeonline_redirector::get_redirection_url(self::CMID, $courseurl); + + // eXeLearning only accepts return urls under the module path, so the course page + // must be reached through returnto.php instead of being sent as is. + $returnurl = $this->get_payload_returnurl($url); + $this->assertStringStartsWith($CFG->wwwroot . '/mod/exeweb/returnto.php', $returnurl); + $this->assertStringContainsString('id=' . self::CMID, $returnurl); + $this->assertStringContainsString(rawurlencode('/course/view.php?id=7'), str_replace('&', '&', $returnurl)); + } + + public function test_default_return_url_resolves_to_the_activity_course(): void { + global $CFG; + + require_once($CFG->dirroot . '/course/lib.php'); + + $course = $this->getDataGenerator()->create_course(); + + // No explicit destination. The wrapped url carries no destination at all, which is what + // makes the return script land the user on the activity's course rather than the site home. + $wrapped = new moodle_url($this->get_payload_returnurl( + exeonline_redirector::get_redirection_url(self::CMID) + )); + + $this->assertSame($CFG->wwwroot . '/mod/exeweb/returnto.php', $wrapped->out_omit_querystring()); + $this->assertSame(self::CMID, (int)$wrapped->param('id')); + $this->assertNull($wrapped->param(exeonline_redirector::RETURNTO_PARAM)); + $this->assertSame( + course_get_url($course)->out(false), + exeonline_redirector::resolve_returnto_url('', $course)->out(false) + ); + } + + public function test_external_return_url_is_dropped_and_resolves_to_the_course(): void { + global $CFG; + + require_once($CFG->dirroot . '/course/lib.php'); + + $course = $this->getDataGenerator()->create_course(); + + // A destination outside this Moodle can't be forwarded to, so it is discarded instead of + // being handed to returnto.php. + $wrapped = new moodle_url($this->get_payload_returnurl( + exeonline_redirector::get_redirection_url(self::CMID, new moodle_url('https://not-this-moodle.invalid/foo')) + )); + + $this->assertSame($CFG->wwwroot . '/mod/exeweb/returnto.php', $wrapped->out_omit_querystring()); + $this->assertNull($wrapped->param(exeonline_redirector::RETURNTO_PARAM)); + $this->assertSame( + course_get_url($course)->out(false), + exeonline_redirector::resolve_returnto_url('', $course)->out(false) + ); + } + + public function test_resolve_returnto_url_restores_the_carried_destination(): void { + global $CFG; + + $course = $this->getDataGenerator()->create_course(); + $resolved = exeonline_redirector::resolve_returnto_url('/course/view.php?id=' . $course->id, $course); + + $this->assertSame($CFG->wwwroot . '/course/view.php?id=' . $course->id, $resolved->out(false)); + } + + public function test_resolve_returnto_url_falls_back_to_the_course(): void { + global $CFG; + + require_once($CFG->dirroot . '/course/lib.php'); + + $course = $this->getDataGenerator()->create_course(); + $resolved = exeonline_redirector::resolve_returnto_url('', $course); + + $this->assertSame(course_get_url($course)->out(false), $resolved->out(false)); + } +}