Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
41d8725
[FIX] LTI: Add AGS consumer line item and score services
Saaweel May 22, 2026
e6d13e9
[FIX] LTI: Publish AGS scores in provider mode
Saaweel May 22, 2026
df58abf
[FIX] LTI: Update language labels
Saaweel May 22, 2026
7e4da05
Merge branch 'ilias11_LTI_fix_deep_linking' into ilias11_LTI_ags_deep…
Saaweel May 22, 2026
35e90a9
[FIX] LTI: Add content selection language labels
Saaweel May 27, 2026
0723440
[FIX] LTI: Add lost $logger var
Saaweel Jun 1, 2026
089b001
[FIX] LTI: Fix deep linking user selection
Saaweel Jun 2, 2026
bd46d97
[FIX] LTI: Restore attended setter on consumer result
Saaweel Jun 3, 2026
06728aa
[FIX] LTI: Fix deep linking redirect handling
Saaweel Jun 3, 2026
b6fdc47
[FIX] LTI: Preserve deep linking target link URI
Saaweel Jun 3, 2026
ac965ac
[FIX] LTI: Resolve AGS score user identifiers
Saaweel Jun 11, 2026
d0ae088
[FIX] LTI: Accept AGS score denominator
Saaweel Jun 12, 2026
6cf5502
[FIX] LTI: Share AGS user identifier matching
Saaweel Jun 16, 2026
59ca25d
[FIX] LTI: Allow deep linking content selection
Saaweel Jun 18, 2026
1a72920
[FIX] LTI: Improve AGS gradebook results
Saaweel Jun 19, 2026
019aa21
[FIX] LTI: Return correct userId and score scale in AGS results
Saaweel Jun 25, 2026
471e2f8
[FIX] LTI: Improve consumer creation error handling
Saaweel Jun 30, 2026
a62b2ae
[FIX] LTI: Complete fully graded submitted activities
Saaweel Jul 24, 2026
b826a58
[FIX] LTI: Format consumer provider form
Saaweel Jul 29, 2026
4584211
[FIX] LTI: Sort language entries
Saaweel Jul 29, 2026
a007719
[FIX] LTI: Escape launch form values
Saaweel Jul 31, 2026
8d2520e
[FIX] LTI: Remove redundant exits
Saaweel Jul 31, 2026
00dd024
[FIX] LTI: Send deep linking response via HTTP
Saaweel Jul 31, 2026
dd1cdfa
[FIX] LTI: Restore provider title on line item deletion
Saaweel Jul 31, 2026
18c363e
[FIX] LTI: Use HTTP services for line item requests
Saaweel Jul 31, 2026
5279a3f
Update components/ILIAS/LTIConsumer/classes/class.ilLTIConsumerGradeS…
Saaweel Jul 31, 2026
097ca97
[FIX] LTI: Remove unused stored line item score
Saaweel Jul 31, 2026
311783a
[FIX] LTI: Use string prefix helper
Saaweel Jul 31, 2026
532aed3
[FIX] LTI: Reuse user for identifier lookup
Saaweel Jul 31, 2026
2a3daf7
Update components/ILIAS/LTIConsumer/classes/class.ilLTIConsumeProvide…
Saaweel Jul 31, 2026
7fb4b52
[FIX] LTI: Correct deep linking parameter type
Saaweel Jul 31, 2026
1bebfa8
[FIX] LTI: Remove redundant deployment ID check
Saaweel Jul 31, 2026
dab258a
Merge branch 'ilias11_LTI_ags_deeplinking' of https://github.com/surl…
Saaweel Jul 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -256,4 +256,60 @@ public function step_16(): void
$this->db->manipulate($query);
}
}

public function step_17(): void
{
if (!$this->db->tableColumnExists('lti_consumer_settings', 'score_maximum')) {
$this->db->addTableColumn('lti_consumer_settings', 'score_maximum', [
'type' => 'float',
'notnull' => false,
'default' => 1
]);
}
}

