Merge branch 'MDL-65959-master' of git://github.com/peterRd/moodle
authorSara Arjona <sara@moodle.com>
Mon, 26 Oct 2020 21:39:28 +0000 (22:39 +0100)
committerSara Arjona <sara@moodle.com>
Mon, 26 Oct 2020 21:39:28 +0000 (22:39 +0100)
52 files changed:
admin/settings/courses.php
backup/moodle2/backup_activity_task.class.php
backup/moodle2/restore_activity_task.class.php
backup/util/ui/base_moodleform.class.php
course/amd/build/downloadcontent.min.js [new file with mode: 0644]
course/amd/build/downloadcontent.min.js.map [new file with mode: 0644]
course/amd/src/downloadcontent.js [new file with mode: 0644]
course/classes/output/content_export_link.php [new file with mode: 0644]
course/downloadcontent.php [new file with mode: 0644]
course/edit_form.php
course/lib.php
course/tests/behat/course_download_content.feature [new file with mode: 0644]
course/tests/behat/course_download_content_permissions.feature [new file with mode: 0644]
course/view.php
lang/en/admin.php
lang/en/course.php
lang/en/moodle.php
lang/en/role.php
lib/classes/content.php
lib/db/access.php
lib/db/install.xml
lib/db/upgrade.php
lib/navigationlib.php
lib/pagelib.php
lib/tests/content_test.php [new file with mode: 0644]
mod/lti/amd/build/tool_card_controller.min.js
mod/lti/amd/build/tool_card_controller.min.js.map
mod/lti/amd/build/tool_configure_controller.min.js
mod/lti/amd/build/tool_configure_controller.min.js.map
mod/lti/amd/src/tool_card_controller.js
mod/lti/amd/src/tool_configure_controller.js
mod/lti/certs.php
mod/lti/classes/local/ltiopenid/jwks_helper.php [new file with mode: 0644]
mod/lti/classes/local/ltiopenid/registration_exception.php [new file with mode: 0644]
mod/lti/classes/local/ltiopenid/registration_helper.php [new file with mode: 0644]
mod/lti/classes/local/ltiservice/service_base.php
mod/lti/lang/en/lti.php
mod/lti/locallib.php
mod/lti/openid-configuration.php [new file with mode: 0644]
mod/lti/openid-registration.php [new file with mode: 0644]
mod/lti/service/basicoutcomes/classes/local/service/basicoutcomes.php
mod/lti/service/gradebookservices/classes/local/service/gradebookservices.php
mod/lti/service/memberships/classes/local/service/memberships.php
mod/lti/service/toolsettings/classes/local/service/toolsettings.php
mod/lti/startltiadvregistration.php [new file with mode: 0644]
mod/lti/templates/cartridge_registration_form.mustache
mod/lti/templates/external_registration.mustache
mod/lti/templates/loader.mustache
mod/lti/templates/tool_configure.mustache
mod/lti/tests/behat/toolconfigure.feature
mod/lti/tests/openidregistration_test.php [new file with mode: 0644]
version.php

index 6b53eb0..c0da1aa 100644 (file)
@@ -22,6 +22,8 @@
  * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+use core_admin\local\settings\filesize;
