diff --git a/.gitignore b/.gitignore index 81dec99469db91c4cfcaaee295928299d1e03e8e..db2847960867dafa0c0e3ee54d4e0d6335c8ea9f 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ data/oer_logos/* data/upload_doc/* public/.htaccess +public/.rnd public/assets/javascripts/*.js public/assets/javascripts/*.js.map public/assets/stylesheets/*.css diff --git a/app/controllers/admin/lti.php b/app/controllers/admin/lti.php index eb3082c5dc4b6bcfb30dfa42ac8a3fb889222b7e..25ea3febe9caac15d398566403f6e0d2207de7a7 100644 --- a/app/controllers/admin/lti.php +++ b/app/controllers/admin/lti.php @@ -23,16 +23,21 @@ class Admin_LtiController extends AuthenticatedController $GLOBALS['perm']->check('root'); Navigation::activateItem('/admin/config/lti'); - PageLayout::setTitle(_('Konfiguration der LTI-Tools')); + PageLayout::setTitle(_('LTI-Tools')); $widget = Sidebar::get()->addWidget(new ActionsWidget()); $widget->addLink( _('Neues LTI-Tool registrieren'), - $this->url_for('admin/lti/edit'), + $this->url_for('lti/tool/add/global'), Icon::create('add') )->asDialog(); + $widget->addLink( + _('Daten zur LTI-Plattform anzeigen'), + $this->url_for('lti/auth/platform_data'), + Icon::create('info') + )->asDialog(); - Helpbar::get()->addPlainText('', _('Hier können Sie Verknüpfungen mit externen Tools konfigurieren, sofern diese den LTI-Standard (Version 1.x) unterstützen.')); + Helpbar::get()->addPlainText('', _('Hier können Sie LTI-Tools konfigurieren. Diese müssen den LTI-Standard in Version 1.0/1.1 oder 1.3A unterstützen.')); } /** @@ -42,66 +47,4 @@ class Admin_LtiController extends AuthenticatedController { $this->tools = LtiTool::findAll(); } - - /** - * Display dialog for editing an LTI tool. - * - * @param int $id tool id - */ - public function edit_action($id = null) - { - $this->tool = new LtiTool($id); - } - - /** - * Save changes for an LTI tool. - * - * @param int $id tool id - */ - public function save_action($id) - { - CSRFProtection::verifyUnsafeRequest(); - - $tool = new LtiTool($id ?: null); - $tool->name = trim(Request::get('name')); - $tool->launch_url = trim(Request::get('launch_url')); - $tool->consumer_key = trim(Request::get('consumer_key')); - $tool->consumer_secret = trim(Request::get('consumer_secret')); - $tool->custom_parameters = trim(Request::get('custom_parameters')); - $tool->allow_custom_url = Request::int('allow_custom_url', 0); - $tool->deep_linking = Request::int('deep_linking', 0); - $tool->send_lis_person = Request::int('send_lis_person', 0); - $tool->oauth_signature_method = Request::get('oauth_signature_method', 'sha1'); - - if ($tool->store()) { - PageLayout::postSuccess(sprintf( - _('Einstellungen für "%s" wurden gespeichert.'), - htmlReady($tool->name) - )); - } - - $this->redirect('admin/lti'); - } - - /** - * Delete an LTI tool. - * - * @param int $id tool id - */ - public function delete_action($id) - { - CSRFProtection::verifyUnsafeRequest(); - - $tool = LtiTool::find($id); - $tool_name = $tool->name; - - if ($tool && $tool->delete()) { - PageLayout::postSuccess(sprintf( - _('Das LTI-Tool "%s" wurde gelöscht.'), - htmlReady($tool_name) - )); - } - - $this->redirect('admin/lti'); - } } diff --git a/app/controllers/course/lti.php b/app/controllers/course/lti.php index ab403af6f50946e2ebaaeefd990ef76df381debb..5fe73d1ef91b7d11cea362fbaeec1387d2b42081 100644 --- a/app/controllers/course/lti.php +++ b/app/controllers/course/lti.php @@ -1,6 +1,17 @@ <?php -use Studip\OAuth2\NegotiatesWithPsr7; +use OAT\Library\Lti1p3Core\Message\Launch\Builder\LtiResourceLinkLaunchRequestBuilder; +use OAT\Library\Lti1p3Core\Message\Launch\Validator\Platform\PlatformLaunchValidator; +use OAT\Library\Lti1p3Core\Message\Payload\Claim\AgsClaim; +use OAT\Library\Lti1p3Core\Message\Payload\Claim\ContextClaim; +use OAT\Library\Lti1p3Core\Resource\LtiResourceLink\LtiResourceLink; +use OAT\Library\Lti1p3Core\Resource\LtiResourceLink\LtiResourceLinkInterface; +use OAT\Library\Lti1p3Core\Security\Nonce\NonceRepository; +use OAT\Library\Lti1p3DeepLinking\Factory\ResourceCollectionFactory; +use OAT\Library\Lti1p3DeepLinking\Message\Launch\Builder\DeepLinkingLaunchRequestBuilder; +use Studip\LTI13a\PlatformManager; +use Studip\LTI13a\Registration; +use Studip\LTI13a\RegistrationManager; /** * course/lti.php - LTI consumer API for Stud.IP @@ -16,7 +27,7 @@ use Studip\OAuth2\NegotiatesWithPsr7; class Course_LtiController extends StudipController { - use NegotiatesWithPsr7; + use Studip\OAuth2\NegotiatesWithPsr7; public function __construct(\Trails\Dispatcher $dispatcher) { @@ -34,23 +45,29 @@ class Course_LtiController extends StudipController public function before_filter(&$action, &$args) { parent::before_filter($action, $args); - // these actions do not require session authentication + //The profile and outcome actions do not require all the other + //stuff that is going on in this method: if (in_array($action, ['profile', 'outcome'])) { return; } $this->course_id = Context::getId(); - $this->edit_perm = $GLOBALS['perm']->have_studip_perm('tutor', $this->course_id); + $this->course = Course::find($this->course_id); - if (!in_array($action, ['index', 'iframe', 'grades']) && !$this->edit_perm) { - throw new AccessDeniedException(_('Sie besitzen keine Berechtigung, um LTI-Tools zu konfigurieren.')); + if (in_array($action, ['select_tool', 'add_link']) && !$this->course) { + throw new AccessDeniedException(); } - if ($action !== 'grades') { - Navigation::activateItem('/course/lti/index'); + $this->edit_perm = $GLOBALS['perm']->have_studip_perm('tutor', $this->course_id); + if (!in_array($action, ['index', 'iframe', 'grades', 'consent']) && !$this->edit_perm) { + throw new AccessDeniedException(); } - $title = CourseConfig::get($this->course_id)->LTI_TOOL_TITLE; - PageLayout::setTitle(Context::getHeaderLine() . ' - ' . $title); + if ( + !in_array($action, ['admin', 'grades']) + && Navigation::hasItem('/course/lti/index') + ) { + Navigation::activateItem('/course/lti/index'); + } } /** @@ -58,7 +75,17 @@ class Course_LtiController extends StudipController */ public function index_action() { - $this->lti_data_array = LtiData::findByCourse_id($this->course_id, 'ORDER BY position'); + $this->lti_data_array = []; + if ($this->edit_perm) { + $this->lti_data_array = LtiDeployment::findByCourse_id($this->course_id, 'ORDER BY position'); + } else { + //Only load those deployments that are fully configured: + $this->lti_data_array = LtiDeployment::findBySQL( + "`course_id` = :course_id AND (`options` IS NULL OR `options` NOT LIKE '%unfinished_deep_linking%') + ORDER BY `position`", + ['course_id' => $this->course_id] + ); + } if ($this->edit_perm) { $widget = Sidebar::get()->addWidget(new ActionsWidget()); @@ -67,17 +94,21 @@ class Course_LtiController extends StudipController $this->url_for('course/lti/config'), Icon::create('admin') )->asDialog('size=auto'); - $widget->addLink( - _('Abschnitt hinzufügen'), - $this->url_for('course/lti/edit'), - Icon::create('add') - )->asDialog(); + $global_tools_available = LtiTool::countBySQL("`range_id` = 'global'") > 0; + if (Config::get()->LTI_ALLOW_TOOL_CONFIG_IN_COURSE || $global_tools_available) { + $widget->addLink( + _('LTI-Tool hinzufügen'), + $this->url_for('course/lti/select_tool'), + Icon::create('add') + )->asDialog('size=auto'); + } - if (LtiTool::findByDeep_linking(1)) { + $global_deep_linking_tools_exist = LtiTool::countBySQL("`deep_linking` = 1 AND `range_id` = 'global'") > 0; + if ($global_deep_linking_tools_exist) { $widget->addLink( - _('Link aus LTI-Tool einfügen'), + _('Tool mittels LTI Deep Linking hinzufügen'), $this->url_for('course/lti/add_link'), - Icon::create('add') + Icon::create('network2') )->asDialog('size=auto'); } } @@ -85,126 +116,257 @@ class Course_LtiController extends StudipController Helpbar::get()->addPlainText('', _('Auf dieser Seite können Sie externe Anwendungen einbinden, sofern diese den LTI-Standard (Version 1.x) unterstützen.')); } - /** - * Display the launch form for a tool as an iframe. - */ - public function iframe_action(string $position) + public function select_tool_action() { - $lti_data = LtiData::findByCourseAndPosition($this->course_id, $position); - $lti_link = $this->getLtiLink($lti_data); + //The permission check is done in the before filter. - $this->launch_url = $lti_data->getLaunchURL(); - $this->launch_data = $lti_link->getBasicLaunchData(); - $this->signature = $lti_link->getLaunchSignature($this->launch_data); + $this->global_tools = LtiTool::findBySQL("`lti_version` = '1.3a' AND `range_id` = 'global' ORDER BY `name` ASC"); - $this->set_layout(null); + if (!$this->global_tools) { + if (!Config::get()->LTI_ALLOW_TOOL_CONFIG_IN_COURSE) { + PageLayout::postError(_('Es sind keine globalen LTI-Tools konfiguriert, die in dieser Veranstaltung eingebunden werden können.')); + return; + } + //Redirect to the page to configure an LTI tool for the course: + $this->redirect('lti/tool/add/' . $this->course->id); + } + + $this->selected_tool_id = ''; + if (count($this->global_tools) >= 1) { + //Preselect the first tool: + $this->selected_tool_id = $this->global_tools[0]->id; + } } - /** - * Edit the course settings. - */ - public function config_action() + public function select_tool_redirect_action() { - $this->title = CourseConfig::get($this->course_id)->LTI_TOOL_TITLE; + if (Request::isPost()) { + CSRFProtection::verifyUnsafeRequest(); + $selected_tool_id = Request::get('selected_tool_id'); + if ($selected_tool_id === 'new') { + //Redirect to the page to configure an LTI tool for the course: + $this->redirect('lti/tool/add/' . $this->course->id); + } else { + //Load the selected tool and check if it can be used in the course. + $selected_tool = LtiTool::find($selected_tool_id); + if (!$selected_tool || $selected_tool->range_id !== 'global') { + PageLayout::postError(_('Das ausgewählte LTI-Tool kann nicht genutzt werden.')); + $this->redirect('course/lti/select_tool'); + return; + } + $this->redirect('lti/tool/add/' . $this->course->id . '/' . $selected_tool->id); + } + } else { + $this->redirect('course/lti/select_tool'); + } } - /** - * Save the course settings. - */ - public function save_config_action() + public function consent_action(string $deployment_id) { - CSRFProtection::verifyUnsafeRequest(); + $this->deployment = LtiDeployment::find($deployment_id); + if (!$this->deployment) { + PageLayout::postError(_('Die Einbindung eines LTI-Tools ist ungültig.')); + return; + } - $title = trim(Request::get('title')); - CourseConfig::get($this->course_id)->store('LTI_TOOL_TITLE', $title); + $this->privacy_settings = LtiToolPrivacySettings::findOneBySQL( + 'tool_id = :tool_id AND user_id = :user_id', + ['tool_id' => $this->deployment->tool_id, 'user_id' => $GLOBALS['user']->id] + ); + if (!$this->privacy_settings) { + $this->privacy_settings = new LtiToolPrivacySettings(); + $this->privacy_settings->tool_id = $this->deployment->tool_id; + $this->privacy_settings->user_id = $GLOBALS['user']->id; + } - PageLayout::postSuccess(_('Die Einstellungen wurden gespeichert.')); - $this->redirect('course/lti'); + if (Request::isPost()) { + CSRFProtection::verifyUnsafeRequest(); + if (Request::submitted('save')) { + if (!Request::get('confirmed')) { + PageLayout::postError(_('Ohne die aktive Zustimmung zur Weitergabe Ihrer personenbezogenen Daten können Sie das LTI-Tool nicht nutzen!')); + return; + } + //Save the privacy settings and redirect to the tool: + $this->privacy_settings->accepted = '1'; + + //Check which optional fields are allowed to be transmitted to the tool: + $optional_field_list = Request::getArray('submit_optional_field', []); + $optional_fields = []; + if (array_key_exists('lang', $optional_field_list)) { + $optional_fields[] = 'lang'; + } + if (array_key_exists('avatar_url', $optional_field_list)) { + $optional_fields[] = 'avatar_url'; + } + $this->privacy_settings->allowed_optional_fields = implode(',', $optional_fields); + //Store the privacy settings: + $this->privacy_settings->store(); + } + if (Request::isDialog()) { + //Close the dialog: + $this->response->add_header('X-Dialog-Close', '1'); + } elseif (Request::submitted('redirect_to_tool') && Request::submitted('save')) { + //Redirect to the tool launch action, but only after the privacy settings have been saved: + $this->redirect('course/lti/iframe/' . $this->deployment->id); + } else { + //Redirect to the LTI tool page of the course: + $this->redirect('course/lti/index'); + } + } } /** - * Move an LTI content block (either up or down). - * - * @param int $position block position - * @param string $direction 'up' or 'down' + * Display the launch form for a tool as an iframe. */ - public function move_action($position, $direction) + public function iframe_action(string $deployment_id) { - CSRFProtection::verifyUnsafeRequest(); - - if ($direction === 'up') { - $position2 = $position - 1; - } else { - $position2 = $position + 1; + $this->deployment = LtiDeployment::find($deployment_id); + $this->show_data_protection_info = !LtiToolPrivacySettings::countBySQL( + "`tool_id` = :tool_id AND `user_id` = :user_id AND `accepted` = 1", + ['tool_id' => $this->deployment->tool_id, 'user_id' => $GLOBALS['user']->id] + ); + if ($this->show_data_protection_info) { + $this->redirect('course/lti/consent/' . $deployment_id, ['redirect_to_tool' => '1']); + return; } - $lti_data = LtiData::findByCourseAndPosition($this->course_id, $position); - $lti_data2 = LtiData::findByCourseAndPosition($this->course_id, $position2); - - if ($lti_data && $lti_data2) { - $lti_data->position = $position2; - $lti_data->store(); - - $lti_data2->position = $position; - $lti_data2->store(); + if (!$this->show_data_protection_info) { + //Redirect to the tool. + $this->lti13a_mode = false; + $lti_version = $this->deployment->getToolLtiVersion(); + if ($lti_version === '1.3a') { + //LTI 1.3a + $this->lti13a_mode = true; + + $lti_resource_link = new LtiResourceLink( + $this->deployment->tool_id . '_' . $this->deployment->id . '_' . $this->course_id, + [ + 'url' => $this->deployment->getLaunchURL(), + 'title' => $this->deployment->title + ] + ); + + $registration = new Registration($this->deployment->tool); + $builder = new LtiResourceLinkLaunchRequestBuilder(); + + //The AGS URLs need several parameters: + $ags_url_parameters = [ + 'cid' => $this->course_id, + 'tool_id' => $this->deployment->tool_id, + 'deployment_id' => $this->deployment->id, + 'cancel_login' => '1' + ]; + + //Build the message: + $this->message = $builder->buildLtiResourceLinkLaunchRequest( + $lti_resource_link, + $registration, + $GLOBALS['user']->id, + $this->deployment->id, + [ + PlatformManager::getLtiRoleClaimForStudipRole($GLOBALS['perm']->get_studip_perm($this->course_id)) + ], + array_merge( + [ + new ContextClaim( + $this->course_id, + ['http://purl.imsglobal.org/vocab/lis/v2/course#CourseOffering'], + $this->course->veranstaltungsnummer ?? '', + $this->course?->getFullName() ?? '' + ), + new AgsClaim( + [ + 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem', + 'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly', + 'https://purl.imsglobal.org/spec/lti-ags/scope/score' + ], + $this->url_for('lti/ags/line_items', $ags_url_parameters), + $this->url_for('lti/ags/line_item', $ags_url_parameters) + ) + ], + $this->deployment->getCustomLtiParameterArray(), + ) + ); + } else { + //LTI 1.0/1.1 + $lti_link = $this->getLtiLink($this->deployment); + $this->launch_url = $this->deployment->getLaunchURL(); + $this->launch_data = $lti_link->getBasicLaunchData(); + $this->signature = $lti_link->getLaunchSignature($this->launch_data); + } + $this->set_layout(null); } + } - $this->redirect('course/lti'); + /** + * Edit the course settings. + */ + public function config_action() + { + $course_config = CourseConfig::get($this->course_id); + $this->personal_data_warning = $course_config->LTI_DATA_PROTECTION_COURSE_WARNING; + if (empty($this->personal_data_warning)) { + $this->personal_data_warning = Config::get()->LTI_DATA_PROTECTION_DEFAULT_WARNING; + } } /** - * Edit an LTI content block (using a dialog window). - * - * @param int $position block position (blank: create a new block) + * Save the course settings. */ - public function edit_action($position = '') + public function save_config_action() { - $this->lti_data = new LtiData(); + CSRFProtection::verifyUnsafeRequest(); + + $course_config = CourseConfig::get($this->course_id); - if ($position !== '') { - $this->lti_data = LtiData::findByCourseAndPosition($this->course_id, $position); + if (Request::bool('reset_warning')) { + $course_config->delete('LTI_DATA_PROTECTION_COURSE_WARNING'); + } else { + $course_config->store( + 'LTI_DATA_PROTECTION_COURSE_WARNING', + trim(Request::get('personal_data_warning')) + ); } - $this->tools = LtiTool::findAll(); + PageLayout::postSuccess(_('Die Einstellungen wurden gespeichert.')); + $this->redirect('course/lti'); } /** - * Save an LTI content block. + * Moves an LTI deployment in a course either up or down. + * + * @param string $deployment_id The ID of the deployment to be moved. * - * @param int $position block position (blank: create a new block) + * @param string $direction 'up' for moving the deployment upwards or 'down' for downwards. */ - public function save_action($position) + public function move_action(string $deployment_id, string $direction) { CSRFProtection::verifyUnsafeRequest(); - if ($position !== '') { - $lti_data = LtiData::findByCourseAndPosition($this->course_id, $position); - } else { - $lti_data = new LtiData(); - $lti_data->course_id = $this->course_id; - $lti_data->position = LtiData::countBySQL('course_id = ?', [$this->course_id]); + $deployment = LtiDeployment::find($deployment_id); + if (!$deployment) { + //Redirect and do nothing: + $this->redirect('course/lti'); + return; } - $lti_data->title = trim(Request::get('title')); - $lti_data->description = Studip\Markup::purifyHtml(Request::get('description')); - $lti_data->tool_id = Request::int('tool_id'); + $new_position = 0; - if ($lti_data->tool_id == 0) { - $lti_data->launch_url = trim(Request::get('launch_url')); - $options['consumer_key'] = trim(Request::get('consumer_key')); - $options['consumer_secret'] = trim(Request::get('consumer_secret')); - $options['send_lis_person'] = Request::int('send_lis_person', 0); - $options['oauth_signature_method'] = Request::get('oauth_signature_method', 'sha1'); + if ($direction === 'up') { + $new_position = $deployment->position - 1; } else { - $lti_data->launch_url = trim(Request::get('custom_url')); + $new_position = $deployment->position + 1; } - $options['custom_parameters'] = trim(Request::get('custom_parameters')); - $options['document_target'] = Request::option('document_target', 'window'); - $lti_data->options = $options; - $lti_data->store(); + //Find the deployment with the new position: + $other_deployment = LtiDeployment::findByCourseAndPosition($this->course_id, $new_position); + if ($other_deployment) { + $other_deployment->position = $deployment->position; + $other_deployment->store(); + } + $deployment->position = $new_position; + $deployment->store(); - PageLayout::postSuccess(_('Der Abschnitt wurde gespeichert.')); $this->redirect('course/lti'); } @@ -217,8 +379,8 @@ class Course_LtiController extends StudipController { CSRFProtection::verifyUnsafeRequest(); - $lti_data = LtiData::findByCourseAndPosition($this->course_id, $position); - $lti_data->delete(); + $deployment = LtiDeployment::findByCourseAndPosition($this->course_id, $position); + $deployment->delete(); PageLayout::postSuccess(_('Der Abschnitt wurde gelöscht.')); $this->redirect('course/lti'); @@ -229,46 +391,149 @@ class Course_LtiController extends StudipController */ public function add_link_action() { - $this->tools = LtiTool::findByDeep_linking(1); + //The permission check is done in the before filter. + + $this->tools = LtiTool::findBySQL("`deep_linking` = '1' AND `range_id` = 'global' ORDER BY `name` ASC"); + if (!$this->tools) { + PageLayout::postError(_('Es sind keine globalen LTI-Tools konfiguriert.')); + return; + } } /** * Dispatch a ContentItemSelectionRequest to a specified LTI tool. */ - public function select_link_action() + public function select_link_action($deployment_id = '') { - $tool_id = Request::int('tool_id'); - $tool = LtiTool::find($tool_id); + $this->deployment = null; + if ($deployment_id) { + $this->deployment = LtiDeployment::find($deployment_id); + if (!$this->deployment) { + PageLayout::postError(_('Die Einbindung des LTI-Tools wurde nicht gefunden!')); + return; + } + if ($this->deployment->course_id !== $this->course_id) { + PageLayout::postError(_('Die Einbindung des LTI-Tools ist nicht für diese Veranstaltung bestimmt.')); + return; + } + if (empty($this->deployment->options['unfinished_deep_linking'])) { + PageLayout::postError(_('Die Einbindung des LTI-Tools ist bereits abgeschlossen.')); + return; + } + } - $custom_parameters = explode("\n", $tool->custom_parameters); - $content_item_return_url = $this->url_for('course/lti/save_link/' . $tool_id); + $this->tool = LtiTool::find(Request::int('tool_id')); + if (!$this->tool) { + PageLayout::postError(_('Das ausgewählte LTI-Tool wurde nicht gefunden.')); + $this->redirect('course/lti/add_link'); + return; + } + if (!$this->tool->deep_linking) { + PageLayout::postError(_('Das ausgewählte LTI-Tool unterstützt kein Deep Linking.')); + $this->redirect('course/lti/add_link'); + return; + } + } - // set up ContentItemSelectionRequest - $lti_link = new LtiLink($tool->launch_url, $tool->consumer_key, $tool->consumer_secret, $tool->oauth_signature_method); - $lti_link->setUser($GLOBALS['user']->id, 'Instructor', $tool->send_lis_person); - $lti_link->setCourse($this->course_id); - $lti_link->addLaunchParameters([ - 'lti_message_type' => 'ContentItemSelectionRequest', - 'accept_media_types' => 'application/vnd.ims.lti.v1.ltilink', - 'accept_presentation_document_targets' => 'iframe,window', - 'content_item_return_url' => $content_item_return_url, - 'launch_presentation_locale' => str_replace('_', '-', $_SESSION['_language']), - 'launch_presentation_document_target' => 'window' - ]); + public function process_select_link_action($deployment_id = '') + { + CSRFProtection::verifyUnsafeRequest(); - foreach ($custom_parameters as $param) { - if (strpos($param, '=') !== false) { - list($key, $value) = explode('=', $param, 2); - $lti_link->addCustomParameter(trim($key), trim($value)); + $this->deployment = null; + if ($deployment_id) { + $this->deployment = LtiDeployment::find($deployment_id); + if (!$this->deployment) { + PageLayout::postError(_('Die Einbindung des LTI-Tools wurde nicht gefunden!')); + return; + } + if ($this->deployment->course_id !== $this->course_id) { + PageLayout::postError(_('Die Einbindung des LTI-Tools ist nicht für diese Veranstaltung bestimmt.')); + return; } + if (empty($this->deployment->options['unfinished_deep_linking'])) { + PageLayout::postError(_('Die Einbindung des LTI-Tools ist bereits abgeschlossen.')); + return; + } + } + + $this->tool = null; + if ($this->deployment) { + $this->tool = $this->deployment->tool; + } else { + $this->tool = LtiTool::find(Request::int('tool_id')); + } + if (!$this->tool) { + PageLayout::postError(_('Das ausgewählte LTI-Tool wurde nicht gefunden.')); + $this->redirect('course/lti/add_link'); + return; } + if (!$this->tool->deep_linking) { + PageLayout::postError(_('Das ausgewählte LTI-Tool unterstützt kein Deep Linking.')); + $this->redirect('course/lti/add_link'); + return; + } + + if ($this->tool->lti_version === '1.3a') { + //LTI 1.3a + if ($this->deployment) { + $builder = new DeepLinkingLaunchRequestBuilder(); + $message = $builder->buildDeepLinkingLaunchRequest( + PlatformManager::getDeepLinkingConfiguration($this->tool->id), + new Registration($this->deployment->tool), + $GLOBALS['user']->id, + null, + $this->deployment->id, + [PlatformManager::getLtiRoleClaimForStudipRole($GLOBALS['perm']->get_studip_perm($this->course_id))] + ); + $this->render_text($message->toHtmlRedirectForm()); + } else { + //Build an LTI deployment object and mark it as not configured + //so that it can be displayed differently in the UI. + $this->deployment = new LtiDeployment(); + $this->deployment->tool_id = $this->tool->id; + $this->deployment->course_id = $this->course_id; + $this->deployment->title = $this->tool->name; + $this->deployment->options = ['unfinished_deep_linking' => true]; + if ($this->deployment->store() !== false) { + //Display the tool deployment data so that the user can enter + //them in the LTI tool. + PageLayout::postInfo( + _('Bitte tragen Sie die Daten zur Einbindung im LTI-Tool ein bevor sie fortfahren.') + ); + } + } + } else { + //LTI 1.0/1.1 + $custom_parameters = explode("\n", $this->tool->custom_parameters); + $content_item_return_url = $this->url_for('course/lti/save_link/' . $this->tool->id); + + // set up ContentItemSelectionRequest + $lti_link = new LtiLink($this->tool->launch_url, $this->tool->consumer_key, $this->tool->consumer_secret, $this->tool->oauth_signature_method); + $lti_link->setUser($GLOBALS['user']->id, 'Instructor', $this->tool->send_lis_person); + $lti_link->setCourse($this->course_id); + $lti_link->addLaunchParameters([ + 'lti_message_type' => 'ContentItemSelectionRequest', + 'accept_media_types' => 'application/vnd.ims.lti.v1.ltilink', + 'accept_presentation_document_targets' => 'iframe,window', + 'content_item_return_url' => $content_item_return_url, + 'launch_presentation_locale' => str_replace('_', '-', $_SESSION['_language']), + 'launch_presentation_document_target' => 'window' + ]); + + foreach ($custom_parameters as $param) { + if (strpos($param, '=') !== false) { + list($key, $value) = explode('=', $param, 2); + $lti_link->addCustomParameter(trim($key), trim($value)); + } + } - $this->launch_url = $lti_link->getLaunchURL(); - $this->launch_data = $lti_link->getBasicLaunchData(); - $this->signature = $lti_link->getLaunchSignature($this->launch_data); + $this->launch_url = $lti_link->getLaunchURL(); + $this->launch_data = $lti_link->getBasicLaunchData(); + $this->signature = $lti_link->getLaunchSignature($this->launch_data); - $this->set_layout(null); - $this->render_action('iframe'); + $this->set_layout(null); + $this->render_action('iframe'); + } } /** @@ -279,43 +544,103 @@ class Course_LtiController extends StudipController public function save_link_action($tool_id) { $tool = LtiTool::find($tool_id); - $lti_msg = Request::get('lti_msg'); - $lti_errormsg = Request::get('lti_errormsg'); - $content_items = Request::get('content_items'); - $content_items = json_decode($content_items, true); - if (!Studip\OAuth1::verifyRequest($this->getPsrRequest(), $tool->consumer_secret, '')) { - throw new Exception('Could not verify request.'); + if (!$tool) { + PageLayout::postError(_('Das ausgewählte LTI-Tool wurde nicht gefunden.')); + $this->redirect('course/lti/add_link'); + return; + } + if (!$tool->deep_linking) { + PageLayout::postError(_('Das ausgewählte LTI-Tool unterstützt kein Deep Linking.')); + $this->redirect('course/lti/add_link'); + return; } - if (is_array($content_items) && count($content_items['@graph'])) { - // we only support selecting a single content item - $item = $content_items['@graph'][0]; - - $lti_data = new LtiData(); - $lti_data->course_id = $this->course_id; - $lti_data->position = LtiData::countBySQL('course_id = ?', [$this->course_id]); - $lti_data->title = (string) $item['title']; - $lti_data->description = Studip\Markup::purifyHtml(Studip\Markup::markAsHtml($item['text'])); - $lti_data->tool_id = $tool_id; - $lti_data->launch_url = (string) ($item['url'] ?? ''); - $options = []; - if (is_array($item['custom'])) { - $custom_parameters = ''; - foreach ($item['custom'] as $key => $value) { - $custom_parameters .= $key . '=' . $value . "\n"; - } - $options['custom_parameters'] = $custom_parameters; + if ($tool->lti_version === '1.3a') { + //LTI 1.3a + + $validator = new PlatformLaunchValidator( + new RegistrationManager(), + new NonceRepository(Studip\Cache\Factory::getCache()) + ); + $result = $validator->validateToolOriginatingLaunch($this->getPsrRequest()); + if ($result->hasError()) { + PageLayout::postError($result->getError()); + $this->redirect('course/lti/add_link'); + return; } + $all_lti_resources = (new ResourceCollectionFactory())->createFromClaim( + $result->getPayload()->getDeepLinkingContentItems() + ); - if (isset($item['placementAdvice']['presentationDocumentTarget'])) { - $options['document_target'] = $item['placementAdvice']['presentationDocumentTarget']; + $lti_resource_links = $all_lti_resources->getByType(LtiResourceLinkInterface::TYPE); + if (count($lti_resource_links) > 0) { + $use_first_link = true; + foreach ($lti_resource_links as $lti_resource_link) { + $deployment = null; + if ($use_first_link) { + //Recycle the deployment that has been created before + //for the course. + $deployment = LtiDeployment::findOneBySQL( + "`tool_id` = :tool_id AND `course_id` = :course_id + AND `options` LIKE '%unfinished_deep_linking%=%true'" + ); + $use_first_link = false; + } + if (!$deployment) { + //If this is the first link, the deployment has been removed. + //In that case and if it is not the first link, a new deployment + //has to be created. + $deployment = new LtiDeployment(); + $deployment->tool_id = $tool->id; + $deployment->title = $tool->name; + $deployment->course_id = $this->course_id; + } + $deployment->launch_url = $lti_resource_link->getUrl(); + if (!empty($deployment->options['unfinished_deep_linking'])) { + unset($deployment->options['unfinished_deep_linking']); + } + $deployment->store(); + } } + } else { + $lti_msg = Request::get('lti_msg'); + $lti_errormsg = Request::get('lti_errormsg'); + $content_items = Request::get('content_items'); + $content_items = json_decode($content_items, true); + + if (!Studip\OAuth1::verifyRequest($this->getPsrRequest(), $tool->consumer_secret, '')) { + throw new Exception('Could not verify request.'); + } - $lti_data->options = $options; - $lti_data->store(); + if (is_array($content_items) && count($content_items['@graph'])) { + // we only support selecting a single content item + $item = $content_items['@graph'][0]; + + $lti_data = new LtiDeployment(); + $lti_data->course_id = $this->course_id; + $lti_data->position = LtiDeployment::countBySQL('course_id = ?', [$this->course_id]); + $lti_data->title = (string) $item['title']; + $lti_data->description = Studip\Markup::purifyHtml(Studip\Markup::markAsHtml($item['text'])); + $lti_data->tool_id = $tool_id; + $lti_data->launch_url = (string) ($item['url'] ?? ''); + $options = []; + if (is_array($item['custom'])) { + $custom_parameters = ''; + foreach ($item['custom'] as $key => $value) { + $custom_parameters .= $key . '=' . $value . "\n"; + } + $options['custom_parameters'] = $custom_parameters; + } + + if (isset($item['placementAdvice']['presentationDocumentTarget'])) { + $options['document_target'] = $item['placementAdvice']['presentationDocumentTarget']; + } - PageLayout::postSuccess($lti_msg ?: _('Der Link wurde als neuer Abschnitt hinzugefügt.')); + $lti_data->options = $options; + $lti_data->store(); + PageLayout::postSuccess($lti_msg ?: _('Der Link wurde als neuer Abschnitt hinzugefügt.')); + } } if ($lti_errormsg) { @@ -328,7 +653,7 @@ class Course_LtiController extends StudipController /** * Return an LtiLink object for the configured LTI content block. * - * @param LtiData $lti_data data of LTI content block + * @param LtiDeployment $lti_data data of LTI content block * * @return LtiLink LTI link representation */ @@ -455,7 +780,7 @@ class Course_LtiController extends StudipController */ public function outcome_action($id) { - $lti_data = LtiData::find($id); + $lti_data = LtiDeployment::find($id); if (!Studip\OAuth1::verifyRequest($this->getPsrRequest(), $lti_data->getConsumerSecret(), '')) { throw new Exception('Could not verify request.'); @@ -509,13 +834,22 @@ class Course_LtiController extends StudipController } /** - * Display the (simple) LTI gradebook. + * Display the (simple) LTI grade book. */ public function grades_action() { Navigation::activateItem('/course/lti/grades'); - $this->lti_data_array = LtiData::findByCourse_id($this->course_id, 'ORDER BY position'); + if ($this->edit_perm) { + $this->lti_data_array = LtiDeployment::findByCourse_id($this->course_id, 'ORDER BY position'); + } else { + //Only load those deployments that are fully configured: + $this->lti_data_array = LtiDeployment::findBySQL( + "`course_id` = :course_id AND (`options` IS NULL OR `options` NOT LIKE '%unfinished_deep_linking%') + ORDER BY `position`", + ['course_id' => $this->course_id] + ); + } if ($this->edit_perm) { $this->desc = Request::int('desc'); @@ -543,7 +877,16 @@ class Course_LtiController extends StudipController */ public function export_grades_action() { - $lti_data_array = LtiData::findByCourse_id($this->course_id, 'ORDER BY position'); + if ($this->edit_perm) { + $lti_data_array = LtiDeployment::findByCourse_id($this->course_id, 'ORDER BY position'); + } else { + //Only load those deployments that are fully configured: + $lti_data_array = LtiDeployment::findBySQL( + "`course_id` = :course_id AND (`options` IS NULL OR `options` NOT LIKE '%unfinished_deep_linking%') + ORDER BY `position`", + ['course_id' => $this->course_id] + ); + } $columns = [_('Nachname'), _('Vorname')]; diff --git a/app/controllers/lti.php b/app/controllers/lti.php deleted file mode 100644 index 82d9840fb81b0f281cfd9070240661d74de0de56..0000000000000000000000000000000000000000 --- a/app/controllers/lti.php +++ /dev/null @@ -1,127 +0,0 @@ -<?php -/** - * lti.php - LTI 1.1 single sign on controller - * - * This program 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 2 of - * the License, or (at your option) any later version. - * - * @author Elmar Ludwig - * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 - * @category Stud.IP - */ - -class LtiController extends AuthenticatedController -{ - /** - * Callback function being called before an action is executed. - */ - public function before_filter(&$action, &$args) - { - // enforce LTI SSO login - Request::set('sso', 'lti'); - - parent::before_filter($action, $args); - } - - /** - * Redirect to enrollment action for the given course, if needed. - */ - public function index_action($course_id = null) - { - $course_id = Request::option('custom_cid', $course_id); - $course_id = Request::option('custom_course', $course_id); - $message_type = Request::option('lti_message_type'); - - if ($message_type === 'ContentItemSelectionRequest') { - $_SESSION['ContentItemSelection'] = [ - 'oauth_consumer_key' => Request::get('oauth_consumer_key'), - 'content_item_return_url' => Request::get('content_item_return_url'), - 'document_targets' => Request::get('accept_presentation_document_targets'), - 'data' => Request::get('data') - ]; - $this->redirect('lti/content_item'); - } else if ($course_id) { - $this->redirect('course/enrolment/apply/' . $course_id); - } else { - $this->redirect('start'); - } - } - - /** - * Select course for ContentItemSelectionRequest message. - */ - public function content_item_action() - { - PageLayout::setTitle(_('Veranstaltung verknüpfen')); - Navigation::activateItem('/browse/my_courses/content_item'); - - $this->document_targets = $_SESSION['ContentItemSelection']['document_targets']; - $this->target_labels = [ - 'embed' => _('in Seite einbetten'), - 'frame' => _('gleiches Fenster oder Tab'), - 'iframe' => _('IFrame in der Seite'), - 'window' => _('neues Fenster oder Tab'), - 'popup' => _('Popup-Fenster'), - 'overlay' => _('Dialog'), - 'none' => _('nicht anzeigen') - ]; - - $sql = "JOIN seminar_user USING(Seminar_id) - LEFT JOIN semester_courses sc ON seminare.seminar_id = sc.course_id - LEFT JOIN semester_data s USING (semester_id) - WHERE user_id = ? AND seminar_user.status IN ('dozent', 'tutor') - ORDER BY s.beginn DESC, Name"; - $this->courses = Course::findBySQL($sql, [$GLOBALS['user']->id]); - } - - /** - * Return the selected content item to the LTI consumer. - */ - public function link_content_item_action() - { - CSRFProtection::verifyUnsafeRequest(); - $course_id = Request::option('course_id'); - $target = Request::option('target'); - $course = Course::find($course_id); - - $consumer_key = $_SESSION['ContentItemSelection']['oauth_consumer_key']; - $return_url = $_SESSION['ContentItemSelection']['content_item_return_url']; - $data = $_SESSION['ContentItemSelection']['data']; - unset($_SESSION['ContentItemSelection']); - - $consumer_config = $GLOBALS['STUDIP_AUTH_CONFIG_LTI']['consumer_keys'][$consumer_key]; - $consumer_secret = $consumer_config['consumer_secret']; - $signature_method = $consumer_config['signature_method'] ?? 'sha1'; - - $content_items = [ - '@context' => 'http://purl.imsglobal.org/ctx/lti/v1/ContentItem', - '@graph' => [] - ]; - - if (Request::submitted('link')) { - $content_items['@graph'][] = [ - '@type' => 'LtiLinkItem', - 'mediaType' => 'application/vnd.ims.lti.v1.ltilink', - 'title' => $course->name, - 'text' => $course->beschreibung, - 'placementAdvice' => ['presentationDocumentTarget' => $target], - 'custom' => ['course' => $course_id] - ]; - } - - // set up ContentItemSelection - $lti_link = new LtiLink($return_url, $consumer_key, $consumer_secret, $signature_method); - $lti_link->addLaunchParameters([ - 'lti_message_type' => 'ContentItemSelection', - 'content_items' => json_encode($content_items), - 'data' => $data - ]); - - $this->launch_url = $lti_link->getLaunchURL(); - $this->launch_data = $lti_link->getBasicLaunchData(); - $this->signature = $lti_link->getLaunchSignature($this->launch_data); - $this->render_template('course/lti/iframe'); - } -} diff --git a/app/controllers/lti/ags.php b/app/controllers/lti/ags.php new file mode 100644 index 0000000000000000000000000000000000000000..be4aed7160796bde547e605aa8a7ba4229b54b44 --- /dev/null +++ b/app/controllers/lti/ags.php @@ -0,0 +1,106 @@ +<?php + +use Studip\LTI13a\LineItemRepository; +use Studip\LTI13a\RegistrationManager; +use OAT\Library\Lti1p3Core\Security\OAuth2\Validator\RequestAccessTokenValidator; +use OAT\Library\Lti1p3Ags\Service\LineItem\Server\Handler\UpdateLineItemServiceServerRequestHandler; +use OAT\Library\Lti1p3Ags\Service\LineItem\Server\Handler\DeleteLineItemServiceServerRequestHandler; +use OAT\Library\Lti1p3Ags\Service\LineItem\Server\Handler\GetLineItemServiceServerRequestHandler; +use OAT\Library\Lti1p3Ags\Service\Result\Server\Handler\ResultServiceServerRequestHandler; +use OAT\Library\Lti1p3Ags\Service\Score\Server\Handler\ScoreServiceServerRequestHandler; +use OAT\Library\Lti1p3Ags\Service\LineItem\Server\Handler\CreateLineItemServiceServerRequestHandler; +use OAT\Library\Lti1p3Ags\Service\LineItem\Server\Handler\ListLineItemsServiceServerRequestHandler; +use OAT\Library\Lti1p3Core\Service\Server\LtiServiceServer; + +/** + * ags.php - LTI assignment and grade services controller + * + * This program 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 2 of + * the License, or (at your option) any later version. + * + * @author Moritz Strohm + * @date 2024 + * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 + * @category Stud.IP + */ + + +class Lti_AgsController extends StudipController +{ + use \Studip\OAuth2\NegotiatesWithPsr7; + + public function __construct(\Trails\Dispatcher $dispatcher) + { + $this->with_session = true; + parent::__construct($dispatcher); + } + + public function before_filter(&$action, &$args) + { + parent::before_filter($action, $args); + + //All the work is done by the OAT-SA library and + //the implementation of its interfaces in Stud.IP. + //Only the handler changes for the endpoints. + $reg_manager = new RegistrationManager(); + $line_item_repo = new LineItemRepository(); + $validator = new RequestAccessTokenValidator($reg_manager); + $handler = null; + if ($action === 'line_item') { + if (empty($args)) { + if (Request::isPut()) { + //Update a line item: + $handler = new UpdateLineItemServiceServerRequestHandler($line_item_repo); + } elseif (Request::isDelete()) { + //Delete a line item: + $handler = new DeleteLineItemServiceServerRequestHandler($line_item_repo); + } else { + //Get a line item: + $handler = new GetLineItemServiceServerRequestHandler($line_item_repo); + } + } elseif ($args[0] === 'results') { + $handler = new ResultServiceServerRequestHandler($line_item_repo, new Studip\LTI13a\ResultRepository()); + } elseif ($args[0] === 'scores') { + $handler = new ScoreServiceServerRequestHandler($line_item_repo,new \Studip\LTI13a\ScoreRepository()); + } + } elseif ($action === 'line_items') { + if (Request::isPost()) { + //Create a line item: + $handler = new CreateLineItemServiceServerRequestHandler($line_item_repo); + } else { + //List line items: + $handler = new ListLineItemsServiceServerRequestHandler($line_item_repo); + } + } else { + //Invalid endpoint. + throw new AccessDeniedException(studip_interpolate('Invalid endpoint: %{endpoint}', ['endpoint' => $action])); + } + if (!$handler) { + throw new \Studip\LTIException('No handler available for this request.'); + } + $server = new LtiServiceServer($validator, $handler); + $this->renderPsrResponse($server->handle($this->getPsrRequest())); + } + + /** + * This is the endpoint for the LTI AGS lineitem service. + * + * @return void + */ + public function line_item_action(): void + { + //Nothing here. All is done in the before_filter. + } + + /** + * This is the endpoint for the LTI AGS lineitems service. + * + * @return void + */ + public function line_items_action(): void + { + //Nothing here. All is done in the before_filter. + } +} diff --git a/app/controllers/lti/auth.php b/app/controllers/lti/auth.php new file mode 100644 index 0000000000000000000000000000000000000000..07b6096647ba4173d5ae00b165f3ed9eb0ba0d3c --- /dev/null +++ b/app/controllers/lti/auth.php @@ -0,0 +1,250 @@ +<?php + +use OAT\Library\Lti1p3Core\Message\Payload\Builder\MessagePayloadBuilder; +use OAT\Library\Lti1p3Core\Security\Jwks\Exporter\JwksExporter; +use OAT\Library\Lti1p3Core\Security\Jwks\Server\JwksRequestHandler; +use OAT\Library\Lti1p3Core\Security\Key\KeyChainRepository; +use OAT\Library\Lti1p3Core\Security\OAuth2\Factory\AuthorizationServerFactory; +use OAT\Library\Lti1p3Core\Security\OAuth2\Generator\AccessTokenResponseGenerator; +use OAT\Library\Lti1p3Core\Security\OAuth2\Repository\AccessTokenRepository; +use OAT\Library\Lti1p3Core\Security\OAuth2\Repository\ClientRepository; +use OAT\Library\Lti1p3Core\Security\OAuth2\Repository\ScopeRepository; +use OAT\Library\Lti1p3Core\Security\Oidc\OidcAuthenticator; +use OAT\Library\Lti1p3Core\Security\Oidc\Server\OidcAuthenticationRequestHandler; +use Studip\Cache\Factory; +use Studip\LTI13a\KeyManager; +use Studip\LTI13a\NonceGenerator; +use Studip\LTI13a\PlatformManager; +use Studip\LTI13a\RegistrationManager; +use Studip\LTI13a\UserAuthenticator; +use Studip\OAuth2\Bridge\ScopeEntity; + +/** + * auth.php - LTI authentication controller + * + * This program 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 2 of + * the License, or (at your option) any later version. + * + * @author Elmar Ludwig + * @author Moritz Strohm + * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 + * @category Stud.IP + */ + +class Lti_AuthController extends StudipController +{ + use Studip\OAuth2\NegotiatesWithPsr7; + + public function __construct(\Trails\Dispatcher $dispatcher) + { + $this->allow_nobody = false; + $action = basename(get_route()); + if (in_array($action, ['jwks', 'oauth2_token'])) { + $this->allow_nobody = true; + $this->with_session = $action !== 'jwks'; + } + parent::__construct($dispatcher); + } + + /** + * Callback function being called before an action is executed. + */ + public function before_filter(&$action, &$args) + { + if (in_array($action, ['index', 'content_item', 'link_content_item'])) { + // enforce LTI SSO login + Request::set('sso', 'lti'); + } + parent::before_filter($action, $args); + } + + /** + * Redirect to enrolment action for the given course, if needed. + */ + public function index_action($course_id = null) + { + $course_id = Request::option('custom_cid', $course_id); + $course_id = Request::option('custom_course', $course_id); + $message_type = Request::option('lti_message_type'); + + if ($message_type === 'ContentItemSelectionRequest') { + $_SESSION['ContentItemSelection'] = [ + 'oauth_consumer_key' => Request::get('oauth_consumer_key'), + 'content_item_return_url' => Request::get('content_item_return_url'), + 'document_targets' => Request::get('accept_presentation_document_targets'), + 'data' => Request::get('data') + ]; + $this->redirect('lti/content_item'); + } else if ($course_id) { + $this->redirect('course/enrolment/apply/' . $course_id); + } else { + $this->redirect('start'); + } + } + + /** + * Select course for ContentItemSelectionRequest message. + */ + public function content_item_action() + { + PageLayout::setTitle(_('Veranstaltung verknüpfen')); + Navigation::activateItem('/browse/my_courses/content_item'); + + $this->document_targets = $_SESSION['ContentItemSelection']['document_targets']; + $this->target_labels = [ + 'embed' => _('in Seite einbetten'), + 'frame' => _('gleiches Fenster oder Tab'), + 'iframe' => _('IFrame in der Seite'), + 'window' => _('neues Fenster oder Tab'), + 'popup' => _('Popup-Fenster'), + 'overlay' => _('Dialog'), + 'none' => _('nicht anzeigen') + ]; + + $sql = "JOIN seminar_user USING(Seminar_id) + LEFT JOIN semester_courses sc ON seminare.seminar_id = sc.course_id + LEFT JOIN semester_data s USING (semester_id) + WHERE user_id = ? AND seminar_user.status IN ('dozent', 'tutor') + ORDER BY s.beginn DESC, Name"; + $this->courses = Course::findBySQL($sql, [$GLOBALS['user']->id]); + } + + /** + * Return the selected content item to the LTI consumer. + */ + public function link_content_item_action() + { + CSRFProtection::verifyUnsafeRequest(); + $course_id = Request::option('course_id'); + $target = Request::option('target'); + $course = Course::find($course_id); + + $consumer_key = $_SESSION['ContentItemSelection']['oauth_consumer_key']; + $return_url = $_SESSION['ContentItemSelection']['content_item_return_url']; + $data = $_SESSION['ContentItemSelection']['data']; + unset($_SESSION['ContentItemSelection']); + + $consumer_config = $GLOBALS['STUDIP_AUTH_CONFIG_LTI']['consumer_keys'][$consumer_key]; + $consumer_secret = $consumer_config['consumer_secret']; + $signature_method = $consumer_config['signature_method'] ?? 'sha1'; + + $content_items = [ + '@context' => 'http://purl.imsglobal.org/ctx/lti/v1/ContentItem', + '@graph' => [] + ]; + + if (Request::submitted('link')) { + $content_items['@graph'][] = [ + '@type' => 'LtiLinkItem', + 'mediaType' => 'application/vnd.ims.lti.v1.ltilink', + 'title' => $course->name, + 'text' => $course->beschreibung, + 'placementAdvice' => ['presentationDocumentTarget' => $target], + 'custom' => ['course' => $course_id] + ]; + } + + // set up ContentItemSelection + $lti_link = new LtiLink($return_url, $consumer_key, $consumer_secret, $signature_method); + $lti_link->addLaunchParameters([ + 'lti_message_type' => 'ContentItemSelection', + 'content_items' => json_encode($content_items), + 'data' => $data + ]); + + $this->launch_url = $lti_link->getLaunchURL(); + $this->launch_data = $lti_link->getBasicLaunchData(); + $this->signature = $lti_link->getLaunchSignature($this->launch_data); + $this->render_template('course/lti/iframe'); + } + + /** + * This action handles OIDC (OpenID connect) requests. + * + * @return void + */ + public function oidc_init_action(): void + { + $reg_manager = new RegistrationManager(); + $user_authenticator = new UserAuthenticator(); + $request = $this->getPsrRequest(); + + $oidc_handler = new OidcAuthenticationRequestHandler( + new OidcAuthenticator( + $reg_manager, + $user_authenticator, + //The following is necessary due to a library bug. + //See: https://github.com/oat-sa/lib-lti1p3-core/issues/154 + new MessagePayloadBuilder(new NonceGenerator(true)) + ) + ); + $response = $oidc_handler->handle($request); + $this->renderPsrResponse($response); + } + + /** + * This action handles JSON web key set (JWKS) requests for the platform key. + * + * @return void + */ + public function jwks_action(): void + { + $repo = new KeyChainRepository(); + $keyring = Keyring::findOneBySQL("`range_type` = 'global' AND `range_id` = 'lti13a_platform'"); + if ($keyring) { + $repo->addKeyChain($keyring->toKeyChain()); + } + $handler = new JwksRequestHandler(new JwksExporter($repo)); + $response = $handler->handle('lti13a_platform'); + $this->renderPsrResponse($response); + } + + /** + * Generates OAuth2 tokens for LTI tools. + */ + public function oauth2_token_action(): void + { + $keyring = Keyring::findOneByRange_id('lti13a_platform'); + if (!$keyring) { + throw new \Studip\Exception( + 'Stud.IP LTI 1.3a platform keyring cannot be found!' + ); + } + $key_chain = $keyring->toKeyChain(); + $response_generator = new AccessTokenResponseGenerator( + new KeyManager(), + new AuthorizationServerFactory( + new ClientRepository(new RegistrationManager()), + new AccessTokenRepository(Factory::getCache()), + new ScopeRepository( + [ + new ScopeEntity('https://purl.imsglobal.org/spec/lti-ags/scope/lineitem'), + new ScopeEntity('https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly'), + new ScopeEntity('https://purl.imsglobal.org/spec/lti-ags/scope/score') + ] + ), + $key_chain->getPrivateKey()->getContent() + ) + ); + + $response = $response_generator->generate( + $this->getPsrRequest(), + $this->getPsrResponse(), + 'lti13a_platform' + ); + + $this->renderPsrResponse($response); + } + + /** + * Displays LTI platform data of the Stud.IP installation. The data are needed for configuring the + * platform on the tool side. + */ + public function platform_data_action() + { + $this->platform = PlatformManager::getPlatformConfiguration(); + $this->render_template('lti/_platform_data'); + } +} diff --git a/app/controllers/lti/tool.php b/app/controllers/lti/tool.php new file mode 100644 index 0000000000000000000000000000000000000000..0e7077e9b0927477ebae94b7cd461d3b1543320c --- /dev/null +++ b/app/controllers/lti/tool.php @@ -0,0 +1,236 @@ +<?php + +class Lti_ToolController extends AuthenticatedController +{ + public function before_filter(&$action, &$args) + { + parent::before_filter($action, $args); + + $this->tool = null; + $this->deployment = null; + $this->tool_id = ''; + $this->range_id = ''; + + if (in_array($action, ['index', 'add', 'edit', 'delete'])) { + $this->range_id = $args[0]; + $this->tool_id = $args[1] ?? ''; + + if ($action === 'add' && !$this->tool_id) { + $this->tool = new LtiTool(); + $this->tool->range_id = $this->range_id; + } else { + if (!$this->tool_id) { + PageLayout::postError(_('Es wurde kein LTI-Tool angegeben.')); + return; + } + $this->tool = LtiTool::find($this->tool_id); + if (!$this->tool) { + throw new \Studip\Exception(_('Das angegebene LTI-Tool wurde nicht gefunden.')); + } + } + } + } + + public function index_action($range_id, $tool_id): void + { + //$this->tool is created in the before-filter. + if ($this->range_id !== 'global') { + $this->deployment = LtiDeployment::findOneBySQL( + '`tool_id` = :tool_id AND `course_id` = :range_id', + ['tool_id' => $this->tool->id, 'range_id' => $this->range_id] + ); + } + } + + public function add_action($range_id, $tool_id = ''): void + { + //NOTE: The parameters are checked and processed in the before_filter. + $this->addEditHandler(); + } + + public function edit_action($range_id, $tool_id): void + { + //NOTE: The parameters are checked and processed in the before_filter. + $this->addEditHandler(); + } + + protected function addEditHandler(): void + { + if (!$this->tool) { + return; + } + $this->deployment = null; + if ($this->tool->isNew()) { + if (!Config::get()->LTI_ALLOW_TOOL_CONFIG_IN_COURSE && $this->range_id !== 'global') { + throw new AccessDeniedException( + _('Die Einrichtung von LTI-Tools in Veranstaltungen ist ausgeschaltet.') + ); + } + if ($this->tool->range_id === 'global' && !$this->tool->isEditableByUser()) { + throw new AccessDeniedException(); + } + PageLayout::postWarning(_('Bitte beachten Sie das geltende europäische Datenschutzrecht (DSGVO)!')); + if ($this->tool->range_id !== 'global') { + $this->deployment = new LtiDeployment(); + $this->deployment->course_id = $this->tool->range_id; + } + } elseif ($this->range_id !== 'global') { + $this->deployment = LtiDeployment::findOneBySQL( + '`tool_id` = :tool_id AND `course_id` = :range_id', + ['tool_id' => $this->tool->id, 'range_id' => $this->range_id] + ); + if (!$this->deployment) { + //Create a new deployment: + $this->deployment = new LtiDeployment(); + $this->deployment->tool_id = $this->tool->id; + $this->deployment->course_id = $this->range_id; + } + } + + if (Request::isPost()) { + $this->saveTool(); + } + } + + /** + * Handles the saving of a tool. + */ + protected function saveTool(): void + { + CSRFProtection::verifyUnsafeRequest(); + if ($this->range_id === 'global') { + //The admin page for editing global tools. + $this->tool->name = trim(Request::get('name')); + $this->tool->launch_url = trim(Request::get('launch_url')); + } else { + //The page for editing tools configured in courses. + $this->deployment->title = trim(Request::get('name')); + $this->deployment->description = trim(Request::get('description')); + $this->deployment->launch_url = trim(Request::get('launch_url')); + $document_target = trim(Request::get('document_target')); + if ($document_target === 'iframe') { + if (!is_array($this->deployment->options)) { + $this->deployment->options = []; + } + $this->deployment->options['document_target'] = $document_target; + } elseif (isset($this->deployment->options['document_target'])) { + unset($this->deployment->options['document_target']); + } + } + + //If a deployment is present, the tool is not used in the global context. + //If a tool is not used in the global context and the range_id is not set to "global", + //it is a tool that is only used for one course. + if ( + !$this->deployment + || $this->tool->range_id !== 'global' + || $GLOBALS['perm']->have_perm('root') + ) { + $this->tool->name = trim(Request::get('name')); + $this->tool->launch_url = trim(Request::get('launch_url')); + $this->tool->terms_of_use_url = trim(Request::get('terms_of_use_url')); + $this->tool->privacy_policy_url = trim(Request::get('privacy_policy_url')); + $this->tool->data_protection_notes = trim(Request::get('data_protection_notes')); + $this->tool->lti_version = Request::get('lti_version', '1.3a'); + if ($this->tool->lti_version === '1.3a') { + $this->tool->oauth_signature_method = 'sha256'; + $this->tool->oidc_init_url = trim(Request::get('oidc_init_url')); + $this->tool->jwks_url = trim(Request::get('jwks_url')); + $this->tool->jwks_key_id = trim(Request::get('jwks_key_id')); + $this->tool->deep_linking_url = trim(Request::get('deep_linking_url')); + $this->tool->deep_linking = (bool) $this->tool->deep_linking_url; + } else { + //LTI 1.0/1.1: + $this->tool->oauth_signature_method = 'sha1'; + $this->tool->consumer_key = trim(Request::get('consumer_key')); + $this->tool->consumer_secret = trim(Request::get('consumer_secret')); + } + $this->tool->send_lis_person = Request::int('send_lis_person', 0); + $this->tool->custom_parameters = trim(Request::get('custom_parameters')); + $tool_public_key = trim(Request::get('tool_public_key')); + $errors = $this->tool->validate(); + if ($errors) { + PageLayout::postError( + _('Die folgenden Daten zum LTI-Tool sind fehlerhaft:'), + array_map('htmlReady', $errors) + ); + return; + } + if ($this->tool->lti_version === '1.3a' && !$tool_public_key && !$this->tool->jwks_url) { + PageLayout::postError( + _('Es wurde weder ein öffentlicher Schlüssel noch eine JWKS-URL zum Tool angegeben.') + ); + return; + } + if ($this->tool->store() !== false) { + if ($this->deployment) { + $this->deployment->tool_id = $this->tool->id; + } + } else { + PageLayout::postError(_('Das LTI-Tool konnte nicht gespeichert werden.')); + return; + } + } + if ($this->deployment) { + $this->deployment->store(); + } + if ($this->tool->lti_version === '1.3a' && $tool_public_key) { + if (!$this->tool->updatePublicKey($tool_public_key)) { + PageLayout::postError( + _('Der öffentliche Schlüssel des LTI-Tools konnte nicht gespeichert werden.') + ); + } + } + + PageLayout::postSuccess(_('Das LTI-Tool wurde gespeichert.')); + if (Request::isDialog()) { + $this->response->add_header('X-Dialog-Close', '1'); + $this->render_nothing(); + } elseif ($this->range_id === 'global') { + $this->redirect('admin/lti'); + } else { + $this->redirect('course/lti'); + } + } + + public function delete_action($range_id, $tool_id): void + { + //NOTE: The parameters are checked and processed in the before_filter. + CSRFProtection::verifyUnsafeRequest(); + $deleted = false; + $tool_name = $this->tool->name; + if ($this->tool->range_id === 'global') { + if ($range_id === 'global') { + $deleted = $this->tool->delete(); + } else { + //A tool shall be deleted from a course: Delete the deployment instead. + $deployment = LtiDeployment::findOneBySQL( + "`tool_id` = :tool_id AND `course_id` = :course_id", + ['tool_id' => $this->tool->id, 'course_id' => $range_id] + ); + if ($deployment) { + $tool_name = $deployment->title; + $deleted = $deployment->delete(); + } else { + PageLayout::postError(sprintf(_('Das LTI-Tool „%s“ ist in dieser Veranstaltung nicht vorhanden.'), htmlReady($this->tool->name))); + return; + } + } + } else { + //Delete the tool directly: + $deleted = $this->tool->delete(); + } + if ($deleted !== false) { + PageLayout::postSuccess(sprintf(_('Das LTI-Tool „%s“ wurde gelöscht.'), htmlReady($tool_name))); + } else { + PageLayout::postError(_('Das LTI-Tool „%s“ konnte nicht gelöscht werden.'), htmlReady($tool_name)); + } + if ($range_id === 'global') { + //Redirect to the admin overview page. + $this->redirect('admin/lti'); + } elseif (Course::exists($range_id)) { + //Redirect to the LTI module of the course: + $this->redirect('course/lti', ['cid' => $range_id]); + } + } +} diff --git a/app/views/admin/lti/edit.php b/app/views/admin/lti/edit.php index 0473e86304671cde911f1caabeb809b058e96f15..adc8c595f20bdb43920582c3e14ed7250b99c725 100644 --- a/app/views/admin/lti/edit.php +++ b/app/views/admin/lti/edit.php @@ -2,73 +2,30 @@ /** * @var Admin_LtiController $controller * @var LtiTool $tool + * @var \OAT\Library\Lti1p3Core\Platform\Platform $platform */ ?> -<form class="default" action="<?= $controller->link_for('admin/lti/save/' . $tool->id) ?>" method="post"> +<form class="default" action="<?= $controller->link_for('admin/lti/edit/' . $tool->id) ?>" + method="post" data-dialog="reload-on-close"> <?= CSRFProtection::tokenTag() ?> <fieldset> <legend> <?= _('Konfiguration des LTI-Tools') ?> </legend> - - <label> - <span class="required"> - <?= _('Name der Anwendung') ?> - </span> - <input type="text" name="name" value="<?= htmlReady($tool->name) ?>" required> - </label> - - <label> - <span class="required"> - <?= _('URL der Anwendung') ?> - </span> - <input type="text" name="launch_url" value="<?= htmlReady($tool->launch_url) ?>" required> - </label> - - <label> - <span class="required"> - <?= _('Consumer-Key') ?> - </span> - <input type="text" name="consumer_key" value="<?= htmlReady($tool->consumer_key) ?>" required> - </label> - - <label> - <span class="required"> - <?= _('Consumer-Secret') ?> - </span> - <input type="text" name="consumer_secret" value="<?= htmlReady($tool->consumer_secret) ?>" required> - </label> - - <label> - <?= _('OAuth Signatur Methode') ?> - <select name="oauth_signature_method"> - <option value="sha1">HMAC-SHA1</option> - <option value="sha256" <?=$tool->oauth_signature_method === 'sha256' ? 'selected' : '' ?>>HMAC-SHA256</option> - </select> - </label> - - <label> - <input type="checkbox" name="allow_custom_url" value="1" <?= $tool->allow_custom_url ? ' checked' : '' ?>> - <?= _('Eingabe einer abweichenden URL im Kurs erlauben') ?> - </label> - - <label> - <input type="checkbox" name="deep_linking" value="1" <?= $tool->deep_linking ? ' checked' : '' ?>> - <?= _('Auswahl von Inhalten über LTI Deep Linking (Content Item)') ?> - </label> - - <label> - <input type="checkbox" name="send_lis_person" value="1" <?= $tool->send_lis_person ? ' checked' : '' ?>> - <?= _('Nutzerdaten an LTI-Tool senden') ?> - <?= tooltipIcon(_('Nutzerdaten dürfen nur an das externe Tool gesendet werden, wenn es keine Datenschutzbedenken gibt. Mit Setzen des Hakens bestätigen Sie, dass die Ãœbermittlung der Daten zulässig ist.')) ?> + <label class="studiprequired"> + <span class="textlabel"><?= _('Name') ?></span> + <span class="asterisk">*</span> + <input type="text" name="name" value="<?= htmlReady($tool->name) ?>"> </label> + <?= $this->render_partial('lti/_tool_form_fields', ['tool' => $tool]) ?> + </fieldset> - <label> - <?= _('Zusätzliche LTI-Parameter') ?> - <?= tooltipIcon(_('Ein Wert pro Zeile, Beispiel: Review:Chapter=1.2.56')) ?> - <textarea name="custom_parameters"><?= htmlReady($tool->custom_parameters) ?></textarea> - </label> + <? if ($platform) : ?> + <fieldset> + <legend><?= _('Daten zur LTI-Plattform') ?></legend> + <?= $this->render_partial('lti/_platform_data', ['platform' => $platform]) ?> </fieldset> + <? endif ?> <footer data-dialog-button> <?= Studip\Button::createAccept(_('Speichern'), 'save') ?> diff --git a/app/views/admin/lti/index.php b/app/views/admin/lti/index.php index ad4ac50bf44e98eb98947166ca002ec92b1bc415..54ebd940cebbfaf5413eb4f394d00b47cc9b8cb5 100644 --- a/app/views/admin/lti/index.php +++ b/app/views/admin/lti/index.php @@ -4,64 +4,64 @@ * @var LtiTool[] $tools */ ?> -<form action="" method="post"> - <?= CSRFProtection::tokenTag() ?> - <table class="default"> - <caption> - <?= _('Aktuell konfigurierte LTI-Tools') ?> - </caption> +<? if ($tools) : ?> + <form action="" method="post"> + <?= CSRFProtection::tokenTag() ?> + <table class="default"> + <caption><?= _('Aktuell konfigurierte LTI-Tools') ?></caption> - <colgroup> - <col style="width: 30%;"> - <col style="width: 40%;"> - <col style="width: 20%;"> - <col style="width: 5%;"> - <col style="width: 5%;"> - </colgroup> + <colgroup> + <col style="width: 30%;"> + <col style="width: 40%;"> + <col style="width: 20%;"> + <col style="width: 5%;"> + <col style="width: 5%;"> + </colgroup> - <thead> - <tr> - <th><?= _('Name der Anwendung') ?></th> - <th><?= _('URL der Anwendung') ?></th> - <th><?= _('Consumer-Key') ?></th> - <th><?= _('Links') ?></th> - <th class="actions"> - <?= _('Aktionen') ?> - </th> - </tr> - </thead> - - <tbody> - <? foreach ($tools as $tool): ?> + <thead> <tr> - <td> - <a href="<?= $controller->link_for('admin/lti/edit/' . $tool->id) ?>" title="<?= _('LTI-Tool konfigurieren') ?>" data-dialog> - <?= htmlReady($tool->name) ?> - </a> - </td> - <td> - <a href="<?= htmlReady($tool->launch_url) ?>" target="_blank" class="link-extern"> - <?= htmlReady($tool->launch_url) ?> - </a> - </td> - <td> - <?= htmlReady($tool->consumer_key) ?> - </td> - <td> - <?= count($tool->links) ?> - </td> - <td class="actions"> - <a href="<?= $controller->link_for('admin/lti/edit/' . $tool->id) ?>" title="<?= _('LTI-Tool konfigurieren') ?>" data-dialog> - <?= Icon::create('edit') ?> - </a> - <?= Icon::create('trash')->asInput([ - 'formaction' => $controller->url_for('admin/lti/delete/' . $tool->id), - 'title' => _('LTI-Tool löschen'), - 'data-confirm' => sprintf(_('Wollen Sie wirklich das LTI-Tool "%s" löschen?'), $tool->name) - ]) ?> - </td> + <th><?= _('Name der Anwendung') ?></th> + <th><?= _('URL der Anwendung') ?></th> + <th><?= _('Consumer-Key') ?></th> + <th><?= _('LTI-Version') ?></th> + <th><?= _('Links') ?></th> + <th class="actions"><?= _('Aktionen') ?></th> </tr> - <? endforeach ?> - </tbody> - </table> -</form> + </thead> + + <tbody> + <? foreach ($tools as $tool): ?> + <tr> + <td> + <a href="<?= $controller->link_for('lti/tool/edit/global/' . $tool->id) ?>" data-dialog> + <?= htmlReady($tool->name) ?> + </a> + </td> + <td> + <a href="<?= htmlReady($tool->launch_url) ?>" target="_blank" class="link-extern"> + <?= htmlReady($tool->launch_url) ?> + </a> + </td> + <td><?= htmlReady($tool->consumer_key) ?></td> + <td><?= htmlReady($tool->getLtiVersionString()) ?></td> + <td><?= count($tool->links) ?></td> + <td class="actions"> + <a href="<?= $controller->link_for('lti/tool/edit/global/' . $tool->id) ?>" title="<?= _('LTI-Tool konfigurieren') ?>" + aria-label="<?= _('LTI-Tool konfigurieren') ?>" data-dialog> + <?= Icon::create('edit') ?> + </a> + <?= Icon::create('trash')->asInput([ + 'formaction' => $controller->url_for('lti/tool/delete/global/' . $tool->id), + 'title' => _('LTI-Tool löschen'), + 'data-confirm' => sprintf(_('Wollen Sie das LTI-Tool „%s“ wirklich löschen?'), htmlReady($tool->name)), + 'aria-label' => _('LTI-Tool löschen'), + ]) ?> + </td> + </tr> + <? endforeach ?> + </tbody> + </table> + </form> +<? else : ?> + <?= MessageBox::info(_('Es sind keine globalen LTI-Tools konfiguriert.')) ?> +<? endif ?> diff --git a/app/views/course/lti/config.php b/app/views/course/lti/config.php index e9994035a0b53dbe917a2b58eab1e54bd130b2d3..fe487f92d1c2d082ed96a72fb7c36403a44566ab 100644 --- a/app/views/course/lti/config.php +++ b/app/views/course/lti/config.php @@ -1,18 +1,25 @@ <form class="default" action="<?= $controller->link_for('course/lti/save_config') ?>" method="post"> <?= CSRFProtection::tokenTag() ?> <fieldset> - <legend> - <?= _('Einstellungen') ?> - </legend> - + <legend><?= _('Datenschutzhinweis beim Wechsel in ein LTI-Tool') ?></legend> + <label> + <?= _('Text des Datenschutzhinweises') ?> + <textarea name="personal_data_warning"><?= htmlReady($personal_data_warning) ?></textarea> + </label> <label> - <span class="required"> - <?= _('Titel des Reiters') ?> - </span> - <input type="text" name="title" value="<?= htmlReady($title) ?>" required> + <input type="checkbox" value="1" name="reset_warning" + data-deactivates="textarea[name='personal_data_warning']"> + <?= _('Den systemweit konfigurierten Standardtext verwenden.') ?> </label> </fieldset> + <fieldset> + <legend><?= _('LTI Plattform-Konfiguration') ?></legend> + <?= $this->render_partial('lti/_platform_data', [ + 'platform' => \Studip\LTI13a\PlatformManager::getPlatformConfiguration(), + ]) ?> + </fieldset> + <footer data-dialog-button> <?= Studip\Button::createAccept(_('Speichern'), 'save') ?> <?= Studip\LinkButton::createCancel(_('Abbrechen'), $controller->url_for('course/lti')) ?> diff --git a/app/views/course/lti/consent.php b/app/views/course/lti/consent.php new file mode 100644 index 0000000000000000000000000000000000000000..4c810a5708fab9390b9beb89ef4bde18286a6ae6 --- /dev/null +++ b/app/views/course/lti/consent.php @@ -0,0 +1,72 @@ +<?php +/** + * @var AuthenticatedController $controller + * @var LtiDeployment $deployment + * @var LtiDeploymentPrivacySettings $privacy_settings + */ +?> +<? if ($deployment) : ?> + <form class="default" method="post" <?= $privacy_settings->isNew() ? 'data-dialog="reload-on-close"' : 'data-dialog' ?> + action="<?= $controller->link_for('course/lti/consent/' . $deployment->id) ?>"> + <?= CSRFProtection::tokenTag() ?> + <? + $data_protection_warning = CourseConfig::get(Context::getId())->LTI_DATA_PROTECTION_COURSE_WARNING; + if (empty($data_protection_warning)) { + $data_protection_warning = Config::get()->LTI_DATA_PROTECTION_DEFAULT_WARNING; + } + ?> + <fieldset> + <legend><?= _('Datenschutzhinweise') ?></legend> + <section> + <p><?= htmlReady($data_protection_warning) ?></p> + <? if ($deployment->tool->data_protection_notes) : ?> + <p><?= formatReady($deployment->tool->data_protection_notes) ?></p> + <? endif ?> + </section> + </fieldset> + <fieldset> + <? + $optional_field_list = explode(',', $privacy_settings->allowed_optional_fields ?? ''); + ?> + <legend><?= _('Folgenden Daten werden übertragen') ?></legend> + <?= _('Beim Wechsel in das LTI-Tool werden die folgenden personenbezogenen Daten übertragen:') ?> + <label> + <input type="checkbox" checked disabled> + <?= _('Die ID ihres Stud.IP-Kontos') ?> + </label> + <label> + <input type="checkbox" checked disabled> + <?= _('Ihr Vor- und Nachname, sowie gegebenenfalls vorhandene Titel') ?> + </label> + <label> + <input type="checkbox" checked disabled> + <?= _('Ihre E-Mail Adresse') ?> + </label> + <label> + <input type="checkbox" name="submit_optional_field[lang]" value="1" + <?= in_array('lang', $optional_field_list) ? 'checked' : '' ?>> + <?= _('Ihre in Stud.IP eingestellte Sprache') ?> + </label> + <label> + <input type="checkbox" name="submit_optional_field[avatar_url]" value="1" + <?= in_array('avatar_url', $optional_field_list) ? 'checked' : '' ?>> + <?= _('Ihr Profilbild') ?> + </label> + </fieldset> + <?= $this->render_partial('lti/_deployment_user_info', ['deployment' => $deployment]) ?> + <fieldset> + <legend><?= _('Bestätigung') ?></legend> + <label> + <input type="checkbox" name="confirmed" value="1" required> + <?= _( + 'Ich habe die Datenschutzhinweise zur Benutzung des LTI-Tools zur Kenntnis genommen und stimme der Weitergabe meiner personenbezogenen Daten zu. ' + . 'Mir ist bewusst, dass ich ohne die Zustimmung das LTI-Tool nicht nutzen kann.' + ) ?> + </label> + <div data-dialog-button> + <?= \Studip\Button::createAccept(_('Speichern'), 'save') ?> + <?= \Studip\Button::createCancel(_('Abbrechen')) ?> + </div> + </fieldset> + </form> +<? endif ?> diff --git a/app/views/course/lti/edit.php b/app/views/course/lti/edit.php deleted file mode 100644 index 0131e05eacb32671575d1a9ca2f875c11971970b..0000000000000000000000000000000000000000 --- a/app/views/course/lti/edit.php +++ /dev/null @@ -1,117 +0,0 @@ -<?php -/** - * @var Course_LtiController $controller - * @var LtiData $lti_data - * @var LtiTool[] $tools - */ -?> -<form class="default" action="<?= $controller->link_for('course/lti/save', $lti_data->isNew() ? '' : $lti_data->position) ?>" method="post"> - <?= CSRFProtection::tokenTag() ?> - <fieldset> - <legend> - <?= _('Einstellungen') ?> - </legend> - - <label> - <span class="required"> - <?= _('Titel') ?> - </span> - <input type="text" name="title" value="<?= htmlReady($lti_data->title) ?>" required> - </label> - - <label> - <?= _('Beschreibung') ?> - <textarea name="description" class="wysiwyg"><?= wysiwygReady($lti_data->description) ?></textarea> - </label> - - <label> - <?= _('Auswahl des externen Tools') ?> - <select class="config_tool" name="tool_id"> - <? foreach ($tools as $tool): ?> - <option value="<?= $tool->id ?>" - <? if ($tool->allow_custom_url): ?> - data-url="<?= htmlReady($tool->launch_url) ?>" - <? endif ?> - <?= $lti_data->tool_id == $tool->id ? 'selected' : '' ?>><?= htmlReady($tool->name) ?></option> - <? endforeach ?> - <option value="0" <?= !$lti_data->tool_id ? 'selected' : '' ?>><?= _('Zugangsdaten selbst eingeben...') ?></option> - </select> - </label> - - <div class="config_custom_url"> - <label> - <?= _('URL der Anwendung (optional)') ?> - <?= tooltipIcon(_('Sie können direkt auf eine URL in der Anwendung verlinken.')) ?> - <input type="text" name="custom_url" value="<?= htmlReady($lti_data->launch_url) ?>"> - </label> - </div> - - <div class="config_launch_url"> - <label> - <?= _('URL der Anwendung') ?> - <?= tooltipIcon(_('Die Betreiber dieses Tools müssen Ihnen eine URL und Zugangsdaten (Consumer-Key und Consumer-Secret) mitteilen.')) ?> - <input type="text" name="launch_url" value="<?= htmlReady($lti_data->launch_url) ?>"> - </label> - - <label> - <?= _('Consumer-Key des LTI-Tools') ?> - <input type="text" name="consumer_key" value="<?= htmlReady($lti_data->options['consumer_key'] ?? '') ?>"> - </label> - - <label> - <?= _('Consumer-Secret des LTI-Tools') ?> - <input type="text" name="consumer_secret" value="<?= htmlReady($lti_data->options['consumer_secret'] ?? '') ?>"> - </label> - - <label> - <?= _('OAuth Signatur Methode des LTI-Tools') ?> - <select name="oauth_signature_method"> - <option value="sha1">HMAC-SHA1</option> - <option value="sha256" <?= isset($lti_data->options['oauth_signature_method']) && $lti_data->options['oauth_signature_method'] === 'sha256' ? 'selected' : '' ?>>HMAC-SHA256</option> - </select> - </label> - - <label> - <input type="checkbox" name="send_lis_person" value="1" <?= !empty($lti_data->options['send_lis_person']) ? ' checked' : '' ?>> - <?= _('Nutzerdaten an LTI-Tool senden') ?> - <?= tooltipIcon(_('Nutzerdaten dürfen nur an das externe Tool gesendet werden, wenn es keine Datenschutzbedenken gibt. Mit Setzen des Hakens bestätigen Sie, dass die Ãœbermittlung der Daten zulässig ist.')) ?> - </label> - </div> - - <label> - <input type="checkbox" name="document_target" value="iframe" <?= isset($lti_data->options['document_target']) && $lti_data->options['document_target'] === 'iframe' ? ' checked' : '' ?>> - <?= _('Anzeige im IFRAME auf der Seite') ?> - <?= tooltipIcon(_('Normalerweise wird das externe Tool in einem neuen Fenster angezeigt. Aktivieren Sie diese Option, wenn die Anzeige stattdessen in einem IFRAME erfolgen soll.')) ?> - </label> - - <label> - <?= _('Zusätzliche LTI-Parameter') ?> - <?= tooltipIcon(_('Ein Wert pro Zeile, Beispiel: Review:Chapter=1.2.56')) ?> - <textarea name="custom_parameters"><?= htmlReady($lti_data->options['custom_parameters'] ?? '') ?></textarea> - </label> - </fieldset> - - <footer data-dialog-button> - <?= Studip\Button::createAccept(_('Speichern'), 'save') ?> - <?= Studip\LinkButton::createCancel(_('Abbrechen'), $controller->url_for('course/lti')) ?> - </footer> -</form> - -<script> - $('.config_tool').change(function() { - let url = $(this).find(':selected').data('url'); - - if ($(this).val() == 0) { - $('.config_launch_url').show(); - } else { - $('.config_launch_url').hide(); - } - - if (url) { - $('.config_custom_url').find('input').attr('placeholder', url); - $('.config_custom_url').show(); - } else { - $('.config_custom_url').hide(); - } - }).trigger('change'); -</script> diff --git a/app/views/course/lti/grades.php b/app/views/course/lti/grades.php index a200405fc96e5766a09f27069a1d2890c5a80949..e9b0b40fcbebc69aa7df309610c630d66fed94f3 100644 --- a/app/views/course/lti/grades.php +++ b/app/views/course/lti/grades.php @@ -1,39 +1,41 @@ -<table class="default"> - <caption> - <?= _('Ergebnisse') ?> - </caption> +<? if (empty($lti_data_array)): ?> + <?= MessageBox::info(_('Es sind keine LTI-Tools konfiguriert.')) ?> +<? else : ?> + <table class="default"> + <caption><?= _('Ergebnisse') ?></caption> - <thead> - <tr class="sortable"> - <th class="<?= $desc ? 'sortdesc' : 'sortasc' ?>"> - <a href="<?= $controller->link_for('course/lti/grades', ['desc' => !$desc]) ?>"> - <?= _('Teilnehmende') ?> - </a> - </th> - <? foreach ($lti_data_array as $lti_data): ?> - <th style="text-align: right;"> - <?= htmlReady($lti_data->title) ?> + <thead> + <tr class="sortable"> + <th class="<?= $desc ? 'sortdesc' : 'sortasc' ?>"> + <a href="<?= $controller->link_for('course/lti/grades', ['desc' => !$desc]) ?>"> + <?= _('Teilnehmende') ?> + </a> </th> - <? endforeach ?> - </tr> - </thead> - - <tbody> - <? foreach ($members as $member): ?> - <tr> - <td> - <?= htmlReady($member->nachname) ?>, <?= htmlReady($member->vorname) ?> - </td> <? foreach ($lti_data_array as $lti_data): ?> - <td style="text-align: right;"> - <? if ($grade = $lti_data->grades->findOneBy('user_id', $member->user_id)): ?> - <?= sprintf('%.0f%%', $grade->score * 100) ?> - <? else: ?> - – - <? endif ?> - </td> + <th style="text-align: right;"> + <?= htmlReady($lti_data->title) ?> + </th> <? endforeach ?> </tr> - <? endforeach ?> - </tbody> -</table> + </thead> + + <tbody> + <? foreach ($members as $member): ?> + <tr> + <td> + <?= htmlReady($member->nachname) ?>, <?= htmlReady($member->vorname) ?> + </td> + <? foreach ($lti_data_array as $lti_data): ?> + <td style="text-align: right;"> + <? if ($grade = $lti_data->grades->findOneBy('user_id', $member->user_id)): ?> + <?= sprintf('%.0f%%', $grade->score * 100) ?> + <? else: ?> + – + <? endif ?> + </td> + <? endforeach ?> + </tr> + <? endforeach ?> + </tbody> + </table> +<? endif ?> diff --git a/app/views/course/lti/grades_user.php b/app/views/course/lti/grades_user.php index 8be7b0270fa812c23573573eb605cd379e67598c..fb041c577034ccee0ab3a81599281a5d0023f6d4 100644 --- a/app/views/course/lti/grades_user.php +++ b/app/views/course/lti/grades_user.php @@ -1,33 +1,29 @@ -<table class="default"> - <caption> - <?= _('Ergebnisse') ?> - </caption> +<? if (empty($lti_data_array)): ?> + <?= MessageBox::info(_('Es sind keine LTI-Tools konfiguriert.')) ?> +<? else : ?> + <table class="default"> + <caption><?= _('Ergebnisse') ?></caption> - <thead> - <tr> - <th> - <?= _('Abschnitt') ?> - </th> - <th style="text-align: right;"> - <?= _('Bewertung') ?> - </th> + <thead> + <tr> + <th><?= _('Abschnitt') ?></th> + <th style="text-align: right;"><?= _('Bewertung') ?></th> </tr> - </thead> + </thead> - <tbody> - <? foreach ($lti_data_array as $lti_data): ?> - <tr> - <td> - <?= htmlReady($lti_data->title) ?> - </td> - <td style="text-align: right;"> - <? if ($grade = LtiGrade::find([$lti_data->id, $GLOBALS['user']->id])): ?> - <?= sprintf('%.0f%%', $grade->score * 100) ?> - <? else: ?> - – - <? endif ?> - </td> - </tr> - <? endforeach ?> - </tbody> -</table> + <tbody> + <? foreach ($lti_data_array as $lti_data): ?> + <tr> + <td><?= htmlReady($lti_data->title) ?></td> + <td style="text-align: right;"> + <? if ($grade = LtiGrade::find([$lti_data->id, $GLOBALS['user']->id])): ?> + <?= sprintf('%.0f%%', $grade->score * 100) ?> + <? else: ?> + – + <? endif ?> + </td> + </tr> + <? endforeach ?> + </tbody> + </table> +<? endif ?> diff --git a/app/views/course/lti/iframe.php b/app/views/course/lti/iframe.php index 1cd0d2c53c76e9527d599beec5b9ce46ea13650a..c1a7de1b8be0ac876fbad350fb29bd3afe4d9c2c 100644 --- a/app/views/course/lti/iframe.php +++ b/app/views/course/lti/iframe.php @@ -1,24 +1,42 @@ <?php /** - * @var string $launch_url + * @var StudipController $controller + * @var LtiDeployment $deployment * @var array $launch_data * @var string $signature + * @var bool $lti13a_mode + * @var \OAT\Library\Lti1p3Core\Message\LtiMessage $message */ ?> -<!DOCTYPE html> -<html> -<head> +<? if ($deployment) : ?> + <!DOCTYPE html> + <html> + <head> <meta charset="UTF-8"> -</head> -<body onload="document.ltiLaunchForm.submit();"> - <form name="ltiLaunchForm" method="post" action="<?= htmlReady($launch_url) ?>"> - <? foreach ($launch_data as $key => $value): ?> - <input type="hidden" name="<?= htmlReady($key) ?>" value="<?= htmlReady($value, false) ?>"> - <? endforeach ?> - <input type="hidden" name="oauth_signature" value="<?= $signature ?>"> - <noscript> - <button><?= _('Anwendung starten') ?></button> - </noscript> - </form> -</body> -</html> + <? if (!$lti13a_mode) : ?> + <script type="text/javascript"> + window.onload=document.ltiLaunchForm.submit(); + </script> + <? endif ?> + </head> + <body> + <? if ($lti13a_mode) : ?> + <? if ($message) : ?> + <?= $message->toHtmlRedirectForm(Request::submitted('do_not_send') ? false : true) ?> + <? else: ?> + <?= _('Das LTI-Tool kann nicht aufgerufen werden.') ?> + <? endif ?> + <? else : ?> + <form name="ltiLaunchForm" method="post" action="<?= htmlReady($deployment->getLaunchUrl()) ?>"> + <? foreach ($launch_data as $key => $value): ?> + <input type="hidden" name="<?= htmlReady($key) ?>" value="<?= htmlReady($value, false) ?>"> + <? endforeach ?> + <input type="hidden" name="oauth_signature" value="<?= $signature ?>"> + <noscript> + <button><?= _('Anwendung starten') ?></button> + </noscript> + </form> + <? endif ?> + </body> + </html> +<? endif ?> diff --git a/app/views/course/lti/index.php b/app/views/course/lti/index.php index df1696bee4fa0ea1533a839856f84d03e11cc367..9c399d625d314e0035d5f91e8bf4e7062d9f4915 100644 --- a/app/views/course/lti/index.php +++ b/app/views/course/lti/index.php @@ -1,20 +1,30 @@ <?php /** * @var Course_LtiController $controller - * @var LtiData[] $lti_data_array + * @var LtiDeployment[] $lti_data_array * @var bool $edit_perm */ ?> <? if (empty($lti_data_array)): ?> - <?= MessageBox::info(_('Es wurden noch keine Inhalte angelegt.')) ?> + <?= MessageBox::info(_('Es sind keine LTI-Tools konfiguriert.')) ?> <? endif ?> <? foreach ($lti_data_array as $lti_data): ?> - <? $launch_url = $lti_data->getLaunchURL() ?> + <? + $launch_url = $lti_data->getLaunchURL(); + $unfinished_deep_linking = !empty($lti_data->options['unfinished_deep_linking']); + $no_consent = !LtiToolPrivacySettings::countBySql( + '`tool_id` = :tool_id AND `user_id` = :user_id', + ['tool_id' => $lti_data->tool_id, 'user_id' => $GLOBALS['user']->id] + ); + ?> <article class="studip"> <header> - <h1><?= htmlReady($lti_data->title) ?></h1> + <h1> + <?= htmlReady($lti_data->title) ?> + <?= $unfinished_deep_linking ? '(' . _('LTI Deep Linking noch nicht fertig eingerichtet') . ')' : '' ?> + </h1> <? if ($edit_perm): ?> <nav> @@ -22,47 +32,91 @@ <?= CSRFProtection::tokenTag() ?> <? if ($lti_data->position > 0): ?> <?= Icon::create('arr_2up', Icon::ROLE_SORT)->asInput([ - 'formaction' => $controller->url_for('course/lti/move/' . $lti_data->position . '/up') + 'formaction' => $controller->url_for('course/lti/move/' . $lti_data->id . '/up'), + 'title' => _('Nach oben verschieben'), + 'aria-label' => _('Nach oben verschieben') ]) ?> <? endif ?> <? if ($lti_data->position < count($lti_data_array) - 1): ?> <?= Icon::create('arr_2down', Icon::ROLE_SORT)->asInput([ - 'formaction' => $controller->url_for('course/lti/move/' . $lti_data->position . '/down') + 'formaction' => $controller->url_for('course/lti/move/' . $lti_data->id . '/down'), + 'title' => _('Nach unten verschieben'), + 'aria-label' => _('Nach unten verschieben') ]) ?> <? endif ?> - <?= Icon::create('edit')->asInput([ - 'formaction' => $controller->url_for('course/lti/edit/' . $lti_data->position), - 'title' => _('Abschnitt bearbeiten'), - 'data-dialog' => '' - ]) ?> - <?= Icon::create('trash')->asInput([ - 'formaction' => $controller->url_for('course/lti/delete/' . $lti_data->position), - 'title' => _('Abschnitt löschen'), - 'data-confirm' => sprintf(_('Wollen Sie wirklich den Abschnitt "%s" löschen?'), $lti_data->title) - ]) ?> + <? + $menu = ActionMenu::get(); + $show_admin_actions = $GLOBALS['perm']->have_studip_perm('tutor', $lti_data->course_id); + if ($show_admin_actions) { + $menu->addLink( + $controller->url_for('lti/tool/index/' . $lti_data->course_id . '/' . $lti_data->tool->id), + _('Konfiguration des LTI-Tools anzeigen'), + Icon::create('info-circle'), + ['data-dialog' => 'size=default'] + ); + } + $menu->addLink( + $controller->url_for('course/lti/consent/' . $lti_data->id), + _('Datenschutzeinstellungen'), + Icon::create('privacy'), + ['data-dialog' => 'size=default'] + ); + + if ($show_admin_actions) { + $menu->addLink( + $controller->url_for('lti/tool/edit/' . $lti_data->course_id . '/' . $lti_data->tool->id), + _('LTI-Tool konfigurieren'), + Icon::create('edit'), + ['data-dialog' => 'size=default'] + ); + $menu->addLink( + sprintf( + 'javascript:void(STUDIP.Dialog.confirmAsPost(\'%s\', \'%s\'))', + sprintf(_('Wollen Sie das LTI-Tool "%s" wirklich entfernen?'), $lti_data->title), + $controller->url_for('lti/tool/delete/' . $lti_data->course_id . '/' . $lti_data->tool->id) + ), + _('LTI-Tool entfernen'), + Icon::create('trash') + ); + } + ?> + <?= $menu->render() ?> </form> </nav> <? endif ?> </header> - <section> - <?= formatReady($lti_data->description) ?> - - <? if ($launch_url && $lti_data->options['document_target'] === 'iframe'): ?> - <iframe style="border: none; height: 640px; width: 100%;" - src="<?= $controller->link_for('course/lti/iframe', $lti_data->position) ?>"></iframe> - <? endif ?> - </section> - - <? if ($launch_url && $lti_data->options['document_target'] !== 'iframe'): ?> - <footer> + <? if ($unfinished_deep_linking) : ?> <?= Studip\LinkButton::create( - _('Anwendung starten'), - $controller->link_for('course/lti/iframe', $lti_data->position), + _('Einrichtung abschließen'), + $controller->url_for('course/lti/select_link/' . $lti_data->id, ['tool_id' => $lti_data->tool_id]), ['target' => '_blank'] ) ?> - </footer> - <? endif ?> + <? elseif ($no_consent) : ?> + <?= formatReady($lti_data->description) ?> + <p><?= _('Sie haben der Datenweitergabe an das LTI-Tool noch nicht zugestimmt und können es deswegen noch nicht nutzen.') ?></p> + <?= Studip\LinkButton::create( + _('Datenschutzeinstellungen öffnen'), + $controller->url_for('course/lti/consent/' . $lti_data->id), + ['data-dialog' => 'reload-on-close'] + ) ?> + <? elseif ($launch_url) : ?> + <? + $document_target = $lti_data->options['document_target'] ?? ''; + ?> + <?= formatReady($lti_data->description) ?> + <? if ($document_target === 'iframe') : ?> + <iframe style="border: none; height: 640px; width: 100%;" + src="<?= $controller->link_for('course/lti/iframe/' . $lti_data->id) ?>"></iframe> + <? else : ?> + <?= Studip\LinkButton::create( + _('Anwendung starten'), + $controller->url_for('course/lti/iframe/' . $lti_data->id), + ['target' => '_blank'] + ) ?> + <? endif ?> + <? endif ?> + </section> </article> <? endforeach ?> diff --git a/app/views/course/lti/select_link.php b/app/views/course/lti/select_link.php new file mode 100644 index 0000000000000000000000000000000000000000..370587e842f0ae314076917c1ea15ae197884c67 --- /dev/null +++ b/app/views/course/lti/select_link.php @@ -0,0 +1,15 @@ +<?php +/** + * @var AuthenticatedController $controller + * @var LtiTool $tool + * @var LtiDeployment $deployment + */ +?> +<form class="default" method="post" + action="<?= $controller->link_for('course/lti/process_select_link/' . htmlReady($deployment->id), ['tool_id' => $tool->id]) ?>"> + <?= CSRFProtection::tokenTag() ?> + <?= $this->render_partial('lti/_tool_info', ['tool' => $tool, 'deployment' => $deployment]) ?> + <div data-dialog-button> + <?= \Studip\Button::create(_('Weiter'), 'continue') ?> + </div> +</form> diff --git a/app/views/course/lti/select_tool.php b/app/views/course/lti/select_tool.php new file mode 100644 index 0000000000000000000000000000000000000000..b6163e0859adb3e2f9770f011fd1c2edd1fe5bd5 --- /dev/null +++ b/app/views/course/lti/select_tool.php @@ -0,0 +1,31 @@ +<?php +/** + * @var StudipController $controller + * @var LtiTool[] $global_tools + */ +?> +<form class="default" method="post" action="<?= $controller->link_for('course/lti/select_tool_redirect') ?>" + data-dialog> + <?= CSRFProtection::tokenTag() ?> + <fieldset> + <legend><?= _('Auswahl des LTI-Tools') ?></legend> + <label> + <?= _('Bitte wählen Sie ein LTI-Tool aus.') ?> + <select name="selected_tool_id"> + <? foreach ($global_tools as $tool) : ?> + <option value="<?= htmlReady($tool->id) ?>"> + <?= htmlReady($tool->name) ?> + </option> + <? endforeach ?> + <? if (Config::get()->LTI_ALLOW_TOOL_CONFIG_IN_COURSE) : ?> + <option value="new"> + <?= _('Neues LTI-Tool für die Veranstaltung einrichten.') ?> + </option> + <? endif ?> + </select> + </label> + </fieldset> + <div data-dialog-button> + <?= \Studip\Button::create(_('Weiter')) ?> + </div> +</form> diff --git a/app/views/lti/_deployment_user_info.php b/app/views/lti/_deployment_user_info.php new file mode 100644 index 0000000000000000000000000000000000000000..b43da8fba15eab3d53388109d64c46b56be9e744 --- /dev/null +++ b/app/views/lti/_deployment_user_info.php @@ -0,0 +1,43 @@ +<? +/** + * @var LtiDeployment $deployment + */ +?> +<? if (!empty($deployment)) : ?> + <article class="studip"> + <header><h1><?= htmlReady($deployment->title) ?></h1></header> + <section> + <? if ($deployment->tool->range_id === 'global') : ?> + <p> + <?= sprintf( + 'Dies ist eine Einbindung des LTI-Tools „%s“.', + htmlReady($deployment->tool->name) + ) ?> + </p> + <? endif ?> + <p><?= formatReady($deployment->description ?? '') ?></p> + <? + $url_parts = parse_url($deployment->getLaunchURL()); + ?> + <? if (!empty($url_parts['host'])) : ?> + <p><?= _('Domain') ?>: <?= htmlReady($url_parts['host']) ?></p> + <? endif ?> + <? if ($deployment->tool->terms_of_use_url || $deployment->tool->privacy_policy_url) : ?> + <p> + <? if ($deployment->tool->terms_of_use_url) : ?> + <a href="<?= htmlReady($deployment->tool->terms_of_use_url) ?>"> + <?= Icon::create('link-extern')->asImg(['class' => 'text-bottom']) ?> + <?= _('Nutzungsbedingungen') ?> + </a> + <? endif ?> + <? if ($deployment->tool->privacy_policy_url) : ?> + <a href="<?= htmlReady($deployment->tool->privacy_policy_url) ?>"> + <?= Icon::create('link-extern')->asImg(['class' => 'text-bottom']) ?> + <?= _('Datenschutzerklärung') ?> + </a> + <? endif ?> + </p> + <? endif ?> + </section> + </article> +<? endif ?> diff --git a/app/views/lti/_platform_data.php b/app/views/lti/_platform_data.php new file mode 100644 index 0000000000000000000000000000000000000000..e9e2979348cf91a67b2ebc42536d8424f0f903c4 --- /dev/null +++ b/app/views/lti/_platform_data.php @@ -0,0 +1,45 @@ +<?php +/** + * @var \OAT\Library\Lti1p3Core\Platform\Platform $platform + */ +?> +<dl> + <dt><?= _('Plattform-ID') ?></dt> + <dd> + <a href="<?= htmlReady($platform->getAudience()) ?>"> + <?= htmlReady($platform->getAudience()) ?> + </a> + </dd> + + <dt><?= _('OAuth2 access token URL') ?></dt> + <dd> + <a href="<?= htmlReady($platform->getOAuth2AccessTokenUrl()) ?>"> + <?= htmlReady($platform->getOAuth2AccessTokenUrl()) ?> + </a> + </dd> + + <dt><?= _('OIDC authentication URL') ?></dt> + <dd> + <a href="<?= htmlReady($platform->getOidcAuthenticationUrl()) ?>"> + <?= htmlReady($platform->getOidcAuthenticationUrl()) ?> + </a> + </dd> + + <dt><?= _('JWKS URL') ?></dt> + <dd> + <a href="<?= URLHelper::getLink('dispatch.php/lti/auth/jwks', [], true) ?>"> + <?= URLHelper::getLink('dispatch.php/lti/auth/jwks', [], true) ?> + </a> + </dd> + + <? + $keyring = \Studip\LTI13a\PlatformManager::getPlatformKeyring(); + if (!$keyring) { + $keyring = \Studip\LTI13a\PlatformManager::generatePlatformKeyring(); + } + ?> + <? if ($keyring) : ?> + <dt><?= _('Öffentlicher Schlüssel') ?></dt> + <dd><pre><?= htmlReady($keyring->public_key) ?></pre></dd> + <? endif ?> +</dl> diff --git a/app/views/lti/_tool_form_fields.php b/app/views/lti/_tool_form_fields.php new file mode 100644 index 0000000000000000000000000000000000000000..7a7dfbff5241a3fc39d02f88fd24fa68cf579119 --- /dev/null +++ b/app/views/lti/_tool_form_fields.php @@ -0,0 +1,148 @@ +<?php +/** + * @var LtiTool $tool + * @var LtiDeployment $deployment + */ +?> +<fieldset> + <legend><?= _('Grunddaten') ?></legend> + <label class="studiprequired"> + <span class="textlabel"><?= _('Titel') ?></span> + <span class="asterisk">*</span> + <input type="text" name="name" required + value="<?= htmlReady(!empty($deployment) ? $deployment->title : $tool->name ?? '') ?>"> + </label> + <? if (!empty($deployment)) : ?> + <label> + <?= _('Beschreibung') ?> + <textarea name="description" class="wysiwyg"><?= wysiwygReady($deployment->description ?? '') ?></textarea> + </label> + <label> + <?= _('Datenschutzhinweise') ?> + <textarea name="data_protection_notes" class="wysiwyg" + placeholder="<?= _('Bitte machen Sie Angaben zu dem angebundenen Werkzeug, soweit sie ihnen bekannt sind. Wie ist der Name, wer bietet es an, wozu wird es eingesetzt und welche Daten werden übertragen? (Beispiel: „Tool XY wird zur Durchführung von Sprachtests genutzt und Testergebnisse und ggf. Noten gespeichert. Zur Anmeldung werden Name und Nutzerkennung übertragen.“)') ?>"><?= wysiwygReady($tool->data_protection_notes) ?></textarea> + </label> + <? endif ?> + <? if ($tool->isEditableByUser()) : ?> + <label> + <?= _('URL zu den Nutzungsbedingungen des LTI-Tools (falls verfügbar)') ?> + <input type="url" name="terms_of_use_url" value="<?= htmlReady($tool->terms_of_use_url) ?>"> + </label> + <label> + <?= _('URL zur Datenschutzerklärung des LTI-Tools (falls verfügbar)') ?> + <input type="url" name="privacy_policy_url" value="<?= htmlReady($tool->privacy_policy_url) ?>"> + </label> + <? endif ?> +</fieldset> +<fieldset> + <legend><?= _('Konfiguration des LTI-Tools') ?></legend> + <? if ($tool->isEditableByUser()) : ?> + <label class="studiprequired"> + <span class="textlabel"><?= _('LTI-Version') ?></span> + <span class="asterisk">*</span> + <select name="lti_version" + data-shows=".lti11-field" data-hides=".lti13a-field" + data-triggering-value="1.1"> + <option value="1.1" <?= !empty($tool->lti_version) && $tool->lti_version === '1.1' ? 'selected' : '' ?>> + 1.0/1.1 + </option> + <option value="1.3a" <?= empty($tool->lti_version) || $tool->lti_version === '1.3a' ? 'selected' : '' ?>> + 1.3a + </option> + </select> + </label> + <? endif ?> + + <label class="studiprequired"> + <span class="textlabel"><?= _('LTI Launch-URL') ?></span> + <span class="asterisk">*</span> + <input type="text" name="launch_url" required + value="<?= htmlReady( + !empty($deployment->launch_url) + ? $deployment->launch_url + : $tool->launch_url ?? '' + ) ?>"> + </label> + + <? if ($tool->isEditableByUser()) : ?> + <div class="lti13a-field"> + <label> + <?= _('OIDC Login-URL') ?> + <?= tooltipIcon(_('Die URL, mit der der Login via OpenID Connect stattfindet.')) ?> + <input type="text" name="oidc_init_url" value="<?= htmlReady($tool->oidc_init_url ?? '') ?>"> + </label> + <label> + <?= _('Deep-linking URL') ?> + <input type="url" name="deep_linking_url" value="<?= htmlReady($tool->deep_linking_url ?? '') ?>"> + </label> + <label> + <?= _('JWKS-URL') ?> + <?= tooltipIcon(_('Die URL, mit der der der Austausch von JSON web keys stattfinden kann.')) ?> + <input type="text" name="jwks_url" + value="<?= htmlReady($tool->jwks_url ?? '') ?>"> + </label> + <label> + <?= _('Schlüssel-ID') ?> + <?= tooltipIcon(_('Die ID des Schlüssels, der über die JWKS-URL geladen werden soll.')) ?> + <input type="text" name="jwks_key_id" value="<?= htmlReady($tool->jwks_key_id ?? '') ?>"> + </label> + <label> + <?= _('Öffentlicher Schlüssel des LTI-Tools') ?> + <? + $keyring = null; + if ($tool && !$tool->isNew()) { + $keyring = $tool->getKeyring(); + } + $public_key_string = ''; + if ($keyring) { + $keychain = $keyring->toKeyChain(); + $public_key_string = $keychain->getPublicKey()->getContent(); + } + ?> + <textarea name="tool_public_key"><?= htmlReady($public_key_string) ?></textarea> + </label> + </div> + <div class="lti11-field"> + <label class="studiprequired"> + <span class="textlabel"><?= _('Consumer-Key des LTI-Tools') ?></span> + <span class="asterisk">*</span> + <input type="text" name="consumer_key" required + value="<?= htmlReady($tool->consumer_key ?? '') ?>"> + </label> + <label class="studiprequired"> + <span class="textlabel"><?= _('Consumer-Secret des LTI-Tools') ?></span> + <span class="asterisk">*</span> + <input type="text" name="consumer_secret" required + value="<?= htmlReady($tool->consumer_secret ?? '') ?>"> + </label> + </div> + <label> + <input type="checkbox" name="send_lis_person" value="1" <?= !empty($tool->send_lis_person) ? ' checked' : '' ?>> + <?= _('Personendaten an das LTI-Tool senden') ?> + <?= tooltipIcon(_('Personendaten dürfen nur an das externe Tool gesendet werden, wenn es keine Datenschutzbedenken gibt. Mit Setzen des Hakens bestätigen Sie, dass die Ãœbermittlung der Daten zulässig ist.')) ?> + </label> + <? endif ?> + <label> + <?= _('Zusätzliche LTI-Parameter') ?> + <?= tooltipIcon(_('Ein Wert pro Zeile, Beispiel: Review:Chapter=1.2.56')) ?> + <textarea name="custom_parameters"><?= htmlReady( + !empty($deployment->options['custom_parameters']) + ? $deployment->options['custom_parameters'] + : $tool->custom_parameters ?? '' + ) ?></textarea> + </label> +</fieldset> +<? if (!empty($deployment)) : ?> + <fieldset> + <legend><?= _('Anzeigeeinstellungen') ?></legend> + <label> + <input type="checkbox" name="document_target" value="iframe" <?= isset($deployment->options['document_target']) && $deployment->options['document_target'] === 'iframe' ? ' checked' : '' ?>> + <?= _('Anzeige im IFRAME auf der Seite') ?> + <?= tooltipIcon(_('Normalerweise wird das externe Tool in einem neuen Fenster angezeigt. Aktivieren Sie diese Option, wenn die Anzeige stattdessen in einem IFRAME erfolgen soll.')) ?> + </label> + </fieldset> +<? endif ?> + +<footer data-dialog-button> + <?= Studip\Button::createAccept(_('Speichern'), 'save') ?> +</footer> diff --git a/app/views/lti/_tool_info.php b/app/views/lti/_tool_info.php new file mode 100644 index 0000000000000000000000000000000000000000..452e8f48f98c4f12b5077296d3fb31f3a8526cde --- /dev/null +++ b/app/views/lti/_tool_info.php @@ -0,0 +1,79 @@ +<?php +/** + * @var LtiTool $tool + * @var LtiDeployment $deployment + * @var StudipControlle $controller + */ +?> +<? if (!empty($tool)) : ?> + <article class="studip"> + <header> + <? if ($deployment) : ?> + <h1><?= htmlReady($deployment->title) ?></h1> + <? else : ?> + <h1><?= htmlReady($tool->name) ?></h1> + <? endif ?> + </header> + <dl> + <dt><?= _('Launch-URL') ?></dt> + <dd> + <? if ($deployment && $deployment->launch_url) : ?> + <a href="<?= htmlReady($deployment->launch_url) ?>"> + <?= Icon::create('link-extern')->asImg(['class' => 'text-bottom']) ?> + <?= htmlReady($deployment->launch_url) ?> + </a> + <? else : ?> + <a href="<?= htmlReady($tool->launch_url) ?>"> + <?= Icon::create('link-extern')->asImg(['class' => 'text-bottom']) ?> + <?= htmlReady($tool->launch_url) ?> + </a> + <? endif ?> + </dd> + + <? if ($tool->terms_of_use_url) : ?> + <dt><?= _('Nutzungsbedingungen') ?></dt> + <dd> + <a href="<?= htmlReady($tool->terms_of_use_url) ?>"> + <?= Icon::create('link-extern') ?> + <?= htmlReady($tool->terms_of_use_url) ?> + </a> + </dd> + <? endif ?> + <? if ($tool->privacy_policy_url) : ?> + <dt><?= _('Datenschutzerklärung') ?></dt> + <dd> + <a href="<?= htmlReady($tool->privacy_policy_url) ?>"> + <?= Icon::create('link-extern') ?> + <?= htmlReady($tool->terms_of_use_url) ?> + </a> + </dd> + <? endif ?> + + <? if ($tool) : ?> + <dt><?= _('Client-ID') ?></dt> + <dd><?= htmlReady($tool->id) ?></dd> + <? endif ?> + + <? if ($deployment) : ?> + <dt><?= _('Deployment-ID') ?></dt> + <dd><?= htmlReady($deployment->id) ?></dd> + + <? if ($parameters = $deployment->getCustomParameters()) : ?> + <dt><?= _('LTI custom parameters') ?></dt> + <dd><?= htmlReady($parameters) ?></dd> + <? endif ?> + <? endif ?> + <dt><?= _('Direktlink zum LTI-Tool') ?></dt> + <dd> + <a href="<?= $controller->link_for('course/lti/iframe', $deployment->id) ?>"> + <?= Icon::create('link-extern')->asImg(['class' => 'text-bottom']) ?> + <?= $controller->link_for('course/lti/iframe', $deployment->id) ?> + </a> + </dd> + </dl> + </article> + <article class="studip"> + <header><h1><?= _('Plattform-Konfiguration') ?></h1></header> + <?= $this->render_partial('lti/_platform_data', ['platform' => \Studip\LTI13a\PlatformManager::getPlatformConfiguration()]) ?> + </article> +<? endif ?> diff --git a/app/views/lti/tool/add.php b/app/views/lti/tool/add.php new file mode 100644 index 0000000000000000000000000000000000000000..fda0849c060acccb298c8644c6fa2939608a8963 --- /dev/null +++ b/app/views/lti/tool/add.php @@ -0,0 +1,16 @@ +<?php +/** + * @var AuthenticatedController $controller + * @var string $range_id + * @var LtiTool $tool + * @var LtiDeployment $deployment + */ +?> +<form class="default" method="post" data-dialog="reload-on-close" + action="<?= $controller->link_for('lti/tool/add/' . $range_id . '/' . $tool->id) ?>"> + <?= CSRFProtection::tokenTag() ?> + <?= $this->render_partial('lti/_tool_form_fields', [ + 'tool' => $tool, + 'deployment' => $deployment, + ]) ?> +</form> diff --git a/app/views/lti/tool/edit.php b/app/views/lti/tool/edit.php new file mode 100644 index 0000000000000000000000000000000000000000..5b29dd3340fffd67742a2bce5599ff71016ddae7 --- /dev/null +++ b/app/views/lti/tool/edit.php @@ -0,0 +1,18 @@ +<? +/** + * @var AuthenticatedController $controller + * @var string $range_id + * @var LtiTool $tool + * @var LtiDeployment $deployment + */ +?> +<? if ($tool) : ?> + <form class="default" method="post" data-dialog="reload-on-close" + action="<?= $controller->link_for('lti/tool/edit/' . $range_id . '/' . $tool->id) ?>"> + <?= CSRFProtection::tokenTag() ?> + <?= $this->render_partial('lti/_tool_form_fields', [ + 'tool' => $tool, + 'deployment' => $deployment, + ]) ?> + </form> +<? endif ?> diff --git a/app/views/lti/tool/index.php b/app/views/lti/tool/index.php new file mode 100644 index 0000000000000000000000000000000000000000..c5254400584f4b84dfab6ae42140fca37e4574c3 --- /dev/null +++ b/app/views/lti/tool/index.php @@ -0,0 +1,7 @@ +<?php +/** + * @var LtiTool $tool + * @var LtiDeployment $deployment + */ +?> +<?= $this->render_partial('lti/_tool_info', ['tool' => $tool, 'deployment' => $deployment]) ?> diff --git a/composer.json b/composer.json index 7aef546f92209ea029b67eb42571a7aa76f08921..410e14d1cae37dbedc49acff664b7934f7ad915b 100644 --- a/composer.json +++ b/composer.json @@ -125,7 +125,12 @@ "nyholm/psr7-server": "1.1.0", "erusev/parsedown": "1.7.4", "league/oauth2-client": "2.7.0", - "nette/php-generator": "4.1.5" + "nette/php-generator": "4.1.5", + "oat-sa/lib-lti1p3-core": "^7.1", + "oat-sa/lib-lti1p3-ags": "^2.0", + "phpseclib/phpseclib2_compat": "^1.0", + "oat-sa/lib-lti1p3-deep-linking": "^4.1", + "lcobucci/jwt": "^4.3" }, "replace": { "symfony/polyfill-php73": "*", diff --git a/composer.lock b/composer.lock index 7897e22c5d8504ec8ebc26cffd5441c808320524..d889e8f02a31281b05603cff93c451e21fc08424 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5bc2e4ef41517f46c14d02cfecdd95d9", + "content-hash": "3a2daa57e8228dca24a4ab68a05bb5a3", "packages": [ { "name": "algo26-matthias/idna-convert", @@ -62,6 +62,135 @@ }, "time": "2024-02-14T13:30:05+00:00" }, + { + "name": "brick/math", + "version": "0.12.1", + "source": { + "type": "git", + "url": "https://github.com/brick/math.git", + "reference": "f510c0a40911935b77b86859eb5223d58d660df1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/brick/math/zipball/f510c0a40911935b77b86859eb5223d58d660df1", + "reference": "f510c0a40911935b77b86859eb5223d58d660df1", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.2", + "phpunit/phpunit": "^10.1", + "vimeo/psalm": "5.16.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Brick\\Math\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Arbitrary-precision arithmetic library", + "keywords": [ + "Arbitrary-precision", + "BigInteger", + "BigRational", + "arithmetic", + "bigdecimal", + "bignum", + "bignumber", + "brick", + "decimal", + "integer", + "math", + "mathematics", + "rational" + ], + "support": { + "issues": "https://github.com/brick/math/issues", + "source": "https://github.com/brick/math/tree/0.12.1" + }, + "funding": [ + { + "url": "https://github.com/BenMorel", + "type": "github" + } + ], + "time": "2023-11-29T23:19:16+00:00" + }, + { + "name": "carbonphp/carbon-doctrine-types", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon-doctrine-types.git", + "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/18ba5ddfec8976260ead6e866180bd5d2f71aa1d", + "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "conflict": { + "doctrine/dbal": "<4.0.0 || >=5.0.0" + }, + "require-dev": { + "doctrine/dbal": "^4.0.0", + "nesbot/carbon": "^2.71.0 || ^3.0.0", + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KyleKatarn", + "email": "kylekatarnls@gmail.com" + } + ], + "description": "Types to use Carbon in Doctrine", + "keywords": [ + "carbon", + "date", + "datetime", + "doctrine", + "time" + ], + "support": { + "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "type": "tidelift" + } + ], + "time": "2024-02-09T16:56:22+00:00" + }, { "name": "caxy/php-htmldiff", "version": "v0.1.15", @@ -123,6 +252,50 @@ }, "time": "2023-11-05T23:49:04+00:00" }, + { + "name": "codercat/jwk-to-pem", + "version": "1.1", + "source": { + "type": "git", + "url": "https://github.com/acodercat/php-jwk-to-pem.git", + "reference": "4b3cdcf5f87b9b074f132f763a6b7b82c7d3ff1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/acodercat/php-jwk-to-pem/zipball/4b3cdcf5f87b9b074f132f763a6b7b82c7d3ff1d", + "reference": "4b3cdcf5f87b9b074f132f763a6b7b82c7d3ff1d", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "phpseclib/phpseclib": "^3.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "CoderCat\\JWKToPEM\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "codercat", + "email": "1067302838@qq.com" + } + ], + "description": "Convert JWK to PEM format.", + "support": { + "issues": "https://github.com/acodercat/php-jwk-to-pem/issues", + "source": "https://github.com/acodercat/php-jwk-to-pem/tree/1.1" + }, + "time": "2021-04-28T07:37:03+00:00" + }, { "name": "defuse/php-encryption", "version": "v2.4.0", @@ -1269,38 +1442,39 @@ }, { "name": "lcobucci/jwt", - "version": "5.3.0", + "version": "4.3.0", "source": { "type": "git", "url": "https://github.com/lcobucci/jwt.git", - "reference": "08071d8d2c7f4b00222cc4b1fb6aa46990a80f83" + "reference": "4d7de2fe0d51a96418c0d04004986e410e87f6b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/lcobucci/jwt/zipball/08071d8d2c7f4b00222cc4b1fb6aa46990a80f83", - "reference": "08071d8d2c7f4b00222cc4b1fb6aa46990a80f83", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/4d7de2fe0d51a96418c0d04004986e410e87f6b4", + "reference": "4d7de2fe0d51a96418c0d04004986e410e87f6b4", "shasum": "" }, "require": { + "ext-hash": "*", + "ext-json": "*", + "ext-mbstring": "*", "ext-openssl": "*", "ext-sodium": "*", - "php": "~8.1.0 || ~8.2.0 || ~8.3.0", - "psr/clock": "^1.0" + "lcobucci/clock": "^2.0 || ^3.0", + "php": "^7.4 || ^8.0" }, "require-dev": { - "infection/infection": "^0.27.0", - "lcobucci/clock": "^3.0", - "lcobucci/coding-standard": "^11.0", - "phpbench/phpbench": "^1.2.9", - "phpstan/extension-installer": "^1.2", - "phpstan/phpstan": "^1.10.7", - "phpstan/phpstan-deprecation-rules": "^1.1.3", - "phpstan/phpstan-phpunit": "^1.3.10", - "phpstan/phpstan-strict-rules": "^1.5.0", - "phpunit/phpunit": "^10.2.6" - }, - "suggest": { - "lcobucci/clock": ">= 3.0" + "infection/infection": "^0.21", + "lcobucci/coding-standard": "^6.0", + "mikey179/vfsstream": "^1.6.7", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^1.4", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-phpunit": "^1.0", + "phpstan/phpstan-strict-rules": "^1.0", + "phpunit/php-invoker": "^3.1", + "phpunit/phpunit": "^9.5" }, "type": "library", "autoload": { @@ -1326,7 +1500,7 @@ ], "support": { "issues": "https://github.com/lcobucci/jwt/issues", - "source": "https://github.com/lcobucci/jwt/tree/5.3.0" + "source": "https://github.com/lcobucci/jwt/tree/4.3.0" }, "funding": [ { @@ -1338,7 +1512,7 @@ "type": "patreon" } ], - "time": "2024-04-11T23:07:54+00:00" + "time": "2023-01-02T13:28:00+00:00" }, { "name": "league/event", @@ -2076,6 +2250,112 @@ }, "time": "2022-11-28T03:29:06+00:00" }, + { + "name": "nesbot/carbon", + "version": "3.8.2", + "source": { + "type": "git", + "url": "https://github.com/briannesbitt/Carbon.git", + "reference": "e1268cdbc486d97ce23fef2c666dc3c6b6de9947" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/e1268cdbc486d97ce23fef2c666dc3c6b6de9947", + "reference": "e1268cdbc486d97ce23fef2c666dc3c6b6de9947", + "shasum": "" + }, + "require": { + "carbonphp/carbon-doctrine-types": "<100.0", + "ext-json": "*", + "php": "^8.1", + "psr/clock": "^1.0", + "symfony/clock": "^6.3 || ^7.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation": "^4.4.18 || ^5.2.1|| ^6.0 || ^7.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^3.57.2", + "kylekatarnls/multi-tester": "^2.5.3", + "ondrejmirtes/better-reflection": "^6.25.0.4", + "phpmd/phpmd": "^2.15.0", + "phpstan/extension-installer": "^1.3.1", + "phpstan/phpstan": "^1.11.2", + "phpunit/phpunit": "^10.5.20", + "squizlabs/php_codesniffer": "^3.9.0" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev", + "dev-2.x": "2.x-dev" + }, + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "https://markido.com" + }, + { + "name": "kylekatarnls", + "homepage": "https://github.com/kylekatarnls" + } + ], + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "https://carbon.nesbot.com", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "docs": "https://carbon.nesbot.com/docs", + "issues": "https://github.com/briannesbitt/Carbon/issues", + "source": "https://github.com/briannesbitt/Carbon" + }, + "funding": [ + { + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", + "type": "tidelift" + } + ], + "time": "2024-11-07T17:46:48+00:00" + }, { "name": "nette/php-generator", "version": "v4.1.5", @@ -2484,108 +2764,250 @@ "time": "2023-11-08T09:30:43+00:00" }, { - "name": "okvpn/clock-lts", - "version": "1.0.0", + "name": "oat-sa/lib-lti1p3-ags", + "version": "2.0.2", "source": { "type": "git", - "url": "https://github.com/okvpn/clock-lts.git", - "reference": "5e7dc00d23a7d65e8ed2b0ff7a4dcf7860c05a14" + "url": "https://github.com/oat-sa/lib-lti1p3-ags.git", + "reference": "3967915ab99e1a01b8445aef94bae6e79ff3dac0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/okvpn/clock-lts/zipball/5e7dc00d23a7d65e8ed2b0ff7a4dcf7860c05a14", - "reference": "5e7dc00d23a7d65e8ed2b0ff7a4dcf7860c05a14", + "url": "https://api.github.com/repos/oat-sa/lib-lti1p3-ags/zipball/3967915ab99e1a01b8445aef94bae6e79ff3dac0", + "reference": "3967915ab99e1a01b8445aef94bae6e79ff3dac0", "shasum": "" }, "require": { - "php": ">=7.2", - "psr/clock": "^1.0" - }, - "provide": { - "psr/clock-implementation": "1.0" - }, - "replace": { - "lcobucci/clock": "*" + "ext-json": "*", + "nesbot/carbon": "^2.72 || ^3.0", + "nyholm/psr7": "^1.8", + "oat-sa/lib-lti1p3-core": "^7.0", + "php": ">=8.0.0", + "psr/http-message": "^1.1 || ^2.0" }, "require-dev": { - "infection/infection": "^0.27", - "lcobucci/coding-standard": "^11.0.0", - "phpstan/extension-installer": "^1.3.1", - "phpstan/phpstan": "^1.10.25", - "phpstan/phpstan-deprecation-rules": "^1.1.3", - "phpstan/phpstan-phpunit": "^1.3.13", - "phpstan/phpstan-strict-rules": "^1.5.1", - "phpunit/phpunit": "^10.2.3" + "php-coveralls/php-coveralls": "^2.4", + "phpunit/phpunit": "^9.6", + "psalm/plugin-phpunit": "^0.15.1", + "vimeo/psalm": "^4.6" }, "type": "library", "autoload": { "psr-4": { - "Lcobucci\\Clock\\": "src" + "OAT\\Library\\Lti1p3Ags\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" - ], - "authors": [ - { - "name": "LuÃs Cobucci", - "email": "lcobucci@gmail.com" - }, - { - "name": "UÅ‚adzimir Tsykun", - "email": "vtsykun@okvpn.org" - } + "GPL-2.0-only" ], - "description": "Adding support for a wider range of PHP versions to lcobucci/clock", + "description": "OAT LTI 1.3 Advantage AGS Library", "support": { - "source": "https://github.com/okvpn/clock-lts/tree/1.0.0" + "issues": "https://github.com/oat-sa/lib-lti1p3-ags/issues", + "source": "https://github.com/oat-sa/lib-lti1p3-ags/tree/2.0.2" }, - "time": "2023-09-26T11:13:49+00:00" + "time": "2024-03-09T20:25:26+00:00" }, { - "name": "opis/json-schema", - "version": "2.3.0", + "name": "oat-sa/lib-lti1p3-core", + "version": "7.1.0", "source": { "type": "git", - "url": "https://github.com/opis/json-schema.git", - "reference": "c48df6d7089a45f01e1c82432348f2d5976f9bfb" + "url": "https://github.com/oat-sa/lib-lti1p3-core.git", + "reference": "39dbc6880734f99ca41488e5ebcd5b6ad8fc26f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opis/json-schema/zipball/c48df6d7089a45f01e1c82432348f2d5976f9bfb", - "reference": "c48df6d7089a45f01e1c82432348f2d5976f9bfb", + "url": "https://api.github.com/repos/oat-sa/lib-lti1p3-core/zipball/39dbc6880734f99ca41488e5ebcd5b6ad8fc26f4", + "reference": "39dbc6880734f99ca41488e5ebcd5b6ad8fc26f4", "shasum": "" }, "require": { + "codercat/jwk-to-pem": "^1.1", "ext-json": "*", - "opis/string": "^2.0", - "opis/uri": "^1.0", - "php": "^7.4 || ^8.0" + "ext-openssl": "*", + "guzzlehttp/guzzle": "^7.8", + "lcobucci/jwt": "^4.3", + "league/oauth2-server": "^8.5", + "nesbot/carbon": "^2.72 || ^3.0", + "nyholm/psr7": "^1.8", + "php": ">=8.0.0", + "phpseclib/phpseclib": "^3.0.37", + "psr/cache": "^2.0 || ^3.0", + "psr/http-message": "^1.1 || ^2.0", + "psr/http-server-handler": "^1.0", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "ramsey/uuid": "^3.9 || ^4.7" }, "require-dev": { - "ext-bcmath": "*", - "ext-intl": "*", - "phpunit/phpunit": "^9.0" + "cache/array-adapter": "^1.2", + "php-coveralls/php-coveralls": "^2.7", + "phpunit/phpunit": "^9.6", + "psalm/plugin-phpunit": "^0.15", + "vimeo/psalm": "^4.30" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.x-dev" - } - }, "autoload": { "psr-4": { - "Opis\\JsonSchema\\": "src/" + "OAT\\Library\\Lti1p3Core\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "Apache-2.0" + "GPL-2.0-only" ], - "authors": [ - { - "name": "Sorin Sarca", + "description": "OAT LTI 1.3 Core Library", + "support": { + "issues": "https://github.com/oat-sa/lib-lti1p3-core/issues", + "source": "https://github.com/oat-sa/lib-lti1p3-core/tree/7.1.0" + }, + "time": "2024-11-29T11:31:29+00:00" + }, + { + "name": "oat-sa/lib-lti1p3-deep-linking", + "version": "4.1.0", + "source": { + "type": "git", + "url": "https://github.com/oat-sa/lib-lti1p3-deep-linking.git", + "reference": "7e11d03b690fd2a726edd29d3002ee0777985bc1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/oat-sa/lib-lti1p3-deep-linking/zipball/7e11d03b690fd2a726edd29d3002ee0777985bc1", + "reference": "7e11d03b690fd2a726edd29d3002ee0777985bc1", + "shasum": "" + }, + "require": { + "ext-json": "*", + "oat-sa/lib-lti1p3-core": "^7.0", + "php": ">=8.0.0" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.4", + "phpunit/phpunit": "^9.6", + "psalm/plugin-phpunit": "^0.15", + "vimeo/psalm": "^4.6" + }, + "type": "library", + "autoload": { + "psr-4": { + "OAT\\Library\\Lti1p3DeepLinking\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-only" + ], + "description": "OAT LTI 1.3 Deep Linking Library", + "support": { + "issues": "https://github.com/oat-sa/lib-lti1p3-deep-linking/issues", + "source": "https://github.com/oat-sa/lib-lti1p3-deep-linking/tree/4.1.0" + }, + "time": "2023-12-22T08:22:05+00:00" + }, + { + "name": "okvpn/clock-lts", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/okvpn/clock-lts.git", + "reference": "5e7dc00d23a7d65e8ed2b0ff7a4dcf7860c05a14" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/okvpn/clock-lts/zipball/5e7dc00d23a7d65e8ed2b0ff7a4dcf7860c05a14", + "reference": "5e7dc00d23a7d65e8ed2b0ff7a4dcf7860c05a14", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "psr/clock": "^1.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "replace": { + "lcobucci/clock": "*" + }, + "require-dev": { + "infection/infection": "^0.27", + "lcobucci/coding-standard": "^11.0.0", + "phpstan/extension-installer": "^1.3.1", + "phpstan/phpstan": "^1.10.25", + "phpstan/phpstan-deprecation-rules": "^1.1.3", + "phpstan/phpstan-phpunit": "^1.3.13", + "phpstan/phpstan-strict-rules": "^1.5.1", + "phpunit/phpunit": "^10.2.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Lcobucci\\Clock\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "LuÃs Cobucci", + "email": "lcobucci@gmail.com" + }, + { + "name": "UÅ‚adzimir Tsykun", + "email": "vtsykun@okvpn.org" + } + ], + "description": "Adding support for a wider range of PHP versions to lcobucci/clock", + "support": { + "source": "https://github.com/okvpn/clock-lts/tree/1.0.0" + }, + "time": "2023-09-26T11:13:49+00:00" + }, + { + "name": "opis/json-schema", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/opis/json-schema.git", + "reference": "c48df6d7089a45f01e1c82432348f2d5976f9bfb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/json-schema/zipball/c48df6d7089a45f01e1c82432348f2d5976f9bfb", + "reference": "c48df6d7089a45f01e1c82432348f2d5976f9bfb", + "shasum": "" + }, + "require": { + "ext-json": "*", + "opis/string": "^2.0", + "opis/uri": "^1.0", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "ext-bcmath": "*", + "ext-intl": "*", + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opis\\JsonSchema\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Sorin Sarca", "email": "sarca_sorin@hotmail.com" }, { @@ -3267,6 +3689,54 @@ ], "time": "2024-03-03T02:14:58+00:00" }, + { + "name": "phpseclib/phpseclib2_compat", + "version": "1.0.6", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib2_compat.git", + "reference": "90976f25d6c2ff936878624b9cfaa322db11dde7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib2_compat/zipball/90976f25d6c2ff936878624b9cfaa322db11dde7", + "reference": "90976f25d6c2ff936878624b9cfaa322db11dde7", + "shasum": "" + }, + "require": { + "phpseclib/phpseclib": "^3.0" + }, + "provide": { + "phpseclib/phpseclib": "2.0.47" + }, + "require-dev": { + "phpunit/phpunit": "^5.7|^6.0|^9.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "phpseclib\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + } + ], + "description": "phpseclib 2.0 polyfill built with phpseclib 3.0", + "homepage": "https://github.com/phpseclib/phpseclib2_compat", + "support": { + "issues": "https://github.com/phpseclib/phpseclib2_compat/issues", + "source": "https://github.com/phpseclib/phpseclib2_compat" + }, + "time": "2024-02-26T14:37:15+00:00" + }, { "name": "psr/cache", "version": "3.0.0", @@ -3914,6 +4384,187 @@ }, "time": "2019-03-08T08:55:37+00:00" }, + { + "name": "ramsey/collection", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/ramsey/collection.git", + "reference": "a4b48764bfbb8f3a6a4d1aeb1a35bb5e9ecac4a5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/collection/zipball/a4b48764bfbb8f3a6a4d1aeb1a35bb5e9ecac4a5", + "reference": "a4b48764bfbb8f3a6a4d1aeb1a35bb5e9ecac4a5", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "captainhook/plugin-composer": "^5.3", + "ergebnis/composer-normalize": "^2.28.3", + "fakerphp/faker": "^1.21", + "hamcrest/hamcrest-php": "^2.0", + "jangregor/phpstan-prophecy": "^1.0", + "mockery/mockery": "^1.5", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpcsstandards/phpcsutils": "^1.0.0-rc1", + "phpspec/prophecy-phpunit": "^2.0", + "phpstan/extension-installer": "^1.2", + "phpstan/phpstan": "^1.9", + "phpstan/phpstan-mockery": "^1.1", + "phpstan/phpstan-phpunit": "^1.3", + "phpunit/phpunit": "^9.5", + "psalm/plugin-mockery": "^1.1", + "psalm/plugin-phpunit": "^0.18.4", + "ramsey/coding-standard": "^2.0.3", + "ramsey/conventional-commits": "^1.3", + "vimeo/psalm": "^5.4" + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + }, + "ramsey/conventional-commits": { + "configFile": "conventional-commits.json" + } + }, + "autoload": { + "psr-4": { + "Ramsey\\Collection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Ramsey", + "email": "ben@benramsey.com", + "homepage": "https://benramsey.com" + } + ], + "description": "A PHP library for representing and manipulating collections.", + "keywords": [ + "array", + "collection", + "hash", + "map", + "queue", + "set" + ], + "support": { + "issues": "https://github.com/ramsey/collection/issues", + "source": "https://github.com/ramsey/collection/tree/2.0.0" + }, + "funding": [ + { + "url": "https://github.com/ramsey", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/ramsey/collection", + "type": "tidelift" + } + ], + "time": "2022-12-31T21:50:55+00:00" + }, + { + "name": "ramsey/uuid", + "version": "4.7.6", + "source": { + "type": "git", + "url": "https://github.com/ramsey/uuid.git", + "reference": "91039bc1faa45ba123c4328958e620d382ec7088" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/91039bc1faa45ba123c4328958e620d382ec7088", + "reference": "91039bc1faa45ba123c4328958e620d382ec7088", + "shasum": "" + }, + "require": { + "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12", + "ext-json": "*", + "php": "^8.0", + "ramsey/collection": "^1.2 || ^2.0" + }, + "replace": { + "rhumsaa/uuid": "self.version" + }, + "require-dev": { + "captainhook/captainhook": "^5.10", + "captainhook/plugin-composer": "^5.3", + "dealerdirect/phpcodesniffer-composer-installer": "^0.7.0", + "doctrine/annotations": "^1.8", + "ergebnis/composer-normalize": "^2.15", + "mockery/mockery": "^1.3", + "paragonie/random-lib": "^2", + "php-mock/php-mock": "^2.2", + "php-mock/php-mock-mockery": "^1.3", + "php-parallel-lint/php-parallel-lint": "^1.1", + "phpbench/phpbench": "^1.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-mockery": "^1.1", + "phpstan/phpstan-phpunit": "^1.1", + "phpunit/phpunit": "^8.5 || ^9", + "ramsey/composer-repl": "^1.4", + "slevomat/coding-standard": "^8.4", + "squizlabs/php_codesniffer": "^3.5", + "vimeo/psalm": "^4.9" + }, + "suggest": { + "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", + "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", + "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", + "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", + "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Ramsey\\Uuid\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "keywords": [ + "guid", + "identifier", + "uuid" + ], + "support": { + "issues": "https://github.com/ramsey/uuid/issues", + "source": "https://github.com/ramsey/uuid/tree/4.7.6" + }, + "funding": [ + { + "url": "https://github.com/ramsey", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/ramsey/uuid", + "type": "tidelift" + } + ], + "time": "2024-04-27T21:32:50+00:00" + }, { "name": "scssphp/scssphp", "version": "v1.12.1", @@ -4189,6 +4840,80 @@ ], "time": "2024-04-15T07:35:15+00:00" }, + { + "name": "symfony/clock", + "version": "v6.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "b2bf55c4dd115003309eafa87ee7df9ed3dde81b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/b2bf55c4dd115003309eafa87ee7df9ed3dde81b", + "reference": "b2bf55c4dd115003309eafa87ee7df9ed3dde81b", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/clock": "^1.0", + "symfony/polyfill-php83": "^1.28" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v6.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:18:03+00:00" + }, { "name": "symfony/console", "version": "v6.4.7", @@ -5046,6 +5771,179 @@ ], "time": "2024-05-31T14:49:08+00:00" }, + { + "name": "symfony/translation", + "version": "v6.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "bee9bfabfa8b4045a66bf82520e492cddbaffa66" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/bee9bfabfa8b4045a66bf82520e492cddbaffa66", + "reference": "bee9bfabfa8b4045a66bf82520e492cddbaffa66", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/translation-contracts": "^2.5|^3.0" + }, + "conflict": { + "symfony/config": "<5.4", + "symfony/console": "<5.4", + "symfony/dependency-injection": "<5.4", + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<5.4", + "symfony/service-contracts": "<2.5", + "symfony/twig-bundle": "<5.4", + "symfony/yaml": "<5.4" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^4.18|^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/console": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/finder": "^5.4|^6.0|^7.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0", + "symfony/intl": "^5.4|^6.0|^7.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^5.4|^6.0|^7.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v6.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-27T18:14:25+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.5.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "4667ff3bd513750603a09c8dedbea942487fb07c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/4667ff3bd513750603a09c8dedbea942487fb07c", + "reference": "4667ff3bd513750603a09c8dedbea942487fb07c", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.5-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.5.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:20:29+00:00" + }, { "name": "symfony/var-dumper", "version": "v6.4.7", @@ -8745,9 +9643,9 @@ "ext-dom": "*", "ext-iconv": "*" }, - "platform-dev": {}, + "platform-dev": [], "platform-overrides": { "php": "8.1" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.3.0" } diff --git a/db/migrations/6.0.39_add_lti13a.php b/db/migrations/6.0.39_add_lti13a.php new file mode 100644 index 0000000000000000000000000000000000000000..3aac9638f995d6b786a36f283c93647df421b682 --- /dev/null +++ b/db/migrations/6.0.39_add_lti13a.php @@ -0,0 +1,235 @@ +<?php + + +class AddLti13a extends Migration +{ + public function description() + { + return 'Add tables and settings for the LTI 1.3A functionality.'; + } + + protected function up() + { + $db = DBManager::get(); + + $db->exec( + "CREATE TABLE IF NOT EXISTS keyrings ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + range_id CHAR(32) COLLATE latin1_bin NOT NULL, + range_type VARCHAR(16) NOT NULL, + public_key BLOB(16384) NOT NULL, + private_key BLOB(16384) NOT NULL DEFAULT '', + passphrase VARCHAR(512) NOT NULL DEFAULT '', + mkdate INT(11) NOT NULL DEFAULT 0, + chdate INT(11) NOT NULL DEFAULT 0 + )" + ); + $db->exec("ALTER TABLE `keyrings` ADD INDEX(`range_id`, `range_type`)"); + + $db->exec("RENAME TABLE `lti_tool` TO lti_tools"); + + $db->exec( + "ALTER TABLE `lti_tools` + ADD COLUMN lti_version VARCHAR(8) NOT NULL DEFAULT '1.3a', + ADD COLUMN range_id CHAR(32) COLLATE latin1_bin NOT NULL, + ADD COLUMN oidc_init_url VARCHAR(255) NOT NULL DEFAULT '', + ADD COLUMN oauth2_client_id INT NULL DEFAULT NULL, + ADD COLUMN jwks_url VARCHAR(255) NOT NULL DEFAULT '', + ADD COLUMN jwks_key_id VARCHAR(255) NOT NULL DEFAULT '', + ADD COLUMN deep_linking_url VARCHAR(255) NOT NULL DEFAULT '', + ADD COLUMN terms_of_use_url VARCHAR(255) NOT NULL DEFAULT '', + ADD COLUMN privacy_policy_url VARCHAR(255) NOT NULL DEFAULT '', + ADD COLUMN data_protection_notes TEXT DEFAULT NULL" + ); + + $this->migrateLtiDataTable(); + + $this->addConfig(); + + $this->migrateLtiToolTitle(); + } + + protected function migrateLtiDataTable() + { + $db = DBManager::get(); + $db->exec("RENAME TABLE `lti_data` TO lti_deployments"); + + //Create LTI tool instances for the old LTI 1.0/1.1 tools + //that have been configured directly in a course: + $stmt = $db->prepare( + "SELECT `id`, `tool_id`, `title`, `options` + FROM `lti_deployments` + WHERE `tool_id` = '0'" + ); + $update_stmt = $db->prepare( + "UPDATE `lti_deployments` + SET `tool_id` = :new_tool_id, + `options` = :new_options + WHERE `id` = :deployment_id" + ); + $create_tool_stmt = $db->prepare( + "INSERT INTO `lti_tools` + (`id`, `name`, `launch_url`, `consumer_key`, `consumer_secret`, + `custom_parameters`, `send_lis_person`, `lti_version`, `is_global`, + `mkdate`, `chdate`) + VALUES + (:id, :name, :launch_url, :consumer_key, :consumer_secret, + :custom_parameters, :send_lis_person, '1.1', '0', + UNIX_TIMESTAMP(), UNIX_TIMESTAMP())" + ); + $new_tool_id_stmt = $db->prepare("SELECT MAX(`id`) + 1 FROM `lti_tools`"); + $stmt->execute(); + while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { + if (empty($row['id']) || empty($row['title']) || empty($row['options'])) { + //That tool cannot be migrated. + continue; + } + //Create a new tool and migrate the data from the options field: + $options = json_decode($row['options'], true); + $new_tool_id_stmt->execute(); + $new_tool_id = $new_tool_id_stmt->fetchColumn(); + $success = $create_tool_stmt->execute([ + 'id' => $new_tool_id, + 'name' => $row['title'], + 'launch_url' => $options['launch_url'] ?? '', + 'consumer_key' => $options['consumer_key'] ?? '', + 'consumer_secret' => $options['consumer_secret'] ?? '', + 'custom_parameters' => $options['custom_parameters'] ?? '', + 'send_lis_person' => $options['send_lis_person'] ?? '0', + ]); + unset( + $options['launch_url'], + $options['consumer_key'], + $options['consumer_secret'], + $options['custom_parameters'], + $options['send_lis_person'] + ); + if ($success) { + $update_stmt->execute([ + 'new_tool_id' => $new_tool_id, + 'new_options' => json_encode($options ?? []), + 'deployment_id' => $row['id'] + ]); + } + } + + $db->exec( + "CREATE TABLE IF NOT EXISTS lti_tool_privacy_settings ( + tool_id INT(11) NOT NULL, + user_id CHAR(32) COLLATE latin1_bin NOT NULL, + accepted TINYINT(1) NOT NULL DEFAULT 0, + allowed_optional_fields VARCHAR(256) NOT NULL DEFAULT '', + mkdate INT(11) NOT NULL DEFAULT 0, + chdate INT(11) NOT NULL DEFAULT 0, + PRIMARY KEY (tool_id, user_id) + )" + ); + } + + protected function addConfig() + { + $db = DBManager::get(); + + $configs = [ + [ + 'LTI_DATA_PROTECTION_DEFAULT_WARNING', + 'Bitte beachten Sie die Datenschutzhinweise. Wenn Sie zugestimmt haben, werden Ihre Daten weitergegeben.', + 'string', + 'global', + 'Eine Warnung zur Weitergabe personenbezogener Daten, die standardmäßig angezeigt wird, wenn Personen aus einer Veranstaltung in ein LTI-Tool wechseln.' + ], + [ + 'LTI_DATA_PROTECTION_COURSE_WARNING', + '', + 'string', + 'course', + 'Eine in einer Veranstaltung angepasste Warnung zur Weitergabe personenbezogener Daten, die angezeigt wird, wenn Personen aus der Veranstaltung in ein LTI-Tool wechseln.' + ], + [ + 'LTI_ALLOW_TOOL_CONFIG_IN_COURSE', + '1', + 'boolean', + 'global', + 'Soll es Lehrenden möglich sein, eigene LTI-Tools zu konfigurieren? Wenn nicht, können nur global konfigurierte LTI-Tools in Veranstaltungen angebunden werden.' + ] + ]; + + $stmt = $db->prepare( + "INSERT INTO `config` + (`field`, `value`, `type`, `range`, `description`, `section`, `mkdate`, `chdate`) + VALUES + (:field, :value, :type, :range, :description, 'LTI', UNIX_TIMESTAMP(), UNIX_TIMESTAMP())" + ); + + foreach ($configs as $c) { + $stmt->execute([ + 'field' => $c[0], + 'value' => $c[1], + 'type' => $c[2], + 'range' => $c[3], + 'description' => $c[4], + ]); + } + } + + protected function migrateLtiToolTitle() + { + $db = DBManager::get(); + $plugin_id = $db->query("SELECT `pluginid` FROM `plugins` WHERE `pluginclassname` = 'LtiToolModule'")->fetchColumn(); + if ($plugin_id === false) { + //The LTI core module is not registered. We cannot continue. + return; + } + + $fetch_stmt = $db->prepare("SELECT `range_id`, `value` FROM `config_values` where `field` = 'LTI_TOOL_TITLE'"); + $get_tool_metadata_stmt = $db->prepare( + "SELECT `metadata` FROM `tools_activated` + WHERE `range_type` = 'course' AND `plugin_id` = :plugin_id AND `range_id` = :range_id" + ); + $update_tool_stmt = $db->prepare( + "UPDATE `tools_activated` + SET `metadata` = :metadata, + `chdate` = UNIX_TIMESTAMP() + WHERE + `range_type` = 'course' + AND `range_id` = :range_id + AND `plugin_id` = :plugin_id" + ); + + $fetch_stmt->execute(); + while ($row = $fetch_stmt->fetch()) { + $get_tool_metadata_stmt->execute(['plugin_id' => $plugin_id, 'range_id' => $row['range_id']]); + $metadata_json = $get_tool_metadata_stmt->fetchColumn(); + if ($metadata_json === false) { + //Tool not activated, therefore, nothing needs to be done. + continue; + } + $metadata = []; + if ($metadata_json) { + //Decode the JSON to get an array that can be modified: + $metadata = json_decode($metadata_json, true); + } + if (!$metadata) { + //In case the decoding did not work or there is nothing to decode, create a new array: + $metadata = []; + } + $metadata['displayname'] = $row['value']; + + $update_tool_stmt->execute([ + 'range_id' => $row['range_id'], + 'plugin_id' => $plugin_id, + 'metadata' => json_encode($metadata) + ]); + } + + //At this point, all entries from LTI_TOOL_TITLE have been migrated so that that configuration + //can be removed: + $db->exec("DELETE FROM `config_values` where `field` = 'LTI_TOOL_TITLE'"); + $db->exec("DELETE FROM `config` WHERE `field` = 'LTI_TOOL_TITLE'"); + } + + protected function down() + { + //Uhhh... no! + } +} diff --git a/db/studip_default_data.sql b/db/studip_default_data.sql index 511f124fccfbe2db46908ef38000672e3f08e9ea..12ab9b58e65fe4a5a38c081aa72ee1aa4ea2ee42 100644 --- a/db/studip_default_data.sql +++ b/db/studip_default_data.sql @@ -249,7 +249,6 @@ INSERT INTO `config` (`field`, `value`, `type`, `range`, `section`, `mkdate`, `c INSERT INTO `config` (`field`, `value`, `type`, `range`, `section`, `mkdate`, `chdate`, `description`) VALUES('LOGIN_FAQ_TITLE', 'Hinweise zum Login', 'i18n', 'global', 'Loginseite', 1716385357, 1716385357, 'Ãœberschrift für den FAQ-Bereich auf der Loginseite'); INSERT INTO `config` (`field`, `value`, `type`, `range`, `section`, `mkdate`, `chdate`, `description`) VALUES('LOGIN_FAQ_VISIBILITY', '1', 'boolean', 'global', 'Loginseite', 1716385357, 1716385357, 'Soll der FAQ-Bereich auf der Loginseite sichtbar sein?'); INSERT INTO `config` (`field`, `value`, `type`, `range`, `section`, `mkdate`, `chdate`, `description`) VALUES('LOG_ENABLE', '1', 'boolean', 'global', 'modules', 1293118059, 1293118059, 'Schaltet ein oder aus, ob das Log global verfügbar ist.'); -INSERT INTO `config` (`field`, `value`, `type`, `range`, `section`, `mkdate`, `chdate`, `description`) VALUES('LTI_TOOL_TITLE', 'LTI-Tool', 'string', 'course', '', 1557244743, 1557244743, 'Voreinstellung für den Titel des Reiters \"LTI-Tool\" im Kurs.'); INSERT INTO `config` (`field`, `value`, `type`, `range`, `section`, `mkdate`, `chdate`, `description`) VALUES('MAILQUEUE_ENABLE', '0', 'boolean', 'global', 'global', 1403258017, 1403258017, 'Aktiviert bzw. deaktiviert die Mailqueue'); INSERT INTO `config` (`field`, `value`, `type`, `range`, `section`, `mkdate`, `chdate`, `description`) VALUES('MAILQUEUE_SEND_LIMIT', '0', 'integer', 'global', 'global', 1462287310, 1462287310, 'Wieviele Mails soll die Mailqueue maximal auf einmal an den Mailserver schicken. 0 für unendlich viele.'); INSERT INTO `config` (`field`, `value`, `type`, `range`, `section`, `mkdate`, `chdate`, `description`) VALUES('MAIL_AS_HTML', '0', 'boolean', 'user', '', 1293118060, 1293118060, 'Benachrichtigungen werden im HTML-Format versandt'); diff --git a/lib/classes/LTI13a/Identity.php b/lib/classes/LTI13a/Identity.php new file mode 100644 index 0000000000000000000000000000000000000000..238ad926e1809827f666b7536037f4d8af70eae1 --- /dev/null +++ b/lib/classes/LTI13a/Identity.php @@ -0,0 +1,103 @@ +<?php + +namespace Studip\LTI13a; + +use OAT\Library\Lti1p3Core\Message\Payload\MessagePayloadInterface; +use OAT\Library\Lti1p3Core\User\UserIdentityInterface; +use OAT\Library\Lti1p3Core\Util\Collection\CollectionInterface; + + +class Identity implements UserIdentityInterface +{ + protected \User $user; + + protected array $allowed_optional_fields = []; + + public function __construct(\User $user, \LtiTool $tool) + { + $this->user = $user; + + $privacy_settings = \LtiToolPrivacySettings::findOneBySQL( + '`tool_id` = :tool_id AND `user_id` = :user_id', + ['tool_id' => $tool->id, 'user_id' => $user->id] + ); + if ($privacy_settings) { + $this->allowed_optional_fields = explode(',', $privacy_settings->allowed_optional_fields); + } + } + + #[\Override] + public function getIdentifier(): string + { + return $this->user->id; + } + + #[\Override] + public function getName(): ?string + { + return $this->user->getFullName(); + } + + #[\Override] + public function getEmail(): ?string + { + return $this->user->email; + } + + #[\Override] + public function getGivenName(): ?string + { + return $this->user->vorname; + } + + #[\Override] + public function getFamilyName(): ?string + { + return $this->user->nachname; + } + + #[\Override] + public function getMiddleName(): ?string + { + return ''; + } + + #[\Override] + public function getLocale(): ?string + { + if (!in_array('lang', $this->allowed_optional_fields)) { + return ''; + } + return $this->user->preferred_language; + } + + #[\Override] + public function getPicture(): ?string + { + if (!in_array('avatar_url', $this->allowed_optional_fields)) { + return ''; + } + return \Avatar::getAvatar($this->user->id)->getURL(\Avatar::MEDIUM); + } + + #[\Override] + public function getAdditionalProperties(): CollectionInterface + { + return []; + } + + #[\Override] + public function normalize(): array + { + return [ + MessagePayloadInterface::CLAIM_SUB => $this->getIdentifier(), + MessagePayloadInterface::CLAIM_USER_NAME => $this->getName(), + MessagePayloadInterface::CLAIM_USER_EMAIL => $this->getEmail(), + MessagePayloadInterface::CLAIM_USER_GIVEN_NAME => $this->getGivenName(), + MessagePayloadInterface::CLAIM_USER_FAMILY_NAME => $this->getFamilyName(), + MessagePayloadInterface::CLAIM_USER_MIDDLE_NAME => $this->getMiddleName(), + MessagePayloadInterface::CLAIM_USER_LOCALE => $this->getLocale(), + MessagePayloadInterface::CLAIM_USER_PICTURE => $this->getPicture() + ]; + } +} diff --git a/lib/classes/LTI13a/KeyChainFactory.php b/lib/classes/LTI13a/KeyChainFactory.php new file mode 100644 index 0000000000000000000000000000000000000000..98c88057616d5d19bdcc85ebafb9e919a2b12fcd --- /dev/null +++ b/lib/classes/LTI13a/KeyChainFactory.php @@ -0,0 +1,56 @@ +<?php + +namespace Studip\LTI13a; + +use OAT\Library\Lti1p3Core\Security\Key\KeyChain; +use OAT\Library\Lti1p3Core\Security\Key\KeyChainFactoryInterface; +use OAT\Library\Lti1p3Core\Security\Key\KeyChainInterface; +use OAT\Library\Lti1p3Core\Security\Key\KeyChainRepositoryInterface; +use OAT\Library\Lti1p3Core\Security\Key\KeyInterface; + +class KeyChainFactory implements KeyChainFactoryInterface +{ + /** + * @param string $identifier THe ID of the keychain. + * + * @param string $keySetName The name of the keychain. + * + * @param mixed $publicKey The public key for the keychain. + * + * @param mixed $privateKey The private key for the keychain. + * + * @param string|null $privateKeyPassPhrase The passphrase for the private key. + * + * @param string $algorithm The algorithm to use. + * + * @return KeyChainInterface The generated KeyChainInterface instance. + * + * @throws \Studip\KeyringException In case no keychain can be generated. + */ + public function create( + string $identifier, + string $keySetName, + $publicKey, + $privateKey = null, + ?string $privateKeyPassPhrase = null, + string $algorithm = KeyInterface::ALG_RS256 + ) : KeyChainInterface + { + $keyring = null; + if (!$publicKey && !$privateKey) { + $keyring = \Keyring::generate($identifier, 'global', $privateKeyPassPhrase, $algorithm); + } else { + $keyring = \Keyring::findOneBySQL('range_id = :id', ['id' => $identifier]); + if ($keyring) { + return $keyring->toKeyChain(); + } else { + throw new \Studip\KeyringException( + 'Keyring not found.', + \Studip\KeyringException::NOT_FOUND + ); + + } + } + return $keyring; + } +} diff --git a/lib/classes/LTI13a/KeyManager.php b/lib/classes/LTI13a/KeyManager.php new file mode 100644 index 0000000000000000000000000000000000000000..13a60015c761c046be95ac2f6c87543f7e73ac52 --- /dev/null +++ b/lib/classes/LTI13a/KeyManager.php @@ -0,0 +1,33 @@ +<?php + +namespace Studip\LTI13a; + +use OAT\Library\Lti1p3Core\Security\Key\KeyChainInterface; +use OAT\Library\Lti1p3Core\Security\Key\KeyChainRepositoryInterface; + +class KeyManager implements KeyChainRepositoryInterface +{ + + #[\Override] + public function find(string $identifier): ?KeyChainInterface + { + $keyring = \Keyring::findOneByRange_id($identifier); + if ($keyring) { + return $keyring->toKeyChain(); + } + return null; + } + + /** + * @inheritDoc + */ + #[\Override] + public function findByKeySetName(string $keySetName): array + { + $keyring = \Keyring::findOneByRange_id($keySetName); + if ($keyring) { + return [$keyring->toKeyChain()]; + } + return []; + } +} diff --git a/lib/classes/LTI13a/LineItemRepository.php b/lib/classes/LTI13a/LineItemRepository.php new file mode 100644 index 0000000000000000000000000000000000000000..add863efcc70b72e673f268654b7c18c26ee01d0 --- /dev/null +++ b/lib/classes/LTI13a/LineItemRepository.php @@ -0,0 +1,192 @@ +<?php + +namespace Studip\LTI13a; + +use OAT\Library\Lti1p3Ags\Model\LineItem\LineItemCollection; +use OAT\Library\Lti1p3Ags\Model\LineItem\LineItemCollectionInterface; +use OAT\Library\Lti1p3Ags\Model\LineItem\LineItemInterface; +use OAT\Library\Lti1p3Ags\Repository\LineItemRepositoryInterface; +use Studip\LTIException; +use Grading\Definition; + +class LineItemRepository implements LineItemRepositoryInterface +{ + /** + * Converts the tool-ID and deployment-ID in the tool name used in the + * Stud.IP grading context. + * + * @param string $tool_id The Stud.IP LTI tool ID. + * @param string $deployment_id The Stud.IP LTI deployment ID. + * @return string The corresponding tool name used in the Stud.IP grading context. + */ + public static function getGradingToolName(string $tool_id, string $deployment_id) : string + { + return sprintf('lti-%s-%s', $tool_id, $deployment_id); + } + + /** + * Converts the LTI line item identifier to search parameters to retrieve + * Stud.IP grading definitions. + * + * @param string $line_item_identifier The LTI line item identifier. + * + * @return array The search parameters for searching in the Stud.IP grading context. + * This is an associative array with two keys: + * 'tool' => The identifier of the tool in the Stud.IP grading context. + * 'course_id' => The Stud.IP course-ID. + * In case the search parameters cannot be generated, an empty array is returned. + */ + public static function getSearchParametersFromLineItemIdentifier(string $line_item_identifier) : array + { + //$lineItemIdentifier contains the full URL to the line item. + //We must extract the course-ID, tool-ID and deployment-ID + //from the URL parameters first, before searching a grading definition. + $url_parts = parse_url($line_item_identifier); + $parameters = []; + if (empty($url_parts['query'])) { + //Nothing we can convert. + return []; + } + parse_str($url_parts['query'], $parameters); + if (empty($parameters)) { + //Same as above. + return []; + } + + $search_parameters = [ + 'course_id' => $parameters['cid'], + 'tool' => self::getGradingToolName($parameters['tool_id'], $parameters['deployment_id']) + ]; + if (!empty($parameters['definition_id'])) { + $search_parameters['definition_id'] = $parameters['definition_id']; + } + + return $search_parameters; + } + + /** + * @inheritDoc + */ + public function find(string $lineItemIdentifier): ?LineItemInterface + { + $search_parameters = self::getSearchParametersFromLineItemIdentifier($lineItemIdentifier); + if (!$search_parameters) { + //Nothing we can search for. + return null; + } + + $definition = null; + if (!empty($search_parameters['definition_id'])) { + $definition = Definition::find($search_parameters['definition_id']); + } else { + $definition = Definition::findOneBySQL( + "`course_id` = :course_id AND `tool` = :tool", + [ + 'course_id' => $search_parameters['course_id'], + 'tool' => $search_parameters['tool'] + ] + ); + } + if ($definition) { + return $definition->toLineItem(); + } + return null; + } + + /** + * @inheritDoc + */ + public function findCollection( + ?string $resourceIdentifier = null, + ?string $resourceLinkIdentifier = null, + ?string $tag = null, + ?int $limit = null, + ?int $offset = null + ): LineItemCollectionInterface + { + $result = new LineItemCollection(); + if (!$resourceLinkIdentifier) { + //Nothing we can search for. + return $result; + } + + //$resourceLinkIdentifier contains the Stud.IP tool-ID, the deployment-ID and the course-ID, + //separated by underscores. + $id_parts = explode('_', $resourceLinkIdentifier); + if (count($id_parts) !== 3) { + throw new LTIException('Invalid resource link identifier.'); + } + $tool_id = $id_parts[0]; + $deployment_id = $id_parts[1]; + $course_id = $id_parts[2]; + + $sql = ''; + $sql_params = []; + if ($tool_id && $course_id) { + $sql .= "`tool` = :tool AND `course_id` = :course_id"; + $sql_params['tool'] = self::getGradingToolName($tool_id, $deployment_id); + $sql_params['course_id'] = $course_id; + } else { + //No tool-ID means no line item collection can be found. + return $result; + } + + if ($limit) { + if (empty($sql)) { + $sql .= "TRUE "; + } + $sql .= "LIMIT :limit "; + $sql_params['limit'] = $limit; + } + if ($offset) { + $sql .= "OFFSET :offset"; + $sql_params['offset'] = $offset; + } + $definitions = Definition::findBySql($sql, $sql_params); + + foreach ($definitions as $definition) { + $result->add($definition->toLineItem()); + } + return $result; + } + + /** + * @inheritDoc + */ + public function save(LineItemInterface $lineItem): LineItemInterface + { + //The resource link identifier contains the Stud.IP tool-ID, deployment-ID and course-ID + //separated by underscores. + $studip_ids = explode('_', $lineItem->getResourceLinkIdentifier() ?? ''); + $tool_id = $studip_ids[0]; + $deployment_id = $studip_ids[1]; + $course_id = $studip_ids[2]; + + $definition = new Definition(); + $definition->id = $lineItem->getIdentifier(); + $definition->name = $lineItem->getLabel(); + $definition->course_id = $course_id; + $definition->tool = sprintf('lti-%s-%s', $tool_id, $deployment_id); + $definition->weight = '1.0'; + if ($definition->store()) { + return $definition->toLineItem(); + } else { + throw new LTIException('Could not save line item.'); + } + } + + /** + * @inheritDoc + */ + public function delete(string $lineItemIdentifier): void + { + $search_parameters = self::getSearchParametersFromLineItemIdentifier($lineItemIdentifier); + $definition = Definition::findOneBySQL( + '`course_id` = :course_id AND `tool` = :tool', + $search_parameters + ); + if ($definition) { + $definition->delete(); + } + } +} diff --git a/lib/classes/LTI13a/NonceGenerator.php b/lib/classes/LTI13a/NonceGenerator.php new file mode 100644 index 0000000000000000000000000000000000000000..8550709dabb958c56740083f708b1cc37c6a3b4e --- /dev/null +++ b/lib/classes/LTI13a/NonceGenerator.php @@ -0,0 +1,34 @@ +<?php + +namespace Studip\LTI13a; + +use OAT\Library\Lti1p3Core\Security\Nonce\NonceGeneratorInterface; +use OAT\Library\Lti1p3Core\Security\Nonce\NonceInterface; +use OAT\Library\Lti1p3Core\Security\Nonce\Nonce; + +class NonceGenerator implements NonceGeneratorInterface +{ + public function __construct( + protected bool $pass_nonce_from_request = false + ) { + } + + #[\Override] + public function generate(?int $ttl = null): NonceInterface + { + $expiration = new \DateTime(); + $expiration = $expiration->add(new \DateInterval('PT5M')); + if ($this->pass_nonce_from_request) { + return new Nonce( + \Request::get('nonce'), + $expiration + ); + } else { + $nonce = md5(random_bytes(16) . 'lti13a_nonce'); + return new Nonce( + $nonce, + $expiration + ); + } + } +} diff --git a/lib/classes/LTI13a/PlatformManager.php b/lib/classes/LTI13a/PlatformManager.php new file mode 100644 index 0000000000000000000000000000000000000000..10c7cb19696cbddd5bd20113994a87498b300e34 --- /dev/null +++ b/lib/classes/LTI13a/PlatformManager.php @@ -0,0 +1,106 @@ +<?php + +namespace Studip\LTI13a; + +use OAT\Library\Lti1p3Core\Platform\Platform; +use OAT\Library\Lti1p3DeepLinking\Settings\DeepLinkingSettings; +use OAT\Library\Lti1p3Core\Resource\LtiResourceLink\LtiResourceLinkInterface; + +class PlatformManager +{ + /** + * Generates an object containing the configuration to use this Stud.IP + * as LTI 1.3A platform. + * + * @return Platform The platform configuration. + */ + public static function getPlatformConfiguration() : Platform + { + $c = \Config::get(); + + return new Platform( + $c->STUDIP_INSTALLATION_ID, + $c->UNI_NAME_CLEAN, + $GLOBALS['ABSOLUTE_URI_STUDIP'], + \URLHelper::getURL('dispatch.php/lti/auth/oidc_init', null, true), + \URLHelper::getURL('dispatch.php/lti/auth/oauth2_token', null, true) + ); + } + + /** + * Generates an object containing the settings for using this Stud.IP + * as a platform that connects to an LTI tool via Deep Linking. + * + * @param string $tool_id An optional LTI tool ID that is used to construct + * the platform return URL. + * + * @return DeepLinkingSettings The settings for deep linking. + */ + public static function getDeepLinkingConfiguration(string $tool_id = '') : DeepLinkingSettings + { + $c = \Config::get(); + + return new DeepLinkingSettings( + self::getDeepLinkingReturnUrl($tool_id), + [LtiResourceLinkInterface::TYPE], + ['window', 'iframe'], + 'text/html', + true, + false, + $c->UNI_NAME_CLEAN, + '' + ); + } + + /** + * Returns the keyring for the platform. + * + * @return \Keyring|null The keyring for the platform or null if no such keyring exists. + */ + public static function getPlatformKeyring() : ?\Keyring + { + return \Keyring::findOneBySQL("`range_type` = 'global' AND `range_id` = 'lti13a_platform'"); + } + + public static function generatePlatformKeyring() : \Keyring + { + return \Keyring::generate('lti13a_platform', 'global'); + } + + public static function getLtiRoleClaimForStudipRole(string $role) : string + { + if (in_array($role, ['dozent', 'admin', 'root'])) { + //Lecturer/admin + return 'http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor'; + } elseif ($role === 'tutor') { + return 'http://purl.imsglobal.org/vocab/lis/v2/membership#Mentor'; + } elseif (in_array($role, ['user', 'autor'])) { + //Learner + return 'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'; + } + //Invalid role: + return ''; + } + + /** + * Generates the URL for returning from the tool in an LTI deep linking process. + * + * @param string $tool_id The optional LTI Tool-ID to append to the URL. + * + * @return string The URL for returning from an LTI deep linking process. + */ + public static function getDeepLinkingReturnUrl(string $tool_id = '') : string + { + return \URLHelper::getURL('dispatch.php/course/lti/save_link/' . $tool_id, null, true); + } + + /** + * Returns the URL from which the JSON web key set (JWKS) can be retrieved. + * + * @return string The JWKS URL. + */ + public static function getJwksUrl() : string + { + return \URLHelper::getURL('dispatch.php/lti/auth/jwks'); + } +} diff --git a/lib/classes/LTI13a/Registration.php b/lib/classes/LTI13a/Registration.php new file mode 100644 index 0000000000000000000000000000000000000000..c4b898f7b5b4955d9d9bfa247469280d11b327d1 --- /dev/null +++ b/lib/classes/LTI13a/Registration.php @@ -0,0 +1,122 @@ +<?php + +namespace Studip\LTI13a; + +use OAT\Library\Lti1p3Core\Registration\RegistrationInterface; +use OAT\Library\Lti1p3Core\Tool\ToolInterface; +use OAT\Library\Lti1p3Core\Platform\PlatformInterface; +use OAT\Library\Lti1p3Core\Security\Key\KeyChainInterface; + +class Registration implements RegistrationInterface +{ + public function __construct( + protected ?\LtiTool $tool + ) { + } + + public function setLtiTool(\LtiTool $tool) + { + $this->tool = $tool; + } + + public function getLtiTool() : ?\LtiTool + { + return $this->tool; + } + + #[\Override] + public function getIdentifier(): string + { + if (!$this->tool) { + return ''; + } + return $this->tool->id; + } + + #[\Override] + public function getClientId(): string + { + return $this->tool->id ?? ''; + } + + #[\Override] + public function getPlatform(): PlatformInterface + { + return PlatformManager::getPlatformConfiguration(); + } + + #[\Override] + public function getTool(): ToolInterface + { + if (!$this->tool) { + throw new \Studip\LTIException( + 'No LTI tool link present.', + \Studip\LTIException::REGISTRATION_NOT_LINKED_TO_TOOL + ); + } + return $this->tool->getToolData(); + } + + #[\Override] + public function getDeploymentIds(): array + { + if (!$this->tool) { + return []; + } + return \DBManager::get()->fetchFirst("SELECT `id` FROM `lti_deployments` WHERE `tool_id` = ?", [$this->tool->id]); + } + + #[\Override] + public function hasDeploymentId(string $deploymentId): bool + { + if (!$this->tool) { + return false; + } + return \LtiDeployment::countBySql( + "`tool_id` = :tool_id AND `id` = :deployment_id", + ['tool_id' => $this->tool->id, 'deployment_id' => $deploymentId] + ) > 0; + } + + #[\Override] + public function getDefaultDeploymentId(): ?string + { + //There is no default deployment-ID in Stud.IP: + return null; + } + + #[\Override] + public function getPlatformKeyChain(): ?KeyChainInterface + { + $platform_keyring = PlatformManager::getPlatformKeyring(); + if (!$platform_keyring) { + $platform_keyring = PlatformManager::generatePlatformKeyring(); + } + return $platform_keyring->toKeyChain(); + } + + #[\Override] + public function getToolKeyChain(): ?KeyChainInterface + { + if (!$this->tool) { + return null; + } + $keyring = $this->tool->getKeyring(); + if (!$keyring) { + $keyring = $this->tool->getKeyring(true); + } + return $keyring->toKeyChain(); + } + + #[\Override] + public function getPlatformJwksUrl(): ?string + { + return PlatformManager::getJwksUrl(); + } + + #[\Override] + public function getToolJwksUrl(): ?string + { + return $this->tool->jwks_url ?? null; + } +} diff --git a/lib/classes/LTI13a/RegistrationManager.php b/lib/classes/LTI13a/RegistrationManager.php new file mode 100644 index 0000000000000000000000000000000000000000..922cb5876a78d3ccd88d74fa15e78756d39b05a0 --- /dev/null +++ b/lib/classes/LTI13a/RegistrationManager.php @@ -0,0 +1,67 @@ +<?php + +namespace Studip\LTI13a; + +use OAT\Library\Lti1p3Core\Registration\RegistrationRepositoryInterface; +use OAT\Library\Lti1p3Core\Registration\RegistrationInterface; + +class RegistrationManager implements RegistrationRepositoryInterface +{ + #[\Override] + public function find(string $identifier): ?RegistrationInterface + { + //The identifier is the ID of a tool. + $tool = \LtiTool::find($identifier); + if (!$tool) { + return null; + } + return new Registration($tool); + } + + /** + * @inheritDoc + */ + #[\Override] + public function findAll(): array + { + $tools = \LtiTool::findBySQL('TRUE'); + $registrations = []; + foreach ($tools as $tool) { + $registrations[] = new Registration($tool); + } + return $registrations; + } + + #[\Override] + public function findByClientId(string $clientId): ?RegistrationInterface + { + //Find a registration by its client-ID. The client-ID is equivalent to the tool-ID in Stud.IP. + if (!$clientId) { + //Nothing to search for. + return null; + } + $tool = \LtiTool::find($clientId); + if ($tool) { + return new Registration($tool); + } + return null; + } + + #[\Override] + public function findByPlatformIssuer(string $issuer, string $clientId = null): ?RegistrationInterface + { + //Only handle requests for registrations of this Stud.IP: + if ($issuer !== \Config::get()->STUDIP_INSTALLATION_ID) { + //Invalid issuer. + return null; + } + return $this->findByClientId($clientId); + } + + #[\Override] + public function findByToolIssuer(string $issuer, string $clientId = null): ?RegistrationInterface + { + //Tool registrations are not supported at this moment. + return null; + } +} diff --git a/lib/classes/LTI13a/ResultRepository.php b/lib/classes/LTI13a/ResultRepository.php new file mode 100644 index 0000000000000000000000000000000000000000..350bf39c39d9f1f89df5dd9e9f7b58c1f56d8c5e --- /dev/null +++ b/lib/classes/LTI13a/ResultRepository.php @@ -0,0 +1,64 @@ +<?php + +namespace Studip\LTI13a; + +use OAT\Library\Lti1p3Ags\Model\Result\Result; +use OAT\Library\Lti1p3Ags\Model\Result\ResultCollection; +use OAT\Library\Lti1p3Ags\Model\Result\ResultCollectionInterface; +use OAT\Library\Lti1p3Ags\Model\Result\ResultInterface; +use OAT\Library\Lti1p3Ags\Repository\ResultRepositoryInterface; + +class ResultRepository implements ResultRepositoryInterface +{ + public function findCollectionByLineItemIdentifier( + string $lineItemIdentifier, + ?int $limit = null, + ?int $offset = null + ) : ResultCollectionInterface { + $sql_params = LineItemRepository::getSearchParametersFromLineItemIdentifier($lineItemIdentifier); + if (!$sql_params) { + //Nothing we can search for: + return new ResultCollection(); + } + $sql = 'JOIN `grading_definitions` gd + ON (`definition_id` = gd.`id`) + WHERE gd.`course_id` = :course_id + AND gd.`tool` = :tool'; + if ($limit) { + $sql .= 'LIMIT :limit '; + $sql_params['limit'] = $limit; + } + if ($offset) { + $sql .= 'OFFSET :offset '; + $sql_params['offset'] = $offset; + } + + $grades = \Grading\Instance::findBySQL($sql, $sql_params); + $results = new ResultCollection(); + foreach ($grades as $grade) { + $results->add($grade->toResult()); + } + return $results; + } + + public function findByLineItemIdentifierAndUserIdentifier( + string $lineItemIdentifier, + string $userIdentifier + ) : ?ResultInterface { + $search_parameters = LineItemRepository::getSearchParametersFromLineItemIdentifier($lineItemIdentifier); + $search_parameters['user_id'] = $userIdentifier; + + $grade = \Grading\Instance::findOneBySQL( + 'JOIN `grading_definitions` gd + ON (`definition_id` = gd.`id`) + WHERE gd.`course_id` = :course_id + AND gd.`tool` = :tool + AND `user_id` = :user_id', + $search_parameters + ); + if ($grade) { + return $grade->toResult(); + } + return null; + } +} diff --git a/lib/classes/LTI13a/ScoreRepository.php b/lib/classes/LTI13a/ScoreRepository.php new file mode 100644 index 0000000000000000000000000000000000000000..06cf452f6eb80b1d3e64f738c3194730f029f5a9 --- /dev/null +++ b/lib/classes/LTI13a/ScoreRepository.php @@ -0,0 +1,29 @@ +<?php + +namespace Studip\LTI13a; + +use OAT\Library\Lti1p3Ags\Model\Score\ScoreInterface; +use OAT\Library\Lti1p3Ags\Repository\ScoreRepositoryInterface; + +class ScoreRepository implements ScoreRepositoryInterface +{ + public function save(ScoreInterface $score): ScoreInterface + { + $user_id = $score->getUserIdentifier(); + $definition_id = $score->getLineItemIdentifier(); + + $grade = \Grading\Instance::findOneBySQL( + '`definition_id` = :definition_id AND `user_id` = :user_id', + ['definition_id' => $definition_id, 'user_id' => $user_id] + ); + if (!$grade) { + $grade = new \Grading\Instance(); + $grade->definition_id = $definition_id; + $grade->user_id = $user_id; + } + $grade->rawgrade = $score->getScoreGiven(); + $grade->feedback = $score->getComment(); + $grade->store(); + return $score; + } +} diff --git a/lib/classes/LTI13a/UserAuthenticator.php b/lib/classes/LTI13a/UserAuthenticator.php new file mode 100644 index 0000000000000000000000000000000000000000..125a60856867efd8fdf065ea49f0ed827a41ecd5 --- /dev/null +++ b/lib/classes/LTI13a/UserAuthenticator.php @@ -0,0 +1,38 @@ +<?php + +namespace Studip\LTI13a; + +use OAT\Library\Lti1p3Core\Security\User\UserAuthenticatorInterface; +use OAT\Library\Lti1p3Core\Registration\RegistrationInterface; +use OAT\Library\Lti1p3Core\Security\User\Result\UserAuthenticationResultInterface; +use OAT\Library\Lti1p3Core\Security\User\Result\UserAuthenticationResult; +use Psr\Log\LoggerInterface; + +class UserAuthenticator implements UserAuthenticatorInterface +{ + protected $logger = null; + + public function setLogger(LoggerInterface $logger) + { + $this->logger = $logger; + } + + #[\Override] + public function authenticate(RegistrationInterface $registration, string $loginHint): UserAuthenticationResultInterface + { + $user = \User::find($loginHint); + + $identity = null; + $tool = null; + if ($registration instanceof Registration) { + $tool = $registration->getLtiTool(); + } else { + $tool = \LtiTool::find($registration->getIdentifier()); + } + if ($user && $tool) { + $identity = new Identity($user, $tool); + } + + return new UserAuthenticationResult($user instanceof \User, $identity); + } +} diff --git a/lib/classes/auth_plugins/StudipAuthLTI.php b/lib/classes/auth_plugins/StudipAuthLTI.php index d5a286334f2be567f8358f12f403338c6a06e52a..7fd580164203f6dae7f54ccecaef206417de10a4 100644 --- a/lib/classes/auth_plugins/StudipAuthLTI.php +++ b/lib/classes/auth_plugins/StudipAuthLTI.php @@ -1,7 +1,8 @@ <?php /* - * StudipAuthLTI.php - Stud.IP authentication against LTI 1.1 consumer + * StudipAuthLTI.class.php - Stud.IP authentication against an LTI 1.3A consumer * Copyright (c) 2018 Elmar Ludwig + * Copyright (c) 2023-2024 Moritz Strohm * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as @@ -11,7 +12,13 @@ use Studip\OAuth2\NegotiatesWithPsr7; -class StudipAuthLTI extends StudipAuthSSO +use OAT\Library\Lti1p3Core\Registration\RegistrationInterface; +use OAT\Library\Lti1p3Core\Security\User\Result\UserAuthenticationResultInterface; +use OAT\Library\Lti1p3Core\Security\User\UserAuthenticatorInterface; +use OAT\Library\Lti1p3Core\Security\User\Result\UserAuthenticationResult; +use OAT\Library\Lti1p3Core\User\UserIdentity; + +class StudipAuthLTI extends StudipAuthSSO implements UserAuthenticatorInterface { use NegotiatesWithPsr7; @@ -132,4 +139,35 @@ class StudipAuthLTI extends StudipAuthSSO { return Request::get($key); } + + //\OAT\Library\Lti1p3Core\Security\User\UserAuthenticatorInterface implementation: + + public function authenticate(RegistrationInterface $registration, string $loginHint) : UserAuthenticationResultInterface + { + //Check if the user-ID is known: + $user = User::find($loginHint); + if (!$user) { + return new UserAuthenticationResult(false, null); + } + + //Authenticate the user: + if ($this->authenticateUser($user->username, '')) { + return new UserAuthenticationResult( + true, + new UserIdentity( + $user->id, + $user->getFullName(), + $user->email, + $user->vorname, + $user->nachname, + '', + $user->preferred_language, + Avatar::getAvatar($user->id)->getURL(Avatar::SMALL) + ) + ); + } + + //The user could not be authenticated: + return new UserAuthenticationResult(false, null); + } } diff --git a/lib/exceptions/KeyringException.php b/lib/exceptions/KeyringException.php new file mode 100644 index 0000000000000000000000000000000000000000..df797192aa1ca6de0637f58641abb2612e5087df --- /dev/null +++ b/lib/exceptions/KeyringException.php @@ -0,0 +1,27 @@ +<?php + +namespace Studip; + +use Studip\Exception; + +/** + * The KeyringException class represents exceptions that occurr when using keyrings. + */ +class KeyringException extends Exception +{ + /** + * The CREATION_FAILED status code means that a keyring could not be created. + */ + public const CREATION_FAILED = 1; + + /** + * The NOT FOUND status code means that the search for a keyring did not yield a result. + */ + public const NOT_FOUND = 2; + + /** + * The UNSUPPORTED_KEY_ALGORITHM status code means that the selected key algorithm + * is not supported. + */ + public const UNSUPPORTED_KEY_ALGORITHM = 3; +} diff --git a/lib/exceptions/LTIException.php b/lib/exceptions/LTIException.php new file mode 100644 index 0000000000000000000000000000000000000000..9e096da0983edde95c1b05392aa58f9d82c4cd2f --- /dev/null +++ b/lib/exceptions/LTIException.php @@ -0,0 +1,17 @@ +<?php + +namespace Studip; + +use Studip\Exception; + +/** + * The LTIException class represents exceptions that occur in the Stud.IP LTI interface. + */ +class LTIException extends Exception +{ + /** + * The REGISTRATION_NOT_LINKED_TO_TOOL status code represents the case where + * a LTI tool registration is not linked to a tool. + */ + public const REGISTRATION_NOT_LINKED_TO_TOOL = 1; +} diff --git a/lib/models/Grading/Definition.php b/lib/models/Grading/Definition.php index 41b4f7171fb58c5f941582711eb80742d4700ff4..26c2bba0c8c7aaff04bca1fa44e37ce72ee57607 100644 --- a/lib/models/Grading/Definition.php +++ b/lib/models/Grading/Definition.php @@ -2,6 +2,10 @@ namespace Grading; +use OAT\Library\Lti1p3Ags\Model\LineItem\LineItem; +use OAT\Library\Lti1p3Ags\Model\LineItem\LineItemInterface; +use OAT\Library\Lti1p3Ags\Model\LineItem\LineItemSubmissionReview; + /** * @license GPL2 or any later version * @@ -65,4 +69,31 @@ class Definition extends \SimpleORMap { return Definition::findBySQL('course_id = ? ORDER BY position ASC, name ASC', [$course->id]); } + + public function toLineItem() : LineItemInterface + { + //Build the resource link identifier first: + $studip_ids = explode('-', $this->tool ?? ''); + $tool_id = $studip_ids[1] ?? ''; + $deployment_id = $studip_ids[2] ?? ''; + $resource_link_identifier = sprintf('%s_%s_%s', $tool_id, $deployment_id, $this->course_id); + + $identifier = \URLHelper::getURL( + 'dispatch.php/lti/ags/line_item', + [ + 'cid' => $this->course_id, + 'definition_id' => $this->id, + 'deployment_id' => $deployment_id, + 'tool_id' => $tool_id + ] + ); + + return new LineItem( + PHP_FLOAT_MAX, + $this->name, + $identifier, + $deployment_id, + $resource_link_identifier + ); + } } diff --git a/lib/models/Grading/Instance.php b/lib/models/Grading/Instance.php index 7f362d346e329dae9aedbead8737cc2e93b31b1c..8d9280129b65121feb21bfd01d7f6abbe2a8372f 100644 --- a/lib/models/Grading/Instance.php +++ b/lib/models/Grading/Instance.php @@ -2,6 +2,8 @@ namespace Grading; +use OAT\Library\Lti1p3Ags\Model\Result\Result; + /** * @license GPL2 or any later version * @@ -84,4 +86,16 @@ class Instance extends \SimpleORMap } return $this->content['rawgrade'] = number_format($grade, 5, '.', ''); } + + public function toResult() : Result + { + return new Result( + $this->user_id, + $this->definition_id, + $this->user_id . '_' . $this->definition_id, + $this->rawgrade, + 9.99999, //see above + $this->feedback + ); + } } diff --git a/lib/models/Keyring.php b/lib/models/Keyring.php new file mode 100644 index 0000000000000000000000000000000000000000..b4cfffc05b722548396ea2dfab68f6e4ce22ffb9 --- /dev/null +++ b/lib/models/Keyring.php @@ -0,0 +1,169 @@ +<?php +/** + * Keyring.php + * + * This program 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 2 of + * the License, or (at your option) any later version. + * + * @author Moritz Strohm <strohm@data-quest.de> + * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 + * @since 6.0 + */ + +/** + * The Keyring class stores cryptographic keyrings in the database. + * + * @property string id database column + * @property string range_id database column + * @property string range_type database column + * @property string public_key database column + * @property string private_key database column + * @property string passphrase database column + * @property string mkdate database column + * @property string chdate database column + */ +class Keyring extends SimpleORMap +{ + private const ALGORTIHM_RS256 = 'RSA-OAEP-256'; + protected static function configure($config = []) + { + $config['db_table'] = 'keyrings'; + parent::configure($config); + } + + /** + * This method generates a new keyring. + * + * @param string $range_id The ID of the range for which to generate the keyring. + * + * @param string $range_type The type of range for which to generate the keyring. + * + * @param string $passphrase An optional passphrase for the keyring. This should not be stored + * as plain text in here, but instead in a cryptographically hashed form. + * + * @param string $algorithm The algorithm to use for the new keyring. + * + * @return Keyring The generated keyring. + * + * @throws \Studip\KeyringException In case no keyring can be generated, a KeyringException is thrown. + */ + public static function generate( + string $range_id, + string $range_type, + string $passphrase = '', + string $algorithm = self::ALGORTIHM_RS256 + ) : Keyring + { + if ($algorithm === self::ALGORTIHM_RS256) { + $private_key = phpseclib3\Crypt\RSA::createKey(4096); + if ($passphrase) { + $private_key = $private_key->withPassword($passphrase); + } + //Explicitly set OAEP as padding method: + $private_key->withPadding(\phpseclib\Crypt\RSA::ENCRYPTION_OAEP); + //Explicitly set sha256: + $private_key->withHash('sha256'); + + $public_key = $private_key->getPublicKey(); + $keyring = new self(); + $keyring->range_id = $range_id; + $keyring->range_type = $range_type; + $keyring->private_key = $private_key; + $keyring->public_key = $public_key; + if ($passphrase) { + $hasher = UserManagement::getPwdHasher(); + $keyring->passphrase = $hasher->HashPassword($passphrase); + } + if ($keyring->store()) { + return $keyring; + } + throw new \Studip\KeyringException( + _('Es konnte kein Schlüsselpaar erzeugt werden.'), + \Studip\KeyringException::CREATION_FAILED + ); + } else { + throw new \Studip\KeyringException( + sprintf( + _('Der Schlüsselalgorithmus %s wird nicht unterstützt.'), + $algorithm + ), + \Studip\KeyringException::UNSUPPORTED_KEY_ALGORITHM + ); + } + } + + /** + * Generates a new keyring from a public key. + * This method will not attempt to re-create the private key from the public key. + * Instead, the public key is the only part of the key that is stored in a new + * keyring instance. + * + * @param \OAT\Library\Lti1p3Core\Security\Key\KeyInterface|string $key The public key. + * + * @param string $range_type The range type for the keyring. + * + * @param string $range_id The range-ID for the keyring. + * + * @return Keyring|null A keyring, if it can be created or null in case of failure to do so. + */ + public static function createFromPublicKey( + \OAT\Library\Lti1p3Core\Security\Key\KeyInterface|string $key, + string $range_type, + string $range_id + ) : ?Keyring + { + $keyring = new self(); + $keyring->range_type = $range_type; + $keyring->range_id = $range_id; + $keyring->private_key = ''; + + if (is_string($key)) { + $keyring->public_key = $key; + } else { + //Instance of KeyInterface: + $content = $key->getContent(); + if (!$content || empty($content['n']) || empty($content['e'])) { + //No key present or base or exponent missing. + return null; + } + + $loaded_key = \phpseclib3\Crypt\RSA::loadPublicKey([ + 'e' => new \phpseclib3\Math\BigInteger(base64_decode(strtr($content['e'], '-_', '+/'))), + 'n' => new \phpseclib3\Math\BigInteger(base64_decode(strtr($content['n'], '-_', '+/'))), + ]); + $keyring->public_key = $loaded_key->toString('PKCS8'); + } + return $keyring; + } + + /** + * Converts the keyring to a KeyChain instance of the Lti1p3Core library. + * + * @return \OAT\Library\Lti1p3Core\Security\Key\KeyChain A KeyChain representation + * of the keyring. + */ + public function toKeyChain() : \OAT\Library\Lti1p3Core\Security\Key\KeyChain + { + $public_key = new \OAT\Library\Lti1p3Core\Security\Key\Key( + $this->public_key + ); + + //A private key is optional. + $private_key = null; + if ($this->private_key) { + $private_key = new \OAT\Library\Lti1p3Core\Security\Key\Key( + $this->private_key, + $this->passphrase ?? null + ); + } + + return new \OAT\Library\Lti1p3Core\Security\Key\KeyChain( + $this->id, + $this->range_id, + $public_key, + $private_key + ); + } +} diff --git a/lib/models/LtiData.php b/lib/models/LtiDeployment.php similarity index 55% rename from lib/models/LtiData.php rename to lib/models/LtiDeployment.php index 0c73dbcbaf3b09027cdc64cc9f277a9f691e87e3..9860279681074ee04272a12d276849b0fd2cbde4 100644 --- a/lib/models/LtiData.php +++ b/lib/models/LtiDeployment.php @@ -1,6 +1,6 @@ <?php /** - * LtiData.php - LTI consumer API for Stud.IP + * LtiDeployment.php - A class that represents an LTI tool deployment. * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as @@ -8,6 +8,7 @@ * the License, or (at your option) any later version. * * @author Elmar Ludwig + * @author Moritz Strohm * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 * * @property int $id database column @@ -25,14 +26,14 @@ * @property LtiTool $tool belongs_to LtiTool */ -class LtiData extends SimpleORMap +class LtiDeployment extends SimpleORMap { /** * Configure the database mapping. */ protected static function configure($config = []) { - $config['db_table'] = 'lti_data'; + $config['db_table'] = 'lti_deployments'; $config['serialized_fields']['options'] = JSONArrayObject::class; @@ -51,9 +52,23 @@ class LtiData extends SimpleORMap 'on_delete' => 'delete' ]; + $config['registered_callbacks']['before_create'] = ['cbCalculatePosition']; + parent::configure($config); } + /** + * Calculates the position of the new deployment in the course. + */ + public function cbCalculatePosition() : void + { + $this->position = self::countBySql( + 'JOIN `lti_tools` ON `tool_id` = `lti_tools`.`id` + WHERE `lti_tools`.`range_id` = :range_id', + ['range_id' => $this->tool->range_id] + ) + 1; + } + /** * Find a single entry by course_id and position. * @@ -74,23 +89,26 @@ class LtiData extends SimpleORMap $position = $this->position; if ($result = parent::delete()) { - $db->execute('UPDATE lti_data SET position = position - 1 WHERE course_id = ? AND position > ?', [$course_id, $position]); + $db->execute('UPDATE `lti_deployments` SET `position` = position - 1 WHERE `course_id` = ? AND `position` > ?', [$course_id, $position]); } return $result; } + public function getToolLtiVersion() : string + { + return $this->tool->lti_version ?? ''; + } + + /** * Get the launch_url of this entry. */ public function getLaunchURL() { - if ($this->tool_id) { - if (!$this->tool->allow_custom_url && !$this->tool->deep_linking || !$this->launch_url) { - return $this->tool->launch_url; - } + if (empty($this->tool->allow_custom_url) && empty($this->tool->deep_linking) || empty($this->launch_url)) { + return $this->tool->launch_url ?? ''; } - return $this->launch_url; } @@ -99,11 +117,7 @@ class LtiData extends SimpleORMap */ public function getConsumerKey() { - if ($this->tool_id) { - return $this->tool->consumer_key; - } - - return $this->options['consumer_key']; + return $this->tool->consumer_key ?? ''; } /** @@ -111,11 +125,7 @@ class LtiData extends SimpleORMap */ public function getConsumerSecret() { - if ($this->tool_id) { - return $this->tool->consumer_secret; - } - - return $this->options['consumer_secret']; + return $this->tool->consumer_secret ?? ''; } /** @@ -123,11 +133,7 @@ class LtiData extends SimpleORMap */ public function getOauthSignatureMethod() { - if ($this->tool_id) { - return $this->tool->oauth_signature_method; - } - - return $this->options['oauth_signature_method'] ?? 'sha1'; + return $this->tool->oauth_signature_method ?? 'sha1'; } /** @@ -135,11 +141,29 @@ class LtiData extends SimpleORMap */ public function getCustomParameters() { - if ($this->tool_id) { - return $this->tool->custom_parameters . "\n" . $this->options['custom_parameters']; + $parameters = ''; + if (!empty($this->tool->custom_parameters)) { + $parameters .= $this->tool->custom_parameters . "\n"; } + $parameters .= $this->options['custom_parameters'] ?? ''; + return $parameters; + } - return $this->options['custom_parameters']; + public function getCustomLtiParameterArray() : array + { + $parameter_str = $this->getCustomParameters(); + if (empty($parameter_str)) { + return []; + } + $parameters = explode("\n", $parameter_str); + $array = []; + foreach ($parameters as $parameter) { + $key_value_parts = explode('=', $parameter, 2); + if (count($key_value_parts) === 2) { + $array[trim($key_value_parts[0])] = trim($key_value_parts[1]); + } + } + return ['https://purl.imsglobal.org/spec/lti/claim/custom' => $array]; } /** @@ -147,10 +171,17 @@ class LtiData extends SimpleORMap */ public function getSendLisPerson() { - if ($this->tool_id) { - return $this->tool->send_lis_person; - } + return $this->tool->send_lis_person; + } - return $this->options['send_lis_person']; + /** + * Whether the LtiData instance uses its own (private) tool + * or one of the globally defined LTI tools. + * + * @return bool True, if the LtiData instance uses its own tool, false otherwise. + */ + public function hasOwnTool() : bool + { + return $this->tool && !$this->tool->is_global; } } diff --git a/lib/models/LtiGrade.php b/lib/models/LtiGrade.php index 167ea58ced412d8b2aedb2d98bc86aee62c721b8..7afe456658e95a76a50c768d24abe90cd6213571 100644 --- a/lib/models/LtiGrade.php +++ b/lib/models/LtiGrade.php @@ -16,7 +16,7 @@ * @property float $score database column * @property int $mkdate database column * @property int $chdate database column - * @property LtiData $link belongs_to LtiData + * @property LtiDeployment $link belongs_to LtiData * @property User $user belongs_to User */ @@ -30,7 +30,7 @@ class LtiGrade extends SimpleORMap $config['db_table'] = 'lti_grade'; $config['belongs_to']['link'] = [ - 'class_name' => LtiData::class, + 'class_name' => LtiDeployment::class, 'foreign_key' => 'link_id' ]; $config['belongs_to']['user'] = [ diff --git a/lib/models/LtiTool.php b/lib/models/LtiTool.php index 53be2bc81b83eaa9cd39bf67d8b7ed1ce9f8bb37..066df4666c047c06c3cccb9478f71419bb1f53cc 100644 --- a/lib/models/LtiTool.php +++ b/lib/models/LtiTool.php @@ -1,4 +1,7 @@ <?php + +use OAT\Library\Lti1p3Core\Tool\Tool; + /** * LtiTool.php - LTI consumer API for Stud.IP * @@ -16,13 +19,23 @@ * @property string $consumer_key database column * @property string $consumer_secret database column * @property string $custom_parameters database column + * @property string $lti_version database column + * @property string $oidc_init_url database column + * @property string $oauth2_client_id database column + * @property string $jwks_url database column + * @property string $jwks_key_id database column + * @property string $deep_linking_url database column + * @property string $terms_of_use_url database column + * @property string $privacy_policy_url database column + * @property string $data_protection_notes database column + * @property string $range_id database column * @property int $allow_custom_url database column * @property int $deep_linking database column * @property int $send_lis_person database column * @property int $mkdate database column * @property int $chdate database column * @property string $oauth_signature_method database column - * @property SimpleORMapCollection|LtiData[] $links has_many LtiData + * @property SimpleORMapCollection|LtiDeployment[] $links has_many LtiData */ class LtiTool extends SimpleORMap @@ -32,22 +45,151 @@ class LtiTool extends SimpleORMap */ protected static function configure($config = []) { - $config['db_table'] = 'lti_tool'; + $config['db_table'] = 'lti_tools'; $config['has_many']['links'] = [ - 'class_name' => LtiData::class, + 'class_name' => LtiDeployment::class, 'assoc_foreign_key' => 'tool_id', 'on_delete' => 'delete' ]; + $config['has_one']['oauth2_client'] = [ + 'class_name' => \Studip\OAuth2\Models\Client::class, + 'foreign_key' => 'oauth2_client_id', + 'on_delete' => 'delete' + ]; + parent::configure($config); } /** - * Find all entries. + * Validates the data in the LtiTool instance. + * + * @return string[] An array with errors. The array is empty if all + * fields are filled with valid data. + */ + public function validate() : array + { + $errors = []; + if (!$this->name) { + $errors[] = _('Es wurde kein Name angegeben.'); + } + if (!$this->launch_url) { + $errors[] = _('Es wurde keine Launch-URL angegeben.'); + } + if (!in_array($this->lti_version, ['1.1', '1.3a'])) { + $errors[] = _('Die ausgewählte LTI-Version ist ungültig.'); + } + if ($this->lti_version === '1.1') { + if (!$this->consumer_key) { + $errors[] = _('Es wurde kein Consumer-Key angegeben.'); + } + if (!$this->consumer_secret) { + $errors[] = _('Es wurde kein Consumer-Secret angegeben.'); + } + } + return $errors; + } + + /** + * Retrieves all LTI tools. + * + * @param bool $with_private_tools Whether to include all private tools (true) + * or not (false). Defautls to false. + * + * @return array A list of all LTI tools. + */ + public static function findAll(bool $with_private_tools = false) : array + { + if ($with_private_tools) { + return self::findBySQL("1 ORDER BY name"); + } else { + return self::findBySQL("`range_id` = 'global' ORDER BY name"); + } + } + + /** + * Checks whether a user may have the permissions to edit the tool. + * + * @param string $user_id The ID of the user whose edit permissions shall be checked. + * + * @return bool True, if the user may edit the tool, false otherwise. + */ + public function isEditableByUser(string $user_id = null) : bool + { + $user_id ??= User::findCurrent()->id; + return $this->range_id === 'global' && $GLOBALS['perm']->have_perm('root') + || ($this->range_id !== 'global' && $GLOBALS['perm']->have_studip_perm('tutor', $this->range_id)); + } + + //ToolInterface implementation + + public function getToolData() : Tool + { + return new Tool( + $this->id, + $this->name, + $this->launch_url, + $this->oidc_init_url, + $this->launch_url, + $this->deep_linking_url + ); + } + + /** + * Retrieves the keyring of the LTI tool or generates one, if explicitly requested. + * + * @param bool $generate Generates a new keyring for the tool if set to true. + * Defaults to false. + * + * @return Keyring|null The keyring for the tool or null if no such keyring exists. */ - public static function findAll() + public function getKeyring(bool $generate = false) : ?Keyring + { + $keyring = Keyring::findOneBySQL( + "`range_type` = 'lti_tool' AND `range_id` = :tool_id", + ['tool_id' => $this->id] + ); + if ($generate && !$keyring) { + $keyring = Keyring::generate($this->id, 'lti_tool'); + } + return $keyring; + } + + /** + * Sets or updates the public key for the LTI tool. + * + * @param string $public_key The public key to set. + * + * @return bool True, if the public key could be set, false otherwise. + */ + public function updatePublicKey(string $public_key) : bool + { + if (!$public_key) { + //No key? Then it cannot be set. + return false; + } + $keyring = $this->getKeyring(); + if ($keyring) { + //Clear the fields for the passphrase and the private key: + $keyring->passphrase = ''; + $keyring->private_key = ''; + //Store the new public key for the tool: + $keyring->public_key = $public_key; + } else { + $keyring = Keyring::createFromPublicKey($public_key, 'lti_tool', $this->id); + } + return $keyring->store() !== false; + } + + public function getLtiVersionString() : string { - return self::findBySQL('1 ORDER BY name'); + if ($this->lti_version === '1.3a') { + return '1.3a'; + } elseif ($this->lti_version === '1.1') { + return '1.0/1.1'; + } else { + return _('unbekannt'); + } } } diff --git a/lib/models/LtiToolPrivacySettings.php b/lib/models/LtiToolPrivacySettings.php new file mode 100644 index 0000000000000000000000000000000000000000..040aba1370527b7f1294d181964a78e90e1ed191 --- /dev/null +++ b/lib/models/LtiToolPrivacySettings.php @@ -0,0 +1,46 @@ +<?php +/** + * LtiToolPrivacySettings.php + * + * This program 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 2 of + * the License, or (at your option) any later version. + * + * @author Moritz Strohm <strohm@data-quest.de> + * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 + * @since 6.0 + */ + +/** + * The LtiToolPrivacySettings class represents the privacy + * settings a user made for a specific LTI tool deployment. + * It not only stores the decision if the privacy statement of the tool + * has been accepted, but also which data may be transferred to the + * LTI tool. + * + * @property string deployment_id database column + * @property string user_id database column + * @property string accepted database column + * @property string allowed_optional_fields database column + * @property string mkdate database column + * @property string chdate database column + */ +class LtiToolPrivacySettings extends SimpleORMap +{ + protected static function configure($config = []) + { + $config['db_table'] = 'lti_tool_privacy_settings'; + + $config['belongs_to']['user'] = [ + 'class_name' => User::class, + 'foreign_key' => 'user_id' + ]; + $config['belongs_to']['tool'] = [ + 'class_name' => LtiTool::class, + 'foreign_key' => 'tool_id' + ]; + + parent::configure($config); + } +} diff --git a/lib/modules/CoreParticipants.php b/lib/modules/CoreParticipants.php index 1d1527253f0b494700e28d864d83739d2d2d7e58..54704958e821c8b687137af456692309e3887c1f 100644 --- a/lib/modules/CoreParticipants.php +++ b/lib/modules/CoreParticipants.php @@ -19,6 +19,9 @@ class CoreParticipants extends CorePlugin implements StudipModule if ($user_id === 'nobody') { return null; } + if (CourseConfig::get()->LTI_SHARING_ENABLED) { + return null; + } $auto_insert_perm = Config::get()->AUTO_INSERT_SEM_PARTICIPANTS_VIEW_PERM; // show the participants-icon only if the course is not an auto-insert-sem @@ -114,6 +117,9 @@ class CoreParticipants extends CorePlugin implements StudipModule if ($GLOBALS['user']->id === 'nobody') { return []; } + if (CourseConfig::get()->LTI_SHARING_ENABLED) { + return []; + } $navigation = new Navigation(_('Teilnehmende')); $navigation->setImage(Icon::create('persons', Icon::ROLE_INFO_ALT)); @@ -200,6 +206,6 @@ class CoreParticipants extends CorePlugin implements StudipModule public function isActivatableForContext(Range $context) { - return $context->getRangeType() === 'course'; + return $context->getRangeType() === 'course' && !CourseConfig::get()->LTI_SHARING_ENABLED; } } diff --git a/lib/modules/LtiToolModule.php b/lib/modules/LtiToolModule.php index 507fc269750f94dccc04275c0805dfd4908aaf13..d527f8d797241fd384e58643deacfe538b14c2c5 100644 --- a/lib/modules/LtiToolModule.php +++ b/lib/modules/LtiToolModule.php @@ -10,7 +10,6 @@ * @author Elmar Ludwig * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 */ - class LtiToolModule extends CorePlugin implements StudipModule, SystemPlugin, PrivacyPlugin { /** @@ -28,7 +27,7 @@ class LtiToolModule extends CorePlugin implements StudipModule, SystemPlugin, Pr LtiGrade::deleteBySQL('user_id = ?', [$user->id]); }); NotificationCenter::on('CourseDidDelete', function ($event, $course) { - LtiData::deleteBySQL('course_id = ?', [$course->id]); + LtiDeployment::deleteBySQL('course_id = ?', [$course->id]); }); } @@ -41,16 +40,13 @@ class LtiToolModule extends CorePlugin implements StudipModule, SystemPlugin, Pr return null; } - $title = CourseConfig::get($course_id)->LTI_TOOL_TITLE; - $changed = LtiData::countBySQL('course_id = ? AND chdate > ?', [$course_id, $last_visit]); + $changed = LtiDeployment::countBySQL('course_id = ? AND chdate > ?', [$course_id, $last_visit]); - $icon = $changed - ? Icon::create('link-extern', Icon::ROLE_NEW) - : Icon::create('link-extern'); + $icon = Icon::create('link-extern', $changed ? Icon::ROLE_NEW : Icon::ROLE_CLICKABLE); - $navigation = new Navigation($title, 'dispatch.php/course/lti'); + $navigation = new Navigation(_('LTI-Tools'), 'dispatch.php/course/lti'); $navigation->setImage($icon); - $navigation->setLinkAttributes(['title' => $title]); + $navigation->setLinkAttributes(['title' => _('LTI-Tools')]); return $navigation; } @@ -64,13 +60,12 @@ class LtiToolModule extends CorePlugin implements StudipModule, SystemPlugin, Pr return []; } - $title = CourseConfig::get($course_id)->LTI_TOOL_TITLE; - $grades = LtiData::countBySQL('course_id = ?', [$course_id]); + $grades = LtiDeployment::countBySQL('course_id = ?', [$course_id]); - $navigation = new Navigation($title); + $navigation = new Navigation(_('LTI-Tools')); $navigation->setImage(Icon::create('link-extern', Icon::ROLE_INFO_ALT)); $navigation->setActiveImage(Icon::create('link-extern', Icon::ROLE_INFO)); - $navigation->addSubNavigation('index', new Navigation($title, 'dispatch.php/course/lti')); + $navigation->addSubNavigation('index', new Navigation(_('LTI-Tools'), 'dispatch.php/course/lti')); if ($grades) { $navigation->addSubNavigation('grades', new Navigation(_('Ergebnisse'), 'dispatch.php/course/lti/grades')); @@ -104,14 +99,11 @@ class LtiToolModule extends CorePlugin implements StudipModule, SystemPlugin, Pr public function getMetadata() { return [ - 'summary' => _('Verlinkung auf Inhalte in externen Anwendungen (LTI-Tool)'), - 'description' => _('Dieses Modul bietet eine Möglichkeit zur Einbindung von externen Tools, '. - 'sofern diese den LTI-Standard unterstützen. Ähnlich wie bei der Seite '. - '"Informationen" kann ein Titel sowie ein freier Text angegeben werden, der '. - 'den Nutzern zur Erläuterung angezeigt wird. Zur Einbindung von Inhalten aus '. - 'Fremdsystemen wird die LTI-Schnittstelle in der Version 1.x unterstützt.'), + 'summary' => _('Anbindung von LTI-Tools'), + 'description' => _('Mit diesem Werkzeug können LTI-Tools eingebunden werden, '. + 'sofern diese LTI in Version 1.0, 1.1 oder 1.3A unterstützen.'), 'category' => _('Kommunikation und Zusammenarbeit'), - 'keywords' => _('Einbindung von LTI-Tools (Version 1.x)'), + 'keywords' => implode(';', ['LTI', _('LTI-Tools'), _('E-Learning')]), 'icon' => Icon::create('link-extern', Icon::ROLE_INFO), 'icon_clickable' => Icon::create('link-extern'), 'screenshots' => [ @@ -119,7 +111,8 @@ class LtiToolModule extends CorePlugin implements StudipModule, SystemPlugin, Pr 'pictures' => [ ['source' => 'Lti_tool_demo.jpg', 'title' => 'Beispiel für Wordpress-Einbindung'] ] - ] + ], + 'displayname' => _('LTI-Tools') ]; } } diff --git a/resources/assets/javascripts/bootstrap/studip_helper_attributes.js b/resources/assets/javascripts/bootstrap/studip_helper_attributes.js index 26cdd9c78b1fa0f9040c5522e441c839a07f1efb..54f8da39fd5ea8f97695851844aa63879f313583 100644 --- a/resources/assets/javascripts/bootstrap/studip_helper_attributes.js +++ b/resources/assets/javascripts/bootstrap/studip_helper_attributes.js @@ -133,7 +133,7 @@ STUDIP.ready((event) => { // $(document).on('change', '[data-hides],[data-shows]', function () { - if (!$(this).is(':checkbox,:radio')) { + if (!$(this).is(':checkbox,:radio,select')) { return; } @@ -142,14 +142,40 @@ $(document).on('change', '[data-hides],[data-shows]', function () { if (selector === undefined || $(this).prop('disabled')) { return; } - - var state = $(this).prop('checked') || $(this).prop('indeterminate') || false; + let triggering_value = undefined; + if ($(this).is('select')) { + triggering_value = $(this).data('triggering-value').toString(); + } + let state = $(this).prop('checked') || $(this).prop('indeterminate') || $(this).val().toString() === triggering_value || false; $(selector).each(function() { var condition = $(this).data(`${type}Condition`), toggle = state && (!condition || $(condition).length > 0); $(this) .toggle(type === 'shows' ? toggle : !toggle) .trigger('update.proxy'); + //Check if there are required fields that become hidden or the other way around. + //Hidden fields must not be required. + let elements = $(this).find('input[required]'); + if ($(this).attr('required')) { + //Append the element itself: + $(elements).append($(this)); + } + $(elements).each(function(index, element) { + + let hide = (type === 'shows' && !condition) || (type === 'hides' && condition); + if (hide) { + //Remove the required attribute: + if ($(element).attr('required') !== 'false') { + $(element).attr('data-is_required', '1'); + $(element).removeAttr('required'); + } + } else { + //Set the required attribute: + if ($(element).attr('data-is_required')) { + $(element).attr('required', 'required'); + } + } + }); }); }); });