From cd8222ba049eca136bb443410022d99dfbc5d0f2 Mon Sep 17 00:00:00 2001 From: Moritz Strohm Date: Thu, 24 Apr 2025 10:48:24 +0000 Subject: distinguish between LTI deployment IDs and LTI resource links in the database, fixes #5330 Closes #5330 Merge request studip/studip!4045 --- app/controllers/course/lti.php | 183 ++++++++++-------- app/controllers/lti/tool.php | 199 +++++++++++--------- app/views/admin/lti/index.php | 18 +- app/views/course/lti/consent.php | 14 +- app/views/course/lti/iframe.php | 6 +- app/views/course/lti/index.php | 52 +++--- app/views/course/lti/select_tool.php | 14 +- app/views/lti/_tool_form_fields.php | 204 ++++++++++----------- app/views/lti/_tool_info.php | 19 +- .../6.0.48_add_lti_resource_links_table.php | 61 ++++++ lib/classes/LTI13a/LineItemRepository.php | 32 ++-- lib/models/Grading/Definition.php | 27 ++- lib/models/LtiDeployment.php | 52 +----- lib/models/LtiResourceLink.php | 187 +++++++++++++++++++ lib/models/LtiTool.php | 2 +- lib/modules/LtiToolModule.php | 6 +- 16 files changed, 669 insertions(+), 407 deletions(-) create mode 100644 db/migrations/6.0.48_add_lti_resource_links_table.php create mode 100644 lib/models/LtiResourceLink.php diff --git a/app/controllers/course/lti.php b/app/controllers/course/lti.php index 1560f6d..d167a9e 100644 --- a/app/controllers/course/lti.php +++ b/app/controllers/course/lti.php @@ -76,14 +76,18 @@ class Course_LtiController extends StudipController */ public function index_action() { - $this->lti_data_array = []; + $this->links = []; if ($this->edit_perm) { - $this->lti_data_array = LtiDeployment::findByCourse_id($this->course_id, 'ORDER BY position'); + $this->links = \LtiResourceLink::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`", + //Only load those LTI resource links that are fully configured: + $this->links = \LtiResourceLink::findBySQL( + "JOIN `lti_deployments` + ON `lti_deployments`.`id` = `lti_resource_links`.`deployment_id` + WHERE + `lti_resource_links`.`course_id` = :course_id + AND (`lti_deployments`.`options` IS NULL OR `lti_deployments`.`options` NOT LIKE '%unfinished_deep_linking%') + ORDER BY `lti_resource_links`.`position`", ['course_id' => $this->course_id] ); } @@ -131,10 +135,13 @@ class Course_LtiController extends StudipController public function select_tool_action() { //The permission check is done in the before filter. + $this->global_tool_deployments = LtiDeployment::findBySQL( + "JOIN `lti_tools` + ON `lti_deployments`.`tool_id` = `lti_tools`.`id` + WHERE `lti_tools`.`lti_version` = '1.3a' AND `lti_tools`.`range_id` = 'global' ORDER BY `lti_tools`.`name` ASC" + ); - $this->global_tools = LtiTool::findBySQL("`lti_version` = '1.3a' AND `range_id` = 'global' ORDER BY `name` ASC"); - - if (!$this->global_tools) { + if (!$this->global_tool_deployments) { 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; @@ -143,10 +150,10 @@ class Course_LtiController extends StudipController $this->redirect('lti/tool/add/' . $this->course->id); } - $this->selected_tool_id = ''; - if (count($this->global_tools) >= 1) { + $this->selected_deployment_id = ''; + if (count($this->global_tool_deployments) >= 1) { //Preselect the first tool: - $this->selected_tool_id = $this->global_tools[0]->id; + $this->selected_deployment_id = $this->global_tool_deployments[0]->id; } } @@ -154,40 +161,50 @@ class Course_LtiController extends StudipController { if (Request::isPost()) { CSRFProtection::verifyUnsafeRequest(); - $selected_tool_id = Request::get('selected_tool_id'); - if ($selected_tool_id === 'new') { + $selected_deployment_id = Request::get('selected_deployment_id'); + if ($selected_deployment_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') { + //Load the selected deployment and check if it can be used in the course. + $selected_deployment = LtiDeployment::find($selected_deployment_id); + if (!$selected_deployment || $selected_deployment->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); + //Link the tool in the course: + $link = new \LtiResourceLink(); + $link->deployment_id = $selected_deployment->id; + $link->course_id = $this->course->id; + if ($link->store()) { + PageLayout::postSuccess(_('Das LTI-Tool wurde eingebunden.')); + } else { + PageLayout::postError(_('Das LTI-Tool konnte nicht eingebunden werden.')); + } + $this->relocate('course/lti', ['cid' => $this->course->id]); } } else { $this->redirect('course/lti/select_tool'); } } - public function consent_action(string $deployment_id) + public function consent_action(string $link_id) { - $this->deployment = LtiDeployment::find($deployment_id); - if (!$this->deployment) { + $this->resource_link = \LtiResourceLink::find($link_id); + if (!$this->resource_link) { PageLayout::postError(_('Die Einbindung eines LTI-Tools ist ungültig.')); return; } + $tool_id = $this->resource_link->deployment->tool_id; $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] + ['tool_id' => $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->tool_id = $tool_id; $this->privacy_settings->user_id = $GLOBALS['user']->id; } @@ -219,7 +236,7 @@ class Course_LtiController extends StudipController $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); + $this->redirect('course/lti/iframe/' . $this->resource_link->id); } else { //Redirect to the LTI tool page of the course: $this->redirect('course/lti/index'); @@ -230,55 +247,47 @@ class Course_LtiController extends StudipController /** * Display the launch form for a tool as an iframe. */ - public function iframe_action(string $deployment_id) + public function iframe_action(string $link_id) { - $this->deployment = LtiDeployment::find($deployment_id); + $this->resource_link = \LtiResourceLink::find($link_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] + ['tool_id' => $this->resource_link->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']); + $this->redirect('course/lti/consent/' . $this->resource_link->deployment_id, ['redirect_to_tool' => '1']); return; } if (!$this->show_data_protection_info) { //Redirect to the tool. $this->lti13a_mode = false; - $lti_version = $this->deployment->getToolLtiVersion(); + $lti_version = $this->resource_link->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 - ] - ); - - $return_url = !isset($this->deployment->options['document_target']) ? - URLHelper::getURL($GLOBALS['ABSOLUTE_URI_STUDIP'] . 'dispatch.php/course/lti', ['deployment_id' => $deployment_id]) : ''; - $document_target = isset($this->deployment->options['document_target']) ? 'iframe' : 'window'; + $return_url = !isset($this->resource_link->deployment->options['document_target']) ? + URLHelper::getURL($GLOBALS['ABSOLUTE_URI_STUDIP'] . 'dispatch.php/course/lti', ['deployment_id' => $this->resource_link->deployment_id]) : ''; + $document_target = isset($this->resource_link->deployment->options['document_target']) ? 'iframe' : 'window'; $locale = str_replace('_', '-', $_SESSION['_language']); - $registration = new Registration($this->deployment->tool); + $registration = new Registration($this->resource_link->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, + 'tool_id' => $this->resource_link->deployment->tool_id, + 'deployment_id' => $this->resource_link->deployment_id, 'cancel_login' => '1' ]; //Build the message: $this->message = $builder->buildLtiResourceLinkLaunchRequest( - $lti_resource_link, + $this->resource_link, $registration, $GLOBALS['user']->id, - $this->deployment->id, + $this->resource_link->deployment_id, [ PlatformManager::getLtiRoleClaimForStudipRole($GLOBALS['perm']->get_studip_perm($this->course_id)) ], @@ -307,11 +316,12 @@ class Course_LtiController extends StudipController $this->url_for('lti/ags/line_item', $ags_url_parameters) ) ], - $this->deployment->getCustomLtiParameterArray(), + $this->resource_link->deployment->getCustomLtiParameterArray(), ) ); } else { //LTI 1.0/1.1 + $this->deployment = $this->resource_link->deployment; $lti_link = $this->getLtiLink($this->deployment); $this->launch_url = $this->deployment->getLaunchURL(); $this->launch_data = $lti_link->getBasicLaunchData(); @@ -356,18 +366,18 @@ class Course_LtiController extends StudipController } /** - * Moves an LTI deployment in a course either up or down. + * Moves an LTI resource link up or down in a course. * - * @param string $deployment_id The ID of the deployment to be moved. + * @param string $link_id The ID of the resource link to be moved. * * @param string $direction 'up' for moving the deployment upwards or 'down' for downwards. */ - public function move_action(string $deployment_id, string $direction) + public function move_action(string $link_id, string $direction) { CSRFProtection::verifyUnsafeRequest(); - $deployment = LtiDeployment::find($deployment_id); - if (!$deployment) { + $link = \LtiResourceLink::find($link_id); + if (!$link) { //Redirect and do nothing: $this->redirect('course/lti'); return; @@ -376,19 +386,19 @@ class Course_LtiController extends StudipController $new_position = 0; if ($direction === 'up') { - $new_position = $deployment->position - 1; + $new_position = $link->position - 1; } else { - $new_position = $deployment->position + 1; + $new_position = $link->position + 1; } //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(); + $other_link = \LtiResourceLink::findByCourseAndPosition($this->course_id, $new_position); + if ($other_link) { + $other_link->position = $link->position; + $other_link->store(); } - $deployment->position = $new_position; - $deployment->store(); + $link->position = $new_position; + $link->store(); $this->redirect('course/lti'); } @@ -402,8 +412,8 @@ class Course_LtiController extends StudipController { CSRFProtection::verifyUnsafeRequest(); - $deployment = LtiDeployment::findByCourseAndPosition($this->course_id, $position); - $deployment->delete(); + $link = \LtiResourceLink::findByCourseAndPosition($this->course_id, $position); + $link->delete(); PageLayout::postSuccess(_('Der Abschnitt wurde gelöscht.')); $this->redirect('course/lti'); @@ -600,16 +610,15 @@ class Course_LtiController extends StudipController 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'" + $deployment = LtiDeployment::findOneBySQL( + "JOIN `lti_resource_links` + ON `lti_deployments`.`id` = `lti_resource_links`.`deployment_id` + WHERE + `lti_deployments`.`tool_id` = :tool_id AND `lti_resource_links`.`course_id` = :course_id + AND `lti_deployments`.`options` LIKE '%unfinished_deep_linking%=%true'", + ['tool_id' => $this->tool->id, 'course_id' => $this->range_id] ); $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 @@ -617,13 +626,17 @@ class Course_LtiController extends StudipController $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(); + if ($deployment->store()) { + $link = new \LtiResourceLink(); + $link->deployment_id = $deployment->id; + $link->course_id = $this->range_id; + $link->store(); + } } } } else { @@ -632,17 +645,15 @@ class Course_LtiController extends StudipController $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 (!Studip\OAuth1::verifyRequest($this->getPsrRequest(), $tool->consumer_secret, '')) { + throw new Exception('Could not verify request.'); + } 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; @@ -662,6 +673,11 @@ class Course_LtiController extends StudipController $lti_data->options = $options; $lti_data->store(); + $link = new \LtiResourceLink(); + $link->deployment_id = $lti_data->id; + $link->course_id = $this->course_id; + $link->position = \LtiResourceLink::countBySQL('course_id = ?', [$this->course_id]); + $link->store(); PageLayout::postSuccess($lti_msg ?: _('Der Link wurde als neuer Abschnitt hinzugefügt.')); } } @@ -864,12 +880,21 @@ class Course_LtiController extends StudipController Navigation::activateItem('/course/lti/grades'); if ($this->edit_perm) { - $this->lti_data_array = LtiDeployment::findByCourse_id($this->course_id, 'ORDER BY position'); + $this->lti_data_array = LtiDeployment::findBySQL( + "JOIN `lti_resource_links` + ON `lti_deployments`.`id` = `lti_resource_links`.`deployment_id` + WHERE `lti_resource_links`.`course_id` = :course_id + ORDER BY `lti_resource_links`.`position`", + ['course_id' => $this->course_id] + ); } 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`", + "JOIN `lti_resource_links` + ON `lti_deployments`.`id` = `lti_resource_links`.`deployment_id` + WHERE `lti_resource_links`.`course_id` = :course_id + AND (`lti_deployments`.`options` IS NULL OR `lti_deployments`.`options` NOT LIKE '%unfinished_deep_linking%') + ORDER BY `lti_resource_links`.`position`", ['course_id' => $this->course_id] ); } diff --git a/app/controllers/lti/tool.php b/app/controllers/lti/tool.php index 0e7077e..fae276e 100644 --- a/app/controllers/lti/tool.php +++ b/app/controllers/lti/tool.php @@ -33,10 +33,13 @@ class Lti_ToolController extends AuthenticatedController public function index_action($range_id, $tool_id): void { - //$this->tool is created in the before-filter. + //$this->range_id and $this->tool are created in the before-filter. if ($this->range_id !== 'global') { $this->deployment = LtiDeployment::findOneBySQL( - '`tool_id` = :tool_id AND `course_id` = :range_id', + 'JOIN `lti_resource_links` + ON `lti_deployments`.`id` = `lti_resource_links`.`deployment_id` + WHERE + `lti_deployments`.`tool_id` = :tool_id AND `lti_resource_links`.`course_id` = :range_id', ['tool_id' => $this->tool->id, 'range_id' => $this->range_id] ); } @@ -59,7 +62,6 @@ class Lti_ToolController extends AuthenticatedController 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( @@ -70,21 +72,14 @@ class Lti_ToolController extends AuthenticatedController 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') { + } elseif (!$this->tool->isEditableByUser()) { + throw new AccessDeniedException(); + } else { + //The tool is old and editable by the user. Check if a deployment exists and load it. $this->deployment = LtiDeployment::findOneBySQL( - '`tool_id` = :tool_id AND `course_id` = :range_id', - ['tool_id' => $this->tool->id, 'range_id' => $this->range_id] + "`tool_id` = :tool_id ORDER BY `mkdate` ASC", + ['tool_id' => $this->tool->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()) { @@ -98,81 +93,95 @@ class Lti_ToolController extends AuthenticatedController 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')); + //Note: $this->tool is created in the before_filter. + $new_tool = $this->tool->isNew(); + $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 { - //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']); - } + //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')); - //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; + //Check if the tool has a deployment. If so, use it. Otherwise, create a new deployment. + if (!$new_tool) { + $this->deployment = LtiDeployment::findOneBySQL( + "`tool_id` = :tool_id ORDER BY `mkdate` ASC", + ['tool_id' => $this->tool->id] + ); + } + if (!$this->deployment) { + $this->deployment = new LtiDeployment(); + if (!$new_tool) { + $this->deployment->tool_id = $this->tool->id; } - 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; + } + $this->deployment->description = trim(Request::get('description')); + $this->deployment->title = $this->tool->name; + $this->deployment->launch_url = $this->tool->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']); + } + + $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->deployment) { + if ($this->tool->store() !== false) { + $this->deployment->tool_id = $this->tool->id; $this->deployment->store(); + } else { + PageLayout::postError(_('Das LTI-Tool konnte nicht gespeichert werden.')); + return; + } + if ($this->range_id !== 'global') { + $resource_link_exists = false; + if (!$new_tool) { + //Create an LTI resource link, if it doesn't exist yet: + $resource_link_exists = \LtiResourceLink::countBySQL( + "`deployment_id` = :deployment_id AND `course_id` = :course_id", + ['deployment_id' => $this->deployment->id, 'course_id' => $this->range_id] + ) > 0; + } + if (!$resource_link_exists) { + //Either the tool has just been created or the tool existed and it wasn't yet + //linked to the course. In those both cases, we have to create a new LTI resource link. + $resource_link = new \LtiResourceLink(); + $resource_link->deployment_id = $this->deployment->id; + $resource_link->course_id = $this->range_id; + $resource_link->store(); + } } if ($this->tool->lti_version === '1.3a' && $tool_public_key) { if (!$this->tool->updatePublicKey($tool_public_key)) { @@ -201,23 +210,31 @@ class Lti_ToolController extends AuthenticatedController $tool_name = $this->tool->name; if ($this->tool->range_id === 'global') { if ($range_id === 'global') { + //A global tool shall be deleted globally. + if (!$this->tool->isEditableByUser()) { + throw new AccessDeniedException(); + } $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", + //A tool shall be deleted from a course: Delete the resource link instead. + $link = \LtiResourceLink::findOneBySQL( + "JOIN `lti_deployments` + ON `lti_deployments`.`id` = `lti_resource_links`.`deployment_id` + WHERE `lti_deployments`.`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(); + if ($link) { + $deleted = $link->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: + //A tool that is used inside a course shall be deleted. + if (!$this->tool->isEditableByUser()) { + throw new AccessDeniedException(); + } $deleted = $this->tool->delete(); } if ($deleted !== false) { diff --git a/app/views/admin/lti/index.php b/app/views/admin/lti/index.php index 54ebd94..f047add 100644 --- a/app/views/admin/lti/index.php +++ b/app/views/admin/lti/index.php @@ -16,6 +16,7 @@ + @@ -24,6 +25,7 @@ + @@ -44,7 +46,21 @@ consumer_key) ?> getLtiVersionString()) ?> - links) ?> + + id); + ?> + id ?? '') ?> + + + $tool->id] + ) ?> + diff --git a/app/views/course/lti/consent.php b/app/views/course/lti/consent.php index 4c810a5..be78d5d 100644 --- a/app/views/course/lti/consent.php +++ b/app/views/course/lti/consent.php @@ -1,13 +1,13 @@ - +
isNew() ? 'data-dialog="reload-on-close"' : 'data-dialog' ?> - action="link_for('course/lti/consent/' . $deployment->id) ?>"> + action="link_for('course/lti/consent/' . $resource_link->id) ?>"> LTI_DATA_PROTECTION_COURSE_WARNING; @@ -19,8 +19,8 @@