+
 $capabilities = array(
     'moodle/backup:backupcourse',
     'moodle/category:manage',
@@ -75,6 +77,17 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) {
     $temp->add(new admin_setting_configselect('moodlecourse/visible', new lang_string('visible'), new lang_string('visible_help'),
         1, $choices));
 
+    // Enable/disable download course content.
+    $choices = [
+        DOWNLOAD_COURSE_CONTENT_DISABLED => new lang_string('no'),
+        DOWNLOAD_COURSE_CONTENT_ENABLED => new lang_string('yes'),
+    ];
+    $downloadcontentsitedefault = new admin_setting_configselect('moodlecourse/downloadcontentsitedefault',
+            new lang_string('enabledownloadcoursecontent', 'course'),
+            new lang_string('downloadcoursecontent_help', 'course'), 0, $choices);
+    $downloadcontentsitedefault->add_dependent_on('downloadcoursecontentallowed');
+    $temp->add($downloadcontentsitedefault);
+
     // Course format.
     $temp->add(new admin_setting_heading('courseformathdr', new lang_string('type_format', 'plugin'), ''));
 
@@ -161,6 +174,21 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) {
 
     $ADMIN->add('courses', $temp);
 
+    // Download course content.
+    $downloadcoursedefaulturl = new moodle_url('/admin/settings.php', ['section' => 'coursesettings']);
+    $temp = new admin_settingpage('downloadcoursecontent', new lang_string('downloadcoursecontent', 'course'));
+    $temp->add(new admin_setting_configcheckbox('downloadcoursecontentallowed',
+            new lang_string('downloadcoursecontentallowed', 'admin'),
+            new lang_string('downloadcoursecontentallowed_desc', 'admin', $downloadcoursedefaulturl->out()), 0));
+
+    // 50MB default maximum size per file when downloading course content.
+    $defaultmaxdownloadsize = 50 * filesize::UNIT_MB;
+    $temp->add(new filesize('maxsizeperdownloadcoursefile', new lang_string('maxsizeperdownloadcoursefile', 'admin'),
+            new lang_string('maxsizeperdownloadcoursefile_desc', 'admin'), $defaultmaxdownloadsize, filesize::UNIT_MB));
+    $temp->hide_if('maxsizeperdownloadcoursefile', 'downloadcoursecontentallowed');
+
+    $ADMIN->add('courses', $temp);
+
     // "courserequests" settingpage.
     $temp = new admin_settingpage('courserequest', new lang_string('courserequest'));
     $temp->add(new admin_setting_configcheckbox('enablecourserequests',
index b1da9bb..5567d91 100644 (file)
@@ -272,7 +272,7 @@ abstract class backup_activity_task extends backup_task {
         $settingname = $settingprefix . 'included';
         $activity_included = new backup_activity_generic_setting($settingname, base_setting::IS_BOOLEAN, true);
         $activity_included->get_ui()->set_icon(new image_icon('icon', get_string('pluginname', $this->modulename),
-            $this->modulename, array('class' => 'iconlarge icon-post')));
+            $this->modulename, array('class' => 'iconlarge icon-post ml-1')));
         $this->add_setting($activity_included);
         // Look for "activities" root setting
         $activities = $this->plan->get_setting('activities');
index 6d5e6a5..9e0cd35 100644 (file)
@@ -297,7 +297,7 @@ abstract class restore_activity_task extends restore_task {
         $settingname = $settingprefix . 'included';
         $activity_included = new restore_activity_generic_setting($settingname, base_setting::IS_BOOLEAN, true);
         $activity_included->get_ui()->set_icon(new image_icon('icon', get_string('pluginname', $this->modulename),
-            $this->modulename, array('class' => 'iconlarge icon-post')));
+            $this->modulename, array('class' => 'iconlarge icon-post ml-1')));
         $this->add_setting($activity_included);
         // Look for "activities" root setting
         $activities = $this->plan->get_setting('activities');
index b37aff2..3915516 100644 (file)
@@ -317,7 +317,7 @@ abstract class base_moodleform extends moodleform {
             $label = format_string($settingui->get_label($task), true, array('context' => $context));
             $labelicon = $settingui->get_icon();
             if (!empty($labelicon)) {
-                $label .= '&nbsp;'.$OUTPUT->render($labelicon);
+                $label .= $OUTPUT->render($labelicon);
             }
             $this->_form->addElement('static', 'static_'.$settingui->get_name(), $label, $settingui->get_static_value().$icon);
             $this->_form->addElement('html', html_writer::end_tag('div'));
diff --git a/course/amd/build/downloadcontent.min.js b/course/amd/build/downloadcontent.min.js
new file mode 100644 (file)
index 0000000..8e99320
Binary files /dev/null and b/course/amd/build/downloadcontent.min.js differ
diff --git a/course/amd/build/downloadcontent.min.js.map b/course/amd/build/downloadcontent.min.js.map
new file mode 100644 (file)
index 0000000..29cc63d
Binary files /dev/null and b/course/amd/build/downloadcontent.min.js.map differ
diff --git a/course/amd/src/downloadcontent.js b/course/amd/src/downloadcontent.js
new file mode 100644 (file)
index 0000000..67a6d83
--- /dev/null
@@ -0,0 +1,125 @@
+// 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/>.
+
+/**
+ * Functions related to downloading course content.
+ *
+ * @module     core_course/downloadcontent
+ * @package    core_course
+ * @copyright  2020 Michael Hawkins <michaelh@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+import Config from 'core/config';
+import CustomEvents from 'core/custom_interaction_events';
+import * as ModalFactory from 'core/modal_factory';
+import jQuery from 'jquery';
+import Pending from 'core/pending';
+
+/**
+ * Set up listener to trigger the download course content modal.
+ *
+ * @return {void}
+ */
+export const init = () => {
+    const pendingPromise = new Pending();
+
+    document.addEventListener('click', (e) => {
+        const downloadModalTrigger = e.target.closest('[data-downloadcourse]');
+
+        if (downloadModalTrigger) {
+            e.preventDefault();
+            displayDownloadConfirmation(downloadModalTrigger);
+        }
+    });
+
+    pendingPromise.resolve();
+};
+
+/**
+ * Display the download course content modal.
+ *
+ * @method displayDownloadConfirmation
+ * @param {Object} downloadModalTrigger The DOM element that triggered the download modal.
+ * @return {void}
+ */
+const displayDownloadConfirmation = (downloadModalTrigger) => {
+    ModalFactory.create({
+        title: downloadModalTrigger.dataset.downloadTitle,
+        type: ModalFactory.types.SAVE_CANCEL,
+        body: `<p>${downloadModalTrigger.dataset.downloadBody}</p>`,
+        buttons: {
+            save: downloadModalTrigger.dataset.downloadButtonText
+        },
+        templateContext: {
+            classes: 'downloadcoursecontentmodal'
+        }
+    })
+    .then(modal => {
+        // Display the modal.
+        modal.show();
+
+        const saveButton = document.querySelector('.modal .downloadcoursecontentmodal [data-action="save"]');
+        const cancelButton = document.querySelector('.modal .downloadcoursecontentmodal [data-action="cancel"]');
+        const modalContainer = document.querySelector('.modal[data-region="modal-container"]');
+
+        // Create listener to trigger the download when the "Download" button is pressed.
+        jQuery(saveButton).on(CustomEvents.events.activate, (e) => downloadContent(e, downloadModalTrigger, modal));
+
+        // Create listener to destroy the modal when closing modal by cancelling.
+        jQuery(cancelButton).on(CustomEvents.events.activate, () => {
+            modal.destroy();
+        });
+
+        // Create listener to destroy the modal when closing modal by clicking outside of it.
+        if (modalContainer.querySelector('.downloadcoursecontentmodal')) {
+            jQuery(modalContainer).on(CustomEvents.events.activate, () => {
+                modal.destroy();
+            });
+        }
+    });
+};
+
+/**
+ * Trigger downloading of course content.
+ *
+ * @method downloadContent
+ * @param {Event} e The event triggering the download.
+ * @param {Object} downloadModalTrigger The DOM element that triggered the download modal.
+ * @param {Object} modal The modal object.
+ * @return {void}
+ */
+const downloadContent = (e, downloadModalTrigger, modal) => {
+    e.preventDefault();
+
+    // Create a form to submit the file download request, so we can avoid sending sesskey over GET.
+    const downloadForm = document.createElement('form');
+    downloadForm.action = downloadModalTrigger.dataset.downloadLink;
+    downloadForm.method = 'POST';
+    // Open download in a new tab, so current course view is not disrupted.
+    downloadForm.target = '_blank';
+    const downloadSesskey = document.createElement('input');
+    downloadSesskey.name = 'sesskey';
+    downloadSesskey.value = Config.sesskey;
+    downloadForm.appendChild(downloadSesskey);
+    downloadForm.style.display = 'none';
+
+    document.body.appendChild(downloadForm);
+    downloadForm.submit();
+    document.body.removeChild(downloadForm);
+
+    // Destroy the modal to prevent duplicates if reopened later.
+    modal.destroy();
+};
diff --git a/course/classes/output/content_export_link.php b/course/classes/output/content_export_link.php
new file mode 100644 (file)
index 0000000..6b3deea
--- /dev/null
@@ -0,0 +1,60 @@
+<?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/>.
+
+/**
+ * Prepares content for buttons/links to course content export/download.
+ *
+ * @package   core_course
+ * @copyright 2020 Michael Hawkins <michaelh@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_course\output;
+
+/**
+ * Prepares content for buttons/links to course content export/download.
+ *
+ * @package   core_course
+ * @copyright 2020 Michael Hawkins <michaelh@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class content_export_link {
+
+    /**
+     * Prepare and return the various attributes required for a link/button to populate/trigger the download course content modal.
+     *
+     * @param \context $context The context of the content being exported.
+     * @return stdClass
+     */
+    public static function get_attributes(\context $context): \stdClass {
+        global $CFG;
+        $downloadattr = new \stdClass();
+        $downloadattr->url = new \moodle_url('/course/downloadcontent.php', ['contextid' => $context->id]);
+        $downloadattr->displaystring = get_string('downloadcoursecontent', 'course');
+        $maxfilesize = display_size($CFG->maxsizeperdownloadcoursefile);
+        $downloadlink = new \moodle_url('/course/downloadcontent.php', ['contextid' => $context->id, 'download' => 1]);
+
+        $downloadattr->elementattributes = [
+            'data-downloadcourse' => 1,
+            'data-download-body' => get_string('downloadcourseconfirmation', 'course', $maxfilesize),
+            'data-download-button-text' => get_string('download'),
+            'data-download-link' => $downloadlink->out(false),
+            'data-download-title' => get_string('downloadcoursecontent', 'course'),
+        ];
+
+        return $downloadattr;
+    }
+}
diff --git a/course/downloadcontent.php b/course/downloadcontent.php
new file mode 100644 (file)
index 0000000..7813a0f
--- /dev/null
@@ -0,0 +1,94 @@
+<?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/>.
+
+/**
+ * Download course content confirmation and execution.
+ *
+ * @package    core
+ * @subpackage course
+ * @copyright  2020 Michael Hawkins <michaelh@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require_once('../config.php');
+
+use core\content;
+use core\content\export\zipwriter;
+
+$contextid = required_param('contextid', PARAM_INT);
+$isdownload = optional_param('download', 0, PARAM_BOOL);
+$coursecontext = context::instance_by_id($contextid);
+$courseid = $coursecontext->instanceid;
+$courselink = new moodle_url('/course/view.php', ['id' => $courseid]);
+
+if (!\core\content::can_export_context($coursecontext, $USER)) {
+    redirect($courselink);
+}
+
+$PAGE->set_url('/course/downloadcontent.php', ['contextid' => $contextid]);
+require_login($courseid);
+
+$courseinfo = get_fast_modinfo($courseid)->get_course();
+$filename = str_replace('/', '', str_replace(' ', '_', $courseinfo->shortname)) . '_' . time() . '.zip';
+
+// If download confirmed, prepare and start the zipstream of the course download content.
+if ($isdownload) {
+    confirm_sesskey();
+
+    $exportoptions = null;
+
+    if (!empty($CFG->maxsizeperdownloadcoursefile)) {
+        $exportoptions = new stdClass();
+        $exportoptions->maxfilesize = $CFG->maxsizeperdownloadcoursefile;
+    }
+
+    // Use file writer in debug developer mode, so any errors can be displayed instead of being streamed into the output file.
+    if (debugging('', DEBUG_DEVELOPER)) {
+        $writer = zipwriter::get_file_writer($filename, $exportoptions);
+
+        ob_start();
+        content::export_context($coursecontext, $USER, $writer);
+        $content = ob_get_clean();
+
+        // If no errors found, output the file.
+        if (empty($content)) {
+            send_file($writer->get_file_path(), $filename);
+            redirect($courselink);
+        } else {
+            // If any errors occurred, display them instead of outputting the file.
+            debugging("Errors found while producing the download course content output:\n {$content}", DEBUG_DEVELOPER);
+        }
+    } else {
+        // If not developer debugging, stream the output file directly.
+        $writer = zipwriter::get_stream_writer($filename, $exportoptions);
+        content::export_context($coursecontext, $USER, $writer);
+
+        redirect($courselink);
+    }
+
+} else {
+    $PAGE->set_title(get_string('downloadcoursecontent', 'course'));
+    $PAGE->set_heading(format_string($courseinfo->fullname));
+
+    echo $OUTPUT->header();
+    echo $OUTPUT->heading(get_string('downloadcoursecontent', 'course'));
+
+    // Prepare download confirmation information and display it.
+    $maxfilesize = display_size($CFG->maxsizeperdownloadcoursefile);
+    $downloadlink = new moodle_url('/course/downloadcontent.php', ['contextid' => $contextid, 'download' => 1]);
+
+    echo $OUTPUT->confirm(get_string('downloadcourseconfirmation', 'course', $maxfilesize), $downloadlink, $courselink);
+}
index 8a289cc..9c67474 100644 (file)
@@ -120,6 +120,27 @@ class course_edit_form extends moodleform {
                 $mform->setConstant('visible', $courseconfig->visible);
             }
         }
+
+        // Download course content.
+        if ($CFG->downloadcoursecontentallowed) {
+            $downloadchoices = [
+                DOWNLOAD_COURSE_CONTENT_DISABLED => get_string('no'),
+                DOWNLOAD_COURSE_CONTENT_ENABLED => get_string('yes'),
+            ];
+            $sitedefaultstring = $downloadchoices[$courseconfig->downloadcontentsitedefault];
+            $downloadchoices[DOWNLOAD_COURSE_CONTENT_SITE_DEFAULT] = get_string('sitedefaultspecified', '', $sitedefaultstring);
+            $downloadselectdefault = $courseconfig->downloadcontent ?? DOWNLOAD_COURSE_CONTENT_SITE_DEFAULT;
+
+            $mform->addElement('select', 'downloadcontent', get_string('enabledownloadcoursecontent', 'course'), $downloadchoices);
+            $mform->addHelpButton('downloadcontent', 'downloadcoursecontent', 'course');
+            $mform->setDefault('downloadcontent', $downloadselectdefault);
+
+            if (!has_capability('moodle/course:configuredownloadcontent', $coursecontext)) {
+                $mform->hardFreeze('downloadcontent');
+                $mform->setConstant('downloadcontent', $downloadselectdefault);
+            }
+        }
+
         $mform->addElement('date_time_selector', 'startdate', get_string('startdate'));
         $mform->addHelpButton('startdate', 'startdate');
         $date = (new DateTime())->setTimestamp(usergetmidnight(time()));
index a834ba7..e34df5a 100644 (file)
@@ -71,6 +71,11 @@ define('COURSE_CUSTOMFIELD_EMPTY', -1);
 // Course activity chooser footer default display option.
 define('COURSE_CHOOSER_FOOTER_NONE', 'hidden');
 
+// Download course content options.
+define('DOWNLOAD_COURSE_CONTENT_DISABLED', 0);
+define('DOWNLOAD_COURSE_CONTENT_ENABLED', 1);
+define('DOWNLOAD_COURSE_CONTENT_SITE_DEFAULT', 2);
+
 function make_log_url($module, $url) {
     switch ($module) {
         case 'course':
diff --git a/course/tests/behat/course_download_content.feature b/course/tests/behat/course_download_content.feature
new file mode 100644 (file)
index 0000000..bd745f8
--- /dev/null
@@ -0,0 +1,47 @@
+@core @core_course
+Feature: Course content can be downloaded
+  In order to retain a backup offline copy of course activity/resource data
+  As a user
+  I can download a course's content
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | teacher1 | Teacher   | 1        | teacher1@example.com |
+      | student1 | Student   | 1        | student1@example.com |
+    And the following "courses" exist:
+      | fullname   | shortname |
+      | Hockey 101 | C1        |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | teacher1 | C1     | editingteacher |
+      | student1 | C1     | student        |
+    And I log in as "admin"
+    And I navigate to "Courses > Courses > Download course content" in site administration
+    And I set the following fields to these values:
+    | Download course content feature available | 1 |
+    And I press "Save changes"
+    And I navigate to "Courses > Courses > Course default settings" in site administration
+    And I set the field "Enable download course content" to "Yes"
+    And I press "Save changes"
+    And I log out
+
+  @javascript
+  Scenario: A student can download course content when the feature is enabled in their course
+    Given I log in as "student1"
+    When I am on "Hockey 101" course homepage
+    And "Download course content" "button" should exist
+    And I press "Download course content"
+    Then I should see "You are about to download a zip file"
+    # Without the ability to check the downloaded file, the absence of an exception being thrown here is considered a success.
+    And I click on "Download" "button" in the "Download course content" "dialogue"
+
+  @javascript
+  Scenario: A teacher can download course content when the feature is enabled in their course
+    Given I log in as "teacher1"
+    When I am on "Hockey 101" course homepage
+    And "Download course content" "link" should exist in current page administration
+    And I navigate to "Download course content" in current page administration
+    Then I should see "You are about to download a zip file"
+    # Without the ability to check the downloaded file, the absence of an exception being thrown here is considered a success.
+    And I click on "Download" "button" in the "Download course content" "dialogue"
diff --git a/course/tests/behat/course_download_content_permissions.feature b/course/tests/behat/course_download_content_permissions.feature
new file mode 100644 (file)
index 0000000..19fa331
--- /dev/null
@@ -0,0 +1,112 @@
+@core @core_course
+Feature: Access to downloading course content can be controlled
+  In order to allow or restrict access to download course content
+  As a trusted user
+  I can control access to the download course content feature
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | teacher1 | Teacher   | 1        | teacher1@example.com |
+      | student1 | Student   | 1        | student1@example.com |
+    And the following "courses" exist:
+      | fullname   | shortname |
+      | Hockey 101 | C1        |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | teacher1 | C1     | editingteacher |
+      | student1 | C1     | student        |
+    And I log in as "admin"
+    And the following config values are set as admin:
+    | downloadcoursecontentallowed | 1 |
+    And I log out
+
+  Scenario: Site admins can remove the download course content feature
+    Given I log in as "admin"
+    And I am on "Hockey 101" course homepage
+    And I navigate to "Edit settings" in current page administration
+    And I set the field "Enable download course content" to "Yes"
+    And I press "Save and display"
+    And "Download course content" "link" should exist in current page administration
+    When the following config values are set as admin:
+      | downloadcoursecontentallowed | 0 |
+    And I am on "Hockey 101" course homepage
+    Then "Download course content" "link" should not exist in current page administration
+    And I navigate to "Edit settings" in current page administration
+    And I should not see "Enable download course content"
+
+  Scenario: Site admins can set the default value for whether download course content is enabled in courses
+    Given I log in as "admin"
+    And I am on "Hockey 101" course homepage
+    And "Download course content" "link" should not exist in current page administration
+    When I navigate to "Courses > Courses > Course default settings" in site administration
+    And I set the field "Enable download course content" to "Yes"
+    And I press "Save changes"
+    And I am on "Hockey 101" course homepage
+    Then "Download course content" "link" should exist in current page administration
+
+  Scenario: A teacher can enable and disable the download course content feature when it is available
+    Given I log in as "teacher1"
+    When I am on "Hockey 101" course homepage
+    And "Download course content" "link" should not exist in current page administration
+    And I navigate to "Edit settings" in current page administration
+    And I should see "Enable download course content"
+    And I set the field "Enable download course content" to "Yes"
+    And I press "Save and display"
+    Then "Download course content" "link" should exist in current page administration
+    And I navigate to "Edit settings" in current page administration
+    And I set the field "Enable download course content" to "No"
+    And I press "Save and display"
+    And "Download course content" "link" should not exist in current page administration
+
+  Scenario: Teachers require a capability to access the download course content feature or modify its availability in a course
+    Given I log in as "admin"
+    And I navigate to "Courses > Courses > Course default settings" in site administration
+    And I set the field "Enable download course content" to "Yes"
+    And I press "Save changes"
+    And I log out
+    # Check teacher can see download option and enable dropdown.
+    And I log in as "teacher1"
+    And I am on "Hockey 101" course homepage
+    And "Download course content" "link" should exist in current page administration
+    And I navigate to "Edit settings" in current page administration
+    And "Enable download course content" "select" should exist
+    And I log out
+    # Remove teacher's capabilities for download course content.
+    And I log in as "admin"
+    And I set the following system permissions of "Teacher" role:
+      | capability                             | permission |
+      | moodle/course:downloadcoursecontent    | Prohibit   |
+      | moodle/course:configuredownloadcontent | Prohibit   |
+    And I log out
+    # Check teacher can no longer see download option, and that enable value is visible, but dropdown no longer available.
+    When I log in as "teacher1"
+    And I am on "Hockey 101" course homepage
+    Then "Download course content" "link" should not exist in current page administration
+    And I navigate to "Edit settings" in current page administration
+    And I should see "Enable download course content"
+    And I should see "Site default (Yes)"
+    And "Enable download course content" "select" should not exist
+
+  Scenario: Students require a capability to access the download course content feature in a course
+    Given I log in as "teacher1"
+    And I am on "Hockey 101" course homepage
+    And I navigate to "Edit settings" in current page administration
+    And I set the field "Enable download course content" to "Yes"
+    And I press "Save and display"
+    And I log out
+    # Check student can see download button.
+    And I log in as "student1"
+    And I am on "Hockey 101" course homepage
+    And "Download course content" "button" should exist
+    And I log out
+    And I log in as "admin"
+    # Remove student's capability for download course content.
+    When I set the following system permissions of "Student" role:
+      | capability                             | permission |
+      | moodle/course:downloadcoursecontent    | Prohibit   |
+    And I log out
+    # Check student can no longer see download button.
+    And I log in as "student1"
+    And I am on "Hockey 101" course homepage
+    Then "Download course content" "link" should not exist in current page administration
index 611ed6c..80b7026 100644 (file)
         $PAGE->requires->js_init_call('M.core_completion.init');
     }
 
+    // Determine whether the user has permission to download course content.
+    $candownloadcourse = \core\content::can_export_context($context, $USER);
+
     // We are currently keeping the button here from 1.x to help new teachers figure out
     // what to do, even though the link also appears in the course admin block.  It also
     // means you can back out of a situation where you removed the admin block. :)
     if ($PAGE->user_allowed_editing()) {
         $buttons = $OUTPUT->edit_button($PAGE->url);
         $PAGE->set_button($buttons);
+    } else if ($candownloadcourse) {
+        // Show the download course content button if user has permission to access it.
+        // Only showing this if user doesn't have edit rights, since those who do will access it via the actions menu.
+        $buttonattr = \core_course\output\content_export_link::get_attributes($context);
+        $button = new single_button($buttonattr->url, $buttonattr->displaystring, 'post', false, $buttonattr->elementattributes);
+        $PAGE->set_button($OUTPUT->render($button));
     }
 
     // If viewing a section, make the title more specific
     // Include course AJAX
     include_course_ajax($course, $modnamesused);
 
+    // If available, include the JS to prepare the download course content modal.
+    if ($candownloadcourse) {
+        $PAGE->requires->js_call_amd('core_course/downloadcontent', 'init');
+    }
+
     echo $OUTPUT->footer();
index 4f66c4f..f6768fb 100644 (file)
@@ -516,6 +516,8 @@ $string['doctonewwindow'] = 'Open in new window';
 $string['doesnotfit'] = 'Email display settings';
 $string['doesnotfitdetail'] = 'Display settings for email leaving Moodle.';
 $string['download'] = 'Download';
+$string['downloadcoursecontentallowed'] = 'Download course content feature available';
+$string['downloadcoursecontentallowed_desc'] = 'Whether the download course content feature is available to courses. When available, course content downloads can be enabled/disabled using the "Enable download course content" setting within the course edit menu (the default for this can be set in <a href={$a} target="_blank">Course default settings</a>).';
 $string['durationunits'] = 'duration units';
 $string['edithelpdocs'] = 'Edit help documents';
 $string['editlang'] = '<b>Edit</b>';
@@ -802,6 +804,8 @@ sites. If this is not what you wanted then you should make sure you are updating
 from a STABLE branch of the Moodle code. See Moodle Docs for more details.';
 $string['maxbytes'] = 'Maximum uploaded file size';
 $string['maxconsecutiveidentchars'] = 'Consecutive identical characters';
+$string['maxsizeperdownloadcoursefile'] = 'Maximum size per file';
+$string['maxsizeperdownloadcoursefile_desc'] = 'The maximum size of each file when downloading course content. Files exceeding this size will be omitted from the download.';
 $string['maxeditingtime'] = 'Maximum time to edit posts';
 $string['maxusersperpage'] = ' Maximum users per page';
 $string['configmaxusersperpage'] = 'Maximum number of users displayed within user selector in course, group, cohort, webservice etc.';
index 156ebab..a4e069d 100644 (file)
@@ -54,6 +54,10 @@ $string['customfield_visibility_help'] = 'This setting determines who can view t
 $string['customfield_visibletoall'] = 'Everyone';
 $string['customfield_visibletoteachers'] = 'Teachers';
 $string['customfieldsettings'] = 'Common course custom fields settings';
+$string['downloadcourseconfirmation'] = 'You are about to download a zip file of course content (excluding items which cannot be downloaded and any files larger than {$a}).';
+$string['downloadcoursecontent'] = 'Download course content';
+$string['downloadcoursecontent_help'] = 'This setting determines whether course content may be downloaded by users with the download course content capability (by default users with the role of student or teacher).';
+$string['enabledownloadcoursecontent'] = 'Enable download course content';
 $string['errorendbeforestart'] = 'The end date ({$a}) is before the course start date.';
 $string['favourite'] = 'Starred course';
 $string['gradetopassnotset'] = 'This course does not have a grade to pass set. It may be set in the grade item of the course (Gradebook setup).';
index 3fe5a85..05702bb 100644 (file)
@@ -1930,6 +1930,7 @@ $string['since'] = 'Since';
 $string['sincelast'] = 'since last login';
 $string['site'] = 'Site';
 $string['sitedefault'] = 'Site default';
+$string['sitedefaultspecified'] = 'Site default ({$a})';
 $string['siteerrors'] = 'Site errors';
 $string['sitefiles'] = 'Site files';
 $string['sitefilesused'] = 'Site files used in this course';
index 625c0c0..e884127 100644 (file)
@@ -172,6 +172,8 @@ $string['course:changelockedcustomfields'] = 'Change locked custom fields';
 $string['course:changeshortname'] = 'Change course short name';
 $string['course:changesummary'] = 'Change course summary';
 $string['course:configurecustomfields'] = 'Configure custom fields';
+$string['course:configuredownloadcontent'] = 'Configure download course content';
+$string['course:downloadcoursecontent'] = 'Download course content';
 $string['course:enrolconfig'] = 'Configure enrol instances in courses';
 $string['course:enrolreview'] = 'Review course enrolments';
 $string['course:setforcedlanguage'] = 'Force course language';
index 1699213..d9ad2c9 100644 (file)
@@ -52,7 +52,30 @@ class content {
      * @return  bool
      */
     public static function can_export_context(context $currentcontext, stdClass $user): bool {
-        return true;
+        global $CFG;
+
+        $canexport = false;
+
+        if ($currentcontext->contextlevel == CONTEXT_COURSE) {
+            if ($CFG->downloadcoursecontentallowed &&
+                    has_capability('moodle/course:downloadcoursecontent', $currentcontext, $user)) {
+
+                $courseinfo = get_fast_modinfo($currentcontext->instanceid)->get_course();
+
+                // If enabled/disabled explicitly set on course, use that as the course setting, otherwise use site default.
+                if (isset($courseinfo->downloadcontent) && $courseinfo->downloadcontent != DOWNLOAD_COURSE_CONTENT_SITE_DEFAULT) {
+                    $canexport = $courseinfo->downloadcontent;
+                } else {
+                    $canexport = get_config('moodlecourse')->downloadcontentsitedefault;
+                }
+
+            }
+        } else if ($currentcontext->contextlevel == CONTEXT_MODULE) {
+            // Modules can only be exported if exporting is allowed in their course context.
+            $canexport = self::can_export_context($currentcontext->get_course_context(), $user);
+        }
+
+        return $canexport;
     }
 
     /**
index 5078f4d..74861f2 100644 (file)
@@ -2576,4 +2576,26 @@ $capabilities = array(
             'editingteacher' => CAP_ALLOW,
         ]
     ],
+
+    // Allow users to download course content.
+    'moodle/course:downloadcoursecontent' => [
+        'captype' => 'read',
+        'contextlevel' => CONTEXT_COURSE,
+        'archetypes' => array(
+            'student' => CAP_ALLOW,
+            'teacher' => CAP_ALLOW,
+            'editingteacher' => CAP_ALLOW,
+            'manager' => CAP_ALLOW
+        )
+    ],
+
+    // Allow users to configure download course content functionality within a course, if the feature is available.
+    'moodle/course:configuredownloadcontent' => [
+        'captype' => 'write',
+        'contextlevel' => CONTEXT_COURSE,
+        'archetypes' => array(
+            'editingteacher' => CAP_ALLOW,
+            'manager' => CAP_ALLOW
+        )
+    ],
 );
index 2225860..00697cb 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="lib/db" VERSION="20201008" COMMENT="XMLDB file for core Moodle tables"
+<XMLDB PATH="lib/db" VERSION="20201021" COMMENT="XMLDB file for core Moodle tables"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../lib/xmldb/xmldb.xsd"
 >
@@ -89,6 +89,7 @@
         <FIELD NAME="showreports" TYPE="int" LENGTH="4" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="visible" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="1" SEQUENCE="false"/>
         <FIELD NAME="visibleold" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="1" SEQUENCE="false" COMMENT="the state of visible field when hiding parent category, this helps us to recover hidden states when unhiding the parent category later"/>
+        <FIELD NAME="downloadcontent" TYPE="int" LENGTH="1" NOTNULL="false" SEQUENCE="false"/>
         <FIELD NAME="groupmode" TYPE="int" LENGTH="4" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="groupmodeforce" TYPE="int" LENGTH="4" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="defaultgroupingid" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="default grouping used in course modules, does not have key intentionally"/>
index 7da92b5..424abe2 100644 (file)
@@ -2774,6 +2774,7 @@ function xmldb_main_upgrade($oldversion) {
 
         // Add example field.
         $field = new xmldb_field('example', XMLDB_TYPE_TEXT, null, null, null, null, null, 'tutorial');
+
         if (!$dbman->field_exists($table, $field)) {
             $dbman->add_field($table, $field);
         }
@@ -2863,8 +2864,19 @@ function xmldb_main_upgrade($oldversion) {
     }
 
     if ($oldversion < 2021052500.32) {
-        global $DB, $CFG;
+        // Define field downloadcontent to be added to course.
+        $table = new xmldb_table('course');
+        $field = new xmldb_field('downloadcontent', XMLDB_TYPE_INTEGER, '1', null, null, null, null, 'visibleold');
+
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
 
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2021052500.32);
+    }
+
+    if ($oldversion < 2021052500.33) {
         $table = new xmldb_table('badge_backpack');
         $uniquekey = new xmldb_key('backpackcredentials', XMLDB_KEY_UNIQUE, ['userid', 'externalbackpackid']);
 
@@ -2900,7 +2912,7 @@ function xmldb_main_upgrade($oldversion) {
         }
 
         // Main savepoint reached.
-        upgrade_main_savepoint(true, 2021052500.32);
+        upgrade_main_savepoint(true, 2021052500.33);
     }
 
     return true;
index f656e8b..d579e8e 100644 (file)
@@ -4448,7 +4448,7 @@ class settings_navigation extends navigation_node {
      * @return navigation_node|false
      */
     protected function load_course_settings($forceopen = false) {
-        global $CFG;
+        global $CFG, $USER;
         require_once($CFG->dirroot . '/course/lib.php');
 
         $course = $this->page->course;
@@ -4605,6 +4605,16 @@ class settings_navigation extends navigation_node {
             }
         }
 
+        // Prepare data for course content download functionality if it is enabled.
+        // Will only be included here if the action menu is already in use, otherwise a button will be added to the UI elsewhere.
+        if (\core\content::can_export_context($coursecontext, $USER) && !empty($coursenode->get_children_key_list())) {
+            $linkattr = \core_course\output\content_export_link::get_attributes($coursecontext);
+            $actionlink = new action_link($linkattr->url, $linkattr->displaystring, null, $linkattr->elementattributes);
+
+            $coursenode->add($linkattr->displaystring, $actionlink, self::TYPE_SETTING, null, 'download',
+                    new pix_icon('t/download', ''));
+        }
+
         // Return we are done
         return $coursenode;
     }
