This commit is contained in:
Markos Gogoulos
2026-03-03 23:07:09 +02:00
parent 45774fbc8c
commit edd40d45b4
5 changed files with 287 additions and 87 deletions

View File

@@ -8,57 +8,3 @@
*/
defined('MOODLE_INTERNAL') || die();
/**
* Find the first LTI activity for the MediaCMS tool in a course, or create a
* visible dummy one if none exists. Repairs any existing stealth/hidden activity.
*
* @param int $courseid
* @param int $typeid LTI tool type ID
* @return int course-module ID
*/
function filter_mediacms_get_dummy_activity($courseid, $typeid) {
global $DB;
$sql = "SELECT cm.id
FROM {course_modules} cm
JOIN {modules} m ON m.id = cm.module
JOIN {lti} lti ON lti.id = cm.instance
WHERE cm.course = :courseid
AND m.name = 'lti'
AND lti.typeid = :typeid
AND cm.deletioninprogress = 0
ORDER BY cm.visible DESC, cm.visibleoncoursepage DESC
LIMIT 1";
$existing = $DB->get_record_sql($sql, ['courseid' => $courseid, 'typeid' => $typeid]);
if ($existing) {
return $existing->id;
}
// Create the dummy activity then immediately force visibleoncoursepage=0,
// because add_moduleinfo() always defaults it to 1.
$moduleinfo = new stdClass();
$moduleinfo->course = $courseid;
$moduleinfo->module = $DB->get_field('modules', 'id', ['name' => 'lti']);
$moduleinfo->modulename = 'lti';
$moduleinfo->section = 0;
$moduleinfo->visible = 1;
$moduleinfo->visibleoncoursepage = 1;
$moduleinfo->availability = null;
$moduleinfo->showdescription = 0;
$moduleinfo->name = 'My Media';
$moduleinfo->intro = '';
$moduleinfo->introformat = FORMAT_HTML;
$moduleinfo->typeid = $typeid;
$moduleinfo->instructorchoiceacceptgrades = 0;
$moduleinfo->grade = 0;
$moduleinfo->instructorchoicesendname = 1;
$moduleinfo->instructorchoicesendemailaddr = 1;
$moduleinfo->launchcontainer = LTI_LAUNCH_CONTAINER_EMBED_NO_BLOCKS;
$moduleinfo->instructorcustomparameters = '';
$result = add_moduleinfo($moduleinfo, get_course($courseid));
return $result->coursemodule;
}

View File