public function step_18(): void
{
if (!$this->db->tableExists('lti_consumer_lineitems')) {
$values = [
'id' => ['type' => 'integer', 'length' => 4, 'notnull' => true],
'context_id' => ['type' => 'integer', 'length' => 4, 'notnull' => true],
'obj_id' => ['type' => 'integer', 'length' => 4, 'notnull' => false],
'client_id' => ['type' => 'text', 'length' => 255, 'notnull' => false],
'label' => ['type' => 'text', 'length' => 255, 'notnull' => false],
'score_maximum' => ['type' => 'float', 'notnull' => false, 'default' => 1],
'resource_id' => ['type' => 'text', 'length' => 255, 'notnull' => false],
'resource_link_id' => ['type' => 'text', 'length' => 255, 'notnull' => false],
'tag' => ['type' => 'text', 'length' => 255, 'notnull' => false],
'enabled' => ['type' => 'integer', 'length' => 1, 'notnull' => true, 'default' => 1]
];
$this->db->createTable('lti_consumer_lineitems', $values);
$this->db->addPrimaryKey('lti_consumer_lineitems', ['id']);
$this->db->createSequence('lti_consumer_lineitems');
}
}

public function step_19(): void
{
if ($this->db->tableExists('lti_consumer_lineitems') &&
!$this->db->tableColumnExists('lti_consumer_lineitems', 'resource_link_id')) {
$this->db->addTableColumn('lti_consumer_lineitems', 'resource_link_id', [
'type' => 'text',
'length' => 255,
'notnull' => false
]);
}
}