index 8378da8..6e5f3aa 100644 (file)
@@ -2051,7 +2051,8 @@ class moodle_page {
             $reportnode = $myprofilenode->add(get_string('reports'));
         }
         // Finally add the report to the navigation tree.
-        $reportnode->add($nodeinfo['name'], $nodeinfo['url'], navigation_node::TYPE_COURSE);
+        $reportnode->add($nodeinfo['name'], $nodeinfo['url'], navigation_node::TYPE_CUSTOM, null, null,
+            new pix_icon('i/report', $nodeinfo['name']));
     }
 
     /**
diff --git a/lib/tests/content_test.php b/lib/tests/content_test.php
new file mode 100644 (file)
index 0000000..52c8f56
--- /dev/null
@@ -0,0 +1,119 @@
+<?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 core\content class.
+ *
+ * @package     core
+ * @category    test
+ * @copyright   2020 Michael Hawkins <michaelh@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core;
+
+/**
+ * Unit tests for core\content class.
+ *
+ * @package     core
+ * @category    test
+ * @copyright   2020 Michael Hawkins <michaelh@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class content_test extends \advanced_testcase {
+
+    /**
+     * A test to confirm only valid cases allow exporting of course content.
+     */
+    public function test_can_export_context_course() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $course1 = $this->getDataGenerator()->create_course();
+        $course2 = $this->getDataGenerator()->create_course();
+        $course1context = \context_course::instance($course1->id);
+        $course2context = \context_course::instance($course2->id);
+
+        // Enrol user as student in course1 only.
+        $user = $this->getDataGenerator()->create_and_enrol($course1, 'student');
+
+        // Confirm by default enrolled user does not have permission to export in course1.
+        $this->assertFalse(content::can_export_context($course1context, $user));
+
+        // Make course download available on site, but not enabled in course1 or by default.
+        set_config('downloadcoursecontentallowed', true);
+
+        // Confirm user still does not have permission to export (disabled in courses by default).
+        $this->assertFalse(content::can_export_context($course1context, $user));
+
+        // Enable export in courses by default.
+        set_config('downloadcontentsitedefault', DOWNLOAD_COURSE_CONTENT_ENABLED, 'moodlecourse');
+
+        // Confirm user now has permission to export in course1 only.
+        $this->assertTrue(content::can_export_context($course1context, $user));
+
+        // Disable course downloads in course1.
+        $course1->downloadcontent = DOWNLOAD_COURSE_CONTENT_DISABLED;
+        $DB->update_record('course', $course1);
+        rebuild_course_cache($course1->id);
+
+        // Confirm user does not have permission to export in course1.
+        $this->assertFalse(content::can_export_context($course1context, $user));
+
+        // Enable course downloads in course1.
+        $course1->downloadcontent = DOWNLOAD_COURSE_CONTENT_ENABLED;
+        $DB->update_record('course', $course1);
+        rebuild_course_cache($course1->id);
+
+        // Confirm user has permission to export in course1.
+        $this->assertTrue(content::can_export_context($course1context, $user));
+
+        // Confirm user does not have permission to export in course they are not enrolled in (course2).
+        $this->assertFalse(content::can_export_context($course2context, $user));
+
+        // Disable export in courses by default.
+        set_config('downloadcontentsitedefault', DOWNLOAD_COURSE_CONTENT_DISABLED, 'moodlecourse');
+
+        // Confirm user still has permission to export in course1 (still enabled at the course level).
+        $this->assertTrue(content::can_export_context($course1context, $user));
+
+        // Disable the course downloads feature.
+        set_config('downloadcoursecontentallowed', false);
+
+        // Confirm user no longer has permission to export in course1.
+        $this->assertFalse(content::can_export_context($course1context, $user));
+    }
+
+    /**
+     * A test to confirm unsupported contexts will return false when checking whether content can be exported.
+     */
+    public function test_can_export_context_unsupported_context() {
+        $this->resetAfterTest();
+
+        $course1 = $this->getDataGenerator()->create_course();
+        $systemcontext = \context_system::instance();
+
+        // Enrol user as student in course1 only.
+        $user = $this->getDataGenerator()->create_and_enrol($course1, 'student');
+
+        // Make course download available on site (course context).
+        set_config('downloadcoursecontentallowed', true);
+
+        // Confirm system context does not gain permission to export content.
+        $this->assertFalse(content::can_export_context($systemcontext, $user));
+    }
+}
index 75f566f..075fa9e 100644 (file)
Binary files a/mod/lti/amd/build/tool_card_controller.min.js and b/mod/lti/amd/build/tool_card_controller.min.js differ
index bfe3b34..e99b87b 100644 (file)
Binary files a/mod/lti/amd/build/tool_card_controller.min.js.map and b/mod/lti/amd/build/tool_card_controller.min.js.map differ
index 4fccd1b..94998c3 100644 (file)
Binary files a/mod/lti/amd/build/tool_configure_controller.min.js and b/mod/lti/amd/build/tool_configure_controller.min.js differ
index 5191052..df0e78c 100644 (file)
Binary files a/mod/lti/amd/build/tool_configure_controller.min.js.map and b/mod/lti/amd/build/tool_configure_controller.min.js.map differ
index 763037f..4bd865c 100644 (file)
             return toolTypeData;
         }).then(function(toolTypeData) {
             return templates.render('mod_lti/tool_card', toolTypeData);
-        }).then(function(renderResult) {
-            var html = renderResult[0];
-            var js = renderResult[1];
-
+        }).then(function(html, js) {
             templates.replaceNode(element, html, js);
             return;
         }).catch(function() {
index 40dc885..6a38f59 100644 (file)
@@ -32,26 +32,17 @@ define(['jquery', 'core/ajax', 'core/notification', 'core/templates', 'mod_lti/e
     var SELECTORS = {
         EXTERNAL_REGISTRATION_CONTAINER: '#external-registration-container',
         EXTERNAL_REGISTRATION_PAGE_CONTAINER: '#external-registration-page-container',
+        EXTERNAL_REGISTRATION_TEMPLATE_CONTAINER: '#external-registration-template-container',
         CARTRIDGE_REGISTRATION_CONTAINER: '#cartridge-registration-container',
         CARTRIDGE_REGISTRATION_FORM: '#cartridge-registration-form',
         ADD_TOOL_FORM: '#add-tool-form',
         TOOL_LIST_CONTAINER: '#tool-list-container',
         TOOL_CREATE_BUTTON: '#tool-create-button',
+        TOOL_CREATE_LTILEGACY_BUTTON: '#tool-createltilegacy-button',
         REGISTRATION_CHOICE_CONTAINER: '#registration-choice-container',
         TOOL_URL: '#tool-url'
     };
 
-    /**
-     * Get the tool create button element.
-     *
-     * @method getToolCreateButton
-     * @private
-     * @return {Object} jQuery object
-     */
-    var getToolCreateButton = function() {
-        return $(SELECTORS.TOOL_CREATE_BUTTON);
-    };
-
     /**
      * Get the tool list container element.
      *
@@ -96,6 +87,40 @@ define(['jquery', 'core/ajax', 'core/notification', 'core/templates', 'mod_lti/e
         return $(SELECTORS.REGISTRATION_CHOICE_CONTAINER);
     };
 
+    /**
+     * Close the LTI Advantage Registration IFrame.
+     *
+     * @private
+     * @param {Object} e post message event sent from the registration frame.
+     */
+    var closeLTIAdvRegistration = function(e) {
+        if (e.data && 'org.imsglobal.lti.close' === e.data.subject) {
+            $(SELECTORS.EXTERNAL_REGISTRATION_TEMPLATE_CONTAINER).empty();
+            hideExternalRegistration();
+            showRegistrationChoices();
+            showToolList();
+            showRegistrationChoices();
+            reloadToolList();
+        }
+    };
+
+    /**
+     * Load the external registration template and render it in the DOM and display it.
+     *
+     * @method initiateRegistration
+     * @private
+     * @param {String} url where to send the registration request
+     */
+    var initiateRegistration = function(url) {
+        // Show the external registration page in an iframe.
+        $(SELECTORS.EXTERNAL_REGISTRATION_PAGE_CONTAINER).removeClass('hidden');
+        var container = $(SELECTORS.EXTERNAL_REGISTRATION_TEMPLATE_CONTAINER);
+        container.append($("<iframe src='startltiadvregistration.php?url="
+                         + encodeURIComponent(url) + "'></iframe>"));
+        showExternalRegistration();
+        window.addEventListener("message", closeLTIAdvRegistration, false);
+    };
+
     /**
      * Get the tool type URL.
      *
@@ -287,22 +312,38 @@ define(['jquery', 'core/ajax', 'core/notification', 'core/templates', 'mod_lti/e
                 });
     };
 
+    /**
+     * Start the LTI Advantage registration.
+     *
+     * @method addLTIAdvTool
+     * @private
+     */
+    var addLTIAdvTool = function() {
+        var url = $.trim(getToolURL());
+
+        if (url) {
+            $(SELECTORS.TOOL_URL).val('');
+            hideToolList();
+            initiateRegistration(url);
+        }
+
+    };
+
     /**
      * Trigger appropriate registration process process for the user input
      * URL. It can either be a cartridge or a registration url.
      *
-     * @method addTool
+     * @method addLTILegacyTool
      * @private
      * @return {Promise} jQuery Deferred object
      */
-    var addTool = function() {
+    var addLTILegacyTool = function() {
         var url = $.trim(getToolURL());
 
         if (url === "") {
             return $.Deferred().resolve();
         }
-
-        var toolButton = getToolCreateButton();
+        var toolButton = $(SELECTORS.TOOL_CREATE_LTILEGACY_BUTTON);
         startLoading(toolButton);
 
         var promise = toolType.isCartridge(url);
@@ -372,10 +413,16 @@ define(['jquery', 'core/ajax', 'core/notification', 'core/templates', 'mod_lti/e
             showRegistrationFeedback(data);
         });
 
-        var form = $(SELECTORS.ADD_TOOL_FORM);
-        form.submit(function(e) {
+        var addLegacyButton = $(SELECTORS.TOOL_CREATE_LTILEGACY_BUTTON);
+        addLegacyButton.click(function(e) {
+            e.preventDefault();
+            addLTILegacyTool();
+        });
+
+        var addLTIButton = $(SELECTORS.TOOL_CREATE_BUTTON);
+        addLTIButton.click(function(e) {
             e.preventDefault();
-            addTool();
+            addLTIAdvTool();
         });
 
     };
index 7afbbe6..419482f 100644 (file)
  * @copyright  2019 Stephen Vickers
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
+use mod_lti\local\ltiopenid\jwks_helper;
 
 define('NO_DEBUG_DISPLAY', true);
 define('NO_MOODLE_COOKIES', true);
 
 require_once(__DIR__ . '/../../config.php');
 
-$jwks = array('keys' => array());
-
-$privatekey = get_config('mod_lti', 'privatekey');
-$res = openssl_pkey_get_private($privatekey);
-$details = openssl_pkey_get_details($res);
-
-$jwk = array();
-$jwk['kty'] = 'RSA';
-$jwk['alg'] = 'RS256';
-$jwk['kid'] = get_config('mod_lti', 'kid');
-$jwk['e'] = rtrim(strtr(base64_encode($details['rsa']['e']), '+/', '-_'), '=');
-$jwk['n'] = rtrim(strtr(base64_encode($details['rsa']['n']), '+/', '-_'), '=');
-$jwk['use'] = 'sig';
-
-$jwks['keys'][] = $jwk;
-
 @header('Content-Type: application/json; charset=utf-8');
 
-echo json_encode($jwks, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
+echo json_encode(jwks_helper::get_jwks(), JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
diff --git a/mod/lti/classes/local/ltiopenid/jwks_helper.php b/mod/lti/classes/local/ltiopenid/jwks_helper.php
new file mode 100644 (file)
index 0000000..20cc956
--- /dev/null
@@ -0,0 +1,72 @@
+<?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/>.
+
+/**
+ * This files exposes functions for LTI 1.3 Key Management.
+ *
+ * @package    mod_lti
+ * @copyright  2020 Claude Vervoort (Cengage)
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace mod_lti\local\ltiopenid;
+
+/**
+ * This class exposes functions for LTI 1.3 Key Management.
+ *
+ * @package    mod_lti
+ * @copyright  2020 Claude Vervoort (Cengage)
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class jwks_helper {
+
+    /**
+     * Returns the private key to use to sign outgoing JWT.
+     *
+     * @return array keys are kid and key in PEM format.
+     */
+    public static function get_private_key() {
+        $privatekey = get_config('mod_lti', 'privatekey');
+        $kid = get_config('mod_lti', 'kid');
+        return [
+            "key" => $privatekey,
+            "kid" => $kid
+        ];
+    }
+
+    /**
+     * Returns the JWK Key Set for this site.
+     * @return array keyset exposting the site public key.
+     */
+    public static function get_jwks() {
+        $jwks = array('keys' => array());
+
+        $privatekey = self::get_private_key();
+        $res = openssl_pkey_get_private($privatekey['key']);
+        $details = openssl_pkey_get_details($res);
+
+        $jwk = array();
+        $jwk['kty'] = 'RSA';
+        $jwk['alg'] = 'RS256';
+        $jwk['kid'] = $privatekey['kid'];
+        $jwk['e'] = rtrim(strtr(base64_encode($details['rsa']['e']), '+/', '-_'), '=');
+        $jwk['n'] = rtrim(strtr(base64_encode($details['rsa']['n']), '+/', '-_'), '=');
+        $jwk['use'] = 'sig';
+
+        $jwks['keys'][] = $jwk;
+        return $jwks;
+    }
+
+}
diff --git a/mod/lti/classes/local/ltiopenid/registration_exception.php b/mod/lti/classes/local/ltiopenid/registration_exception.php
new file mode 100644 (file)
index 0000000..216f8c4
--- /dev/null
@@ -0,0 +1,32 @@
+<?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/>.
+
+/**
+ * This library exposes functions for LTI Dynamic Registration.
+ *
+ * @package    mod_lti
+ * @copyright  2020 Claude Vervoort (Cengage), Carlos Costa, Adrian Hutchinson (Macgraw Hill)
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace mod_lti\local\ltiopenid;
+
+/**
+ * Exception when transforming the registration to LTI config.
+ *
+ * Code is the HTTP Error code.
+ */
+class registration_exception extends \Exception {
+}
diff --git a/mod/lti/classes/local/ltiopenid/registration_helper.php b/mod/lti/classes/local/ltiopenid/registration_helper.php
new file mode 100644 (file)
index 0000000..448fc01
--- /dev/null
@@ -0,0 +1,345 @@
+<?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/>.
+
+/**
+ * A Helper for LTI Dynamic Registration.
+ *
+ * @package    mod_lti
+ * @copyright  2020 Claude Vervoort (Cengage), Carlos Costa, Adrian Hutchinson (Macgraw Hill)
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace mod_lti\local\ltiopenid;
+
+defined('MOODLE_INTERNAL') || die;
+
+require_once($CFG->dirroot . '/mod/lti/locallib.php');
+use Firebase\JWT\JWK;
+use Firebase\JWT\JWT;
+use stdClass;
+
+/**
+ * This class exposes functions for LTI Dynamic Registration.
+ *
+ * @package    mod_lti
+ * @copyright  2020 Claude Vervoort (Cengage), Carlos Costa, Adrian Hutchinson (Macgraw Hill)
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class registration_helper {
+    /** score scope */
+    const SCOPE_SCORE = 'https://purl.imsglobal.org/spec/lti-ags/scope/score';
+    /** result scope */
+    const SCOPE_RESULT = 'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly';
+    /** lineitem read-only scope */
+    const SCOPE_LINEITEM_RO = 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly';
+    /** lineitem full access scope */
+    const SCOPE_LINEITEM = 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem';
+    /** Names and Roles (membership) scope */
+    const SCOPE_NRPS = 'https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly';
+    /** Tool Settings scope */
+    const SCOPE_TOOL_SETTING = 'https://purl.imsglobal.org/spec/lti-ts/scope/toolsetting';
+
+
+    /**
+     * Function used to validate parameters.
+     *
+     * This function is needed because the payload contains nested
+     * objects, and optional_param() does not support arrays of arrays.
+     *
+     * @param array $payload that may contain the parameter key
+     * @param string $key the key of the value to be looked for in the payload
+     * @param bool $required if required, not finding a value will raise a registration_exception
+     *
+     * @return mixed
+     */
+    private static function get_parameter(array $payload, string $key, bool $required) {
+        if (!isset($payload[$key]) || empty($payload[$key])) {
+            if ($required) {
+                throw new registration_exception('missing required attribute '.$key, 400);
+            }
+            return null;
+        }
+        $parameter = $payload[$key];
+        // Cleans parameters to avoid XSS and other issues.
+        if (is_array($parameter)) {
+            return clean_param_array($parameter, PARAM_TEXT, true);
+        }
+        return clean_param($parameter, PARAM_TEXT);
+    }
+
+    /**
+     * Transforms an LTI 1.3 Registration to a Moodle LTI Config.
+     *
+     * @param array $registrationpayload the registration data received from the tool.
+     * @param string $clientid the clientid to be issued for that tool.
+     *
+     * @return object the Moodle LTI config.
+     */
+    public static function registration_to_config(array $registrationpayload, string $clientid): object {
+        $responsetypes = self::get_parameter($registrationpayload, 'response_types', true);
+        $initiateloginuri = self::get_parameter($registrationpayload, 'initiate_login_uri', true);
+        $redirecturis = self::get_parameter($registrationpayload, 'redirect_uris', true);
+        $clientname = self::get_parameter($registrationpayload, 'client_name', true);
+        $jwksuri = self::get_parameter($registrationpayload, 'jwks_uri', true);
+        $tokenendpointauthmethod = self::get_parameter($registrationpayload, 'token_endpoint_auth_method', true);
+
+        $applicationtype = self::get_parameter($registrationpayload, 'application_type', false);
+        $logouri = self::get_parameter($registrationpayload, 'logo_uri', false);
+
+        $ltitoolconfiguration = self::get_parameter($registrationpayload,
+            'https://purl.imsglobal.org/spec/lti-tool-configuration', true);
+
+        $domain = self::get_parameter($ltitoolconfiguration, 'domain', true);
+        $targetlinkuri = self::get_parameter($ltitoolconfiguration, 'target_link_uri', true);
+        $customparameters = self::get_parameter($ltitoolconfiguration, 'custom_parameters', false);
+        $scopes = explode(" ", self::get_parameter($registrationpayload, 'scope', false) ?? '');
+        $claims = self::get_parameter($ltitoolconfiguration, 'claims', false);
+        $messages = $ltitoolconfiguration['messages'] ?? [];
+        $description = self::get_parameter($ltitoolconfiguration, 'description', false);
+
+        // Validate response type.
+        // According to specification, for this scenario, id_token must be explicitly set.
+        if (!in_array('id_token', $responsetypes)) {
+            throw new registration_exception('invalid_response_types', 400);
+        }
+
+        // According to specification, this parameter needs to be an array.
+        if (!is_array($redirecturis)) {
+            throw new registration_exception('invalid_redirect_uris', 400);
+        }
+
+        // According to specification, for this scenario private_key_jwt must be explicitly set.
+        if ($tokenendpointauthmethod !== 'private_key_jwt') {
+            throw new registration_exception('invalid_token_endpoint_auth_method', 400);
+        }
+
+        if (!empty($applicationtype) && $applicationtype !== 'web') {
+            throw new registration_exception('invalid_application_type', 400);
+        }
+
+        $config = new stdClass();
+        $config->lti_clientid = $clientid;
+        $config->lti_toolurl = $targetlinkuri;
+        $config->lti_tooldomain = $domain;
+        $config->lti_typename = $clientname;
+        $config->lti_description = $description;
+        $config->lti_ltiversion = LTI_VERSION_1P3;
+        $config->lti_organizationid_default = LTI_DEFAULT_ORGID_SITEID;
+        $config->lti_icon = $logouri;
+        $config->lti_coursevisible = LTI_COURSEVISIBLE_PRECONFIGURED;
+        $config->lti_contentitem = 0;
+        // Sets Content Item.
+        if (!empty($messages)) {
+            $messagesresponse = [];
+            foreach ($messages as $value) {
+                if ($value['type'] === 'LtiDeepLinkingRequest') {
+                    $config->lti_contentitem = 1;
+                    $config->lti_toolurl_ContentItemSelectionRequest = $value['target_link_uri'] ?? '';
+                    array_push($messagesresponse, $value);
+                }
+            }
+        }
+
+        $config->lti_keytype = 'JWK_KEYSET';
+        $config->lti_publickeyset = $jwksuri;
+        $config->lti_initiatelogin = $initiateloginuri;
+        $config->lti_redirectionuris = implode(PHP_EOL, $redirecturis);
+        $config->lti_customparameters = '';
+        // Sets custom parameters.
+        if (isset($customparameters)) {
+            $paramssarray = [];
+            foreach ($customparameters as $key => $value) {
+                array_push($paramssarray, $key . '=' . $value);
+            }
+            $config->lti_customparameters = implode(PHP_EOL, $paramssarray);
+        }
+        // Sets launch container.
+        $config->lti_launchcontainer = LTI_LAUNCH_CONTAINER_EMBED_NO_BLOCKS;
+
+        // Sets Service info based on scopes.
+        $config->lti_acceptgrades = LTI_SETTING_NEVER;
+        $config->ltiservice_gradesynchronization = 0;
+        $config->ltiservice_memberships = 0;
+        $config->ltiservice_toolsettings = 0;
+        if (isset($scopes)) {
+            // Sets Assignment and Grade Services info.
+
+            if (in_array(self::SCOPE_SCORE, $scopes)) {
+                $config->lti_acceptgrades = LTI_SETTING_DELEGATE;
+                $config->ltiservice_gradesynchronization = 1;
+            }
+            if (in_array(self::SCOPE_RESULT, $scopes)) {
+                $config->lti_acceptgrades = LTI_SETTING_DELEGATE;
+                $config->ltiservice_gradesynchronization = 1;
+            }
+            if (in_array(self::SCOPE_LINEITEM_RO, $scopes)) {
+                $config->lti_acceptgrades = LTI_SETTING_DELEGATE;
+                $config->ltiservice_gradesynchronization = 1;
+            }
+            if (in_array(self::SCOPE_LINEITEM, $scopes)) {
+                $config->lti_acceptgrades = LTI_SETTING_DELEGATE;
+                $config->ltiservice_gradesynchronization = 2;
+            }
+
+            // Sets Names and Role Provisioning info.
+            if (in_array(self::SCOPE_NRPS, $scopes)) {
+                $config->ltiservice_memberships = 1;
+            }
+
+            // Sets Tool Settings info.
+            if (in_array(self::SCOPE_TOOL_SETTING, $scopes)) {
+                $config->ltiservice_toolsettings = 1;
+            }
+        }
+
+        // Sets privacy settings.
+        $config->lti_sendname = LTI_SETTING_NEVER;
+        $config->lti_sendemailaddr = LTI_SETTING_NEVER;
+        if (isset($claims)) {
+            // Sets name privacy settings.
+
+            if (in_array('name', $claims)) {
+                $config->lti_sendname = LTI_SETTING_ALWAYS;
+            }
+            if (in_array('given_name', $claims)) {
+                $config->lti_sendname = LTI_SETTING_ALWAYS;
+            }
+            if (in_array('family_name', $claims)) {
+                $config->lti_sendname = LTI_SETTING_ALWAYS;
+            }
+
+            // Sets email privacy settings.
+            if (in_array('email', $claims)) {
+                $config->lti_sendemailaddr = LTI_SETTING_ALWAYS;
+            }
+        }
+        return $config;
+    }
+
+    /**
+     * Transforms a moodle LTI 1.3 Config to an OAuth/LTI Client Registration.
+     *
+     * @param object $config Moodle LTI Config.
+     * @param int $typeid which is the LTI deployment id.
+     *
+     * @return array the Client Registration as an associative array.
+     */
+    public static function config_to_registration(object $config, int $typeid): array {
+        $registrationresponse = [];
+        $registrationresponse['client_id'] = $config->lti_clientid;
+        $registrationresponse['token_endpoint_auth_method'] = ['private_key_jwt'];
+        $registrationresponse['response_types'] = ['id_token'];
+        $registrationresponse['jwks_uri'] = $config->lti_publickeyset;
+        $registrationresponse['initiate_login_uri'] = $config->lti_initiatelogin;
+        $registrationresponse['grant_types'] = ['client_credentials', 'implicit'];
+        $registrationresponse['redirect_uris'] = explode(PHP_EOL, $config->lti_redirectionuris);
+        $registrationresponse['application_type'] = ['web'];
+        $registrationresponse['token_endpoint_auth_method'] = 'private_key_jwt';
+        $registrationresponse['client_name'] = $config->lti_typename;
+        $registrationresponse['logo_uri'] = $config->lti_icon ?? '';
+        $lticonfigurationresponse = [];
+        $lticonfigurationresponse['deployment_id'] = strval($typeid);
+        $lticonfigurationresponse['target_link_uri'] = $config->lti_toolurl;
+        $lticonfigurationresponse['domain'] = $config->lti_tooldomain ?? '';
+        $lticonfigurationresponse['description'] = $config->lti_description ?? '';
+        if ($config->lti_contentitem == 1) {
+            $contentitemmessage = [];
+            $contentitemmessage['type'] = 'LtiDeepLinkingRequest';
+            if (isset($config->lti_toolurl_ContentItemSelectionRequest)) {
+                $contentitemmessage['target_link_uri'] = $config->lti_toolurl_ContentItemSelectionRequest;
+            }
+            $lticonfigurationresponse['messages'] = [$contentitemmessage];
+        }
+        if (isset($config->lti_customparameters) && !empty($config->lti_customparameters)) {
+            $params = [];
+            foreach (explode(PHP_EOL, $config->lti_customparameters) as $param) {
+                $split = explode('=', $param);
+                $params[$split[0]] = $split[1];
+            }
+            $lticonfigurationresponse['custom_parameters'] = $params;
+        }
+        $scopesresponse = [];
+        if ($config->ltiservice_gradesynchronization > 0) {
+            $scopesresponse[] = self::SCOPE_SCORE;
+            $scopesresponse[] = self::SCOPE_RESULT;
+            $scopesresponse[] = self::SCOPE_LINEITEM_RO;
+        }
+        if ($config->ltiservice_gradesynchronization == 2) {
+            $scopesresponse[] = self::SCOPE_LINEITEM;
+        }
+        if ($config->ltiservice_memberships == 1) {
+            $scopesresponse[] = self::SCOPE_NRPS;
+        }
+        if ($config->ltiservice_toolsettings == 1) {
+            $scopesresponse[] = self::SCOPE_TOOL_SETTING;
+        }
+        $registrationresponse['scope'] = implode(' ', $scopesresponse);
+
+        $claimsresponse = ['sub', 'iss'];
+        if ($config->lti_sendname = LTI_SETTING_ALWAYS) {
+            $claimsresponse[] = 'name';
+            $claimsresponse[] = 'family_name';
+            $claimsresponse[] = 'middle_name';
+        }
+        if ($config->lti_sendemailaddr = LTI_SETTING_ALWAYS) {
+            $claimsresponse[] = 'email';
+        }
+        $lticonfigurationresponse['claims'] = $claimsresponse;
+        $registrationresponse['https://purl.imsglobal.org/spec/lti-tool-configuration'] = $lticonfigurationresponse;
+        return $registrationresponse;
+    }
+
+    /**
+     * Validates the registration token is properly signed and not used yet.
+     * Return the client id to use for this registration.
+     *
+     * @param string $registrationtokenjwt registration token
+     *
+     * @return string client id for the registration
+     */
+    public static function validate_registration_token(string $registrationtokenjwt): string {
+        global $DB;
+        $keys = JWK::parseKeySet(jwks_helper::get_jwks());
+        $registrationtoken = JWT::decode($registrationtokenjwt, $keys, ['RS256']);
+
+        // Get clientid from registrationtoken.
+        $clientid = $registrationtoken->sub;
+
+        // Checks if clientid is already registered.
+        if (!empty($DB->get_record('lti_types', array('clientid' => $clientid)))) {
+            throw new registration_exception("token_already_used", 401);
+        }
+        return $clientid;
+    }
+
+    /**
+     * Initializes an array with the scopes for services supported by the LTI module
+     *
+     * @return array List of scopes
+     */
+    public static function lti_get_service_scopes() {
+
+        $services = lti_get_services();
+        $scopes = array();
+        foreach ($services as $service) {
+            $servicescopes = $service->get_scopes();
+            if (!empty($servicescopes)) {
+                $scopes = array_merge($scopes, $servicescopes);
+            }
+        }
+        return $scopes;
+    }
+
+}
index d95aa53..234b558 100644 (file)
@@ -207,7 +207,7 @@ abstract class service_base {
     abstract public function get_resources();
 
     /**
-     * Get the scope(s) permitted for this service.
+     * Get the scope(s) permitted for this service in the context of a particular tool type.
      *
      * A null value indicates that no scopes are required to access the service.
      *
@@ -217,6 +217,17 @@ abstract class service_base {
         return null;
     }
 
+    /**
+     * Get the scope(s) permitted for this service.
+     *
+     * A null value indicates that no scopes are required to access the service.
+     *
+     * @return array|null
+     */
+    public function get_scopes() {
+        return null;
+    }
+
     /**
      * Returns the configuration options for this service.
      *
index 51d2593..3746760 100644 (file)
@@ -66,6 +66,8 @@ $string['activate'] = 'Activate';
 $string['activatetoadddescription'] = 'You will need to activate this tool before you can add a description.';
 $string['active'] = 'Active';
 $string['activity'] = 'Activity';
+$string['add_ltiadv'] = 'Add LTI Advantage';
+$string['add_ltilegacy'] = 'Add Legacy LTI';
 $string['addnewapp'] = 'Enable external application';
 $string['addserver'] = 'Add new trusted server';
 $string['addtype'] = 'Add preconfigured tool';
index a80f1b1..44acf5e 100644 (file)
@@ -54,6 +54,7 @@ defined('MOODLE_INTERNAL') || die;
 use moodle\mod\lti as lti;
 use Firebase\JWT\JWT;
 use Firebase\JWT\JWK;
+use mod_lti\local\ltiopenid\jwks_helper;
 
 global $CFG;
 require_once($CFG->dirroot.'/mod/lti/OAuth.php');
@@ -2725,7 +2726,11 @@ function lti_get_type_type_config($id) {
 function lti_prepare_type_for_save($type, $config) {
     if (isset($config->lti_toolurl)) {
         $type->baseurl = $config->lti_toolurl;
-        $type->tooldomain = lti_get_domain_from_url($config->lti_toolurl);
+        if (isset($config->lti_tooldomain)) {
+            $type->tooldomain = $config->lti_tooldomain;
+        } else {
+            $type->tooldomain = lti_get_domain_from_url($config->lti_toolurl);
+        }
     }
     if (isset($config->lti_description)) {
         $type->description = $config->lti_description;
@@ -3281,9 +3286,8 @@ function lti_sign_jwt($parms, $endpoint, $oauthconsumerkey, $typeid = 0, $nonce
         }
     }
 
-    $privatekey = get_config('mod_lti', 'privatekey');
-    $kid = get_config('mod_lti', 'kid');
-    $jwt = JWT::encode($payload, $privatekey, 'RS256', $kid);
+    $privatekey = jwks_helper::get_private_key();
+    $jwt = JWT::encode($payload, $privatekey['key'], 'RS256', $privatekey['kid']);
 
     $newparms = array();
     $newparms['id_token'] = $jwt;
@@ -3828,6 +3832,7 @@ function lti_get_service_by_resource_id($services, $resourceid) {
 
 /**
  * Initializes an array with the scopes for services supported by the LTI module
+ * and authorized for this particular tool instance.
  *
  * @param object $type  LTI tool type
  * @param array  $typeconfig  LTI tool type configuration
@@ -3848,7 +3853,6 @@ function lti_get_permitted_service_scopes($type, $typeconfig) {
     }
 
     return $scopes;
-
 }
 
 /**
@@ -4468,3 +4472,4 @@ function lti_new_access_token($typeid, $scopes) {
     return $newtoken;
 
 }
+
diff --git a/mod/lti/openid-configuration.php b/mod/lti/openid-configuration.php
new file mode 100644 (file)
index 0000000..e30b121
--- /dev/null
@@ -0,0 +1,61 @@
+<?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/>.
+
+/**
+ * This file returns the OpenId/LTI Configuration for this site.
+ *
+ * It is part of the LTI Tool Dynamic Registration, and used by
+ * tools to get the site configuration and registration end-point.
+ *
+ * @package    mod_lti
+ * @copyright  2020 Claude Vervoort (Cengage), Carlos Costa, Adrian Hutchinson (Macgraw Hill)
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+use mod_lti\local\ltiopenid\registration_helper;
+
+define('NO_DEBUG_DISPLAY', true);
+define('NO_MOODLE_COOKIES', true);
+require_once(__DIR__ . '/../../config.php');
+require_once($CFG->dirroot . '/mod/lti/locallib.php');
+require_once($CFG->libdir.'/weblib.php');
+
+$scopes = registration_helper::lti_get_service_scopes();
+$scopes[] = 'openid';
+$conf = [
+    'issuer' => $CFG->wwwroot,
+    'token_endpoint' => (new moodle_url('/mod/lti/token.php'))->out(false),
+    'token_endpoint_auth_methods_supported' => ['private_key_jwt'],
+    'token_endpoint_auth_signing_alg_values_supported' => ['RS256'],
+    'jwks_uri' => (new moodle_url('/mod/lti/certs.php'))->out(false),
+    'registration_endpoint' => (new moodle_url('/mod/lti/openid-registration.php'))->out(false),
+    'scopes_supported' => $scopes,
+    'response_types_supported' => ['id_token'],
+    'subject_types_supported' => ['public', 'pairwise'],
+    'id_token_signing_alg_values_supported' => ['RS256'],
+    'claims_supported' => ['sub', 'iss', 'name', 'given_name', 'family_name', 'email'],
+    'https://purl.imsglobal.org/spec/lti-platform-configuration ' => [
+        'product_family_code' => 'moodle',
+        'version' => $CFG->release,
+        'messages_supported' => ['LtiResourceLink', 'LtiDeepLinkingRequest'],
+        'placements' => ['AddContentMenu'],
+        'variables' => array_keys(lti_get_capabilities())
+    ]
+];
+
+@header('Content-Type: application/json; charset=utf-8');
+
+echo json_encode($conf, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
diff --git a/mod/lti/openid-registration.php b/mod/lti/openid-registration.php
new file mode 100644 (file)
index 0000000..c06e531
--- /dev/null
@@ -0,0 +1,62 @@
+<?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/>.
+
+/**
+ * This file receives a registration request along with the registration token and returns a client_id.
+ *
+ * @copyright  2020 Claude Vervoort (Cengage), Carlos Costa, Adrian Hutchinson (Macgraw Hill)
+ * @package    mod_lti
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define('NO_DEBUG_DISPLAY', true);
+define('NO_MOODLE_COOKIES', true);
+
+use mod_lti\local\ltiopenid\registration_helper;
+use mod_lti\local\ltiopenid\registration_exception;
+
+require_once(__DIR__ . '/../../config.php');
+require_once($CFG->dirroot . '/mod/lti/locallib.php');
+
+$code = 200;
+$message = '';
+// Retrieve registration token from Bearer Authorization header.
+$authheader = moodle\mod\lti\OAuthUtil::get_headers() ['Authorization'] ?? '';
+if (!($authheader && substr($authheader, 0, 7) == 'Bearer ')) {
+    $message = 'missing_registration_token';
+    $code = 401;
+} else {
+    $registrationpayload = json_decode(file_get_contents('php://input'), true);
+
+    // Registers tool.
+    $type = new stdClass();
+    $type->state = LTI_TOOL_STATE_PENDING;
+    try {
+        $clientid = registration_helper::validate_registration_token(trim(substr($authheader, 7)));
+        $config = registration_helper::registration_to_config($registrationpayload, $clientid);
+        $typeid = lti_add_type($type, clone $config);
+        $message = json_encode(registration_helper::config_to_registration($config, $typeid));
+        header('Content-Type: application/json; charset=utf-8');
+    } catch (registration_exception $e) {
+        $code = $e->getCode();
+        $message = $e->getMessage();
+    }
+}
+$response = new \mod_lti\local\ltiservice\response();
+// Set code.
+$response->set_code($code);
+// Set body.
+$response->set_body($message);
+$response->send();
index 32c2a47..bfc6420 100644 (file)
@@ -81,4 +81,13 @@ class basicoutcomes extends \mod_lti\local\ltiservice\service_base {
 
     }
 
+    /**
+     * Get the scope(s) permitted for the tool relevant to this service.
+     *
+     * @return array
+     */
+    public function get_scopes() {
+        return [self::SCOPE_BASIC_OUTCOMES];
+    }
+
 }
index 9d35849..caa437a 100644 (file)
@@ -112,6 +112,16 @@ class gradebookservices extends service_base {
 
     }
 
+    /**
+     * Get the scopes defined by this service.
+     *
+     * @return array
+     */
+    public function get_scopes() {
+        return [self::SCOPE_GRADEBOOKSERVICES_LINEITEM_READ, self::SCOPE_GRADEBOOKSERVICES_RESULT_READ,
+            self::SCOPE_GRADEBOOKSERVICES_SCORE, self::SCOPE_GRADEBOOKSERVICES_LINEITEM];
+    }
+
     /**
      * Adds form elements for gradebook sync add/edit page.
      *
index 46d437c..695e33f 100644 (file)
@@ -102,6 +102,15 @@ class memberships extends \mod_lti\local\ltiservice\service_base {
 
     }
 
+    /**
+     * Get the scope(s) defined by this service.
+     *
+     * @return array
+     */
+    public function get_scopes() {
+        return [self::SCOPE_MEMBERSHIPS_READ];
+    }
+
     /**
      * Get the JSON for members.
      *
index 31ca1d6..91a851f 100644 (file)
@@ -88,6 +88,15 @@ class toolsettings extends \mod_lti\local\ltiservice\service_base {
 
     }
 
+    /**
+     * Get the scope(s) defined this service.
+     *
+     * @return array
+     */
+    public function get_scopes() {
+        return [self::SCOPE_TOOL_SETTINGS];
+    }
+
     /**
      * Get the distinct settings from each level by removing any duplicates from higher levels.
      *
diff --git a/mod/lti/startltiadvregistration.php b/mod/lti/startltiadvregistration.php
new file mode 100644 (file)
index 0000000..9644a94
--- /dev/null
@@ -0,0 +1,51 @@
+<?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/>.
+
+/**
+ * Redirect the user to registration with token and openid config url as query params.
+ *
+ * @package mod_lti
+ * @copyright  2020 Cengage
+ * @author     Claude Vervoort
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+use Firebase\JWT\JWT;
+
+use mod_lti\local\ltiopenid\jwks_helper;
+
+require_once(__DIR__ . '/../../config.php');
+require_once($CFG->libdir.'/weblib.php');
+
+require_login();
+$context = context_system::instance();
+require_capability('moodle/site:config', $context);
+
+$starturl = required_param('url', PARAM_URL);
+$now = time();
+$token = [
+    "sub" => random_string(15),
+    "scope" => "reg",
+    "iat" => $now,
+    "exp" => $now + HOURSECS
+];
+$privatekey = jwks_helper::get_private_key();
+$regtoken = JWT::encode($token, $privatekey['key'], 'RS256', $privatekey['kid']);
+$confurl = new moodle_url('/mod/lti/openid-configuration.php');
+$url = new moodle_url($starturl);
+$url->param('openid_configuration', $confurl->out(false));
+$url->param('registration_token', $regtoken);
+header("Location: ".$url->out(false));
index 5e41523..4821f79 100644 (file)
                 <div class="controls">
                     <button id="cartridge-registration-submit" type="submit" class="btn btn-success">
                         <span class="btn-text">{{#str}} savechanges {{/str}}</span>
-                        <div class="btn-loader">
+                        <span class="btn-loader">
                             {{> mod_lti/loader }}
-                        </div>
+                        </span>
                     </button>
                     <button id="cartridge-registration-cancel" type="button" class="btn">
                         <span class="btn-text">{{#str}} cancel {{/str}}</span>
-                        <div class="btn-loader">
+                        <span class="btn-loader">
                             {{> mod_lti/loader }}
-                        </div>
+                        </span>
                     </button>
                 </div>
             </div>
index 6be8746..9c5a601 100644 (file)
     Context variables required for this template:
     *
 
+    Example context (json):
+    {
+    }
+
 }}
 <div id="external-registration-page-container">
     <button id="cancel-external-registration" class="btn btn-danger">
         <span class="btn-text">{{#str}} cancel {{/str}}</span>
-        <div class="btn-loader">
+        <span class="btn-loader">
             {{> mod_lti/loader }}
-        </div>
+        </span>
     </button>
     <div id="external-registration-template-container"></div>
 </div>
index 0f21877..6a4d6b9 100644 (file)
@@ -34,6 +34,6 @@
     }
 
 }}
-<div class="loader">
+<span class="loader">
     {{#pix}} i/loading, core, {{#str}} loadinghelp, moodle {{/str}} {{/pix}}
-</div>
+</span>
index 4060152..099e3c5 100644 (file)
     Context variables required for this template:
     *
 
+    Example context (json):
+    {
+        "configuremanualurl":"https://some.tool.example/mod/lti/typessettings.php?sesskey=OKl37bHflL&amp;returnto=toolconfigure",
+        "managetoolsurl":"https://some.tool.example/admin/settings.php?section=modsettinglti",
+        "managetoolproxiesurl":"https://some.tool.example/mod/lti/toolproxies.php"
+    }
+
 }}
 <h2>{{#str}} manage_external_tools, mod_lti {{/str}}</h2>
 <div id="main-content-container">
                             placeholder="{{#str}} toolurlplaceholder, mod_lti {{/str}}"
                             required>
                         <button id="tool-create-button" type="submit" class="btn btn-success">
-                            <span class="btn-text">{{#str}} add {{/str}}</span>
-                            <div class="btn-loader">
+                            <span class="btn-text">{{#str}} add_ltiadv, mod_lti {{/str}}</span>
+                            <span class="btn-loader">
+                                {{> mod_lti/loader }}
+                            </span>
+                        </button>
+                        <button id="tool-createltilegacy-button" type="button" class="btn btn-warning">
+                            <span class="btn-text">{{#str}} add_ltilegacy, mod_lti {{/str}}</span>
+                            <span class="btn-loader">
                                 {{> mod_lti/loader }}
-                            </div>
+                            </span>
                         </button>
                     </div>
                 </form>
index 171ebee..3622bf8 100644 (file)
@@ -22,7 +22,7 @@ Feature: Configure tool types
   @javascript
   Scenario: Add a tool type from a cartridge URL
     When I set the field "url" to local url "/mod/lti/tests/fixtures/ims_cartridge_basic_lti_link.xml"
-    And I press "Add"
+    And I press "Add Legacy LTI"
     Then I should see "Enter your consumer key and shared secret"
     And I press "Save changes"
     And I should see "Example tool"
@@ -30,7 +30,7 @@ Feature: Configure tool types
   @javascript
   Scenario: Try to add a non-existant cartridge
     When I set the field "url" to local url "/mod/lti/tests/fixtures/nonexistant.xml"
-    And I press "Add"
+    And I press "Add Legacy LTI"
     Then I should see "Enter your consumer key and shared secret"
     And I press "Save changes"
     And I should see "Failed to create new tool. Please check the URL and try again."
@@ -38,6 +38,6 @@ Feature: Configure tool types
   @javascript
   Scenario: Attempt to add a tool type from a configuration URL, then cancel
     When I set the field "url" to local url "/mod/lti/tests/fixtures/tool_provider.php"
-    And I press "Add"
+    And I press "Add Legacy LTI"
     Then I should see "Cancel"
     And I press "cancel-external-registration"
diff --git a/mod/lti/tests/openidregistration_test.php b/mod/lti/tests/openidregistration_test.php
new file mode 100644 (file)
index 0000000..af67829
--- /dev/null
@@ -0,0 +1,269 @@
+<?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/>.
+//
+// This file is part of BasicLTI4Moodle
+//
+// BasicLTI4Moodle is an IMS BasicLTI (Basic Learning Tools for Interoperability)
+// consumer for Moodle 1.9 and Moodle 2.0. BasicLTI is a IMS Standard that allows web
+// based learning tools to be easily integrated in LMS as native ones. The IMS BasicLTI
+// specification is part of the IMS standard Common Cartridge 1.1 Sakai and other main LMS
+// are already supporting or going to support BasicLTI. This project Implements the consumer
+// for Moodle. Moodle is a Free Open source Learning Management System by Martin Dougiamas.
+// BasicLTI4Moodle is a project iniciated and leaded by Ludo(Marc Alier) and Jordi Piguillem
+// at the GESSI research group at UPC.
+// SimpleLTI consumer for Moodle is an implementation of the early specification of LTI
+// by Charles Severance (Dr Chuck) htp://dr-chuck.com , developed by Jordi Piguillem in a
+// Google Summer of Code 2008 project co-mentored by Charles Severance and Marc Alier.
+//
+// BasicLTI4Moodle is copyright 2009 by Marc Alier Forment, Jordi Piguillem and Nikolas Galanis
+// of the Universitat Politecnica de Catalunya http://www.upc.edu
+// Contact info: Marc Alier Forment granludo @ gmail.com or marc.alier @ upc.edu.
+
+/**
+ * This file contains unit tests for lti/openidregistrationlib.php
+ *
+ * @package    mod_lti
+ * @copyright  2020 Claude Vervoort, Cengage
+ * @author     Claude Vervoort
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+use mod_lti\local\ltiopenid\registration_exception;
+use mod_lti\local\ltiopenid\registration_helper;
+
+/**
+ * OpenId LTI Registration library tests
+ */
+class mod_lti_openidregistrationlib_testcase extends advanced_testcase {
+
+    /**
+     * @var string A has-it-all client registration.
+     */
+    private $registrationfulljson = <<<EOD
+    {
+        "application_type": "web",
+        "response_types": ["id_token"],
+        "grant_types": ["implict", "client_credentials"],
+        "initiate_login_uri": "https://client.example.org/lti/init",
+        "redirect_uris":
+        ["https://client.example.org/callback",
+        "https://client.example.org/callback2"],
+        "client_name": "Virtual Garden",
+        "client_name#ja": "バーチャルガーデン",
+        "jwks_uri": "https://client.example.org/.well-known/jwks.json",
+        "logo_uri": "https://client.example.org/logo.png",
+        "policy_uri": "https://client.example.org/privacy",
+        "policy_uri#ja": "https://client.example.org/privacy?lang=ja",
+        "tos_uri": "https://client.example.org/tos",
+        "tos_uri#ja": "https://client.example.org/tos?lang=ja",
+        "token_endpoint_auth_method": "private_key_jwt",
+        "contacts": ["ve7jtb@example.org", "mary@example.org"],
+        "scope": "https://purl.imsglobal.org/spec/lti-ags/scope/score https://purl.imsglobal.org/spec/lti-ags/scope/lineitem",
+        "https://purl.imsglobal.org/spec/lti-tool-configuration": {
+            "domain": "client.example.org",
+            "description": "Learn Botany by tending to your little (virtual) garden.",
+            "description#ja": "小さな(仮想)庭に行くことで植物学を学びましょう。",
+            "target_link_uri": "https://client.example.org/lti",
+            "custom_parameters": {
+                "context_history": "\$Context.id.history"
+            },
+            "claims": ["iss", "sub", "name", "given_name", "family_name", "email"],
+            "messages": [
+                {
+                    "type": "LtiDeepLinkingRequest",
+                    "target_link_uri": "https://client.example.org/lti/dl",
+                    "label": "Add a virtual garden",
+                    "label#ja": "バーチャルガーデンを追加する"
+                }
+            ]
+        }
+    }
+EOD;
+
+    /**
+     * @var string A minimalist client registration.
+     */
+    private $registrationminimaljson = <<<EOD
+    {
+        "application_type": "web",
+        "response_types": ["id_token"],
+        "grant_types": ["implict", "client_credentials"],
+        "initiate_login_uri": "https://client.example.org/lti/init",
+        "redirect_uris":
+        ["https://client.example.org/callback"],
+        "client_name": "Virtual Garden",
+        "jwks_uri": "https://client.example.org/.well-known/jwks.json",
+        "token_endpoint_auth_method": "private_key_jwt",
+        "https://purl.imsglobal.org/spec/lti-tool-configuration": {
+            "domain": "client.example.org",
+            "target_link_uri": "https://client.example.org/lti"
+        }
+    }
+EOD;
+
+    /**
+     * @var string A minimalist with deep linking client registration.
+     */
+    private $registrationminimaldljson = <<<EOD
+    {
+        "application_type": "web",
+        "response_types": ["id_token"],
+        "grant_types": ["implict", "client_credentials"],
+        "initiate_login_uri": "https://client.example.org/lti/init",
+        "redirect_uris":
+        ["https://client.example.org/callback"],
+        "client_name": "Virtual Garden",
+        "jwks_uri": "https://client.example.org/.well-known/jwks.json",
+        "token_endpoint_auth_method": "private_key_jwt",
+        "https://purl.imsglobal.org/spec/lti-tool-configuration": {
+            "domain": "client.example.org",
+            "target_link_uri": "https://client.example.org/lti",
+            "messages": [
+                {
+                    "type": "LtiDeepLinkingRequest"
+                }
+            ]
+        }
+    }
+EOD;
+
+    /**
+     * Test the mapping from Registration JSON to LTI Config for a has-it-all tool registration.
+     */
+    public function test_to_config_full() {
+        $registration = json_decode($this->registrationfulljson, true);
+        $registration['scope'] .= ' https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly';
+        $config = registration_helper::registration_to_config($registration, 'TheClientId');
+        $this->assertEquals('JWK_KEYSET', $config->lti_keytype);
+        $this->assertEquals(LTI_VERSION_1P3, $config->lti_ltiversion);
+        $this->assertEquals('TheClientId', $config->lti_clientid);
+        $this->assertEquals('Virtual Garden', $config->lti_typename);
+        $this->assertEquals('Learn Botany by tending to your little (virtual) garden.', $config->lti_description);
+        $this->assertEquals('https://client.example.org/lti/init', $config->lti_initiatelogin);
+        $this->assertEquals(implode(PHP_EOL, ["https://client.example.org/callback",
+            "https://client.example.org/callback2"]), $config->lti_redirectionuris);
+        $this->assertEquals("context_history=\$Context.id.history", $config->lti_customparameters);
+        $this->assertEquals("https://client.example.org/.well-known/jwks.json", $config->lti_publickeyset);
+        $this->assertEquals("https://client.example.org/logo.png", $config->lti_icon);
+        $this->assertEquals(2, $config->ltiservice_gradesynchronization);
+        $this->assertEquals(LTI_SETTING_DELEGATE, $config->lti_acceptgrades);
+        $this->assertEquals(1, $config->ltiservice_memberships);
+        $this->assertEquals(0, $config->ltiservice_toolsettings);
+        $this->assertEquals(LTI_SETTING_ALWAYS, $config->lti_sendname);
+        $this->assertEquals(LTI_SETTING_ALWAYS, $config->lti_sendemailaddr);
+        $this->assertEquals(1, $config->lti_contentitem);
+        $this->assertEquals('https://client.example.org/lti/dl', $config->lti_toolurl_ContentItemSelectionRequest);
+    }
+
+    /**
+     * Test the mapping from Registration JSON to LTI Config for a minimal tool registration.
+     */
+    public function test_to_config_minimal() {
+        $registration = json_decode($this->registrationminimaljson, true);
+        $config = registration_helper::registration_to_config($registration, 'TheClientId');
+        $this->assertEquals('JWK_KEYSET', $config->lti_keytype);
+        $this->assertEquals(LTI_VERSION_1P3, $config->lti_ltiversion);
+        $this->assertEquals('TheClientId', $config->lti_clientid);
+        $this->assertEquals('Virtual Garden', $config->lti_typename);
+        $this->assertEmpty($config->lti_description);
+        $this->assertEquals('https://client.example.org/lti/init', $config->lti_initiatelogin);
+        $this->assertEquals('https://client.example.org/callback', $config->lti_redirectionuris);
+        $this->assertEmpty($config->lti_customparameters);
+        $this->assertEquals("https://client.example.org/.well-known/jwks.json", $config->lti_publickeyset);
+        $this->assertEmpty($config->lti_icon);
+        $this->assertEquals(0, $config->ltiservice_gradesynchronization);
+        $this->assertEquals(LTI_SETTING_NEVER, $config->lti_acceptgrades);
+        $this->assertEquals(0, $config->ltiservice_memberships);
+        $this->assertEquals(LTI_SETTING_NEVER, $config->lti_sendname);
+        $this->assertEquals(LTI_SETTING_NEVER, $config->lti_sendemailaddr);
+        $this->assertEquals(0, $config->lti_contentitem);
+    }
+
+    /**
+     * Test the mapping from Registration JSON to LTI Config for a minimal tool with
+     * deep linking support registration.
+     */
+    public function test_to_config_minimal_with_deeplinking() {
+        $registration = json_decode($this->registrationminimaldljson, true);
+        $config = registration_helper::registration_to_config($registration, 'TheClientId');
+        $this->assertEquals(1, $config->lti_contentitem);
+        $this->assertEmpty($config->lti_toolurl_ContentItemSelectionRequest);
+    }
+
+    /**
+     * Validation Test: initiation login.
+     */
+    public function test_validation_initlogin() {
+        $registration = json_decode($this->registrationfulljson, true);
+        $this->expectException(registration_exception::class);
+        $this->expectExceptionCode(400);
+        unset($registration['initiate_login_uri']);
+        registration_helper::registration_to_config($registration, 'TheClientId');
+    }
+
+    /**
+     * Validation Test: redirect uris.
+     */
+    public function test_validation_redirecturis() {
+        $registration = json_decode($this->registrationfulljson, true);
+        $this->expectException(registration_exception::class);
+        $this->expectExceptionCode(400);
+        unset($registration['redirect_uris']);
+        registration_helper::registration_to_config($registration, 'TheClientId');
+    }
+
+    /**
+     * Validation Test: jwks uri empty.
+     */
+    public function test_validation_jwks() {
+        $registration = json_decode($this->registrationfulljson, true);
+        $this->expectException(registration_exception::class);
+        $this->expectExceptionCode(400);
+        $registration['jwks_uri'] = '';
+        registration_helper::registration_to_config($registration, 'TheClientId');
+    }
+
+    /**
+     * Test the transformation from lti config to OpenId LTI Client Registration response.
+     */
+    public function test_config_to_registration() {
+        $orig = json_decode($this->registrationfulljson, true);
+        $orig['scope'] .= ' https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly';
+        $reg = registration_helper::config_to_registration(registration_helper::registration_to_config($orig, 'clid'), 12);
+        $this->assertEquals('clid', $reg['client_id']);
+        $this->assertEquals($orig['response_types'], $reg['response_types']);
+        $this->assertEquals($orig['initiate_login_uri'], $reg['initiate_login_uri']);
+        $this->assertEquals($orig['redirect_uris'], $reg['redirect_uris']);
+        $this->assertEquals($orig['jwks_uri'], $reg['jwks_uri']);
+        $this->assertEquals($orig['logo_uri'], $reg['logo_uri']);
+        $this->assertEquals('https://purl.imsglobal.org/spec/lti-ags/scope/score '.
+            'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly '.
+            'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly '.
+            'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem '.
+            'https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly', $reg['scope']);
+        $ltiorig = $orig['https://purl.imsglobal.org/spec/lti-tool-configuration'];
+        $lti = $reg['https://purl.imsglobal.org/spec/lti-tool-configuration'];
+        $this->assertEquals("12", $lti['deployment_id']);
+        $this->assertEquals($ltiorig['target_link_uri'], $lti['target_link_uri']);
+        $this->assertEquals($ltiorig['domain'], $lti['domain']);
+        $this->assertEquals($ltiorig['custom_parameters'], $lti['custom_parameters']);
+        $this->assertEquals($ltiorig['description'], $lti['description']);
+        $dlmsgorig = $ltiorig['messages'][0];
+        $dlmsg = $lti['messages'][0];
+        $this->assertEquals($dlmsgorig['type'], $dlmsg['type']);
+        $this->assertEquals($dlmsgorig['target_link_uri'], $dlmsg['target_link_uri']);
+    }
+}
index a1508dc..ae51df3 100644 (file)
@@ -29,7 +29,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$version  = 2021052500.32;              // YYYYMMDD      = weekly release date of this DEV branch.
+$version  = 2021052500.33;              // YYYYMMDD      = weekly release date of this DEV branch.
                                         //         RR    = release increments - 00 in DEV branches.
                                         //           .XX = incremental changes.
 $release  = '4.0dev (Build: 20201023)'; // Human-friendly version name