@@ -0,0 +1,190 @@
<?php
/**
* MediaCMS custom LTI 1.3 auth endpoint.
*
* Functionally identical to /mod/lti/auth.php except the no-activity (id=0)
* branch only requires the user to be logged in — not moodle/course:manageactivities.
*
* Custom params (publishdata, redirect_path) are read from the PHP session
* where lti_launch.php / select_media_picker.php store them before starting
* the OIDC flow.
*
* Setup required:
* 1. MediaCMS admin → LTI Platforms → edit Moodle record:
* set "Auth login url" to https://YOUR_MOODLE/filter/mediacms/lti_auth.php
* 2. Moodle admin → Site admin → Plugins → Activity modules → External tool
* → Manage tools → MediaCMS tool → edit → add to "Redirection URIs":
* https://YOUR_MOODLE/filter/mediacms/lti_auth.php
*
* @package filter_mediacms
* @copyright 2026 MediaCMS
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
require_once(__DIR__ . '/../../config.php');
require_once($CFG->dirroot . '/mod/lti/locallib.php');
global $_POST, $_SERVER, $SESSION;
if (!isloggedin() && empty($_POST['repost'])) {
header_remove("Set-Cookie");
$PAGE->set_pagelayout('popup');
$PAGE->set_context(context_system::instance());
$output = $PAGE->get_renderer('mod_lti');
$page = new \mod_lti\output\repost_crosssite_page($_SERVER['REQUEST_URI'], $_POST);
echo $output->header();
echo $output->render($page);
echo $output->footer();
return;
}
$scope = optional_param('scope', '', PARAM_TEXT);
$responsetype = optional_param('response_type', '', PARAM_TEXT);
$clientid = optional_param('client_id', '', PARAM_TEXT);
$redirecturi = optional_param('redirect_uri', '', PARAM_URL);
$loginhint = optional_param('login_hint', '', PARAM_TEXT);
$ltimessagehintenc = optional_param('lti_message_hint', '', PARAM_TEXT);
$state = optional_param('state', '', PARAM_TEXT);
$responsemode = optional_param('response_mode', '', PARAM_TEXT);
$nonce = optional_param('nonce', '', PARAM_TEXT);
$prompt = optional_param('prompt', '', PARAM_TEXT);
$ok = !empty($scope) && !empty($responsetype) && !empty($clientid) &&
!empty($redirecturi) && !empty($loginhint) && !empty($nonce);
if (!$ok) {
$error = 'invalid_request';
}
$ltimessagehint = json_decode($ltimessagehintenc);
$ok = $ok && isset($ltimessagehint->launchid);
if (!$ok) {
$error = 'invalid_request';
$desc = 'No launch id in LTI hint';
}
if ($ok && ($scope !== 'openid')) {
$ok = false;
$error = 'invalid_scope';
}
if ($ok && ($responsetype !== 'id_token')) {
$ok = false;
$error = 'unsupported_response_type';
}
if ($ok) {
$launchid = $ltimessagehint->launchid;
list($courseid, $typeid, $id, $messagetype, $foruserid, $titleb64, $textb64) =
explode(',', $SESSION->$launchid, 7);
unset($SESSION->$launchid);
$config = lti_get_type_type_config($typeid);
$ok = ($clientid === $config->lti_clientid);
if (!$ok) {
$error = 'unauthorized_client';
}
}
if ($ok && ($loginhint !== $USER->id)) {
$ok = false;
$error = 'access_denied';
}
if (empty($config)) {
throw new moodle_exception('invalidrequest', 'error');
} else {
$uris = array_map('trim', explode("\n", $config->lti_redirectionuris));
if (!in_array($redirecturi, $uris)) {
throw new moodle_exception('invalidrequest', 'error');
}
}
if ($ok) {
if (isset($responsemode)) {
$ok = ($responsemode === 'form_post');
if (!$ok) {
$error = 'invalid_request';
$desc = 'Invalid response_mode';
}
} else {
$ok = false;
$error = 'invalid_request';
$desc = 'Missing response_mode';
}
}
if ($ok && !empty($prompt) && ($prompt !== 'none')) {
$ok = false;
$error = 'invalid_request';
$desc = 'Invalid prompt';
}
if ($ok) {
$course = $DB->get_record('course', ['id' => $courseid], '*', MUST_EXIST);
if ($id) {
// Activity-based launch — identical to auth.php's if ($id) branch.
$cm = get_coursemodule_from_id('lti', $id, 0, false, MUST_EXIST);
$context = context_module::instance($cm->id);
require_login($course, true, $cm);
require_capability('mod/lti:view', $context);
$lti = $DB->get_record('lti', ['id' => $cm->instance], '*', MUST_EXIST);
$lti->cmid = $cm->id;
list($endpoint, $params) = lti_get_launch_data($lti, $nonce, $messagetype, $foruserid);
} else {
// No-activity launch — student-accessible.
// Custom params (publishdata / redirect_path) were stored in the session
// by lti_launch.php or select_media_picker.php before initiating the OIDC flow.
require_login($course);
$customparams = '';
if (!empty($SESSION->mediacms_launch_customparams)) {
$customparams = $SESSION->mediacms_launch_customparams;
unset($SESSION->mediacms_launch_customparams);
}
// Minimal LTI instance object — enough for lti_get_launch_data to sign the JWT.
$lti = new stdClass();
$lti->id = 0;
$lti->typeid = (int) $typeid;
$lti->course = (int) $courseid;
$lti->cmid = 0;
$lti->name = 'MediaCMS';
$lti->toolurl = '';
$lti->securetoolurl = '';
$lti->instructorcustomparameters = $customparams;
$lti->instructorchoicesendname = LTI_SETTING_ALWAYS;
$lti->instructorchoicesendemailaddr = LTI_SETTING_ALWAYS;
$lti->instructorchoiceacceptgrades = LTI_SETTING_NEVER;
$lti->instructorchoiceallowroster = null;
$lti->launchcontainer = LTI_LAUNCH_CONTAINER_EMBED_NO_BLOCKS;
$lti->resourcekey = '';
$lti->password = '';
$lti->servicesalt = '';
$lti->resource_link_id = '';
list($endpoint, $params) = lti_get_launch_data(
$lti,
$nonce,
$messagetype ?: 'basic-lti-launch-request',
$foruserid
);
}
} else {
$params['error'] = $error;
if (!empty($desc)) {
$params['error_description'] = $desc;
}
}
if (isset($state)) {
$params['state'] = $state;
}
unset($SESSION->lti_message_hint);
$r = '<form action="' . $redirecturi . "\" name=\"ltiAuthForm\" id=\"ltiAuthForm\" " .
"method=\"post\" enctype=\"application/x-www-form-urlencoded\">\n";
foreach ($params as $key => $value) {
$key = htmlspecialchars($key, ENT_COMPAT);
$value = htmlspecialchars($value, ENT_COMPAT);
$r .= " <input type=\"hidden\" name=\"{$key}\" value=\"{$value}\"/>\n";
}
$r .= "</form>\n";
$r .= "<script type=\"text/javascript\">\n" .
"//<![CDATA[\n" .
"document.ltiAuthForm.submit();\n" .
"//]]>\n" .
"</script>\n";
echo $r;

View File

@@ -5,6 +5,13 @@
* Builds custom_publishdata (enrolled courses + roles) and initiates
* the LTI 1.3 OIDC login flow, outputting the auto-submit form directly.
*
* No dummy LTI activity is created. The publishdata is stored in the PHP
* session and picked up by lti_auth.php during the OIDC callback.
*
* Edge case: if the user is not enrolled in any course the launch still
* proceeds using the site course (SITEID). MediaCMS will receive an empty
* publishdata array and can decide how to handle it (e.g. show a message).
*
* @package filter_mediacms
* @copyright 2026 MediaCMS
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
@@ -13,10 +20,8 @@
require_once(__DIR__ . '/../../config.php');
require_once($CFG->dirroot . '/mod/lti/lib.php');
require_once($CFG->dirroot . '/mod/lti/locallib.php');
require_once($CFG->dirroot . '/course/modlib.php');
require_once(__DIR__ . '/locallib.php');
global $SITE, $DB, $CFG, $USER;
global $SITE, $DB, $CFG, $USER, $SESSION;
require_login();
@@ -32,7 +37,7 @@ if (!$type) {
throw new moodle_exception('ltitoolnotfound', 'filter_mediacms');
}
// Build custom_publishdata: all courses the user is enrolled in + role.
// Build publishdata: all courses the user is enrolled in + role.
$enrolled_courses = enrol_get_users_courses($USER->id, true, ['id', 'shortname', 'fullname']);
$publish_data = [];
@@ -60,8 +65,8 @@ foreach ($enrolled_courses as $enrolled_course) {
$publishdata_b64 = base64_encode(json_encode($publish_data));
// Use a course the user is actually enrolled in so they have mod/lti:view during
// the OIDC flow. Fall back to SITEID only for admins with no course enrolments.
// Use a course the user is actually enrolled in so they pass require_login
// in lti_auth.php. Fall back to SITEID for admins with no course enrolments.
$launch_courseid = SITEID;
$launch_course = $SITE;
foreach ($enrolled_courses as $ec) {
@@ -72,26 +77,10 @@ foreach ($enrolled_courses as $ec) {
}
}
// Get or create the dummy activity (visible, non-stealth).
try {
$dummy_cmid = filter_mediacms_get_dummy_activity($launch_courseid, $type->id);
} catch (Exception $e) {
throw new moodle_exception('cannotcreatedummyactivity', 'filter_mediacms');
}
$cm = get_coursemodule_from_id('lti', $dummy_cmid, 0, false, MUST_EXIST);
$instance = $DB->get_record('lti', ['id' => $cm->instance], '*', MUST_EXIST);
// DEBUG: log enrolled courses retrieved.
error_log('MediaCMS My Media publishdata courses (' . count($publish_data) . '): ' . json_encode($publish_data));
// Write publishdata to DB — Moodle's auth.php re-reads the instance from DB
// when building the LTI launch JWT, so in-memory changes are ignored.
$DB->set_field('lti', 'instructorcustomparameters', 'publishdata=' . $publishdata_b64, ['id' => $cm->instance]);
$instance->instructorcustomparameters = 'publishdata=' . $publishdata_b64;
$instance->name = 'MediaCMS My Media';
// Store publishdata in session — lti_auth.php picks it up after the OIDC roundtrip.
$SESSION->mediacms_launch_customparams = 'publishdata=' . $publishdata_b64;
$typeconfig = lti_get_type_type_config($type->id);
$content = lti_initiate_login($launch_course->id, $dummy_cmid, $instance, $typeconfig, null, $instance->name);
$content = lti_initiate_login($launch_courseid, 0, null, $typeconfig, null, 'MediaCMS My Media');
echo $content;

View File

@@ -0,0 +1,77 @@
<?php
/**
* Student-accessible MediaCMS media picker launcher.
*
* Initiates a no-activity LTI 1.3 OIDC login that routes to MediaCMS's
* /lti/select-media/ UI. No LTI activity is created in the course.
*
* The redirect_path custom param is stored in the PHP session and injected
* by lti_auth.php during the OIDC callback, so MediaCMS routes to the
* media-picker rather than the default My Media page.
*
* Flow:
* 1. TinyMCE plugin opens this URL in an iframe (contentItemUrl).
* 2. We store redirect_path in session and start the OIDC flow.
* 3. lti_auth.php processes the OIDC callback (no manageactivities check).
* 4. MediaCMS receives redirect_path=/lti/select-media/?mode=lms_embed_mode.
* 5. User picks a video; MediaCMS sends postMessage({type:'videoSelected',...})
* which iframeembed.js already handles.
*
* @package filter_mediacms
* @copyright 2026 MediaCMS
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
require_once(__DIR__ . '/../../config.php');
require_once($CFG->dirroot . '/mod/lti/lib.php');
require_once($CFG->dirroot . '/mod/lti/locallib.php');
global $DB, $PAGE, $OUTPUT, $SITE, $USER, $SESSION;
require_login();
$courseid = required_param('courseid', PARAM_INT);
$ltitoolid = get_config('filter_mediacms', 'ltitoolid');
if (empty($ltitoolid)) {
die('MediaCMS LTI tool not configured.');
}
$type = $DB->get_record('lti_types', ['id' => $ltitoolid]);
if (!$type) {
die('LTI tool not found.');
}
// Resolve course — fall back to the user's first enrolled course if needed.
if ($courseid && $courseid != SITEID) {
$course = get_course($courseid);
$context = context_course::instance($courseid);
} else {
$course = $SITE;
$context = context_system::instance();
foreach (enrol_get_users_courses($USER->id, true, ['id']) as $ec) {
if ((int)$ec->id !== SITEID) {
$course = get_course($ec->id);
$context = context_course::instance($ec->id);
break;
}
}
}
require_login($course);
$PAGE->set_url(new moodle_url('/filter/mediacms/select_media_picker.php', ['courseid' => $course->id]));
$PAGE->set_context($context);
$PAGE->set_pagelayout('embedded');
$PAGE->set_title('MediaCMS Select Media');
$typeconfig = lti_get_type_type_config($type->id);
// Store redirect_path in session — lti_auth.php picks it up after the OIDC roundtrip.
$SESSION->mediacms_launch_customparams = 'redirect_path=/lti/select-media/?mode=lms_embed_mode';
$content = lti_initiate_login($course->id, 0, null, $typeconfig, null, 'MediaCMS Select Media');
echo $OUTPUT->header();
echo $content;
echo $OUTPUT->footer();

View File

@@ -161,16 +161,14 @@ class plugininfo extends plugin implements plugin_with_buttons, plugin_with_menu
$courseid = $COURSE->id;
}
// Build the content item URL for LTI Deep Linking.
// This URL initiates the LTI Deep Linking flow which allows users
// to select content (like videos) from the tool provider.
// Build the URL for the student-accessible media picker.
// Uses /filter/mediacms/select_media_picker.php instead of the standard
// /mod/lti/contentitem.php, which requires moodle/course:manageactivities
// and therefore fails for students.
$contentitemurl = '';
if (!empty($ltitoolid) && $courseid > 0) {
$contentitemurl = (new moodle_url('/mod/lti/contentitem.php', [
'id' => $ltitoolid,
'course' => $courseid,
'title' => 'MediaCMS Library',
'return_types' => 1 // LTI_DEEPLINKING_RETURN_TYPE_LTI_LINK
$contentitemurl = (new moodle_url('/filter/mediacms/select_media_picker.php', [
'courseid' => $courseid,
]))->out(false);
}