Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 78 additions & 5 deletions classes/exeonline/exeonline_redirector.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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');
Expand Down Expand Up @@ -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.
Expand Down
45 changes: 45 additions & 0 deletions returnto.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?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/>.

/**
* 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));
149 changes: 149 additions & 0 deletions tests/exeonline/exeonline_redirector_test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
<?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/>.

/**
* 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('&amp;', '&', $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));
}
}
Loading