diff --git a/.github/workflows/moodle-ci.yml b/.github/workflows/moodle-ci.yml index 103d321..7923852 100644 --- a/.github/workflows/moodle-ci.yml +++ b/.github/workflows/moodle-ci.yml @@ -11,4 +11,4 @@ jobs: uses: Opencast-Moodle/moodle-workflows-opencast/.github/workflows/moodle-ci.yml@main with: requires-tool-plugin: true - branch-tool-plugin: master \ No newline at end of file + branch-tool-plugin: main diff --git a/classes/local/lti_helper.php b/classes/local/lti_helper.php new file mode 100644 index 0000000..b7ba615 --- /dev/null +++ b/classes/local/lti_helper.php @@ -0,0 +1,191 @@ +. + +/** + * LTI helper class for filter opencast. + * @package filter_opencast + * @copyright 2024 Farbod Zamani Boroujeni, ELAN e.V. + * @author Farbod Zamani Boroujeni + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace filter_opencast\local; + +use oauth_helper; +use tool_opencast\local\settings_api; + +/** + * LTI helper class for filter opencast. + * @package filter_opencast + * @copyright 2024 Farbod Zamani Boroujeni, ELAN e.V. + * @author Farbod Zamani Boroujeni + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class lti_helper { + + /** */ + const LTI_LAUNCH_PATH = '/filter/opencast/ltilaunch.php'; + + /** + * Create necessary lti parameters. + * @param string $consumerkey LTI consumer key. + * @param string $consumersecret LTI consumer secret. + * @param string $endpoint of the opencast instance. + * @param string $customtool the custom tool + * @param int $courseid the course id to add into the parameters + * + * @return array lti parameters + */ + public static function create_lti_parameters($consumerkey, $consumersecret, $endpoint, $customtool, $courseid) { + global $CFG, $USER; + + $course = get_course($courseid); + + require_once($CFG->dirroot . '/mod/lti/locallib.php'); + require_once($CFG->dirroot . '/lib/oauthlib.php'); + + $helper = new oauth_helper(['oauth_consumer_key' => $consumerkey, + 'oauth_consumer_secret' => $consumersecret, ]); + + // Set all necessary parameters. + $params = []; + $params['oauth_version'] = '1.0'; + $params['oauth_nonce'] = $helper->get_nonce(); + $params['oauth_timestamp'] = $helper->get_timestamp(); + $params['oauth_consumer_key'] = $consumerkey; + + $params['context_id'] = $course->id; + $params['context_label'] = trim($course->shortname); + $params['context_title'] = trim($course->fullname); + $params['resource_link_id'] = 'o' . random_int(1000, 9999) . '-' . random_int(1000, 9999); + $params['resource_link_title'] = 'Opencast'; + $params['context_type'] = ($course->format == 'site') ? 'Group' : 'CourseSection'; + $params['launch_presentation_locale'] = current_language(); + $params['ext_lms'] = 'moodle-2'; + $params['tool_consumer_info_product_family_code'] = 'moodle'; + $params['tool_consumer_info_version'] = strval($CFG->version); + $params['oauth_callback'] = 'about:blank'; + $params['lti_version'] = 'LTI-1p0'; + $params['lti_message_type'] = 'basic-lti-launch-request'; + $urlparts = parse_url($CFG->wwwroot); + $params['tool_consumer_instance_guid'] = $urlparts['host']; + $params['custom_tool'] = urlencode($customtool); + + // User data. + $params['user_id'] = $USER->id; + $params['lis_person_name_given'] = $USER->firstname; + $params['lis_person_name_family'] = $USER->lastname; + $params['lis_person_name_full'] = $USER->firstname . ' ' . $USER->lastname; + $params['ext_user_username'] = $USER->username; + $params['lis_person_contact_email_primary'] = $USER->email; + $params['roles'] = lti_get_ims_role($USER, null, $course->id, false); + + if (!empty($CFG->mod_lti_institution_name)) { + $params['tool_consumer_instance_name'] = trim(html_to_text($CFG->mod_lti_institution_name, 0)); + } else { + $params['tool_consumer_instance_name'] = get_site()->shortname; + } + + $params['launch_presentation_document_target'] = 'iframe'; + $params['oauth_signature_method'] = 'HMAC-SHA1'; + $params['oauth_signature'] = $helper->sign("POST", $endpoint, $params, $consumersecret . '&'); + return $params; + } + + /** + * Retrieves the LTI consumer key and consumer secret for a given Opencast instance ID. + * + * @param int $ocinstanceid The ID of the Opencast instance. + * + * @return array An associative array containing the 'consumerkey' and 'consumersecret' for the given Opencast instance. + * If the credentials are not found, an empty array is returned. + */ + public static function get_lti_credentials(int $ocinstanceid) { + $lticonsumerkey = settings_api::get_lticonsumerkey($ocinstanceid); + $lticonsumersecret = settings_api::get_lticonsumersecret($ocinstanceid); + return ['consumerkey' => $lticonsumerkey, 'consumersecret' => $lticonsumersecret]; + } + + /** + * Checks if LTI credentials are configured for a given Opencast instance. + * + * This function verifies whether both the LTI consumer key and consumer secret + * are set for the specified Opencast instance. + * + * @param int $ocinstanceid The ID of the Opencast instance to check. + * + * @return bool Returns true if both LTI consumer key and secret are configured, + * false otherwise. + */ + public static function is_lti_credentials_configured(int $ocinstanceid) { + $lticredentials = self::get_lti_credentials($ocinstanceid); + return !empty($lticredentials['consumerkey']) && !empty($lticredentials['consumersecret']); + } + + /** + * Retrieves an object containing LTI settings for a given Opencast instance. + * + * This function gathers the LTI credentials and API URL for the specified Opencast instance + * and returns them as a structured object. + * + * @param int $ocinstanceid The ID of the Opencast instance for which to retrieve LTI settings. + * + * @return object An object containing the following properties: + * - ocinstanceid: The ID of the Opencast instance. + * - consumerkey: The LTI consumer key for the instance. + * - consumersecret: The LTI consumer secret for the instance. + * - baseurl: The API URL for the Opencast instance. + */ + public static function get_lti_set_object(int $ocinstanceid) { + $lticredentials = self::get_lti_credentials($ocinstanceid); + $baseurl = settings_api::get_apiurl($ocinstanceid); + + return (object) [ + 'ocinstanceid' => $ocinstanceid, + 'consumerkey' => $lticredentials['consumerkey'], + 'consumersecret' => $lticredentials['consumersecret'], + 'baseurl' => $baseurl, + ]; + } + + /** + * Generates the LTI launch URL for the Opencast filter. + * + * This function creates a URL for launching LTI content specific to the Opencast filter, + * incorporating necessary parameters such as course ID, Opencast instance ID, and episode ID. + * + * @param int $ocinstanceid The ID of the Opencast instance. + * @param int $courseid The ID of the course. + * @param string $episodeid The ID of the Opencast episode. + * @param bool $output Optional. If true, returns the URL as a string. If false, returns a moodle_url object. Default is true. + * + * @return string|moodle_url If $output is true, returns the LTI launch URL as a string. + * If $output is false, returns a moodle_url object representing the LTI launch URL. + */ + public static function get_filter_lti_launch_url(int $ocinstanceid, int $courseid, string $episodeid, bool $output = true) { + $params = [ + 'courseid' => $courseid, + 'ocinstanceid' => $ocinstanceid, + 'episodeid' => $episodeid, + 'sesskey' => sesskey(), + ]; + $ltilaunchurl = new \moodle_url(self::LTI_LAUNCH_PATH, $params); + if ($output) { + return $ltilaunchurl->out(false); + } + return $ltilaunchurl; + } +} diff --git a/classes/text_filter.php b/classes/text_filter.php index b7a3cca..08b7c26 100644 --- a/classes/text_filter.php +++ b/classes/text_filter.php @@ -26,7 +26,12 @@ namespace filter_opencast; +use filter_opencast\local\lti_helper; use mod_opencast\local\paella_transform; +use tool_opencast\local\settings_api; +use stdClass; +use moodle_url; + /** * Automatic opencast videos filter class. @@ -57,9 +62,9 @@ private static function get_attribute(string $tag, string $attributename, string * @return array|null [ocinstanceid, episodeid] or null if there are no matches. */ private static function test_url(string $url, array $episodeurls) { - foreach ($episodeurls as [$ocinstanceid, $episoderegex, $baseurl]) { + foreach ($episodeurls as [$ocinstanceid, $episoderegex, $baseurl, $shoulduselti]) { if (preg_match_all($episoderegex, $url, $matches)) { - return [$ocinstanceid, $matches[1][0]]; + return [$ocinstanceid, $matches[1][0], $shoulduselti]; } } return null; @@ -81,7 +86,7 @@ public function filter($text, array $options = []) { // First section: (Relatively) quick check if there are episode urls in the text, and only look for these later. // Improvable by combining all episode urls into one big regex if needed. - $ocinstances = \tool_opencast\local\settings_api::get_ocinstances(); + $ocinstances = settings_api::get_ocinstances(); $occurrences = []; foreach ($ocinstances as $ocinstance) { $episodeurls = get_config('filter_opencast', 'episodeurl_' . $ocinstance->id); @@ -90,6 +95,11 @@ public function filter($text, array $options = []) { continue; } + $uselticonfig = get_config('filter_opencast', 'uselti_' . $ocinstance->id); + // Double check. + $hasconfiguredlti = lti_helper::is_lti_credentials_configured($ocinstance->id); + $shoulduselti = $uselticonfig && $hasconfiguredlti; + foreach (explode("\n", $episodeurls) as $episodeurl) { $episodeurl = trim($episodeurl); @@ -105,7 +115,7 @@ public function filter($text, array $options = []) { if (self::str_contains($text, $baseurl)) { $episoderegex = "/" . preg_quote($episodeurl, "/") . "/"; $episoderegex = preg_replace('/\\\\\[EPISODEID\\\]/', '([0-9a-zA-Z\-]+)', $episoderegex); - $occurrences[] = [$ocinstance->id, $episoderegex, $baseurl]; + $occurrences[] = [$ocinstance->id, $episoderegex, $baseurl, $shoulduselti]; } } } @@ -139,7 +149,7 @@ public function filter($text, array $options = []) { if (self::str_starts_with($match, "render_player($episode[0], $episode[1], $i++, $width, $height); + $replacement = $this->render_player($episode[0], $episode[1], $episode[2], $i++, $width, $height); } if ($replacement) { $newtext .= $replacement; @@ -192,14 +202,15 @@ public function filter($text, array $options = []) { * Render HTML for embedding video player. * @param int $ocinstanceid Id of ocinstance. * @param string $episodeid Id opencast episode. + * @param bool $shoulduselti Flag to determine whether to use LTI launch for this video or not. * @param int $playerid Unique id to assign to player element. * @param int|null $width Optionally width for player. * @param int|null $height Optionally height for player. * @return string|null */ - protected function render_player(int $ocinstanceid, string $episodeid, int $playerid, - $width = null, $height = null) { - global $OUTPUT, $PAGE; + protected function render_player(int $ocinstanceid, string $episodeid, bool $shoulduselti, + int $playerid, $width = null, $height = null) { + global $OUTPUT, $PAGE, $COURSE; $data = paella_transform::get_paella_data_json($ocinstanceid, $episodeid); @@ -218,7 +229,11 @@ protected function render_player(int $ocinstanceid, string $episodeid, int $play $mustachedata->data = json_encode($data); $mustachedata->width = $width; $mustachedata->height = $height; - $mustachedata->modplayerpath = (new moodle_url('/mod/opencast/player.html'))->out(false); + if ($shoulduselti) { + $mustachedata->ltiplayerpath = lti_helper::get_filter_lti_launch_url($ocinstanceid, $COURSE->id, $episodeid); + } else { + $mustachedata->modplayerpath = (new moodle_url('/mod/opencast/player.html'))->out(false); + } if (isset($data['streams'])) { if (count($data['streams']) === 1) { diff --git a/lang/en/filter_opencast.php b/lang/en/filter_opencast.php index bfb6591..373f916 100644 --- a/lang/en/filter_opencast.php +++ b/lang/en/filter_opencast.php @@ -24,9 +24,14 @@ defined('MOODLE_INTERNAL') || die(); $string['filtername'] = 'Opencast'; +$string['ltilaunch_failed'] = 'Performing LTI authentication failed, please try again!'; $string['pluginname'] = 'Opencast Filter'; $string['privacy:metadata'] = 'The Opencast filter plugin does not store any personal data.'; $string['setting_configurl'] = 'URL to Paella config.json'; $string['setting_configurl_desc'] = 'URL of the config.json used by Paella Player. Can either be a absolute URL or a URL relative to the wwwroot.'; $string['setting_episodeurl'] = 'URL templates for filtering'; $string['setting_episodeurl_desc'] = 'URLs matching this template are replaced with the Opencast player. You must use the placeholder [EPISODEID] to indicate where the episode ID is contained in the URL e.g. http://stable.opencast.de/play/[EPISODEID]. If you want to filter for multiple URLs, enter each URL in a new line.'; +$string['setting_uselti'] = 'Enable LTI authentication'; +$string['setting_uselti_desc'] = 'When enabled, Opencast videos are delivered through LTI authentication using the default Opencast video player. This is typically used alongside Secure Static Files in Opencast for enhanced security.'; +$string['setting_uselti_nolti_desc'] = 'To enable LTI Authentication for Opencast, you must configure the required credentials (Consumer Key and Consumer Secret) for this instance. Please do so via this link: {$a}'; +$string['setting_uselti_ocinstance_name'] = 'Opencast API {$a} Instance'; diff --git a/ltilaunch.php b/ltilaunch.php new file mode 100644 index 0000000..534fd88 --- /dev/null +++ b/ltilaunch.php @@ -0,0 +1,81 @@ +. + +/** + * LTI Launch page. + * Designed to be called as a link in an iframe to prepare the lti launch data and perform the launch. + * + * @package filter_opencast + * @copyright 2024 Farbod Zamani Boroujeni, ELAN e.V. + * @author Farbod Zamani Boroujeni + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +use filter_opencast\local\lti_helper; + +require(__DIR__ . '/../../config.php'); + +global $PAGE; + +$courseid = required_param('courseid', PARAM_INT); +$ocinstanceid = required_param('ocinstanceid', PARAM_INT); +$episodeid = required_param('episodeid', PARAM_ALPHANUMEXT); + +$baseurl = lti_helper::get_filter_lti_launch_url($ocinstanceid, $courseid, $episodeid, false); + +$PAGE->set_pagelayout('embedded'); +$PAGE->set_url($baseurl); + +require_login($courseid, false); + +if (confirm_sesskey()) { + $ltisetobject = lti_helper::get_lti_set_object($ocinstanceid); + $customtool = "/play/{$episodeid}"; + $endpoint = rtrim($ltisetobject->baseurl, '/') . '/lti'; + $ltiparams = lti_helper::create_lti_parameters( + $ltisetobject->consumerkey, + $ltisetobject->consumersecret, + $endpoint, + $customtool, + $courseid + ); + $formid = "ltiLaunchForm-{$episodeid}"; + $formattributed = [ + 'action' => $endpoint, + 'method' => 'post', + 'id' => $formid, + 'name' => $formid, + 'encType' => 'application/x-www-form-urlencoded', + ]; + echo html_writer::start_tag('form', $formattributed); + + foreach ($ltiparams as $name => $value) { + $attributes = ['type' => 'hidden', 'name' => htmlspecialchars($name), 'value' => htmlspecialchars($value)]; + echo html_writer::empty_tag('input', $attributes) . "\n"; + } + + echo html_writer::end_tag('form'); + + echo html_writer::script( + "window.onload = function() { + document.getElementById('{$formid}').submit(); + };" + ); + + exit(); +} + +throw new \moodle_exception('ltilaunch_failed', 'filter_opencast', $baseurl); diff --git a/settings.php b/settings.php index 2a703b4..a744251 100644 --- a/settings.php +++ b/settings.php @@ -21,6 +21,9 @@ * @copyright 2018 Tamara Gunkel * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ + +use filter_opencast\local\lti_helper; + defined('MOODLE_INTERNAL') || die; if ($ADMIN->fulltree) { @@ -34,5 +37,29 @@ $settings->add(new admin_setting_configtext('filter_opencast/configurl_' . $instance->id, new lang_string('setting_configurl', 'filter_opencast'), new lang_string('setting_configurl_desc', 'filter_opencast'), '/filter/opencast/config.json')); + + + $hasconfiguredlti = lti_helper::is_lti_credentials_configured($instance->id); + // Providing use lti option, when when the consumer key and secret are configured in tool_opencast. + if ($hasconfiguredlti) { + $settings->add(new admin_setting_configcheckbox('filter_opencast/uselti_' . $instance->id, + new lang_string('setting_uselti', 'filter_opencast'), + new lang_string('setting_uselti_desc', 'filter_opencast'), 0)); + } else { + // Otherwise, we will inform the admin about this setting with extra info to configure this if needed. + $path = '/admin/settings.php?section=tool_opencast_configuration'; + if (count($ocinstances) > 1) { + $path .= '_' . $instance->id; + } + $toolopencasturl = new moodle_url($path); + $ocinstancename = $instance->name ?? $instance->id; + $link = html_writer::link($toolopencasturl, + get_string('setting_uselti_ocinstance_name', 'filter_opencast', $ocinstancename), ['target' => '_blank']); + $description = get_string('setting_uselti_nolti_desc', 'filter_opencast', $link); + $settings->add( + new admin_setting_configempty('block_opencast/uselti_' . $instance->id, + get_string('setting_uselti', 'filter_opencast'), + $description)); + } } } diff --git a/styles.css b/styles.css index db5f0a8..f5cb637 100644 --- a/styles.css +++ b/styles.css @@ -13,11 +13,12 @@ } } -.filter-opencast .mod-opencast-paella-player { +.filter-opencast .mod-opencast-paella-player, +.filter-opencast .filter-lti-player { position: absolute; top: 0; left: 0; width: 100%; height: 100%; border: none; -} \ No newline at end of file +} diff --git a/templates/player.mustache b/templates/player.mustache index 5ff60be..1c6a1eb 100644 --- a/templates/player.mustache +++ b/templates/player.mustache @@ -28,6 +28,7 @@ "playerid": "ocplayer_0", "configurl": "/filter/opencast/config.json", "modplayerpath": "/mod/opencast/player.html", + "ltiplayerpath": "/filter/opencast/ltilaunch.php", "data": "{\"metadata\":{\"title\": \"Test video\", \"etc\": \"...\"}" } }} @@ -38,9 +39,15 @@ {{^resolution}}
{{/resolution}} +{{#modplayerpath}} +{{/modplayerpath}} +{{#ltiplayerpath}} + +{{/ltiplayerpath}}
+{{#modplayerpath}} \ No newline at end of file + +{{/modplayerpath}} diff --git a/tests/replacement_test.php b/tests/replacement_test.php index 18a7d87..638001a 100644 --- a/tests/replacement_test.php +++ b/tests/replacement_test.php @@ -43,6 +43,7 @@ public function setUp(): void { $this->resetAfterTest(); set_config('episodeurl_1', "http://localhost:8080/play/[EPISODEID]\nhttps://stable.opencast.de/play/[EPISODEID]", 'filter_opencast'); + set_config('uselti_1', 0, 'filter_opencast'); } /** diff --git a/tests/testable_filter.php b/tests/testable_filter.php index ed017df..f81a9ab 100644 --- a/tests/testable_filter.php +++ b/tests/testable_filter.php @@ -42,12 +42,13 @@ class testable_filter extends \filter_opencast { * Render a simple * @param int $ocinstanceid Id of ocinstance. * @param string $episodeid Id opencast episode. + * @param bool $shoulduselti Flag to determine whether to use LTI launch for this video or not. * @param int $playerid Unique id to assign to player element. * @param int|null $width Optionally width for player. * @param int|null $height Optionally height for player. * @return string */ - protected function render_player(int $ocinstanceid, string $episodeid, int $playerid, $width = null, + protected function render_player(int $ocinstanceid, string $episodeid, bool $shoulduselti, int $playerid, $width = null, $height = null): string { return ''; }