public function step_20(): void
{
if ($this->db->tableExists('lti_consumer_lineitems') &&
!$this->db->tableColumnExists('lti_consumer_lineitems', 'client_id')) {
$this->db->addTableColumn('lti_consumer_lineitems', 'client_id', [
'type' => 'text',
'length' => 255,
'notnull' => false
]);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,46 @@ public function __construct(?int $providerId = null)

private function preventClientIdInUrl(string $url): string
{
return preg_replace('/(\?|&)?client_id=[^&]*/', '', $url);
if (str_contains($url, ',')) {
return implode(',', array_map([$this, 'preventClientIdInUrl'], explode(',', $url)));
}

$parts = parse_url($url);
if ($parts === false || !isset($parts['query'])) {
return $url;
}

parse_str($parts['query'], $query);
unset($query['client_id']);

$result = '';
if (isset($parts['scheme'])) {
$result .= $parts['scheme'] . '://';
}
if (isset($parts['user'])) {
$result .= $parts['user'];
if (isset($parts['pass'])) {
$result .= ':' . $parts['pass'];
}
$result .= '@';
}
if (isset($parts['host'])) {
$result .= $parts['host'];
}
if (isset($parts['port'])) {
$result .= ':' . $parts['port'];
}
$result .= $parts['path'] ?? '';

$queryString = http_build_query($query, '', '&', PHP_QUERY_RFC3986);
if ($queryString !== '') {
$result .= '?' . $queryString;
}
if (isset($parts['fragment'])) {
$result .= '#' . $parts['fragment'];
}

return $result;
}

/**
Expand Down
97 changes: 93 additions & 4 deletions components/ILIAS/LTIConsumer/classes/class.ilLTIConsumeProviderFormGUI.php
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,88 @@ public function setAdminContext(bool $adminContext): void
$this->adminContext = $adminContext;
}

/**
* Build the "Deep linking" UI (button + modal with iframe).
*
* @param array $target_gui_class GUI class path that has startDeepLinkingCmd()
* @param string $cmd command name, default 'startDeepLinking'
* @return string rendered HTML to embed in a form/custom input
*/
private function buildDlUiParts(
array $target_gui_class = [ilLTIConsumeProviderSettingsGUI::class],
string $cmd = 'startDeepLinking'
): string {
global $DIC;

$factory = $DIC->ui()->factory();
$renderer = $DIC->ui()->renderer();
$ctrl = $DIC->ctrl();

if ($this->isAdminContext()) {
$target_gui_class = [
ilAdministrationGUI::class,
ilObjLTIConsumerGUI::class,
ilLTIConsumerSettingsGUI::class,
ilLTIConsumeProviderSettingsGUI::class
];

$ctrl->setParameterByClass(ilLTIConsumerSettingsGUI::class, 'ref_id', ilObjLTIConsumer::getRefIdOfConsumerByDeploymentId((string) $this->getProvider()->getId()));
}

$iframe_url = $ctrl->getLinkTargetByClass(
$target_gui_class,
$cmd,
'',
true
);

$iframe_html = sprintf(
'<iframe src="%s" style="width:100%%;height:70vh;border:0;" allow="fullscreen"></iframe>',
htmlspecialchars($iframe_url, ENT_QUOTES)
);

$content = $factory->legacy()->content($iframe_html);

$modal = $factory
->modal()
->roundtrip($this->lng->txt('subtab_provider_settings'), $content);

$button = $factory
->button()
->standard($this->lng->txt('select'), '')
->withOnClick($modal->getShowSignal());

$html = $renderer->render([$button, $modal]);

$html .= <<<HTML
<script>
function closeDialog() {
let dlg = document.querySelector('dialog.c-modal.il-modal-roundtrip[open], dialog.c-modal[open]');
if (dlg && typeof dlg.close === 'function') {
dlg.close();
}
let backdrop = document.querySelector('.c-modal__backdrop, dialog + .c-modal__backdrop');
if (backdrop) {
backdrop.remove();
}

}
document.addEventListener('click', function (e) {
closeDialog();
}, true);

window.onLtiDeepLinkDone = function (url) {
closeDialog();
if(url) {
window.location.href = url;
}
};
</script>
HTML;

return $html;
}

public function initForm(string $formaction, string $saveCmd, string $cancelCmd): void
{
global $DIC;
Expand Down Expand Up @@ -126,6 +208,15 @@ public function initForm(string $formaction, string $saveCmd, string $cancelCmd)
if ($this->provider->getId() == 0) {
$lti13->setInfo($lng->txt('lti_con_version_1.3_before_id'));
}

if (!empty($this->provider->isContentItem())) {

$dl_html = $this->buildDlUiParts();
$dl_input = new ilCustomInputGUI($this->lng->txt('tab_content'));
$dl_input->setHTML($dl_html);
$lti13->addSubItem($dl_input);
}

$versionInp->addOption($lti13);
$providerUrlInp = new ilTextInputGUI($lng->txt('lti_con_tool_url'), 'provider_url13');
$providerUrlInp->setValue($this->provider->getProviderUrl());
Expand Down Expand Up @@ -885,7 +976,7 @@ public function initProvider(ilLTIConsumeProvider $provider): void
if (preg_match_all('/\S+/sm', $this->getInput('redirection_uris'), $redirect_uris_matches)) {
$provider->setRedirectionUris(implode(",", $redirect_uris_matches[0]));
} else {
$provider->setRedirectionUris($this->provider->getInitiateLogin());
$provider->setRedirectionUris($this->provider->getRedirectionUris());
}
$provider->setKeyType($this->getInput('key_type'));
if ($provider->getKeyType() == 'RSA_KEY') {
Expand Down Expand Up @@ -976,10 +1067,8 @@ public function getDynRegRequest(): string
}
$showToolConfigUrl = $DIC->ctrl()->getLinkTargetByClass([ilRepositoryGUI::class, ilObjLTIConsumerGUI::class], 'showToolConfig');
$regErrorUrl = $DIC->ctrl()->getLinkTargetByClass([ilRepositoryGUI::class, ilObjLTIConsumerGUI::class], 'addDynReg');
$this->getItemByPostVar('lti_dyn_reg_url')->setDisabled(true);
$this->getItemByPostVar('lti_dyn_reg_custom_params')->setDisabled(true);
$this->clearCommandButtons();
//$this->addCommandButton("cancelDynReg", $DIC->language()->txt('cancel'));
$this->addCommandButton("addDynReg", $DIC->language()->txt('save'));
$template = new ilTemplate('tpl.lti_dyn_reg_request.html', true, true, "components/ILIAS/LTIConsumer");
$template->setVariable('LTI_TOOL_REG_URL', $toolRegUrl);
$template->setVariable('LTI_DYN_REG_URL', $regUrl);
Expand Down
47 changes: 47 additions & 0 deletions components/ILIAS/LTIConsumer/classes/class.ilLTIConsumeProviderSettingsGUI.php
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,53 @@ private function getClientIdFromProviderUrl(string $providerUrl): string
return $res;
}

protected function startDeepLinkingCmd(): never
{
global $DIC;
$provider = $this->object->getProvider();

$platform_client_id = $provider->getClientId();
$deployment_id = $provider->getId();
$user_id = ilObjLTIConsumer::getDeepLinkingUserIdentifier($provider, $DIC->user()); // or whatever you used as login_hint

$lti_message_hint = json_encode([
'deployment_id' => $deployment_id,
]);
$state = bin2hex(random_bytes(16));


$params = [
'login_hint' => $user_id,
'iss' => ilObjLTIConsumer::getPlattformId(),
'lti_message_hint' => $lti_message_hint,
'target_link_uri' => $provider->getContentItemUrl(),
'id' => $platform_client_id, // Instead of client_id due to ILIAS redirection system
'lti_deployment_id' => $deployment_id,
'state' => $state,
];


$join = (str_contains($provider->getInitiateLogin(), '?')) ? '&' : '?';
$url = $provider->getInitiateLogin() . $join . http_build_query($params);
$urlSafe = htmlspecialchars($url, ENT_QUOTES);
$html = <<<HTML
<!doctype html>
<html>
<body onload="window.location.href='{$urlSafe}'">
<noscript>
<p>Continue to deep linking...</p>
<a href="{$urlSafe}">Continue</a>
</noscript>
</body>
</html>
HTML;
$response = $DIC->http()->response()->withBody(ILIAS\Filesystem\Stream\Streams::ofString($html));
$DIC->http()->saveResponse($response);
$DIC->http()->sendResponse();
$DIC->http()->close();

}

/**
* @throws ilMDServicesException
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,8 @@ protected function getLoginLTI13Form(): ?string
return null;
} else {
$this->initCmixUser();
$params = $this->getLaunchParametersLTI13($loginData['redirect_uri'], $this->object->getProvider()->getClientId(), $this->object->getProvider()->getId(), $loginData['nonce']);
$targetLinkUri = $loginData['target_link_uri'] ?? $this->getTargetLinkUri();
$params = $this->getLaunchParametersLTI13($targetLinkUri, $this->object->getProvider()->getClientId(), $this->object->getProvider()->getId(), $loginData['nonce']);
if (isset($loginData['state'])) {
$params['state'] = $loginData['state'];
}
Expand Down Expand Up @@ -252,16 +253,17 @@ protected function getStartButtonTxt13(): string
$target = $this->object->getLaunchMethod() == "newWin" ? "_blank" : "_self";
$button = '<input class="btn btn-default ilPre" type="button" onClick="ltilaunch()" value = "' . $this->lng->txt("show_content") . '" />';
$ltiMessageHint = (string) $this->object->getRefId() . ":" . CLIENT_ID;
$html = $this->dic->refinery()->encode()->htmlSpecialCharsAsEntities();
ilSession::set('lti_message_hint', $ltiMessageHint);
$output = '<form id="lti_launch_form" name="lti_launch_form" action="' . $this->object->getProvider()->getInitiateLogin() . '" method="post" target="' . $target . '" encType="application/x-www-form-urlencoded">';
$output = '<form id="lti_launch_form" name="lti_launch_form" action="' . $html->transform($this->object->getProvider()->getInitiateLogin()) . '" method="post" target="' . $target . '" encType="application/x-www-form-urlencoded">';

$output .= sprintf('<input type="hidden" name="%s" value="%s" />', 'iss', ilObjLTIConsumer::getIliasHttpPath()) . "\n";
$output .= sprintf('<input type="hidden" name="%s" value="%s" />', 'target_link_uri', $this->object->getProvider()->getProviderUrl()) . "\n";
$output .= sprintf('<input type="hidden" name="%s" value="%s" />', 'login_hint', $user_ident) . "\n";
$output .= sprintf('<input type="hidden" name="%s" value="%s" />', 'iss', $html->transform(ilObjLTIConsumer::getIliasHttpPath())) . "\n";
$output .= sprintf('<input type="hidden" name="%s" value="%s" />', 'target_link_uri', $html->transform($this->getTargetLinkUri())) . "\n";
$output .= sprintf('<input type="hidden" name="%s" value="%s" />', 'login_hint', $html->transform($user_ident)) . "\n";
$output .= sprintf('<input type="hidden" name="%s" value="%s" />', 'lti_message_hint', $ltiMessageHint) . "\n";
$output .= sprintf('<input type="hidden" name="%s" value="%s" />', 'client_id', $this->object->getProvider()->getClientId()) . "\n";
$output .= sprintf('<input type="hidden" name="%s" value="%s" />', 'client_id', $html->transform($this->object->getProvider()->getClientId())) . "\n";
$output .= sprintf('<input type="hidden" name="%s" value="%s" />', 'lti_deployment_id', $this->object->getProvider()->getId()) . "\n";
$output .= sprintf('<input type="hidden" name="%s" value="%s" />', 'launch_presentation_return_url', $returnUrl) . "\n";
$output .= sprintf('<input type="hidden" name="%s" value="%s" />', 'launch_presentation_return_url', $html->transform($returnUrl)) . "\n";
$output .= sprintf('<input type="hidden" name="%s" value="%s" />', 'lis_result_sourcedid', $token) . "\n";
$output .= $button;
$output .= '</form>';
Expand Down Expand Up @@ -289,13 +291,14 @@ protected function getEmbeddedAutoStartFormular(): string
$target = "_self";
$output = '';
$ltiMessageHint = (string) $this->object->getRefId() . ":" . CLIENT_ID;
$html = $this->dic->refinery()->encode()->htmlSpecialCharsAsEntities();
ilSession::set('lti_message_hint', $ltiMessageHint);
$output = '<form id="lti_launch_form" name="lti_launch_form" action="' . $this->object->getProvider()->getInitiateLogin() . '" method="post" target="' . $target . '" encType="application/x-www-form-urlencoded">';
$output .= sprintf('<input type="hidden" name="%s" value="%s" />', 'iss', ilObjLTIConsumer::getIliasHttpPath()) . "\n";
$output .= sprintf('<input type="hidden" name="%s" value="%s" />', 'target_link_uri', $this->object->getProvider()->getProviderUrl()) . "\n";
$output .= sprintf('<input type="hidden" name="%s" value="%s" />', 'login_hint', $user_ident) . "\n";
$output = '<form id="lti_launch_form" name="lti_launch_form" action="' . $html->transform($this->object->getProvider()->getInitiateLogin()) . '" method="post" target="' . $target . '" encType="application/x-www-form-urlencoded">';
$output .= sprintf('<input type="hidden" name="%s" value="%s" />', 'iss', $html->transform(ilObjLTIConsumer::getIliasHttpPath())) . "\n";
$output .= sprintf('<input type="hidden" name="%s" value="%s" />', 'target_link_uri', $html->transform($this->getTargetLinkUri())) . "\n";
$output .= sprintf('<input type="hidden" name="%s" value="%s" />', 'login_hint', $html->transform($user_ident)) . "\n";
$output .= sprintf('<input type="hidden" name="%s" value="%s" />', 'lti_message_hint', $ltiMessageHint) . "\n";
$output .= sprintf('<input type="hidden" name="%s" value="%s" />', 'client_id', $this->object->getProvider()->getClientId()) . "\n";
$output .= sprintf('<input type="hidden" name="%s" value="%s" />', 'client_id', $html->transform($this->object->getProvider()->getClientId())) . "\n";
$output .= sprintf('<input type="hidden" name="%s" value="%s" />', 'lti_deployment_id', $this->object->getProvider()->getId()) . "\n";
$output .= '</form>';

Expand Down Expand Up @@ -404,6 +407,18 @@ protected function getLaunchParametersLTI13(string $endpoint, string $clientId,
);
}

private function getTargetLinkUri(): string
{
foreach (explode(';', $this->object->getCustomParams()) as $param) {
$parts = explode('=', $param, 2);
if (count($parts) === 2 && trim($parts[0]) === 'target_link_uri') {
return trim($parts[1]);
}
}

return $this->object->getProvider()->getProviderUrl();
}

public static function isEmbeddedLaunchRequest(): bool
{
global $DIC; /* @var \ILIAS\DI\Container $DIC */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,12 @@ public function getResources(): array
*/
public function getPermittedScopes(): array
{
$scopes = array();
$scopes[] = self::SCOPE_GRADESERVICE_LINEITEM;
$scopes[] = self::SCOPE_GRADESERVICE_LINEITEM_READ;
$scopes[] = self::SCOPE_GRADESERVICE_RESULT_READ;
$scopes[] = self::SCOPE_GRADESERVICE_SCORE;
return $scopes;
return [
self::SCOPE_GRADESERVICE_LINEITEM,
self::SCOPE_GRADESERVICE_LINEITEM_READ,
self::SCOPE_GRADESERVICE_RESULT_READ,
self::SCOPE_GRADESERVICE_SCORE
];
}

/**
Expand Down
Loading
Loading