diff --git a/components/ILIAS/Init/classes/class.ilInitialisation.php b/components/ILIAS/Init/classes/class.ilInitialisation.php index 8f179044a58d..29c0320c932d 100755 --- a/components/ILIAS/Init/classes/class.ilInitialisation.php +++ b/components/ILIAS/Init/classes/class.ilInitialisation.php @@ -1452,7 +1452,6 @@ protected static function initHTML(): void $_GET['offset'] = (int) $_GET['offset']; // old code } - $GLOBALS["DIC"]["lti"]->init(); self::initKioskMode($GLOBALS["DIC"]); } } diff --git a/components/ILIAS/Init/classes/class.ilStartUpGUI.php b/components/ILIAS/Init/classes/class.ilStartUpGUI.php index 8b07bc8621fe..0ae745c60889 100755 --- a/components/ILIAS/Init/classes/class.ilStartUpGUI.php +++ b/components/ILIAS/Init/classes/class.ilStartUpGUI.php @@ -154,7 +154,6 @@ public function getSafePostCommands(): array { return [ 'doStandardAuthentication', - 'doLTIAuthentication' ]; } @@ -602,57 +601,6 @@ static function (ILIAS\Data\Password $value): string { ->withAdditionalTransformation($this->saniziteArrayElementsTrafo()); } - private function doLTIAuthentication(): void - { - $this->getLogger()->debug('Trying lti authentication'); - - $credentials = new ilAuthFrontendCredentialsLTI(); - $credentials->initFromRequest(); - - $provider_factory = new ilAuthProviderFactory(); - $provider = $provider_factory->getProviderByAuthMode($credentials, ilAuthUtils::AUTH_PROVIDER_LTI); - - $status = ilAuthStatus::getInstance(); - - $frontend_factory = new ilAuthFrontendFactory(); - $frontend_factory->setContext(ilAuthFrontendFactory::CONTEXT_STANDARD_FORM); - $frontend = $frontend_factory->getFrontend( - $this->authSession, - $status, - $credentials, - [$provider] - ); - $frontend->authenticate(); - - $lti_target = $this->dic['lti']->getLaunchTarget(); - if ($lti_target !== '') { - ilSession::set('orig_request_target', $lti_target); - } - - switch ($status->getStatus()) { - case ilAuthStatus::STATUS_AUTHENTICATED: - ilLTISessionCookie::allowCrossSite(); - // the user of a launch goes to the object the platform launched - if ($lti_target !== '') { - $this->ctrl->redirectToURL($lti_target); - } - ilLoggerFactory::getLogger('auth')->debug('Authentication successful; Redirecting to starting page.'); - ilInitialisation::redirectToStartingPage(); - - // no break - case ilAuthStatus::STATUS_ACCOUNT_MIGRATION_REQUIRED: - $this->ctrl->redirect($this, 'showAccountMigration'); - - // no break - case ilAuthStatus::STATUS_AUTHENTICATION_FAILED: - $this->mainTemplate->setOnScreenMessage('failure', $status->getTranslatedReason(), true); - $this->ctrl->redirect($this, 'showLoginPage'); - } - - $this->mainTemplate->setOnScreenMessage('failure', $this->lng->txt('err_wrong_login')); - $this->showLoginPage(); - } - private function doApacheAuthentication(): void { $this->getLogger()->debug('Trying apache authentication'); diff --git a/components/ILIAS/LTI/LTI.php b/components/ILIAS/LTI/LTI.php index 44255ac8a61c..91fba7d71648 100644 --- a/components/ILIAS/LTI/LTI.php +++ b/components/ILIAS/LTI/LTI.php @@ -20,25 +20,6 @@ namespace ILIAS; -use ArrayAccess; -use Closure; -use ILIAS\DI\Container; -use ILIAS\DI\UIServices; -use ILIAS\GlobalScreen\Services as GlobalScreen; -use ILIAS\HTTP\GlobalHttpState; -use ILIAS\LTI\View as LTIView; -use ILIAS\Refinery\Factory as Refinery; -use ILIAS\Setup\Agent; -use ILIAS\UI\Factory as UIFactory; -use ILIAS\UI\Renderer as UIRenderer; -use ilAuthSession; -use ilCtrl; -use ilLanguage; -use ilLTISetupAgent; -use ilLTIViewGUI; -use ilObjUser; -use ReflectionClass; - /** * LTI component: LTI 1.1 (classes/LTI1p1) and LTI Advantage (classes/LTIAdvantage) for ILIAS as consumer and provider. * See README.md for the structure. @@ -48,31 +29,22 @@ class LTI implements Component\Component { public function init( - array | ArrayAccess &$define, - array | ArrayAccess &$implement, - array | ArrayAccess &$use, - array | ArrayAccess &$contribute, - array | ArrayAccess &$seek, - array | ArrayAccess &$provide, - array | ArrayAccess &$pull, - array | ArrayAccess &$internal, + array | \ArrayAccess &$define, + array | \ArrayAccess &$implement, + array | \ArrayAccess &$use, + array | \ArrayAccess &$contribute, + array | \ArrayAccess &$seek, + array | \ArrayAccess &$provide, + array | \ArrayAccess &$pull, + array | \ArrayAccess &$internal, ): void { - $define[] = LTIView::class; - - $implement[LTIView::class] = static fn() => new ilLTIViewGUI( - $use[GlobalHttpState::class], - $pull[Refinery::class], - $use[UIFactory::class], - $use[UIRenderer::class], - self::legacy(UIServices::class, static fn(Container $dic): UIServices => $dic->ui()), - self::legacy(ilCtrl::class, static fn(Container $dic): ilCtrl => $dic['ilCtrl']), - self::legacy(ilObjUser::class, static fn(Container $dic): ilObjUser => $dic['ilUser']), - self::legacy(ilLanguage::class, static fn(Container $dic): ilLanguage => $dic['lng']), - self::legacy(GlobalScreen::class, static fn(Container $dic): GlobalScreen => $dic['global_screen']), - self::legacy(ilAuthSession::class, static fn(Container $dic): ilAuthSession => $dic['ilAuthSession']), - ); - - $contribute[Agent::class] = static fn() => new ilLTISetupAgent($pull[Refinery::class]); + $define[] = \ILIAS\LTI\View::class; + + $implement[\ILIAS\LTI\View::class] = static fn(): \ILIAS\LTI\View => + $internal[\ilLTIViewGUI::class]; + + $contribute[\ILIAS\Setup\Agent::class] = static fn(): \ILIAS\Setup\Agent => + new \ilLTISetupAgent($pull[\ILIAS\Refinery\Factory::class]); $contribute[Component\Resource\PublicAsset::class] = fn() => new Component\Resource\Endpoint($this, "ltiresult.php"); @@ -106,22 +78,31 @@ public function init( $contribute[Component\Resource\PublicAsset::class] = fn() => new Component\Resource\ComponentCSS($this, "lti_view.css"); - } - /** - * A service the legacy initialisation still sets up, which the LTI view only gets once it uses it. - * - * @template T of object - * @param class-string $class - * @param Closure(Container): T $get - * @return T - */ - private static function legacy(string $class, Closure $get): object - { - return new ReflectionClass($class)->newLazyProxy(static function () use ($get): object { - global $DIC; - - return $get($DIC); - }); + $internal[\ilLTIViewGUI::class] = static fn(): \ilLTIViewGUI => + new \ilLTIViewGUI( + $use[\ILIAS\HTTP\GlobalHttpState::class], + $pull[\ILIAS\Refinery\Factory::class], + $use[\ILIAS\UI\Factory::class], + $use[\ILIAS\UI\Renderer::class], + $use[\ILIAS\UICore\GlobalTemplate::class], + $use[\ILIAS\Language\Language::class], + $internal[\ILIAS\LTI\View\CurrentUser::class], + $internal[\ILIAS\LTI\View\Navigation::class], + $internal[\ILIAS\LTI\View\ScreenContext::class], + $internal[\ILIAS\LTI\View\AuthSession::class], + ); + + $internal[\ILIAS\LTI\View\CurrentUser::class] = static fn(): \ILIAS\LTI\View\CurrentUser => + new \ILIAS\LTI\View\CurrentUserLegacyProxy(); + + $internal[\ILIAS\LTI\View\Navigation::class] = static fn(): \ILIAS\LTI\View\Navigation => + new \ILIAS\LTI\View\NavigationLegacyProxy(); + + $internal[\ILIAS\LTI\View\ScreenContext::class] = static fn(): \ILIAS\LTI\View\ScreenContext => + new \ILIAS\LTI\View\ScreenContextLegacyProxy(); + + $internal[\ILIAS\LTI\View\AuthSession::class] = static fn(): \ILIAS\LTI\View\AuthSession => + new \ILIAS\LTI\View\AuthSessionLegacyProxy(); } } diff --git a/components/ILIAS/LTI/README.md b/components/ILIAS/LTI/README.md index 71bb870e3d44..5ca97a01edf2 100644 --- a/components/ILIAS/LTI/README.md +++ b/components/ILIAS/LTI/README.md @@ -46,6 +46,7 @@ LTI/ │ └── Setup/ Setup agent and database update steps ├── resources/ Endpoints ├── src/ What the component provides to the others through the bootstrap (ILIAS\LTI\View) +│ └── View/ What the LTI view needs of services not in the bootstrap yet, with their legacy proxies └── templates/default/ Templates of LTI 1.1 are prefixed with tpl.lti1p1_ ``` @@ -59,9 +60,9 @@ LTI/ * **LTIAdvantage/Common**: only what the Advantage platform and tool roles both use. * **Administration**: the administration node (`ilObjLTIAdministration*`). Its screens serve LTI 1.1 and LTI Advantage. * **Tool**: ILIAS as the tool a platform launches. celtic/lti checks a launch of either version by its - data, so the launch (`ilLTILaunchReceiver`, `ilLTIDataConnector`), the login (`ilAuthProviderLTI`), the - LTI view (`ilLTIViewGUI`, which `LTI.php` provides as `ILIAS\LTI\View` and Init passes to the legacy - environment as `$DIC['lti']`), the releases of objects (`ilLTIProviderObjectSettingGUI`, `ilLTIRelease`) and + data, so the launch (`ilLTILaunchReceiver`, `ilLTIDataConnector`), the login (`ilLTILoginGUI`, + `ilAuthProviderLTI`), the LTI view (`ilLTIViewGUI`, which `LTI.php` provides as `ILIAS\LTI\View` and Init + passes to the legacy environment as `$DIC['lti']`; `ilLTIViewLayoutProvider` shapes its pages), the releases of objects (`ilLTIProviderObjectSettingGUI`, `ilLTIRelease`) and the learning progress sent back (`ilLTIAppEventListener`, `ilLTICronOutcomeService`) are shared. Only what a version adds on top goes to LTI1p1 or LTIAdvantage. Several of these names are fixed by the core: `ilAuthProviderLTI`, `ilAuthFrontendCredentialsLTI`, `ilLTIProviderObjectSettingGUI`, @@ -104,7 +105,7 @@ the endpoints: default tool of the library, with its PEM key or key set URL. A launch starts the OpenID Connect login at the tool, which gets the id_token from `ltiauth.php`; the login waits in the session and is used once. Tools often ask `ltiauth.php` with a POST from their own site, which does not carry a SameSite=Lax cookie, - so the page that starts the launch sends the session cookie again as SameSite=None, as `ilStartUpGUI` + so the page that starts the launch sends the session cookie again as SameSite=None, as `ilLTILoginGUI` does for LTI sessions. An embedded launch also offers the platform storage of LTI (`Platform::getStorageJS()` of celtic/lti in the page around the iframe), for tools that cannot keep a cookie inside an iframe. `ltitoken.php` checks the client assertion of a tool and issues access tokens for the Assignment and diff --git a/components/ILIAS/LTI/classes/Tool/class.ilLTILoginGUI.php b/components/ILIAS/LTI/classes/Tool/class.ilLTILoginGUI.php new file mode 100644 index 000000000000..c3fd0b6c279b --- /dev/null +++ b/components/ILIAS/LTI/classes/Tool/class.ilLTILoginGUI.php @@ -0,0 +1,119 @@ + + */ +class ilLTILoginGUI implements ilCtrlBaseClassInterface, ilCtrlSecurityInterface +{ + public const string CMD_AUTHENTICATE = 'doLTIAuthentication'; + + private readonly ilCtrlInterface $ctrl; + private readonly ilGlobalTemplateInterface $main_tpl; + private readonly ilAuthSession $auth_session; + private readonly ilLanguage $lng; + private readonly ilLogger $logger; + + public function __construct() + { + global $DIC; + + $this->ctrl = $DIC->ctrl(); + $this->main_tpl = $DIC->ui()->mainTemplate(); + $this->auth_session = $DIC['ilAuthSession']; + $this->lng = $DIC->language(); + $this->logger = $DIC->logger()->forComponent('lti'); + } + + public function getUnsafeGetCommands(): array + { + return []; + } + + /** + * A platform cannot send the token of ilCtrl. + */ + public function getSafePostCommands(): array + { + return [self::CMD_AUTHENTICATE]; + } + + public function executeCommand(): void + { + if ($this->ctrl->getCmd() === self::CMD_AUTHENTICATE) { + $this->authenticate(); + } + + $this->ctrl->redirectByClass(ilStartUpGUI::class, 'showLoginPage'); + } + + private function authenticate(): void + { + $this->logger->debug('Trying lti authentication'); + + $credentials = new ilAuthFrontendCredentialsLTI(); + $credentials->initFromRequest(); + + $provider_factory = new ilAuthProviderFactory(); + $provider = $provider_factory->getProviderByAuthMode($credentials, ilAuthUtils::AUTH_PROVIDER_LTI); + + $status = ilAuthStatus::getInstance(); + + $frontend_factory = new ilAuthFrontendFactory(); + $frontend_factory->setContext(ilAuthFrontendFactory::CONTEXT_STANDARD_FORM); + $frontend = $frontend_factory->getFrontend( + $this->auth_session, + $status, + $credentials, + [$provider] + ); + $frontend->authenticate(); + + $lti_target = ilLTIViewGUI::getLaunchTarget(); + if ($lti_target !== '') { + ilSession::set('orig_request_target', $lti_target); + } + + switch ($status->getStatus()) { + case ilAuthStatus::STATUS_AUTHENTICATED: + ilLTISessionCookie::allowCrossSite(); + // the user of a launch goes to the object the platform launched + if ($lti_target !== '') { + $this->ctrl->redirectToURL($lti_target); + } + $this->logger->debug('Authentication successful; Redirecting to starting page.'); + ilInitialisation::redirectToStartingPage(); + + // no break + case ilAuthStatus::STATUS_ACCOUNT_MIGRATION_REQUIRED: + $this->ctrl->redirectByClass(ilStartUpGUI::class, 'showAccountMigration'); + + // no break + case ilAuthStatus::STATUS_AUTHENTICATION_FAILED: + $this->main_tpl->setOnScreenMessage('failure', $status->getTranslatedReason(), true); + $this->ctrl->redirectByClass(ilStartUpGUI::class, 'showLoginPage'); + } + + $this->main_tpl->setOnScreenMessage('failure', $this->lng->txt('err_wrong_login'), true); + } +} diff --git a/components/ILIAS/LTI/classes/Tool/class.ilLTIViewGUI.php b/components/ILIAS/LTI/classes/Tool/class.ilLTIViewGUI.php index c4dbff538128..fc7b9e8ec621 100644 --- a/components/ILIAS/LTI/classes/Tool/class.ilLTIViewGUI.php +++ b/components/ILIAS/LTI/classes/Tool/class.ilLTIViewGUI.php @@ -18,12 +18,16 @@ declare(strict_types=1); -use ILIAS\DI\UIServices; -use ILIAS\GlobalScreen\Services as GlobalScreen; use ILIAS\HTTP\GlobalHttpState; +use ILIAS\Language\Language; +use ILIAS\LTI\View\AuthSession; +use ILIAS\LTI\View\CurrentUser; +use ILIAS\LTI\View\Navigation; +use ILIAS\LTI\View\ScreenContext; use ILIAS\Refinery\Factory as Refinery; use ILIAS\UI\Factory as UIFactory; use ILIAS\UI\Renderer as UIRenderer; +use ILIAS\UICore\GlobalTemplate; /** * The LTI view of a user who came from a platform: ILIAS shows the launched object without its own @@ -31,7 +35,8 @@ * platform. ilLTIViewLayoutProvider shapes the page. * * The LTI component provides the view as ILIAS\LTI\View, see LTI.php; the legacy environment knows it as - * $DIC['lti'], which ilFrameTargetInfo and ilLocatorGUI ask whether the view is active. + * $DIC['lti'], which ilFrameTargetInfo and ilLocatorGUI ask whether the view is active. The other methods + * are used inside the LTI component only. * * @author Saúl Díaz */ @@ -44,58 +49,30 @@ class ilLTIViewGUI implements ILIAS\LTI\View private const string CONTEXT_PARAM = 'lti_context_id'; - private bool $initialised = false; - public function __construct( private readonly GlobalHttpState $http, private readonly Refinery $refinery, private readonly UIFactory $ui_factory, private readonly UIRenderer $ui_renderer, - private readonly UIServices $ui, - private readonly ilCtrlInterface $ctrl, - private readonly ilObjUser $user, - private readonly ilLanguage $lng, - private readonly GlobalScreen $global_screen, - private readonly ilAuthSession $auth_session, + private readonly GlobalTemplate $tpl, + private readonly Language $lng, + private readonly CurrentUser $user, + private readonly Navigation $navigation, + private readonly ScreenContext $screen_context, + private readonly AuthSession $auth_session, ) { } - public function init(): void - { - $this->initialised = true; - if (!$this->isActive()) { - return; - } - - $this->lng->loadLanguageModule('lti'); - $this->global_screen->tool()->context()->claim()->lti(); - - $meta = $this->global_screen->layout()->meta(); - $meta->addCss('./assets/css/lti_view.css'); - $css = $this->getPostData()['launch_presentation_css_url'] ?? ''; - if ($css !== '') { - $meta->addCss($css); - } - // inside the frame of the platform, links meant for the whole window stay in the frame - $meta->addOnloadCode( - 'if (window.self !== window.top) { const update = (root) => root.querySelectorAll(\'a[target="_top"], form[target="_top"]\').forEach((e) => e.setAttribute("target", "_self")); update(document); new MutationObserver(() => update(document)).observe(document.body, {childList: true, subtree: true}); }' - ); - } - public function isActive(): bool { - // before ilInitialisation has set up the user, and without a user, as in the cron job or the SOAP - // server, there is no view - if (!$this->initialised || !ilContext::hasUser()) { - return false; - } - - return str_starts_with((string) $this->user->getAuthMode(), ilAuthProviderLTI::AUTH_MODE_PREFIX); + return str_starts_with($this->user->getAuthMode(), ilAuthProviderLTI::AUTH_MODE_PREFIX); } public function executeCommand(): void { - if ($this->ctrl->getCmd() === self::CMD_EXIT) { + $this->lng->loadLanguageModule('lti'); + + if ($this->navigation->getCommand() === self::CMD_EXIT) { $this->exit(); } } @@ -122,7 +99,11 @@ private static function getLaunchSessionKey(int $ref_id): string return 'lti_' . $ref_id . '_post_data'; } - public function getLaunchTarget(): string + /** + * The address the user of an accepted launch goes on to: the object the platform launched, or an empty + * string when the session holds no launch. + */ + public static function getLaunchTarget(): string { $ref_id = (int) (((array) ilSession::get(self::SESSION_CONTEXT_IDS))[0] ?? 0); if ($ref_id === 0) { @@ -169,14 +150,13 @@ public function getTitleForExitPage(): string return $this->lng->txt('lti_exited'); } - /** - * @throws ilCtrlException - */ public function getExitLink(): string { - $this->ctrl->setParameterByClass(self::class, self::CONTEXT_PARAM, $this->getContextId()); - - return $this->ctrl->getLinkTargetByClass([ilLTIRouterGUI::class, self::class], self::CMD_EXIT); + return $this->navigation->getLinkTarget( + [ilLTIRouterGUI::class, self::class], + self::CMD_EXIT, + [self::CONTEXT_PARAM => $this->getContextId()] + ); } /** @@ -196,7 +176,7 @@ private function exit(): void if ($return_url !== '') { $this->logout(); - $this->ctrl->redirectToURL($return_url); + $this->navigation->redirectToURL($return_url); } $content = $this->ui_factory->messageBox()->info($this->lng->txt('lti_exited_info')); @@ -212,17 +192,12 @@ private function exit(): void */ public function printPage(string $title, array $content): void { - $context = $this->global_screen->tool()->context(); - if (!$this->isActive()) { - $context->claim()->lti(); - } - $context->current()->addAdditionalData(self::GS_EXIT_MODE, true); - $context->current()->addAdditionalData(self::GS_PAGE_TITLE, $title); + $this->screen_context->addAdditionalData(self::GS_EXIT_MODE, true); + $this->screen_context->addAdditionalData(self::GS_PAGE_TITLE, $title); - $tpl = $this->ui->mainTemplate(); - $tpl->setTitle($title, true); - $tpl->setContent($this->ui_renderer->render($content)); - $tpl->printToStdout(); + $this->tpl->setTitle($title, true); + $this->tpl->setContent($this->ui_renderer->render($content)); + $this->tpl->printToStdout(); } /** @@ -234,7 +209,7 @@ private function logout(): void return; } - $this->auth_session->setExpired(true); + $this->auth_session->expire(); session_destroy(); ilLTISessionCookie::remove(); } diff --git a/components/ILIAS/LTI/classes/Tool/class.ilLTIViewLayoutProvider.php b/components/ILIAS/LTI/classes/Tool/class.ilLTIViewLayoutProvider.php index 5bcc2ad72282..11c6e011e596 100644 --- a/components/ILIAS/LTI/classes/Tool/class.ilLTIViewLayoutProvider.php +++ b/components/ILIAS/LTI/classes/Tool/class.ilLTIViewLayoutProvider.php @@ -19,6 +19,7 @@ declare(strict_types=1); use ILIAS\GlobalScreen\Scope\Layout\Factory\BreadCrumbsModification; +use ILIAS\GlobalScreen\Scope\Layout\Factory\ContentModification; use ILIAS\GlobalScreen\Scope\Layout\Factory\FooterModification; use ILIAS\GlobalScreen\Scope\Layout\Factory\MainBarModification; use ILIAS\GlobalScreen\Scope\Layout\Factory\MetaBarModification; @@ -36,19 +37,51 @@ * meta bar, the title of the launch, the breadcrumbs from the launched object on and no footer. After the * exit, and on the other pages of LTI without a login (ilLTIViewGUI::printPage()), neither bar is left. * + * The provider looks at every page and changes only those of the LTI view, so that no initialisation has to + * switch the page to the view beforehand. + * * @author Saúl Díaz */ class ilLTIViewLayoutProvider extends AbstractModificationProvider { private const int PRIORITY = 63; + private const string FRAME_LINKS_CODE = 'if (window.self !== window.top) { const update = (root) => root.querySelectorAll(\'a[target="_top"], form[target="_top"]\').forEach((e) => e.setAttribute("target", "_self")); update(document); new MutationObserver(() => update(document)).observe(document.body, {childList: true, subtree: true}); }'; + public function isInterestedInContexts(): ContextCollection { - return $this->context_collection->lti(); + return $this->context_collection->main(); + } + + /** + * The content stays as it is; the page of a user who came from a platform gets the styles of the view + * and of the platform. + */ + public function getContentModification(CalledContexts $screen_context_stack): ?ContentModification + { + if (!$this->getView()->isActive()) { + return null; + } + + $this->dic->language()->loadLanguageModule('lti'); + + $meta = $this->globalScreen()->layout()->meta(); + $meta->addCss('./assets/css/lti_view.css'); + $css = $this->getView()->getPostData()['launch_presentation_css_url'] ?? ''; + if ($css !== '') { + $meta->addCss($css); + } + // inside the frame of the platform, links meant for the whole window stay in the frame + $meta->addOnloadCode(self::FRAME_LINKS_CODE); + + return null; } public function getMainBarModification(CalledContexts $screen_context_stack): ?MainBarModification { + if (!$this->isViewPage($screen_context_stack)) { + return null; + } $exit_mode = $this->isExitMode($screen_context_stack); return $this->globalScreen()->layout()->factory()->mainbar()->withModification( @@ -71,6 +104,9 @@ function (?MainBar $mainbar) use ($exit_mode): ?MainBar { public function getMetaBarModification(CalledContexts $screen_context_stack): ?MetaBarModification { + if (!$this->isViewPage($screen_context_stack)) { + return null; + } $exit_mode = $this->isExitMode($screen_context_stack); return $this->globalScreen()->layout()->factory()->metabar()->withModification( @@ -94,6 +130,9 @@ function (?MetaBar $metabar) use ($exit_mode): ?MetaBar { public function getTitleModification(CalledContexts $screen_context_stack): ?TitleModification { + if (!$this->isViewPage($screen_context_stack)) { + return null; + } $data = $screen_context_stack->current()->getAdditionalData(); $page_title = $data->exists(ilLTIViewGUI::GS_PAGE_TITLE) ? (string) $data->get(ilLTIViewGUI::GS_PAGE_TITLE) : null; @@ -107,6 +146,9 @@ public function getTitleModification(CalledContexts $screen_context_stack): ?Tit */ public function getBreadCrumbsModification(CalledContexts $screen_context_stack): ?BreadCrumbsModification { + if (!$this->isViewPage($screen_context_stack)) { + return null; + } $context_id = $this->getView()->getContextId(); return $this->globalScreen()->layout()->factory()->breadcrumbs()->withModification( @@ -127,18 +169,35 @@ function (?Breadcrumbs $breadcrumbs) use ($context_id): ?Breadcrumbs { public function getFooterModification(CalledContexts $screen_context_stack): ?FooterModification { + if (!$this->isViewPage($screen_context_stack)) { + return null; + } + return $this->globalScreen()->layout()->factory()->footer()->withModification( static fn(?Footer $footer): ?Footer => null )->withPriority(self::PRIORITY); } + /** + * The pages of a user who came from a platform, and the pages of LTI without a login. + */ + private function isViewPage(CalledContexts $screen_context_stack): bool + { + return $this->getView()->isActive() || $this->isExitMode($screen_context_stack); + } + private function isExitMode(CalledContexts $screen_context_stack): bool { return $screen_context_stack->current()->getAdditionalData()->is(ilLTIViewGUI::GS_EXIT_MODE, true); } - private function getView(): ILIAS\LTI\View + private function getView(): ilLTIViewGUI { - return $this->dic['lti']; + $view = $this->dic['lti']; + if (!$view instanceof ilLTIViewGUI) { + throw new LogicException('The LTI view is not provided by the LTI component.'); + } + + return $view; } } diff --git a/components/ILIAS/LTI/resources/lti.php b/components/ILIAS/LTI/resources/lti.php index 00950766d372..d4f5bdb5d635 100644 --- a/components/ILIAS/LTI/resources/lti.php +++ b/components/ILIAS/LTI/resources/lti.php @@ -20,7 +20,7 @@ /** * Endpoint a platform launches ILIAS objects at, with any LTI version: the launch logs the user in through - * ilStartUpGUI::doLTIAuthentication() and ilAuthProviderLTI. Its URL must not change: platforms keep it. + * ilLTILoginGUI and ilAuthProviderLTI. Its URL must not change: platforms keep it. */ // ilCtrl takes the command from the request, and a platform cannot send it: it is set before ILIAS starts. @@ -45,4 +45,4 @@ global $DIC; $DIC->ctrl()->setTargetScript('ilias.php'); -$DIC->ctrl()->callBaseClass(ilStartUpGUI::class); +$DIC->ctrl()->callBaseClass(ilLTILoginGUI::class); diff --git a/components/ILIAS/LTI/src/View.php b/components/ILIAS/LTI/src/View.php index 3eb9d3ae1215..dee4cffc0bfa 100644 --- a/components/ILIAS/LTI/src/View.php +++ b/components/ILIAS/LTI/src/View.php @@ -21,41 +21,15 @@ namespace ILIAS\LTI; /** - * The LTI view of a user who came from a platform, as the LTI component provides it. The legacy - * environment knows it as $DIC['lti']. + * Whether the current request shows the LTI view of a user who came from a platform. The legacy environment + * knows it as $DIC['lti']; everything else of the view is internal to the LTI component. * * @author Saúl Díaz */ interface View { - /** - * Switches the page to the LTI view for a user who came from a platform. - */ - public function init(): void; - /** * Whether the current user came from a platform. */ public function isActive(): bool; - - public function executeCommand(): void; - - /** - * The address the user of an accepted launch goes on to: the object the platform launched, or an - * empty string when the session holds no launch. - */ - public function getLaunchTarget(): string; - - public function getContextId(): int; - - public function getTitle(): string; - - public function getExitLink(): string; - - /** - * A page of LTI without any navigation of ILIAS. - * - * @param array $content the components of the page - */ - public function printPage(string $title, array $content): void; } diff --git a/components/ILIAS/LTI/src/View/AuthSession.php b/components/ILIAS/LTI/src/View/AuthSession.php new file mode 100644 index 000000000000..d95619d4a870 --- /dev/null +++ b/components/ILIAS/LTI/src/View/AuthSession.php @@ -0,0 +1,31 @@ + + */ +interface AuthSession +{ + public function expire(): void; +} diff --git a/components/ILIAS/LTI/src/View/AuthSessionLegacyProxy.php b/components/ILIAS/LTI/src/View/AuthSessionLegacyProxy.php new file mode 100644 index 000000000000..715067219084 --- /dev/null +++ b/components/ILIAS/LTI/src/View/AuthSessionLegacyProxy.php @@ -0,0 +1,36 @@ + + */ +final class AuthSessionLegacyProxy implements AuthSession +{ + public function expire(): void + { + global $DIC; + + $DIC['ilAuthSession']->setExpired(true); + } +} diff --git a/components/ILIAS/LTI/src/View/CurrentUser.php b/components/ILIAS/LTI/src/View/CurrentUser.php new file mode 100644 index 000000000000..ab2f38c9e948 --- /dev/null +++ b/components/ILIAS/LTI/src/View/CurrentUser.php @@ -0,0 +1,34 @@ + + */ +interface CurrentUser +{ + /** + * The auth mode of the user, or an empty string while there is no user. + */ + public function getAuthMode(): string; +} diff --git a/components/ILIAS/LTI/src/View/CurrentUserLegacyProxy.php b/components/ILIAS/LTI/src/View/CurrentUserLegacyProxy.php new file mode 100644 index 000000000000..c88c19773e8e --- /dev/null +++ b/components/ILIAS/LTI/src/View/CurrentUserLegacyProxy.php @@ -0,0 +1,42 @@ + + */ +final class CurrentUserLegacyProxy implements CurrentUser +{ + public function getAuthMode(): string + { + global $DIC; + + // without a user, as in the cron job or the SOAP server, and before ilInitialisation has set it up + if (!\ilContext::hasUser() || !$DIC instanceof \ILIAS\DI\Container || !$DIC->offsetExists('ilUser')) { + return ''; + } + + return (string) $DIC->user()->getAuthMode(); + } +} diff --git a/components/ILIAS/LTI/src/View/Navigation.php b/components/ILIAS/LTI/src/View/Navigation.php new file mode 100644 index 000000000000..a8074a3f00f8 --- /dev/null +++ b/components/ILIAS/LTI/src/View/Navigation.php @@ -0,0 +1,39 @@ + + */ +interface Navigation +{ + public function getCommand(): string; + + /** + * @param string[] $class_path + * @param array $parameters for the last class of the path + */ + public function getLinkTarget(array $class_path, string $command, array $parameters = []): string; + + public function redirectToURL(string $url): void; +} diff --git a/components/ILIAS/LTI/src/View/NavigationLegacyProxy.php b/components/ILIAS/LTI/src/View/NavigationLegacyProxy.php new file mode 100644 index 000000000000..7eed01b4e320 --- /dev/null +++ b/components/ILIAS/LTI/src/View/NavigationLegacyProxy.php @@ -0,0 +1,56 @@ + + */ +final class NavigationLegacyProxy implements Navigation +{ + public function getCommand(): string + { + global $DIC; + + return (string) $DIC->ctrl()->getCmd(); + } + + public function getLinkTarget(array $class_path, string $command, array $parameters = []): string + { + global $DIC; + + $ctrl = $DIC->ctrl(); + $class = end($class_path); + foreach ($parameters as $name => $value) { + $ctrl->setParameterByClass($class, $name, $value); + } + + return $ctrl->getLinkTargetByClass($class_path, $command); + } + + public function redirectToURL(string $url): void + { + global $DIC; + + $DIC->ctrl()->redirectToURL($url); + } +} diff --git a/components/ILIAS/LTI/src/View/ScreenContext.php b/components/ILIAS/LTI/src/View/ScreenContext.php new file mode 100644 index 000000000000..a529d9a22e0d --- /dev/null +++ b/components/ILIAS/LTI/src/View/ScreenContext.php @@ -0,0 +1,31 @@ + + */ +interface ScreenContext +{ + public function addAdditionalData(string $key, mixed $value): void; +} diff --git a/components/ILIAS/LTI/src/View/ScreenContextLegacyProxy.php b/components/ILIAS/LTI/src/View/ScreenContextLegacyProxy.php new file mode 100644 index 000000000000..9c6d9490501b --- /dev/null +++ b/components/ILIAS/LTI/src/View/ScreenContextLegacyProxy.php @@ -0,0 +1,36 @@ + + */ +final class ScreenContextLegacyProxy implements ScreenContext +{ + public function addAdditionalData(string $key, mixed $value): void + { + global $DIC; + + $DIC->globalScreen()->tool()->context()->current()->addAdditionalData($key, $value); + } +} diff --git a/components/ILIAS/LTI/tests/ilLTIViewGUITest.php b/components/ILIAS/LTI/tests/ilLTIViewGUITest.php new file mode 100644 index 000000000000..c1bc61409908 --- /dev/null +++ b/components/ILIAS/LTI/tests/ilLTIViewGUITest.php @@ -0,0 +1,114 @@ + + */ +final class ilLTIViewGUITest extends TestCase +{ + /** + * @return array + */ + public static function authModes(): array + { + return [ + 'user of a platform' => ['lti_3', true], + 'local user' => ['local', false], + 'no user' => ['', false], + ]; + } + + #[DataProvider('authModes')] + public function testIsActiveForUsersOfAPlatformOnly(string $auth_mode, bool $active): void + { + $user = $this->createStub(CurrentUser::class); + $user->method('getAuthMode')->willReturn($auth_mode); + + $this->assertSame($active, $this->getView($user)->isActive()); + } + + public function testCommandsLoadTheLanguageOfTheView(): void + { + $lng = $this->createMock(ILIAS\Language\Language::class); + $lng->expects($this->once())->method('loadLanguageModule')->with('lti'); + + $navigation = $this->createStub(Navigation::class); + $navigation->method('getCommand')->willReturn(''); + + $this->getView($this->createStub(CurrentUser::class), lng: $lng, navigation: $navigation)->executeCommand(); + } + + public function testExitPageIsShownWithoutNavigation(): void + { + $screen_context = $this->createMock(ScreenContext::class); + $screen_context->expects($this->exactly(2)) + ->method('addAdditionalData') + ->willReturnCallback(function (string $key, mixed $value): void { + match ($key) { + ilLTIViewGUI::GS_EXIT_MODE => $this->assertTrue($value), + ilLTIViewGUI::GS_PAGE_TITLE => $this->assertSame('Title', $value), + }; + }); + + $tpl = $this->createMock(ILIAS\UICore\GlobalTemplate::class); + $tpl->expects($this->once())->method('setTitle')->with('Title', true); + $tpl->expects($this->once())->method('setContent')->with('content'); + $tpl->expects($this->once())->method('printToStdout'); + + $renderer = $this->createStub(ILIAS\UI\Renderer::class); + $renderer->method('render')->willReturn('content'); + + $this->getView( + $this->createStub(CurrentUser::class), + $screen_context, + $tpl, + $renderer + )->printPage('Title', []); + } + + private function getView( + CurrentUser $user, + ?ScreenContext $screen_context = null, + ?ILIAS\UICore\GlobalTemplate $tpl = null, + ?ILIAS\UI\Renderer $renderer = null, + ?ILIAS\Language\Language $lng = null, + ?Navigation $navigation = null, + ): ilLTIViewGUI { + return new ilLTIViewGUI( + $this->createStub(ILIAS\HTTP\GlobalHttpState::class), + $this->createStub(ILIAS\Refinery\Factory::class), + $this->createStub(ILIAS\UI\Factory::class), + $renderer ?? $this->createStub(ILIAS\UI\Renderer::class), + $tpl ?? $this->createStub(ILIAS\UICore\GlobalTemplate::class), + $lng ?? $this->createStub(ILIAS\Language\Language::class), + $user, + $navigation ?? $this->createStub(Navigation::class), + $screen_context ?? $this->createStub(ScreenContext::class), + $this->createStub(AuthSession::class), + ); + } +}