diff --git a/lms-plugins/mediacms-moodle/filter/mediacms/locallib.php b/lms-plugins/mediacms-moodle/filter/mediacms/locallib.php index 938a6365..6ca727ac 100644 --- a/lms-plugins/mediacms-moodle/filter/mediacms/locallib.php +++ b/lms-plugins/mediacms-moodle/filter/mediacms/locallib.php @@ -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; -} diff --git a/lms-plugins/mediacms-moodle/filter/mediacms/lti_auth.php b/lms-plugins/mediacms-moodle/filter/mediacms/lti_auth.php new file mode 100644 index 00000000..1124cfab --- /dev/null +++ b/lms-plugins/mediacms-moodle/filter/mediacms/lti_auth.php @@ -0,0 +1,190 @@ +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 = '
\n"; +foreach ($params as $key => $value) { + $key = htmlspecialchars($key, ENT_COMPAT); + $value = htmlspecialchars($value, ENT_COMPAT); + $r .= " \n"; +} +$r .= "
\n"; +$r .= "\n"; +echo $r; diff --git a/lms-plugins/mediacms-moodle/filter/mediacms/lti_launch.php b/lms-plugins/mediacms-moodle/filter/mediacms/lti_launch.php index b4ceb227..b2aa10e1 100644 --- a/lms-plugins/mediacms-moodle/filter/mediacms/lti_launch.php +++ b/lms-plugins/mediacms-moodle/filter/mediacms/lti_launch.php @@ -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; diff --git a/lms-plugins/mediacms-moodle/filter/mediacms/select_media_picker.php b/lms-plugins/mediacms-moodle/filter/mediacms/select_media_picker.php new file mode 100644 index 00000000..ee916a11 --- /dev/null +++ b/lms-plugins/mediacms-moodle/filter/mediacms/select_media_picker.php @@ -0,0 +1,77 @@ +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(); diff --git a/lms-plugins/mediacms-moodle/tiny/mediacms/classes/plugininfo.php b/lms-plugins/mediacms-moodle/tiny/mediacms/classes/plugininfo.php index d948f284..91cba2d5 100755 --- a/lms-plugins/mediacms-moodle/tiny/mediacms/classes/plugininfo.php +++ b/lms-plugins/mediacms-moodle/tiny/mediacms/classes/plugininfo.php @@ -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); }