- tool->data_protection_notes) : ?> -

tool->data_protection_notes) ?>

+ deployment->tool->data_protection_notes) : ?> +

deployment->tool->data_protection_notes) ?>

@@ -53,7 +53,7 @@ - render_partial('lti/_deployment_user_info', ['deployment' => $deployment]) ?> + render_partial('lti/_deployment_user_info', ['deployment' => $resource_link->deployment]) ?>

- title) ?> + deployment->title) ?>

@@ -30,16 +30,16 @@
diff --git a/db/migrations/6.0.48_add_lti_resource_links_table.php b/db/migrations/6.0.48_add_lti_resource_links_table.php new file mode 100644 index 0000000..40bb129 --- /dev/null +++ b/db/migrations/6.0.48_add_lti_resource_links_table.php @@ -0,0 +1,61 @@ +exec( + "CREATE TABLE IF NOT EXISTS lti_resource_links ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + deployment_id INT(11) NOT NULL, + course_id CHAR(32) NOT NULL, + position INT(11) NOT NULL DEFAULT 0, + mkdate INT(11) NOT NULL DEFAULT 0, + chdate INT(11) NOT NULL DEFAULT 0 + )" + ); + $db->exec("ALTER TABLE `lti_resource_links` ADD INDEX (`deployment_id`)"); + + //Migrate the contents of lti_deployments: + $db->exec( + "INSERT INTO `lti_resource_links` (`deployment_id`, `course_id`, `position`, `mkdate`, `chdate`) + SELECT `id`, `course_id`, `position`, `mkdate`, `chdate` FROM `lti_deployments`" + ); + + //Remove columns from lti_deployments: + $db->exec("ALTER TABLE `lti_deployments` DROP COLUMN `course_id`, DROP COLUMN `position`"); + } + + protected function down() + { + $db = DBManager::get(); + + //Add columns to lti_deployments: + $db->exec( + "ALTER TABLE `lti_deployments` + ADD COLUMN `course_id` CHAR(32) NOT NULL, + ADD COLUMN `position` INT(11) NOT NULL DEFAULT 0" + ); + + //Migrate the content of lti_resource_links: + $db->exec( + "UPDATE `lti_deployments` JOIN `lti_resource_links` + ON `lti_deployments`.`id` = `lti_resource_links`.`deployment_id` + SET + `lti_deployments`.`course_id` = `lti_resource_links`.`course_id`, + `lti_deployments`.`position` = `lti_resource_links`.`position`" + ); + + //Remove the lti_resource_links table: + $db->exec("DROP TABLE `lti_resource_links`"); + } +} diff --git a/lib/classes/LTI13a/LineItemRepository.php b/lib/classes/LTI13a/LineItemRepository.php index add863e..c6c6ef1 100644 --- a/lib/classes/LTI13a/LineItemRepository.php +++ b/lib/classes/LTI13a/LineItemRepository.php @@ -110,22 +110,19 @@ class LineItemRepository implements LineItemRepositoryInterface 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) { + //Find the LTI resource link by its ID: + $resource_link = \LtiResourceLink::find($resourceLinkIdentifier); + if (!$resource_link) { throw new LTIException('Invalid resource link identifier.'); } - $tool_id = $id_parts[0]; - $deployment_id = $id_parts[1]; - $course_id = $id_parts[2]; + $tool_id = $resource_link->deployment->tool_id ?? null; $sql = ''; $sql_params = []; - if ($tool_id && $course_id) { + if ($tool_id && $resource_link->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; + $sql_params['tool'] = self::getGradingToolName($tool_id, $resource_link->deployment_id); + $sql_params['course_id'] = $resource_link->course_id; } else { //No tool-ID means no line item collection can be found. return $result; @@ -155,18 +152,17 @@ class LineItemRepository implements LineItemRepositoryInterface */ 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]; + $resource_link_id = $lineItem->getResourceLinkIdentifier() ?? ''; + $resource_link = \LtiResourceLink::find($resource_link_id); + if (!$resource_link) { + throw new LTIException('Invalid resource link identifier.'); + } $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->course_id = $resource_link->course_id; + $definition->tool = $resource_link->id; $definition->weight = '1.0'; if ($definition->store()) { return $definition->toLineItem(); diff --git a/lib/models/Grading/Definition.php b/lib/models/Grading/Definition.php index 7fc2066..56417b2 100644 --- a/lib/models/Grading/Definition.php +++ b/lib/models/Grading/Definition.php @@ -72,21 +72,20 @@ class Definition extends \SimpleORMap 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); + $resource_link_identifier = $this->tool ?? ''; + $deployment_id = ''; + if ($resource_link_identifier) { + $lti_resource_link = \LtiResourceLink::find($resource_link_identifier); + if ($lti_resource_link) { + $deployment_id = $lti_resource_link->deployment_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 - ] - ); + $identifier = \URLHelper::getURL(sprintf( + 'dispatch.php/lti/ags/line_item/%1$s/%2$s', + $resource_link_identifier, + $this->id + )); return new LineItem( PHP_FLOAT_MAX, diff --git a/lib/models/LtiDeployment.php b/lib/models/LtiDeployment.php index 0b66427..e59e738 100644 --- a/lib/models/LtiDeployment.php +++ b/lib/models/LtiDeployment.php @@ -12,8 +12,6 @@ * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 * * @property int $id database column - * @property int $position database column - * @property string $course_id database column * @property string $title database column * @property string $description database column * @property int $tool_id database column @@ -37,64 +35,24 @@ class LtiDeployment extends SimpleORMap $config['serialized_fields']['options'] = JSONArrayObject::class; - $config['belongs_to']['course'] = [ - 'class_name' => Course::class, - 'foreign_key' => 'course_id' - ]; $config['belongs_to']['tool'] = [ 'class_name' => LtiTool::class, 'foreign_key' => 'tool_id' ]; - + $config['has_many']['resource_links'] = [ + 'class_name' => LtiResourceLink::class, + 'assoc_foreign_key' => 'deployment_id', + 'on_delete' => 'delete' + ]; $config['has_many']['grades'] = [ 'class_name' => LtiGrade::class, 'assoc_foreign_key' => 'link_id', '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. - * - * @return static|null - */ - public static function findByCourseAndPosition($course_id, $position) - { - return self::findOneBySQL('course_id = ? AND position = ?', [$course_id, $position]); - } - - /** - * Delete this entity. - */ - public function delete() - { - $db = DBManager::get(); - $course_id = $this->course_id; - $position = $this->position; - - if ($result = parent::delete()) { - $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 ?? ''; diff --git a/lib/models/LtiResourceLink.php b/lib/models/LtiResourceLink.php new file mode 100644 index 0000000..785c350 --- /dev/null +++ b/lib/models/LtiResourceLink.php @@ -0,0 +1,187 @@ + Course::class, + 'foreign_key' => 'course_id' + ]; + $config['belongs_to']['deployment'] = [ + 'class_name' => LtiDeployment::class, + 'foreign_key' => 'deployment_id' + ]; + + $config['registered_callbacks']['before_create'] = ['cbCalculatePosition']; + + parent::configure($config); + } + + /** + * Calculates the position for a new LTI resource link in the course. + */ + public function cbCalculatePosition() : void + { + $this->position = self::countByCourse_id($this->course_id); + } + + /** + * Delete this entity. + */ + public function delete() + { + $course_id = $this->course_id; + $position = $this->position; + if ($result = parent::delete()) { + DBManager::get()->execute( + "UPDATE `lti_resource_links` + SET `position` = position - 1 + WHERE `course_id` = :course_id AND `position` > :position", + [ + 'course_id' => $course_id, + 'position' => $position + ] + ); + } + + return $result; + } + + /** + * Find a single entry by course_id and position. + * + * @return static|null + */ + public static function findByCourseAndPosition($course_id, $position) + { + return self::findOneBySQL('course_id = ? AND position = ?', [$course_id, $position]); + } + + //OAT library LtiResourceLinkInterface and ResourceInterface implementation: + + public function getUrl(): ?string + { + if ($this->deployment) { + return $this->deployment->getLaunchURL(); + } + return null; + } + + public function getIcon(): ?array + { + return null; + } + + public function getThumbnail(): ?array + { + if ($this->course) { + return [$this->course->getItemAvatarURL()]; + } + return null; + } + + public function getIframe(): ?array + { + //Not supported. + return null; + } + + public function getCustom(): ?array + { + //Not supported. + return null; + } + + public function getLineItem(): ?array + { + // TODO: Implement getLineItem() method. + return null; + } + + public function getAvailability(): ?array + { + // TODO: Implement getAvailability() method. + return null; + } + + public function getSubmission(): ?array + { + // TODO: Implement getSubmission() method. + return null; + } + + public function getIdentifier(): string + { + return strval($this->id); + } + + public function getType(): string + { + return 'ltiResourceLink'; + } + + public function getTitle(): ?string + { + if ($this->deployment) { + return $this->deployment->title; + } + return null; + } + + public function getText(): ?string + { + return null; + } + + public function getProperties(): CollectionInterface + { + $collection = new Collection(); + $collection->add([ + 'url' => $this->getUrl(), + 'title' => $this->getTitle() + ]); + return $collection; + } + + public function normalize(): array + { + return array_filter( + array_merge( + $this->getProperties()->all(), + ['type' => $this->getType()] + ) + ); + } +} diff --git a/lib/models/LtiTool.php b/lib/models/LtiTool.php index 9839ac0..bde6203 100644 --- a/lib/models/LtiTool.php +++ b/lib/models/LtiTool.php @@ -48,7 +48,7 @@ class LtiTool extends SimpleORMap { $config['db_table'] = 'lti_tools'; - $config['has_many']['links'] = [ + $config['has_many']['deployments'] = [ //formerly: links 'class_name' => LtiDeployment::class, 'assoc_foreign_key' => 'tool_id', 'on_delete' => 'delete' diff --git a/lib/modules/LtiToolModule.php b/lib/modules/LtiToolModule.php index 2a64e83..9d977be 100644 --- a/lib/modules/LtiToolModule.php +++ b/lib/modules/LtiToolModule.php @@ -27,7 +27,7 @@ class LtiToolModule extends CorePlugin implements StudipModule, SystemPlugin, Pr LtiGrade::deleteBySQL('user_id = ?', [$user->id]); }); NotificationCenter::on('CourseDidDelete', function ($event, $course) { - LtiDeployment::deleteBySQL('course_id = ?', [$course->id]); + \LtiResourceLink::deleteBySQL('course_id = ?', [$course->id]); }); } @@ -40,7 +40,7 @@ class LtiToolModule extends CorePlugin implements StudipModule, SystemPlugin, Pr return null; } - $changed = LtiDeployment::countBySQL('course_id = ? AND chdate > ?', [$course_id, $last_visit]); + $changed = \LtiResourceLink::countBySQL('course_id = ? AND chdate > ?', [$course_id, $last_visit]); $icon = Icon::create('plugin', $changed ? Icon::ROLE_NEW : Icon::ROLE_CLICKABLE); @@ -60,7 +60,7 @@ class LtiToolModule extends CorePlugin implements StudipModule, SystemPlugin, Pr return []; } - $grades = LtiDeployment::countBySQL('course_id = ?', [$course_id]); + $grades = \LtiResourceLink::countBySQL('course_id = ?', [$course_id]); $navigation = new Navigation(_('LTI-Tools')); $navigation->setImage(Icon::create('link-extern', Icon::ROLE_INFO_ALT)); -- cgit v1.0