From 25dab7336707926c685207e20af02b343b1b0ba7 Mon Sep 17 00:00:00 2001 From: Charles Severance Date: Tue, 11 Oct 2022 11:22:05 -0400 Subject: [PATCH] SAK-47852 Plus (LTI Advantage Provider) initial addition to Sakai (#10877) https://sakaiproject.atlassian.net/browse/SAK-47852 --- assignment/impl/pom.xml | 1 - assignment/tool/pom.xml | 2 - basiclti/basiclti-blis/pom.xml | 1 - .../org/sakaiproject/lti13/LTI13Servlet.java | 65 +- basiclti/basiclti-common/pom.xml | 9 +- .../basiclti/util/SakaiBLTIUtil.java | 28 +- .../basiclti/util/SakaiContentItemUtil.java | 24 +- .../org/sakaiproject/lti13/LineItemUtil.java | 6 +- .../lti13/util/SakaiExtension.java | 9 +- .../resources/docs/LTI_CertPlan.xls | Bin 33792 -> 0 bytes basiclti/basiclti-impl/pom.xml | 1 - .../src/bundle/ltiservice.properties | 4 +- .../impl/BasicLTISecurityServiceImpl.java | 35 +- .../lti/impl/LTIRoleMapperImpl.java | 213 +-- .../lti/impl/UserFinderOrCreatorImpl.java | 219 ++- .../src/bundle/basiclti.properties | 1 + basiclti/basiclti-tool/pom.xml | 5 +- .../src/webapp/vm/lti_tool_insert.vm | 1 + basiclti/docs/CERTIFICATION.md | 32 + basiclti/docs/CERTIFICATION_22.pdf | Bin 0 -> 756995 bytes basiclti/docs/CUSTOM.md | 41 + basiclti/docs/README.md | 1 + basiclti/pom.xml | 8 - basiclti/tsugi-util/pom.xml | 26 +- .../src/java/org/tsugi/HACK/HackMoodle.java | 72 + .../src/java/org/tsugi/HACK/README.md | 10 + .../java/org/tsugi/ags2/objects/LineItem.java | 17 +- .../java/org/tsugi/ags2/objects/Result.java | 3 - .../java/org/tsugi/ags2/objects/Score.java | 7 +- .../tsugi/ags2/objects/SubmissionReview.java | 3 - .../java/org/tsugi/basiclti/BasicLTIUtil.java | 102 +- .../objects/ContentItemResponse.java | 3 - .../org/tsugi/contentitem/objects/Icon.java | 3 - .../contentitem/objects/LtiLinkItem.java | 3 - .../contentitem/objects/PlacementAdvice.java | 3 - .../tsugi/deeplink/objects/ContentItem.java | 130 ++ .../deeplink/objects/DeepLinkResponse.java | 157 ++ .../deeplink/objects/LtiResourceLink.java | 55 + .../tsugi/deeplink/objects/MiniLineItem.java | 32 + .../java/org/tsugi/http/HttpClientUtil.java | 215 +++ .../src/java/org/tsugi/http/HttpUtil.java | 40 +- .../java/org/tsugi/jackson/JacksonUtil.java | 11 + .../org/tsugi/lti13/DeepLinkResponse.java | 1 - .../org/tsugi/lti13/LTI13AccessTokenUtil.java | 222 +++ .../org/tsugi/lti13/LTI13ConstantsUtil.java | 18 + .../java/org/tsugi/lti13/LTI13KeySetUtil.java | 18 +- .../src/java/org/tsugi/lti13/LTI13Util.java | 13 + .../java/org/tsugi/lti13/objects/BaseJWT.java | 33 +- .../org/tsugi/lti13/objects/BasicOutcome.java | 6 +- .../java/org/tsugi/lti13/objects/Context.java | 5 +- .../org/tsugi/lti13/objects/DeepLink.java | 5 +- .../org/tsugi/lti13/objects/Endpoint.java | 22 +- .../java/org/tsugi/lti13/objects/ForUser.java | 5 +- .../tsugi/lti13/objects/LTI11Transition.java | 5 +- ...formMessage.java => LTILaunchMessage.java} | 13 +- .../objects/LTIPlatformConfiguration.java | 9 +- .../lti13/objects/LTIToolConfiguration.java | 81 +- .../org/tsugi/lti13/objects/LaunchJWT.java | 53 +- .../org/tsugi/lti13/objects/LaunchLIS.java | 7 +- .../lti13/objects/LaunchPresentation.java | 5 +- .../tsugi/lti13/objects/NamesAndRoles.java | 5 +- ...ion.java => OpenIDClientRegistration.java} | 56 +- ....java => OpenIDProviderConfiguration.java} | 16 +- .../org/tsugi/lti13/objects/ResourceLink.java | 5 +- .../org/tsugi/lti13/objects/ToolPlatform.java | 5 +- .../org/tsugi/nrps/objects/Container.java | 73 + .../java/org/tsugi/nrps/objects/Member.java | 87 + .../org/tsugi/nrps/objects/MemberMessage.java | 44 + .../org/tsugi/oauth2/objects/AccessToken.java | 24 +- .../tsugi/oauth2/objects/ClientAssertion.java | 48 + .../objects/RegistrationRequest.java | 83 + .../objects/RegistrationResponse.java | 72 + .../org/tsugi/shared/objects/Contact.java | 3 - .../org/tsugi/shared/objects/DateRange.java | 27 + .../org/tsugi/shared/objects/SizedUrl.java | 31 + .../org/tsugi/shared/objects/TsugiBase.java | 4 - .../src/java/org/tsugi/time/InstantUtil.java | 67 + .../test/org/tsugi/HACK/MoodleHackTest.java | 29 + .../deeplink/DeepLinkResponseObjectTest.java | 96 + .../src/test/org/tsugi/http/HttpUtilTest.java | 23 + .../tsugi/lti13/LTI13AccessTokenUtilTest.java | 114 ++ .../test/org/tsugi/lti13/LTI13ObjectTest.java | 57 +- .../test/org/tsugi/lti13/LTI13UtilTest.java | 12 + .../src/test/org/tsugi/nrps/NRPSTest.java | 78 + .../org/tsugi/oauth2/OAUTH2ObjectTest.java | 48 +- .../test/org/tsugi/time/InstantUtilTest.java | 65 + .../deeplink/deep_link_settings.json | 12 + .../deeplink/sample_ltiresourcelink.json | 39 + .../resources/deeplink/sample_response.json | 106 ++ .../test/resources/nrps/sample_container.json | 39 + .../test/resources/nrps/sample_member.json | 29 + .../resources/nrps/sample_member_message.json | 11 + .../resources/oauth2/sample_access_token.json | 6 + .../config/bundle/default.sakai.properties | 123 ++ .../webapp/WEB-INF/templates/bootstrap.html | 5 +- deploy/pom.xml | 5 + .../sakaiproject/grading/api/Assignment.java | 1 + .../api/model/GradebookAssignment.java | 3 + gradebookng/impl/pom.xml | 16 + .../grading/impl/GradingServiceImpl.java | 217 ++- .../src/main/webapp/WEB-INF/components.xml | 3 +- .../impl/test/GradingServiceTests.java | 2 + .../impl/test/GradingTestConfiguration.java | 6 + .../src/main/sql/hsqldb/sakai_site.sql | 9 + .../src/main/sql/mysql/sakai_site.sql | 6 + .../src/main/sql/oracle/sakai_site.sql | 9 + .../images/logo-jewel-square.png | Bin 0 -> 29997 bytes .../src/morpheus-master/sass/base/_icons.scss | 1 + library/src/webapp/js/headscripts.js | 23 +- library/src/webapp/js/waterfall-light.js | 207 +++ master/pom.xml | 60 +- plus/README.md | 85 + plus/SQL.md | 105 ++ plus/TODO.md | 40 + plus/api/pom.xml | 54 + .../org/sakaiproject/plus/api/Launch.java | 38 + .../sakaiproject/plus/api/PlusService.java | 177 ++ .../sakaiproject/plus/api/model/BaseLTI.java | 105 ++ .../sakaiproject/plus/api/model/Context.java | 108 ++ .../plus/api/model/ContextLog.java | 105 ++ .../sakaiproject/plus/api/model/LineItem.java | 98 + .../org/sakaiproject/plus/api/model/Link.java | 76 + .../plus/api/model/Membership.java | 68 + .../sakaiproject/plus/api/model/Score.java | 128 ++ .../sakaiproject/plus/api/model/Subject.java | 77 + .../sakaiproject/plus/api/model/Tenant.java | 166 ++ .../api/repository/ContextLogRepository.java | 31 + .../api/repository/ContextRepository.java | 33 + .../api/repository/LineItemRepository.java | 24 + .../plus/api/repository/LinkRepository.java | 27 + .../api/repository/MembershipRepository.java | 30 + .../plus/api/repository/ScoreRepository.java | 29 + .../api/repository/SubjectRepository.java | 31 + .../plus/api/repository/TenantRepository.java | 27 + plus/docs/COMPLETED.md | 52 + plus/docs/INSTALL-BLACKBOARD.md | 159 ++ plus/docs/INSTALL-BRIGHTSPACE.md | 50 + plus/docs/INSTALL-CANVAS.md | 50 + plus/docs/INSTALL-MOODLE.md | 21 + plus/docs/INSTALL-SAKAI.md | 62 + plus/docs/NOTES.md | 16 + plus/docs/TESTING.md | 136 ++ plus/impl/pom.xml | 155 ++ .../plus/impl/PlusEventObserver.java | 69 + .../plus/impl/PlusServiceImpl.java | 1426 ++++++++++++++ .../impl/jobs/SiteMembershipsSyncJob.java | 39 + .../repository/ContextLogRepositoryImpl.java | 90 + .../repository/ContextRepositoryImpl.java | 89 + .../repository/LineItemRepositoryImpl.java | 25 + .../impl/repository/LinkRepositoryImpl.java | 52 + .../repository/MembershipRepositoryImpl.java | 81 + .../impl/repository/ScoreRepositoryImpl.java | 71 + .../repository/SubjectRepositoryImpl.java | 132 ++ .../impl/repository/TenantRepositoryImpl.java | 63 + .../src/main/webapp/WEB-INF/components.xml | 86 + .../plus/impl/PlusModelTests.java | 521 ++++++ .../plus/impl/PlusServiceImplTests.java | 55 + .../plus/impl/PlusTestConfiguration.java | 205 ++ .../src/test/resources/hibernate.properties | 19 + plus/pom.xml | 63 + plus/provider/maven.xml | 13 + plus/provider/pom.xml | 146 ++ plus/provider/src/bundle/plus.properties | 176 ++ .../sakaiproject/plus/ProviderServlet.java | 1653 +++++++++++++++++ .../webapp/WEB-INF/applicationContext.xml | 12 + .../src/main/webapp/WEB-INF/descriptor.json | 103 + plus/provider/src/main/webapp/WEB-INF/web.xml | 35 + plus/provider/src/main/webapp/deeplink.jsp | 89 + plus/provider/src/main/webapp/descriptor.txt | 21 + plus/provider/src/main/webapp/whatisthis.htm | 53 + .../plus/provider/ProviderTests.java | 47 + plus/tool/pom.xml | 82 + .../plus/tool/MainController.java | 324 ++++ .../plus/tool/PlusConfiguration.java | 54 + .../plus/tool/WebMvcConfiguration.java | 90 + .../exception/MissingSessionException.java | 39 + .../src/main/resources/Messages.properties | 66 + .../webapp/WEB-INF/templates/context.html | 62 + .../webapp/WEB-INF/templates/contexts.html | 22 + .../main/webapp/WEB-INF/templates/delete.html | 26 + .../main/webapp/WEB-INF/templates/form.html | 117 ++ .../main/webapp/WEB-INF/templates/index.html | 27 + .../webapp/WEB-INF/templates/notallow.html | 14 + .../webapp/WEB-INF/templates/notfound.html | 14 + .../main/webapp/WEB-INF/templates/tenant.html | 53 + .../main/webapp/WEB-INF/tools/sakai.plus.xml | 11 + plus/tool/src/main/webapp/WEB-INF/web.xml | 7 + pom.xml | 2 + portal/portal-impl/impl/pom.xml | 1 - .../impl/src/bundle/sitenav.properties | 1 + .../portal/charon/SkinnableCharonPortal.java | 7 +- .../portal/charon/handlers/PlusHandler.java | 120 ++ .../portal/charon/handlers/SiteHandler.java | 13 +- .../src/webapp/vm/morpheus/includePageBody.vm | 9 +- .../impl/src/webapp/vm/morpheus/plus.vm | 360 ++++ portal/portal-service-impl/impl/pom.xml | 1 - site-manage/site-manage-impl/impl/pom.xml | 1 - .../tool/src/main/frontend/js/sakai-i18n.js | 7 +- 198 files changed, 12923 insertions(+), 633 deletions(-) delete mode 100644 basiclti/basiclti-docs/resources/docs/LTI_CertPlan.xls create mode 100644 basiclti/docs/CERTIFICATION.md create mode 100644 basiclti/docs/CERTIFICATION_22.pdf create mode 100644 basiclti/docs/CUSTOM.md create mode 100644 basiclti/tsugi-util/src/java/org/tsugi/HACK/HackMoodle.java create mode 100644 basiclti/tsugi-util/src/java/org/tsugi/HACK/README.md create mode 100644 basiclti/tsugi-util/src/java/org/tsugi/deeplink/objects/ContentItem.java create mode 100644 basiclti/tsugi-util/src/java/org/tsugi/deeplink/objects/DeepLinkResponse.java create mode 100644 basiclti/tsugi-util/src/java/org/tsugi/deeplink/objects/LtiResourceLink.java create mode 100644 basiclti/tsugi-util/src/java/org/tsugi/deeplink/objects/MiniLineItem.java create mode 100644 basiclti/tsugi-util/src/java/org/tsugi/http/HttpClientUtil.java create mode 100644 basiclti/tsugi-util/src/java/org/tsugi/lti13/LTI13AccessTokenUtil.java rename basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/{LTIPlatformMessage.java => LTILaunchMessage.java} (87%) rename basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/{ToolConfiguration.java => OpenIDClientRegistration.java} (75%) rename basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/{PlatformConfiguration.java => OpenIDProviderConfiguration.java} (93%) create mode 100644 basiclti/tsugi-util/src/java/org/tsugi/nrps/objects/Container.java create mode 100644 basiclti/tsugi-util/src/java/org/tsugi/nrps/objects/Member.java create mode 100644 basiclti/tsugi-util/src/java/org/tsugi/nrps/objects/MemberMessage.java create mode 100644 basiclti/tsugi-util/src/java/org/tsugi/oauth2/objects/ClientAssertion.java create mode 100644 basiclti/tsugi-util/src/java/org/tsugi/provision/objects/RegistrationRequest.java create mode 100644 basiclti/tsugi-util/src/java/org/tsugi/provision/objects/RegistrationResponse.java create mode 100644 basiclti/tsugi-util/src/java/org/tsugi/shared/objects/DateRange.java create mode 100644 basiclti/tsugi-util/src/java/org/tsugi/shared/objects/SizedUrl.java create mode 100644 basiclti/tsugi-util/src/java/org/tsugi/time/InstantUtil.java create mode 100644 basiclti/tsugi-util/src/test/org/tsugi/HACK/MoodleHackTest.java create mode 100644 basiclti/tsugi-util/src/test/org/tsugi/deeplink/DeepLinkResponseObjectTest.java create mode 100644 basiclti/tsugi-util/src/test/org/tsugi/http/HttpUtilTest.java create mode 100644 basiclti/tsugi-util/src/test/org/tsugi/lti13/LTI13AccessTokenUtilTest.java create mode 100644 basiclti/tsugi-util/src/test/org/tsugi/nrps/NRPSTest.java create mode 100644 basiclti/tsugi-util/src/test/org/tsugi/time/InstantUtilTest.java create mode 100644 basiclti/tsugi-util/src/test/resources/deeplink/deep_link_settings.json create mode 100644 basiclti/tsugi-util/src/test/resources/deeplink/sample_ltiresourcelink.json create mode 100644 basiclti/tsugi-util/src/test/resources/deeplink/sample_response.json create mode 100644 basiclti/tsugi-util/src/test/resources/nrps/sample_container.json create mode 100644 basiclti/tsugi-util/src/test/resources/nrps/sample_member.json create mode 100644 basiclti/tsugi-util/src/test/resources/nrps/sample_member_message.json create mode 100644 basiclti/tsugi-util/src/test/resources/oauth2/sample_access_token.json create mode 100644 library/src/morpheus-master/images/logo-jewel-square.png create mode 100755 library/src/webapp/js/waterfall-light.js create mode 100644 plus/README.md create mode 100644 plus/SQL.md create mode 100644 plus/TODO.md create mode 100644 plus/api/pom.xml create mode 100644 plus/api/src/main/java/org/sakaiproject/plus/api/Launch.java create mode 100644 plus/api/src/main/java/org/sakaiproject/plus/api/PlusService.java create mode 100644 plus/api/src/main/java/org/sakaiproject/plus/api/model/BaseLTI.java create mode 100644 plus/api/src/main/java/org/sakaiproject/plus/api/model/Context.java create mode 100644 plus/api/src/main/java/org/sakaiproject/plus/api/model/ContextLog.java create mode 100644 plus/api/src/main/java/org/sakaiproject/plus/api/model/LineItem.java create mode 100644 plus/api/src/main/java/org/sakaiproject/plus/api/model/Link.java create mode 100644 plus/api/src/main/java/org/sakaiproject/plus/api/model/Membership.java create mode 100644 plus/api/src/main/java/org/sakaiproject/plus/api/model/Score.java create mode 100644 plus/api/src/main/java/org/sakaiproject/plus/api/model/Subject.java create mode 100644 plus/api/src/main/java/org/sakaiproject/plus/api/model/Tenant.java create mode 100644 plus/api/src/main/java/org/sakaiproject/plus/api/repository/ContextLogRepository.java create mode 100644 plus/api/src/main/java/org/sakaiproject/plus/api/repository/ContextRepository.java create mode 100644 plus/api/src/main/java/org/sakaiproject/plus/api/repository/LineItemRepository.java create mode 100644 plus/api/src/main/java/org/sakaiproject/plus/api/repository/LinkRepository.java create mode 100644 plus/api/src/main/java/org/sakaiproject/plus/api/repository/MembershipRepository.java create mode 100644 plus/api/src/main/java/org/sakaiproject/plus/api/repository/ScoreRepository.java create mode 100644 plus/api/src/main/java/org/sakaiproject/plus/api/repository/SubjectRepository.java create mode 100644 plus/api/src/main/java/org/sakaiproject/plus/api/repository/TenantRepository.java create mode 100644 plus/docs/COMPLETED.md create mode 100644 plus/docs/INSTALL-BLACKBOARD.md create mode 100644 plus/docs/INSTALL-BRIGHTSPACE.md create mode 100644 plus/docs/INSTALL-CANVAS.md create mode 100644 plus/docs/INSTALL-MOODLE.md create mode 100644 plus/docs/INSTALL-SAKAI.md create mode 100644 plus/docs/NOTES.md create mode 100644 plus/docs/TESTING.md create mode 100644 plus/impl/pom.xml create mode 100644 plus/impl/src/main/java/org/sakaiproject/plus/impl/PlusEventObserver.java create mode 100644 plus/impl/src/main/java/org/sakaiproject/plus/impl/PlusServiceImpl.java create mode 100644 plus/impl/src/main/java/org/sakaiproject/plus/impl/jobs/SiteMembershipsSyncJob.java create mode 100644 plus/impl/src/main/java/org/sakaiproject/plus/impl/repository/ContextLogRepositoryImpl.java create mode 100644 plus/impl/src/main/java/org/sakaiproject/plus/impl/repository/ContextRepositoryImpl.java create mode 100644 plus/impl/src/main/java/org/sakaiproject/plus/impl/repository/LineItemRepositoryImpl.java create mode 100644 plus/impl/src/main/java/org/sakaiproject/plus/impl/repository/LinkRepositoryImpl.java create mode 100644 plus/impl/src/main/java/org/sakaiproject/plus/impl/repository/MembershipRepositoryImpl.java create mode 100644 plus/impl/src/main/java/org/sakaiproject/plus/impl/repository/ScoreRepositoryImpl.java create mode 100644 plus/impl/src/main/java/org/sakaiproject/plus/impl/repository/SubjectRepositoryImpl.java create mode 100644 plus/impl/src/main/java/org/sakaiproject/plus/impl/repository/TenantRepositoryImpl.java create mode 100644 plus/impl/src/main/webapp/WEB-INF/components.xml create mode 100644 plus/impl/src/test/org/sakaiproject/plus/impl/PlusModelTests.java create mode 100644 plus/impl/src/test/org/sakaiproject/plus/impl/PlusServiceImplTests.java create mode 100644 plus/impl/src/test/org/sakaiproject/plus/impl/PlusTestConfiguration.java create mode 100644 plus/impl/src/test/resources/hibernate.properties create mode 100644 plus/pom.xml create mode 100644 plus/provider/maven.xml create mode 100644 plus/provider/pom.xml create mode 100644 plus/provider/src/bundle/plus.properties create mode 100644 plus/provider/src/main/java/org/sakaiproject/plus/ProviderServlet.java create mode 100644 plus/provider/src/main/webapp/WEB-INF/applicationContext.xml create mode 100644 plus/provider/src/main/webapp/WEB-INF/descriptor.json create mode 100644 plus/provider/src/main/webapp/WEB-INF/web.xml create mode 100644 plus/provider/src/main/webapp/deeplink.jsp create mode 100644 plus/provider/src/main/webapp/descriptor.txt create mode 100644 plus/provider/src/main/webapp/whatisthis.htm create mode 100644 plus/provider/src/test/org/sakaiproject/plus/provider/ProviderTests.java create mode 100644 plus/tool/pom.xml create mode 100644 plus/tool/src/main/java/org/sakaiproject/plus/tool/MainController.java create mode 100644 plus/tool/src/main/java/org/sakaiproject/plus/tool/PlusConfiguration.java create mode 100644 plus/tool/src/main/java/org/sakaiproject/plus/tool/WebMvcConfiguration.java create mode 100644 plus/tool/src/main/java/org/sakaiproject/plus/tool/exception/MissingSessionException.java create mode 100644 plus/tool/src/main/resources/Messages.properties create mode 100644 plus/tool/src/main/webapp/WEB-INF/templates/context.html create mode 100644 plus/tool/src/main/webapp/WEB-INF/templates/contexts.html create mode 100644 plus/tool/src/main/webapp/WEB-INF/templates/delete.html create mode 100644 plus/tool/src/main/webapp/WEB-INF/templates/form.html create mode 100644 plus/tool/src/main/webapp/WEB-INF/templates/index.html create mode 100644 plus/tool/src/main/webapp/WEB-INF/templates/notallow.html create mode 100644 plus/tool/src/main/webapp/WEB-INF/templates/notfound.html create mode 100644 plus/tool/src/main/webapp/WEB-INF/templates/tenant.html create mode 100644 plus/tool/src/main/webapp/WEB-INF/tools/sakai.plus.xml create mode 100644 plus/tool/src/main/webapp/WEB-INF/web.xml create mode 100644 portal/portal-impl/impl/src/java/org/sakaiproject/portal/charon/handlers/PlusHandler.java create mode 100755 portal/portal-render-engine-impl/impl/src/webapp/vm/morpheus/plus.vm diff --git a/assignment/impl/pom.xml b/assignment/impl/pom.xml index 548750693dda..29a5310aab5f 100644 --- a/assignment/impl/pom.xml +++ b/assignment/impl/pom.xml @@ -219,7 +219,6 @@ org.sakaiproject.basiclti basiclti-util - ${sakai.version} org.sakaiproject.basiclti diff --git a/assignment/tool/pom.xml b/assignment/tool/pom.xml index 16dd72ef4e87..ebb739a517d5 100644 --- a/assignment/tool/pom.xml +++ b/assignment/tool/pom.xml @@ -94,7 +94,6 @@ org.sakaiproject.basiclti basiclti-api - ${project.version} org.sakaiproject.basiclti @@ -104,7 +103,6 @@ org.sakaiproject.basiclti basiclti-util - ${project.version} ant diff --git a/basiclti/basiclti-blis/pom.xml b/basiclti/basiclti-blis/pom.xml index c85084617157..d5469dbd3799 100644 --- a/basiclti/basiclti-blis/pom.xml +++ b/basiclti/basiclti-blis/pom.xml @@ -69,7 +69,6 @@ com.googlecode.json-simple json-simple - ${json.simple.version} commons-lang diff --git a/basiclti/basiclti-blis/src/java/org/sakaiproject/lti13/LTI13Servlet.java b/basiclti/basiclti-blis/src/java/org/sakaiproject/lti13/LTI13Servlet.java index 21404d377dbf..9cbe9d27627c 100644 --- a/basiclti/basiclti-blis/src/java/org/sakaiproject/lti13/LTI13Servlet.java +++ b/basiclti/basiclti-blis/src/java/org/sakaiproject/lti13/LTI13Servlet.java @@ -87,14 +87,15 @@ import org.tsugi.lti13.LTI13ConstantsUtil; import org.tsugi.oauth2.objects.AccessToken; +import org.tsugi.oauth2.objects.ClientAssertion; import org.tsugi.lti13.objects.Endpoint; import org.tsugi.lti13.objects.LaunchLIS; import org.tsugi.ags2.objects.Result; import org.tsugi.ags2.objects.Score; import org.tsugi.lti13.objects.LaunchJWT; -import org.tsugi.lti13.objects.PlatformConfiguration; +import org.tsugi.lti13.objects.OpenIDProviderConfiguration; import org.tsugi.lti13.objects.LTIPlatformConfiguration; -import org.tsugi.lti13.objects.LTIPlatformMessage; +import org.tsugi.lti13.objects.LTILaunchMessage; import org.sakaiproject.lti13.util.SakaiAccessToken; import org.sakaiproject.lti13.util.SakaiLineItem; @@ -323,7 +324,7 @@ protected void doPut(HttpServletRequest request, HttpServletResponse response) t String[] parts = uri.split("/"); - // /imsblis/lti13/lineitems/{signed-placement}/{lineitem-id} + // /imsblis/lti13/lineitems/{signed-placement} if (parts.length == 5 && "lineitem".equals(parts[3])) { log.error("Attempt to modify on-demand line item request={}", uri); LTI13Util.return400(response, "Attempt to modify an 'on-demand' line item"); @@ -360,6 +361,7 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) // Set score for auto-created line item // /imsblis/lti13/lineitem/{signed-placement} + // SAK-47261 - This pattern with no lineItem does not work post SAK-47621 if (parts.length == 6 && "lineitem".equals(parts[3]) && "scores".equals(parts[5])) { String signed_placement = parts[4]; String lineItem = null; @@ -670,7 +672,7 @@ protected void handleSakaiConfig(HttpServletRequest request, HttpServletResponse ["RS256", "ES256"], "claims_supported": ["sub", "iss", "name", "given_name", "family_name", "nickname", "picture", "email", "locale"], - "https://purl.imsglobal.org/spec/lti-platform-configuration ": { + "https://purl.imsglobal.org/spec/lti-platform-configuration": { "product_family_code": "ExampleLMS", "messages_supported": [ {"type": "LtiResourceLinkRequest"}, @@ -715,18 +717,18 @@ protected void handleWellKnown(HttpServletRequest request, HttpServletResponse r lpc.product_family_code = "sakailms.org"; lpc.version = sakaiVersion; - LTIPlatformMessage mp = new LTIPlatformMessage(); + LTILaunchMessage mp = new LTILaunchMessage(); mp.type = LaunchJWT.MESSAGE_TYPE_LAUNCH; lpc.messages_supported.add(mp); - mp = new LTIPlatformMessage(); + mp = new LTILaunchMessage(); mp.type = LaunchJWT.MESSAGE_TYPE_DEEP_LINK; lpc.messages_supported.add(mp); lpc.variables.add(LTICustomVars.USER_ID); lpc.variables.add(LTICustomVars.PERSON_EMAIL_PRIMARY); - PlatformConfiguration pc = new PlatformConfiguration(); + OpenIDProviderConfiguration pc = new OpenIDProviderConfiguration(); pc.issuer = issuerURL; pc.authorization_endpoint = authOIDC; pc.token_endpoint = tokenUrl; @@ -796,9 +798,9 @@ protected void handleTokenPost(String tool_id, HttpServletRequest request, HttpS return; } - String grant_type = request.getParameter(AccessToken.GRANT_TYPE); - String client_assertion = request.getParameter(AccessToken.CLIENT_ASSERTION); - String scope = request.getParameter(AccessToken.SCOPE); + String grant_type = request.getParameter(ClientAssertion.GRANT_TYPE); + String client_assertion = request.getParameter(ClientAssertion.CLIENT_ASSERTION); + String scope = request.getParameter(ClientAssertion.SCOPE); String missing = ""; if (grant_type == null) { missing += " " + "grant_type"; @@ -876,57 +878,57 @@ protected void handleTokenPost(String tool_id, HttpServletRequest request, HttpS HashSet returnScopeSet = new HashSet (); // Work through requested scopes - if (scope.contains(Endpoint.SCOPE_LINEITEM_READONLY)) { + if (scope.contains(LTI13ConstantsUtil.SCOPE_LINEITEM_READONLY)) { if (allowLineItems != 1) { - LTI13Util.return400(response, "invalid_scope", Endpoint.SCOPE_LINEITEM_READONLY); + LTI13Util.return400(response, "invalid_scope", LTI13ConstantsUtil.SCOPE_LINEITEM_READONLY); log.error("Scope lineitem not allowed {}", tool_id); return; } - returnScopeSet.add(Endpoint.SCOPE_LINEITEM_READONLY); + returnScopeSet.add(LTI13ConstantsUtil.SCOPE_LINEITEM_READONLY); sat.addScope(SakaiAccessToken.SCOPE_LINEITEMS_READONLY); } - if (scope.contains(Endpoint.SCOPE_LINEITEM)) { + if (scope.contains(LTI13ConstantsUtil.SCOPE_LINEITEM)) { if (allowLineItems != 1) { - LTI13Util.return400(response, "invalid_scope", Endpoint.SCOPE_LINEITEM); + LTI13Util.return400(response, "invalid_scope", LTI13ConstantsUtil.SCOPE_LINEITEM); log.error("Scope lineitem not allowed {}", tool_id); return; } - returnScopeSet.add(Endpoint.SCOPE_LINEITEM); + returnScopeSet.add(LTI13ConstantsUtil.SCOPE_LINEITEM); sat.addScope(SakaiAccessToken.SCOPE_LINEITEMS); sat.addScope(SakaiAccessToken.SCOPE_LINEITEMS_READONLY); } - if (scope.contains(Endpoint.SCOPE_SCORE)) { + if (scope.contains(LTI13ConstantsUtil.SCOPE_SCORE)) { if (allowOutcomes != 1 || allowLineItems != 1) { - LTI13Util.return400(response, "invalid_scope", Endpoint.SCOPE_SCORE); + LTI13Util.return400(response, "invalid_scope", LTI13ConstantsUtil.SCOPE_SCORE); log.error("Scope lineitem not allowed {}", tool_id); return; } - returnScopeSet.add(Endpoint.SCOPE_SCORE); + returnScopeSet.add(LTI13ConstantsUtil.SCOPE_SCORE); sat.addScope(SakaiAccessToken.SCOPE_BASICOUTCOME); } - if (scope.contains(Endpoint.SCOPE_RESULT_READONLY)) { + if (scope.contains(LTI13ConstantsUtil.SCOPE_RESULT_READONLY)) { if (allowOutcomes != 1 || allowLineItems != 1) { - LTI13Util.return400(response, "invalid_scope", Endpoint.SCOPE_RESULT_READONLY); + LTI13Util.return400(response, "invalid_scope", LTI13ConstantsUtil.SCOPE_RESULT_READONLY); log.error("Scope lineitem not allowed {}", tool_id); return; } - returnScopeSet.add(Endpoint.SCOPE_RESULT_READONLY); + returnScopeSet.add(LTI13ConstantsUtil.SCOPE_RESULT_READONLY); sat.addScope(SakaiAccessToken.SCOPE_BASICOUTCOME); } - if (scope.contains(LaunchLIS.SCOPE_NAMES_AND_ROLES)) { + if (scope.contains(LTI13ConstantsUtil.SCOPE_NAMES_AND_ROLES)) { if (allowRoster != 1) { - LTI13Util.return400(response, "invalid_scope", LaunchLIS.SCOPE_NAMES_AND_ROLES); + LTI13Util.return400(response, "invalid_scope", LTI13ConstantsUtil.SCOPE_NAMES_AND_ROLES); log.error("Scope lineitem not allowed {}", tool_id); return; } - returnScopeSet.add(LaunchLIS.SCOPE_NAMES_AND_ROLES); + returnScopeSet.add(LTI13ConstantsUtil.SCOPE_NAMES_AND_ROLES); sat.addScope(SakaiAccessToken.SCOPE_ROSTER); } @@ -1143,6 +1145,7 @@ protected void handleRegistrationEndpointPost(String tool_key_str, HttpServletRe log.debug("jsonString={}", jsonString); + // TODO: Make this be a RegistrationRequest Object js = JSONValue.parse(jsonString); if (js == null || !(js instanceof JSONObject)) { LTI13Util.return400(response, "Badly formatted JSON"); @@ -1193,6 +1196,7 @@ protected void handleRegistrationEndpointPost(String tool_key_str, HttpServletRe jso.put("client_id", client_id); + // TODO: Make this be a RegistrationResponse Object toolConfigurationObj = jso.get("https://purl.imsglobal.org/spec/lti-tool-configuration"); if ( toolConfigurationObj instanceof JSONObject ) { JSONObject toolConfiguration = (JSONObject) toolConfigurationObj; @@ -1937,23 +1941,14 @@ private void handleLineItemsUpdate(String signed_placement, String lineItem, Htt return; } - // TODO: Does PUT need to return the entire line item - I think the code below - // actually is wrong - we just need to do a GET to get the entire line - // item after the PUT. It seems wasteful to always do the GET after PUT - // when the tool can do it if it wants the newly updated item. So - // For now I am sending nothing back for a pUT request. - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PUT - - /* // Add the link to this lineitem item.id = getOurServerUrl() + LTI13_PATH + "lineitems/" + signed_placement + "/" + retval.getId(); log.debug("Lineitem item={}",item); - response.setContentType(LineItem.CONTENT_TYPE); + response.setContentType(SakaiLineItem.CONTENT_TYPE); PrintWriter out = response.getWriter(); out.print(JacksonUtil.prettyPrint(item)); - */ } /** diff --git a/basiclti/basiclti-common/pom.xml b/basiclti/basiclti-common/pom.xml index 82c16b259e31..b410bc8f1503 100644 --- a/basiclti/basiclti-common/pom.xml +++ b/basiclti/basiclti-common/pom.xml @@ -62,7 +62,14 @@ com.googlecode.json-simple json-simple - ${json.simple.version} + + + io.jsonwebtoken + jjwt-api + + + com.nimbusds + nimbus-jose-jwt org.springframework diff --git a/basiclti/basiclti-common/src/java/org/sakaiproject/basiclti/util/SakaiBLTIUtil.java b/basiclti/basiclti-common/src/java/org/sakaiproject/basiclti/util/SakaiBLTIUtil.java index 9d6c4dbf645a..6f5727755442 100644 --- a/basiclti/basiclti-common/src/java/org/sakaiproject/basiclti/util/SakaiBLTIUtil.java +++ b/basiclti/basiclti-common/src/java/org/sakaiproject/basiclti/util/SakaiBLTIUtil.java @@ -1903,7 +1903,7 @@ public static String[] postLaunchJWT(Properties toolProps, Properties ltiProps, ) { Endpoint endpoint = new Endpoint(); endpoint.scope = new ArrayList<>(); - endpoint.scope.add(Endpoint.SCOPE_LINEITEM); + endpoint.scope.add(LTI13ConstantsUtil.SCOPE_LINEITEM); if ( allowOutcomes != 0 && outcomesEnabled() && content != null) { SakaiLineItem defaultLineItem = LineItemUtil.getDefaultLineItem(site, content); @@ -2048,9 +2048,18 @@ public static String[] postLaunchJWT(Properties toolProps, Properties ltiProps, } } - Integer form_id = jws.hashCode(); + String launch_error = rb.getString("error.submit.timeout")+" "+launch_url; + String html = getJwsHTMLForm(launch_url, "id_token", jws, ljs, state, launch_error, dodebug); + + String[] retval = {html, launch_url}; + return retval; + } + + public static String getJwsHTMLForm(String launch_url, String form_field, String jwt, String jsonStr, String state, String launch_error, boolean dodebug) { + + Integer form_id = jwt.hashCode(); String html = "
\n" - + " \n"; + + " \n"; if ( state != null ) { html += " \n"; @@ -2063,26 +2072,24 @@ public static String[] postLaunchJWT(Properties toolProps, Properties ltiProps, if ( ! dodebug ) { - String launch_error = rb.getString("error.submit.timeout")+" "+launch_url; html += "\n"; } else { - html += "

\n--- Unencoded JWT:
" - + BasicLTIUtil.htmlspecialchars(ljs) - + "

\n

\n--- State:
" + html += "

\n--- Unencoded JWT:

\n"
+						+ BasicLTIUtil.htmlspecialchars(jsonStr)
+						+ "
\n

\n

\n--- State:
" + BasicLTIUtil.htmlspecialchars(state) + "

\n

\n--- Encoded JWT:
" - + BasicLTIUtil.htmlspecialchars(jws) + + BasicLTIUtil.htmlspecialchars(jwt) + "

\n"; html += "\n"; } - String[] retval = {html, launch_url}; - return retval; + return html; } public static String getSourceDID(User user, Placement placement, Properties config) { @@ -2449,7 +2456,6 @@ private static Object handleGradebook(String sourcedid, HttpServletRequest reque } retval = retMap; } else if (isDelete) { - g.setAssignmentScoreString(siteId, gradebookColumn.getId(), user_id, null, "External Outcome"); g.deleteAssignmentScoreComment(siteId, gradebookColumn.getId(), user_id); log.info("Delete Score site={} title={} user_id={}", siteId, title, user_id); retval = Boolean.TRUE; diff --git a/basiclti/basiclti-common/src/java/org/sakaiproject/basiclti/util/SakaiContentItemUtil.java b/basiclti/basiclti-common/src/java/org/sakaiproject/basiclti/util/SakaiContentItemUtil.java index 1104c6ef8bcc..b25a9796cf1e 100644 --- a/basiclti/basiclti-common/src/java/org/sakaiproject/basiclti/util/SakaiContentItemUtil.java +++ b/basiclti/basiclti-common/src/java/org/sakaiproject/basiclti/util/SakaiContentItemUtil.java @@ -42,11 +42,11 @@ public static LtiLinkItem getLtiLinkItem(String toolRegistration) Tool theTool = ToolManager.getTool(toolRegistration); if ( theTool == null ) return null; - Icon icon = new Icon("https://www.apereo.org/sites/all/themes/apereo/images/apereo-logo-white-bg.png"); - icon.setHeight(64); - icon.setWidth(64); + Icon icon = new Icon("https://www.apereo.org/sites/all/themes/apereo/images/apereo-logo-white-bg.png"); + icon.setHeight(64); + icon.setWidth(64); - PlacementAdvice placementAdvice = new PlacementAdvice(); + PlacementAdvice placementAdvice = new PlacementAdvice(); // If we are http, lets go in a new window String serverUrl = SakaiBLTIUtil.getOurServerUrl(); @@ -56,14 +56,14 @@ public static LtiLinkItem getLtiLinkItem(String toolRegistration) placementAdvice.setPresentationDocumentTarget(placementAdvice.WINDOW); } - LtiLinkItem item = new LtiLinkItem(toolRegistration, placementAdvice, icon); - item.setUrl(SakaiLTIProviderUtil.getProviderLaunchUrl(toolRegistration)); - item.setTitle(theTool.getTitle()); + LtiLinkItem item = new LtiLinkItem(toolRegistration, placementAdvice, icon); + item.setUrl(SakaiLTIProviderUtil.getProviderLaunchUrl(toolRegistration)); + item.setTitle(theTool.getTitle()); // Because of weirdness in the CI Implementations and unclear semantics // between text and title, we send title twice - // item.setText(theTool.getDescription()); - item.setText(theTool.getTitle()); + // item.setText(theTool.getDescription()); + item.setText(theTool.getTitle()); return item; } @@ -73,9 +73,11 @@ public static ContentItemResponse getContentItemResponse(String toolRegistration LtiLinkItem item = getLtiLinkItem(toolRegistration); if ( item == null ) return null; - ContentItemResponse resp = new ContentItemResponse(); - resp.addGraph(item); + ContentItemResponse resp = new ContentItemResponse(); + resp.addGraph(item); return resp; } + // vim: tabstop=4 noet + } diff --git a/basiclti/basiclti-common/src/java/org/sakaiproject/lti13/LineItemUtil.java b/basiclti/basiclti-common/src/java/org/sakaiproject/lti13/LineItemUtil.java index cdda0a9a5eb9..e60e944564e1 100644 --- a/basiclti/basiclti-common/src/java/org/sakaiproject/lti13/LineItemUtil.java +++ b/basiclti/basiclti-common/src/java/org/sakaiproject/lti13/LineItemUtil.java @@ -199,10 +199,14 @@ public static Assignment createLineItem(String context_id, Long tool_id, MapC!`}_U>Z}oW}z0b@&&%NiId(OG% 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 a!xL#<$Vw^LFTE-HH(7%JSMYyJ0{;yad*Yw~ diff --git a/basiclti/basiclti-impl/pom.xml b/basiclti/basiclti-impl/pom.xml index f9845cd0a9aa..b928221ca3ab 100644 --- a/basiclti/basiclti-impl/pom.xml +++ b/basiclti/basiclti-impl/pom.xml @@ -88,7 +88,6 @@ com.googlecode.json-simple json-simple - ${json.simple.version} com.fasterxml.jackson.core diff --git a/basiclti/basiclti-impl/src/bundle/ltiservice.properties b/basiclti/basiclti-impl/src/bundle/ltiservice.properties index 469843965818..529c69dd604f 100644 --- a/basiclti/basiclti-impl/src/bundle/ltiservice.properties +++ b/basiclti/basiclti-impl/src/bundle/ltiservice.properties @@ -73,8 +73,8 @@ bl_allowlineitems=Allow External Tool to create grade columns bl_allowroster=Provide Roster to External Tool bl_allowsettings_ext=Allow External Tool to store setting data pl_header=Tools can generally accept direct LTI Launches or a Content-Item / Deep-Link Selection launches. It is not common, but some tools can handle both types of launch at one endpoint. -bl_allowcontentitem=The tool URL can receive a Content-Item / Deep-Link launch -bl_pl_launch=The tool URL can receive an LTI launch +bl_allowcontentitem=The tool URL can receive a Deep-Link / Content-Item +bl_pl_launch=The tool URL can receive an LTI Resource Link launch bl_pl_linkselection=The tool can receive a Content-Item or Deep-Link launch pl_placement=Indicate where these tools are placed in Sakai. bl_pl_contenteditor=Allow the tool to be used from the rich text editor diff --git a/basiclti/basiclti-impl/src/java/org/sakaiproject/basiclti/impl/BasicLTISecurityServiceImpl.java b/basiclti/basiclti-impl/src/java/org/sakaiproject/basiclti/impl/BasicLTISecurityServiceImpl.java index db6df68b710c..a37b34f3ae31 100644 --- a/basiclti/basiclti-impl/src/java/org/sakaiproject/basiclti/impl/BasicLTISecurityServiceImpl.java +++ b/basiclti/basiclti-impl/src/java/org/sakaiproject/basiclti/impl/BasicLTISecurityServiceImpl.java @@ -243,33 +243,6 @@ public boolean parseEntityReference(String reference, Reference ref) return false; } - private void sendHTMLPage(HttpServletResponse res, String body) - { - try - { - res.setContentType("text/html; charset=UTF-8"); - res.setCharacterEncoding("utf-8"); - res.addDateHeader("Expires", System.currentTimeMillis() - (1000L * 60L * 60L * 24L * 365L)); - res.addDateHeader("Last-Modified", System.currentTimeMillis()); - res.addHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0, post-check=0, pre-check=0"); - res.addHeader("Pragma", "no-cache"); - java.io.PrintWriter out = res.getWriter(); - - out.println(""); - out.println(""); - out.println("\n"); - out.println(""); - out.println("\n\n"); - out.println(body); - out.println("\n\n"); - } - catch (Exception e) - { - log.warn("Failed to send HTML page.", e); - } - - } - private void doSplash(HttpServletRequest req, HttpServletResponse res, String splash, ResourceLoader rb) { // req.getRequestURL()=http://localhost:8080/access/lti/site/85fd092b-1755-4aa9-8abc-e6549527dce0/content:0 @@ -281,7 +254,7 @@ private void doSplash(HttpServletRequest req, HttpServletResponse res, String sp body += rb.getString("launch.button", "Press to continue to proceed to external tool."); body += "\">

\n"; body += splash+"

"; - sendHTMLPage(res, body); + org.tsugi.basiclti.BasicLTIUtil.sendHTMLPage(res, body); } // Do a redirect in HTML + JavaScript instead of with a 302 so we have some recovery options inside an iframe @@ -303,7 +276,7 @@ private void doRedirect(HttpServletRequest req, HttpServletResponse res, String body.append("window.location='"+redirectUrl+"';\n"); body.append("\n"); body.append(""); - sendHTMLPage(res, body.toString()); + org.tsugi.basiclti.BasicLTIUtil.sendHTMLPage(res, body.toString()); } /* @@ -407,7 +380,7 @@ private boolean sanityCheck(HttpServletRequest req, HttpServletResponse res, String oidc_endpoint = (String) tool.get(LTIService.LTI13_OIDC_ENDPOINT); if (SakaiBLTIUtil.isLTI13(tool, content) && StringUtils.isBlank(oidc_endpoint) ) { String errorMessage = "

" + SakaiBLTIUtil.getRB(rb, "error.no.oidc_endpoint", "Missing oidc_endpoint value for LTI 1.3 launch") + "

"; - sendHTMLPage(res, errorMessage); + org.tsugi.basiclti.BasicLTIUtil.sendHTMLPage(res, errorMessage); return false; } @@ -679,7 +652,7 @@ else if (refId.startsWith("export:") && refId.length() > 7) try { if (retval != null) { - sendHTMLPage(res, retval[0]); + org.tsugi.basiclti.BasicLTIUtil.sendHTMLPage(res, retval[0]); } String refstring = ref.getReference(); if ( retval != null && retval.length > 1 ) refstring = retval[1]; diff --git a/basiclti/basiclti-impl/src/java/org/sakaiproject/lti/impl/LTIRoleMapperImpl.java b/basiclti/basiclti-impl/src/java/org/sakaiproject/lti/impl/LTIRoleMapperImpl.java index 261a8230d63b..de2f8f137804 100644 --- a/basiclti/basiclti-impl/src/java/org/sakaiproject/lti/impl/LTIRoleMapperImpl.java +++ b/basiclti/basiclti-impl/src/java/org/sakaiproject/lti/impl/LTIRoleMapperImpl.java @@ -5,7 +5,7 @@ * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://opensource.org/licenses/ecl2 + * http://opensource.org/licenses/ecl2 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, @@ -33,132 +33,121 @@ import org.sakaiproject.site.api.Site; import org.sakaiproject.site.api.SiteService; +import lombok.Setter; + /** * @author Adrian Fish */ @Slf4j public class LTIRoleMapperImpl implements LTIRoleMapper { - /** - * Injected from Spring, see components.xml - */ - private SiteService siteService = null; - public void setSiteService(SiteService siteService) { - this.siteService = siteService; - } - - private ServerConfigurationService serverConfigurationService; - public void setServerConfigurationService(ServerConfigurationService serverConfigurationService) { - this.serverConfigurationService = serverConfigurationService; - } - - public Map.Entry mapLTIRole(Map payload, User user, Site site, boolean trustedConsumer) throws LTIException { - - // Check if the user is a member of the site already - boolean userExistsInSite = false; - try { - Member member = site.getMember(user.getId()); - if (member != null && BasicLTIUtil.equals(member.getUserEid(), user.getEid())) { - userExistsInSite = true; - return new AbstractMap.SimpleImmutableEntry(userRole(payload), member.getRole().getId()); - } - } catch (Exception e) { - log.warn(e.getLocalizedMessage(), e); - throw new LTIException( "launch.site.invalid", "siteId="+site.getId(), e); - } - - if (log.isDebugEnabled()) { - log.debug("userExistsInSite={}", userExistsInSite); - } - - // If not a member of the site, and we are a trusted consumer, error - // Otherwise, add them to the site - if (!userExistsInSite && trustedConsumer) { - throw new LTIException( "launch.site.user.missing", "user_id="+user.getId()+ ", siteId="+site.getId(), null); - } - - String ltiRole = null; - - if (trustedConsumer) { - // If the launch is from a trusted consumer, just return the user's - // role in the site. No need to map. - Member member = site.getMember(user.getId()); - return new AbstractMap.SimpleImmutableEntry(ltiRole, member.getRole().getId()); - } else { - ltiRole = userRole(payload); - } - - if (log.isDebugEnabled()) { - log.debug("ltiRole={}", ltiRole); - } - - try { - site = siteService.getSite(site.getId()); - Set roles = site.getRoles(); - - //BLTI-151 see if we can directly map the incoming role to the list of site roles - String newRole = null; - if (log.isDebugEnabled()) { - log.debug("Incoming ltiRole: {}", ltiRole); - } - for (Role r : roles) { - String roleId = r.getId(); - - if (BasicLTIUtil.equalsIgnoreCase(roleId, ltiRole)) { - newRole = roleId; - if (log.isDebugEnabled()) { - log.debug("Matched incoming role to role in site: {}", roleId); - } - break; - } - } - - //if we haven't mapped a role, check against the standard roles and fallback - if (BasicLTIUtil.isBlank(newRole)) { - - if (log.isDebugEnabled()) { - log.debug("No match, falling back to determine role"); - } - - String maintainRole = site.getMaintainRole(); - - if (maintainRole == null) { - maintainRole = serverConfigurationService.getString("lti.role.mapping.Instructor", null); + /** + * Injected from Spring, see components.xml + */ + @Setter private SiteService siteService; + + @Setter private ServerConfigurationService serverConfigurationService; + + public Map.Entry mapLTIRole(Map payload, User user, Site site, boolean trustedConsumer) throws LTIException { + + // Check if the user is a member of the site already + boolean userExistsInSite = false; + try { + Member member = site.getMember(user.getId()); + if (member != null && BasicLTIUtil.equals(member.getUserEid(), user.getEid())) { + userExistsInSite = true; + return new AbstractMap.SimpleImmutableEntry(userRole(payload), member.getRole().getId()); + } + } catch (Exception e) { + log.warn(e.getLocalizedMessage(), e); + throw new LTIException( "launch.site.invalid", "siteId="+site.getId(), e); + } + + if (log.isDebugEnabled()) { + log.debug("userExistsInSite={}", userExistsInSite); + } + + // If not a member of the site, and we are a trusted consumer, error + // Otherwise, add them to the site + if (!userExistsInSite && trustedConsumer) { + throw new LTIException( "launch.site.user.missing", "user_id="+user.getId()+ ", siteId="+site.getId(), null); + } + + String ltiRole = null; + + if (trustedConsumer) { + // If the launch is from a trusted consumer, just return the user's + // role in the site. No need to map. + Member member = site.getMember(user.getId()); + return new AbstractMap.SimpleImmutableEntry(ltiRole, member.getRole().getId()); + } else { + ltiRole = userRole(payload); } - boolean isInstructor = ltiRole.indexOf("instructor") >= 0; - if (isInstructor && maintainRole != null) { - newRole = maintainRole; - }else{ - newRole=serverConfigurationService.getString("lti.role.mapping.Student", null); + log.debug("ltiRole={}", ltiRole); + + try { + site = siteService.getSite(site.getId()); + Set roles = site.getRoles(); + + //BLTI-151 see if we can directly map the incoming role to the list of site roles + String newRole = null; + log.debug("Incoming ltiRole: {}", ltiRole); + for (Role r : roles) { + String roleId = r.getId(); + + if (BasicLTIUtil.equalsIgnoreCase(roleId, ltiRole)) { + newRole = roleId; + log.debug("Matched incoming role to role in site: {}", roleId); + break; + } + } + + //if we haven't mapped a role, check against the standard roles and fallback + if (BasicLTIUtil.isBlank(newRole)) { + + log.debug("No match, falling back to determine role"); + + String maintainRole = site.getMaintainRole(); + + if (maintainRole == null) { + maintainRole = serverConfigurationService.getString("lti.role.mapping.Instructor", null); + } + + boolean isInstructor = ltiRole.indexOf("instructor") >= 0; + if (isInstructor && maintainRole != null) { + newRole = maintainRole; + } else { + newRole=serverConfigurationService.getString("lti.role.mapping.Student", null); + } + + log.debug("Determined newRole as: {}", newRole); + } + + if (newRole == null) { + log.warn("Could not find Sakai role, role={} user={} site={}", ltiRole, user.getId(), site.getId()); + throw new LTIException( "launch.role.missing", "siteId="+site.getId(), null); + + } + + return new AbstractMap.SimpleImmutableEntry(ltiRole, newRole); + } catch (Exception e) { + log.warn("Could not map role role={} user={} site={}", ltiRole, user.getId(), site.getId()); + log.warn(e.getLocalizedMessage(), e); + throw new LTIException( "map.role", "siteId="+site.getId(), e); } - - - if (log.isDebugEnabled()) { - log.debug("Determined newRole as: {}", newRole); - } - } - if (newRole == null) { - log.warn("Could not find Sakai role, role={} user={} site={}", ltiRole, user.getId(), site.getId()); - throw new LTIException( "launch.role.missing", "siteId="+site.getId(), null); - - } - - return new AbstractMap.SimpleImmutableEntry(ltiRole, newRole); - } catch (Exception e) { - log.warn("Could not map role role={} user={} site={}", ltiRole, user.getId(), site.getId()); - log.warn(e.getLocalizedMessage(), e); - throw new LTIException( "map.role", "siteId="+site.getId(), e); - } - } + } private String userRole(Map payload) { String ltiRole; ltiRole = (String) payload.get(BasicLTIConstants.ROLES); if (ltiRole == null) { - ltiRole = ""; + ltiRole = ""; } else { - ltiRole = ltiRole.toLowerCase(); + ltiRole = ltiRole.toLowerCase(); } return ltiRole; } + + // vim: tabstop=4 noet + } diff --git a/basiclti/basiclti-impl/src/java/org/sakaiproject/lti/impl/UserFinderOrCreatorImpl.java b/basiclti/basiclti-impl/src/java/org/sakaiproject/lti/impl/UserFinderOrCreatorImpl.java index bf1b3a11b061..17f3bf83217e 100644 --- a/basiclti/basiclti-impl/src/java/org/sakaiproject/lti/impl/UserFinderOrCreatorImpl.java +++ b/basiclti/basiclti-impl/src/java/org/sakaiproject/lti/impl/UserFinderOrCreatorImpl.java @@ -5,7 +5,7 @@ * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.opensource.org/licenses/ECL-2.0 + * http://www.opensource.org/licenses/ECL-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, @@ -19,6 +19,8 @@ import java.util.Collection; import java.util.Map; +import org.apache.commons.lang3.StringUtils; + import lombok.extern.slf4j.Slf4j; import org.tsugi.basiclti.BasicLTIConstants; @@ -31,114 +33,127 @@ import org.sakaiproject.user.api.UserNotDefinedException; import org.sakaiproject.user.api.UserDirectoryService; +import lombok.Setter; + /** * @author Adrian Fish */ @Slf4j public class UserFinderOrCreatorImpl implements UserFinderOrCreator { - private UserDirectoryService userDirectoryService = null; - public void setUserDirectoryService(UserDirectoryService userDirectoryService) { - this.userDirectoryService = userDirectoryService; - } - - public User findOrCreateUser(Map payload, boolean trustedConsumer, boolean emailtrusted) throws LTIException { - - User user; - String eid=null; - String user_id = (String) payload.get(BasicLTIConstants.USER_ID); - - // Get the eid, either from the value provided or if trusted get it from the user_id,otherwise construct it. - if(!emailtrusted){ - eid = getEid(payload, trustedConsumer, user_id); - } - - - // If we did not get first and last name, split lis_person_name_full - final String fullname = (String) payload.get(BasicLTIConstants.LIS_PERSON_NAME_FULL); - String fname = (String) payload.get(BasicLTIConstants.LIS_PERSON_NAME_GIVEN); - String lname = (String) payload.get(BasicLTIConstants.LIS_PERSON_NAME_FAMILY); - String email = (String) payload.get(BasicLTIConstants.LIS_PERSON_CONTACT_EMAIL_PRIMARY); - - if (fname == null && lname == null && fullname != null) { - int ipos = fullname.trim().lastIndexOf(' '); - if (ipos == -1) { - fname = fullname; - } else { - fname = fullname.substring(0, ipos); - lname = fullname.substring(ipos + 1); - } - } - - // If trusted consumer, login, if email trusted consumer then we look up the user info based on the email address otherwise check for existing user and create one if required - // Note that if trusted, then the user must have already logged into Sakai in order to have an account stub created for them - // otherwise this will fail since they don't exist. Perhaps this should be addressed? - if (trustedConsumer) { - try { - if (BasicLTIUtil.isNotBlank((String) payload.get(BasicLTIConstants.EXT_SAKAI_PROVIDER_EID))) { - user = userDirectoryService.getUserByEid(eid); - } else { - user = userDirectoryService.getUser(user_id); - } - } catch (UserNotDefinedException e) { - throw new LTIException("launch.user.invalid", "user_id=" + user_id, e); - } - return user; - } + @Setter private UserDirectoryService userDirectoryService; + + public User findOrCreateUser(Map payload, boolean trustedConsumer, boolean emailtrusted) throws LTIException { + + User user; + String eid=null; + String user_id = (String) payload.get(BasicLTIConstants.USER_ID); + + // Get the eid, either from the value provided or if trusted get it from the user_id, otherwise construct it. + if(!emailtrusted){ + eid = getEid(payload, trustedConsumer, user_id); + } + + // If we did not get first and last name, split lis_person_name_full + final String fullname = (String) payload.get(BasicLTIConstants.LIS_PERSON_NAME_FULL); + String fname = (String) payload.get(BasicLTIConstants.LIS_PERSON_NAME_GIVEN); + String lname = (String) payload.get(BasicLTIConstants.LIS_PERSON_NAME_FAMILY); + String email = (String) payload.get(BasicLTIConstants.LIS_PERSON_CONTACT_EMAIL_PRIMARY); + String subject_guid = (String) payload.get("subject_guid"); + if (emailtrusted && StringUtils.isEmpty(email)) { + log.warn("trusting email as eid, no email provided subject_guid={}", subject_guid); + eid = subject_guid; + } + + if (fname == null && lname == null && fullname != null) { + int ipos = fullname.trim().lastIndexOf(' '); + if (ipos == -1) { + fname = fullname; + } else { + fname = fullname.substring(0, ipos); + lname = fullname.substring(ipos + 1); + } + } + + // If trusted consumer, login, if email trusted consumer then we look up the user info based on the email address otherwise check for existing user and create one if required + // Note that if trusted, then the user must have already logged into Sakai in order to have an account stub created for them + // otherwise this will fail since they don't exist. Perhaps this should be addressed? + if (trustedConsumer) { + try { + if (BasicLTIUtil.isNotBlank((String) payload.get(BasicLTIConstants.EXT_SAKAI_PROVIDER_EID))) { + user = userDirectoryService.getUserByEid(eid); + } else { + user = userDirectoryService.getUser(user_id); + } + } catch (UserNotDefinedException e) { + throw new LTIException("launch.user.invalid", "user_id=" + user_id, e); + } + return user; + } + /* * looking up user based on email address may return multiple results, * this is not a valid case hence this is an error condition with this * work flow */ - if (emailtrusted) { - Collection findUsersByEmail = userDirectoryService.findUsersByEmail((String) email); - if (!findUsersByEmail.isEmpty()) { - if (findUsersByEmail.size() > 1) { - log.warn("multiple user id's exist for emailaddress= {}", email); - throw new LTIException("launch.user.multiple.emailaddress", "email=" + email,null); - } - user = (User) findUsersByEmail.toArray()[0]; - } else { - log.warn("Invalid user for emailaddress= {}", email); - throw new LTIException("launch.user.invalid", "email=" + email,null); - } - return user; - } - - try { - user = userDirectoryService.getUserByEid(eid); - } catch (Exception e) { - if (log.isDebugEnabled()) { - log.debug(e.getLocalizedMessage(), e); - } - user = null; - } - - if (user == null) { - try { - String hiddenPW = IdManager.createUuid(); - userDirectoryService.addUser(null, eid, fname, lname, email, hiddenPW, "registered", null); - log.info("Created user={}", eid); - user = userDirectoryService.getUserByEid(eid); - } catch (Exception e) { - throw new LTIException("launch.create.user", "user_id=" + user_id, e); - } - } - - // post the login event - // eventTrackingService().post(eventTrackingService().newEvent(EVENT_LOGIN, - // null, true)); - - return user; - } - - private String getEid(Map payload, boolean trustedConsumer, String user_id) throws LTIException { - - String eid; - String oauth_consumer_key = (String) payload.get("oauth_consumer_key"); - String ext_sakai_provider_eid = (String) payload.get(BasicLTIConstants.EXT_SAKAI_PROVIDER_EID); - - if (BasicLTIUtil.isNotBlank(ext_sakai_provider_eid)){ + if (emailtrusted && ! StringUtils.isEmpty(email)) { + Collection findUsersByEmail = userDirectoryService.findUsersByEmail((String) email); + if (findUsersByEmail.isEmpty()) { + eid = email; + log.warn("creating new user with eid based on email={}", eid); + } else { + if (findUsersByEmail.size() > 1) { + log.warn("multiple user id's exist for emailaddress= {}", email); + throw new LTIException("launch.user.multiple.emailaddress", "email=" + email,null); + } + user = (User) findUsersByEmail.toArray()[0]; + return user; + } + } + + if ( StringUtils.isEmpty(eid)) { + throw new LTIException("launch.user.noeid", "user_id=" + user_id); + } + + + try { + user = userDirectoryService.getUserByEid(eid); + } catch (Exception e) { + log.warn("Could not find user: {}: {}", eid, e.toString()); + user = null; + } + + /* + * Create the user with an unguessable password. In the future, perhaps with SSO or perhaps + * with password reset enabled, and if the eid is the email address, this user will be + * able to log into their account directly. + */ + if (user == null) { + try { + String hiddenPW = IdManager.createUuid(); + userDirectoryService.addUser(null, eid, fname, lname, email, hiddenPW, "registered", null); + log.info("Created user={}", eid); + user = userDirectoryService.getUserByEid(eid); + } catch (Exception e) { + throw new LTIException("launch.create.user", "user_id=" + user_id, e); + } + } + + // post the login event + // eventTrackingService().post(eventTrackingService().newEvent(EVENT_LOGIN, + // null, true)); + + return user; + } + + private String getEid(Map payload, boolean trustedConsumer, String user_id) throws LTIException { + + String eid; + String oauth_consumer_key = (String) payload.get("oauth_consumer_key"); + String subject_guid = (String) payload.get("subject_guid"); + String ext_sakai_provider_eid = (String) payload.get(BasicLTIConstants.EXT_SAKAI_PROVIDER_EID); + + if (BasicLTIUtil.isNotBlank(ext_sakai_provider_eid)){ eid = (String) payload.get(BasicLTIConstants.EXT_SAKAI_PROVIDER_EID); } else { @@ -149,6 +164,8 @@ private String getEid(Map payload, boolean trustedConsumer, String user_id) thro log.error(e.getLocalizedMessage(), e); throw new LTIException( "launch.user.invalid", "user_id="+user_id, e); } + } else if ( BasicLTIUtil.isNotBlank(subject_guid) ) { + eid = "subject:" + subject_guid; } else { eid = oauth_consumer_key + ":" + user_id; } @@ -156,6 +173,8 @@ private String getEid(Map payload, boolean trustedConsumer, String user_id) thro log.debug("eid={}", eid); } } - return eid; - } + return eid; + } + + } diff --git a/basiclti/basiclti-portlet/src/bundle/basiclti.properties b/basiclti/basiclti-portlet/src/bundle/basiclti.properties index 910f4d14b1d6..7e38b0479193 100644 --- a/basiclti/basiclti-portlet/src/bundle/basiclti.properties +++ b/basiclti/basiclti-portlet/src/bundle/basiclti.properties @@ -85,6 +85,7 @@ launch.tool.search = Error searching through tools launch.tool.add = Could not add tool to site launch.continue = Press to continue to tool launch.user.invalid = Unable to find user +launch.user.noeid = Unable to determine eid launch.site.invalid = Unable to find site launch.site.user.missing = Unable to find user in site launch.site.tool.missing = Unable to find tool in site diff --git a/basiclti/basiclti-tool/pom.xml b/basiclti/basiclti-tool/pom.xml index 9a32141cfcc3..4f9c07ac53ad 100644 --- a/basiclti/basiclti-tool/pom.xml +++ b/basiclti/basiclti-tool/pom.xml @@ -20,7 +20,10 @@ ${project.groupId} basiclti-api - ${project.version} + + + org.sakaiproject.basiclti + basiclti-util ${project.groupId} diff --git a/basiclti/basiclti-tool/src/webapp/vm/lti_tool_insert.vm b/basiclti/basiclti-tool/src/webapp/vm/lti_tool_insert.vm index 9cf266fafbd7..0f5d374d2b33 100644 --- a/basiclti/basiclti-tool/src/webapp/vm/lti_tool_insert.vm +++ b/basiclti/basiclti-tool/src/webapp/vm/lti_tool_insert.vm @@ -148,6 +148,7 @@ function importLTI13Config() { jQuery.getJSON( importUrl, function(data) { console.log(data); + jQuery("#lti13_on-input").prop('checked', true); if ( data.initiate_login_uri ) jQuery("#lti13_oidc_endpoint").val(data.initiate_login_uri); if ( data.jwks_uri ) jQuery("#lti13_tool_keyset").val(data.jwks_uri); if ( data.client_id ) jQuery("#lti13_client_id").val(data.client_id); diff --git a/basiclti/docs/CERTIFICATION.md b/basiclti/docs/CERTIFICATION.md new file mode 100644 index 000000000000..cd46a6ffb781 --- /dev/null +++ b/basiclti/docs/CERTIFICATION.md @@ -0,0 +1,32 @@ + +Certification Notes +------------------- + +The certification suite used two different redirect urls - one for regular launch +and one for deep link launches. + + https://ltiadvantagevalidator.imsglobal.org/ltiplatform/deeplinkredirecturl.html + https://ltiadvantagevalidator.imsglobal.org/ltiplatform/oidcredirecturl.html + +When setting up a Sakai, you need to include both of these in the "LTI 1.3 Tool Redirect +Endpoint(s) (comma separated and provided by the tool)" field separated by commas so that +both the Deep Link and Resource Link launches work. The IMS OIDC login step chooses the +right redirect url. + + https://ltiadvantagevalidator.imsglobal.org/ltiplatform/deeplinkredirecturl.html, https://ltiadvantagevalidator.imsglobal.org/ltiplatform/oidcredirecturl.html + +Create two sites, with the same instructor. Use DeepLink to install a link in both sites +before you do the Deep Link certification - it will use the second one - but you will need +two links in two contexts with two users to pass the AGS part of the course or you will +get a duplicate lineitem error. + +If you restart the test delete all of the gradebook columns before restarting or you +will get duplicate columns in the AGS phase. + +It seems as though you can run the whole test with PII turned off. + +Also delete the LTI links installed by the DeepLink process if you restart. + +Please Submit a 2nd, Different Context Student Payload + + diff --git a/basiclti/docs/CERTIFICATION_22.pdf b/basiclti/docs/CERTIFICATION_22.pdf new file mode 100644 index 0000000000000000000000000000000000000000..1587ad5f4b78453aceab2352ba5ce3c95f2e1dee GIT binary patch literal 756995 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

M0VF_GfPPlM!mh;dOhberS*VOGO|Rp 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 ${project.groupId} basiclti-api - ${project.version} - provided ${project.groupId} @@ -149,7 +147,6 @@ ${project.groupId} basiclti-util - ${project.version} ${project.groupId} @@ -161,11 +158,6 @@ profile2-api ${project.version} - - com.nimbusds - nimbus-jose-jwt - 8.2 - diff --git a/basiclti/tsugi-util/pom.xml b/basiclti/tsugi-util/pom.xml index dafd7ea688fd..04bd196e3e27 100644 --- a/basiclti/tsugi-util/pom.xml +++ b/basiclti/tsugi-util/pom.xml @@ -18,6 +18,10 @@ 2009 jar + + shared + + @@ -37,10 +41,13 @@ org.apache.commons commons-text + + commons-httpclient + commons-httpclient + com.googlecode.json-simple json-simple - ${json.simple.version} com.fasterxml.jackson.core @@ -54,6 +61,10 @@ com.fasterxml.jackson.core jackson-databind + + com.nimbusds + nimbus-jose-jwt + io.jsonwebtoken jjwt-api @@ -63,20 +74,15 @@ jjwt-impl test - - commons-io - commons-io - test - io.jsonwebtoken jjwt-jackson test - - com.nimbusds - nimbus-jose-jwt - + + commons-io + commons-io + org.mockito mockito-core diff --git a/basiclti/tsugi-util/src/java/org/tsugi/HACK/HackMoodle.java b/basiclti/tsugi-util/src/java/org/tsugi/HACK/HackMoodle.java new file mode 100644 index 000000000000..3fc75ff9e289 --- /dev/null +++ b/basiclti/tsugi-util/src/java/org/tsugi/HACK/HackMoodle.java @@ -0,0 +1,72 @@ + +package org.tsugi.HACK; + +import org.tsugi.basiclti.BasicLTIUtil; +import org.json.simple.JSONValue; +import org.json.simple.JSONObject; +import org.json.simple.JSONArray; + +public class HackMoodle { + + /* In the IMS Dynamic Registration spec, messages_supported is a (kind of weird) array of objects: + * + * { + * "https://purl.imsglobal.org/spec/lti-platform-configuration": { + * "product_family_code": "ExampleLMS", + * "messages_supported": [ + * {"type": "LtiResourceLinkRequest"}, + * {"type": "LtiDeepLinkingRequest"}], + * "variables": ["CourseSection.timeFrame.end", "CourseSection.timeFrame.begin", "Context.id.history", "ResourceLink.id.history"] + * } + * + * In Moodle (at least in 3.10) these come back as an array of strings. + * + * { + * "https://purl.imsglobal.org/spec/lti-platform-configuration": { + * "product_family_code": "moodle", + * "version": "3.10.9+ (Build: 20220129)", + * "messages_supported": [ + * "LtiResourceLinkRequest", + * "LtiDeepLinkingRequest" + * ], + * "variables": ["CourseSection.timeFrame.end", "CourseSection.timeFrame.begin", "Context.id.history", "ResourceLink.id.history"] + * } + * + * Usage: + * + * body = org.tsugi.HACK.HackMoodle.hackOpenIdConfiguration(body); + * openIDConfig = mapper.readValue(body, OpenIDProviderConfiguration.class); + */ + + public static String hackOpenIdConfiguration(String body) + { + JSONObject jso = BasicLTIUtil.parseJSONObject(body); + if ( jso == null ) return body; + + JSONObject pc = (JSONObject) jso.get("https://purl.imsglobal.org/spec/lti-platform-configuration"); + if ( pc == null ) return body; + + JSONArray messages_supported = (JSONArray) pc.get("messages_supported"); + JSONArray new_messages = new JSONArray(); + + boolean changed = false; + for (Object jo : messages_supported) { + if ( jo instanceof String ) { + JSONObject new_message = new JSONObject(); + new_message.put("type", (String) jo); + new_messages.add(new_message); + changed = true; + } else { + new_messages.add(jo); + } + } + + if ( ! changed ) return body; + + pc.put("messages_supported", new_messages); + jso.put("https://purl.imsglobal.org/spec/lti-platform-configuration", pc); + + return jso.toJSONString(); + } + +} diff --git a/basiclti/tsugi-util/src/java/org/tsugi/HACK/README.md b/basiclti/tsugi-util/src/java/org/tsugi/HACK/README.md new file mode 100644 index 000000000000..057f514db381 --- /dev/null +++ b/basiclti/tsugi-util/src/java/org/tsugi/HACK/README.md @@ -0,0 +1,10 @@ + +Unfortunate HACKs for LTI +========================= + +When there is something that cannot be handled using the Jon Postel rule of + + "Be liberal in what you accept, and conservative in what you send" + +And the only option is to hack it - come to this directory :) + diff --git a/basiclti/tsugi-util/src/java/org/tsugi/ags2/objects/LineItem.java b/basiclti/tsugi-util/src/java/org/tsugi/ags2/objects/LineItem.java index c23b7eea7c4b..9b28382e65e4 100644 --- a/basiclti/tsugi-util/src/java/org/tsugi/ags2/objects/LineItem.java +++ b/basiclti/tsugi-util/src/java/org/tsugi/ags2/objects/LineItem.java @@ -3,15 +3,14 @@ import java.util.ArrayList; import java.util.List; -import javax.annotation.Generated; - import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import org.tsugi.shared.objects.DateRange; + @JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) -@Generated("com.googlecode.jsonschema2pojo") /* https://www.imsglobal.org/spec/lti-ags/v2p0#line-item-service application/vnd.ims.lis.v2.lineitem+json @@ -37,7 +36,7 @@ } */ // TODO: Where did the scoreUrl and resultUrl end up? -public class LineItem extends org.tsugi.jackson.objects.JacksonBase { +public class LineItem extends DateRange { public static final String MIME_TYPE = "application/vnd.ims.lis.v2.lineitem+json"; public static final String MIME_TYPE_CONTAINER = "application/vnd.ims.lis.v2.lineitemcontainer+json"; @@ -47,24 +46,22 @@ public class LineItem extends org.tsugi.jackson.objects.JacksonBase { @JsonProperty("scoreMaximum") public Double scoreMaximum; + @JsonProperty("label") public String label; + @JsonProperty("resourceId") public String resourceId; + @JsonProperty("tag") public String tag; - @JsonProperty("startDateTime") - public String startDateTime; - - @JsonProperty("endDateTime") - public String endDateTime; - @JsonProperty("submissionReview") public SubmissionReview submissionReview; @JsonProperty("id") public String id; // Output only + @JsonProperty("resourceLinkId") public String resourceLinkId; // Output only } diff --git a/basiclti/tsugi-util/src/java/org/tsugi/ags2/objects/Result.java b/basiclti/tsugi-util/src/java/org/tsugi/ags2/objects/Result.java index c37eee76c3f0..c5baa32b8c40 100644 --- a/basiclti/tsugi-util/src/java/org/tsugi/ags2/objects/Result.java +++ b/basiclti/tsugi-util/src/java/org/tsugi/ags2/objects/Result.java @@ -3,13 +3,10 @@ import java.util.ArrayList; import java.util.List; -import javax.annotation.Generated; - import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonSerialize; @JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) -@Generated("com.googlecode.jsonschema2pojo") /* application/vnd.ims.lis.v2.resultcontainer+json diff --git a/basiclti/tsugi-util/src/java/org/tsugi/ags2/objects/Score.java b/basiclti/tsugi-util/src/java/org/tsugi/ags2/objects/Score.java index 74ffd2e923e3..cb174233b447 100644 --- a/basiclti/tsugi-util/src/java/org/tsugi/ags2/objects/Score.java +++ b/basiclti/tsugi-util/src/java/org/tsugi/ags2/objects/Score.java @@ -3,13 +3,10 @@ import java.util.ArrayList; import java.util.List; -import javax.annotation.Generated; - import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonSerialize; @JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) -@Generated("com.googlecode.jsonschema2pojo") /* application/vnd.ims.lis.v1.score+json { @@ -24,6 +21,8 @@ */ public class Score extends org.tsugi.jackson.objects.JacksonBase { + public static final String MIME_TYPE = "application/vnd.ims.lis.v1.score+json"; + /** * the user has not started the activity, or the activity has been reset for that student. */ @@ -49,7 +48,6 @@ public class Score extends org.tsugi.jackson.objects.JacksonBase { */ public static final String ACTIVITY_COMPLETED = "Completed"; - /** * The grading process is completed; the score value, if any, represents the current Final Grade */ @@ -75,7 +73,6 @@ public class Score extends org.tsugi.jackson.objects.JacksonBase { */ public static final String GRADING_NOTREADY = "NotReady"; - @JsonProperty("timestamp") public String timestamp; @JsonProperty("scoreGiven") diff --git a/basiclti/tsugi-util/src/java/org/tsugi/ags2/objects/SubmissionReview.java b/basiclti/tsugi-util/src/java/org/tsugi/ags2/objects/SubmissionReview.java index 8d138272cc66..ddcfb35d40c1 100644 --- a/basiclti/tsugi-util/src/java/org/tsugi/ags2/objects/SubmissionReview.java +++ b/basiclti/tsugi-util/src/java/org/tsugi/ags2/objects/SubmissionReview.java @@ -5,15 +5,12 @@ import java.util.Map; import java.util.TreeMap; -import javax.annotation.Generated; - import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; @JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) -@Generated("com.googlecode.jsonschema2pojo") /* "submissionReview": { diff --git a/basiclti/tsugi-util/src/java/org/tsugi/basiclti/BasicLTIUtil.java b/basiclti/tsugi-util/src/java/org/tsugi/basiclti/BasicLTIUtil.java index 583b4b60a8ea..f1671d4d7a56 100644 --- a/basiclti/tsugi-util/src/java/org/tsugi/basiclti/BasicLTIUtil.java +++ b/basiclti/tsugi-util/src/java/org/tsugi/basiclti/BasicLTIUtil.java @@ -48,6 +48,7 @@ import java.text.SimpleDateFormat; import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; import net.oauth.OAuth; import net.oauth.OAuthAccessor; @@ -181,7 +182,7 @@ public static Object validateMessage(HttpServletRequest request, String URL, try { base_string = OAuthSignatureMethod.getBaseString(oam); } catch (Exception e) { - return "Unable to find base string"; + return "Unable to find base string"; } try { @@ -742,7 +743,7 @@ public static String getOAuthURL(String method, String url, } /** - * getOAuthURL - Form a GET request signed by OAuth + * getOAuthURL - Form a GET request signed by OAuth * @param method * @param url * @param oauth_consumer_key @@ -780,7 +781,7 @@ public static HttpURLConnection sendOAuthURL(String method, String url, String o } /** - * getResponseCode - Read the HTTP Response + * getResponseCode - Read the HTTP Response * @param connection */ public static int getResponseCode(HttpURLConnection connection) @@ -794,7 +795,7 @@ public static int getResponseCode(HttpURLConnection connection) /** - * readHttpResponse - Read the HTTP Response + * readHttpResponse - Read the HTTP Response * @param connection */ public static String readHttpResponse(HttpURLConnection connection) @@ -1058,36 +1059,36 @@ public static String mergeCSV(String old_id_history, String new_id_history, Stri /** * Simple utility method deal with a request that has the wrong URL when behind - * a proxy. + * a proxy. * * @param servletUrl - * @param extUrl - * The url that the external world sees us as responding to. This needs to be - * up to but not including the last slash like and not include any path information - * https://www.sakailms.org/ - although we do compensate for extra stuff at the end. + * @param extUrl + * The url that the external world sees us as responding to. This needs to be + * up to but not including the last slash like and not include any path information + * https://www.sakailms.org/ - although we do compensate for extra stuff at the end. * @return - * The full path of the request with extUrl in place of whatever the request - * thinks is the current URL. + * The full path of the request with extUrl in place of whatever the request + * thinks is the current URL. */ - static public String getRealPath(String servletUrl, String extUrl) - { - Pattern pat = Pattern.compile("^https??://[^/]*"); - // Deal with potential bad extUrl formats - Matcher m = pat.matcher(extUrl); - if (m.find()) { - extUrl = m.group(0); - } - - String retval = pat.matcher(servletUrl).replaceFirst(extUrl); - return retval; - } + static public String getRealPath(String servletUrl, String extUrl) + { + Pattern pat = Pattern.compile("^https??://[^/]*"); + // Deal with potential bad extUrl formats + Matcher m = pat.matcher(extUrl); + if (m.find()) { + extUrl = m.group(0); + } + + String retval = pat.matcher(servletUrl).replaceFirst(extUrl); + return retval; + } static public String getRealPath(HttpServletRequest request, String extUrl) - { - String URLstr = request.getRequestURL().toString(); - String retval = getRealPath(URLstr, extUrl); - return retval; - } + { + String URLstr = request.getRequestURL().toString(); + String retval = getRealPath(URLstr, extUrl); + return retval; + } /** * Simple utility method to help with the migration from Properties to @@ -1236,7 +1237,7 @@ public static boolean equalsIgnoreCase(String str1, String str2) { } /** - * Return a ISO 8601 formatted date + * Return a ISO 8601 formatted date */ public static String getISO8601() { return getISO8601(null); @@ -1435,4 +1436,47 @@ public static Double getDouble(JSONObject obj, String key) { return null; } + public static String getBrowserSignature(HttpServletRequest request) { + String [] look_at = { "x-forwarded-proto", "x-forwarded-port", "host", + "accept-encoding", "cf-ipcountry", "user-agent", "accept", "accept-language"}; + StringBuilder text = new StringBuilder(); + for (String s: look_at) { + String value = request.getHeader(s); + if ( isBlank(value) ) continue; + text.append(":::"); + text.append(s); + text.append("="); + text.append(value); + } + return text.toString(); + } + + public static void sendHTMLPage(HttpServletResponse res, String body) + { + try + { + res.setContentType("text/html; charset=UTF-8"); + res.setCharacterEncoding("utf-8"); + res.addDateHeader("Expires", System.currentTimeMillis() - (1000L * 60L * 60L * 24L * 365L)); + res.addDateHeader("Last-Modified", System.currentTimeMillis()); + res.addHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0, post-check=0, pre-check=0"); + res.addHeader("Pragma", "no-cache"); + java.io.PrintWriter out = res.getWriter(); + + out.println(""); + out.println(""); + out.println("\n"); + out.println(""); + out.println("\n\n"); + out.println(body); + out.println("\n\n"); + } + catch (Exception e) + { + log.warn("Failed to send HTML page.", e); + } + + } + + } diff --git a/basiclti/tsugi-util/src/java/org/tsugi/contentitem/objects/ContentItemResponse.java b/basiclti/tsugi-util/src/java/org/tsugi/contentitem/objects/ContentItemResponse.java index 71e0fcfb3c8d..d4a30987836d 100644 --- a/basiclti/tsugi-util/src/java/org/tsugi/contentitem/objects/ContentItemResponse.java +++ b/basiclti/tsugi-util/src/java/org/tsugi/contentitem/objects/ContentItemResponse.java @@ -4,8 +4,6 @@ import java.util.List; import java.util.ArrayList; -import javax.annotation.Generated; - import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyOrder; import com.fasterxml.jackson.databind.annotation.JsonSerialize; @@ -13,7 +11,6 @@ import org.tsugi.jackson.objects.JacksonBase; @JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) -@Generated("com.googlecode.jsonschema2pojo") @JsonPropertyOrder({ "@context", "@graph" diff --git a/basiclti/tsugi-util/src/java/org/tsugi/contentitem/objects/Icon.java b/basiclti/tsugi-util/src/java/org/tsugi/contentitem/objects/Icon.java index 8656b6cf6463..c770b198ba48 100644 --- a/basiclti/tsugi-util/src/java/org/tsugi/contentitem/objects/Icon.java +++ b/basiclti/tsugi-util/src/java/org/tsugi/contentitem/objects/Icon.java @@ -3,8 +3,6 @@ import java.util.ArrayList; -import javax.annotation.Generated; - import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyOrder; import com.fasterxml.jackson.databind.annotation.JsonSerialize; @@ -12,7 +10,6 @@ import org.tsugi.jackson.objects.JacksonBase; @JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) -@Generated("com.googlecode.jsonschema2pojo") @JsonPropertyOrder({ "@id", "width", diff --git a/basiclti/tsugi-util/src/java/org/tsugi/contentitem/objects/LtiLinkItem.java b/basiclti/tsugi-util/src/java/org/tsugi/contentitem/objects/LtiLinkItem.java index f80cf017ec6e..9e52d63b3a70 100644 --- a/basiclti/tsugi-util/src/java/org/tsugi/contentitem/objects/LtiLinkItem.java +++ b/basiclti/tsugi-util/src/java/org/tsugi/contentitem/objects/LtiLinkItem.java @@ -3,8 +3,6 @@ import java.util.ArrayList; -import javax.annotation.Generated; - import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyOrder; import com.fasterxml.jackson.databind.annotation.JsonSerialize; @@ -12,7 +10,6 @@ import org.tsugi.shared.objects.TsugiBase; @JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) -@Generated("com.googlecode.jsonschema2pojo") @JsonPropertyOrder({ "@type", "@id", diff --git a/basiclti/tsugi-util/src/java/org/tsugi/contentitem/objects/PlacementAdvice.java b/basiclti/tsugi-util/src/java/org/tsugi/contentitem/objects/PlacementAdvice.java index 1c434551c7f5..09857d5bc522 100644 --- a/basiclti/tsugi-util/src/java/org/tsugi/contentitem/objects/PlacementAdvice.java +++ b/basiclti/tsugi-util/src/java/org/tsugi/contentitem/objects/PlacementAdvice.java @@ -3,8 +3,6 @@ import java.util.ArrayList; -import javax.annotation.Generated; - import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyOrder; import com.fasterxml.jackson.databind.annotation.JsonSerialize; @@ -12,7 +10,6 @@ import org.tsugi.shared.objects.TsugiBase; @JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) -@Generated("com.googlecode.jsonschema2pojo") @JsonPropertyOrder({ "presentationDocumentTarget", "width", diff --git a/basiclti/tsugi-util/src/java/org/tsugi/deeplink/objects/ContentItem.java b/basiclti/tsugi-util/src/java/org/tsugi/deeplink/objects/ContentItem.java new file mode 100644 index 000000000000..4f177078648e --- /dev/null +++ b/basiclti/tsugi-util/src/java/org/tsugi/deeplink/objects/ContentItem.java @@ -0,0 +1,130 @@ + +package org.tsugi.deeplink.objects; + +import java.util.Map; +import java.util.HashMap; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; + +import org.tsugi.jackson.objects.JacksonBase; + +import org.tsugi.shared.objects.DateRange; +import org.tsugi.shared.objects.SizedUrl; +import org.tsugi.ags2.objects.LineItem; + +@JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) +@JsonPropertyOrder({ + "type", + "title", + "url", + "text", + "icon" +}) + +// This needs all the possible fields to avoid @SubType complexity +// And keep Jon Postel happy whilst parsing. +public class ContentItem extends JacksonBase { + + @JsonProperty("type") + public String type; + public static final String TYPE_LTIRESOURCELINK = "ltiResourceLink"; + + @JsonProperty("title") + public String title; + + @JsonProperty("url") + public String url; + + @JsonProperty("text") + public String text; + + @JsonProperty("html") + public String html; + + @JsonProperty("icon") + public SizedUrl icon; + + @JsonProperty("thumbnail") + public SizedUrl thumbnail; + + @JsonProperty("lineItem") + public MiniLineItem lineItem; + + @JsonProperty("available") + public DateRange available; + + @JsonProperty("submission") + public DateRange submission; + + @JsonProperty("custom") + public Map custom; + + // Define in more detail later + @JsonProperty("window") + public Map window; + public static final String WINDOW_TARGETNAME = "targetName"; + + public void setWindowTarget(String target) { + if ( this.window == null ) this.window = new HashMap(); + this.window.put(LtiResourceLink.WINDOW_TARGETNAME, target); + } + + // Define in more detail later + @JsonProperty("iframe") + public Map iframe; + public static final String IFRAME_HEIGHT = "height"; + public static final String IFRAME_WIDTH = "width"; + + // Define in more detail later + @JsonProperty("embed") + public Map embed; + public static final String EMBED_HTML = "html"; + +} + +/* + +{ +"type": "ltiResourceLink", +"title": "A title", +"text": "This is a link to an activity that will be graded", +"url": "https://lti.example.com/launchMe", +"icon": { + "url": "https://lti.example.com/image.jpg", + "width": 100, + "height": 100 +}, +"thumbnail": { + "url": "https://lti.example.com/thumb.jpg", + "width": 90, + "height": 90 +}, +"lineItem": { + "scoreMaximum": 87, + "label": "Chapter 12 quiz", + "resourceId": "xyzpdq1234", + "tag": "originality" +}, +"available": { + "startDateTime": "2018-02-06T20:05:02Z", + "endDateTime": "2018-03-07T20:05:02Z" +}, +"submission": { + "endDateTime": "2018-03-06T20:05:02Z" +}, +"custom": { + "quiz_id": "az-123", + "duedate": "$Resource.submission.endDateTime" +}, +"window": { + "targetName": "examplePublisherContent" +}, +"iframe": { + "height": 890 +} +}, + +*/ + diff --git a/basiclti/tsugi-util/src/java/org/tsugi/deeplink/objects/DeepLinkResponse.java b/basiclti/tsugi-util/src/java/org/tsugi/deeplink/objects/DeepLinkResponse.java new file mode 100644 index 000000000000..7e1dd3384daa --- /dev/null +++ b/basiclti/tsugi-util/src/java/org/tsugi/deeplink/objects/DeepLinkResponse.java @@ -0,0 +1,157 @@ + +package org.tsugi.deeplink.objects; + +import java.util.List; +import java.util.ArrayList; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; + +import org.tsugi.jackson.objects.JacksonBase; + +import org.tsugi.lti13.objects.BaseJWT; + +// https://www.imsglobal.org/spec/lti-dl/v2p0 +/* + +{ + "iss": "962fa4d8-bcbf-49a0-94b2-2de05ad274af", + "aud": "https://platform.example.org", + "exp": 1510185728, + "iat": 1510185228, + "nonce": "fc5fdc6d-5dd6-47f4-b2c9-5d1216e9b771", + "azp": "962fa4d8-bcbf-49a0-94b2-2de05ad274af", + "https://purl.imsglobal.org/spec/lti/claim/deployment_id": "07940580-b309-415e-a37c-914d387c1150", + "https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiDeepLinkingResponse", + "https://purl.imsglobal.org/spec/lti/claim/version": "1.3.0", + "https://purl.imsglobal.org/spec/lti-dl/claim/content_items": [ + { + "type": "link", + "title": "My Home Page", + "url": "https://something.example.com/page.html", + "icon": { + "url": "https://lti.example.com/image.jpg", + "width": 100, + "height": 100 + }, + "thumbnail": { + "url": "https://lti.example.com/thumb.jpg", + "width": 90, + "height": 90 + } + }, + { + "type": "html", + "html": "

A Custom Title

" + }, + { + "type": "link", + "url": "https://www.youtube.com/watch?v=corV3-WsIro", + "embed": { + "html": "" + }, + "window": { + "targetName": "youtube-corV3-WsIro", + "windowFeatures": "height=560,width=315,menubar=no" + }, + "iframe": { + "width": 560, + "height": 315, + "src": "https://www.youtube.com/embed/corV3-WsIro" + } + }, + { + "type": "image", + "url": "https://www.example.com/image.png", + "https://www.example.com/resourceMetadata": { + "license": "CCBY4.0" + } + }, + { + "type": "ltiResourceLink", + "title": "A title", + "text": "This is a link to an activity that will be graded", + "url": "https://lti.example.com/launchMe", + "icon": { + "url": "https://lti.example.com/image.jpg", + "width": 100, + "height": 100 + }, + "thumbnail": { + "url": "https://lti.example.com/thumb.jpg", + "width": 90, + "height": 90 + }, + "lineItem": { + "scoreMaximum": 87, + "label": "Chapter 12 quiz", + "resourceId": "xyzpdq1234", + "tag": "originality" + }, + "available": { + "startDateTime": "2018-02-06T20:05:02Z", + "endDateTime": "2018-03-07T20:05:02Z" + }, + "submission": { + "endDateTime": "2018-03-06T20:05:02Z" + }, + "custom": { + "quiz_id": "az-123", + "duedate": "$Resource.submission.endDateTime" + }, + "window": { + "targetName": "examplePublisherContent" + }, + "iframe": { + "height": 890 + } + }, + { + "type": "file", + "title": "A file like a PDF that is my assignment submissions", + "url": "https://my.example.com/assignment1.pdf", + "mediaType": "application/pdf", + "expiresAt": "2018-03-06T20:05:02Z" + }, + { + "type": "https://www.example.com/custom_type", + "data": "somedata" + } + ], + "https://purl.imsglobal.org/spec/lti-dl/claim/data": "csrftoken:c7fbba78-7b75-46e3-9201-11e6d5f36f53" +} + +*/ + +@JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) + +public class DeepLinkResponse extends BaseJWT { + + @JsonProperty("https://purl.imsglobal.org/spec/lti/claim/deployment_id") + public String deployment_id; + @JsonProperty("https://purl.imsglobal.org/spec/lti/claim/message_type") + public String message_type = "LtiDeepLinkingResponse"; + @JsonProperty("https://purl.imsglobal.org/spec/lti/claim/version") + public String version = "1.3.0"; + + @JsonProperty("https://purl.imsglobal.org/spec/lti-dl/claim/content_items") + public List content_items = new ArrayList(); + + // Yup this is weird - nonce is not a jwt concept in general + // but DeepLinkResponse strangely requires it... - Learned at D2L + // D2L does not like it on JWTs when requesting a token it is not + // in BaseJWT - but instead here and elsewhere only where required + @JsonProperty("nonce") + public String nonce; + + @JsonProperty("https://purl.imsglobal.org/spec/lti-dl/claim/data") + public String data; + + // Constructor + public DeepLinkResponse() { + super(); + this.nonce = this.jti; + } +} + diff --git a/basiclti/tsugi-util/src/java/org/tsugi/deeplink/objects/LtiResourceLink.java b/basiclti/tsugi-util/src/java/org/tsugi/deeplink/objects/LtiResourceLink.java new file mode 100644 index 000000000000..97f87c292727 --- /dev/null +++ b/basiclti/tsugi-util/src/java/org/tsugi/deeplink/objects/LtiResourceLink.java @@ -0,0 +1,55 @@ + +package org.tsugi.deeplink.objects; + +public class LtiResourceLink extends ContentItem { + + public LtiResourceLink() { + this.type = TYPE_LTIRESOURCELINK; + } + +} + +/* + +{ +"type": "ltiResourceLink", +"title": "A title", +"text": "This is a link to an activity that will be graded", +"url": "https://lti.example.com/launchMe", +"icon": { + "url": "https://lti.example.com/image.jpg", + "width": 100, + "height": 100 +}, +"thumbnail": { + "url": "https://lti.example.com/thumb.jpg", + "width": 90, + "height": 90 +}, +"lineItem": { + "scoreMaximum": 87, + "label": "Chapter 12 quiz", + "resourceId": "xyzpdq1234", + "tag": "originality" +}, +"available": { + "startDateTime": "2018-02-06T20:05:02Z", + "endDateTime": "2018-03-07T20:05:02Z" +}, +"submission": { + "endDateTime": "2018-03-06T20:05:02Z" +}, +"custom": { + "quiz_id": "az-123", + "duedate": "$Resource.submission.endDateTime" +}, +"window": { + "targetName": "examplePublisherContent" +}, +"iframe": { + "height": 890 +} +}, + + */ + diff --git a/basiclti/tsugi-util/src/java/org/tsugi/deeplink/objects/MiniLineItem.java b/basiclti/tsugi-util/src/java/org/tsugi/deeplink/objects/MiniLineItem.java new file mode 100644 index 000000000000..86831c2ae87a --- /dev/null +++ b/basiclti/tsugi-util/src/java/org/tsugi/deeplink/objects/MiniLineItem.java @@ -0,0 +1,32 @@ + +package org.tsugi.deeplink.objects; + +import java.util.ArrayList; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; + +import org.tsugi.jackson.objects.JacksonBase; + +@JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) + + +// Strangely the lineItem in a DeepLink response is *different* +// than the LineItem in AGS (org.tsugi.ags2.objects.LineItem) +// So we call this one the MiniLineItem +public class MiniLineItem extends JacksonBase { + + @JsonProperty("scoreMaximum") + public Double scoreMaximum; + + @JsonProperty("label") + public String label; + + @JsonProperty("resourceId") + public String resourceId; + + @JsonProperty("tag") + public String tag; +} + diff --git a/basiclti/tsugi-util/src/java/org/tsugi/http/HttpClientUtil.java b/basiclti/tsugi-util/src/java/org/tsugi/http/HttpClientUtil.java new file mode 100644 index 000000000000..379c0a395a57 --- /dev/null +++ b/basiclti/tsugi-util/src/java/org/tsugi/http/HttpClientUtil.java @@ -0,0 +1,215 @@ +/* + * Copyright (c) 2020- Charles R. Severance + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + * + */ +package org.tsugi.http; + +import java.lang.StringBuffer; + +import java.io.InputStream; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.Enumeration; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.Cookie; + +import org.tsugi.http.HttpUtil; + +import lombok.extern.slf4j.Slf4j; + +import org.apache.commons.lang3.StringUtils; + +/* + * + * A Java-11 HTTP utility based on + * + * https://mkyong.com/java/how-to-send-http-request-getpost-in-java/ + * https://docs.oracle.com/en/java/javase/11/docs/api/java.net.http/java/net/http/HttpResponse.BodyHandlers.html + */ + +@SuppressWarnings("deprecation") +@Slf4j +public class HttpClientUtil { + + public static HttpRequest setupGet(String url, Map parameters, Map headers, StringBuffer dbs) throws Exception { + + String getUrl = HttpUtil.augmentGetURL(url, parameters); + + if ( dbs != null ) { + dbs.append("setupGet url "); + dbs.append(getUrl); + dbs.append("\n"); + } + + HttpRequest.Builder builder = HttpRequest.newBuilder() + .GET() + .uri(URI.create(getUrl)) + // .timeout(10) + .header("User-Agent", "org.tsugi.http.HttpClientUtil web service request"); + + if ( headers != null ) { + if ( dbs != null && headers.size() > 0 ) { + dbs.append("headers\n"); + dbs.append(headers.toString()); + dbs.append("\n"); + } + for (Map.Entry entry : headers.entrySet()) { + builder.setHeader(entry.getKey().toString(), entry.getValue().toString()); + } + } + + HttpRequest request = builder.build(); + return request; + } + + public static HttpClient getClient() { + + HttpClient httpClient = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .build(); + + return httpClient; + } + + public static HttpResponse sendGet(String url, Map parameters, Map headers, StringBuffer dbs) throws Exception { + HttpRequest request = setupGet(url, parameters, headers, dbs); + HttpResponse response = getClient().send(request, HttpResponse.BodyHandlers.ofString()); + + if ( dbs != null ) { + dbs.append("http status="); + dbs.append(response.statusCode()); + dbs.append("\n"); + } + + return response; + } + + public static HttpResponse sendGetStream(String url, Map parameters, Map headers, StringBuffer dbs) throws Exception { + HttpRequest request = setupGet(url, parameters, headers, dbs); + HttpResponse response = getClient().send(request, HttpResponse.BodyHandlers.ofInputStream()); + + if ( dbs != null ) { + dbs.append("http status="); + dbs.append(response.statusCode()); + dbs.append("\n"); + } + + return response; + } + + // Convenience method + public static HttpResponse sendPost(String url, Map data, Map headers, StringBuffer dbs) throws Exception { + return sendBody("POST", url, data, headers, dbs); + } + + // Convenience method + public static HttpResponse sendPost(String url, String data, Map headers, StringBuffer dbs) throws Exception { + return sendBody("POST", url, data, headers, dbs); + } + + // Key/value body + public static HttpResponse sendBody(String method, String url, Map data, Map headers, StringBuffer dbs) throws Exception { + HttpRequest.BodyPublisher body = buildFormDataFromMap(data, dbs); + if ( headers == null ) headers = new HashMap(); + if ( headers.get("Content-Type") == null ) headers.put("Content-Type", "application/x-www-form-urlencoded"); + return sendBody(method, url, body, headers, dbs); + } + + // Straight up text body + public static HttpResponse sendBody(String method, String url, String data, Map headers, StringBuffer dbs) throws Exception { + if ( dbs != null && data != null && data.length() > 0 ) { + dbs.append("sendPost data\n"); + dbs.append(StringUtils.truncate(data, 1000)); + dbs.append("\n"); + } + + HttpRequest.BodyPublisher body = HttpRequest.BodyPublishers.ofString(data); + if ( headers == null ) headers = new HashMap(); + return sendBody(method, url, body, headers, dbs); + } + + public static HttpResponse sendBody(String method, String url, HttpRequest.BodyPublisher body, Map headers, StringBuffer dbs) throws Exception { + + HttpRequest.Builder builder = HttpRequest.newBuilder() + .method(method, body) + // .timeout(10) + .uri(URI.create(url)) + .header("User-Agent", "org.tsugi.http.HttpClientUtil web service request"); + + if ( dbs != null ) { + dbs.append("send"); + dbs.append(method); + dbs.append(" url "); + dbs.append(url); + dbs.append("\n"); + } + + if ( headers != null ) { + if ( dbs != null && headers.size() > 0 ) { + dbs.append("headers\n"); + dbs.append(headers.toString()); + dbs.append("\n"); + } + for (Map.Entry entry : headers.entrySet()) { + builder.setHeader(entry.getKey().toString(), entry.getValue().toString()); + } + } + + HttpRequest request = builder.build(); + + HttpClient httpClient = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if ( dbs != null ) { + dbs.append("http status="); + dbs.append(response.statusCode()); + dbs.append("\n"); + } + + return response; + } + + private static HttpRequest.BodyPublisher buildFormDataFromMap(Map data, StringBuffer dbs) { + if ( data == null || data.size() < 1 ) return null; + var builder = new StringBuilder(); + for (Map.Entry entry : data.entrySet()) { + if (builder.length() > 0) { + builder.append("&"); + } + builder.append(URLEncoder.encode(entry.getKey().toString(), StandardCharsets.UTF_8)); + builder.append("="); + builder.append(URLEncoder.encode(entry.getValue().toString(), StandardCharsets.UTF_8)); + } + + if ( dbs != null ) { + dbs.append("request builder: "); + dbs.append(builder.toString()); + dbs.append("\n"); + } + + return HttpRequest.BodyPublishers.ofString(builder.toString()); + } + +} diff --git a/basiclti/tsugi-util/src/java/org/tsugi/http/HttpUtil.java b/basiclti/tsugi-util/src/java/org/tsugi/http/HttpUtil.java index 72b5c4889e52..b1f89739d0dd 100644 --- a/basiclti/tsugi-util/src/java/org/tsugi/http/HttpUtil.java +++ b/basiclti/tsugi-util/src/java/org/tsugi/http/HttpUtil.java @@ -17,14 +17,23 @@ package org.tsugi.http; import java.util.Enumeration; +import java.util.Map; +import java.util.Date; +import java.time.Instant; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + import javax.servlet.http.HttpServletRequest; import javax.servlet.http.Cookie; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; +import org.apache.commons.httpclient.util.DateUtil; + /** - * Some Tsugi Utility code for to make using Jackson easier to use. + * Some Tsugi Utility code for to make using Http easier to use. */ @SuppressWarnings("deprecation") @Slf4j @@ -66,4 +75,33 @@ public static String getCookie(HttpServletRequest request, String lookup) { } return null; } + + public static String augmentGetURL(String url, Map data) { + + if ( data == null || data.size() < 1 ) return url; + + var builder = new StringBuilder(); + builder.append(url); + boolean questionMark = url.contains("?"); + + for (Map.Entry entry : data.entrySet()) { + if (! questionMark ) { + builder.append("?"); + } else { + builder.append("&"); + } + builder.append(URLEncoder.encode(entry.getKey().toString(), StandardCharsets.UTF_8)); + builder.append("="); + builder.append(URLEncoder.encode(entry.getValue().toString(), StandardCharsets.UTF_8)); + } + return builder.toString(); + } + + // Retry-After: Date: Wed, 21 Oct 2015 07:28:00 GMT + // + public static Instant getInstantFromHttp(String headerDate) + { + // TODO: Work this out + return null; + } } diff --git a/basiclti/tsugi-util/src/java/org/tsugi/jackson/JacksonUtil.java b/basiclti/tsugi-util/src/java/org/tsugi/jackson/JacksonUtil.java index 449d5df92cb5..6225070901a0 100644 --- a/basiclti/tsugi-util/src/java/org/tsugi/jackson/JacksonUtil.java +++ b/basiclti/tsugi-util/src/java/org/tsugi/jackson/JacksonUtil.java @@ -18,6 +18,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectWriter; import lombok.extern.slf4j.Slf4j; @@ -62,4 +63,14 @@ public static String toString(Object jackson) { } } + public static ObjectMapper getLaxObjectMapper() + { + ObjectMapper mapper = new ObjectMapper() + .configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true) + .configure(DeserializationFeature.FAIL_ON_UNRESOLVED_OBJECT_IDS, false) + .configure(DeserializationFeature.FAIL_ON_INVALID_SUBTYPE, false) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + return mapper; + } + } diff --git a/basiclti/tsugi-util/src/java/org/tsugi/lti13/DeepLinkResponse.java b/basiclti/tsugi-util/src/java/org/tsugi/lti13/DeepLinkResponse.java index 291c5fa1ca31..2df65491e1f0 100644 --- a/basiclti/tsugi-util/src/java/org/tsugi/lti13/DeepLinkResponse.java +++ b/basiclti/tsugi-util/src/java/org/tsugi/lti13/DeepLinkResponse.java @@ -226,7 +226,6 @@ public class DeepLinkResponse { public DeepLinkResponse(String id_token) { this.id_token = id_token; - body = (JSONObject) LTI13JwtUtil.jsonJwtBody(id_token); if ( body == null ) { throw new java.lang.RuntimeException("Could not extract body from id_token"); diff --git a/basiclti/tsugi-util/src/java/org/tsugi/lti13/LTI13AccessTokenUtil.java b/basiclti/tsugi-util/src/java/org/tsugi/lti13/LTI13AccessTokenUtil.java new file mode 100644 index 000000000000..bf0c5b13913f --- /dev/null +++ b/basiclti/tsugi-util/src/java/org/tsugi/lti13/LTI13AccessTokenUtil.java @@ -0,0 +1,222 @@ +/* + * $URL$ + * $Id$ + * + * Copyright (c) 2022- Charles R. Severance + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +package org.tsugi.lti13; + +import java.lang.StringBuffer; + +import java.util.Map; +import java.util.TreeMap; + +import java.net.http.HttpResponse; // Thanks Java 11 + +import java.security.Key; +import java.security.KeyPair; + +import org.apache.commons.lang3.StringUtils; + +import io.jsonwebtoken.Jwts; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.tsugi.http.HttpClientUtil; +import org.tsugi.jackson.JacksonUtil; +import org.tsugi.oauth2.objects.AccessToken; +import org.tsugi.oauth2.objects.ClientAssertion; + +import lombok.extern.slf4j.Slf4j; + +// https://www.imsglobal.org/spec/security/v1p0/ + +@Slf4j +public class LTI13AccessTokenUtil { + + /** + * Return a Map of values ready for posting + * + * grant_type=client_credentials + * client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer + * client_assertion=eyJ0eXAiOiJKV1QiLCJhbG....qEZtDgBgMMsneNePfMrifOvvFLkxnpefA + * scope=http://imsglobal.org/ags/lineitem http://imsglobal.org/ags/result/read + * + * https://www.imsglobal.org/spec/security/v1p0/#using-json-web-tokens-with-oauth-2-0-client-credentials-grant + */ + public static Map getClientAssertion(String[] scopes, KeyPair keyPair, + String clientId, String deploymentId, String tokenAudience, StringBuffer dbs) + { + if ( dbs != null ) { + dbs.append("getClientAssertion\n"); + } + + Map retval = new TreeMap(); + retval.put(ClientAssertion.GRANT_TYPE, ClientAssertion.GRANT_TYPE_CLIENT_CREDENTIALS); + retval.put(ClientAssertion.CLIENT_ASSERTION_TYPE, ClientAssertion.CLIENT_ASSERTION_TYPE_JWT); + if ( scopes != null && scopes.length > 0 ) { + retval.put(ClientAssertion.SCOPE, String.join(" ", scopes)); + if ( dbs != null ) { + dbs.append("scopes\n"); + dbs.append((String) retval.get(ClientAssertion.SCOPE)); + dbs.append("\n"); + } + } + + ClientAssertion ca = new ClientAssertion(); + ca.issuer = clientId; // This is our server + ca.subject = clientId; + ca.deployment_id = deploymentId; + if ( !StringUtils.isEmpty(tokenAudience) ) ca.audience = tokenAudience; + + String cas = JacksonUtil.toString(ca); + + Key privateKey = keyPair.getPrivate(); + Key publicKey = keyPair.getPublic(); + + String kid = LTI13KeySetUtil.getPublicKID(publicKey); + + log.debug("getClientAssertion kid={} token={}", kid, cas); + if ( dbs != null && kid != null && cas != null ) { + dbs.append("kid="); + dbs.append(kid); + dbs.append("\n"); + dbs.append(StringUtils.truncate(cas, 1000)); + dbs.append("\n"); + } + + String jws = Jwts.builder().setHeaderParam("kid", kid). + setPayload(cas).signWith(privateKey).compact(); + retval.put(ClientAssertion.CLIENT_ASSERTION, jws); + + return retval; + } + + public static AccessToken getScoreToken(String url, KeyPair keyPair, + String clientId, String deploymentId, String tokenAudience, StringBuffer dbs) + { + Map assertion = getScoreAssertion(keyPair, clientId, deploymentId, tokenAudience, dbs); + return retrieveToken(url, assertion, dbs); + } + + public static Map getScoreAssertion(KeyPair keyPair, + String clientId, String deploymentId, String tokenAudience, StringBuffer dbs) + { + return getClientAssertion( + new String[] { + LTI13ConstantsUtil.SCOPE_LINEITEM, + LTI13ConstantsUtil.SCOPE_SCORE, + LTI13ConstantsUtil.SCOPE_RESULT_READONLY + }, + keyPair, clientId, deploymentId, tokenAudience, dbs); + } + + public static AccessToken getNRPSToken(String url, KeyPair keyPair, + String clientId, String deploymentId, String tokenAudience, StringBuffer dbs) + { + Map assertion = getNRPSAssertion(keyPair, clientId, deploymentId, tokenAudience, dbs); + return retrieveToken(url, assertion, dbs); + } + + public static Map getNRPSAssertion(KeyPair keyPair, + String clientId, String deploymentId, String tokenAudience, StringBuffer dbs) + { + return getClientAssertion( + new String[] { + LTI13ConstantsUtil.SCOPE_NAMES_AND_ROLES + }, + keyPair, clientId, deploymentId, tokenAudience, dbs); + } + + public static AccessToken getLineItemsToken(String url, KeyPair keyPair, + String clientId, String deploymentId, String tokenAudience, StringBuffer dbs) + { + Map assertion = getLineItemsAssertion(keyPair, clientId, deploymentId, tokenAudience, dbs); + return retrieveToken(url, assertion, dbs); + } + + + public static Map getLineItemsAssertion(KeyPair keyPair, + String clientId, String deploymentId, String tokenAudience, StringBuffer dbs) + { + return getClientAssertion( + new String[] { + LTI13ConstantsUtil.SCOPE_LINEITEM + }, + keyPair, clientId, deploymentId, tokenAudience, dbs); + } + + /* + * Retrieve an access token + * + * Documentation: + * https://www.imsglobal.org/spec/security/v1p0/#using-json-web-tokens-with-oauth-2-0-client-credentials-grant + * https://canvas.instructure.com/doc/api/file.oauth_endpoints.html#post-login-oauth2-token + */ + protected static AccessToken retrieveToken(String url, Map assertion, StringBuffer dbs) + { + try { + if ( dbs != null ) { + dbs.append("retrieveToken\n"); + dbs.append(url); + dbs.append(assertion.toString()); + dbs.append("\n"); + } + + HttpResponse response = HttpClientUtil.sendPost(url, assertion, null, dbs); + String responseStr = response.body(); + + if ( responseStr == null ) { + log.info("Empty / null response to POST url={} sent={}",url, assertion, responseStr); + if ( dbs != null ) dbs.append("Empty / null response to POST\n"); + return null; + } + + if ( dbs != null ) { + dbs.append("responseStr\n"); + dbs.append(responseStr); + dbs.append("\n"); + } + + ObjectMapper mapper = JacksonUtil.getLaxObjectMapper(); + AccessToken accessToken = mapper.readValue(responseStr, AccessToken.class); + + if ( accessToken == null || StringUtils.isEmpty(accessToken.access_token) ) { + log.info("Failed to parse access token url={} sent={} received={}",url, assertion, responseStr); + if ( dbs != null ) dbs.append("Could not parse access token\n"); + return null; + } + + if ( dbs != null ) { + dbs.append("access_token\n"); + dbs.append(accessToken); + dbs.append("\n"); + } + + return accessToken; + } catch (Exception e) { + log.error("Error retrieving token from {}", url, e); + + if ( dbs != null ) { + dbs.append("Exception retrieving token "); + dbs.append(e.getMessage()); + dbs.append("\n"); + } + + return null; + } + } + +} diff --git a/basiclti/tsugi-util/src/java/org/tsugi/lti13/LTI13ConstantsUtil.java b/basiclti/tsugi-util/src/java/org/tsugi/lti13/LTI13ConstantsUtil.java index 96db631250d7..bfa3e51faa3d 100644 --- a/basiclti/tsugi-util/src/java/org/tsugi/lti13/LTI13ConstantsUtil.java +++ b/basiclti/tsugi-util/src/java/org/tsugi/lti13/LTI13ConstantsUtil.java @@ -74,6 +74,12 @@ public class LTI13ConstantsUtil { public static final String MESSAGE_TYPE_LTI_RESOURCE = "LtiResourceLinkRequest"; public static final String MESSAGE_TYPE_LTI_DEEP_LINKING_REQUEST = "LtiDeepLinkingRequest"; public static final String MESSAGE_TYPE_LTI_DEEP_LINKING_RESPONSE = "LtiDeepLinkingResponse"; + // Context Launch (Draft) + public static final String MESSAGE_TYPE_LTI_CONTEXT = "LtiContextLaunchRequest"; + // Submission Review (Draft) + public static final String MESSAGE_TYPE_LTI_SUBMISSION_REVIEW_REQUEST = "LtiSubmissionReviewRequest"; + // Data Privacy Launch (Draft) + public static final String MESSAGE_TYPE_LTI_DATA_PRIVACY_LAUNCH_REQUEST = "DataPrivacyLaunchRequest"; public static final String CONTENT_ITEM_DOC_TARGET_IFRAME = "iframe"; public static final String CONTENT_ITEM_DOC_TARGET_WINDOW = "window"; public static final String CONTENT_ITEM_MEDIA_TYPES = "*/*"; @@ -81,5 +87,17 @@ public class LTI13ConstantsUtil { //Deep Linking public static final String DEEP_LINKING_RETURN_URL = "return_url"; + // Access Token + public static final String SCOPE_RESULT_READONLY = "https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly"; + public static final String SCOPE_SCORE = "https://purl.imsglobal.org/spec/lti-ags/scope/score"; + public static final String SCOPE_LINEITEM = "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem"; + public static final String SCOPE_LINEITEM_READONLY = "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly"; + public static final String SCOPE_NAMES_AND_ROLES = "https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly"; + + public static final String MEDIA_TYPE_MEMBERSHIPS = "application/vnd.ims.lti-nrps.v2.membershipcontainer+json"; + public static final String MEDIA_TYPE_LINEITEM = "application/vnd.ims.lis.v2.lineitem+json"; + public static final String MEDIA_TYPE_LINEITEMS = "application/vnd.ims.lis.v2.lineitemcontainer+json"; + public static final String SCORE_TYPE = "application/vnd.ims.lis.v1.score+json"; + public static final String RESULTS_TYPE = "application/vnd.ims.lis.v2.resultcontainer+json"; } diff --git a/basiclti/tsugi-util/src/java/org/tsugi/lti13/LTI13KeySetUtil.java b/basiclti/tsugi-util/src/java/org/tsugi/lti13/LTI13KeySetUtil.java index 171391e969df..3d530b4dcee9 100644 --- a/basiclti/tsugi-util/src/java/org/tsugi/lti13/LTI13KeySetUtil.java +++ b/basiclti/tsugi-util/src/java/org/tsugi/lti13/LTI13KeySetUtil.java @@ -88,18 +88,32 @@ public static String getKeySetJSON(RSAPublicKey key) return getKeySetJSON(keys); } - public static RSAPublicKey getKeyFromKeySet(String kid, String url) + public static com.nimbusds.jose.jwk.JWKSet getKeySetFromUrl(String url) throws java.text.ParseException, com.nimbusds.jose.JOSEException, java.net.MalformedURLException, java.io.IOException { com.nimbusds.jose.jwk.JWKSet localKeys = com.nimbusds.jose.jwk.JWKSet.load(new java.net.URL(url)); + return localKeys; + } + + public static RSAPublicKey getKeyFromKeySet(String kid, com.nimbusds.jose.jwk.JWKSet localKeys) + throws java.text.ParseException, com.nimbusds.jose.JOSEException, java.net.MalformedURLException, java.io.IOException + { com.nimbusds.jose.jwk.RSAKey nimbusPublic = (com.nimbusds.jose.jwk.RSAKey) localKeys.getKeyByKeyId(kid); RSAPublicKey publicKey = nimbusPublic.toRSAPublicKey(); return publicKey; } - public static RSAPublicKey getKeyFromKeySetString(String kid, String json) + public static RSAPublicKey getKeyFromKeySet(String kid, String url) + throws java.text.ParseException, com.nimbusds.jose.JOSEException, java.net.MalformedURLException, java.io.IOException + { + com.nimbusds.jose.jwk.JWKSet localKeys = getKeySetFromUrl(url); + + return getKeyFromKeySet(kid, localKeys); + } + + public static RSAPublicKey getKeyFromKeySetString(String kid, String json) throws java.text.ParseException, com.nimbusds.jose.JOSEException { com.nimbusds.jose.jwk.JWKSet localKeys = com.nimbusds.jose.jwk.JWKSet.parse(json); diff --git a/basiclti/tsugi-util/src/java/org/tsugi/lti13/LTI13Util.java b/basiclti/tsugi-util/src/java/org/tsugi/lti13/LTI13Util.java index bfe6c3079176..c241af9f7ac3 100644 --- a/basiclti/tsugi-util/src/java/org/tsugi/lti13/LTI13Util.java +++ b/basiclti/tsugi-util/src/java/org/tsugi/lti13/LTI13Util.java @@ -414,6 +414,19 @@ public static String compute_HMAC_SHA256(String message, String secret) } } + /** + * Get the scores URL from a lineItem Url + * + * Moodle gives us lineItem URLs with query parameters. + */ + public static String getScoreUrlForLineItem(String lineItemUrl) + { + if ( lineItemUrl == null ) return lineItemUrl; + int pos = lineItemUrl.indexOf("?"); + if ( pos < 0 ) return lineItemUrl + "/scores"; + return lineItemUrl.substring(0,pos) + "/scores?" + lineItemUrl.substring(pos+1); + } + /* HTTP/1.1 400 OK Content-Type: application/json;charset=UTF-8 diff --git a/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/BaseJWT.java b/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/BaseJWT.java index 1c0530b91476..714946d57ef6 100644 --- a/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/BaseJWT.java +++ b/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/BaseJWT.java @@ -1,25 +1,40 @@ package org.tsugi.lti13.objects; -import javax.annotation.Generated; +import java.util.UUID; +import org.tsugi.jackson.objects.JacksonBase; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonSerialize; @JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) -@Generated("com.googlecode.jsonschema2pojo") -public class BaseJWT { +public class BaseJWT extends JacksonBase { - @JsonProperty("iss") + // Put in the basic values - which can be removed or replaced in out calling code + public BaseJWT() + { + this.issued = new Long(System.currentTimeMillis() / 1000L); + this.expires = this.issued + 3600L; + this.jti = UUID.randomUUID().toString(); + // Move this to LaunchJWT and DeepLinkResponse + // this.nonce = this.jti; + } + + @JsonProperty("iss") // A unique identifier for the entity that issued the JWT public String issuer; // The url of the LMS or product - @JsonProperty("aud") - public String audience; // The Client ID - @JsonProperty("sub") + @JsonProperty("aud") // Authorization server identifier (s) + public String audience; + @JsonProperty("sub") // "client_id" of the OAuth Consumer public String subject; // The user_id - @JsonProperty("nonce") - public String nonce; + @JsonProperty("iat") public Long issued; @JsonProperty("exp") public Long expires; + @JsonProperty("jti") + public String jti; + + // Move this to LaunchJWT and DeepLinkResponse where it is explicitly needed + // @JsonProperty("nonce") + // public String nonce; } diff --git a/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/BasicOutcome.java b/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/BasicOutcome.java index 019286129262..f67009a259b0 100644 --- a/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/BasicOutcome.java +++ b/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/BasicOutcome.java @@ -1,13 +1,9 @@ package org.tsugi.lti13.objects; -import javax.annotation.Generated; - import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonSerialize; @JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) -@Generated("com.googlecode.jsonschema2pojo") - /* "https://purl.imsglobal.org/spec/lti/claim/context": { "id": "6", @@ -18,7 +14,7 @@ ] }, */ -public class BasicOutcome { +public class BasicOutcome extends org.tsugi.jackson.objects.JacksonBase { @JsonProperty("lis_result_sourcedid") public String lis_result_sourcedid; diff --git a/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/Context.java b/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/Context.java index d9ba2eae16a5..244847404f7c 100644 --- a/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/Context.java +++ b/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/Context.java @@ -3,13 +3,10 @@ import java.util.ArrayList; import java.util.List; -import javax.annotation.Generated; - import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonSerialize; @JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) -@Generated("com.googlecode.jsonschema2pojo") /* "https://purl.imsglobal.org/spec/lti/claim/context": { @@ -21,7 +18,7 @@ ] }, */ -public class Context { +public class Context extends org.tsugi.jackson.objects.JacksonBase { // Per Viktor, the short form is deprecated in LTI 1.3 public static String COURSE_OFFERING = "http://purl.imsglobal.org/vocab/lis/v2/course#CourseOffering"; diff --git a/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/DeepLink.java b/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/DeepLink.java index 6cf082ce0edf..6dbf63727b0d 100644 --- a/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/DeepLink.java +++ b/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/DeepLink.java @@ -3,13 +3,10 @@ import java.util.ArrayList; import java.util.List; -import javax.annotation.Generated; - import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonSerialize; @JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) -@Generated("com.googlecode.jsonschema2pojo") /* * Specification: @@ -29,7 +26,7 @@ } */ -public class DeepLink { +public class DeepLink extends org.tsugi.jackson.objects.JacksonBase { // TODO: What do these mean? public static final String ACCEPT_TYPE_LINK = "link"; diff --git a/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/Endpoint.java b/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/Endpoint.java index fb832c3e86e9..9ac256054689 100644 --- a/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/Endpoint.java +++ b/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/Endpoint.java @@ -3,13 +3,10 @@ import java.util.ArrayList; import java.util.List; -import javax.annotation.Generated; - import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonSerialize; @JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) -@Generated("com.googlecode.jsonschema2pojo") /* "https:\/\/purl.imsglobal.org\/spec\/lti-ags\/claim\/endpoint": { @@ -23,24 +20,7 @@ "lineitem": "https:\/\/lti-ri.imsglobal.org\/platforms\/7\/contexts\/6\/line_items\/9" }, */ -public class Endpoint { - - /** - * Tool can access the results for its line items - */ - public static String SCOPE_RESULT_READONLY = "https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly"; - /** - * Tool can publish score updates to the line items - */ - public static String SCOPE_SCORE = "https://purl.imsglobal.org/spec/lti-ags/scope/score"; - /** - * Tool can fully manage its line items including, adding and removing line items - */ - public static String SCOPE_LINEITEM = "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem"; - /** - * Tool can query its line line items - no modification allowed - */ - public static String SCOPE_LINEITEM_READONLY = "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly"; +public class Endpoint extends org.tsugi.jackson.objects.JacksonBase { @JsonProperty("scope") public List scope = new ArrayList(); diff --git a/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/ForUser.java b/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/ForUser.java index 3b1a9b4ff0d0..444d22b145ff 100644 --- a/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/ForUser.java +++ b/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/ForUser.java @@ -3,14 +3,11 @@ import java.util.List; import java.util.ArrayList; -import javax.annotation.Generated; - import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyOrder; import com.fasterxml.jackson.databind.annotation.JsonSerialize; @JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) -@Generated("com.googlecode.jsonschema2pojo") /* * user_id (Required): id of the graded user, as identified by sub claim for launches done by that user. @@ -28,7 +25,7 @@ * roles: Roles in the context as defined in LTI 1.3 Core specifications. */ -public class ForUser { +public class ForUser extends org.tsugi.jackson.objects.JacksonBase { // Required @JsonProperty("user_id") public String user_id; diff --git a/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/LTI11Transition.java b/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/LTI11Transition.java index ef8271f7a4a4..7b32cdb1694c 100644 --- a/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/LTI11Transition.java +++ b/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/LTI11Transition.java @@ -1,13 +1,10 @@ package org.tsugi.lti13.objects; -import javax.annotation.Generated; - import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyOrder; import com.fasterxml.jackson.databind.annotation.JsonSerialize; @JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) -@Generated("com.googlecode.jsonschema2pojo") /* @@ -31,7 +28,7 @@ } */ -public class LTI11Transition { +public class LTI11Transition extends org.tsugi.jackson.objects.JacksonBase { @JsonProperty("user_id") public String user_id; @JsonProperty("oauth_consumer_key") diff --git a/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/LTIPlatformMessage.java b/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/LTILaunchMessage.java similarity index 87% rename from basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/LTIPlatformMessage.java rename to basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/LTILaunchMessage.java index 00028ede02b0..c5eb67e59c98 100644 --- a/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/LTIPlatformMessage.java +++ b/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/LTILaunchMessage.java @@ -3,16 +3,16 @@ import java.util.ArrayList; import java.util.List; -import javax.annotation.Generated; - import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonSerialize; @JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) -@Generated("com.googlecode.jsonschema2pojo") + +// This is used to populate both "messages_supported" and "messages" +// For messages supported - we only define type /* - "https://purl.imsglobal.org/spec/lti-platform-configuration ": { + "https://purl.imsglobal.org/spec/lti-platform-configuration": { "product_family_code": "ExampleLMS", "messages_supported": [ {"type": "LtiResourceLinkRequest"}, @@ -28,9 +28,9 @@ "placements": ["resourceLink", ... (TBD)] } ] - */ -public class LTIPlatformMessage { + +public class LTILaunchMessage extends org.tsugi.jackson.objects.JacksonBase { // Defined values in org.tsugi.lti13.objects.LaunchJWT.MESSAGE_TYPE_LAUNCH; @JsonProperty("type") public String type; @@ -38,6 +38,7 @@ public class LTIPlatformMessage { public String target_link_uri; @JsonProperty("label") public String label; + // Array of placements indicating where the platform support this link type to be added when the tool is made available. // TODO: Define the constants that belong here @JsonProperty("placements") diff --git a/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/LTIPlatformConfiguration.java b/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/LTIPlatformConfiguration.java index 7fe5287fe7c5..9732981144e9 100644 --- a/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/LTIPlatformConfiguration.java +++ b/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/LTIPlatformConfiguration.java @@ -3,16 +3,13 @@ import java.util.ArrayList; import java.util.List; -import javax.annotation.Generated; - import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonSerialize; @JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) -@Generated("com.googlecode.jsonschema2pojo") /* - "https://purl.imsglobal.org/spec/lti-platform-configuration ": { + "https://purl.imsglobal.org/spec/lti-platform-configuration": { "product_family_code": "ExampleLMS", "messages_supported": [ {"type": "LtiResourceLinkRequest"}, @@ -20,7 +17,7 @@ "variables": ["CourseSection.timeFrame.end", "CourseSection.timeFrame.begin", "Context.id.history", "ResourceLink.id.history"] } */ -public class LTIPlatformConfiguration { +public class LTIPlatformConfiguration extends org.tsugi.jackson.objects.JacksonBase { // Product identifier for the platform. @JsonProperty("product_family_code") @@ -32,7 +29,7 @@ public class LTIPlatformConfiguration { // An array of all supported LTI message types. @JsonProperty("messages_supported") - public List messages_supported = new ArrayList(); + public List messages_supported = new ArrayList(); // An array of all variables supported for use as substitution parameters (optional) @JsonProperty("variables") diff --git a/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/LTIToolConfiguration.java b/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/LTIToolConfiguration.java index c5309e19ad5c..670b6cfbd443 100644 --- a/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/LTIToolConfiguration.java +++ b/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/LTIToolConfiguration.java @@ -5,55 +5,78 @@ import java.util.Map; import java.util.TreeMap; -import javax.annotation.Generated; - import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonSerialize; @JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) -@Generated("com.googlecode.jsonschema2pojo") /* - // "https://purl.imsglobal.org/spec/lti-tool-configuration": { - // "domain": "client.example.org", - // "description": "Learn Botany by tending to your little (virtual) garden.", - // "description#ja": "小さな(仮想)庭に行くことで植物学を学びましょう。", - // "target_link_uri": "https://client.example.org/lti", - // "custom_parameters": { - // "context_history": "$Context.id.history" - // }, - // "claims": ["iss", "sub", "name", "given_name", "family_name"], - // "messages": [ - // { - // "type": "LtiDeepLinkingRequest", - // "target_link_uri": "https://client.example.org/lti/dl", - // "label": "Add a virtual garden", - // "label#ja": "バーチャルガーデンを追加する", - // } - // ] - // } + "https://purl.imsglobal.org/spec/lti-tool-configuration": { + "domain": "client.example.org", + "description": "Learn Botany by tending to your little (virtual) garden.", + "description#ja": "小さな(仮想)庭に行くことで植物学を学びましょう。", + "target_link_uri": "https://client.example.org/lti", + "custom_parameters": { + "context_history": "$Context.id.history" + }, + "claims": ["iss", "sub", "name", "given_name", "family_name"], + "messages": [ + { + "type": "LtiDeepLinkingRequest", + "target_link_uri": "https://client.example.org/lti/dl", + "label": "Add a virtual garden", + "label#ja": "バーチャルガーデンを追加する", + } + ] + } */ -public class LTIToolConfiguration { - // "domain": "client.example.org", +// https://www.imsglobal.org/spec/lti-dr/v1p0#step-3-client-registration +// https://www.imsglobal.org/spec/lti-dr/v1p0#client-registration-response +public class LTIToolConfiguration extends org.tsugi.jackson.objects.JacksonBase { + + // "domain": "client.example.org", @JsonProperty("domain") public String domain; - // "description": "Learn Botany by tending to your little (virtual) garden.", - // "description#ja": "小さな(仮想)庭に行くことで植物学を学びましょう。", + // "description": "Learn Botany by tending to your little (virtual) garden.", + // "description#ja": "小さな(仮想)庭に行くことで植物学を学びましょう。", @JsonProperty("description") public String description; - // "target_link_uri": "https://client.example.org/lti", + // "target_link_uri": "https://client.example.org/lti", @JsonProperty("target_link_uri") public String target_link_uri; - // "custom_parameters": { - // "context_history": "$Context.id.history" - // }, + /* + * In the case where a platform is combining registration and deployment of a tool, the platform may pass the + * LTI deployment_id associated with this client registration's deployment. Response only. + */ + // "deployment_id": "42", + @JsonProperty("deployment_id") + public String deployment_id; + + // "custom_parameters": { + // "context_history": "$Context.id.history" + // }, @JsonProperty("custom_parameters") public Map custom_parameters = new TreeMap(); + @JsonProperty("claims") + public List claims = new ArrayList(); + @JsonProperty("variables") public List variables = new ArrayList(); + + @JsonProperty("messages") + public List messages = new ArrayList(); + + public void addCommonClaims() { + this.claims.add("iss"); + this.claims.add("sub"); + this.claims.add("name"); + this.claims.add("given_name"); + this.claims.add("family_name"); + this.claims.add("email"); + } } diff --git a/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/LaunchJWT.java b/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/LaunchJWT.java index 8d13bf8ff69e..111278fd5d42 100644 --- a/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/LaunchJWT.java +++ b/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/LaunchJWT.java @@ -4,31 +4,26 @@ import java.util.Map; import java.util.ArrayList; -import javax.annotation.Generated; - import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.databind.ser.std.MapSerializer; import org.tsugi.lti13.LTI13ConstantsUtil; @JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) -@Generated("com.googlecode.jsonschema2pojo") public class LaunchJWT extends BaseJWT { public static String CLAIM_PREFIX = "https://purl.imsglobal.org/spec/lti/claim/"; - public static String MESSAGE_TYPE_LAUNCH = "LtiResourceLinkRequest"; - public static String MESSAGE_TYPE_DEEP_LINK = "LtiDeepLinkingRequest"; - public static String ROLE_LEARNER = LTI13ConstantsUtil.ROLE_LEARNER; - public static String ROLE_INSTRUCTOR = LTI13ConstantsUtil.ROLE_INSTRUCTOR; - - // Submission Review (Draft) - public static final String MESSAGE_TYPE_LTI_SUBMISSION_REVIEW_REQUEST = "LtiSubmissionReviewRequest"; - - // Data Privacy Launch (Draft) - public static final String MESSAGE_TYPE_LTI_DATA_PRIVACY_LAUNCH_REQUEST = "DataPrivacyLaunchRequest"; + public static final String MESSAGE_TYPE_LAUNCH = LTI13ConstantsUtil.MESSAGE_TYPE_LTI_RESOURCE; + public static final String MESSAGE_TYPE_DEEP_LINK = LTI13ConstantsUtil.MESSAGE_TYPE_LTI_DEEP_LINKING_REQUEST; + public static final String ROLE_LEARNER = LTI13ConstantsUtil.ROLE_LEARNER; + public static final String ROLE_INSTRUCTOR = LTI13ConstantsUtil.ROLE_INSTRUCTOR; + public static final String MESSAGE_TYPE_LTI_SUBMISSION_REVIEW_REQUEST = LTI13ConstantsUtil.MESSAGE_TYPE_LTI_SUBMISSION_REVIEW_REQUEST; + public static final String MESSAGE_TYPE_LTI_DATA_PRIVACY_LAUNCH_REQUEST = LTI13ConstantsUtil.MESSAGE_TYPE_LTI_DATA_PRIVACY_LAUNCH_REQUEST; + public static final String MESSAGE_TYPE_LTI_CONTEXT = LTI13ConstantsUtil.MESSAGE_TYPE_LTI_CONTEXT; @JsonProperty("https://purl.imsglobal.org/spec/lti/claim/deployment_id") public String deployment_id; @@ -94,6 +89,10 @@ public class LaunchJWT extends BaseJWT { @JsonProperty("https://purl.imsglobal.org/spec/lti/claim/for_user") public ForUser for_user; + // This is in LaunchJWTs + @JsonProperty("nonce") + public String nonce; + // Constructor public LaunchJWT() { this(MESSAGE_TYPE_LAUNCH); @@ -101,9 +100,37 @@ public LaunchJWT() { // Constructor public LaunchJWT(String messageType) { + super(); this.message_type = messageType; this.version = "1.3.0"; this.launch_presentation = new LaunchPresentation(); + this.nonce = this.jti; + } + + // Encode the rules for constructing a name + @JsonIgnore + public String getDisplayName() { + if ( name != null ) return name; + + String display_name = ""; + if ( given_name != null ) display_name = given_name; + if ( middle_name != null ) { + if ( display_name.length() > 0 ) display_name = display_name + " "; + display_name = display_name + middle_name; + } + if ( family_name != null ) { + if ( display_name.length() > 0 ) display_name = display_name + " "; + display_name = display_name + family_name; + } + display_name = display_name.trim(); + if ( display_name.length() < 1 ) display_name = null; + return display_name; + } + + @JsonIgnore + public boolean isInstructor() { + if ( roles == null ) return false; + return roles.contains(ROLE_INSTRUCTOR); } } diff --git a/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/LaunchLIS.java b/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/LaunchLIS.java index c45b0201ec43..e8b8973de4c2 100644 --- a/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/LaunchLIS.java +++ b/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/LaunchLIS.java @@ -1,14 +1,11 @@ package org.tsugi.lti13.objects; -import javax.annotation.Generated; - import java.util.List; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonSerialize; @JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) -@Generated("com.googlecode.jsonschema2pojo") /* "https://purl.imsglobal.org/spec/lti/claim/lis": { @@ -17,9 +14,7 @@ "course_section_sourcedid": "example.edu:SI182-001-F16" } */ -public class LaunchLIS { - - public static final String SCOPE_NAMES_AND_ROLES = "https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly"; +public class LaunchLIS extends org.tsugi.jackson.objects.JacksonBase { @JsonProperty("person_sourcedid") public String person_sourcedid; diff --git a/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/LaunchPresentation.java b/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/LaunchPresentation.java index bfd1b6e339e7..0b5cb8f63598 100644 --- a/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/LaunchPresentation.java +++ b/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/LaunchPresentation.java @@ -1,13 +1,10 @@ package org.tsugi.lti13.objects; -import javax.annotation.Generated; - import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyOrder; import com.fasterxml.jackson.databind.annotation.JsonSerialize; @JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) -@Generated("com.googlecode.jsonschema2pojo") /* "https:\/\/purl.imsglobal.org\/spec\/lti\/claim\/launch_presentation": { @@ -17,7 +14,7 @@ "return_url": "https:\/\/lti-ri.imsglobal.org\/platforms\/7\/returns" }, */ -public class LaunchPresentation { +public class LaunchPresentation extends org.tsugi.jackson.objects.JacksonBase { @JsonProperty("document_target") public String document_target = "iframe"; diff --git a/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/NamesAndRoles.java b/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/NamesAndRoles.java index 7b34ec5720d3..931a2c0c52d1 100644 --- a/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/NamesAndRoles.java +++ b/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/NamesAndRoles.java @@ -3,13 +3,10 @@ import java.util.ArrayList; import java.util.List; -import javax.annotation.Generated; - import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonSerialize; @JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) -@Generated("com.googlecode.jsonschema2pojo") /* "https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice": { @@ -17,7 +14,7 @@ "service_versions" : ["2.0"] } */ -public class NamesAndRoles { +public class NamesAndRoles extends org.tsugi.jackson.objects.JacksonBase { // TODO: What do these mean? public static String SERVICE_VERSION_LTI13 = "2.0"; // Like WTF? But tis true. diff --git a/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/ToolConfiguration.java b/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/OpenIDClientRegistration.java similarity index 75% rename from basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/ToolConfiguration.java rename to basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/OpenIDClientRegistration.java index c5686a55a2ea..b40e59ff718a 100644 --- a/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/ToolConfiguration.java +++ b/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/OpenIDClientRegistration.java @@ -3,18 +3,37 @@ import java.util.ArrayList; import java.util.List; -import javax.annotation.Generated; - import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.databind.annotation.JsonSerialize; @JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) -@Generated("com.googlecode.jsonschema2pojo") + +// https://www.imsglobal.org/spec/lti-dr/v1p0#client-registration-request +// https://openid.net/specs/openid-connect-registration-1_0.html#RegistrationRequest /* - * This is F-ed up. The subitted tool contifuration is different than the retrieved tool configuration. - * - * Request: + +Per https://www.imsglobal.org/spec/lti-dr/v1p0#successful-registration + +As per https://openid.net/specs/openid-connect-registration-1_0.html upon successful registration +a application/json the platform must return a response containing the newly created +client_id. It then echoes the client configuration as recorded in the platform, which +may differ from the configuration passed in the request based on the actual platform's +capabilities and restrictions. + +The registration response may include a Client Configuration Endpoint and a Registration +Access Token to allow a tool to read or update its configuration. + +In the case where a Platform is combining the client registration with the tool's actual +deployment, it may also include the deployment_id in the LTI Tool Configuration section. + +POST /connect/register HTTP/1.1 +Content-Type: application/json +Accept: application/json +Host: server.example.com +Authorization: Bearer eyJhbGciOiJSUzI1NiJ9.eyJ . + { "application_type": "web", "response_types": ["id_token"], @@ -27,15 +46,13 @@ "client_name#ja": "バーチャルガーデン", "jwks_uri": "https://client.example.org/.well-known/jwks.json", "logo_uri": "https://client.example.org/logo.png", - "client_uri": "https://client.example.org", - "client_uri#ja": "https://client.example.org?lang=ja", "policy_uri": "https://client.example.org/privacy", "policy_uri#ja": "https://client.example.org/privacy?lang=ja", "tos_uri": "https://client.example.org/tos", "tos_uri#ja": "https://client.example.org/tos?lang=ja", "token_endpoint_auth_method": "private_key_jwt", - "contacts": ["ve7jtb@example.org", "mary@example.org"], - "scope": "https://purl.imsglobal.org/spec/lti-ags/scope/score", + "contacts": ["ve7jtb@example.org", "mary@example.org"], + "scope": "https://purl.imsglobal.org/spec/lti-ags/scope/score https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly", "https://purl.imsglobal.org/spec/lti-tool-configuration": { "domain": "client.example.org", "description": "Learn Botany by tending to your little (virtual) garden.", @@ -56,7 +73,6 @@ } } - * Response: { "client_id": "709sdfnjkds12", "registration_client_uri": @@ -76,11 +92,12 @@ "scope": "https://purl.imsglobal.org/spec/lti-ags/scope/score", "https://purl.imsglobal.org/spec/lti-tool-configuration": { "domain": "client.example.org", + "deploymemt_id": "12094390", "target_link_uri": "https://client.example.org/lti", "custom_parameters": { "context_history": "$Context.id.history" }, - "claims": ["iss", "sub", "name", "given_name", "family_name"], + "claims": ["iss", "sub"], "messages": [ { "type": "LtiDeepLinkingRequest", @@ -91,8 +108,9 @@ } } - */ -public class ToolConfiguration { +*/ + +public class OpenIDClientRegistration extends org.tsugi.jackson.objects.JacksonBase { // "client_id": "709sdfnjkds12", (returned value) @JsonProperty("client_id") @@ -108,10 +126,12 @@ public class ToolConfiguration { public String application_type; // "response_types": ["id_token"], + @JsonFormat(with = JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY) @JsonProperty("response_types") public List response_types = new ArrayList(); // "grant_types": ["implict", "client_credentials"], + @JsonFormat(with = JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY) @JsonProperty("grant_types") public List grant_types = new ArrayList(); @@ -122,6 +142,7 @@ public class ToolConfiguration { // "redirect_uris": // ["https://client.example.org/callback", // "https://client.example.org/callback2"], + @JsonFormat(with = JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY) @JsonProperty("redirect_uris") public List redirect_uris = new ArrayList(); @@ -158,19 +179,20 @@ public class ToolConfiguration { public String token_endpoint_auth_method; // "contacts": ["ve7jtb@example.org", "mary@example.org"], + @JsonFormat(with = JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY) @JsonProperty("contacts") public List contacts = new ArrayList(); // "scope": "https://purl.imsglobal.org/spec/lti-ags/scope/score", - // TODO: Should this be an array? + // This is a space-separated list of scopes - it is not an array @JsonProperty("scope") public String scope; @JsonProperty("https://purl.imsglobal.org/spec/lti-tool-configuration") public LTIToolConfiguration lti_tool_configuration; - // Constructor - public ToolConfiguration() { + // Constructor for LTI requirements + public OpenIDClientRegistration() { this.application_type = "web"; this.response_types.add("id_token"); this.grant_types.add("implict"); diff --git a/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/PlatformConfiguration.java b/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/OpenIDProviderConfiguration.java similarity index 93% rename from basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/PlatformConfiguration.java rename to basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/OpenIDProviderConfiguration.java index bad01b7cbf21..8c1a4231fd02 100644 --- a/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/PlatformConfiguration.java +++ b/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/OpenIDProviderConfiguration.java @@ -3,16 +3,15 @@ import java.util.ArrayList; import java.util.List; -import javax.annotation.Generated; - import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonSerialize; import org.tsugi.lti13.LTI13ConstantsUtil; @JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) -@Generated("com.googlecode.jsonschema2pojo") +// https://www.imsglobal.org/spec/lti-dr/v1p0#platform-configuration +// https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse /* { "issuer": "https://server.example.com", @@ -33,7 +32,7 @@ ["RS256", "ES256"], "claims_supported": ["sub", "iss", "name", "given_name", "family_name", "nickname", "picture", "email", "locale"], - "https://purl.imsglobal.org/spec/lti-platform-configuration ": { + "https://purl.imsglobal.org/spec/lti-platform-configuration": { "product_family_code": "ExampleLMS", "messages_supported": [ {"type": "LtiResourceLinkRequest"}, @@ -42,7 +41,8 @@ } } */ -public class PlatformConfiguration { + +public class OpenIDProviderConfiguration extends org.tsugi.jackson.objects.JacksonBase { // Platform's issuer value. As per IMS Security Framework and LTI Specification, the Issuer Identifier is // a case-sensitive URL, using the HTTPS scheme, that contains scheme, host, and optionally, port number, @@ -50,6 +50,10 @@ public class PlatformConfiguration { @JsonProperty("issuer") public String issuer; + // Response-only + @JsonProperty("client_id") + public String client_id; + // URL of the OAuth 2.0 Authorization Endpoint. @JsonProperty("authorization_endpoint") public String authorization_endpoint; @@ -104,7 +108,7 @@ public class PlatformConfiguration { public LTIPlatformConfiguration lti_platform_configuration; // Constructor - public PlatformConfiguration() { + public OpenIDProviderConfiguration() { this.token_endpoint_auth_methods_supported.add("private_key_jwt"); this.token_endpoint_auth_signing_alg_values_supported.add("RS256"); this.scopes_supported.add("openid"); diff --git a/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/ResourceLink.java b/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/ResourceLink.java index cb7fe14d09ba..490a3181e7e2 100644 --- a/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/ResourceLink.java +++ b/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/ResourceLink.java @@ -1,12 +1,9 @@ package org.tsugi.lti13.objects; -import javax.annotation.Generated; - import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonSerialize; @JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) -@Generated("com.googlecode.jsonschema2pojo") /* "https://purl.imsglobal.org/spec/lti/claim/resource_link": { @@ -15,7 +12,7 @@ "description": "" }, */ -public class ResourceLink { +public class ResourceLink extends org.tsugi.jackson.objects.JacksonBase { @JsonProperty("id") public String id; diff --git a/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/ToolPlatform.java b/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/ToolPlatform.java index 36e156dfdc4f..fd49d54072a6 100644 --- a/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/ToolPlatform.java +++ b/basiclti/tsugi-util/src/java/org/tsugi/lti13/objects/ToolPlatform.java @@ -1,12 +1,9 @@ package org.tsugi.lti13.objects; -import javax.annotation.Generated; - import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonSerialize; @JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) -@Generated("com.googlecode.jsonschema2pojo") /* "https:\/\/purl.imsglobal.org\/spec\/lti\/claim\/tool_platform": { @@ -18,7 +15,7 @@ "version": "1.0" }, */ -public class ToolPlatform { +public class ToolPlatform extends org.tsugi.jackson.objects.JacksonBase { @JsonProperty("guid") public String guid; diff --git a/basiclti/tsugi-util/src/java/org/tsugi/nrps/objects/Container.java b/basiclti/tsugi-util/src/java/org/tsugi/nrps/objects/Container.java new file mode 100644 index 000000000000..94678afe084d --- /dev/null +++ b/basiclti/tsugi-util/src/java/org/tsugi/nrps/objects/Container.java @@ -0,0 +1,73 @@ +package org.tsugi.nrps.objects; + +import java.util.Map; +import java.util.List; +import java.util.ArrayList; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import org.tsugi.jackson.objects.JacksonBase; + +import org.tsugi.lti13.objects.Context; + +@JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) + +// https://www.imsglobal.org/spec/lti-nrps/v2p0 +/* + +{ +"id" : "https://lms.example.com/sections/2923/memberships?rlid=49566-rkk96", +"context": { + "id": "2923-abc", + "label": "CPS 435", + "title": "CPS 435 Learning Analytics" +}, +"members" : [ + { + "status" : "Active", + "name": "Jane Q. Public", + "picture" : "https://platform.example.edu/jane.jpg", + "given_name" : "Jane", + "family_name" : "Doe", + "middle_name" : "Marie", + "email": "jane@platform.example.edu", + "user_id" : "0ae836b9-7fc9-4060-006f-27b2066ac545", + "lis_person_sourcedid": "59254-6782-12ab", + "lti11_legacy_user_id": "668321221-2879", + "roles": [ + "Instructor", + "Mentor" + ], + "message" : [ + { + "https://purl.imsglobal.org/spec/lti/claim/message_type" : "LtiResourceLinkRequest", + "https://purl.imsglobal.org/spec/lti-bo/claim/basicoutcome" : { + "lis_result_sourcedid": "example.edu:71ee7e42-f6d2-414a-80db-b69ac2defd4", + "lis_outcome_service_url": "https://www.example.com/2344" + }, + "https://purl.imsglobal.org/spec/lti/claim/custom": { + "country" : "Canada", + "user_mobile" : "123-456-7890" + } + } + ] + } +] +} + + */ +public class Container extends JacksonBase { + + @JsonProperty("id") + public String id; + + @JsonProperty("context") + public Context context; + + @JsonProperty("members") + public List members; + +} diff --git a/basiclti/tsugi-util/src/java/org/tsugi/nrps/objects/Member.java b/basiclti/tsugi-util/src/java/org/tsugi/nrps/objects/Member.java new file mode 100644 index 000000000000..b57382419fbb --- /dev/null +++ b/basiclti/tsugi-util/src/java/org/tsugi/nrps/objects/Member.java @@ -0,0 +1,87 @@ +package org.tsugi.nrps.objects; + +import java.util.Map; +import java.util.List; +import java.util.ArrayList; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import org.tsugi.jackson.objects.JacksonBase; + +@JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) + +// https://www.imsglobal.org/spec/lti-nrps/v2p0 +/* + +{ + "status": "Active", + "name": "Jane Q. Public", + "picture": "https://platform.example.edu/jane.jpg", + "given_name": "Jane", + "family_name": "Doe", + "middle_name": "Marie", + "email": "jane@platform.example.edu", + "user_id": "0ae836b9-7fc9-4060-006f-27b2066ac545", + "lis_person_sourcedid": "59254-6782-12ab", + "lti11_legacy_user_id": "668321221-2879", + "roles": [ + "Instructor", + "Mentor" + ], + "message": [ + { + "https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiResourceLinkRequest", + "https://purl.imsglobal.org/spec/lti-bo/claim/basicoutcome": { + "lis_result_sourcedid": "example.edu:71ee7e42-f6d2-414a-80db-b69ac2defd4", + "lis_outcome_service_url": "https://www.example.com/2344" + }, + "https://purl.imsglobal.org/spec/lti/claim/custom": { + "country": "Canada", + "user_mobile": "123-456-7890" + } + } + ] +} + + */ +public class Member extends JacksonBase { + + @JsonProperty("status") + public String status; + public final String STATUS_ACTIVE = "active"; + + @JsonProperty("given_name") + public String given_name; + @JsonProperty("family_name") + public String family_name; + @JsonProperty("middle_name") + public String middle_name; + @JsonProperty("picture") + public String picture; + @JsonProperty("email") + public String email; + @JsonProperty("name") + public String name; + @JsonProperty("locale") + public String locale; + + // a.k.a Subject + @JsonProperty("user_id") + public String user_id; + + @JsonProperty("lis_person_sourcedid") + public String lis_person_sourcedid; + + @JsonProperty("lti11_legacy_user_id") + public String lti11_legacy_user_id; + + @JsonProperty("roles") + public List roles = new ArrayList(); + + @JsonProperty("message") + public List message; + +} diff --git a/basiclti/tsugi-util/src/java/org/tsugi/nrps/objects/MemberMessage.java b/basiclti/tsugi-util/src/java/org/tsugi/nrps/objects/MemberMessage.java new file mode 100644 index 000000000000..0b1dec6fc2c5 --- /dev/null +++ b/basiclti/tsugi-util/src/java/org/tsugi/nrps/objects/MemberMessage.java @@ -0,0 +1,44 @@ +package org.tsugi.nrps.objects; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import org.tsugi.jackson.objects.JacksonBase; + +@JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) + +// https://www.imsglobal.org/spec/lti-nrps/v2p0 +/* + + "message": [ + { + "https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiResourceLinkRequest", + "https://purl.imsglobal.org/spec/lti-bo/claim/basicoutcome": { + "lis_result_sourcedid": "example.edu:71ee7e42-f6d2-414a-80db-b69ac2defd4", + "lis_outcome_service_url": "https://www.example.com/2344" + }, + "https://purl.imsglobal.org/spec/lti/claim/custom": { + "country": "Canada", + "user_mobile": "123-456-7890" + } + } + ] + + */ +public class MemberMessage extends JacksonBase { + + @JsonProperty("https://purl.imsglobal.org/spec/lti/claim/message_type") + public String message_type; + + @JsonProperty("https://purl.imsglobal.org/spec/lti-bo/claim/basicoutcome") + public Map basicoutcome; + + @JsonProperty("https://purl.imsglobal.org/spec/lti/claim/custom") + public Map custom; + +} diff --git a/basiclti/tsugi-util/src/java/org/tsugi/oauth2/objects/AccessToken.java b/basiclti/tsugi-util/src/java/org/tsugi/oauth2/objects/AccessToken.java index cf1466dac795..8114c740bb12 100644 --- a/basiclti/tsugi-util/src/java/org/tsugi/oauth2/objects/AccessToken.java +++ b/basiclti/tsugi-util/src/java/org/tsugi/oauth2/objects/AccessToken.java @@ -1,22 +1,21 @@ package org.tsugi.oauth2.objects; -import javax.annotation.Generated; - import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonSerialize; +/** + * A returned Access Token from a Client-Credentials Grant + */ +// https://www.imsglobal.org/spec/security/v1p0#using-json-web-tokens-with-oauth-2-0-client-credentials-grant // https://tools.ietf.org/html/rfc6750 @JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) -@Generated("com.googlecode.jsonschema2pojo") public class AccessToken { - public static final String BEARER = "Bearer"; - public static final String GRANT_TYPE = "grant_type"; - public static final String CLIENT_ASSERTION = "client_assertion"; - public static final String SCOPE = "scope"; + public static final String TOKEN_TYPE_BEARER = "Bearer"; @JsonProperty("access_token") public String access_token; + // I think the use of "stenotype" in the IMS security spec above is a typo @JsonProperty("token_type") public String token_type; @JsonProperty("expires_in") @@ -27,8 +26,17 @@ public class AccessToken { public String scope; public AccessToken() { - this.token_type = BEARER; + this.token_type = TOKEN_TYPE_BEARER; this.expires_in = new Long(3600); } + // Here it is a comma separated list of scopes - see RFC6750 + public void addScope(String scope) { + if ( this.scope == null ) { + this.scope = scope; + return; + } + this.scope = this.scope + " " + scope; + } + } diff --git a/basiclti/tsugi-util/src/java/org/tsugi/oauth2/objects/ClientAssertion.java b/basiclti/tsugi-util/src/java/org/tsugi/oauth2/objects/ClientAssertion.java new file mode 100644 index 000000000000..8a83f2b29f8b --- /dev/null +++ b/basiclti/tsugi-util/src/java/org/tsugi/oauth2/objects/ClientAssertion.java @@ -0,0 +1,48 @@ +package org.tsugi.oauth2.objects; + +import org.tsugi.lti13.objects.BaseJWT; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; + +/** + * The client_assertion part of a Client-Credentials Grant + * + * This JWT will be signed, compacted, and sent to the oauth_token_url as a POST + * request along with the following values + * + * grant_type=client_credentials + * client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer + * client_assertion=signed_client_assertion_jwt + * scope=blank separated list of scopes + * + * The audience originially was expected to be the token_url on these requests + * But D2L felt like there was supposed to be a separate audience value + * for these tokens in IMS that is part of the contract so we all added + * another column for it :) + * + * Later the IMS working group led by Backboard decided to eventually require the + * deployment_id on this - which I think is a great idea and should have been there + * all along but I still don't get why we need both an audience value + * and a deployment_id - but D2L is rarely wrong on these matters. + * + * https://tools.ietf.org/html/rfc6750 + * https://www.imsglobal.org/spec/security/v1p0#using-json-web-tokens-with-oauth-2-0-client-credentials-grant + */ + +@JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) +public class ClientAssertion extends BaseJWT { + + public static final String GRANT_TYPE = "grant_type"; + public static final String GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials"; + public static final String CLIENT_ASSERTION_TYPE = "client_assertion_type"; + public static final String CLIENT_ASSERTION_TYPE_JWT = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; + public static final String CLIENT_ASSERTION = "client_assertion"; + public static final String SCOPE = "scope"; + + // The IMS LTI Advantage Extension + + @JsonProperty("https://purl.imsglobal.org/spec/lti/claim/deployment_id") + public String deployment_id; + +} diff --git a/basiclti/tsugi-util/src/java/org/tsugi/provision/objects/RegistrationRequest.java b/basiclti/tsugi-util/src/java/org/tsugi/provision/objects/RegistrationRequest.java new file mode 100644 index 000000000000..b79ee093d7a4 --- /dev/null +++ b/basiclti/tsugi-util/src/java/org/tsugi/provision/objects/RegistrationRequest.java @@ -0,0 +1,83 @@ + +package org.tsugi.deeplink.objects; + +import java.util.List; +import java.util.ArrayList; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; + +import org.tsugi.jackson.objects.JacksonBase; + +import org.tsugi.lti13.objects.BaseJWT; + +// https://www.imsglobal.org/spec/lti-dr/v1p0 + +/* + +POST /connect/register HTTP/1.1 +Content-Type: application/json +Accept: application/json +Host: server.example.com +Authorization: Bearer eyJhbGciOiJSUzI1NiJ9.eyJ . + +{ + "application_type": "web", + "response_types": ["id_token"], + "grant_types": ["implict", "client_credentials"], + "initiate_login_uri": "https://client.example.org/lti", + "redirect_uris": + ["https://client.example.org/callback", + "https://client.example.org/callback2"], + "client_name": "Virtual Garden", + "client_name#ja": "バーチャルガーデン", + "jwks_uri": "https://client.example.org/.well-known/jwks.json", + "logo_uri": "https://client.example.org/logo.png", + "policy_uri": "https://client.example.org/privacy", + "policy_uri#ja": "https://client.example.org/privacy?lang=ja", + "tos_uri": "https://client.example.org/tos", + "tos_uri#ja": "https://client.example.org/tos?lang=ja", + "token_endpoint_auth_method": "private_key_jwt", + "contacts": ["ve7jtb@example.org", "mary@example.org"], + "scope": "https://purl.imsglobal.org/spec/lti-ags/scope/score https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly", + "https://purl.imsglobal.org/spec/lti-tool-configuration": { + "domain": "client.example.org", + "description": "Learn Botany by tending to your little (virtual) garden.", + "description#ja": "小さな(仮想)庭に行くことで植物学を学びましょう。", + "target_link_uri": "https://client.example.org/lti", + "custom_parameters": { + "context_history": "$Context.id.history" + }, + "claims": ["iss", "sub", "name", "given_name", "family_name"], + "messages": [ + { + "type": "LtiDeepLinkingRequest", + "target_link_uri": "https://client.example.org/lti/dl", + "label": "Add a virtual garden", + "label#ja": "バーチャルガーデンを追加する", + } + ] + } +} + +*/ + +@JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) + +public class RegistrationRequest extends BaseJWT { + + @JsonProperty("https://purl.imsglobal.org/spec/lti/claim/deployment_id") + public String deployment_id; + @JsonProperty("https://purl.imsglobal.org/spec/lti/claim/message_type") + public String message_type = "LtiDeepLinkingResponse"; + @JsonProperty("https://purl.imsglobal.org/spec/lti/claim/version") + public String version = "1.3.0"; + + @JsonProperty("https://purl.imsglobal.org/spec/lti-dl/claim/content_items") + public List content_items = new ArrayList(); + + @JsonProperty("https://purl.imsglobal.org/spec/lti-dl/claim/data") + public String data; +} + diff --git a/basiclti/tsugi-util/src/java/org/tsugi/provision/objects/RegistrationResponse.java b/basiclti/tsugi-util/src/java/org/tsugi/provision/objects/RegistrationResponse.java new file mode 100644 index 000000000000..8d11d1123cbc --- /dev/null +++ b/basiclti/tsugi-util/src/java/org/tsugi/provision/objects/RegistrationResponse.java @@ -0,0 +1,72 @@ + +package org.tsugi.deeplink.objects; + +import java.util.List; +import java.util.ArrayList; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; + +import org.tsugi.jackson.objects.JacksonBase; + +import org.tsugi.lti13.objects.BaseJWT; + +// https://www.imsglobal.org/spec/lti-dr/v1p0 + +/* + +{ + "client_id": "709sdfnjkds12", + "registration_client_uri": + "https://server.example.com/connect/register?client_id=709sdfnjkds12", + "application_type": "web", + "response_types": ["id_token"], + "grant_types": ["implict", "client_credentials"], + "initiate_login_uri": "https://client.example.org/lti", + "redirect_uris": + ["https://client.example.org/callback", + "https://client.example.org/callback2"], + "client_name": "Virtual Garden", + "jwks_uri": "https://client.example.org/.well-known/jwks.json", + "logo_uri": "https://client.example.org/logo.png", + "token_endpoint_auth_method": "private_key_jwt", + "contacts": ["ve7jtb@example.org", "mary@example.org"], + "scope": "https://purl.imsglobal.org/spec/lti-ags/scope/score", + "https://purl.imsglobal.org/spec/lti-tool-configuration": { + "domain": "client.example.org", + "target_link_uri": "https://client.example.org/lti", + "custom_parameters": { + "context_history": "$Context.id.history" + }, + "claims": ["iss", "sub"], + "messages": [ + { + "type": "LtiDeepLinkingRequest", + "target_link_uri": "https://client.example.org/lti/dl", + "label": "Add a virtual garden" + } + ] + } +} + +*/ + +@JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) + +public class RegistrationResponse extends BaseJWT { + + @JsonProperty("https://purl.imsglobal.org/spec/lti/claim/deployment_id") + public String deployment_id; + @JsonProperty("https://purl.imsglobal.org/spec/lti/claim/message_type") + public String message_type = "LtiDeepLinkingResponse"; + @JsonProperty("https://purl.imsglobal.org/spec/lti/claim/version") + public String version = "1.3.0"; + + @JsonProperty("https://purl.imsglobal.org/spec/lti-dl/claim/content_items") + public List content_items = new ArrayList(); + + @JsonProperty("https://purl.imsglobal.org/spec/lti-dl/claim/data") + public String data; +} + diff --git a/basiclti/tsugi-util/src/java/org/tsugi/shared/objects/Contact.java b/basiclti/tsugi-util/src/java/org/tsugi/shared/objects/Contact.java index 61732bdef8a9..ec0c1f5b5806 100644 --- a/basiclti/tsugi-util/src/java/org/tsugi/shared/objects/Contact.java +++ b/basiclti/tsugi-util/src/java/org/tsugi/shared/objects/Contact.java @@ -6,8 +6,6 @@ import java.util.HashMap; import java.util.Map; -import javax.annotation.Generated; - import com.fasterxml.jackson.annotation.JsonAnyGetter; import com.fasterxml.jackson.annotation.JsonAnySetter; import com.fasterxml.jackson.annotation.JsonProperty; @@ -15,7 +13,6 @@ import com.fasterxml.jackson.databind.annotation.JsonSerialize; @JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) -@Generated("com.googlecode.jsonschema2pojo") @JsonPropertyOrder({ "name", "email" diff --git a/basiclti/tsugi-util/src/java/org/tsugi/shared/objects/DateRange.java b/basiclti/tsugi-util/src/java/org/tsugi/shared/objects/DateRange.java new file mode 100644 index 000000000000..e7f0a9234b85 --- /dev/null +++ b/basiclti/tsugi-util/src/java/org/tsugi/shared/objects/DateRange.java @@ -0,0 +1,27 @@ +package org.tsugi.shared.objects; + +import java.util.ArrayList; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) + +/* https://www.imsglobal.org/spec/lti-ags/v2p0#line-item-service + "something": { + "startDateTime": "2018-03-06T20:05:02Z", + "endDateTime": "2018-04-06T22:05:03Z" + } + */ +// TODO: Where did the scoreUrl and resultUrl end up? +public class DateRange extends org.tsugi.jackson.objects.JacksonBase { + + @JsonProperty("startDateTime") + public String startDateTime; + + @JsonProperty("endDateTime") + public String endDateTime; + +} diff --git a/basiclti/tsugi-util/src/java/org/tsugi/shared/objects/SizedUrl.java b/basiclti/tsugi-util/src/java/org/tsugi/shared/objects/SizedUrl.java new file mode 100644 index 000000000000..48e44dfea501 --- /dev/null +++ b/basiclti/tsugi-util/src/java/org/tsugi/shared/objects/SizedUrl.java @@ -0,0 +1,31 @@ + +package org.tsugi.shared.objects; + +import java.util.ArrayList; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; + +import org.tsugi.jackson.objects.JacksonBase; + +@JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) +@JsonPropertyOrder({ + "url", + "width", + "height" +}) + +public class SizedUrl extends JacksonBase { + + @JsonProperty("url") + public String url; + + @JsonProperty("width") + public Integer width; + + @JsonProperty("height") + public Integer height; + +} + diff --git a/basiclti/tsugi-util/src/java/org/tsugi/shared/objects/TsugiBase.java b/basiclti/tsugi-util/src/java/org/tsugi/shared/objects/TsugiBase.java index d9fb1667a398..19c673107088 100644 --- a/basiclti/tsugi-util/src/java/org/tsugi/shared/objects/TsugiBase.java +++ b/basiclti/tsugi-util/src/java/org/tsugi/shared/objects/TsugiBase.java @@ -6,8 +6,6 @@ import java.util.List; import java.util.ArrayList; -import javax.annotation.Generated; - import com.fasterxml.jackson.annotation.JsonAnyGetter; import com.fasterxml.jackson.annotation.JsonAnySetter; import com.fasterxml.jackson.annotation.JsonProperty; @@ -15,8 +13,6 @@ import com.fasterxml.jackson.databind.annotation.JsonSerialize; @JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) -@Generated("com.googlecode.jsonschema2pojo") - public class TsugiBase extends org.tsugi.jackson.objects.JacksonBase { @JsonProperty("@context") diff --git a/basiclti/tsugi-util/src/java/org/tsugi/time/InstantUtil.java b/basiclti/tsugi-util/src/java/org/tsugi/time/InstantUtil.java new file mode 100644 index 000000000000..d3ee77a42571 --- /dev/null +++ b/basiclti/tsugi-util/src/java/org/tsugi/time/InstantUtil.java @@ -0,0 +1,67 @@ +package org.tsugi.time; + +import java.util.Date; +import java.time.Instant; +import java.text.SimpleDateFormat; +import java.time.format.DateTimeParseException; + +import lombok.extern.slf4j.Slf4j; + +import org.apache.commons.httpclient.util.DateUtil; +import org.apache.commons.httpclient.util.DateParseException; + +@Slf4j +public class InstantUtil { + + public static Instant parseGMTFormats(String dateString) + { + Instant retval = null; + Date d = null; + SimpleDateFormat format = null; + + // https://docs.oracle.com/javase/10/docs/api/java/time/Instant.html#parse(java.lang.CharSequence) + // "2007-12-03T10:15:30.00Z" + try { + retval = Instant.parse(dateString); + if ( retval != null ) return retval; + } catch (DateTimeParseException e) { + // Ignore + } + + // https://stackoverflow.com/questions/1930158/how-to-parse-date-from-http-last-modified-header + // "Wed, 09 Apr 2008 23:55:38 GMT" + try { + format = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz"); + d = format.parse(dateString); + if ( d != null && d.toInstant() != null ) return d.toInstant(); + } catch (Exception e) { + log.debug("Date parse error: {}", dateString); + } + + // https://hc.apache.org/httpclient-legacy/apidocs/org/apache/commons/httpclient/util/DateUtil.html + + try { + // PATTERN_ASCTIME + // Fri Feb 15 14:45:01 2013 + + // PATTERN_RFC1036 + // https://datatracker.ietf.org/doc/html/rfc1036 + // Fri, 19 Nov 82 16:14:55 EST + + // PATTERN_RFC1123 + // https://datatracker.ietf.org/doc/html/rfc1123 + // https://datatracker.ietf.org/doc/html/rfc822#section-5 + // Wed, 02 Oct 2002 08:00:00 EST + // Wed, 02 Oct 2002 13:00:00 GMT + // Wed, 02 Oct 2002 15:00:00 +0200 + + d = DateUtil.parseDate(dateString); + if ( d != null && d.toInstant() != null ) return d.toInstant(); + } catch(DateParseException e) { + log.debug("Date parse error: {}", dateString); + } + + return null; + } + +} diff --git a/basiclti/tsugi-util/src/test/org/tsugi/HACK/MoodleHackTest.java b/basiclti/tsugi-util/src/test/org/tsugi/HACK/MoodleHackTest.java new file mode 100644 index 000000000000..d38980fc9d23 --- /dev/null +++ b/basiclti/tsugi-util/src/test/org/tsugi/HACK/MoodleHackTest.java @@ -0,0 +1,29 @@ +package org.tsugi.HACK; + +import static org.junit.Assert.*; +import org.junit.Before; +import org.junit.Test; + +import lombok.extern.slf4j.Slf4j; +import org.mockito.Mockito; + +import org.tsugi.HACK.HackMoodle; + +@Slf4j +public class MoodleHackTest { + + @Before + public void setUp() throws Exception { + } + + @Test + public void testHackMoodle() { + String one = "{\"https://purl.imsglobal.org/spec/lti-platform-configuration\":{\"messages_supported\":[\"LtiResourceLinkRequest\",\"LtiDeepLinkingRequest\"]}}"; + String two = "{\"https://purl.imsglobal.org/spec/lti-platform-configuration\":{\"messages_supported\":[{\"type\":\"LtiResourceLinkRequest\"},{\"type\":\"LtiDeepLinkingRequest\"}]}}"; + String hack1 = HackMoodle.hackOpenIdConfiguration(one); + hack1 = hack1.replaceAll("\\\\",""); + String hack2 = HackMoodle.hackOpenIdConfiguration(two); + assertEquals(hack1, hack2); + } + +} diff --git a/basiclti/tsugi-util/src/test/org/tsugi/deeplink/DeepLinkResponseObjectTest.java b/basiclti/tsugi-util/src/test/org/tsugi/deeplink/DeepLinkResponseObjectTest.java new file mode 100644 index 000000000000..10f34e32c60c --- /dev/null +++ b/basiclti/tsugi-util/src/test/org/tsugi/deeplink/DeepLinkResponseObjectTest.java @@ -0,0 +1,96 @@ +package org.tsugi.lti13; + +import static org.junit.Assert.*; + +import org.junit.Test; +import org.junit.Before; + +import java.io.InputStream; +import org.apache.commons.io.IOUtils; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.tsugi.jackson.JacksonUtil; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.tsugi.deeplink.objects.*; +import org.tsugi.deeplink.objects.DeepLinkResponse; +import org.tsugi.shared.objects.*; + +public class DeepLinkResponseObjectTest { + + String sampleResponse = null; + String sampleLTILinkItem = null; + + @Before + public void setUp() throws Exception { + InputStream resourceAsStream = this.getClass().getClassLoader().getResourceAsStream("deeplink/sample_response.json"); + sampleResponse = IOUtils.toString(resourceAsStream, "UTF-8"); + resourceAsStream = this.getClass().getClassLoader().getResourceAsStream("deeplink/sample_ltiresourcelink.json"); + sampleLTILinkItem = IOUtils.toString(resourceAsStream, "UTF-8"); + } + + @Test + public void testParse() throws JsonProcessingException { + assertNotNull(sampleResponse); + + ObjectMapper mapper = new ObjectMapper(); + DeepLinkResponse dlr = mapper.readValue(sampleResponse, DeepLinkResponse.class); + assertNotNull(dlr); + assertEquals(dlr.audience, "https://platform.example.org"); + assertEquals(dlr.deployment_id, "07940580-b309-415e-a37c-914d387c1150"); + assertEquals(dlr.message_type, "LtiDeepLinkingResponse"); + assertEquals(dlr.data, "csrftoken:c7fbba78-7b75-46e3-9201-11e6d5f36f53"); + assertEquals(dlr.content_items.size(), 7); + assertEquals(dlr.content_items.get(4).type, "ltiResourceLink"); + + assertNotNull(sampleLTILinkItem); + LtiResourceLink rl = mapper.readValue(sampleLTILinkItem, LtiResourceLink.class); + assertNotNull(rl); + } + + @Test + public void testOne() throws JsonProcessingException { + LtiResourceLink ltiResourceLink = new LtiResourceLink(); + assertEquals(ltiResourceLink.type, "ltiResourceLink"); + ltiResourceLink.title = "A title"; + ltiResourceLink.url = "https://www.dj4e.com/mod/tdiscuss/"; + SizedUrl thumbnail = new SizedUrl(); + thumbnail.url = "http://www.sakailms.org/thumbnail.png"; + thumbnail.height = 142; + thumbnail.width = 142; + ltiResourceLink.thumbnail = thumbnail; + SizedUrl icon = new SizedUrl(); + icon.url = "http://www.sakailms.org/icon.png"; + icon.height = 42; + icon.width = 42; + ltiResourceLink.icon = icon; + String out = JacksonUtil.prettyPrint(ltiResourceLink); + assertFalse(out.contains("TARGETNAME")); + } + + @Test + public void testResponse() throws JsonProcessingException { + org.tsugi.deeplink.objects.DeepLinkResponse dlr = new org.tsugi.deeplink.objects.DeepLinkResponse(); + assertNotNull(dlr.issued); + assertNotNull(dlr.expires); + assertNotNull(dlr.nonce); + + LtiResourceLink ltiResourceLink = new LtiResourceLink(); + assertEquals(ltiResourceLink.type, "ltiResourceLink"); + ltiResourceLink.title = "A title"; + ltiResourceLink.url = "https://www.dj4e.com/mod/tdiscuss/"; + ltiResourceLink.setWindowTarget("_blank"); + dlr.content_items.add(ltiResourceLink); + + String out = JacksonUtil.prettyPrint(dlr); + assertTrue(out.contains("nonce")); + assertTrue(out.contains("\"https://purl.imsglobal.org/spec/lti-dl/claim/content_items\" : [ {")); + assertTrue(out.contains("targetName")); + assertTrue(out.contains("window")); + assertTrue(out.contains("_blank")); + } +} diff --git a/basiclti/tsugi-util/src/test/org/tsugi/http/HttpUtilTest.java b/basiclti/tsugi-util/src/test/org/tsugi/http/HttpUtilTest.java new file mode 100644 index 000000000000..d4e63e8c6006 --- /dev/null +++ b/basiclti/tsugi-util/src/test/org/tsugi/http/HttpUtilTest.java @@ -0,0 +1,23 @@ +package org.tsugi.http; + +import java.util.Map; +import java.util.TreeMap; + +import static org.junit.Assert.*; + +import org.junit.Test; + +import org.tsugi.http.HttpUtil; + +public class HttpUtilTest { + + @Test + public void testOne() { + String url = "https://www.sakailms.org?search=plus"; + Map parms = new TreeMap(); + parms.put("funky", ")(*&^%$#$%^&*U("); + parms.put("town", "burgers"); + String newURL = HttpUtil.augmentGetURL(url, parms); + assertEquals(newURL, "https://www.sakailms.org?search=plus&funky=%29%28*%26%5E%25%24%23%24%25%5E%26*U%28&town=burgers"); + } +} diff --git a/basiclti/tsugi-util/src/test/org/tsugi/lti13/LTI13AccessTokenUtilTest.java b/basiclti/tsugi-util/src/test/org/tsugi/lti13/LTI13AccessTokenUtilTest.java new file mode 100644 index 000000000000..a4959d85d4f5 --- /dev/null +++ b/basiclti/tsugi-util/src/test/org/tsugi/lti13/LTI13AccessTokenUtilTest.java @@ -0,0 +1,114 @@ +package org.tsugi.lti13; + +import java.util.Map; + +import static org.junit.Assert.*; + +import org.junit.Before; +import org.junit.Test; + +import java.security.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.Base64; + +import org.tsugi.oauth2.objects.ClientAssertion; +import org.tsugi.lti13.LTI13ConstantsUtil; + +import io.jsonwebtoken.Jwts; + +public class LTI13AccessTokenUtilTest { + + @Before + public void setUp() throws Exception { + } + + @Test + public void testOne() throws NoSuchAlgorithmException, NoSuchProviderException { + KeyPair keyPair = LTI13Util.generateKeyPair(); + String clientId = "client-was-here"; + String deploymentId = "deployment-id-42"; + String tokenAudience = null; + StringBuffer dbs = new StringBuffer(); + Map retval = LTI13AccessTokenUtil.getClientAssertion( + new String[] { + LTI13ConstantsUtil.SCOPE_RESULT_READONLY, + LTI13ConstantsUtil.SCOPE_LINEITEM_READONLY, + LTI13ConstantsUtil.SCOPE_NAMES_AND_ROLES + }, + keyPair, clientId, deploymentId, tokenAudience, dbs); + assertNotNull(retval); + assertEquals(retval.get(ClientAssertion.GRANT_TYPE), ClientAssertion.GRANT_TYPE_CLIENT_CREDENTIALS); + assertEquals(retval.get(ClientAssertion.CLIENT_ASSERTION_TYPE), ClientAssertion.CLIENT_ASSERTION_TYPE_JWT); + assertNotNull(retval.get(ClientAssertion.CLIENT_ASSERTION)); + + assertTrue(((String)retval.get(ClientAssertion.SCOPE)).contains("https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly")); + assertTrue(((String)retval.get(ClientAssertion.SCOPE)).contains("https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly")); + assertTrue(((String)retval.get(ClientAssertion.SCOPE)).contains("https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly")); + + assertFalse(((String)retval.get(ClientAssertion.SCOPE)).contains("https://purl.imsglobal.org/spec/lti-ags/scope/score")); + + String debugStr = dbs.toString(); + assertTrue(debugStr.contains("kid=")); + } + + @Test + public void testTwo() throws NoSuchAlgorithmException, NoSuchProviderException { + KeyPair keyPair = LTI13Util.generateKeyPair(); + String clientId = "client-was-here"; + String deploymentId = "deployment-id-42"; + String tokenAudience = null; + StringBuffer dbs = new StringBuffer(); + + Map retval = LTI13AccessTokenUtil.getScoreAssertion(keyPair, clientId, deploymentId, tokenAudience, dbs); + assertNotNull(retval); + assertEquals(retval.get(ClientAssertion.GRANT_TYPE), ClientAssertion.GRANT_TYPE_CLIENT_CREDENTIALS); + assertEquals(retval.get(ClientAssertion.CLIENT_ASSERTION_TYPE), ClientAssertion.CLIENT_ASSERTION_TYPE_JWT); + assertNotNull(retval.get(ClientAssertion.CLIENT_ASSERTION)); + assertTrue(((String)retval.get(ClientAssertion.SCOPE)).contains("https://purl.imsglobal.org/spec/lti-ags/scope/lineitem")); + assertTrue(((String)retval.get(ClientAssertion.SCOPE)).contains("https://purl.imsglobal.org/spec/lti-ags/scope/score")); + assertTrue(((String)retval.get(ClientAssertion.SCOPE)).contains("https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly")); + + assertFalse(((String)retval.get(ClientAssertion.SCOPE)).contains("https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly")); + assertFalse(((String)retval.get(ClientAssertion.SCOPE)).contains("https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly")); + + String debugStr = dbs.toString(); + assertTrue(debugStr.contains("kid=")); + assertTrue(debugStr.contains("https://purl.imsglobal.org/spec/lti-ags/scope/lineitem")); + + dbs = new StringBuffer(); + retval = LTI13AccessTokenUtil.getNRPSAssertion(keyPair, clientId, deploymentId, tokenAudience, dbs); + assertNotNull(retval); + assertEquals(retval.get(ClientAssertion.GRANT_TYPE), ClientAssertion.GRANT_TYPE_CLIENT_CREDENTIALS); + assertEquals(retval.get(ClientAssertion.CLIENT_ASSERTION_TYPE), ClientAssertion.CLIENT_ASSERTION_TYPE_JWT); + assertNotNull(retval.get(ClientAssertion.CLIENT_ASSERTION)); + assertTrue(((String)retval.get(ClientAssertion.SCOPE)).contains("https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly")); + + assertFalse(((String)retval.get(ClientAssertion.SCOPE)).contains("https://purl.imsglobal.org/spec/lti-ags/scope/lineitem")); + assertFalse(((String)retval.get(ClientAssertion.SCOPE)).contains("https://purl.imsglobal.org/spec/lti-ags/scope/score")); + assertFalse(((String)retval.get(ClientAssertion.SCOPE)).contains("https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly")); + assertFalse(((String)retval.get(ClientAssertion.SCOPE)).contains("https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly")); + + debugStr = dbs.toString(); + assertTrue(debugStr.contains("kid=")); + assertTrue(debugStr.contains("https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly")); + + dbs = new StringBuffer(); + retval = LTI13AccessTokenUtil.getLineItemsAssertion(keyPair, clientId, deploymentId, tokenAudience, dbs); + assertNotNull(retval); + assertEquals(retval.get(ClientAssertion.GRANT_TYPE), ClientAssertion.GRANT_TYPE_CLIENT_CREDENTIALS); + assertEquals(retval.get(ClientAssertion.CLIENT_ASSERTION_TYPE), ClientAssertion.CLIENT_ASSERTION_TYPE_JWT); + assertNotNull(retval.get(ClientAssertion.CLIENT_ASSERTION)); + assertTrue(((String)retval.get(ClientAssertion.SCOPE)).contains("https://purl.imsglobal.org/spec/lti-ags/scope/lineitem")); + + assertFalse(((String)retval.get(ClientAssertion.SCOPE)).contains("https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly")); + assertFalse(((String)retval.get(ClientAssertion.SCOPE)).contains("https://purl.imsglobal.org/spec/lti-ags/scope/score")); + assertFalse(((String)retval.get(ClientAssertion.SCOPE)).contains("https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly")); + assertFalse(((String)retval.get(ClientAssertion.SCOPE)).contains("https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly")); + + debugStr = dbs.toString(); + assertTrue(debugStr.contains("kid=")); + assertTrue(debugStr.contains("https://purl.imsglobal.org/spec/lti-ags/scope/lineitem")); + } + +} diff --git a/basiclti/tsugi-util/src/test/org/tsugi/lti13/LTI13ObjectTest.java b/basiclti/tsugi-util/src/test/org/tsugi/lti13/LTI13ObjectTest.java index 9864ef32dcef..49cf08eec5e0 100644 --- a/basiclti/tsugi-util/src/test/org/tsugi/lti13/LTI13ObjectTest.java +++ b/basiclti/tsugi-util/src/test/org/tsugi/lti13/LTI13ObjectTest.java @@ -12,9 +12,13 @@ import org.tsugi.lti13.objects.BasicOutcome; import org.tsugi.lti13.objects.Endpoint; import org.tsugi.lti13.objects.LTI11Transition; -import org.tsugi.lti13.objects.PlatformConfiguration; +import org.tsugi.lti13.objects.OpenIDProviderConfiguration; import org.tsugi.lti13.objects.LTIPlatformConfiguration; -import org.tsugi.lti13.objects.LTIPlatformMessage; +import org.tsugi.lti13.objects.LTILaunchMessage; +import org.tsugi.lti13.objects.OpenIDClientRegistration; +import org.tsugi.lti13.objects.LTIToolConfiguration; + +import com.fasterxml.jackson.databind.ObjectMapper; import org.tsugi.lti13.LTICustomVars; @@ -122,7 +126,7 @@ public void testOne() throws com.fasterxml.jackson.core.JsonProcessingException .signWith(key) .compact(); - assertEquals(2254, jws.length()); + assertEquals(2376, jws.length()); Matcher m = base64url_pattern.matcher(jws); good = m.find(); if (!good) { @@ -148,16 +152,16 @@ public void testOne() throws com.fasterxml.jackson.core.JsonProcessingException @Test public void testTwo() { LTIPlatformConfiguration lpc = new LTIPlatformConfiguration(); - LTIPlatformMessage mp = new LTIPlatformMessage(); + LTILaunchMessage mp = new LTILaunchMessage(); mp.type = LaunchJWT.MESSAGE_TYPE_LAUNCH; lpc.messages_supported.add(mp); - mp = new LTIPlatformMessage(); + mp = new LTILaunchMessage(); mp.type = LaunchJWT.MESSAGE_TYPE_DEEP_LINK; lpc.messages_supported.add(mp); lpc.variables.add(LTICustomVars.USER_ID); lpc.variables.add(LTICustomVars.PERSON_EMAIL_PRIMARY); - PlatformConfiguration pc = new PlatformConfiguration(); + OpenIDProviderConfiguration pc = new OpenIDProviderConfiguration(); pc.lti_platform_configuration = lpc; String pcs = JacksonUtil.toString(pc); @@ -175,29 +179,70 @@ public void testTwo() { public void testThree() throws com.fasterxml.jackson.core.JsonProcessingException { LaunchJWT lj = new LaunchJWT(); + assertNotNull(lj.nonce); + assertNotNull(lj.expires); + assertNotNull(lj.issued); + lj.nonce = null; // Since we can't match random stuff + lj.expires = null; // Since we can't match random stuff + lj.issued = null; // Since we can't match random stuff + lj.jti = null; // Since we can't match random stuff String expected = "{\"https://purl.imsglobal.org/spec/lti/claim/message_type\":\"LtiResourceLinkRequest\",\"https://purl.imsglobal.org/spec/lti/claim/version\":\"1.3.0\",\"https://purl.imsglobal.org/spec/lti/claim/roles\":[],\"https://purl.imsglobal.org/spec/lti/claim/role_scope_mentor\":[],\"https://purl.imsglobal.org/spec/lti/claim/launch_presentation\":{\"document_target\":\"iframe\"}}"; String ljs = JacksonUtil.toString(lj); assertEquals(expected,ljs); lj = new LaunchJWT(LaunchJWT.MESSAGE_TYPE_LAUNCH); + lj.nonce = null; // Since we can't match random stuff + lj.expires = null; // Since we can't match random stuff + lj.issued = null; // Since we can't match random stuff + lj.jti = null; // Since we can't match random stuff ljs = JacksonUtil.toString(lj); assertEquals(expected,ljs); lj = new LaunchJWT(LaunchJWT.MESSAGE_TYPE_DEEP_LINK); + lj.nonce = null; // Since we can't match random stuff + lj.expires = null; // Since we can't match random stuff + lj.issued = null; // Since we can't match random stuff + lj.jti = null; // Since we can't match random stuff ljs = JacksonUtil.toString(lj); String expected2 = expected.replaceAll("LtiResourceLinkRequest", "LtiDeepLinkingRequest"); assertEquals(expected2,ljs); lj = new LaunchJWT(LaunchJWT.MESSAGE_TYPE_LTI_DATA_PRIVACY_LAUNCH_REQUEST); + lj.nonce = null; // Since we can't match random stuff + lj.expires = null; // Since we can't match random stuff + lj.issued = null; // Since we can't match random stuff + lj.jti = null; // Since we can't match random stuff ljs = JacksonUtil.toString(lj); expected2 = expected.replaceAll("LtiResourceLinkRequest", "DataPrivacyLaunchRequest"); assertEquals(expected2,ljs); lj = new LaunchJWT(LaunchJWT.MESSAGE_TYPE_LTI_SUBMISSION_REVIEW_REQUEST); + lj.nonce = null; // Since we can't match random stuff + lj.expires = null; // Since we can't match random stuff + lj.issued = null; // Since we can't match random stuff + lj.jti = null; // Since we can't match random stuff ljs = JacksonUtil.toString(lj); expected2 = expected.replaceAll("LtiResourceLinkRequest", "LtiSubmissionReviewRequest"); assertEquals(expected2,ljs); } + @Test + public void testfour() throws com.fasterxml.jackson.core.JsonProcessingException { + LTIToolConfiguration ltc = new LTIToolConfiguration(); + OpenIDClientRegistration cr = new OpenIDClientRegistration(); + cr.lti_tool_configuration = ltc; + + String crs = JacksonUtil.toString(cr); + + // Lets test string / array equivalence! + String first = "{ \"contacts\": \"a@b.com\" }"; + String second = "{ \"contacts\": [ \"a@b.com\"] }"; + + ObjectMapper mapper = JacksonUtil.getLaxObjectMapper(); + OpenIDClientRegistration ocr1 = mapper.readValue(first, OpenIDClientRegistration.class); + OpenIDClientRegistration ocr2 = mapper.readValue(second, OpenIDClientRegistration.class); + assertTrue(ocr1.prettyPrintLog().contains("a@b.com")); + assertEquals(ocr1.prettyPrintLog(), ocr2.prettyPrintLog()); + } } diff --git a/basiclti/tsugi-util/src/test/org/tsugi/lti13/LTI13UtilTest.java b/basiclti/tsugi-util/src/test/org/tsugi/lti13/LTI13UtilTest.java index fc41a5c00fda..8b83d009386d 100644 --- a/basiclti/tsugi-util/src/test/org/tsugi/lti13/LTI13UtilTest.java +++ b/basiclti/tsugi-util/src/test/org/tsugi/lti13/LTI13UtilTest.java @@ -361,4 +361,16 @@ public void testTimeStampSign() { assertFalse(good); } + @Test + public void textScoresUrl() { + assertNull(LTI13Util.getScoreUrlForLineItem(null)); + assertEquals(LTI13Util.getScoreUrlForLineItem(""), "/scores"); + assertEquals(LTI13Util.getScoreUrlForLineItem("tsugi"), "tsugi/scores"); + assertEquals(LTI13Util.getScoreUrlForLineItem("tsugi?x=2"), "tsugi/scores?x=2"); + assertEquals(LTI13Util.getScoreUrlForLineItem("tsugi?"), "tsugi/scores?"); + assertEquals(LTI13Util.getScoreUrlForLineItem("?"), "/scores?"); + assertEquals(LTI13Util.getScoreUrlForLineItem("?x=2"), "/scores?x=2"); + } + + } diff --git a/basiclti/tsugi-util/src/test/org/tsugi/nrps/NRPSTest.java b/basiclti/tsugi-util/src/test/org/tsugi/nrps/NRPSTest.java new file mode 100644 index 000000000000..06253619683f --- /dev/null +++ b/basiclti/tsugi-util/src/test/org/tsugi/nrps/NRPSTest.java @@ -0,0 +1,78 @@ +package org.tsugi.nrps; +import static org.junit.Assert.*; + +import org.junit.Before; +import org.junit.Test; + +import java.util.Properties; +import java.io.InputStream; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.apache.commons.io.IOUtils; + +import lombok.extern.slf4j.Slf4j; + +import org.tsugi.jackson.JacksonUtil; + +import org.tsugi.nrps.objects.Container; +import org.tsugi.nrps.objects.Member; +import org.tsugi.nrps.objects.MemberMessage; + +// https://www.imsglobal.org/spec/lti-nrps/v2p0 + +@Slf4j +public class NRPSTest { + + String sampleMemberMessage = null; + String sampleMember = null; + String sampleContainer = null; + + @Before + public void setUp() throws Exception { + InputStream resourceAsStream = this.getClass().getClassLoader().getResourceAsStream("nrps/sample_member_message.json"); + sampleMemberMessage = IOUtils.toString(resourceAsStream, "UTF-8"); + resourceAsStream = this.getClass().getClassLoader().getResourceAsStream("nrps/sample_member.json"); + sampleMember = IOUtils.toString(resourceAsStream, "UTF-8"); + resourceAsStream = this.getClass().getClassLoader().getResourceAsStream("nrps/sample_container.json"); + sampleContainer = IOUtils.toString(resourceAsStream, "UTF-8"); + } + + @Test + public void testLoad() throws JsonProcessingException { + assertNotNull(sampleMemberMessage); + // Get a picky ObjectMapper + ObjectMapper mapper = new ObjectMapper(); + MemberMessage memberMessage = mapper.readValue(sampleMemberMessage, MemberMessage.class); + assertNotNull(memberMessage); + assertNotNull(memberMessage.custom); + assertEquals(memberMessage.custom.size(), 2); + assertNotNull(memberMessage.basicoutcome); + assertEquals(memberMessage.basicoutcome.size(), 2); + + assertNotNull(sampleMember); + Member member = mapper.readValue(sampleMember, Member.class); + assertNotNull(member); + assertNotNull(member.name); + assertNotNull(member.email); + assertEquals(member.email, "privacy@sakaiger.com"); + assertEquals(member.message.get(0).custom.size(), 2); + assertEquals(member.message.get(0).custom.get("user_mobile"), "123-456-7890"); + assertEquals(member.message.get(0).basicoutcome.size(), 2); + + assertNotNull(sampleContainer); + Container container = mapper.readValue(sampleContainer, Container.class); + assertNotNull(container); + assertNotNull(container.members); + assertNotNull(container.members.get(0)); + assertNotNull(container.members.get(0).name); + assertNotNull(container.members.get(0).email); + assertEquals(container.members.get(0).email, "privacy@sakaiger.com"); + assertEquals(container.members.get(0).message.get(0).custom.size(), 2); + assertEquals(container.members.get(0).message.get(0).custom.get("user_mobile"), "123-456-7890"); + assertEquals(container.members.get(0).message.get(0).basicoutcome.size(), 2); + } + +} + diff --git a/basiclti/tsugi-util/src/test/org/tsugi/oauth2/OAUTH2ObjectTest.java b/basiclti/tsugi-util/src/test/org/tsugi/oauth2/OAUTH2ObjectTest.java index 6d9b6c50f442..1374f3cf7355 100644 --- a/basiclti/tsugi-util/src/test/org/tsugi/oauth2/OAUTH2ObjectTest.java +++ b/basiclti/tsugi-util/src/test/org/tsugi/oauth2/OAUTH2ObjectTest.java @@ -3,19 +3,38 @@ import static org.junit.Assert.*; import org.junit.Test; +import org.junit.Before; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; -import org.tsugi.jackson.JacksonUtil; import org.tsugi.oauth2.objects.AccessToken; +import org.tsugi.oauth2.objects.ClientAssertion; + +import java.io.InputStream; +import org.apache.commons.io.IOUtils; + +import org.tsugi.jackson.JacksonUtil; + +// https://www.imsglobal.org/spec/security/v1p0/ public class OAUTH2ObjectTest { + String sampleToken = null; + + @Before + public void setUp() throws Exception { + InputStream resourceAsStream = this.getClass().getClassLoader().getResourceAsStream("oauth2/sample_access_token.json"); + sampleToken = IOUtils.toString(resourceAsStream, "UTF-8"); + } + @Test public void testOne() throws com.fasterxml.jackson.core.JsonProcessingException { AccessToken at = new AccessToken(); at.access_token = "42"; at.expires_in = new Long(3600); - at.token_type = AccessToken.BEARER; + at.token_type = AccessToken.TOKEN_TYPE_BEARER; at.scope = "yada scope"; String atsp = JacksonUtil.prettyPrint(at); @@ -40,4 +59,29 @@ public void testOne() throws com.fasterxml.jackson.core.JsonProcessingException } assertTrue(good); } + + @Test + public void testTwo() throws com.fasterxml.jackson.core.JsonProcessingException { + assertNotNull(sampleToken); + ObjectMapper mapper = new ObjectMapper(); + AccessToken accessToken = mapper.readValue(sampleToken, AccessToken.class); + assertNotNull(accessToken); + } + + @Test + public void testThree() throws JsonProcessingException { + ClientAssertion ca = new ClientAssertion(); + ca.issuer = "testissuer"; + ca.audience = "why-viktor-why"; + ca.deployment_id = "thanks-eric-nice-feature"; + } +} + +/* +{ + "access_token" : "dkj4985kjaIAJDJ89kl8rkn5", + "token_type" : "bearer", + "expires_in" : 3600, + "scope" : "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem https://purl.imsglobal.org/spec/lti-ags/scope/result/read" } +*/ diff --git a/basiclti/tsugi-util/src/test/org/tsugi/time/InstantUtilTest.java b/basiclti/tsugi-util/src/test/org/tsugi/time/InstantUtilTest.java new file mode 100644 index 000000000000..b428e88c8f87 --- /dev/null +++ b/basiclti/tsugi-util/src/test/org/tsugi/time/InstantUtilTest.java @@ -0,0 +1,65 @@ +package org.tsugi.time; + +import static org.junit.Assert.*; + +import org.junit.Before; +import org.junit.Test; + +import java.util.Date; +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.time.Instant; + +import org.tsugi.time.InstantUtil; + +public class InstantUtilTest { + + @Before + public void setUp() throws Exception { + } + + @Test + public void testInstantParse() throws Exception { + Instant i = InstantUtil.parseGMTFormats("bob"); + assertNull(i); + + String txt = "2007-12-03T10:15:30.00Z"; + i = InstantUtil.parseGMTFormats(txt); + assertEquals(i.toString(), "2007-12-03T10:15:30Z"); + + txt = "Wed, 09 Apr 2008 23:55:38 GMT"; + i = InstantUtil.parseGMTFormats(txt); + assertEquals(i.toString(), "2008-04-09T23:55:38Z"); + + txt = "Fri Feb 15 14:45:01 2013"; + i = InstantUtil.parseGMTFormats(txt); + assertEquals(i.toString(), "2013-02-15T14:45:01Z"); + + // This one is funky - but not really worth supporting or agonizing over + // Keep it here to see if anything changes beneath us + txt = "Fri, 19 Nov 82 16:14:55 EST"; + i = InstantUtil.parseGMTFormats(txt); + assertEquals(i.toString(), "0082-11-17T21:14:55Z"); + + txt = "Wed, 02 Oct 2002 08:00:00 EST"; + i = InstantUtil.parseGMTFormats(txt); + assertEquals(i.toString(), "2002-10-02T13:00:00Z"); + + txt = "Wed, 02 Oct 2002 13:00:00 GMT"; + assertEquals(i.toString(), "2002-10-02T13:00:00Z"); + + i = InstantUtil.parseGMTFormats(txt); + assertEquals(i.toString(), "2002-10-02T13:00:00Z"); + + txt = "Wed, 02 Oct 2002 15:00:00 +0200"; + i = InstantUtil.parseGMTFormats(txt); + assertEquals(i.toString(), "2002-10-02T13:00:00Z"); + + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After + // Retry-After: Wed, 21 Oct 2015 07:28:00 GMT + txt = "Wed, 21 Oct 2015 07:28:00 GMT"; + i = InstantUtil.parseGMTFormats(txt); + assertEquals(i.toString(), "2015-10-21T07:28:00Z"); + } + +} diff --git a/basiclti/tsugi-util/src/test/resources/deeplink/deep_link_settings.json b/basiclti/tsugi-util/src/test/resources/deeplink/deep_link_settings.json new file mode 100644 index 000000000000..eee9c8575f76 --- /dev/null +++ b/basiclti/tsugi-util/src/test/resources/deeplink/deep_link_settings.json @@ -0,0 +1,12 @@ +{ + "accept_types": ["link", "file", "html", "ltiResourceLink", "image"], + "accept_media_types": "image/*,text/html", + "accept_presentation_document_targets": ["iframe", "window", "embed"], + "accept_multiple": true, + "accept_lineitem": false, + "auto_create": true, + "title": "This is the default title", + "text": "This is the default text", + "data": "Some random opaque data that MUST be sent back", + "deep_link_return_url": "https://platform.example/deep_links" +} diff --git a/basiclti/tsugi-util/src/test/resources/deeplink/sample_ltiresourcelink.json b/basiclti/tsugi-util/src/test/resources/deeplink/sample_ltiresourcelink.json new file mode 100644 index 000000000000..c4122e719951 --- /dev/null +++ b/basiclti/tsugi-util/src/test/resources/deeplink/sample_ltiresourcelink.json @@ -0,0 +1,39 @@ +{ + "type": "ltiResourceLink", + "title": "A title", + "text": "This is a link to an activity that will be graded", + "url": "https://lti.example.com/launchMe", + "icon": { + "url": "https://lti.example.com/image.jpg", + "width": 100, + "height": 100 + }, + "thumbnail": { + "url": "https://lti.example.com/thumb.jpg", + "width": 90, + "height": 90 + }, + "lineItem": { + "scoreMaximum": 87, + "label": "Chapter 12 quiz", + "resourceId": "xyzpdq1234", + "tag": "originality" + }, + "available": { + "startDateTime": "2018-02-06T20:05:02Z", + "endDateTime": "2018-03-07T20:05:02Z" + }, + "submission": { + "endDateTime": "2018-03-06T20:05:02Z" + }, + "custom": { + "quiz_id": "az-123", + "duedate": "$Resource.submission.endDateTime" + }, + "window": { + "targetName": "examplePublisherContent" + }, + "iframe": { + "height": 890 + } +} diff --git a/basiclti/tsugi-util/src/test/resources/deeplink/sample_response.json b/basiclti/tsugi-util/src/test/resources/deeplink/sample_response.json new file mode 100644 index 000000000000..e4ca39c2dc18 --- /dev/null +++ b/basiclti/tsugi-util/src/test/resources/deeplink/sample_response.json @@ -0,0 +1,106 @@ +{ + "iss": "962fa4d8-bcbf-49a0-94b2-2de05ad274af", + "aud": "https://platform.example.org", + "exp": 1510185728, + "iat": 1510185228, + "nonce": "fc5fdc6d-5dd6-47f4-b2c9-5d1216e9b771", + "azp": "962fa4d8-bcbf-49a0-94b2-2de05ad274af", + "https://purl.imsglobal.org/spec/lti/claim/deployment_id": "07940580-b309-415e-a37c-914d387c1150", + "https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiDeepLinkingResponse", + "https://purl.imsglobal.org/spec/lti/claim/version": "1.3.0", + "https://purl.imsglobal.org/spec/lti-dl/claim/content_items": [ + { + "type": "link", + "title": "My Home Page", + "url": "https://something.example.com/page.html", + "icon": { + "url": "https://lti.example.com/image.jpg", + "width": 100, + "height": 100 + }, + "thumbnail": { + "url": "https://lti.example.com/thumb.jpg", + "width": 90, + "height": 90 + } + }, + { + "type": "html", + "html": "

A Custom Title

" + }, + { + "type": "link", + "url": "https://www.youtube.com/watch?v=corV3-WsIro", + "embed": { + "html": "" + }, + "window": { + "targetName": "youtube-corV3-WsIro", + "windowFeatures": "height=560,width=315,menubar=no" + }, + "iframe": { + "width": 560, + "height": 315, + "src": "https://www.youtube.com/embed/corV3-WsIro" + } + }, + { + "type": "image", + "url": "https://www.example.com/image.png", + "https://www.example.com/resourceMetadata": { + "license": "CCBY4.0" + } + }, + { + "type": "ltiResourceLink", + "title": "A title", + "text": "This is a link to an activity that will be graded", + "url": "https://lti.example.com/launchMe", + "icon": { + "url": "https://lti.example.com/image.jpg", + "width": 100, + "height": 100 + }, + "thumbnail": { + "url": "https://lti.example.com/thumb.jpg", + "width": 90, + "height": 90 + }, + "lineItem": { + "scoreMaximum": 87, + "label": "Chapter 12 quiz", + "resourceId": "xyzpdq1234", + "tag": "originality" + }, + "available": { + "startDateTime": "2018-02-06T20:05:02Z", + "endDateTime": "2018-03-07T20:05:02Z" + }, + "submission": { + "endDateTime": "2018-03-06T20:05:02Z" + }, + "custom": { + "quiz_id": "az-123", + "duedate": "$Resource.submission.endDateTime" + }, + "window": { + "targetName": "examplePublisherContent" + }, + "iframe": { + "height": 890 + } + }, + { + "type": "file", + "title": "A file like a PDF that is my assignment submissions", + "url": "https://my.example.com/assignment1.pdf", + "mediaType": "application/pdf", + "expiresAt": "2018-03-06T20:05:02Z" + }, + { + "type": "https://www.example.com/custom_type", + "data": "somedata" + } + ], + "https://purl.imsglobal.org/spec/lti-dl/claim/data": "csrftoken:c7fbba78-7b75-46e3-9201-11e6d5f36f53" +} diff --git a/basiclti/tsugi-util/src/test/resources/nrps/sample_container.json b/basiclti/tsugi-util/src/test/resources/nrps/sample_container.json new file mode 100644 index 000000000000..bf50b6215029 --- /dev/null +++ b/basiclti/tsugi-util/src/test/resources/nrps/sample_container.json @@ -0,0 +1,39 @@ +{ +"id" : "https://lms.example.com/sections/2923/memberships?rlid=49566-rkk96", +"context": { + "id": "2923-abc", + "label": "CPS 435", + "title": "CPS 435 Learning Analytics" +}, +"members" : [ + { + "status" : "Active", + "name": "Jane Q. Public", + "picture" : "https://platform.example.edu/jane.jpg", + "given_name" : "Jane", + "family_name" : "Doe", + "middle_name" : "Marie", + "email": "privacy@sakaiger.com", + "user_id" : "0ae836b9-7fc9-4060-006f-27b2066ac545", + "lis_person_sourcedid": "59254-6782-12ab", + "lti11_legacy_user_id": "668321221-2879", + "roles": [ + "Instructor", + "Mentor" + ], + "message" : [ + { + "https://purl.imsglobal.org/spec/lti/claim/message_type" : "LtiResourceLinkRequest", + "https://purl.imsglobal.org/spec/lti-bo/claim/basicoutcome" : { + "lis_result_sourcedid": "example.edu:71ee7e42-f6d2-414a-80db-b69ac2defd4", + "lis_outcome_service_url": "https://www.example.com/2344" + }, + "https://purl.imsglobal.org/spec/lti/claim/custom": { + "country" : "Canada", + "user_mobile" : "123-456-7890" + } + } + ] + } +] +} diff --git a/basiclti/tsugi-util/src/test/resources/nrps/sample_member.json b/basiclti/tsugi-util/src/test/resources/nrps/sample_member.json new file mode 100644 index 000000000000..e5bbcfacd394 --- /dev/null +++ b/basiclti/tsugi-util/src/test/resources/nrps/sample_member.json @@ -0,0 +1,29 @@ +{ + "status" : "Active", + "name": "Jane Q. Public", + "picture" : "https://platform.example.edu/jane.jpg", + "given_name" : "Jane", + "family_name" : "Doe", + "middle_name" : "Marie", + "email": "privacy@sakaiger.com", + "user_id" : "0ae836b9-7fc9-4060-006f-27b2066ac545", + "lis_person_sourcedid": "59254-6782-12ab", + "lti11_legacy_user_id": "668321221-2879", + "roles": [ + "Instructor", + "Mentor" + ], + "message" : [ + { + "https://purl.imsglobal.org/spec/lti/claim/message_type" : "LtiResourceLinkRequest", + "https://purl.imsglobal.org/spec/lti-bo/claim/basicoutcome" : { + "lis_result_sourcedid": "example.edu:71ee7e42-f6d2-414a-80db-b69ac2defd4", + "lis_outcome_service_url": "https://www.example.com/2344" + }, + "https://purl.imsglobal.org/spec/lti/claim/custom": { + "country" : "Canada", + "user_mobile" : "123-456-7890" + } + } + ] +} diff --git a/basiclti/tsugi-util/src/test/resources/nrps/sample_member_message.json b/basiclti/tsugi-util/src/test/resources/nrps/sample_member_message.json new file mode 100644 index 000000000000..b301e4bb980a --- /dev/null +++ b/basiclti/tsugi-util/src/test/resources/nrps/sample_member_message.json @@ -0,0 +1,11 @@ +{ + "https://purl.imsglobal.org/spec/lti/claim/message_type" : "LtiResourceLinkRequest", + "https://purl.imsglobal.org/spec/lti-bo/claim/basicoutcome" : { + "lis_result_sourcedid": "example.edu:71ee7e42-f6d2-414a-80db-b69ac2defd4", + "lis_outcome_service_url": "https://www.example.com/2344" + }, + "https://purl.imsglobal.org/spec/lti/claim/custom": { + "country" : "Canada", + "user_mobile" : "123-456-7890" + } +} diff --git a/basiclti/tsugi-util/src/test/resources/oauth2/sample_access_token.json b/basiclti/tsugi-util/src/test/resources/oauth2/sample_access_token.json new file mode 100644 index 000000000000..db293c75d3f5 --- /dev/null +++ b/basiclti/tsugi-util/src/test/resources/oauth2/sample_access_token.json @@ -0,0 +1,6 @@ +{ + "access_token" : "dkj4985kjaIAJDJ89kl8rkn5", + "token_type" : "bearer", + "expires_in" : 3600, + "scope" : "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem https://purl.imsglobal.org/spec/lti-ags/scope/result/read" +} diff --git a/config/configuration/bundles/src/bundle/org/sakaiproject/config/bundle/default.sakai.properties b/config/configuration/bundles/src/bundle/org/sakaiproject/config/bundle/default.sakai.properties index 25f0a3db0d9c..1a35fcf021d1 100644 --- a/config/configuration/bundles/src/bundle/org/sakaiproject/config/bundle/default.sakai.properties +++ b/config/configuration/bundles/src/bundle/org/sakaiproject/config/bundle/default.sakai.properties @@ -5773,3 +5773,126 @@ rubrics.integration.token-secret=12345678900909091234 # # DEFAULT: 59 # sse.ping.interval.seconds=20 + +# ############################### +# Begin SakaiPlus +# ############################### + +# Enable the SakaiPlus provider - if this is false, all aspects of SakaiPlus are not avaiable +# plus.provider.enabled +# DEFAULT: false + +# Turn on extensive logging for SakaiPlus operations server wide. You can also turn verbose +# debugging on a tenant-by-tenant basis in the Plus Admin tool. +# plus.debug.verbose +# DEFAULT: false + +# Enable / disable using the LTI Advantage Names and Rosters Service (NRPS) to retrieve rosters. In +# most normal cases this should be left on. +# plus.roster.synchronization +# DEFAULT: true + +# Enable / disable supporting incoming deep link requests in the Plus provider servlet. This should be +# left on unless you want to really lock down what aspects of LTI Advantage you want to support in your +# server. +# plus.deeplink.enabled +# DEFAULT: true + +# List the default list of tools that are made available via the provider. Each tenant has the same +# list, this property is used if the tenant entry leaves its list blank. The list is a colon-separated list +# list of Sakai tool registration identifiers like +# sakai.resources:sakai.conversations:sakai.site +# Note that "sakai.site" is a vitural tool id that indicates the creation and launching of an entire SakaiPlus +# site and showing the entire Sakai UI. Since this default is no tools, in effect this is required to use Sakai +# Plus. +# plus.tools.allowed +# DEFAULT: null + +# A colon separated list of Sakai tool ids that absolutely cannot be run inside and iframe +# in the controlling LMS. This will cause Sakai to detect an iframe and generate a popup link +# to escape the iframe. +# plus.tools.new.window +# DEFAULT: null + +# Specify the template site to be used to copy when making a new site in response to the +# first Plus launch to a site or tool. +# plus.new.site.template +# DEFAULT: !worksite + +# Specify the default site type to be created when making a new site in response to the +# first Plus launch to a site or tool. This is a fallback value if Sakai Plus cannot +# determine the sit tye from the incoming LTI Launch Request. +# plus.new.site.type +# DEFAULT: project + +# Set the title of this server to be used in IMS Dynamic Registration, Deep Link, or Canvas +# Configuration responses when there is a need to describe the current server. Generic +# translatable defaults come from from plus.properties unless they are overridden here. +# plus.server.title +# DEFAULT: Sakai Plus + +# Set the description of this server to be used in IMS Dynamic Registration, Deep Link, or Canvas +# Configuration responses when there is a need to describe the current server. Generic +# translatable defaults come from from plus.properties unless they are overridden here. +# plus.server.description +# DEFAULT: Open source LMS and tools + +# Set the policy URL as expected in the IMS Dynamic Registration process. This +# is optional - but some controlling LMS's may require it. See the LTI Dynamic +# Registration documentation for details on the type of information that is expected +# to be available at this URL. +# plus.server.policy.url +# Default: null + +# Set the terms of service URL as expected in the IMS Dynamic Registration process. This +# is optional - but some controlling LMS's may require it. See the LTI Dynamic +# Registration documentation for details on the type of information that is expected +# to be available at this URL. +# plus.server.tos.url +# Default: null + +# Set the logo URL as expected in the IMS Dynamic Registration process. This +# is optional - but some controlling LMS's may require it. See the LTI Dynamic +# Registration documentation for details on the type of information that is expected +# to be available at this URL. +# plus.server.logo.url +# Default: null + +# Set the title used when installing the 'sakai.site' endpoint using Deep Linking. +# Since sakai.site is not actually a registered tool, it has no title and description +# and these properties allow you to override the translatable defaults stored in plus.properties +# sakai.site.title +# Default: Sakai Plus + +# Set the description used when installing the 'sakai.site' endpoint using Deep Linking. +# Since sakai.site is not actually a registered tool, it has no title and description +# and these properties allow you to override the translatable defaults stored in plus.properties +# sakai.site.description +# Default: This link will launch a complete Sakai site with the ability to manage and add tools with roster and gradebook synchronization with the calling LMS. + +# Canvas does no support IMS Dynamic Registration (2022) and instead has a proprietary +# tool registration process using JSON. This field enables support for the Canvas +# specific tool registration. It is a good idea to leave this on. +# plus.canvas.enabled +# DEFAULT: true + +# Override the domain provided in the Canvas registration JSON. This defaults to +# the domain taken from the Sakai serverUrl setting and this property allows +# you to override that if needed. +# plus.canvas.domain +# DEFAULT: derive the value from serverUrl + +# Set the title that this server will name itself when creating the Canvas Registration JSON. +# The default is from plus.properties (i.e. the default is translated). +# plus.canvas.title +# DEFAULT: Sakai Tools + +# Set the description that this server will name itself when creating the Canvas Registration JSON. +# The default is from plus.properties (i.e. the default is translated). +# plus.canvas.description +# DEFAULT: This server hosts Sakai tools that you can launch from Canvas. + +# ############################### +# End SakaiPlus +# ############################### + diff --git a/conversations/tool/src/main/webapp/WEB-INF/templates/bootstrap.html b/conversations/tool/src/main/webapp/WEB-INF/templates/bootstrap.html index 8b0d6362a186..1676ec1dfec4 100644 --- a/conversations/tool/src/main/webapp/WEB-INF/templates/bootstrap.html +++ b/conversations/tool/src/main/webapp/WEB-INF/templates/bootstrap.html @@ -1,4 +1,4 @@ -!DOCTYPE html> + @@ -6,7 +6,8 @@ [(${sakaiHtmlHead})] - + +
+ + + + + + ## Make sure we at least have a jQuery 1.12 or higher, log messages + #set ( $d = "$") + + + + #if ($loggedIn) + + #end ## END of IF ($loggedIn) + + + + + + + + + + + + + + + + + + + + + + #if ( $tutorial && $loggedIn ) + + #end ## END of IF ( $tutorial && $loggedIn ) + + #parse("/vm/morpheus/snippets/portalChat-snippet.vm") + + #parse("/vm/morpheus/snippets/styleable-snippet.vm") + + #if ($pageTop) + + #end ## END of IF ($pageTop) + + + + + + + + + + + #if (${sakaiThemesEnabled}) + + #end + #parse("/vm/morpheus/includeAnalytics.vm") + #parse("/vm/morpheus/includePASystem.vm") + + #parse("/vm/morpheus/includeCookieNotice.vm") + + #parse("/vm/morpheus/includeGoogleTM-NoScript.vm") + + ${includeExtraHead} + + #if ($loggedIn) + + + #parse("/vm/morpheus/connection-manager.vm") + #end + + + + + diff --git a/portal/portal-service-impl/impl/pom.xml b/portal/portal-service-impl/impl/pom.xml index 529cff740b1d..33bd656b6b6e 100644 --- a/portal/portal-service-impl/impl/pom.xml +++ b/portal/portal-service-impl/impl/pom.xml @@ -41,7 +41,6 @@ org.sakaiproject.basiclti basiclti-api - ${project.version} javax.portlet diff --git a/site-manage/site-manage-impl/impl/pom.xml b/site-manage/site-manage-impl/impl/pom.xml index 2e0d3b79e152..11796b09b0ee 100644 --- a/site-manage/site-manage-impl/impl/pom.xml +++ b/site-manage/site-manage-impl/impl/pom.xml @@ -121,7 +121,6 @@ org.sakaiproject.basiclti basiclti-util - ${project.version} diff --git a/webcomponents/tool/src/main/frontend/js/sakai-i18n.js b/webcomponents/tool/src/main/frontend/js/sakai-i18n.js index 6347347f3cfd..99f969321565 100644 --- a/webcomponents/tool/src/main/frontend/js/sakai-i18n.js +++ b/webcomponents/tool/src/main/frontend/js/sakai-i18n.js @@ -24,7 +24,12 @@ function loadProperties(suppliedOptions) { return; } - const lang = window.parent.portal && window.parent.portal.locale ? window.parent.portal.locale : ""; + // https://stackoverflow.com/questions/19565776/check-if-window-parent-is-same-domain + let lang = ""; + try { + lang = window.parent.portal && window.parent.portal.locale ? window.parent.portal.locale : ""; + } catch (e) { } + const defaults = { lang: (window.portal && window.portal.locale) ? window.portal.locale : lang, resourceClass: "org.sakaiproject.i18n.InternationalizedMessages",
diff --git a/deploy/pom.xml b/deploy/pom.xml index 5a0443ad3c80..e48b82e1ac9c 100644 --- a/deploy/pom.xml +++ b/deploy/pom.xml @@ -1013,6 +1013,11 @@ json-simple compile + + com.nimbusds + nimbus-jose-jwt + compile + io.jsonwebtoken jjwt-api diff --git a/gradebookng/api/src/main/java/org/sakaiproject/grading/api/Assignment.java b/gradebookng/api/src/main/java/org/sakaiproject/grading/api/Assignment.java index bd72a8a2a506..75f059f348aa 100644 --- a/gradebookng/api/src/main/java/org/sakaiproject/grading/api/Assignment.java +++ b/gradebookng/api/src/main/java/org/sakaiproject/grading/api/Assignment.java @@ -117,6 +117,7 @@ public class Assignment implements Serializable, Comparable { private Long categoryId; private Integer categoryOrder; private Integer categorizedSortOrder; + private String lineItem; @Getter @Setter private boolean createTask; diff --git a/gradebookng/api/src/main/java/org/sakaiproject/grading/api/model/GradebookAssignment.java b/gradebookng/api/src/main/java/org/sakaiproject/grading/api/model/GradebookAssignment.java index 6bb7b6a1a458..650097c6d104 100644 --- a/gradebookng/api/src/main/java/org/sakaiproject/grading/api/model/GradebookAssignment.java +++ b/gradebookng/api/src/main/java/org/sakaiproject/grading/api/model/GradebookAssignment.java @@ -126,6 +126,9 @@ public class GradebookAssignment extends GradableObject implements PersistableEn @Column(name = "IS_NULL_ZERO") private Boolean countNullsAsZeros = Boolean.FALSE; + @Column(name = "PLUS_LINE_ITEM") + private String lineItem; + @Transient private String itemType; diff --git a/gradebookng/impl/pom.xml b/gradebookng/impl/pom.xml index e286c989bbde..d5304990222c 100644 --- a/gradebookng/impl/pom.xml +++ b/gradebookng/impl/pom.xml @@ -84,6 +84,22 @@ xstream 1.4.18 + + org.sakaiproject.plus + sakai-plus-api + + + org.sakaiproject.basiclti + basiclti-api + ${project.version} + test + + + org.sakaiproject.basiclti + basiclti-util + ${project.version} + test + diff --git a/gradebookng/impl/src/main/java/org/sakaiproject/grading/impl/GradingServiceImpl.java b/gradebookng/impl/src/main/java/org/sakaiproject/grading/impl/GradingServiceImpl.java index 025ef4c3ccf9..3ab10089d538 100644 --- a/gradebookng/impl/src/main/java/org/sakaiproject/grading/impl/GradingServiceImpl.java +++ b/gradebookng/impl/src/main/java/org/sakaiproject/grading/impl/GradingServiceImpl.java @@ -110,6 +110,7 @@ import org.sakaiproject.site.api.SiteService; import org.sakaiproject.tool.api.SessionManager; import org.sakaiproject.tool.api.ToolManager; +import org.sakaiproject.plus.api.PlusService; import org.sakaiproject.grading.api.GradingAuthz; import org.sakaiproject.util.ResourceLoader; @@ -144,6 +145,7 @@ public class GradingServiceImpl implements GradingService { @Autowired private GradingPersistenceManager gradingPersistenceManager; @Autowired private ResourceLoader resourceLoader; @Autowired private SiteService siteService; + @Autowired private PlusService plusService; @Autowired private SectionAwareness sectionAwareness; @Autowired private SecurityService securityService; @Autowired private SessionManager sessionManager; @@ -315,18 +317,33 @@ private Assignment getAssignmentDefinition(GradebookAssignment internalAssignmen assignmentDefinition.setUngraded(internalAssignment.getUngraded()); assignmentDefinition.setSortOrder(internalAssignment.getSortOrder()); assignmentDefinition.setCategorizedSortOrder(internalAssignment.getCategorizedSortOrder()); + assignmentDefinition.setLineItem(internalAssignment.getLineItem()); return assignmentDefinition; } - public Long createAssignment(Long gradebookId, String name, Double points, Date dueDate, Boolean isNotCounted, + // Legacy method - Removed 2022-08-21 - Chuck S. + /* + private Long createAssignment(Long gradebookId, String name, Double points, Date dueDate, Boolean isNotCounted, Boolean isReleased, Boolean isExtraCredit, Integer sortOrder) throws ConflictingAssignmentNameException, StaleObjectModificationException { - return createNewAssignment(gradebookId, null, name, points, dueDate, isNotCounted, isReleased, isExtraCredit, sortOrder, null); + Assignment assignmentDefinition = null; + return createNewAssignment(gradebookId, null, name, points, dueDate, isNotCounted, isReleased, isExtraCredit, sortOrder, null, assignmentDefinition); + } + */ + + private Long createAssignment(Long gradebookId, String name, Double points, Date dueDate, Boolean isNotCounted, + Boolean isReleased, Boolean isExtraCredit, Integer sortOrder, + Assignment assignmentDefinition) + throws ConflictingAssignmentNameException, StaleObjectModificationException { + + return createNewAssignment(gradebookId, null, name, points, dueDate, isNotCounted, isReleased, isExtraCredit, sortOrder, null, assignmentDefinition); } - public Long createAssignmentForCategory(Long gradebookId, Long categoryId, String name, Double points, Date dueDate, Boolean isNotCounted, + // Legacy method - Removed 2022-08-21 - Chuck S. + /* + private Long createAssignmentForCategory(Long gradebookId, Long categoryId, String name, Double points, Date dueDate, Boolean isNotCounted, Boolean isReleased, Boolean isExtraCredit, Integer categorizedSortOrder) throws ConflictingAssignmentNameException, StaleObjectModificationException, IllegalArgumentException { @@ -334,19 +351,32 @@ public Long createAssignmentForCategory(Long gradebookId, Long categoryId, Strin throw new IllegalArgumentException("gradebookId or categoryId is null in BaseHibernateManager.createAssignmentForCategory"); } - return createNewAssignment(gradebookId, categoryId, name, points, dueDate, isNotCounted, isReleased, isExtraCredit, null, categorizedSortOrder); + Assignment assignmentDefinition = null; + return createNewAssignment(gradebookId, categoryId, name, points, dueDate, isNotCounted, isReleased, isExtraCredit, null, categorizedSortOrder, assignmentDefinition); + } + */ + + private Long createAssignmentForCategory(Long gradebookId, Long categoryId, String name, Double points, Date dueDate, Boolean isNotCounted, + Boolean isReleased, Boolean isExtraCredit, Integer categorizedSortOrder, Assignment assignmentDefinition) + throws ConflictingAssignmentNameException, StaleObjectModificationException, IllegalArgumentException { + + if (gradebookId == null || categoryId == null) { + throw new IllegalArgumentException("gradebookId or categoryId is null in BaseHibernateManager.createAssignmentForCategory"); + } + + return createNewAssignment(gradebookId, categoryId, name, points, dueDate, isNotCounted, isReleased, isExtraCredit, null, categorizedSortOrder, assignmentDefinition); } private Long createNewAssignment(final Long gradebookId, final Long categoryId, final String name, final Double points, final Date dueDate, final Boolean isNotCounted, - final Boolean isReleased, final Boolean isExtraCredit, final Integer sortOrder, final Integer categorizedSortOrder) + final Boolean isReleased, final Boolean isExtraCredit, final Integer sortOrder, final Integer categorizedSortOrder, Assignment assignmentDefinition) throws ConflictingAssignmentNameException, StaleObjectModificationException { - GradebookAssignment asn = prepareNewAssignment(name, points, dueDate, isNotCounted, isReleased, isExtraCredit, sortOrder, categorizedSortOrder); + GradebookAssignment asn = prepareNewAssignment(name, points, dueDate, isNotCounted, isReleased, isExtraCredit, sortOrder, categorizedSortOrder, assignmentDefinition); return saveNewAssignment(gradebookId, categoryId, asn); } private GradebookAssignment prepareNewAssignment(final String name, final Double points, final Date dueDate, final Boolean isNotCounted, final Boolean isReleased, - final Boolean isExtraCredit, final Integer sortOrder, final Integer categorizedSortOrder) { + final Boolean isExtraCredit, final Integer sortOrder, final Integer categorizedSortOrder, Assignment assignmentDefinition) { // name cannot contain these special chars as they are reserved for special columns in import/export String validatedName = GradebookHelper.validateGradeItemName(name); @@ -372,6 +402,13 @@ private GradebookAssignment prepareNewAssignment(final String name, final Double asn.setCategorizedSortOrder(categorizedSortOrder); } + // Add things not include in the calling sequence + if ( assignmentDefinition != null ) { + asn.setExternallyMaintained(assignmentDefinition.getExternallyMaintained()); + asn.setExternalId(assignmentDefinition.getExternalId()); + asn.setExternalAppName(assignmentDefinition.getExternalAppName()); + } + return asn; } @@ -382,7 +419,6 @@ private Long saveNewAssignment(Long gradebookId, Long categoryId, GradebookAssig if (assignmentNameExists(asn.getName(), asn.getGradebook())) { throw new ConflictingAssignmentNameException("You cannot save multiple assignments in a gradebook with the same name"); } - return gradingPersistenceManager.saveAssignment(asn).getId(); } @@ -653,7 +689,7 @@ public Map transferGradebook(final GradebookInformation gradebook // create the assignment for the current category try { Long newId = createAssignmentForCategory(gradebook.getId(), categoriesCreated.get(c.getName()), a.getName(), a.getPoints(), - a.getDueDate(), !a.getCounted(), a.getReleased(), a.getExtraCredit(), a.getCategorizedSortOrder()); + a.getDueDate(), !a.getCounted(), a.getReleased(), a.getExtraCredit(), a.getCategorizedSortOrder(), null); transversalMap.put("gb/"+a.getId(),"gb/"+newId); } catch (final ConflictingAssignmentNameException e) { // assignment already exists. Could be from a merge. @@ -686,7 +722,7 @@ public Map transferGradebook(final GradebookInformation gradebook assignments.forEach(a -> { try { - Long newId = createAssignment(gradebook.getId(), a.getName(), a.getPoints(), a.getDueDate(), !a.getCounted(), a.getReleased(), a.getExtraCredit(), a.getSortOrder()); + Long newId = createAssignment(gradebook.getId(), a.getName(), a.getPoints(), a.getDueDate(), !a.getCounted(), a.getReleased(), a.getExtraCredit(), a.getSortOrder(), null); transversalMap.put("gb/"+a.getId(),"gb/"+newId); } catch (final ConflictingAssignmentNameException e) { // assignment already exists. Could be from a merge. @@ -756,15 +792,44 @@ public Long addAssignment(final String gradebookUid, Assignment assignmentDefini final Gradebook gradebook = getGradebook(gradebookUid); + Long assignmentId = null; // if attaching to category if (assignmentDefinition.getCategoryId() != null) { - return createAssignmentForCategory(gradebook.getId(), assignmentDefinition.getCategoryId(), validatedName, + assignmentId = createAssignmentForCategory(gradebook.getId(), assignmentDefinition.getCategoryId(), validatedName, assignmentDefinition.getPoints(), assignmentDefinition.getDueDate(), !assignmentDefinition.getCounted(), assignmentDefinition.getReleased(), - assignmentDefinition.getExtraCredit(), assignmentDefinition.getCategorizedSortOrder()); + assignmentDefinition.getExtraCredit(), assignmentDefinition.getCategorizedSortOrder(), + assignmentDefinition); + } else { + assignmentId = createAssignment(gradebook.getId(), validatedName, assignmentDefinition.getPoints(), assignmentDefinition.getDueDate(), + !assignmentDefinition.getCounted(), assignmentDefinition.getReleased(), assignmentDefinition.getExtraCredit(), assignmentDefinition.getSortOrder(), + assignmentDefinition); } - return createAssignment(gradebook.getId(), validatedName, assignmentDefinition.getPoints(), assignmentDefinition.getDueDate(), - !assignmentDefinition.getCounted(), assignmentDefinition.getReleased(), assignmentDefinition.getExtraCredit(), assignmentDefinition.getSortOrder()); + + // Check if this ia a plus course + if ( plusService.enabled() ) { + try { + final Site site = this.siteService.getSite(gradebookUid); + if ( plusService.enabled(site) ) { + + String lineItem = plusService.createLineItem(site, assignmentId, assignmentDefinition); + + // Update the assignment with the new lineItem + final GradebookAssignment assignment = getAssignmentWithoutStats(gradebookUid, assignmentId); + if (assignment == null) { + throw new AssessmentNotFoundException( + "There is no assignment with id " + assignmentId + " in gradebook " + gradebookUid); + } + assignment.setLineItem(lineItem); + updateAssignment(assignment); + } + } catch (Exception e) { + log.error("Could not load site associated with gradebook - lineitem not created", e); + } + } + + return assignmentId; + } @SuppressWarnings({ "unchecked", "rawtypes" }) @@ -814,6 +879,8 @@ public void updateAssignment(final String gradebookUid, final Long assignmentId, assignment.setExternalId(assignmentDefinition.getExternalId()); assignment.setExternalData(assignmentDefinition.getExternalData()); + assignment.setLineItem(assignmentDefinition.getLineItem()); + // if we have a category, get it and set it // otherwise clear it fully if (assignmentDefinition.getCategoryId() != null) { @@ -828,6 +895,18 @@ public void updateAssignment(final String gradebookUid, final Long assignmentId, if (scaleGrades) { scaleGrades(gradebook, assignment, originalPointsPossible); } + + // Check if this is a plus course + if ( plusService.enabled() ) { + try { + final Site site = this.siteService.getSite(gradebookUid); + if ( plusService.enabled(site) ) { + plusService.updateLineItem(site, assignmentDefinition); + } + } catch (Exception e) { + log.error("Could not load site associated with gradebook - lineitem not updated", e); + } + } } private CourseGrade getCourseGrade(Long gradebookId) { @@ -2111,6 +2190,7 @@ public void saveGradesAndComments(final String gradebookUid, final Long gradable gradeRecordsToUpdate.forEach(gradingPersistenceManager::saveAssignmentGradeRecord); commentsToUpdate.forEach(gradingPersistenceManager::saveComment); eventsToAdd.forEach(gradingPersistenceManager::saveGradingEvent); + eventsToAdd.forEach(this::sendGradingEvent); } catch (final HibernateOptimisticLockingFailureException | StaleObjectStateException holfe) { // TODO: Adrian How janky is this? log.info("An optimistic locking failure occurred while attempting to save scores and comments for gb Item {}", gradableObjectId); @@ -2755,10 +2835,45 @@ private List getAssignments(String gradebookUid, String categoryName */ private void postUpdateGradeEvent(String gradebookUid, String assignmentName, String studentUid, Double pointsEarned) { + log.debug("postUpdateGradeEvent {} {} {} {}", gradebookUid, assignmentName, studentUid, pointsEarned); postEvent("gradebook.updateItemScore", "/gradebook/" + gradebookUid + "/" + assignmentName + "/" + studentUid + "/" + pointsEarned + "/student"); } + /** + * Send a GradebookEvent to Sakai's event table + * + * @param gradebookEvent + * @return + */ + private void sendGradingEvent(GradingEvent gradingEvent) { + String studentId = gradingEvent.getStudentId(); + String scoreStr = gradingEvent.getGrade(); + + // Null is actually OK. + Double score = null; + if ( scoreStr != null ) { + try { + score = new Double(scoreStr); + } catch (Exception e) { + log.debug("Could not parse score as number studentId={} score={}", studentId, scoreStr); + return; + } + } + + GradableObject go = gradingEvent.getGradableObject(); + + log.debug("sendGradingEventchecking GradableObject studentId={} score={} go={}", studentId, score, go); + + if ( go == null ) return; + String assignmentName = go.getName(); + Gradebook gb = go.getGradebook(); + if ( gb == null ) return; + String gradebookUid = gb.getUid(); + + postUpdateGradeEvent(gradebookUid, assignmentName, studentId, score); + } + /** * Retrieves the calculated average course grade. */ @@ -3616,6 +3731,7 @@ else if (gradebook.getGradeType() == GradeType.POINTS && assignment.getPointsPos // Insert the new grading events (GradeRecord) eventsToAdd.forEach(gradingPersistenceManager::saveGradingEvent); + eventsToAdd.forEach(this::sendGradingEvent); } /** @@ -3785,10 +3901,33 @@ public void addExternalAssessment(final String gradebookUid, final String extern asn.setReleased(true); asn.setUngraded(false); - gradingPersistenceManager.saveAssignment(asn); + Long assignmentId = gradingPersistenceManager.saveGradebookAssignment(asn).getId(); log.info("External assessment added to gradebookUid={}, externalId={} by userUid={} from externalApp={}", gradebookUid, externalId, getUserUid(), externalServiceDescription); + + // Check if this ia a plus course + if ( plusService.enabled() ) { + try { + final Site site = this.siteService.getSite(gradebookUid); + if ( plusService.enabled(site) ) { + + String lineItem = plusService.createLineItem(site, assignmentId, getAssignmentDefinition(asn)); + + // Update the assignment with the new lineItem + final GradebookAssignment assignment = getAssignmentWithoutStats(gradebookUid, assignmentId); + if (assignment == null) { + throw new AssessmentNotFoundException( + "There is no assignment with id " + assignmentId + " in gradebook " + gradebookUid); + } + assignment.setLineItem(lineItem); + updateAssignment(assignment); + } + } catch (Exception e) { + log.error("Could not load site associated with gradebook - lineitem not created", e); + } + } + } @Override @@ -3829,6 +3968,18 @@ public void updateExternalAssessment(String gradebookUid, String externalId, Str gradingPersistenceManager.saveAssignment(asn); log.info("External assessment updated in gradebookUid={}, externalId={} by userUid={}", gradebookUid, externalId, getUserUid()); + + // Check if this is a plus course + if ( plusService.enabled() ) { + try { + final Site site = this.siteService.getSite(gradebookUid); + if ( plusService.enabled(site) ) { + plusService.updateLineItem(site, getAssignmentDefinition(asn)); + } + } catch (Exception e) { + log.error("Could not load site associated with gradebook - lineitem not updated", e); + } + } } @Override @@ -4282,10 +4433,32 @@ public void addExternalAssessment(final String gradebookUid, final String extern asn.setUngraded(false); } - gradingPersistenceManager.saveGradebookAssignment(asn); + Long assignmentId = gradingPersistenceManager.saveGradebookAssignment(asn).getId(); log.info("External assessment added to gradebookUid={}, externalId={} by userUid={} from externalApp={}", gradebookUid, externalId, getUserUid(), externalServiceDescription); + + // Check if this ia a plus course + if ( plusService.enabled() ) { + try { + final Site site = this.siteService.getSite(gradebookUid); + if ( plusService.enabled(site) ) { + + String lineItem = plusService.createLineItem(site, assignmentId, getAssignmentDefinition(asn)); + + // Update the assignment with the new lineItem + final GradebookAssignment assignment = getAssignmentWithoutStats(gradebookUid, assignmentId); + if (assignment == null) { + throw new AssessmentNotFoundException( + "There is no assignment with id " + assignmentId + " in gradebook " + gradebookUid); + } + assignment.setLineItem(lineItem); + updateAssignment(assignment); + } + } catch (Exception e) { + log.error("Could not load site associated with gradebook - lineitem not created", e); + } + } } @Override @@ -4340,6 +4513,18 @@ public void updateExternalAssessment(final String gradebookUid, final String ext gradingPersistenceManager.saveGradebookAssignment(asn); log.info("External assessment updated in gradebookUid={}, externalId={} by userUid={}", gradebookUid, externalId, getUserUid()); + + // Check if this is a plus course + if ( plusService.enabled() ) { + try { + final Site site = this.siteService.getSite(gradebookUid); + if ( plusService.enabled(site) ) { + plusService.updateLineItem(site, getAssignmentDefinition(asn)); + } + } catch (Exception e) { + log.error("Could not load site associated with gradebook - lineitem not updated", e); + } + } } @Override diff --git a/gradebookng/impl/src/main/webapp/WEB-INF/components.xml b/gradebookng/impl/src/main/webapp/WEB-INF/components.xml index f76794fd1ab1..ac22dc86e03c 100644 --- a/gradebookng/impl/src/main/webapp/WEB-INF/components.xml +++ b/gradebookng/impl/src/main/webapp/WEB-INF/components.xml @@ -4,7 +4,8 @@ xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd"> + class="org.sakaiproject.util.ResourceLoader"> + ee-IERDy#@aTYMqAKCq)MOpf1nEwR<(Ucq= zHHn^{-UMWMWS{g4feS(`O?)Dp2^~mngdaRaU9;cPG2#Ps4Hn=PUQI?kmMQifbm%|| ztUit3<@%S<5*PvNoDL$Qlk~8hq%z2}PT$nbj9`K=A0h;4={Hh>6^`!QItnJ90^#3C z7z(8@+3v$0oG_x0qM~2F=Gujn{KWyl%Zb)90T>|oL&xLv^saPAdU|f42u)^2dusSY z{QZ5Y1f|Dg{RQ}Q{Pj08_h#}LOBf=h2fls{d;($oDXE)BxQ+s(oSU(lgo%s{2o1eMRT%~2Hv3U#MK=^K+ustzo4c$Gr-LSRX0~sb5fI$<}|dm zrq%!4Mp`#(yU$i2+-{t}qP4M;K7pIHm5n2(8xPSxH8_Fg&)0NB1picVvg9FBlleg) zWb0r|z)DL`OHafLLqI^l?OPP0xpp5^A|b>T6((w_6=;x{rQ$t&cWOmIPz!zybRp`)cil*{bw9* zy3fJ?x5@mw(tq9pm&yynP4{22@xoy0Y(s&7@PkMQ3n;mPo@GHPp{k+}nkH`u@SB4v z2t(BBfg=5(H4s4cysQHYwEKFeJ zV&d%L?80|qyN2ub!u#>>Ma`P`?S?mM5I6)s$p3%+-=M%AzhAOY&Ji*MKmRr$@TzUn z^`>Hbi&so&vBq#ZWGy0%4(~fhvz0K3eNeUCMUNgL2xuBIe;uLX+R;}C)ENG)^!kSb zm3vzG#GY%Z)8nVHqY&KakUPnj8>|UG5P)BgE<~)=mMjP;Vo738?08&OW0drS^2*jF z>@#Mm_JfR#cUPNB3p^M>ZaRcp*)eMeB(P>Qe+;RZM`*km;Qna7fn)KyeVnIkHtduD z1guN~WH{Jj09Pk~KMBHu&3d&U*>HEW+)C*pztc2ib3Btf)-G}PXEU%WYLF4QD=2UZ z=ny!XPAJsVPlY{sBC26~yu-U}06#wm;5za$?SoVadP4mq&fAcO7h`|O=1&kn0B1uF zoHm7Yi5>-DDB7>dKTJ5w91}x|1d0$yfPFGQBcaw^*h_wXMz97W*m*N7e9Cyx5L5sK z8>kl}g{#*#F5q+VAb_DPka)P(5D>_JAAy6S00&*W4sr!f@be;Y1rul8!vn+k_Ynka zL7W$@;L?x_6V-78`M7?fPguW1E+IA=m+|rE_2sD z=l=i7NtBGPe^M$RCD)UN_r&C2&Meh0zik$5#4mJXOd@78+1~4Vzow=}VOlwl6kwOb zAK(`Ue~}e(Khs=eO}7R6-7H;lI|ZhyBP^QT(PBE6eFyYeu?G}vjhx?cQ&p%G3ltYJF9R}?)7-G3Oq zwAY1t)Vk*uksghQq^tA%8tLbO_g555pqp|{t&hJMIafJ~??C*z}gktt_zoJ1h zElx~BCCwZdzt^3StsHPL*%pFRSE+?k<>zOB2+;Aa5|x39(NxxuQkR@}nfU`T`nfb3j*U2L@pzoSWgP%XgD!Nh<1U!pf_N z5c_~Jf)0+A?hxWdE{CGz|!2LdU(MA2?uWv@&F&vwtU> zm~q*!QZnBAlLLUbPwR<2k2@Dn=T_HZs=#hKp{@G565#j!6U(I5cciSK>0-?&nxTNB z`I@y&n>Pzf2_r&qup0tIdduptrmA5Pi5Hc{1%^anct!#OY9NmoiD12pi}Diy5~O4E zaaO-<+}+;x-PlNg3d*`b2nxEXM0Hk&bIKKT=r{O9Q~APQ3iwF@`M{16t7cyc%=pb+ zHyiaj(yy)_x9-3cNwWYGFcj5eH`qm3cq^%@scfo-S=m+9#tIZgHzYuQ%D$U+ZG>q0 z>f|AMf+nJ}S`!bc@gj=SHnG0i7C*`k2jn`$h8-gmIGOY3cq~DSvA32=+jH0W5g~X8 zBLN@_f2SxwfP^p;P&ZfCg@mE7M7WfqaXot*8z8;nAa>&;m|r+Kbtj|E9iV>F@I(4!d{GEqG~ZNV7ym7(GGQ5^(e}wQCKS@75F9{_OzubUDX|$| zb?2VP7hkK3LkzN3ZV~~07a*;MqFFo)__vz#;BF=I3jTgo`a#xsT4~8)l}(I-8~X*6 z70~eFkyh#7de2JM_S9g4uh0&UUCZDPP)|bw!hvb-D3sEF{QU%nj1!MxTCsaHR5P)- z81kRCWDtR)wc4Gp!A#Qv3Xu${s3U;{M#i7At|F{}Zy0*2fq;%UN?zSWfu!QVFYeSQ zX$>I$6NCw@Nl4Weu^!^Xmy<*YlR;(D)%Igjq5~8(8i>H(*C4+;K9JezTa~eLb|&90 z)vq^>Gt{H4*vw_Pt_!EIAW~7Tayr|ilC!Vb$6S{ePc+FZBT(fS@@OiJ{F2n=+^wWq_eFl+BN z2tr8%-rKen;GG8zu!{yNhKV2u$S^?yCyx|%A9KnA15pi+V4Ak^P3pf@b@>>ftOQq> z{yqETLpW9o^cw`ck%{|iYtwgTCoUrkNi zT=a~G4=G(`xzK#`SPfp2lu#=%6-3?Se4CG3(BQcnf=NgC@+CVDJ~Y5j>yweGeVir| zd%~W*4Z~q>lIf=lib}1gi$92hH@VZ)K-7&%r+lAQKBg@?(xu-<$31q@(U28JgrR_x z-}pou7uZn_ga3Ma*I)nRT~rj!6noyj!&gRDNErStTQHvll^4vp2E0DRp|Sv!&H~I6 zKlcBnPU*=I(NwU|?UU{Ew^odwj2+qdyayim{YOyju8;M%&Q@Q+gb>x~@?9rQO2Bsr zdZ2+@&5i_#@jH=!DLu^o`EnzWep+a@K{O;RLJkSHFcJzLF*X%s-C*x$y|BiOag{JT z8%-ArUXC~pIKUwixL46%AP&T3^)^+@(V-!J8V$4jNbD*#&S*+ljM4)_rA&rE@50uw z;V#N6^#=ttrH;Fuh8Q=mJ{VG6CJ|6xD{lKSi~$0ZdH8NMEloHnMoN{Nt(`jqD$`E< zJ5G@7PI>1RM*2)qgeIAgoA6Tmen9mL_$hu0Fe)7SU`qc_MbhPYZ@{U@qholmLBcur z*^mn9O3`-1b^@*luRE-BQKO|IqZDr_03-!Ku)L+ftOrXC5iNcB#)KJc!VDo(7P0bW zY_mP_TR^TbDFA}z)upj5*uCHpP+YQ#5T60WqN!31ej->F0YmRn{b(q}J-` zdphlHg;L}}Q2hqc5jKvrmVemsgK~g^3 zJ2>fKD}wzjZ}&YKQ3?yw?!?;=I)r4L2Y25m-eNm0?s_R&$innUZn1r{7(*}sAnmLH z?YMx|Yk%jj&?G^)lhHBk^8tBSBbBE^1q=7-8UHjT+*+zrlri1Z>6NDw9qe;lUHPG8 zhDl6{2>D+L#C-r!RgvZpu7v(g1v2T0U|ytjAz|@N0Cme&)F51vwB&%v;+drg&$v50 z!^3rO2Ng(11TZafKLpt-F~CEu06$`rvjU%jc0*_wqc_tiBn9|=bZj2P zQsvP~cWkF53kS(w2CfIE@dsA)A_C1w`n;cc(ttt{Q8eMKm5t~y_`C|ka+^^cjYk)g zGN_wysU9A^hH@G3oFHkQ7H>Oa&-{Q%C4@Zzek33~egsnd76r7-}) z9N&neDI;K081bHQnfhfvTMSibnV2p%!4_^!Q0N{b#Mn^aHiy*+h|5FcZ18$3sGDDN zkv&+RV5Wb0tGPrc<0L3gh46q=?~>6LDu+Zljey}@ogIhXBtvy zI8|4@tunQVB*jQs*WwW5`e3Xw1iMlY|1DwtO4@uWUutRhqKo%t&=Dd%a1RlGkN{a2 zl~SW7=^L1woNbg3t}|C)X`RV?cdMDrMbV^EjKeT&q67TTw1^o0kdLDXX~QJDLoK>( z2djVR9WDx#)%hY{AL<0R=6XRR!eMVPl&zE$7w4|tZIbLHDj8MKl}8E%dNV>U*o~LX zHKGQOZtDoRcF*>6$lhr4;1IzWto}!*V!GtPIhcRuBp<7Y zUDI1%G(NF*fmCyQZl%7+X2+#Cnw`BmeLcPl`z(FWj|YL81SGpi6waARP|vT%1ee&8 ze6}IZ+6VdT-}}&hemR_I_BN1HKua~lA6vJ3=gzm zt#Vy+!q8&)T*y*W-rhcPWRIy4^oF443$TM0#4k%gzo*%irtOLL{*ORWa0AvYxO?VK z;UA@#{1I3P$saze!xH7kewe+329eQsNA2a=*-!fn|F9h?ArM3oV9;zBD2T#U_-q{& zh3qk5WI({}`dy-5=;a3x*HN|Kv6!8;SzBPG4@mFuCo30ceLZH&_!Jg?j6ho@PkK6# zE)qaOO6uL=tp_(t8l9Mwu?X^rx68@MC~6<{jZ?y$)lAk?vE3seb*H=Y6be7TAn{YQ z`qh1M-uJjb{0hWS&c@^E(-JP~G%Cvt*OgW*S=nIe`yqLqyaY&z7U=Ro)~^`u0?5mg z#_>cGKCj$oZZn66S6C2ApH882H~7{EI)p6E8DDWB0o`*1tU)G0-&IG`?m`YBQMkIc z62bBbjzX=Zd;+RVSzyl2-%*((AvME%1GTIBz)Z!Cn1-^=%vb=sPtgWo_oDJvwfc->Z{v3 zhJAqqtpYmGzvqW~Xj@TQ8cb%A5o~8|nHYrCv>WbQaso#w=2MU20xAmsw2~wx1Vs3{ zlrES&F%Hc0Y!(k_M=pIsrlKRCq664{QsYw)4k!3+Jv3c%F;&u*nOevfpD&)Gjm#p| zz7~<<<^3p$i>liEVAYk{98$O9&4Ip>l9H^V%|H{DjiaPy2ZoENfNHjYh>9Hw%ojyG zv?CG4*+f&dh(A-qC1ZDMy&hDNhcO|ggFU79`=b*A#<|7$!7aFAkt=-FMQj@8z5hR( z%lPS?k4*>JRv-dCtcQbKDQhFkBVMAl?PTQ3B48-|g(U)dD@5%bXRUazGO(p>WABc* zxNus8_574JeP;;AklOrbsOAJS~8?KG0}dCU$betW2bj zA3q|ol=WmV4mn|EZE?t{)ksij3jcIyMl^zZ$X{CHA0Fj;bJr}pb^9Kz8!mel_0E$@K{ zDNAzNf9&U6i%+m=^l#HCf*B)e9yA-2_0KK*l8GR6gkqW}7I6=YhXWm7HwsXKKCiU3 zSWbgx(odR#JvD74<3z2@2B0((!h%HtC8u5_B8*ZL|JXQ+`886p7~_4rK+n)R%ev;A zL7AB3P5gvNtdgvx&D*bqHg`Hj1=2+O>B?eLSGb8t7^#%9+8^*>lH$PSAVYqmmB{Hy zVo7j4=$0~7a#sL?^*wN25O=MdmI_fkkvaBI5P4XF!=U3~*QaHu0YQQE&Y{05=r#4k zpBqr}X&jYdI6sX+g8mL_WNJ&$cNQ(D8>;D&daYg@H|ir$ah_@@ln5wvs7U{4t*g3q z@%{D7cIoNXai{BVyQCRGoKVsa`|Ug)03r}6cG!M?fe>{DHU~UBjRgsTJ0GDO4QEYD zdCs;gWe%kZRTVhGILoz4{#?0F z6g5>5CN(3TOFlIP@fMO6xO3#+2dWVKH^P+_3E<{#JQneoJ6D%(zr~>^9vc`C4JpMd zx-mjC@)zcWKpv5pQzsSl@j1EB7Nz&`ibu5fusDk%hEBTsISUM!834sOA>?=FPe&fKxs$pR5q1Zl{cw-JlU4kwC9$`Zh>6i*Mj2B?lX^7f zo8j|#lRkW!cI5XR7;EeX0{(3jteW4C67=u6E?R?eSz)mDI)8QErmKEgNEo*SmIF!L z;cTK-LR1rqYWs1Tx;J&$d5#f;7bu^>>Lc#3=ig$-Cpa*Io=>mC#|##%j33BKRKl3NBk6uy3w0de;?DBJ zDo$_KG^MmBt2QT-4$BoXKL$Lh37IAKNzN|lYW>`yv>NZf7Bk2TmIa#N0xid{{}_ab zC8flCizP!J5X4f(q#5@$U2kCnu~B?We(o85~kLZo-ernR~-e{TuSlwrS@u{;u) zxU?JRnGA6K)sF}70Kb>T!^c}^y=GAw7IkYyR`e8^>B({7VMd%UWP*Zr94{CaADsWD z6}FdpWsfm$ix*ctEG+hN%cYo&w}~?ew9VCt5OFFFjQ8Xh3yaR_Sy-H#J1P>RR0}4x z3wwJd)lWbz_W1d$Mo;&)sui3$>b6c?S040=yXu1bs1;9t_h;O&jx$*X7U%;#m!yk!+^GiP|)eI(=9y&250 z-)==T4Pgh3$T-7qZ+l&%td_!$V$~T3`*lc@_Mam&56`CR7TK-66RL-MFFzvN5)f+M zGVk_i^K_dI>n_?f_eQ40cya08<1j=0jtw67gW*t?u} zrE07`%cjGP&WbXIHrcTyRYNH!cA{UwoCwaEcm73WV%X6r-m_J(tA9pm40Dt1V6bRQ_rhbxz3Ch)t`(cJ5D!;4z_ z8T~H^U!gQwnwCa|X}TNOeO7ZlXobZvQF!u4;MZL?HKjJ6l1t7VH%UD4GiO)@=N_b2 zqgL?wVh0N_8Du(~7anIOQLe4i%;|+wir3$MTF#r}E=}h9MBtmJn-a7g>C#9^MUbCi zC|#o4?@#G;0hxO!rF*IKHROz;!QOhf1(JVw@aOHY>*QPN^@W+g=toZYvBXIlEA-=%jdFMVlns@ir+&0B>%fW^Ft&jIS zTPu@6Gv=MXRkqVdxvT8nWSJ>N4$xOopMeJ-0Z`|37mc#2JoWaQv*@zIn{|(ZqBoYPY=akro|3V{}i`@m_^e7nXYR zq78KO{d=ZRpV?&sxhdOX*i!3v3DLkK`B#I*tGT)Es*3KJm>|AOi~}+)7pnTaa|Wm# z*B{J09gG=eTy%&(!Zdwx)zK5+%ZW*!ow2cPvsQ3dn(c3(weqdi3TpS#yAlh{J$eti zoax)HNwFrf1?qk;Pbpa~r3PF-4mDwBf73PgYTC2DsZWAdg|~HgvoU3yCad?wi=x%avV zdde5OauN*7dxn#%#>wO4L+G%?cl(ol;OmaXu?EU>Wg(<~|CEh~ON(KRea5|(Xr3JR z@OWN&u1L*@%ub-jusKeZIsrspbbb%^qF~a1_w=1)>>NtSP`1dwY!Mp1fBsoVAptH# zhK@&)01Jiap7wz&VZkJ}Z^k933oDkx8*rBHtWi=``sZjmcZqjK=^?obp3GoWfZqn< z$DEYS5$U;_T`;%kF;!^;i38S3Tl9qWPr+e?`)%!)`xZ;`!_7`?EP+3NhAXmvlDIgk zkTEx$#bs-ji~beDqzQ4h*rf9#!OqLPSA+>pf*xsNd;F!Ig*-6O|ND1| z&S@T~Y$aH-Id&>z_7Pm)sXZy8lWNoaYDEe+iUya~4pdlWr=G`t zQMSU(!Gwt{SrRceH)rCJsnMR7!2d{>h}k*Ss~t93P3JenV74?93aVG)Q=E>+9Z!1# zp|>to>go;K;;_VMfdCXf`erWaz=A9?Uzfpb9NY9`rd;4%l%XQIlSB$|!;f#0em>|m zGC-;J$I_Wb25&7hN6uYRNO(FJs*g1mXrkC;N)xtSkIOUn+zs(e#*~|zJs?&-qgUd^ z9WL}T9#GlSmzK+Gr82oKUJXQAIFvx_`DQ7VFLJhY73gkBkG|*x7_lJiZeCFP{y_)0 z*qmOn=ii{U^!}@glPs)mFrTvo-Y0Zz1~GfF^ITBP53W7jQ6~ZX znk<&qij(b+6pRRl@M$4=@|~d-ngE}52e$>1aPII#=gFfOOrRw@_B5=Rj`yq?8IoQ! z@DYj>>585dO#E!CE7sZKWAIR3-mNVwC8byz7P)hE#_OAt5!9YibJnCL_PhNqw^O%~ zn=CmNj18~$^;p80M!PmyF&?2MacC4BmzXVvT3&DQmqlBgH8@IhQzsa%hO=gop_z(e zS(Ze;7Qczv)Eolo7Y=NfXtPK`op|~^zpwFd@DTo`(7#u!)3A@#<#NB=J?_Qa@L^*x z%#SB~nF!9MEuD&?^K#V_#~<}yMD6#$um0WQlHn=X-f` zpQ700gp-dLDX;~`P1hkEMDeED1-uD(M9gS!hn!`5in4GJN@|i?^kLU>g-6;Pg*ISG z_0XAjI{S?}liuW%Qd>B$J~+7E95bzste4}b9Ox%HS{eH2JcN4}WB|x-4gRWfu!p~( z0b^I9Raos@-mg+J*pn^=^KMv#g_KY^oeE0!+aYg(tA~ewwD>ysc8{%-J|2$IxCOe6 zb8Ls-qRK|oQc!&}+Z@HczkNR4D|mp@m{e5EAn;OM7WgY9v$8TN@DwUPkFYY5 zHP&ZFo*6P7H`X_GuxARIR*yP)nBtY?a=P}b{9z| z3TA>Gx?+;KTyzFTG3l}|B0|?X)JZ?O%0mjhXn>ZMuC&N?s12J-P(YOH;ju;=q%yo% zM833!_22YOd+*uX8biy3Ub%sIANp<~jhZ}FdFZRml?Ru~RjMN{YPRY;t*j6M#=W-~ z_e;}1A-e16UDhvLB0+HNq@RcX4la^}#|LN?=IiU-%GNO|)I%vk=I3Oiso&Bz~E?^E_x3GB)%YNkMLi}AzDU==E5ISYPZ15ZFd)kplR(+$QrrF5w;u!H%nV?_;MebSWi6iIF(3u}R4|zUPma&?$16 zqa)$J2kaPkqHXr1de+8eHcKUPy9;c$Byouqxt=u?MJ~Zk{$xBs&En~DyK_)l6_0!$ zulfm~Mf%4XRucAG?AUZ7A6yWay&ta_P@eOnKCDB|0cu4tWe*=HRRd72LP7pcfoA)0 zt(AwD>HZ>GQ*t+y$%vZ`k`F{*9;e0isi@vo5qAD9zE3v{3Th*feCyz#$b7-1B*xqE zV?UkqB{R!ufR6f?0-nw$hU@9jBsALufX%Eo*7{j*r!yZhH*4h!=bZI7?rC6Dnz?GC z53%?A$RtOm+v!-$V)H~4k*&O-miz@h$5NP)5d74)Mk&v3e#6CNo`4Fh294p}QhOH` zDWLP~W6;0cTALzwDHYRBdrg}cjzzvynF?XG)?vxse7QN;r9QZCgcr=9+DULruT#hi zcs*DiA5Br{Q+D1*P%Y@wZU)+&Hx*gxgUEJB(`N5f4?yo=(BnyRF4{E>GN|*YgMiuT0 zeAB~~x;U?zbBl$my}pfsSO2mt@&z7m4jQI3vTh?)YzxID{1rc(x^v3))JRo1rs8h3c-j&|R2O9S5Q`XU$#AJMhG`N?YBRSd`N zW)cH((IFwYELc|(j6Yb|TyaZ9DFryEO;N5xW&sQ_E(+WZae0Ft|09}Rn$D8T- zXJ|Hr%b0FwLZPs$zHeVXlPf?2iu%cJxjiDCTyBne{>*J8)wfS0|3$B7Qs{!tT6>`^ zeTq3;lk^fwc8tQS+i&Vj&U+TT`OVR?#xl_{9_m@cd$CgMEPwb&f>=QR7|IkUZ!H9j zGrGLBo^}=&i{7?nrE@l3)bdx0Rz_O5S;SCsu;J0;CA<~hVuSDr|CGNp00JMm1QQ0i{f5GV{5aNe|5<`3 z(yY$PQ)TTOJcR*4h^!fcQy&;ZfdqF$OyU96*DvJU==@o+?v*^voo@L767I&m!9PmT zzDr2R99(Ve>sd2fz|S88FLTDvi-TZq%^O^U2bmVa!1jDI+*cb%UcY3Ip~27p0+e~; zISd}L@SrP|uS+Yml^)R~w_FFYw9JLGQ4ktm<{}scYBwK1?`KG2gF|tkBiZj(w6uS? z#ijQA0@ES|ruxyuw0>)2c^T_U>1Q27iQnb7TxaR^^KD8G4d|lc(G0> zCcE%6a)g>I`0EYe79aNOMdv+(llhWUS6g#X*C+XH{uUT9s02m~dH^9E;PNsbRasD) zO<1izF6@c!>Hmtic~kA3N4+D-%gg_nPmLP=Uuk+JEf5vl?We3am#Zry%lPx~*qDk|+AY#k?J?uYZ?2Q<;K6Hpy6S0-VXP4r@qn zNI8o{_)W^iC&e6zIe<0zm!FYbQRqzSzbhOgl%Wf*>32^1{Xab>~+E`?(gqA&L*%W@r69D5yw6r z?2`$>wl^x&L?*UQYG=Fc?mbv)9+D%ylNTtaHZr4 zOImwj)F|YQfT|po!=px<^D`-{^J_Xs+Hy-Y;igb=!bxsnXyKEX&cf*yIEpuX1RXTA zJ0eEgyFMQckI;4jD>Qs5H(EIEPf{ZQgYjqFSe+QQTa6GA2dAQFLqEN~ zEA9_QzFk>K7*x)rZiBVotd~lpA95NJh9|u~gZl*GcVzODTGGey@{djq4-TsD-yuBX zLnYzPl|EB$l3+P-k`ws>nAhSC7pEmvRXl-j+g?(4O6-FLg@tBQ6ThooNzS1hZb<1* z__ciT##eR2z8PI&d-M%1gUGwN+7zrIly zN0Ly2{6No+)q(!&|Bakq;4#tvhc12?2s6r~KuN?cZqUzElz?Ds+{)7?Gjp@;yPcyk zT3@>jGmMf@h@%baNvBbd=gg6CpC^hFg*nUc@bB`%VtPyfdf<_B05s1K6*4GGitO*E zg?sOIwfV;v(ZQjy+AVLblMumEpc%bHuigTQt5USA=e4 zD$2uIOZaqryqTTg_*3ri8XKZe+V27IQYKPhvm0%GjH%&uLd``(crssLIjXzDd z+ql~&{tp~@ozZxZNzb9RjVkTnGi6|I#8byEFfJidoxP@&<#oApu~b7$H`G`+S-r~w zprHB`Nmv|9?_nV8>~3{w)(HP6 zY83JuTAnc+tnYbdBPS7%zBxm_8QEw}=bo1hLsE3qmI1c}1ae>xI{pt`hF#tHm9|>N zj9d_40`eYXoxdnu6&ZXh`9>i zX|+0evGM+dQ%+IyGDjj?8B<$DL3n+wG1qkL4>)<5MTjLq4-41@PuGnZgWU@*US$z! zxHZtUTSc+K(e*O)6elvDe4dVem}Oh6oNZXw*N9pq`0#$YW|Mz>Fd0~ncJp$fQ%8<` zrp)ixl3xY-({n@02F*O~@K#3)8^gWp?GehICJD?=aA&+MqzRMpIl623sI{iU;11EV z_|mMue{<^441P&dr{`b5e#(6?+eSLFcqRwAVEM7}`dXC9~#@0vwVzq*ty!p=C$>3Jm zA!=4Ag_l^UBGU`}(DDQ;n(k7}`4XrmXC^-AXK1*%DBJ&?9T~xQ&O^43A(U=J z)Pv3}y!Y4R^Eh%LnEu2hXGfh~Ud0s;CtP=a#mTD(I@I31U1z2KqPY@Hw*1LUccIAL zJJXbkZAuMapcuZp?c%b2Y6P#_3GybdO%*)2Z@VqsmU!`PGCdl1aHG-}M?#~Au8)!Jy(I?SH#$qWc z7^th##C-6woy6vo_O65MjyZCb4k35EcfsetSITFgrQZz}{;_Snl07%b=1MN6-TrYi zV7`qGBO5cJaufoSBV3mU^1HeDakgr?EGWX7xSJQ4?dv0R0~b&>Mizq%gP*g+eRo}2 zIVjFWTRmI+et2Q7KbyO6Qr8v994{$(;NI3jxz!(-_V5K1{M4gJ zAOgN?VUFcVjwhN9OU3=?#vS9%P3EnoC@i_L!}AYI9OhQl0Gk4rghHWWDxoi6J~}D} zcPn!t=m_f(?U+I$`4C>p9W8cn!;t9tSHIvIG!OMqq#hr97T`1{X6B}CAB+>|CS&uJ z=XPgTi-qytj#V9!MqlZs)(#9aT!>D?hzEGvjn9O zpCbL7cUd?7GBs}!75L<2CGGv%TI_#KG11oGr4>Uq#~d+pgUe#;@;Ur{Dn88=*G4lj z*_rxqH?We*W!lGZLWsCO0#jUZIr^wqK8U9S{ zG#h1T+4YN&iO()AZnNA@pLIJ>6^9_V0Yc+zc4KeB z>k_6FyV?L9tOR?vtNYRfqU`0cfsIYoC9mCIDzQsH`yCPuBXc_y(X|3&N`xvLAGaVAi_+&E4FU&D{L@M*S_|9ZLmexBw!%<)9wz6_4zVlgN7JmzPP&E+UuWWCdPnc`4>;En7Ljv%)b zvl9W+_Hn4?;`NR^?k)8eOWv7rg;3&7M3sz)(u#i|*Ls>yK4lC}#+~A1Q+%sfxI7F) z`ATE8sJsW2;22^Gh21R|y7v-GkAe=&0{K9+Gh6uO{c6jm^Ku9QOE<*lm)IO8_Z~BL zg7oJnr+HbfHYfbWM*as|NaI};Np`oOi^s;z&BMNKHMx$Lw7qXw4YT`*7F#?VCILDb zMldfpw-vT?s${a8M5Z1dt!xBovG{;07a)srVu%VZ1i%Cd+l{d6~G#@Ox+jA5rSDikAc)qmOh%d;Pv~nz%`Zoof!v;||d}Lfk6UZ#WeTGcEGq#c0~y(~R^6iF#g)4CFkN zuQ6~;t0rA7uUE4i(SKD=+yBP6i?W;x78-BjV|U$0w)@<5HQWgSbEo3?3wo*tSh491 z6B4bcJu>drE|12lkQ8Pn(=|@Xd~~cu={gHdHKbx{6>h{!uliP#uD6SugKc3Y2wvFV z)xv7K9Tb4JjrNsOy?_15oHZ`H&SJ{}Tm0DXk|CTrXq5ZAHQV0lNQpfUdlBmvhbs9k zJHyE?N1JQlAt43rKys8Ip5`kTOv-@0hfxTPZypo`FH(vAxzViW{g>@Ru~J-8k)Uoe z>l}71sCo7=mG>YVkxw_tPY;J zUo|48pVf>r^+{Vv9vK-y zQB_$u_x#Sz9%=Q^q=@$3<>hw&@cfIo!)O})1q8GT37}zLv@}!aj(nNp z+}&Vcz`Tf&*gV*?veNKg2)BIz50^EnYq;B)+u&q7QxFjW4a?XaFp6@se5Jz4*{@?k ziOYm4RxbB(SpjL=*16>V_CVB5c3ve(JdG#|=8gDr8_&cCZ&L*l28r8ncE_zwiq*^Y zt8lAN^G~ax9wneg$`7?Be*9f7d1vwZ&KHf|x0|jVucn&ja=yHI(DbqF*Lxdl(UsIkx_{ zDeBWANRrKVH&F###>9ev3tiu?udjvi#B@BZH3k(S-(Q7w33|SP`AhlI5wMV# zeLoy;s;tftZQ#D&<=-Y&0NOO`tiJ=i2WeNv6;m#@5Yo7s*f?%henwD>^&?T>(DL;} z#S$);5WzeHm5=;z-}Cscr)ptgH>JtZeD#vCT|;D6MMXo}8ed6{9Xs;YQC;{J zP|45F3O}B_x9-E;_ln09~P9sp>88ZKKNRYE<$YjxjRe>^!Y(-B zbI}g<^y*Xl6W&Vj8TrPT)pd)ZAR~yVYy81(d*2qS8>{%e`53F^13XYX6Icqi4P3MH z^D9HlZJ${GW_Z3$o%9VoUvO+v^3{2>Uqv@;Md#w&VtxiTyOC{VY2>%;Llc0gUY9fy zkhO75d93*h|Fz7jZX1_dW-!nDW4dkNz|xojzaT&3&3dN3zksQIo_rCNbUN3Iyy{H7 z+WB(#+cWW~W3L#cXK|wo)yPn2igEW5EX1Bj&)md1i`gG&*|9V?FN28H^G+!a{Rr`E z0%);r_Yea(%Y`Q7Ql8Z)h`9)0xIN@N$LX3?#NKn4t4aH#P;NgSZt}v=`ScOzwXsPc ziZq8{X!kLsxq9PVcWN-%B5I-*HMmA$$p(W`!xzR+`hdGLpvscb zj7?xPJ>IuUX>D4kSc_6InF_JTGZ>w|yURdMj-!I6IqYtZ}*_W5OL)+VaKR?F@Y z^y;Cd#kj`we0~4AGi0B4UUMFkZGM7jx05kXj)%X|RU|uhWE_{1bA2$&@dgQMOq6SA zVTjwYV??DsFihEe!>B8Qm%6u_s3;ny7nz_NtGZgPVxMmUc!tW1&q&(%=;==u^i1>! zRB^ATs%tCUY%32~fzL@EOcEA)a=m)$ub%zJFkP}gEfHfG793mLRZ2SVGB6o4EG5gm zg0vC{4xvVpyXw4Mg}qs7B5xbVhmB__euH+Wlsd?Y^TwoF#E&Ku&@qq(@n`V^xw#R z{gN3AE|sdqGZ7A5ddDmICWE$Y(A}73Bg?2x2w+yoQzvK+TRw1_DOgri#)>KGDAjFN zCkaVaHBpA)_dE8T0zbRxq@o`5~$FR5H#A zpqd)FbnBI&UGE1WSe+*g9lo@jfRZ6aSz`M&_hog&A-vzsb$ z1mJYEv7VvT@dYz7Y%lbkJGku70F*>}rlyKSr*3AURcv$=)Rd74QjmORW z>{p^o6Hjt^+)CF5=G!Y~pV&smJxLVVK;dwUtr+lfq3($$E?RZmzRw9WWWyTckoh+%)H=7zS32D0hRz zY{vC{LCZ~f^p7GT{%fanM5u9C5choIA1#gs$!y5bHMZq5naRS1xM?U9o2D0wg0LY6 z9J3ONWhC3F0Zm0G8JYz}B7sS>`oNI9xTT7VITTnqY7OXI~>pX{@$3>VMK zn+x-pYS`{5N-wY>=Z%_b1+WI|2wGkp@NO=1BYj&fbLwba#KddjW_TxsmVBLW`YfgA zCmW{o$K>C%cW+ud*h+sx6*ce*TJ60ZVjhaIatD}VMe!oy=b3%#OqrP0M?rODxI8|i z-4^xU&2W#@<;7;0)OPB+(X(^5sPofh>N5NqO?zlHPB#1VuC1wriCH!s)jeS$41ZNQ z8-SQ~(vDjWGH%MP#xS#ei!F2oQrce7c*1U)ivEE{XA1U8##^}c}}pbwTy@R zHINT#WoCVp>)TuQZ~F*Tk-9&N>SQEozR^E?qm)G2T$^iRvw?&pm=qJm4EY>_#FJgu zTRLA1#O}Qum;I}#3EKZ=T!d)1a$+l0sHdha?|%e84@cF24zZs6%=QlA(Hu4vl+8$l zo7StWLZknV#!$nJZTgHB%ld4Sl;sO*zOJFPA235@cW5o*2;mZW&DgzlT54V}={YW{ zUo(!%M&Hn2Aa?XrwGWm8?1>ozlvFkn(3m?`stjY`-YyrK`Ye?Eqn##b|CQO!2PcGk z)n>n!)BdO$n%McNZ@u_i>R?Y-Q*sgeo=^uxg8I1l^NS6A1zHkbb{0+!j+@hFkfo)2 zCWT@+y;aC8sWJ)_L6($IEA0F!=XauIks!6C$Z|IQzR}w^Ch!R|R&8w$r`qhyIh)C1 z!sf6{YtNE?TwbB>-II+^@m-{VPqJE$ac2IBfJviQ2eWB5Mj+yQ%o*go?MdIOaL>$g zKlVC$xNnuuP^ULemEm;wnYcz{3Qj;FM$cqLSc|i#!Sl^;vzg?Zgz7TTr$sZdz()I} zkru<%HxAk3*Em79V4~PcR839yTcOjb*iN~J7AZoH^RBlQj$i5A^K4tMZri3+L%lYF z#6eY)d*QX2vfTW>P5lkgmM_vUk*cpr1M7lc!L{~fS8PeDZw2^`H`7h&{BR5{&J^;a z(e06Qk+_R7eh)jEN5gA#>JysyiOKI%dR$OQG&C+g)my^Roj>Gd*qDP2bW|Hs-VMh` zM}R`CwO%7#mWd}Cia?Ta0##~5zDx||*S^&f0s~!+G|X~a-Z)!VG`_iso`z%d{ohwc zhJr$MzYsU?C}+)P|Ea#awk;pcz@B@z$k^B;|v7hZpPTYSXHOdUbt-Gr&9twDWl9BE3ohue@ z-MBl8H|`k*I}7c-O|pJ}8Wt|Tx6gAfA`PA(Azsz?eCVd1V39W2Y0b&WX}x(o=NWRP zEJ}N%rjJ>ROrab0Ugo_zY! zMBL?rn<8ziIWlOe9|Nm*pO(>fyl8teKh;=!`7OemX#fK4c5>S%)??PH3Y{dWH4mJf z7$Kk?#~$a#)Wn&$o7J--d|rzi3o6--p1i1ix^V&eGBueM9L^+wt@)dYVvFj4XQ#)@ z2I`L!uyO*HyDrJu*_7Ll_6J_TowD=8LybGPzb}ytGf^f*T?(bdg+GQlt z#Q~Z_1A7ehvb9hWK#_eC&~D*wqeTlfpQq6o7d0>xkmUvD;rE`J$)5c&jNnO#DwG-t zug$*|Z>(Wfi1Dj=Igj{6V>Br2oq2zoUgH_tgDb6u^@hDPTxY+r(Kj;DyS~<<66m!) zP{8SSo8%Z~4VxX|RMvbYo?*}{R24>+f24@g%EFs!qXHnakqI9YzPp|$w#1F$r2~GD zM={T~eN=o}USBQ|ps^SP_SJ}SrCrCA?z%wV4fX#-B%nQ%^}bs0DR4!$wpCGjSh}I- zp`fB-xETKNz>;Gi8}Q|Y@!U=yE-8^xA2()@<;)nzTD9)_!d88BW`3jZZ&8sKQOu8L zi{y=i~ z?TZq#A>b}AMMCB9{Ynkk{Q5t!D{OO$&ENERy_AchiA9K=OZR6uf4;7&KNkhs!Zp&0 zH0^;$mu{A;jr50P6@62a2s0AznJ<`{rvh_@Ef{TJ(AdwilfFL*OaHmFE#m z`4bKa%g+93R0P@C+4zbULDX?cNc0>DFISNlotH*ZbT<$b;yZJ2(l>#~jHaxEYdG~4 zcw7ncZpbkz5WAYG^K3$|^Csoyah*O{8JrqFiy_f?%N@1g8t zzl8kan8M^x--1j@azp8l(xiq?FFWa8-Nw6NfSR9+4K6X@_|OCsc}7TXRBqr5?+6%m zn{WFNFSBLMW5rQ9I!073Xn%lp7jt-j zQB+Qxv%xNo^JCANCgAq?a8@K0)HS;Mbel%@ z3Vw=VWqZ6eeAIp1G|GB~Pk`Nyp?;dC7HK^>Iq|k`9q`uW_m;wMedemavzeRYW-l+b zw6!QTwY8A;&vcC(yOoJ!41X=IN3m9>w4%!AEtDXAwp;skFMW8A6U|?KHf=I>=0hwr z;8}(d*mE|9;xo{Y%7~OB;^!#SPz~{zW0i9I-OZvEgilE&B3&5)piqCfzIp)37Gb^W zGd156U;#wLYaG{Foa~rDN=Qg5dJo8zLljmv!ywgAO$GQLf}h$d3V&lzv%Jn=$Xz{L zc5S4@KNRh?UwM3yEH70PdVTK%C#U*S4t$_pMNDxi03rE~M)y;H4HYp+hb`mUxrAii-?m1z0sb$#KWG5g$JeiecFlx56M z4``{tVZ7|T?cz-MjokxucE~GgFNUT3oPuQdBajoG(qdo@N%+lg^F^XXPGo@z zZLzz%yJB)cAZ?G$;YptUPWWx*%L~JMMj|6qNf96LZ~6JM(*A)}B3fc}UTH|%JN$k{ zfZhg#;p8U9Z}E=)%W=(wobQpm1ZF>`v<>=)AJzzvistdOhhWpKiz8*26s1*+L;)e^ z;)Ri4`AJv^df2b(F2@-5QWQ}cMW^b1X@PV;u2yB{D*C5J+6TYs3%2Z#KCf*INkVMQ z+_tgh`P|0POE~8?qy#iV2u=cQyt;HM1G~If92Vb4p zkIv-(l8Q%XLYwspZoqHMSSt-N=(yXX zm{pQsC8IZ$wv4?0q=2Ikdd{ZRMudLKUq!C=A62!y&7_?e=DoWqInZ z1+|otpx|NfvcRie4*Tkv(AvCuv#5`n_^C01qlKCAPms7Pbp6Alq~^qUYs~ZvM=F50 zPR2$;$u(jM17=SlmzC!9Ftw)O^x{G4U`d|{cC)D)GAz? z0;5OzwVrX3h$h-(ttnRpp&wdpnYwJ^Fz79{yHOWC)wNO%)gi_;0>5XI#`|+eQkf*2L2Gn+?(DtLy4ZWIfeZ?`AB~AZT#* zQnA6q^E`7{@r|vQg>Aw!8Gx@P)SF2lq%6uZ&?Gm&Nl2ynRjsF>V7qjCyT>5FsYbNB zSjFaWf}^S5WVf@-7&JPC6a2kP&@Kx71X5Cp2FlXlO0=Ga#^>ZERF7KSXK z6;_2@ahe9lB%COAe+W4@I4|8EG-vTylh4$S8NGZsP6`kIM_mX78jPOgVpmg8CJhv&=@)4V* zQKc?Q$4vP-Hp&}W?7!To)hrh&DeY-HC&80U5gO7sRiWmN@=MpAyEy~6QxGZetDW=r z&lIApzoS2<=J>G*Jzpg64P`W$RG;D4A5NUWvUHb#2&}*_lk76k@RXsw@@rRbz56Gvlc}fL~x1fHJ|s zWInyeQvSVB3iw{F^JS}r<(;MbG9c+69lB;DpUh8dS$R0jA2KZC203zqMIsTA#wk%v zhT9?wY|U1?3s*ClY9Wp8akI6C9*>k8`p(U~V6mFWZZd|ns(iGTl)4=f z;!)r0D~p-BP^UB%t?i(GoNptO*L+^cHd?i1o@9wnjx!+#$bPe6j75>p@p1- zLePQdT%oC*|epYFVr^>fp^vAoO@xA6>#F zF~Q_Wi0=uP0X(*uk?`o@9}jfU#5^Epx5gFgfFA~u{lW`Eizn@m^-37@;L7WY;Juf~ zy44nQb)%7&ZIsGZgY8ms7`jgZtElMd>r=z?4HN9Rf)l{@u{k-zXo|$W->p}_ z5D+Z4%yIuQKeaGBUQ(YND~n1`*ZsH<6(}YJ^Y}W`m$^86INV;k>UA78eL0J%1}aF{ zEGZ5NpbU>wXU%7DF^*E=XtHmznNi|BCitWV4J};$AksDrr)U|Mhg-}nmW}Rsk3#5R ztyO&<++gYpGVH)$j_A$9gMo2!vW)n{dxFBI_2?4wdiK0}D}wnsJF50aSc8V5ftsEV zBn!WIw$NmP#ACV%V&bWN_dK+X{z*wm7FeB=?aA-V=l&NNb4RZ{!?GkZMxDcSG83{9 zkyzAN+Mj!HZ23+~jrR@Pb}LXkkZqPtoO$$|a;-GW=Yi?AQ_%DoOd@%q`PNGdE(p55 z+zcj%F>h?fd4~v?X3pV$roM6#LE_gsd(zFYk7<+o`E)!yC_X4I1|2JID0e8%zly!+MRbx_Xc?YUZ!WC--4DcpWIDHl9%%?qEos%^&OqQs zmrqjV8-Fn=9i6Q9&8Oi$OP*AW&cgMNQfSt~TN#^koa%3rKj*KKu>%y$)X2(*2L{nD zH?2<#JZUqa$X!1CSukP}u~}@N-9}=-LCgC?x!uG@I3g=5Dk+IgqoU4s$EuLpjMU^6 zUD2(BnbpaWGIS^NbSYrEo_qPLRz#%KN&Mleu9>oGM&{z{jWj7~4oW?BOBM5)d5Uk` zR@|xe&q?<+s>8>zB74u$KSY%!Ca_~qB@0=$!7o3K z7-<=p=^3B2l|}tQ^1=uHw8QVp$`by<{$t~Z0kmV2EJKWYhi14I0b}nn=9d196GIG2 z@&z*H<1*U?l9Zoee$tZ+4BQV0t0iaFINyLY%u#1RS?Vp(rfNLp!%|AmH&}KwEZpb6 zs=5%Cyc#b#JY5O`GxQGKl-gb1;P|3cQ0F@YhD26m-g4eOXK6J<`89Cq1hq(^>l2I| z9de9s#D;;IuTrw70gK9NtqAPio3a|T`)t=@>!^xug;Y>k>i7O92~Dn zU|h6+e4w;^P(m&~5rI!WJ&;Z$VOU6_X;vIYMz;lS1vNoj#$ZzHw#wM*Hx4oEBcR<% z-r(|}r&XPHYd)&K z(_0L`DJK8RM#ge3Kke|DQPdLpgo2TV=B3@}n`Ps=s7nF=k-c>8lE+fn3IdqX!6 zJh@$T-M9brp+>j2nJ_Uz$RMP~b+xC@3gI>i_E0 zQLQ$}LGaWk?-GR{k*l^R(uTktkmi;&Y3H;?8-~Sub=}np^9cCMX-FBp49lx5+d|Ia zCg!)w@%XXsL{xg5Tq_}ma>JNxB=08A z^?e#A1m7?+1jiOn>_STxr~b3XboJX2W{#-UwKV;6&zs~Vpz572gK) z6U)DLIc?6{5pn80!XkX4fj6@|K2(m-?(v=$w4x$VpDY>_m~*{q9zyVC-2p(q0#)hX z7E0{^0h_mensUgeX$-6olFmR;HyCR^?>XBgmmcy}3!;H3!G#hO%|)Rqt*=}22^nve zI4FEtl9*vEvP{dQIM+i*QqWR7l+jV@u(R1Ao4%gbr+r#zZ=MSLVqB`F5TG5pvKMr{ zc!a$Yig%>8%514q?#S#GIfQS$v8YSj&IJFo0AI{Qhe^vYnndL8Ey<8OYIU8(gV=#j zw0mTIHS|?*Oi6wyytCP1XO?3XfL>Q~iTKInBc`Zm82lfe6Rui2Fe;b_!kDqBLf(}) zkyN-^;)79A2%!l!mUS`P1%N3Ygu7%caGG#C|Nb?AhBQ>w#rlr#SSBuvl9JKW#2EX( zfAZ*s9Tg3n{SSyWcgpFRYA}mBI_!(AFtp`bKNKBCf~0hgf3RVYZXqYIoU8i?;J(EZ zW|B@<66I-w*`SuBkv2lj!uQ+8SY8lrh&4E$f`gC+7sAY~e;nZSIG=Jzs!jUU6#x9k z*FKIY<9&&1lk(zhL*I&@=FUv~yYE)K>5qeGlo;9B&qo$HqB>S=cc8yXQ;!VNI8+;5 zjB8M_^Pq+^J>)2e>#$RcF5Vv-A0MpRL^ep5UwT{4OYXOZa5N&SikfT@%xB zueTc=o8WcJryHM#4gwLoiwB2E9d(diwsJDYRF8`G5DcN_Zid6_YUW*GPi{a?DE)G7 z{fg>FzMVj7?T6GZ3bQsB0ty zT!%iRz=LE#22ILwyi<2=7D3n{Gn*`*Y@-Z$4UOQvkQg#~ZoCVf2YcBxmqYQ^t3qjY z%+EFV9t5ilXo(HkucI%&gcQd*1b$|d0VtJhKSIGr8j0qmv1GMg6%%maR;|#>%47?? zVbn;bA~pj$!D7z6pIA^r=sRPn2^0!6UtJ%7nO@~@IAw78*;5EhaIW{!NQ3UKIRf?7 zB~`$zxkxtRyj^KUboihTS^N$~e=ORv%SpN8nk0jMBUbp5MxgSPpCl zpM3_I{jypEl>E{#Y|;VAJ1`NvzxB==c>^?CVU zTAvDYC3m0`*Tg)a3#{$@*1j3T^}Paj9eS*&p>>ksUC-!!`K`0+z0LNbJ1*wv!m0kE z4Y9DW5S|K_$2uPAU@e~AZkjqyknXfCIqbYZJh%Ly?*x#&e6=`iuc%E)fZS90{wCs4 zIBD>-ZT2ho?FtjG7JnVAhrO{DdUfsdEq`nNuzYQ`f{0$FH_W4_Ry^982i%SX3 zA$FzG7{f|8t}GQ<>6avlMA6kW@J;lE?`xeSJil!`3Y_V7NM-P>{mZl@yAYwh7Xhk| zbn3v?O*5Uik3m)*_@*OdAw9gau~<`nk;-E26I@n^RSsDN0#eP;Hzg})cf&?8wPsp2 z%L6p5V=p8f#`_?ozK~kdEfW6si;JJ>Al%-APEF$^Th_Mj`lQqNgZHD-2u5ZA5mFn> z3LYL*%*iPPG^9MMRX zr?PcV8bazjbypp@IIuR=0tVPH&%vLTYF-nhRn7}iB5 zM7cvHIKM;5xJ6ad+J!O&j2gQb*)%j1)YdhXhV!9d#`8hvuj8J-s0J3Rs_x9q+)DH( zW@bLtuaSGnO)!BX+xMN=sg$q-hJ$#1pdWR5zDA2r3fG9Bo5L1FIx2BjHF{jMj<0<2 z5Y(5QWke1{93T#4@4~{XO(QzENG>SeI6!1bTUp?rnze8^=SD`JLrF+tCEk;#Ykv}e zIuJq47(3+>T^~f)_Nu%E{)+P zLeDAA5>TX+3h1^cS6}za$<0SrYLX-u?@;h|QcrEi!Em)l` z91nM^AGFiDm?K{xVKn%B=#LxSC-+wk(Z|ZkrkBmmkE602kEK@BA=j{+pt=c_a2TBi z*5K6WTWww+4hDeb!^_}b%5TWYmzx(>Z^LuiJ@qEy1yHxFlWI2JO__NuY=7I;G;P>7 zt6Fg#G4tKF7L(OgS=MNYbaU%gfWZx8c%Kgl=`rH3Rt0#miZEz75;1;o8q#Sh$&$U# znMJ6V^2lJQhWC}#^X%TrczL$ zmQM?se5r6*i5y4GXjn9_Gjxj5hFcTgL$!3z@bWYpI>867&@I;VTGccoL$`%e(ksOG z2&P?A#d%@{KM9*;k8R0P8-Gif#ns2&cZ zFs42nC9n*sx^TlH!t9HZ&cL=43{P5Ak6PV%56zx7KCdx@ zTvvVVOL}+R+kA;bM^CStJBX_@KnW$2r}sW!Vv_y48ADLW*K2<#-6e6*7t~?cpvZqqJwL9UvQ#+mKI6Lt*(ZUfLAiLg69N~= z4ZS5BZ@JD3Ob>Pm)Wt&Vt;p5?>___H$2WLw?^S=5n~N&Qdi;O}Tkz`0!eId^l|@hE zq=esSiqy~|!!x#Sf-+-2|9rd311EyVH=Y?=Z)@-5;LQb0qhX?Nbs{D1;)%!Ho+O50>ZV5c% z5K2G;H3*vj3Hw2dbLAzZVQ?5D>@MMP#;8Huf>{_`77=S32}sXYY317w*)NI-!<-W# z*eup2_@DRbcUw(+*x3kQZ{(DXzO$ll+aXvz%5YPU80e4bu(i1wYECPz>JVOl!f<|_ z|8o#g(so$EiMU@`hr-L=XGI)bcvpP2onk;Xwx(*^BD$wM*OmCoY87k50BO;AqbGEc zf0G_4x`*&sde9j%+31?h=U8}ub*kdWaINf$8Xwh7KU;UP9-rBDd+$bWeliQAg|)P! zg8!c%z^HV8KeMAKk=iTX#~fU z{nPK;PiWMatt89ViHm^qIU9ji*)PpzZa-reSCJtWbcMdx`C_viv&?twBDXLoi>)V9 z`x=NH_4B{6eQSqCZ7Y3GYply@cXD^o!ChYB*?_KjdxW4EBPSWqZR&7rY2_Cg>2(-l zOB;6}OqVh$aQ|G;0h={mcB;`M>*cmXtvcy0wju7DFN4mOrYP_;?`T+}1n&9+QiH|C z$ZFxq8O`u?TV@#6PrLtQZV|u?kpfHsHxAxqsKoqD4m2EaA4_C9f6!q;rY=a#E7EPR zvc)J~tKR$T%-nGX9ZtDfWQH-xAV{qmq9o;9koeaUEPzK z3t#;$3WA^RjH5cknF)TcXA`#hJAR{)-V?=$u$c2}r1D_k^FPTj z%DL+nQyqon0xmU89r=~}yD?c%j0BR3KOroRvZ842NooBAFnp3ozywHO%ot0{9j`Q? z(C}AE`UyR-+XFpU@7w|`-{EE%<2t9=O0jH6giDyrASwbhyV zbe(=+VSNDEJ`1WV5iJ@-?QU(@p$`#qE)FjY&mm=9ckV=&J#i7WBa#>1GTh`%m(;|3 zUvAj63N?xjCbN@bRUB!)IC_)NG1~9In#li~uXp8cS!QWJmt647%f~2OW=;33ixS8F zA(6iD1+-XO37g62Y6H%e!BAc`4i>%Av`&fCTtjK@!ReUmFD7>IqJa>u{X^RU1_%6f z=Ik@;a%-xG)*1r?#H>^ti2AeZ0UZM=wFlrjwd~luallA+=T8C}L%_89#a&~!CB*EJ z2(bdL{O8F{aJpqNr})f^i_ZL?oQh;3&p(>lGRH@;i5YW+(=#(4@W$g(Q64CcMgdr~ z>CuN5FjBtMKo%JjvD^(~BHzI||EHkNrVhh772|G{dXxOkk25lmvpB|)+hRkHF<#ky zo;vo%$@Jim7IO0ydS{qDP4b>7V{A?$=s3o$`xmfu1=!#d3K2?Z?Ao}L=Dd|t}r;=NE4w{%UW$sr~gnQ;S|_}FuQCb(Q4OUC`$0*HHE&M<}G}Pn}t75Jb-bb13 zGj$pz=E}+@Qd(LLtGQT&W5lP_EoCNAI{Pn4$9y0|Cx+g`PsBW-p z*5Xn2dAu{mAdXfKk|yc5#ggGRX*{JU*oLGRG((1r*wp5QLo>h3Q4PA8uD~k*4bL$(2CqSK(lM#a-<#1GANoycqD)edC zj2NvlWLqFov5=>{){*Vm8&j|{an7S;GmRF-2%tcqU<=C2rH6-6?Ut!}=Czph$Z9z9 z5ovm8XcBPs`T@pZP;G2VGGgjP!IZR+(6>B$GIjP}di} z2c5_6lq{2H*nI%v5Ff%JAb;M1=PXAuS!)b*nEkk)Qnh4+=0=#y&;)&(XtVop*{Rg8AEiMq{AC3 z=g*;`wH2nxQL)%URiCZ<*uRt1>fmQ`tAT6gmv&f>S2K(MUKp>6<}6Q{=6@n=e0$S9 z_#H$emLAMXhVBeOo|Dl?2dmIKa~-0VsKd83udu|^Zgvz5s||Hd{U zHlp1PFH(hY`AKQU-t+-I?K5S*QujvCykF7NhEoH&;@jo&=jdR@K?(BwRv(=>;~^Zb zhg@w$;sc*o(QmBD0THVf4Xv5I$)7W(4}f@~w!JuQmX6<-jB9!IZ?Q}M9=oa$5h;MS zr~rgv@L#L*!jJwf2kYR71&yu0&a$G(- z>8LY04=aKJ-tX9L(br?pnO0$nyjfgDf{MEqXw}Avw').css({width:'100%',textAlign:'center',position:'absolute'}), + endtxt: 'No More Data', + gap: 300 + },g_option[id].scrollbottom); + } + + // Waterfall is a absolute-position base layout + // the container must be relative or absolute position + // This setting needs to be strengthened + this.css('position', 'relative'); + // start + detect(this); + }, + + sort : function() { + sorting(this); + }, + stop : function() { + var id = getHashId(this); + if(g_option[id].timer){ + clearInterval(g_option[id].timer); + g_option[id].timer = false; + } + }, + end : function() { + var id = getHashId(this); + if(g_option[id].scrollbottom){ + g_option[id].scrollbottom.ele.css('top',g_option[id].top[getPolesCol(id,true)]+"px"); + this.append(g_option[id].scrollbottom.endele); + } + if(g_option[id].timer){ + clearInterval(g_option[id].timer); + g_option[id].timer = false; + } + + } + }; + + /* + get min or max col + @param id : hash id + @param boo: true is max ,else min + */ + function getPolesCol(id,boo){ + var top = g_option[id].top, col = 0, v =top[col]; + for(var i=0;iv){ + v = top[i]; + col = i; + } + } + else{ + if(top[i]=0;i--){ + if(w>gw[i]){ + g_option[id].col = i+1; break; + } + } + + var cwidth =(w-((g_option[id].col-1)*gap))/g_option[id].col, + left=[]; + + for(var j=0;j(ele.prop("scrollHeight")-gap)); + } + + + // get element unique id + function getHashId(t){ + if(!t.attr('wf-id')){ + hash+=0.1; + t.attr('wf-id',hash); + } + return t.attr('wf-id'); + } + + $.fn.waterfall = function() { + var res; + if(!arguments[0] || typeof arguments[0] === 'object'){ + res = methods.init.apply(this,arguments); + } + else if(methods[arguments[0]]){ + res = methods[ arguments[0] ].apply( this, Array.prototype.slice.call( arguments[0], 1 )); + } + else { + $.error( 'Method ' + arguments[0] + ' does not exist on jQuery.waterfall' ); + } + return res || this; + }; + +})(jQuery); + diff --git a/master/pom.xml b/master/pom.xml index 4726dc9b4f88..21d2960cfc4b 100644 --- a/master/pom.xml +++ b/master/pom.xml @@ -94,6 +94,8 @@ 4.0.6 1.3 4.13.2 + 8.2 + 0.10.5 @@ -1813,32 +1815,44 @@ org.sakaiproject.basiclti basiclti-util ${sakai.version} - + provided org.sakaiproject.basiclti basiclti-common ${sakai.version} - + + + + com.nimbusds + nimbus-jose-jwt + ${sakai.nimbus.jose.jwt.version} + provided + + + io.jsonwebtoken + jjwt-api + ${sakai.io.jsonwebtoken.version} + provided + + + io.jsonwebtoken + jjwt-impl + ${sakai.io.jsonwebtoken.version} + provided + + + io.jsonwebtoken + jjwt-jackson + ${sakai.io.jsonwebtoken.version} + provided + + + org.sakaiproject.plus + sakai-plus-api + ${sakai.version} + provided - - io.jsonwebtoken - jjwt-api - ${sakai.jsonwebtoken.jjwt.version} - provided - - - io.jsonwebtoken - jjwt-impl - ${sakai.jsonwebtoken.jjwt.version} - provided - - - io.jsonwebtoken - jjwt-jackson - ${sakai.jsonwebtoken.jjwt.version} - provided - org.sakaiproject.taggable sakai-taggable-api @@ -1915,12 +1929,6 @@ entitybroker-utils ${sakai.version} - - org.sakaiproject.edu-services.gradebook - gradebook-service-hibernate - ${sakai.version} - provided - org.sakaiproject.grading sakai-grading-api diff --git a/plus/README.md b/plus/README.md new file mode 100644 index 000000000000..bf9bae7c9d5e --- /dev/null +++ b/plus/README.md @@ -0,0 +1,85 @@ +Sakai Plus +========== + +"Sakai Plus" is adding an LTI 1.3 / LTI Advantage "tool provider" to Sakai 23. Sakai +already has an LTI 1.1 provider (i.e. you can launch a Sakai tool from another +LMS like Canvas, Moodle, Blackboard, etc.). + +The LTI 1.1 provider will be kept separate from the LTI Advantage provider to allow +each to evolve to best meet their respective use cases. + +Some Properties To Make It Work +------------------------------- + + # Needed for launches to work inside iframes + sakai.cookieSameSite=none + + lti.role.mapping.Instructor=maintain + lti.role.mapping.Student=access + + # Not enabled by default + plus.provider.enabled=true + plus.tools.allowed=sakai.resources:sakai.site + + +Installation Documentation +-------------------------- + +We have documentation on how to install Sakai Plus into a number of LMS systems: + +* [Canvas](docs/INSTALL-CANVAS.md) +* [Brightspace](docs/INSTALL-BRIGHTSPACE.md) +* [Blackboard](docs/INSTALL-BLACKBOARD.md) +* [Moodle](docs/INSTALL-MOODLE.md) +* [Sakai](docs/INSTALL-SAKAI.md) + +Installing Sakai Plus in to a Sakai installation is most often used for "loop back" QA testing. + +Overall Strategy +---------------- + +There are several use cases that SakaiPlus is designed to support - these can be used in combination +depending on the capability of the upstream LMS. + +* A simple left navigation launch from the enterprise LMS to "SakaiPlus" to make a Sakai site, enroll all +the students in the site, and launch students into the site. This is either a `LtiResourceLinkRequest` to +the endpoint base or by adding `sakai.site` to the endpoint. If you send a `LtiContextLaunchRequest` (emerging spec) +to the base endpoint regardless of tool id, it is treated as a `sakai.site` request. + + https://dev1.sakaicloud.com/plus/sakai/ + https://dev1.sakaicloud.com/plus/sakai/sakai.site + +* A `LtiDeepLinkingRequest` sent to the the base endpoint or the `sakai.deeplink` endpoint. This will +check the `plus.allowedtools` list and go through a deep linking flow to choose and install an +individual Sakai tool. You can install a link to `sakai.site` through deep linking if `sakai.site` +is included in the `plus.allowedtools` property + + https://dev1.sakaicloud.com/plus/sakai/ + https://dev1.sakaicloud.com/plus/sakai/sakai.deeplink + +* You can launch to a single Sakai tool without any portal mark-up by hard-coding the `target_uri` to +include the Sakai tool id like `sakai.resources` and sending that end point an `LtiResourceLinkRequest`. + + https://dev1.sakaicloud.com/plus/sakai/sakai.resources + +If you have exactly one tool enabled in the allowed tools list (i.e. like sakai.conversations) and do not +even have sakai.site enabled, a `LtiResourceLinkRequest` sent to the base URL will be sent to that tool. +This feature would allow you to put up a server like conversations.sakaicloud.org and serve one and +only one tool. + +* You can also send a `DataPrivacyLaunchRequest`, SakaiPlus checks the following properties (in order) +and redirects the user to the correct URL: + + plus.server.policy.uri + plus.server.tos.uri + +At a high level some effort has been made to make it so that the `target_link_uri` is the base launch point + + https://dev1.sakaicloud.com/plus/sakai/ + +And the launch `message_type` determines that happens - except of course for launches directly to a single +tool. This reflects the (good) general trend in LTI Advantage to use message type rather than URL patterns +for different kinds of launches. + +It might be necessary to install SakaiPlus more than once so that it shows up in all the right placements +in a particular LMS. diff --git a/plus/SQL.md b/plus/SQL.md new file mode 100644 index 000000000000..875cf4e6b9c4 --- /dev/null +++ b/plus/SQL.md @@ -0,0 +1,105 @@ + + + Nope: alter table SAKAI_SITE add column CONTEXT_GUID varchar(99); + + alter table GB_GRADABLE_OBJECT_T add column PLUS_LINEITEM longtext; + + mysql -u sakaiuser -p + + select TENNANT_GUID, TITLE, TRUST_EMAIL from PLUS_TENANT; + + + select TENNANT_GUID, TITLE, TRUST_EMAIL, ISSUER, CLIENT_ID, DEPLOYMENT_ID, OIDC_KEYSET, OIDC_TOKEN, OIDC_AUTH from PLUS_TENANT; + + +--------------+------------+-------------+--------------------------------+--------------------------------------+----------------------------------------------+--------------------------------------------------------------------------------------------------------------------+---------------------------------------------------------------------+--------------------------------------------------------------+ + | TENNANT_GUID | TITLE | TRUST_EMAIL | ISSUER | CLIENT_ID | DEPLOYMENT_ID | OIDC_KEYSET | OIDC_TOKEN | OIDC_AUTH | + | 123456 | Sakai Plus | NULL | https://canvas.instructure.com | 85530000000000147 | 326:a16deed8f169b120bdd14743e67ca7916eaea622 | https://canvas.instructure.com/api/lti/security/jwks | https://canvas.instructure.com/login/oauth2/token | https://canvas.instructure.com/api/lti/authorize_redirect | + | 54321 | Blackboard | NULL | https://blackboard.com | 4c43e5f0-9eef-425f-bf7c-c81689013cb7 | 14af10f1-04ed-4457-8e40-a581681458ce | https://devportal-stage.saas.bbpd.io/api/v1/management/applications/4c43e5f0-9eef-425f-bf7c-c81689013cb7/jwks.json | https://devportal-stage.saas.bbpd.io/api/v1/gateway/oauth2/jwttoken | https://devportal-stage.saas.bbpd.io/api/v1/gateway/oidcauth | + + https://dev1.sakaicloud.com/plus/sakai/canvas-config.json?guid=123456 + + UPDATE PLUS_TENANT SET + ISSUER = 'https://blackboard.com', + CLIENT_ID = '4c43e5f0-9eef-425f-bf7c-c81689013cb7', + DEPLOYMENT_ID = '14af10f1-04ed-4457-8e40-a581681458ce', + OIDC_KEYSET = 'https://devportal-stage.saas.bbpd.io/api/v1/management/applications/4c43e5f0-9eef-425f-bf7c-c81689013cb7/jwks.json', + OIDC_TOKEN = 'https://devportal-stage.saas.bbpd.io/api/v1/gateway/oauth2/jwttoken', + OIDC_AUTH = 'https://devportal-stage.saas.bbpd.io/api/v1/gateway/oidcauth', + ALLOWED_TOOLS = 'sakai.resources', + NEW_WINDOW_TOOLS = 'sakai.site:sakai.site.roster2', + TRUST_EMAIL = 1, + VERBOSE = 1 + WHERE TENNANT_GUID='54321'; + + + UPDATE PLUS_TENANT SET + ALLOWED_TOOLS = 'sakai.site:sakai.resources:sakai.lessonbuildertool:sakai.conversations:sakai.assignment.grades:sakai.mycalendar:sakai.podcasts:sakai.poll:sakai.dropbox:sakai.mailbox:sakai.chat:sakai.postem:sakai.site.roster2:sakai.samigo', + NEW_WINDOW_TOOLS = 'sakai.site:sakai.site.roster2', + TRUST_EMAIL = 1, + VERBOSE = 1, + DELETED = 0, + SUCCESS = 0 + ; + + INSERT INTO PLUS_TENANT + (TENNANT_GUID, TITLE, ISSUER, OIDC_REGISTRATION_LOCK) + VALUES + ('123', 'Local Moodle', 'http://localhost:8888/moodle', '42'); + + http://localhost:8080/plus/sakai/dynamic/123?unlock_token=42 + + DROP TABLE PLUS_SCORE; + DROP TABLE PLUS_LINEITEM; + DROP TABLE PLUS_LINK; + DROP TABLE PLUS_SUBJECT; + DROP TABLE PLUS_CONTEXT; + + App Key (REST): 7cbbfd88-------REST-----ONLY----6ddc + Secret(REST): ijWQ------REST----ONLY----fkn4U6 + ClientId: 4c43e5f0-9eef-425f-bf7c-c81689013cb7 + https://blackboard.com + https://devportal-stage.saas.bbpd.io/api/v1/management/applications/4c43e5f0-9eef-425f-bf7c-c81689013cb7/jwks.json + https://devportal-stage.saas.bbpd.io/api/v1/gateway/oauth2/jwttoken + https://devportal-stage.saas.bbpd.io/api/v1/gateway/oidcauth + + mysql> describe PLUS_TENANT; + +----------------------------+---------------+------+-----+---------+-------+ + | Field | Type | Null | Key | Default | Extra | + +----------------------------+---------------+------+-----+---------+-------+ + | TENNANT_GUID | varchar(36) | NO | PRI | NULL | | + | CACHE_KEYSET | varchar(4000) | YES | | NULL | | + | CLIENT_ID | varchar(200) | YES | | NULL | | + | DEPLOYMENT_ID | varchar(200) | YES | | NULL | | + | DESCRIPTION | varchar(4000) | YES | | NULL | | + | ISSUER | varchar(200) | YES | MUL | NULL | | + | OIDC_AUDIENCE | varchar(200) | YES | | NULL | | + | OIDC_AUTH | varchar(500) | YES | | NULL | | + | OIDC_KEYSET | varchar(500) | YES | | NULL | | + | OIDC_TOKEN | varchar(500) | YES | | NULL | | + | TITLE | varchar(500) | NO | | NULL | | + | TRUST_EMAIL | bit(1) | YES | | NULL | | + | ALLOWED_TOOLS | varchar(500) | YES | | NULL | | + | TIMEZONE | varchar(100) | YES | | NULL | | + | VERBOSE | bit(1) | YES | | NULL | | + | CREATED_AT | datetime | YES | | NULL | | + | DEBUG_LOG | longtext | YES | | NULL | | + | DELETED | bit(1) | YES | | NULL | | + | DELETED_AT | datetime | YES | | NULL | | + | DELETOR | varchar(99) | YES | | NULL | | + | JSON | longtext | YES | | NULL | | + | LOGIN_AT | datetime | YES | | NULL | | + | LOGIN_COUNT | int(11) | YES | | NULL | | + | LOGIN_IP | varchar(64) | YES | | NULL | | + | LOGIN_USER | varchar(99) | YES | | NULL | | + | MODIFIED_AT | datetime | YES | | NULL | | + | MODIFIER | varchar(99) | YES | | NULL | | + | SENT_AT | datetime | YES | | NULL | | + | STATUS | varchar(200) | YES | | NULL | | + | SUCCESS | bit(1) | YES | | NULL | | + | UPDATED_AT | datetime | YES | | NULL | | + | OIDC_REGISTRATION | longtext | YES | | NULL | | + | OIDC_REGISTRATION_ENDPOINT | varchar(500) | YES | | NULL | | + | OIDC_REGISTRATION_LOCK | varchar(200) | YES | | NULL | | + +----------------------------+---------------+------+-----+---------+-------+ + 34 rows in set (0.01 sec) + diff --git a/plus/TODO.md b/plus/TODO.md new file mode 100644 index 000000000000..98d28cfcbe89 --- /dev/null +++ b/plus/TODO.md @@ -0,0 +1,40 @@ + +TODO LIST +========= + +Figure out how to update loginAt and loginCount efficiently + +Plus tool button to retrieve membership + +Plus tool to resend scores + +Build conversion scripts for 23.x + +Put contextLog expiry into the event expire batch jobs - Matt will help :) + +Make sure columns are created and grades are flowing: + - Assignments - LTI - Graded in LTI (i.e. StickyGrader) + - Assignments - Local - Associate with existing gradebook item + - Assignments - Peer graded + - Samigo + - Forums + +Understand how to adjust for the weird Canvas "many deployment id" strategy. + +Make sakai.plus template site that includes the sakai.plus tool. + +Handle paged rosters in the NRPS API - both reading and producing. + +Make Plus tool useful for students + +Look for or make standard donut chart webcomponent + +Put lineitem creation and score sending into database for redo on failure and add batch job + +o.s.u.ResourceLoader.getString bundle 'Messages' missing key: 'Missing Sakai Session' + +Make smarter use of cookies in the iframe Content Security Policy - Matt +Understand the ramifications of SameSite None - Perhaps add a CSP header to lock things down + +Put context memberships into a batch job - Sam / Matt? + diff --git a/plus/api/pom.xml b/plus/api/pom.xml new file mode 100644 index 000000000000..fd3a067e1cb7 --- /dev/null +++ b/plus/api/pom.xml @@ -0,0 +1,54 @@ + + + + 4.0.0 + + + org.sakaiproject.plus + sakai-plus-base + 23-SNAPSHOT + ../pom.xml + + + sakai-plus-api + jar + + sakai-plus-api + + + shared + + + + + org.hibernate + hibernate-core + + + org.springframework.data + spring-data-jpa + + + org.sakaiproject.kernel + sakai-kernel-api + + + org.sakaiproject.basiclti + basiclti-api + + + org.sakaiproject.basiclti + basiclti-util + + + org.sakaiproject.grading + sakai-grading-api + + + org.apache.commons + commons-lang3 + + + + diff --git a/plus/api/src/main/java/org/sakaiproject/plus/api/Launch.java b/plus/api/src/main/java/org/sakaiproject/plus/api/Launch.java new file mode 100644 index 000000000000..319bb7ebbd5b --- /dev/null +++ b/plus/api/src/main/java/org/sakaiproject/plus/api/Launch.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2021- Charles R. Severance + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package org.sakaiproject.plus.api; + +import org.sakaiproject.plus.api.model.Tenant; +import org.sakaiproject.plus.api.model.Subject; +import org.sakaiproject.plus.api.model.Context; +import org.sakaiproject.plus.api.model.Link; +import org.sakaiproject.plus.api.model.LineItem; +import org.sakaiproject.plus.api.model.Score; + +import lombok.Setter; +import lombok.Getter; + +@Setter +@Getter +public class Launch { + public Tenant tenant = null; + public Context context = null; + public Subject subject = null; + public Link link = null; + public LineItem lineItem = null; + public Score score = null; +} diff --git a/plus/api/src/main/java/org/sakaiproject/plus/api/PlusService.java b/plus/api/src/main/java/org/sakaiproject/plus/api/PlusService.java new file mode 100644 index 000000000000..8988a0c49f4f --- /dev/null +++ b/plus/api/src/main/java/org/sakaiproject/plus/api/PlusService.java @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2021- Charles R. Severance + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package org.sakaiproject.plus.api; + +import java.util.Map; + +import org.sakaiproject.lti.api.LTIException; + +import org.sakaiproject.plus.api.model.Tenant; +import org.sakaiproject.plus.api.model.Subject; +import org.sakaiproject.plus.api.model.Context; +import org.sakaiproject.plus.api.model.Link; + +import org.tsugi.lti13.objects.LaunchJWT; + +import org.sakaiproject.user.api.User; +import org.sakaiproject.site.api.Site; +import org.sakaiproject.event.api.Event; + +public interface PlusService { + + public static final String PLUS_PROPERTY = "plus_site"; + + public static final String PLUS_PROVIDER_ENABLED = "plus.provider.enabled"; + public static final boolean PLUS_PROVIDER_ENABLED_DEFAULT = true; + public static final String PLUS_DEBUG_VERBOSE = "plus.debug.verbose"; + public static final boolean PLUS_DEBUG_VERBOSE_DEFAULT = false; + public static final String PLUS_ROSTER_SYCHRONIZATION = "plus.roster.synchronization"; + public static final boolean PLUS_ROSTER_SYCHRONIZATION_DEFAULT = true; + + public static final String PLUS_DEEPLINK_ENABLED = "plus.deeplink.enabled"; + public static final boolean PLUS_DEEPLINK_ENABLED_DEFAULT = true; + + // plus.allowedtools=sakai.resources:sakai.site + public static final String PLUS_TOOLS_ALLOWED = "plus.tools.allowed"; + public static final String PLUS_TOOLS_ALLOWED_DEFAULT = ""; + public static final String PLUS_TOOLS_NEW_WINDOW = "plus.tools.new.window"; + public static final String PLUS_TOOLS_NEW_WINDOW_DEFAULT = ""; + public static final String PLUS_NEW_SITE_TEMPLATE = "plus.new.site.template"; + public static final String PLUS_NEW_SITE_TEMPLATE_DEFAULT = "!worksite"; + public static final String PLUS_NEW_SITE_TYPE = "plus.new.site.type"; + public static final String PLUS_NEW_SITE_TYPE_DEFAULT = "project"; + + // Used in IMS Dynamic Registration, Deep Link, or Canvas Configuration responses when there is + // a need to describe the current server. Generic translatable defaults come from from plus.properties + // unless they are overridden here. + // Default from plus.properties: Sakai Plus + public static final String PLUS_SERVER_TITLE = "plus.server.title"; + // Default from plus.properties: Open source LMS and tools + public static final String PLUS_SERVER_DESCRIPTION = "plus.server.description"; + + // Used to set fields when IMS Dynamic registration is used - defaults are null + // and not to provide these values in IMS Dynamic Registraiton responses + public static final String PLUS_SERVER_POLICY_URI = "plus.server.policy.uri"; + public static final String PLUS_SERVER_TOS_URI = "plus.server.tos.uri"; + public static final String PLUS_SERVER_LOGO_URI = "plus.server.logo.uri"; + + // Used when installing the 'sakai.site' endpoint using DeepLinking. Since sakai.site is not + // actually a registered tool, it has no title and description and these properties allow + // you to override the translatable defaults stored in plus.properties + // Default from plus.properties: Sakai Plus + public static final String SAKAI_SITE_TITLE = "sakai.site.title"; + // Default from plus.properties: This link will launch a complete Sakai site with the ability to ... + public static final String SAKAI_SITE_DESCRIPTION = "sakai.site.description"; + + // Canvas specific values - these mostly are used to configure the non-standard Canvas + // tool registration since Canvas does not support the IMS Dynamic Registration process + // as of 2022 - and does not seem to be in a rush to implement it. + public static final String PLUS_CANVAS_ENABLED = "plus.canvas.enabled"; + public static final boolean PLUS_CANVAS_ENABLED_DEFAULT = true; + // The default is taken from the sakai current server URL + public static final String PLUS_CANVAS_DOMAIN = "plus.canvas.domain"; + + // Generic defaults for these values come from plus.properties + // Default from plus.properties: Sakai Tools + public static final String PLUS_CANVAS_TITLE = "plus.canvas.title"; + // Default from plus.properties: This server hosts Sakai tools that you can launch from Canvas. + public static final String PLUS_CANVAS_DESCRIPTION = "plus.canvas.description"; + + /* + * Note whether or not this system has Plus enabled + */ + boolean enabled(); + + /* + * Note whether or not a Site has Plus enabled + */ + boolean enabled(Site site); + + /* + * Note whether or not we are in verbose mode + */ + boolean verbose(); + + /* + * Note whether or not we are in verbose mode + */ + boolean verbose(Tenant tenant); + + /* + * Return various URLs for Sakai Plus + */ + String getPlusServletPath(); + String getOidcKeySet(); + String getOidcLogin(Tenant tenant); + String getOidcLaunch(); + String getIMSDynamicRegistration(Tenant tenant); + String getCanvasConfig(Tenant tenant); + + /* + * Get a payload map from a LaunchJWT + */ + Map getPayloadFromLaunchJWT(Tenant tenant, LaunchJWT launchJWT); + + /* + * Handle the initial launch - creating objects as needed (a.k.a. The BIG LEFT JOIN) + */ + Launch updateAll(LaunchJWT tokenBody, Tenant tenant) + throws LTIException; + + /* + * Make sure the Subject knows about the chosen user + */ + void connectSubjectAndUser(Subject subject, User user) + throws LTIException; + + /* + * Make sure the Context knows about the chosen site + */ + void connectContextAndSite(Context context, Site site) + throws LTIException; + + /* + * Make sure the Link knows about the chosen placement + */ + void connectLinkAndPlacement(Link link, String placementId) + throws LTIException; + + /* + * Retrieve Context Memberships from calling LMS and update the site + */ + void syncSiteMemberships(String contextGuid, Site site) + throws LTIException; + + /* + * Create a lineItem for a gradebook Column + */ + String createLineItem(Site site, Long assignmentId, + final org.sakaiproject.grading.api.Assignment assignmentDefinition); + + /* + * Update a lineItem for a gradebook Column + */ + String updateLineItem(Site site, + final org.sakaiproject.grading.api.Assignment assignmentDefinition); + + /* + * Send a score to the calling LMS + */ + // https://www.imsglobal.org/spec/lti-ags/v2p0#score-publish-service + void processGradeEvent(Event event); + +} diff --git a/plus/api/src/main/java/org/sakaiproject/plus/api/model/BaseLTI.java b/plus/api/src/main/java/org/sakaiproject/plus/api/model/BaseLTI.java new file mode 100644 index 000000000000..b558bbc5a7aa --- /dev/null +++ b/plus/api/src/main/java/org/sakaiproject/plus/api/model/BaseLTI.java @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2021- Charles R. Severance + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package org.sakaiproject.plus.api.model; + +import java.io.Serializable; + +import javax.persistence.Column; +import javax.persistence.Lob; +import javax.persistence.MappedSuperclass; +import javax.persistence.Basic; +import javax.persistence.PrePersist; +import javax.persistence.PreUpdate; +import static javax.persistence.FetchType.LAZY; + +import java.time.Instant; + +import lombok.Getter; +import lombok.Setter; + +@MappedSuperclass +@Getter +@Setter +public class BaseLTI implements Serializable { + + public static final int LENGTH_GUID = 36; + public static final int LENGTH_URI = 500; + public static final int LENGTH_TITLE = 500; + public static final int LENGTH_EXTERNAL_ID = 200; + public static final int LENGTH_MEDIUMTEXT = 4000; // Less than 4096 because Oracle + public static final int LENGTH_SAKAI_ID = 99; + + @Column(name = "UPDATED_AT", nullable = true) + private Instant updatedAt; + + @Column(name = "SENT_AT", nullable = true) + private Instant sentAt; + + @Column(name = "SUCCESS") + private Boolean success = Boolean.TRUE; + + @Column(name = "STATUS", length=200, nullable = true) + private String status; + + @Basic(fetch=LAZY) + @Lob + @Column(name = "DEBUG_LOG") + private String debugLog; + + @Column(name = "CREATED_AT", nullable = true) + private Instant createdAt; + + @Column(name = "MODIFIER", length = LENGTH_SAKAI_ID) + private String modifier; + + @Column(name = "MODIFIED_AT") + private Instant modifiedAt; + + @Column(name = "DELETED") + private Boolean deleted = Boolean.FALSE; + + @Column(name = "DELETOR", length = LENGTH_SAKAI_ID) + private String deletor; + + @Column(name = "DELETED_AT") + private Instant deletedAt; + + @Column(name = "LOGIN_COUNT") + private Integer loginCount; + + @Column(name = "LOGIN_IP", length=64) + private String loginIp; + + @Column(name = "LOGIN_USER", length = LENGTH_SAKAI_ID) + private String loginUser; + + @Column(name = "LOGIN_AT") + private Instant loginAt; + + @Basic(fetch=LAZY) + @Lob + @Column(name = "JSON") + private String json; + + @PrePersist + @PreUpdate + public void updateDates() { + if ( createdAt == null ) createdAt = Instant.now(); + modifiedAt = Instant.now(); + } + +} diff --git a/plus/api/src/main/java/org/sakaiproject/plus/api/model/Context.java b/plus/api/src/main/java/org/sakaiproject/plus/api/model/Context.java new file mode 100644 index 000000000000..13f202829624 --- /dev/null +++ b/plus/api/src/main/java/org/sakaiproject/plus/api/model/Context.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2021- Charles R. Severance + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package org.sakaiproject.plus.api.model; + +import java.time.Instant; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; +import javax.persistence.Index; +import javax.persistence.UniqueConstraint; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; +import javax.persistence.Table; + +import org.hibernate.annotations.GenericGenerator; + +import org.sakaiproject.springframework.data.PersistableEntity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +@Entity +@Table(name = "PLUS_CONTEXT", + indexes = { @Index(columnList = "CONTEXT, TENNANT_GUID, SAKAI_SITE_ID") }, + uniqueConstraints = { @UniqueConstraint(columnNames = { "CONTEXT", "TENNANT_GUID" }) } +) +@Data +public class Context extends BaseLTI implements PersistableEntity { + + @Id + @Column(name = "CONTEXT_GUID", length = LENGTH_GUID, nullable = false) + @GeneratedValue(generator = "uuid") + @GenericGenerator(name = "uuid", strategy = "uuid2") + private String id; + + // Controlling LMS context ID (within Tenant) + @Column(name = "CONTEXT", length = BaseLTI.LENGTH_EXTERNAL_ID, nullable = false) + private String context; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "TENNANT_GUID", nullable = false) + @EqualsAndHashCode.Exclude + @ToString.Exclude + private Tenant tenant; + + // For many-deployment systems like Canvas, we can have as many as one + // deployment_id *per context* - If present - this is preferred for use + // in out-going token requests + @Column(name = "DEPLOYMENT_ID", length = LENGTH_EXTERNAL_ID, nullable = true) + private String deploymentId; + + @Column(name = "SAKAI_SITE_ID", length = LENGTH_SAKAI_ID, nullable = true) + private String sakaiSiteId; + + @Column(name = "TITLE", length = LENGTH_TITLE, nullable = true) + private String title; + + @Column(name = "LABEL", length = LENGTH_TITLE, nullable = true) + private String label; + + // launchjwt.endpoint.lineitems + @Column(name = "LINEITEMS", length = LENGTH_URI, nullable = true) + private String lineItems; + + @Column(name = "LINEITEMS_TOKEN", length = LENGTH_URI, nullable = true) + private String lineItemsToken; + + @Column(name = "GRADE_TOKEN", length = LENGTH_URI, nullable = true) + private String gradeToken; + + // launchjwt.names_and_roles.context_memberships_url + @Column(name = "CONTEXT_MEMBERSHIPS", length = LENGTH_URI, nullable = true) + private String contextMemberships; + + @Column(name = "NRPS_TOKEN", length = LENGTH_URI, nullable = true) + private String nrpsToken; + + @Column(name = "NRPS_JOB_START", nullable = true) + private Instant nrpsStart; + + @Column(name = "NRPS_JOB_FINISH", nullable = true) + private Instant nrpsFinish; + + @Column(name = "NRPS_JOB_STATUS", length = LENGTH_TITLE, nullable = true) + private String nrpsStatus; + + @Column(name = "NRPS_JOB_COUNT", nullable = true) + private Long nrpsCount; + +} diff --git a/plus/api/src/main/java/org/sakaiproject/plus/api/model/ContextLog.java b/plus/api/src/main/java/org/sakaiproject/plus/api/model/ContextLog.java new file mode 100644 index 000000000000..14a349bfbad3 --- /dev/null +++ b/plus/api/src/main/java/org/sakaiproject/plus/api/model/ContextLog.java @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2021- Charles R. Severance + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package org.sakaiproject.plus.api.model; + +import java.time.Instant; + +import javax.persistence.Column; +import javax.persistence.Lob; +import javax.persistence.Entity; +import javax.persistence.ManyToOne; +import javax.persistence.JoinColumn; +import javax.persistence.FetchType; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; +import javax.persistence.Table; +import javax.persistence.Basic; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import static javax.persistence.FetchType.LAZY; + +import org.springframework.data.annotation.CreatedDate; + +import org.sakaiproject.springframework.data.PersistableEntity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +@Entity +@Table(name = "PLUS_CONTEXT_LOG") +@Data +// @ToString(exclude = {"context", "subject", "debugLog" }) +// @EqualsAndHashCode(exclude = {"context", "subject", "debugLog" }) +public class ContextLog implements PersistableEntity { + + // These enums *names* must match the values in the spec as they are matched with strings at times + public enum LOG_TYPE { + NRPS_TOKEN, NRPS_LIST, NRPS_MEMBER, NRPS_ERROR, + LineItem_TOKEN, LineItem_CREATE, LineItem_ERROR, + Score_TOKEN, Score_SEND, Score_ERROR + // Add at the end - don't insert new above + }; + + @Id @GeneratedValue + @Column(name = "CONTEXT_LOG_ID") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "CONTEXT_GUID", nullable = false) + @EqualsAndHashCode.Exclude + @ToString.Exclude + private Context context; + + @ManyToOne(fetch = FetchType.LAZY) + @EqualsAndHashCode.Exclude + @ToString.Exclude + @JoinColumn(name = "SUBJECT_GUID", nullable = true) + private Subject subject; + + @Column(name = "LOG_TYPE", nullable = true) + @Enumerated(EnumType.ORDINAL) + private LOG_TYPE type; + + @Column(name = "SUCCESS") + private Boolean success = Boolean.TRUE; + + @Column(name = "HTTP_RESPONSE", nullable = true) + private Integer httpResponse; + + @Column(name = "STATUS", length=200, nullable = true) + private String status; + + @Column(name = "COUNT", nullable = true) + private Long count = 0l; + + @Column(name = "ACTION", length=2000, nullable = true) + private String action; + + @CreatedDate + @Column(name = "CREATED_AT", nullable = true) + private Instant createdAt = Instant.now(); + + @Basic(fetch=LAZY) + @Lob + @EqualsAndHashCode.Exclude + @ToString.Exclude + @Column(name = "DEBUG_LOG") + private String debugLog; + + public int getPositiveHashCode() { return java.lang.Math.abs(this.hashCode()); } +} diff --git a/plus/api/src/main/java/org/sakaiproject/plus/api/model/LineItem.java b/plus/api/src/main/java/org/sakaiproject/plus/api/model/LineItem.java new file mode 100644 index 000000000000..760074e23fc2 --- /dev/null +++ b/plus/api/src/main/java/org/sakaiproject/plus/api/model/LineItem.java @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2021- Charles R. Severance + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package org.sakaiproject.plus.api.model; + +import java.time.Instant; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.Id; +import javax.persistence.Index; +import javax.persistence.UniqueConstraint; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; +import javax.persistence.OneToOne; +import javax.persistence.Table; +import javax.persistence.CascadeType; + +import org.sakaiproject.springframework.data.PersistableEntity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +@Entity +@Table(name = "PLUS_LINEITEM", + indexes = { @Index(columnList = "RESOURCE_ID, CONTEXT_GUID") }, + uniqueConstraints = { @UniqueConstraint(columnNames = { "RESOURCE_ID", "CONTEXT_GUID" }) } +) + +// https://www.imsglobal.org/spec/lti-ags/v2p0#line-item-service-scope-and-allowed-http-methods +@Data +public class LineItem extends BaseLTI implements PersistableEntity { + + // This is in effect a 1-to-1 with GB_GRADABLE_OBJECT_T.ID + @Id + @Column(name = "SAKAI_GRADABLE_OBJECT_ID", unique=true, nullable = false) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "CONTEXT_GUID", nullable = false) + @EqualsAndHashCode.Exclude + @ToString.Exclude + private Context context; + + // Can optionally belong to a link + @OneToOne(cascade = CascadeType.PERSIST) + @JoinColumn(name = "LINK_GUID", nullable = true) + @EqualsAndHashCode.Exclude + @ToString.Exclude + private Link link; + + // The AGS resourceId - recommended + @Column(name = "RESOURCE_ID", length = LENGTH_EXTERNAL_ID, nullable = true) + private String resourceId; + + @Column(name = "TAG", length = LENGTH_EXTERNAL_ID, nullable = true) + private String tag; + + @Column(name = "LABEL", length = LENGTH_TITLE, nullable = true) + private String label; + + @Column(name = "SCOREMAXIMUM") + private Double scoreMaximum; + + @Column(name = "STARTDATETIME") + private Instant startDateTime; + + @Column(name = "ENDDATETIME") + private Instant endDateTime; +} + +/* +{ + "id" : "https://lms.example.com/context/2923/lineitems/1", + "scoreMaximum" : 60, + "label" : "Chapter 5 Test", + "resourceId" : "a-9334df-33", + "tag" : "grade", + "resourceLinkId" : "1g3k4dlk49fk", + "startDateTime": "2018-03-06T20:05:02Z", + "endDateTime": "2018-04-06T22:05:03Z" +} +*/ diff --git a/plus/api/src/main/java/org/sakaiproject/plus/api/model/Link.java b/plus/api/src/main/java/org/sakaiproject/plus/api/model/Link.java new file mode 100644 index 000000000000..50b00168c269 --- /dev/null +++ b/plus/api/src/main/java/org/sakaiproject/plus/api/model/Link.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2021- Charles R. Severance + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package org.sakaiproject.plus.api.model; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; +import javax.persistence.Index; +import javax.persistence.UniqueConstraint; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; +import javax.persistence.Table; + +import org.hibernate.annotations.GenericGenerator; + +import org.sakaiproject.springframework.data.PersistableEntity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +@Entity +@Table(name = "PLUS_LINK", + indexes = { @Index(columnList = "LINK, CONTEXT_GUID, SAKAI_TOOL_ID") }, + uniqueConstraints = { @UniqueConstraint(columnNames = { "LINK", "CONTEXT_GUID" }) } +) +@Data +public class Link extends BaseLTI implements PersistableEntity { + + @Id + @Column(name = "LINK_GUID", length = LENGTH_GUID, nullable = false) + @GeneratedValue(generator = "uuid") + @GenericGenerator(name = "uuid", strategy = "uuid2") + private String id; + + @Column(name = "LINK", length = LENGTH_EXTERNAL_ID, nullable = false) + private String link; + + @Column(name = "SAKAI_TOOL_ID", length = LENGTH_SAKAI_ID, nullable = true) + private String sakaiToolId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "CONTEXT_GUID", nullable = false) + @EqualsAndHashCode.Exclude + @ToString.Exclude + private Context context; + + // We don't have a LineItem here because Plus ignores Basic Outcomes. + // Sakai has no internal concept of a single per-tool grade to return anyways. + // So we are all in on dynamically creating lineItems that correspond to + // a GB_GRADABLE_OBJECT - if we were to someday model a basic outcome + // we would make a different class, perhaps one that extends and overrides + // LineItem. + + @Column(name = "TITLE", length = LENGTH_TITLE, nullable = true) + private String title; + + @Column(name = "DESCRIPTION", length = LENGTH_MEDIUMTEXT, nullable = true) + private String description; +} diff --git a/plus/api/src/main/java/org/sakaiproject/plus/api/model/Membership.java b/plus/api/src/main/java/org/sakaiproject/plus/api/model/Membership.java new file mode 100644 index 000000000000..3f1853a0b1f5 --- /dev/null +++ b/plus/api/src/main/java/org/sakaiproject/plus/api/model/Membership.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2021- Charles R. Severance + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package org.sakaiproject.plus.api.model; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; +import javax.persistence.Index; +import javax.persistence.UniqueConstraint; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; +import javax.persistence.Table; + +import org.sakaiproject.springframework.data.PersistableEntity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +@Entity +@Table(name = "PLUS_MEMBERSHIP", + indexes = { @Index(columnList = "SUBJECT_GUID, CONTEXT_GUID") }, + uniqueConstraints = { @UniqueConstraint(columnNames = { "SUBJECT_GUID", "CONTEXT_GUID" }) } +) +@Data +public class Membership extends BaseLTI implements PersistableEntity { + + public static final Integer ROLE_LEARNER = 0; + public static final Integer ROLE_INSTRUCTOR = 1000; + + @Id @GeneratedValue + @Column(name = "MEMBERSHIP_ID") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "SUBJECT_GUID", nullable = false) + @EqualsAndHashCode.Exclude + @ToString.Exclude + private Subject subject; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "CONTEXT_GUID", nullable = false) + @EqualsAndHashCode.Exclude + @ToString.Exclude + private Context context; + + @Column(name = "ROLE", nullable = true) + private Integer role; + + @Column(name = "ROLE_OVERRIDE", nullable = true) + private Integer roleOverride; +} diff --git a/plus/api/src/main/java/org/sakaiproject/plus/api/model/Score.java b/plus/api/src/main/java/org/sakaiproject/plus/api/model/Score.java new file mode 100644 index 000000000000..fdd498622148 --- /dev/null +++ b/plus/api/src/main/java/org/sakaiproject/plus/api/model/Score.java @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2021- Charles R. Severance + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package org.sakaiproject.plus.api.model; + +import java.time.Instant; + +import javax.persistence.Column; +import javax.persistence.Lob; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; +import javax.persistence.Index; +import javax.persistence.UniqueConstraint; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; +import javax.persistence.Table; +import javax.persistence.CascadeType; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import javax.persistence.Basic; +import static javax.persistence.FetchType.LAZY; + +import org.hibernate.annotations.GenericGenerator; + +import org.sakaiproject.springframework.data.PersistableEntity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +// https://www.imsglobal.org/spec/lti-ags/v2p0#score-publish-service +@Entity +@Table(name = "PLUS_SCORE", + indexes = { @Index(columnList = "SUBJECT_GUID, SAKAI_GRADABLE_OBJECT_ID") }, + // The "logical key" (i.e. when to update versus insert new is subject / column) + uniqueConstraints = { @UniqueConstraint(columnNames = { "SUBJECT_GUID", "SAKAI_GRADABLE_OBJECT_ID" }) } +) +@Data +public class Score implements PersistableEntity { + + // These enums *names* must match the values in the spec as they are matched with strings at times + public enum ACTIVITY_PROGRESS { + Initialized, Started, InProgress, Submitted, Completed; + }; + + public enum GRADING_PROGRESS { + FullyGraded, Pending, PendingManual, Failed; + }; + + @Id + @Column(name = "SCORE_GUID", length = BaseLTI.LENGTH_GUID, nullable = false) + @GeneratedValue(generator = "uuid") + @GenericGenerator(name = "uuid", strategy = "uuid2") + private String id; + + // We have the line item string from SAKAI_GRADE_RECORD_ID - do we need anything else? + // GB_GRADE_RECORD contains the lineitem String we are to use + // so we don't need to link to the PLUS LineItem instance here + @Column(name = "SAKAI_GRADABLE_OBJECT_ID", nullable = false) + private Long gradeBookColumnId; + + @ManyToOne(cascade = CascadeType.PERSIST) + @JoinColumn(name = "SUBJECT_GUID", nullable = false) + @EqualsAndHashCode.Exclude + @ToString.Exclude + private Subject subject; + + @Column(name = "ACTIVITY_PROGRESS", nullable = true) + @Enumerated(EnumType.ORDINAL) + private ACTIVITY_PROGRESS activityProgress; + + @Column(name = "GRADING_PROGRESS", nullable = true) + @Enumerated(EnumType.ORDINAL) + private GRADING_PROGRESS gradingProgress; + + @Column(name = "SCORE_GIVEN", nullable = true) + private Double scoreGiven; + + @Column(name = "SCORE_MAXIMUM", nullable = true) + private Double scoreMaximum; + + @Column(name = "COMMENT", length=200, nullable = true) + private String comment; + + @Column(name = "UPDATED_AT", nullable = true) + private Instant updatedAt; + + @Column(name = "SENT_AT", nullable = true) + private Instant sentAt; + + @Column(name = "SUCCESS", length=200) + private Boolean success = Boolean.TRUE; + + @Column(name = "STATUS", length=200, nullable = true) + private String status; + + @Basic(fetch=LAZY) + @Lob + @Column(name = "DEBUG_LOG") + private String debugLog; + + public void setGradingProgress(String newStatus) + { + GRADING_PROGRESS gp = GRADING_PROGRESS.valueOf(newStatus); + gradingProgress = gp; + } + + public void setActivityProgress(String newStatus) + { + ACTIVITY_PROGRESS ap = ACTIVITY_PROGRESS.valueOf(newStatus); + activityProgress = ap; + } + +} diff --git a/plus/api/src/main/java/org/sakaiproject/plus/api/model/Subject.java b/plus/api/src/main/java/org/sakaiproject/plus/api/model/Subject.java new file mode 100644 index 000000000000..45e99fe025cf --- /dev/null +++ b/plus/api/src/main/java/org/sakaiproject/plus/api/model/Subject.java @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2021- Charles R. Severance + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package org.sakaiproject.plus.api.model; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; +import javax.persistence.Index; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; +import javax.persistence.Table; + +import org.hibernate.annotations.GenericGenerator; + +import org.sakaiproject.springframework.data.PersistableEntity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/* + * The logical key for this table is is either (tenant, email) or (tenant, subject) + * If we are trusting email, and we see a new subject for (tenant, email) we update new subject + * If we are trusting subject and see a new email for (tenant, subject) we update the email + */ +@Entity +@Table(name = "PLUS_SUBJECT", + indexes = @Index(columnList = "SUBJECT, TENNANT_GUID, SAKAI_USER_ID, EMAIL") +) +@Data +public class Subject extends BaseLTI implements PersistableEntity { + + @Id + @Column(name = "SUBJECT_GUID", length = LENGTH_GUID, nullable = false) + @GeneratedValue(generator = "uuid") + @GenericGenerator(name = "uuid", strategy = "uuid2") + private String id; + + @Column(name = "SAKAI_USER_ID", length = LENGTH_SAKAI_ID, nullable = true) + private String sakaiUserId; + + @Column(name = "SUBJECT", length = LENGTH_URI, nullable = false) + @EqualsAndHashCode.Exclude + @ToString.Exclude + private String subject; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "TENNANT_GUID", nullable = false) + @EqualsAndHashCode.Exclude + @ToString.Exclude + private Tenant tenant; + + @Column(name = "DISPLAYNAME", length = LENGTH_TITLE, nullable = true) + private String displayName; + + @Column(name = "EMAIL", length = LENGTH_TITLE, nullable = true) + private String email; + + @Column(name = "LOCALE", length = LENGTH_TITLE, nullable = true) + private String locale; +} diff --git a/plus/api/src/main/java/org/sakaiproject/plus/api/model/Tenant.java b/plus/api/src/main/java/org/sakaiproject/plus/api/model/Tenant.java new file mode 100644 index 000000000000..d5ac9d251019 --- /dev/null +++ b/plus/api/src/main/java/org/sakaiproject/plus/api/model/Tenant.java @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2021- Charles R. Severance + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package org.sakaiproject.plus.api.model; + +import java.time.Instant; + +import javax.persistence.Column; +import javax.persistence.Lob; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; +import javax.persistence.Index; +import javax.persistence.UniqueConstraint; +import javax.persistence.Table; +import javax.persistence.Basic; +import static javax.persistence.FetchType.LAZY; + +import org.hibernate.annotations.GenericGenerator; + +import org.sakaiproject.springframework.data.PersistableEntity; + +import lombok.Getter; +import lombok.Setter; + +import org.apache.commons.lang3.StringUtils; + +@Entity +@Table(name = "PLUS_TENANT", + indexes = { @Index(columnList = "ISSUER, CLIENT_ID") }, + uniqueConstraints = { @UniqueConstraint(columnNames = { "ISSUER", "CLIENT_ID" }) } +) +@Getter +@Setter +public class Tenant extends BaseLTI implements PersistableEntity { + + @Id + @Column(name = "TENNANT_GUID", length = LENGTH_GUID, nullable = false) + @GeneratedValue(generator = "uuid") + @GenericGenerator(name = "uuid", strategy = "uuid2") + private String id; + + @Column(name = "TITLE", length = LENGTH_TITLE, nullable = false) + private String title; + + @Column(name = "DESCRIPTION", length = LENGTH_MEDIUMTEXT, nullable = true) + private String description; + + // Issuer and client_id can be null while a key is being built but a key is not usable + // until both fields are defined and the other values are present + @Column(name = "ISSUER", length = LENGTH_EXTERNAL_ID, nullable = true) + protected String issuer; + + @Column(name = "CLIENT_ID", length = LENGTH_EXTERNAL_ID, nullable = true) + private String clientId; + + // This *may* be the *required* deployment_id as part of a security contract, + // But for Canvas, we can get many deployment_ids per clientId / issuer combination + // This deployment_id is used for authorization, the deployment_id for access + // token callbacks is stored in each context - thanks to Peter F. for this + // observation. + @Column(name = "DEPLOYMENT_ID", length = LENGTH_EXTERNAL_ID, nullable = true) + private String deploymentId; + + // Default this to true - it is the most common approach + @Column(name = "TRUST_EMAIL") + private Boolean trustEmail = Boolean.TRUE; + + @Column(name = "TIMEZONE", length = 100, nullable = true) + private String timeZone; + + @Column(name = "ALLOWED_TOOLS", length = 500, nullable = true) + private String allowedTools; + + @Column(name = "NEW_WINDOW_TOOLS", length = 500, nullable = true) + private String newWindowTools; + + @Column(name = "VERBOSE") + private Boolean verbose = Boolean.FALSE; + + @Column(name = "OIDC_AUTH", length = LENGTH_URI, nullable = true) + private String oidcAuth; + + @Column(name = "OIDC_KEYSET", length = LENGTH_URI, nullable = true) + private String oidcKeySet; + + @Column(name = "OIDC_TOKEN", length = LENGTH_URI, nullable = true) + private String oidcToken; + + // This is usually optional except for D2L + @Column(name = "OIDC_AUDIENCE", length = LENGTH_EXTERNAL_ID, nullable = true) + private String oidcAudience; + + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429 + // HTTP/1.1 429 Too Many Requests + // Content-Type: text/html + // Retry-After: Date: Wed, 21 Oct 2015 07:28:00 GMT + // Retry-After: 3600 + @Column(name = "RETRY_AT", nullable = true) + private Instant retryAt = null; + + @Lob + @Column(name = "CACHE_KEYSET", nullable = true) + private String cacheKeySet; + + // Need to unlock Dynamic registration + @Column(name = "OIDC_REGISTRATION_LOCK", length = LENGTH_EXTERNAL_ID, nullable = true) + private String oidcRegistrationLock; + + @Column(name = "OIDC_REGISTRATION_ENDPOINT", length = LENGTH_URI, nullable = true) + private String oidcRegistrationEndpoint; + + @Basic(fetch=LAZY) + @Lob + @Column(name = "OIDC_REGISTRATION", nullable = true) + private String oidcRegistration; + + public boolean isDraft() + { + if ( issuer == null || clientId == null || + oidcAuth == null || oidcKeySet == null || oidcToken == null ) return true; + + if ( issuer.length() < 1 || clientId.length() < 1 || + oidcAuth.length() < 1 || + oidcKeySet.length() < 1 || oidcToken.length() < 1 ) return true; + return false; + } + + /* + * Validate an incoming deployment_id against the tenant deployment_id + * + * For Canvas, they makes *lots* of deployment_id values for each clientId + * so the tenant deployment_id matching to incoming deployment_id values is + * not straighforward. So we have a heuristic match here. + * + * The *actual* deployment_id for use on AccessToken calls is kept + * in the Context object rather than the Tenant object. The tenant deployment_id is + * for authorization and the Context deployment_id is for callbacks. + * + */ + public boolean validateDeploymentId(String launchDeploymentId) + { + if ( deploymentId == null ) return true; + if ( StringUtils.isEmpty(deploymentId) ) return true; + if ( deploymentId.equals("*") ) return true; + if ( deploymentId.equals(launchDeploymentId) ) return true; + + // Allow for did1,did2,did3 as the deployment_id in case we need that later... + if ( deploymentId.contains(launchDeploymentId) ) return true; + return false; + } + +} diff --git a/plus/api/src/main/java/org/sakaiproject/plus/api/repository/ContextLogRepository.java b/plus/api/src/main/java/org/sakaiproject/plus/api/repository/ContextLogRepository.java new file mode 100644 index 000000000000..18a21b7ee063 --- /dev/null +++ b/plus/api/src/main/java/org/sakaiproject/plus/api/repository/ContextLogRepository.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2021- Charles R. Severance + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package org.sakaiproject.plus.api.repository; + +import java.util.List; + +import org.sakaiproject.plus.api.model.Context; +import org.sakaiproject.plus.api.model.ContextLog; +import org.sakaiproject.springframework.data.SpringCrudRepository; + +public interface ContextLogRepository extends SpringCrudRepository { + + public List getLogEntries(Context context, Boolean success, int limit); + + public int deleteOlderThanDays(int days); + +} diff --git a/plus/api/src/main/java/org/sakaiproject/plus/api/repository/ContextRepository.java b/plus/api/src/main/java/org/sakaiproject/plus/api/repository/ContextRepository.java new file mode 100644 index 000000000000..f83d9b8a4371 --- /dev/null +++ b/plus/api/src/main/java/org/sakaiproject/plus/api/repository/ContextRepository.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2021- Charles R. Severance + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package org.sakaiproject.plus.api.repository; + +import java.util.List; + +import org.sakaiproject.plus.api.model.Context; +import org.sakaiproject.plus.api.model.Tenant; +import org.sakaiproject.springframework.data.SpringCrudRepository; + +public interface ContextRepository extends SpringCrudRepository { + + public List findByTenant(Tenant tenant); + + public Context findByContextAndTenant(String context, Tenant tenant); + + public Context findBySakaiSiteId(String sakaiSiteId); + +} diff --git a/plus/api/src/main/java/org/sakaiproject/plus/api/repository/LineItemRepository.java b/plus/api/src/main/java/org/sakaiproject/plus/api/repository/LineItemRepository.java new file mode 100644 index 000000000000..3e4c54268aef --- /dev/null +++ b/plus/api/src/main/java/org/sakaiproject/plus/api/repository/LineItemRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2021- Charles R. Severance + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package org.sakaiproject.plus.api.repository; + +import org.sakaiproject.plus.api.model.LineItem; +import org.sakaiproject.springframework.data.SpringCrudRepository; + +public interface LineItemRepository extends SpringCrudRepository { + +} diff --git a/plus/api/src/main/java/org/sakaiproject/plus/api/repository/LinkRepository.java b/plus/api/src/main/java/org/sakaiproject/plus/api/repository/LinkRepository.java new file mode 100644 index 000000000000..a237a9bd67fb --- /dev/null +++ b/plus/api/src/main/java/org/sakaiproject/plus/api/repository/LinkRepository.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2021- Charles R. Severance + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package org.sakaiproject.plus.api.repository; + +import org.sakaiproject.plus.api.model.Context; +import org.sakaiproject.plus.api.model.Link; +import org.sakaiproject.springframework.data.SpringCrudRepository; + +public interface LinkRepository extends SpringCrudRepository { + + Link findByLinkAndContext(String link, Context context); + +} diff --git a/plus/api/src/main/java/org/sakaiproject/plus/api/repository/MembershipRepository.java b/plus/api/src/main/java/org/sakaiproject/plus/api/repository/MembershipRepository.java new file mode 100644 index 000000000000..d71995bd71cf --- /dev/null +++ b/plus/api/src/main/java/org/sakaiproject/plus/api/repository/MembershipRepository.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2021- Charles R. Severance + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package org.sakaiproject.plus.api.repository; + +import org.sakaiproject.plus.api.model.Context; +import org.sakaiproject.plus.api.model.Subject; +import org.sakaiproject.plus.api.model.Membership; +import org.sakaiproject.springframework.data.SpringCrudRepository; + +public interface MembershipRepository extends SpringCrudRepository { + + Membership findBySubjectAndContext(Subject subject, Context context); + + Membership upsert(Membership entity); + +} diff --git a/plus/api/src/main/java/org/sakaiproject/plus/api/repository/ScoreRepository.java b/plus/api/src/main/java/org/sakaiproject/plus/api/repository/ScoreRepository.java new file mode 100644 index 000000000000..022a544a8d85 --- /dev/null +++ b/plus/api/src/main/java/org/sakaiproject/plus/api/repository/ScoreRepository.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2021- Charles R. Severance + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package org.sakaiproject.plus.api.repository; + +import org.sakaiproject.plus.api.model.Score; +import org.sakaiproject.plus.api.model.Subject; +import org.sakaiproject.springframework.data.SpringCrudRepository; + +public interface ScoreRepository extends SpringCrudRepository { + + public Score findBySubjectAndColumn(Subject subject, Long gradeBookColumn); + + public Integer deleteBySubjectAndColumn(Subject subject, Long gradeBookColumn); + +} diff --git a/plus/api/src/main/java/org/sakaiproject/plus/api/repository/SubjectRepository.java b/plus/api/src/main/java/org/sakaiproject/plus/api/repository/SubjectRepository.java new file mode 100644 index 000000000000..764dee904b19 --- /dev/null +++ b/plus/api/src/main/java/org/sakaiproject/plus/api/repository/SubjectRepository.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2021- Charles R. Severance + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package org.sakaiproject.plus.api.repository; + +import org.sakaiproject.plus.api.model.Subject; +import org.sakaiproject.plus.api.model.Tenant; +import org.sakaiproject.springframework.data.SpringCrudRepository; + +public interface SubjectRepository extends SpringCrudRepository { + + public Subject findBySubjectAndTenant(String subject, Tenant tenant); + + public Subject findByEmailAndTenant(String email, Tenant tenant); + + public Subject findBySakaiUserIdAndSakaiSiteId(String userId, String siteId); + +} diff --git a/plus/api/src/main/java/org/sakaiproject/plus/api/repository/TenantRepository.java b/plus/api/src/main/java/org/sakaiproject/plus/api/repository/TenantRepository.java new file mode 100644 index 000000000000..da180b8f3d91 --- /dev/null +++ b/plus/api/src/main/java/org/sakaiproject/plus/api/repository/TenantRepository.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2021- Charles R. Severance + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package org.sakaiproject.plus.api.repository; + +import org.sakaiproject.plus.api.model.Tenant; +import org.sakaiproject.springframework.data.SpringCrudRepository; + +public interface TenantRepository extends SpringCrudRepository { + + public Tenant findByIssuerClientIdAndDeploymentId(String issuer, String clientId, String deploymentId); + + +} diff --git a/plus/docs/COMPLETED.md b/plus/docs/COMPLETED.md new file mode 100644 index 000000000000..9cf4afa102a8 --- /dev/null +++ b/plus/docs/COMPLETED.md @@ -0,0 +1,52 @@ + +COMPLETED LIST +============== + +This is just an archive of completed to do items as SakaiPlus was being developed. + +When auto-provisioning set the "Tool Supports LTI 1.3" + +Teach admin tool about the setting - add message if not enabled. + +Add isDraft() indicator to Tenant List in Admin UI + +Make sure columns are created and grades are flowing: + - Gradebook UI + - LTI 1.1 + - LTI 1.3 + - Assignments - Local - New Gradebook Item + - Assignments - LTI - Graded in Sakai + +Finish delete comment PR with Adrian and then re-merge to plus +https://sakaiproject.atlassian.net/browse/SAK-47279 + +Make verbose nicer for Earle (i.e. no System.out.println) + +Make sure we don't retrieve the context memberships too often. + +Teach Sakai to open new windows using JavaScript SAK-47769 + onclick="window.open(this.href,'_blank');return false;" + +Don't put each new syncSiteMemberships processing member=xyzzy@umich.edu in its own debug entry + +Make sure we research what it means when the grade is set to "nothing" + +Roster delay - Instructor 5 minutes Learner 30 minutes + +Expire old ContextLog entries + +Make Plus tool useful for instructors + +Write Test Plan + +Document properties + +Rationalise verbose and log.debug + +Add `deployment_id` to Context and use it if available - Thanks to Peter Fr. +Implement wildcard or comma separated values for `deployment_id` as option, + +Escape from iframe on launch. + +Move waterfall-lite to library and use standard spinner + diff --git a/plus/docs/INSTALL-BLACKBOARD.md b/plus/docs/INSTALL-BLACKBOARD.md new file mode 100644 index 000000000000..39a8b8b84a6a --- /dev/null +++ b/plus/docs/INSTALL-BLACKBOARD.md @@ -0,0 +1,159 @@ +Blackboard +---------- + +Blackboard is planning on supporting IMS Dynamic Configuration, but until they do, +you need to do a bit of cutting and pasting of URLs between the systems. + +To use this process, create a Tenant in Sakai Plus with a title and the following +information: + + Issuer: https://blackboard.com + OIDC Auth: https://developer.blackboard.com/api/v1/gateway/oidcauth + OIDC Token: https://developer.blackboard.com/api/v1/gateway/oauth2/jwttoken + +Then go into the Sakai Plus Registration for the tenant and grab the "Manual Configuration" +URLs so you can create an LTI 1.3 clientID in the Blackboard Developer Portal. Here +are some sample Sakai Plus URLs you will need for the Blackboard Developer portal: + + OIDC Login: https://dev1.sakaicloud.com/plus/sakai/oidc_login/654321 + OIDC Redirect: https://dev1.sakaicloud.com/plus/sakai/oidc_launch + OIDC KeySet: https://dev1.sakaicloud.com/imsblis/lti13/keyset + +Note that the `OIDC Login` value for Sakai Plus includes the Tenant ID for your +newly created Sakai Plus Tenant so it is unique for each Sakai Plus Tenant. The +Redirect and Keyset values are the same for all tenants. + +Use these Sakai Plus values in the Blackboard Developer portal to create an +LTI 1.3 integration. The developer portal will give you a Client Id and +per-client KeySet URL similar to the following: + + OIDC KeySet: https://developer.blackboard.com/api/vl/management/applications/fe3ebd13-39a4-42c4-8b83-194f08e77f8a/jwks.json + Client Id: fe3ebd13-39a4-42c4-8b83-194f08e77f8a + +The value in the KeySet is the same as the Client Id. You will need to update these values +in your Sakai Plus Tenant. + +Once you place Sakai Plus into a Blackboard instance you will be given +a Deployment Id for that integration. + + Deployment Id: ea4e4459-2363-348e-bd38-048993689aa0 + +Once you have updated your Sakai Plus tenant with the `Client ID`, +`Keyset URL`, and `Deployment ID` your security arrangement should be +set up. + +Once the Tenant has all the necessary security set up, there a number +of `target_link_uri` values that you can use. You can send a Deep Link +Canvas +------ + +Canvas does not support IMS Dynamic Registration but has their own JSON-based +automatic Registration process that is supported by Sakai Plus. + + https://canvas.instructure.com/doc/api/file.lti_dev_key_config.html + +To use this process, create a Tenant in Sakai Plus with a title and the following +information: + + Issuer: https://canvas.instructure.com + OIDC Auth: https://canvas.instructure.com/api/lti/authorize_redirect + OIDC KeySet: https://canvas.instructure.com/api/lti/security/jwks + OIDC Token: https://canvas.instructure.com/login/oauth2/token + +Make sure to check "Trust Email" - this needs to be set in the SakaiPlus Tenant +from the beginning. + +This is a partially complete tenant, to get the remaining data, go into +the Tenant detail page and find the Canvas URL that looks like: + + https://dev1.sakaicloud.com/plus/sakai/canvas-config.json?guid=1234567 + +Use this URL in the Canvas Admin -> Developer Keys -> + Developer Key -> + LTI Key. +Set Key Name, Title, and your email address. Then Choose "Enter URL" from the drop-down +and paste the URL for your Tenant in Sakai. Make sure not to have any spaces in +the URL. Then press "Save". The go back in to edit the key and make sure the +key is marked as "Public" in "Additional Settings", changing and saving if necessary. + +to create an integration. This integration +creates a Client Id similar to the following: + + Client Id: 85730000000000147 + +Then to install Sakai Plus into a course or set of courses, you must use the +Client Id to add the tool and it then gives you a Deployment ID. +For a single course, go to Settings -> View App Configurations -> + App. Then +choose "By Client ID" from the drop down and enter the ClientID from the previous +step and press "Submit". + + Deployment Id: 327:a17deed8f179b120bdd14743e67ca7916eaea622 + +Come back to Sakai Plus and update the Tenant to include both values and +your integration should start working. + +For Canvas, sometimes it generates *lots* of Deployment Id values, so you +can make authorization of SakaiPlus based only on Client Id by leaving +the Deployment Id blank/empty in the Tenant. SakaiPlus will track +Deployment Id on a per-context basis for AccessToken calls to the the LMSs. + +Sakai +----- + +For 21 and later versions of Sakai you can use IMS Dynamic Configuration. Create a Tenant with a title +(often SakaiPlus), issuer, and registration unlock code. Then go to the Plus -> Tenant detail +page and find the IMS Dynamic Registration url. + +Sometimes this is truly two independent servers but more commonly we are just setting this +up in a "loop back" configuration for testing. + +The issuer for a Sakai system is the base URL of the system without a trailing slash: + + https://sakai.school.edu + +For testing you might use and issuer like: + + https://localhost:8080 + +In both cases do not include a trailing slash. + +If you want to test both deep link and site launch make sure to add at least one tool plus +'sakai.site' to the allowed tools (i.e. like sakai.resources:sakai.site) + +Make sure to "trust email" or launches in the "loop back" use case site will log you out of the +browser you are launching from. This is only weird when we run both the main site and the plus +site on the same server (i.e. loop back testing). If these are different servers and URLs, +the logout at launch will not be a problem. + +For Dynamic Registration to work, Sakai Plus demands that the issuer in Sakai Plus +match the issuer provided by the LMS during the IMS Dynamic Configuration process. +The registration lock is single use and must be reset in Sakai Plus to re-run the Dynamic +Registration process. + +Once you have the Dynamic Registration URL like: + + http://localhost:8080/plus/sakai/dynamic/8efcdee4-96c3-44bf-92fd-1d901ad593a3?unlock_token=42 + +Go into Administration Workspace -> External Tools -> LTI Advantage Auto Provision, +give the new tool a title like "LMS End of Sakai Plus" and press "Auto-Provision". Then press +"Use LTI Advantage Auto Configuration" and paste in the Dynamic Registration URL, and run +the process. Make sure to enable: + +* Send email +* Send name +* Give access to services +* Choose the various placements (Lessons, etc.) +* Tool Supports LTI 1.3 +* Allow popup to be changed + +before saving the external tool. + +You can select both of the types of launches (and even the privacy placement) as long as the tool +url is something like "http../plus/sakai/" with no suffix like sakai.site or sakai.resources. + +* The tool URL can receive an LTI launch +* The tool can receive a Content-Item or Deep-Link launch + +Once the tool (or tools) are configured, save the tool. + +You can place the SakaiPlus site launch in the left navigation of a site using +Site Info -> Manage Tools -> External Tools (near the bottom of the tool list) + diff --git a/plus/docs/INSTALL-BRIGHTSPACE.md b/plus/docs/INSTALL-BRIGHTSPACE.md new file mode 100644 index 000000000000..ffa15f92560c --- /dev/null +++ b/plus/docs/INSTALL-BRIGHTSPACE.md @@ -0,0 +1,50 @@ +D2L BrightSpace +--------------- + +BrightSpace supports IMS Dynamic Configuration. Create a Tenant with a title, +issuer, and registration unlock code. Then go to the SakaiPlus Tenant detail page and find the IMS +Dynamic configuration URL and use that in the auto-provisioning screen of BrightSpace. + +The issuer for a D2L system is the base URL of the system without a trailing slash: + + https://school.brightspacedemo.com + +While Dynamic Registration is the easiest approach, you can create a draft Tenant +in Sakai Plus, then paste all the Sakai Plus URLs into Brightspace manually, save the tool +in Brightspace, then get copy the Brightspace URLs and edit your Sakai Plus +Tenant. Here are what typical values look like for Brightspace: + + Client ID: 04a7d304-477d-401a-b701-5a58f54772d6 + Deployment ID: 7862b2ce-79a0-77da-b2dd-7c77c4bb6e39 + LMS Authorization: https://school.brightspacedemo.com/d2l/lti/authenticate + LMS KeySet: https://school.brightspacedemo.com/d2l/.well-known/jwks + LMS Token: https://auth.brightspace.com/core/connect/token + LMS Token Audience: https://api.brightspace.com/auth/token + +Some of the values are local to the Brightspace school's URL and others +are global for all schools. + +The basic outline in Brightspace is to + +* Install an LTI Advantage Tool +* Create a Deployment for the tool +* Create a Link for the tool (this is what most LMS's call "Placement") + +Make sure to enable the security settings for `Org Unit Information`, +`User Information`,`Link Information`. If you do not send `Org Unit Information` +Sakai Pus will not know anything about the course it is being launched from. +And sending email is important because otherwise all the SakaiPlus accounts will +use the "subject" as the logical key for user accounts. SakaiPlus can function +without email - but it makes it a lot harder to re-connect user accounts later. + +For Dynamic Registration to work, Sakai Plus demands that the issuer in Sakai Plus +match the issuer provided by the LMS during the IMS Dynamic Configuration process. +The registration lock is single use and must be reset in Sakai Plus to re-run the Dynamic +Registration process. + +Here are some helpful URLs: + + https://documentation.brightspace.com/EN/integrations/ipsis/LTI%20Advantage/intro_to_LTI.htm + https://documentation.brightspace.com/EN/integrations/ipsis/LTI%20Advantage/LTI_register_external_learning_tool.htm + https://success.vitalsource.com/hc/en-gb/articles/360052454313-Brightspace-D2L-LTI-1-3-Tool-Setup + https://documentation.brightspace.com/EN/integrations/ipsis/LTI%20Advantage/deploy_external_learning_tool_for_LTI_A.htm diff --git a/plus/docs/INSTALL-CANVAS.md b/plus/docs/INSTALL-CANVAS.md new file mode 100644 index 000000000000..5d8f720193c9 --- /dev/null +++ b/plus/docs/INSTALL-CANVAS.md @@ -0,0 +1,50 @@ +Canvas +------ + +Canvas does not support IMS Dynamic Registration but has their own JSON-based +automatic Registration process that is supported by Sakai Plus. + + https://canvas.instructure.com/doc/api/file.lti_dev_key_config.html + +To use this process, create a Tenant in Sakai Plus with a title and the following +information: + + Issuer: https://canvas.instructure.com + OIDC Auth: https://canvas.instructure.com/api/lti/authorize_redirect + OIDC KeySet: https://canvas.instructure.com/api/lti/security/jwks + OIDC Token: https://canvas.instructure.com/login/oauth2/token + +Make sure to check "Trust Email" - this needs to be set in the SakaiPlus Tenant +from the beginning. + +This is a partially complete tenant, to get the remaining data, go into +the Tenant detail page and find the Canvas URL that looks like: + + https://dev1.sakaicloud.com/plus/sakai/canvas-config.json?guid=1234567 + +Use this URL in the Canvas Admin -> Developer Keys -> + Developer Key -> + LTI Key. +Set Key Name, Title, and your email address. Then Choose "Enter URL" from the drop-down +and paste the URL for your Tenant in Sakai. Make sure not to have any spaces in +the URL. Then press "Save". The go back in to edit the key and make sure the +key is marked as "Public" in "Additional Settings", changing and saving if necessary. + +to create an integration. This integration +creates a Client Id similar to the following: + + Client Id: 85730000000000147 + +Then to install Sakai Plus into a course or set of courses, you must use the +Client Id to add the tool and it then gives you a Deployment ID. +For a single course, go to Settings -> View App Configurations -> + App. Then +choose "By Client ID" from the drop down and enter the ClientID from the previous +step and press "Submit". + + Deployment Id: 327:a17deed8f179b120bdd14743e67ca7916eaea622 + +Come back to Sakai Plus and update the Tenant to include both values and +your integration should start working. + +For Canvas, sometimes it generates *lots* of Deployment Id values, so you +can make authorization of SakaiPlus based only on Client Id by leaving +the Deployment Id blank/empty in the Tenant. SakaiPlus will track +Deployment Id on a per-context basis for AccessToken calls to the the LMSs. diff --git a/plus/docs/INSTALL-MOODLE.md b/plus/docs/INSTALL-MOODLE.md new file mode 100644 index 000000000000..9c9707de5434 --- /dev/null +++ b/plus/docs/INSTALL-MOODLE.md @@ -0,0 +1,21 @@ +Moodle +------ + +For later versions of Moodle you can use IMS Dynamic Configuration. Create a Tenant with a title, +issuer, and registration unlock code. Then go to the Tenant detail page and find the IMS +Dynamic Registration URL and use that in the auto-provisioning screen of Moodle. + +The issuer for a Moodle system is the base URL of the system without a trailing slash: + + https://moodle.school.edu + +For testing you might use and issuer like: + + https://localhost:8888/moodle + +In both cases do not include a trailing slash. + +For Dynamic Registration to work, Sakai Plus demands that the issuer in Sakai Plus +match the issuer provided by the LMS during the IMS Dynamic Configuration process. +The registration lock is single use and must be reset in Sakai Plus to re-run the Dynamic +Registration process. diff --git a/plus/docs/INSTALL-SAKAI.md b/plus/docs/INSTALL-SAKAI.md new file mode 100644 index 000000000000..1dded6ae2356 --- /dev/null +++ b/plus/docs/INSTALL-SAKAI.md @@ -0,0 +1,62 @@ +Sakai +----- + +For 21 and later versions of Sakai you can use IMS Dynamic Configuration. Create a Tenant with a title +(often SakaiPlus), issuer, and registration unlock code. Then go to the Plus -> Tenant detail +page and find the IMS Dynamic Registration url. + +Sometimes this is truly two independent servers but more commonly we are just setting this +up in a "loop back" configuration for testing. + +The issuer for a Sakai system is the base URL of the system without a trailing slash: + + https://sakai.school.edu + +For testing you might use and issuer like: + + https://localhost:8080 + +In both cases do not include a trailing slash. + +If you want to test both deep link and site launch make sure to add at least one tool plus +'sakai.site' to the allowed tools (i.e. like sakai.resources:sakai.site) + +Make sure to "trust email" or launches in the "loop back" use case site will log you out of the +browser you are launching from. This is only weird when we run both the main site and the plus +site on the same server (i.e. loop back testing). If these are different servers and URLs, +the logout at launch will not be a problem. + +For Dynamic Registration to work, Sakai Plus demands that the issuer in Sakai Plus +match the issuer provided by the LMS during the IMS Dynamic Configuration process. +The registration lock is single use and must be reset in Sakai Plus to re-run the Dynamic +Registration process. + +Once you have the Dynamic Registration URL like: + + http://localhost:8080/plus/sakai/dynamic/8efcdee4-96c3-44bf-92fd-1d901ad593a3?unlock_token=42 + +Go into Administration Workspace -> External Tools -> LTI Advantage Auto Provision, +give the new tool a title like "LMS End of Sakai Plus" and press "Auto-Provision". Then press +"Use LTI Advantage Auto Configuration" and paste in the Dynamic Registration URL, and run +the process. Make sure to enable: + +* Send email +* Send name +* Give access to services +* Choose the various placements (Lessons, etc.) +* Tool Supports LTI 1.3 +* Allow popup to be changed + +before saving the external tool. + +You can select both of the types of launches (and even the privacy placement) as long as the tool +url is something like "http../plus/sakai/" with no suffix like sakai.site or sakai.resources. + +* The tool URL can receive an LTI launch +* The tool can receive a Content-Item or Deep-Link launch + +Once the tool (or tools) are configured, save the tool. + +You can place the SakaiPlus site launch in the left navigation of a site using +Site Info -> Manage Tools -> External Tools (near the bottom of the tool list) + diff --git a/plus/docs/NOTES.md b/plus/docs/NOTES.md new file mode 100644 index 000000000000..ea479ecf526f --- /dev/null +++ b/plus/docs/NOTES.md @@ -0,0 +1,16 @@ +SakaiPlus +========= + +General +------- + +Non-numeric scores are not transferred + +If you edit in SakaiPlus, it is re-sent, but if you edit in the controling LMS - SakaiPlus is unaware. + +Assigments +---------- + +Transfers only happen when the assignment grade is "released to the student". + + diff --git a/plus/docs/TESTING.md b/plus/docs/TESTING.md new file mode 100644 index 000000000000..224012412864 --- /dev/null +++ b/plus/docs/TESTING.md @@ -0,0 +1,136 @@ + +Testing SakaiPlus +================= + +Start up a Sakai + +Add an LTI 1.1 and and LTI Advantage tool that can return grades to your Sakai. This could +be Trophy or LMSTest, or really any LTI tool. We will want to test both LTI 1.1 and LTI Advantage +from the "Plus Site" and track grade flow back to the "Main Site". + +Follow the instructions in [Installing in Sakai](INSTALL-SAKAI.md) to set up a Sakai talking to Sakai. + +Create an instructor account and student account. Make sure both accounts have email addresses. +This allows Sakai to keep you logged into both sites at the same time. Don't use SakaiPlus +as 'admin' unless you add an email address to the admin account. + +Create a Sakai Site as the instructor and add the student account to the site - we will call +this the "Main Site" - this site will serve as a proxy for the launch, roster, and gradebook +in Canvas/D2L/Blackboard, etc. + +Don't log into the student yet - they should just be in the roster in the main site. + +As instructor, add the Gradebook and Lessons to the Main Site. Place the "SakaiPlus Launch" in Lessons +in the Main Site and tell it to open in a new window. Later you should test that if you +lauch sakai.site from SakaiPlus in an iframe - it will escape to a new window. Launches +to specific tools should stay in the iframe - the entire site launch should force +itself out of the iframe. + +Launch the Sakai Plus Link from the Main Site. You should now have two tabs - one on the +Main Site and one on the Plus Site - they will have the same name and you can't change that. +SakaiPlus keeps the name synchronized at each launch. + +From the Plus site, look at Site Info and verify that the roster has both the instructor and +student (i.e. before the student has even launched SakaiPlus). + +In the Plus Site, add Lessons, Gradebook, Samigo, Forums, and Assignments. There is a "sakai.plus" +tool that yon need to add to the site using the Admin because it is not in the Site Info list +of tools. In time - I might make a plus template that has this tool pre-installed to bypass +this step. + +Note that the Plus tool can be added to any site - but it will only show information for +a Plus site (i.e. if you add it to the Main Site you will never see any data). + +Go into the Sakai Plus tool. You should be able to see some information and a debug log of recent +activity. At this point you should see one launch and one roster retrieval. The log of the +retrieval should show that both the instructor and student records were retrieved. + +Go into Gradebook and add a column. Give it a name, score, and due date. Save it and verify it +exists in the Plus Site. Go into the Plus tool and you should see a debug entry for creating +the score via the AGS service. Switch to the Main Site and check the Gradebook and verify +that the score and due date match between the sites. + +The go back to the Plus Site and edit the colum details, change the score and due date and save. +Then check the Plus tool and verify that an update web service request was sent. Then go into the +Main site and verify that whatever values you changed are correct. + +Now close the tab with the Plus Site and go back to the Main Site and re-launch the Plus +Site. Then use the Plus tool to look at the debug. If it has been more than 5 minutes +the roster should be re-retrieved. If less that five minutes between launches, the roster +will not be re-retrieved. + +Now close the tab with the Plus Site again and go back to the Main Site and re-launch the Plus +Site. Then use the Plus tool to look at the debug. Now you will see that since there +were two launches within five minutes - there won't be a new roster retrieval for the latest +launch. + +Go into the gradebook in the Plus Site, add a score for your student. Check the Plus +tool and make sure a web service call happenned and was successful. Then go to the +Main Site and check the grade made it. + +Go back to Plus site and add a comment in the gradebook - check that it makes it +to the Main gradebook. + +Go back to Plus site and edit the comment and score in the gradebook - check +that both makes it to the Main gradebook. + +Now log in as the student, go to the Main Site - check the Gradebook - the student +should see the latest values. Launch into the Plus Site and check the gradebook - +again all values should be correct. + +Launch the Plus tool as the student - it should not show any logging data. After this +test, you can hide the tool from students - it is not intended (so far) to give students +any UI - in the future we might add a student view of this tool. + +One feature of SakaiPlus is site title synchronization. One each launch, the site title +is passed from the main site to the plus site. You can test by changing the main site +title then launching the plus site and watching the title change. If you change the plus +site title and re-launch from the main stie the plus site title should be overwritten. +It can be a little weird for there to be two titles in the top bar - but yu can keep track +of which is which by the tools in each site or the content in the site - perhaps something +on the overview page. This loop-back pattern is not really intended for production +use or deployment - it is really just for testing. + +Now we need to generate grades in the Plus site and keep checking to see they make it to +the Main site. Here are some scenarios: + +* Lessons launching an an LTI 1.1 tool +* Lessons launching an an LTI Advantage tool +* Assignments - Local - Associate with existing gradebook item +* Assignments - Local - Create new gradebook item (verify points make it to Main) +* Assignment with an LTI 1.1 tool +* Assignment with an LTI Advantage tool +* Assignments - Peer graded +* Forums +* Samigo + +At some point add more students to the course. The next launch from a newly added account +or an existing account should pull down the roster. Instructor launches delay pulling down +a new roster until five minutes pass. Student launches delay pulling down a new roster +until 30 minutes pass. + +One thing to test after you add a few more students is the Gradebook feature to +set all the blank columns to some score. WHen this is done in Sakai - all the new +scores should be sent to the Main Site. + +As a note, SakaiPlus handles numeric scores. Letter scores and pass/fail scores in the Plus site +will not be transported to the Main site. + + +TODO +---- + +Document a test plan for the Deep Link / Content Item use cases with Plus + +Test with zero, one and two allowedTools - it treats these use cases quite differently. +If there is > 1 tool, you see a set of cards (like Tsugi) to allow you to choose which +Sakai tool to install. With 1 tool, it just auto-chooses that tool and installs it with +no list of cards. If there are zero tools (not a very usefu use case) it falls back to +installing `sakai.site`. + +You need to launch tools in an iframe and then in a new window. It is easy to do this in +Lessons. Neither shoudl show any of the site list or tool list navigation (see screenshots +in JIRA) + + + diff --git a/plus/impl/pom.xml b/plus/impl/pom.xml new file mode 100644 index 000000000000..8fe169692941 --- /dev/null +++ b/plus/impl/pom.xml @@ -0,0 +1,155 @@ + + + + 4.0.0 + + + org.sakaiproject.plus + sakai-plus-base + 23-SNAPSHOT + + + sakai-plus-impl + sakai-plus-impl + org.sakaiproject.plus + sakai-component + + + + org.sakaiproject.kernel + sakai-kernel-api + + + org.sakaiproject.kernel + sakai-kernel-util + + + org.sakaiproject.kernel + sakai-component-manager + + + org.sakaiproject.plus + sakai-plus-api + + + org.sakaiproject.search + search-api + + + org.sakaiproject.sitestats + sitestats-api + + + org.sakaiproject.entitybroker + entitybroker-api + + + org.springframework + spring-core + + + org.springframework.data + spring-data-jpa + + + org.springframework + spring-context + + + org.springframework + spring-orm + + + org.springframework + spring-tx + + + org.springframework + spring-test + + + org.mockito + mockito-core + + + org.sakaiproject.basiclti + basiclti-api + + + org.sakaiproject.basiclti + basiclti-util + + + org.sakaiproject.basiclti + basiclti-common + ${sakai.version} + + + javax.servlet + javax.servlet-api + + + org.hsqldb + hsqldb + + + org.hibernate + hibernate-core + + + org.apache.commons + commons-lang3 + + + com.googlecode.json-simple + json-simple + + + com.fasterxml.jackson.core + jackson-core + + + com.fasterxml.jackson.core + jackson-annotations + + + com.fasterxml.jackson.core + jackson-databind + + + org.sakaiproject.grading + sakai-grading-api + + + org.sakaiproject.edu-services.sections + sections-api + + + org.quartz-scheduler + quartz + + + + + src/main/java + + + org.sakaiproject.maven.plugins + sakai + + src/main/webapp + + + + + + ${basedir}/src/main/webapp + + + ${basedir}/src/test/resources + + + + diff --git a/plus/impl/src/main/java/org/sakaiproject/plus/impl/PlusEventObserver.java b/plus/impl/src/main/java/org/sakaiproject/plus/impl/PlusEventObserver.java new file mode 100644 index 000000000000..41cbaadd8021 --- /dev/null +++ b/plus/impl/src/main/java/org/sakaiproject/plus/impl/PlusEventObserver.java @@ -0,0 +1,69 @@ +/** + * Copyright (c) 2003-2020 The Apereo Foundation + * + * Licensed under the Educational Community License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://opensource.org/licenses/ecl2 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.sakaiproject.plus.impl; + +import java.util.Observable; +import java.util.Observer; + +import org.apache.commons.lang3.StringUtils; +import org.sakaiproject.event.api.Event; +import org.sakaiproject.event.api.EventTrackingService; +import org.sakaiproject.plus.api.PlusService; + +import org.springframework.beans.factory.annotation.Autowired; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class PlusEventObserver implements Observer { + + @Autowired private EventTrackingService eventTrackingService; + @Autowired private PlusService plusService; + + public void init() { + eventTrackingService.addLocalObserver(this); + } + + public void destroy() { + eventTrackingService.deleteObserver(this); + } + + /* Two Kinds of events + * + * From the UI: + * gradebook.updateItemScore@/gradebookng/7/12/55a0c76a-69e2-4ca7-816b-3c2e8fe38ce0/42/OK/instructor[m, 2] + * + * From web services: + * gradebook.updateItemScore@/gradebook/a77ed1b6-ceea-4339-ad60-8bbe7219f3b5/Trophy/55a0c76a-69e2-4ca7-816b-3c2e8fe38ce0/99.0/student[m, 2] + * + */ + @Override + public void update(Observable o, Object arg) { + if (arg instanceof Event) { + Event event = (Event) arg; + if (event.getModify() && StringUtils.isNoneBlank(event.getEvent())) { + switch (event.getEvent()) { + case "gradebook.updateItemScore": // grade updated in gradebook lets attempt to update the submission + plusService.processGradeEvent(event); + break; + default: + log.debug("This observer is not interested in event [{}]", event); + break; + } + } + } + } +} diff --git a/plus/impl/src/main/java/org/sakaiproject/plus/impl/PlusServiceImpl.java b/plus/impl/src/main/java/org/sakaiproject/plus/impl/PlusServiceImpl.java new file mode 100644 index 000000000000..0a431c7c089e --- /dev/null +++ b/plus/impl/src/main/java/org/sakaiproject/plus/impl/PlusServiceImpl.java @@ -0,0 +1,1426 @@ +/* + * Copyright (c) 2021- Charles R. Severance + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package org.sakaiproject.plus.impl; + +import java.lang.StringBuffer; + +import java.util.Date; + +import java.util.Map; +import java.util.TreeMap; +import java.util.Optional; + +import java.io.InputStream; + +import java.time.Instant; + +import java.net.http.HttpResponse; // Thanks Java 11 + +import java.security.KeyPair; + +import org.apache.commons.lang3.StringUtils; +import static org.apache.commons.lang3.StringUtils.isEmpty; +import static org.apache.commons.lang3.StringUtils.isNotEmpty; + +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; + +import org.sakaiproject.user.api.User; +import org.sakaiproject.site.api.Site; +import org.sakaiproject.event.api.Event; + +import org.sakaiproject.lti.api.LTIException; +import org.sakaiproject.component.api.ServerConfigurationService; +import org.sakaiproject.grading.api.AssessmentNotFoundException; +import org.sakaiproject.grading.api.GradingService; +import org.sakaiproject.grading.api.CommentDefinition; + +import org.sakaiproject.plus.api.Launch; +import org.sakaiproject.plus.api.PlusService; +import org.sakaiproject.plus.api.model.Context; +import org.sakaiproject.plus.api.model.ContextLog; +import org.sakaiproject.plus.api.model.Link; +import org.sakaiproject.plus.api.model.Subject; +import org.sakaiproject.plus.api.model.Tenant; +import org.sakaiproject.plus.api.model.Membership; +import org.sakaiproject.plus.api.repository.ContextRepository; +import org.sakaiproject.plus.api.repository.ContextLogRepository; +import org.sakaiproject.plus.api.repository.LineItemRepository; +import org.sakaiproject.plus.api.repository.LinkRepository; +import org.sakaiproject.plus.api.repository.ScoreRepository; +import org.sakaiproject.plus.api.repository.SubjectRepository; +import org.sakaiproject.plus.api.repository.TenantRepository; +import org.sakaiproject.plus.api.repository.MembershipRepository; +import org.springframework.beans.factory.annotation.Autowired; + +import org.tsugi.http.HttpClientUtil; + +import org.tsugi.basiclti.BasicLTIConstants; +import org.tsugi.basiclti.BasicLTIUtil; +import org.sakaiproject.basiclti.util.SakaiBLTIUtil; + +import org.sakaiproject.lti.api.UserFinderOrCreator; +import org.sakaiproject.lti.api.SiteEmailPreferenceSetter; +import org.sakaiproject.lti.api.SiteMembershipUpdater; + +import org.sakaiproject.basiclti.util.SakaiKeySetUtil; +import org.tsugi.jackson.JacksonUtil; +import org.sakaiproject.lti13.util.SakaiLaunchJWT; + +import org.tsugi.lti13.objects.LaunchJWT; +import org.tsugi.lti13.LTI13Util; +import org.tsugi.lti13.LTI13AccessTokenUtil; +import org.tsugi.lti13.LTI13ConstantsUtil; + +import org.tsugi.nrps.objects.Member; +import org.tsugi.oauth2.objects.AccessToken; +import org.tsugi.ags2.objects.LineItem; +import org.tsugi.ags2.objects.Score; + +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Setter +public class PlusServiceImpl implements PlusService { + + @Autowired private TenantRepository tenantRepository; + @Autowired private SubjectRepository subjectRepository; + @Autowired private ContextRepository contextRepository; + @Autowired private ContextLogRepository contextLogRepository; + @Autowired private LinkRepository linkRepository; + @Autowired private LineItemRepository lineItemRepository; + @Autowired private ScoreRepository scoreRepository; + @Autowired private MembershipRepository membershipRepository; + + @Autowired private GradingService gradingService; + @Autowired private SiteMembershipUpdater siteMembershipUpdater; + @Autowired private SiteEmailPreferenceSetter siteEmailPreferenceSetter; + @Autowired private UserFinderOrCreator userFinderOrCreator; + @Autowired private ServerConfigurationService serverConfigurationService; + + /* + * Indicate if plus is enabled on this system + */ + @Override + public boolean enabled() + { + return serverConfigurationService.getBoolean(PlusService.PLUS_PROVIDER_ENABLED, PlusService.PLUS_PROVIDER_ENABLED_DEFAULT); + } + + /* + * Indicate if plus is enabled on a Site + */ + @Override + public boolean enabled(Site site) + { + String plus_property = site.getProperties().getProperty(PlusService.PLUS_PROPERTY); + return "true".equals(plus_property); + } + + /* + * Return various URLs for Sakai Plus + */ + @Override + public String getPlusServletPath() { + return SakaiBLTIUtil.getOurServerUrl() + "/plus/sakai"; + } + + @Override + public String getOidcKeySet() { + return SakaiBLTIUtil.getOurServerUrl() + "/imsblis/lti13/keyset"; + } + + @Override + public String getOidcLaunch() { + return getPlusServletPath() + "/oidc_launch"; + } + + @Override + public String getOidcLogin(Tenant tenant) { + return getPlusServletPath() + "/oidc_login/" + tenant.getId(); + } + + @Override + public String getIMSDynamicRegistration(Tenant tenant) { + if ( StringUtils.isEmpty(tenant.getOidcRegistrationLock()) ) return null; + return getPlusServletPath() + "/dynamic/" + tenant.getId() + "?unlock_token=" + tenant.getOidcRegistrationLock(); + } + + @Override + public String getCanvasConfig(Tenant tenant) { + return getPlusServletPath() + "/canvas-config.json?guid=" + tenant.getId(); + } + + /* + * Indicate if verbose debugging is enabled + */ + @Override + public boolean verbose() + { + if ( log.isDebugEnabled() ) return true; + return (serverConfigurationService.getBoolean(PlusService.PLUS_DEBUG_VERBOSE, PlusService.PLUS_DEBUG_VERBOSE_DEFAULT)); + } + + /* + * Indicate if verbose debugging is enabled + */ + @Override + public boolean verbose(Tenant tenant) + { + if ( tenant != null && tenant.getVerbose() ) return true; + return verbose(); + } + + /* + * Handle the initial launch - creating objects as needed (a.k.a. The BIG LEFT JOIN) + */ + public Launch updateAll(LaunchJWT launchJWT, Tenant tenant) + throws LTIException + { + if ( launchJWT == null || tenant == null ) { + throw new LTIException("plus.plusservice.null", null, null); + } + + if ( tenant.getId() == null ) { + throw new LTIException("plus.plusservice.tenant.persist", null, null); + } + + String issuer = launchJWT.issuer; + String clientId = launchJWT.audience; + String deploymentId = launchJWT.deployment_id; + + String missing = ""; + if ( issuer == null ) { + missing = missing + "issuer null "; + } else if (! issuer.equals(tenant.getIssuer()) ) { + missing = missing + "issuer mismatch " + issuer + "/" + tenant.getIssuer(); + } + + if ( clientId == null ) { + missing = missing + "clientId null "; + } else if (! clientId.equals(tenant.getClientId()) ) { + missing = missing + "clientId mismatch " + clientId + "/" + tenant.getClientId(); + } + + if ( deploymentId == null ) { + missing = missing + "deploymentId null "; + } else if (! tenant.validateDeploymentId(deploymentId) ) { + missing = missing + "deploymentId mismatch " + deploymentId + "/" + tenant.getDeploymentId(); + } + + if ( ! missing.equals("") ) { + throw new LTIException("plus.plusservice.tenant.check", missing, null); + } + + String contextId = launchJWT.context != null ? launchJWT.context.id : null; + String subjectId = launchJWT.subject; + String linkId = launchJWT.resource_link != null ? launchJWT.resource_link.id : null; + + Launch launch = new Launch(); + launch.tenant = tenant; + + boolean changed = false; + Subject subject = null; + if ( subjectId != null ) { + subject = createOrUpdateSubject(tenant, subjectId, launchJWT); + launch.subject = subject; + } + + Context context = null; + if ( contextId != null ) { + context = contextRepository.findByContextAndTenant(contextId, tenant); + changed = false; + if ( context == null ) { + context = new Context(); + context.setContext(contextId); + context.setTenant(tenant); + context.setDeploymentId(launchJWT.deployment_id); + context.setTitle(launchJWT.context.title); + context.setLabel(launchJWT.context.label); + if ( launchJWT.endpoint != null && launchJWT.endpoint.lineitems != null ) context.setLineItems(launchJWT.endpoint.lineitems); + if ( launchJWT.names_and_roles != null && launchJWT.names_and_roles.context_memberships_url != null ) { + context.setContextMemberships(launchJWT.names_and_roles.context_memberships_url); + } + changed = true; + } else { + if ( StringUtils.compare(context.getDeploymentId(), launchJWT.deployment_id) != 0 ) { + context.setDeploymentId(launchJWT.deployment_id); + changed = true; + } + if ( StringUtils.compare(context.getLabel(), launchJWT.context.label) != 0 ) { + context.setLabel(launchJWT.context.label); + changed = true; + } + if ( StringUtils.compare(context.getTitle(), launchJWT.context.title) != 0 ) { + context.setTitle(launchJWT.context.title); + changed = true; + } + if ( StringUtils.compare(context.getLabel(), launchJWT.context.label) != 0 ) { + context.setLabel(launchJWT.context.label); + changed = true; + } + + if ( launchJWT.endpoint != null && launchJWT.endpoint.lineitems != null && + StringUtils.compare(context.getLineItems(), launchJWT.endpoint.lineitems) != 0 ) { + context.setLineItems(launchJWT.endpoint.lineitems); + changed = true; + } + if ( launchJWT.names_and_roles != null && launchJWT.names_and_roles.context_memberships_url != null && + StringUtils.compare(context.getContextMemberships(), launchJWT.names_and_roles.context_memberships_url) != 0 ) { + context.setContextMemberships(launchJWT.names_and_roles.context_memberships_url); + changed = true; + } + } + if ( changed) contextRepository.save(context); + launch.context = context; + } + + Membership membership; + if ( subject != null && context != null ) { + membership = new Membership(); + membership.setSubject(subject); + membership.setContext(context); + if (launchJWT.isInstructor() ) { + membership.setRole(Membership.ROLE_INSTRUCTOR); + } else { + membership.setRole(Membership.ROLE_LEARNER); + } + membership = membershipRepository.upsert(membership); + } + + if ( linkId != null && context != null ) { + Link link = linkRepository.findByLinkAndContext(linkId, context); + changed = false; + if ( link == null ) { + link = new Link(); + link.setLink(linkId); + link.setContext(context); + link.setTitle(launchJWT.resource_link.title); + link.setDescription(launchJWT.resource_link.description); + changed = true; + } else { + if ( StringUtils.compare(link.getTitle(), launchJWT.resource_link.title) != 0 ) { + link.setTitle(launchJWT.resource_link.title); + changed = true; + } + if ( StringUtils.compare(link.getDescription(), launchJWT.resource_link.description) != 0 ) { + link.setDescription(launchJWT.resource_link.description); + changed = true; + } + } + if ( changed) linkRepository.save(link); + launch.link = link; + } + + return launch; + } + + public Subject createOrUpdateSubject(Tenant tenant, String subjectId, LaunchJWT launchJWT) + { + Subject subject = null; + boolean changed = false; + if ( subjectId != null ) { + subject = subjectRepository.findBySubjectAndTenant(subjectId, tenant); + if ( subject == null ) { + subject = new Subject(); + subject.setSubject(subjectId); + subject.setTenant(tenant); + subject.setEmail(launchJWT.email); + subject.setDisplayName(launchJWT.getDisplayName()); + changed = true; + } else { + if ( StringUtils.compare(subject.getEmail(), launchJWT.email) != 0 ) { + subject.setEmail(launchJWT.email); + changed = true; + } + if ( StringUtils.compare(subject.getDisplayName(), launchJWT.getDisplayName() ) != 0 ) { + subject.setDisplayName(launchJWT.getDisplayName()); + changed = true; + } + } + if ( changed ) { + subjectRepository.save(subject); + } + } + return subject; + } + + @Override + public Map getPayloadFromLaunchJWT(Tenant tenant, LaunchJWT launchJWT) + { + // Store this all in payload for future use and to share some of the + // processing code between LTI 1.1 and Advantage + Map payload = new TreeMap<>(); + payload.put("issuer", launchJWT.issuer); + payload.put("client_id", launchJWT.audience); // Note name change + payload.put("deployment_id", launchJWT.deployment_id); + payload.put("oidc_token", tenant.getOidcToken()); + payload.put("oidc_audience", tenant.getOidcAudience()); + payload.put(BasicLTIConstants.LTI_MESSAGE_TYPE, launchJWT.message_type); + + if ( launchJWT.context != null ) { + if ( launchJWT.context.title != null ) payload.put(BasicLTIConstants.CONTEXT_TITLE, launchJWT.context.title); + if ( launchJWT.context.label != null ) payload.put(BasicLTIConstants.CONTEXT_LABEL, launchJWT.context.label); + } + + // https://www.imsglobal.org/spec/lti/v1p3/#resource-link-claim + String linkId = launchJWT.resource_link != null ? launchJWT.resource_link.id : null; + if ( linkId != null ) { + payload.put(BasicLTIConstants.RESOURCE_LINK_ID, linkId); + if ( launchJWT.resource_link.title != null ) payload.put(BasicLTIConstants.RESOURCE_LINK_TITLE, launchJWT.resource_link.title); + if ( launchJWT.resource_link.description != null ) payload.put(BasicLTIConstants.RESOURCE_LINK_DESCRIPTION, launchJWT.resource_link.description); + } + + // User data + payload.put(BasicLTIConstants.USER_ID, launchJWT.subject); + payload.put(BasicLTIConstants.LAUNCH_PRESENTATION_LOCALE, launchJWT.locale); + payload.put(BasicLTIConstants.LIS_PERSON_CONTACT_EMAIL_PRIMARY, launchJWT.email); + payload.put(BasicLTIConstants.LIS_PERSON_NAME_GIVEN, launchJWT.given_name); + payload.put(BasicLTIConstants.LIS_PERSON_NAME_FAMILY, launchJWT.family_name); + // payload.put(BasicLTIConstants.LIS_PERSON_NAME_MIDDLE, launchJWT.middle_name); + + if ( launchJWT.roles != null ) { + StringBuilder roles = new StringBuilder(); + for (String role : launchJWT.roles) { + if ( roles.length() > 0 ) roles.append(','); + roles.append(role); + } + if ( roles.length() > 0 ) payload.put(BasicLTIConstants.ROLES, roles.toString()); + } + + // TODO: Ask for this in custom... + // payload.put(BasicLTIConstants.USER_IMAGE, ); + // payload.put("ext_email_delivery_preference", ); + + // Because basiclti-common can't (yet) be in shared + if ( launchJWT instanceof SakaiLaunchJWT ) { + SakaiLaunchJWT sakaiLaunchJWT = (SakaiLaunchJWT) launchJWT; + if ( sakaiLaunchJWT.sakai_extension != null ) { + if ( isNotEmpty(sakaiLaunchJWT.sakai_extension.sakai_eid) ) { + payload.put(BasicLTIConstants.EXT_SAKAI_PROVIDER_EID, sakaiLaunchJWT.sakai_extension.sakai_eid); + } + payload.put("ext_sakai_server", sakaiLaunchJWT.sakai_extension.sakai_server); + payload.put("ext_sakai_serverid", sakaiLaunchJWT.sakai_extension.sakai_serverid); + payload.put("ext_sakai_role", sakaiLaunchJWT.sakai_extension.sakai_role); + payload.put("ext_sakai_academic_session", sakaiLaunchJWT.sakai_extension.sakai_academic_session); + } + } + + // https://www.imsglobal.org/spec/lti/v1p3/#platform-instance-claim + // payload.put("ext_lms", ); + + return payload; + } + + /* + * Make sure the Subject knows about the chosen user + */ + public void connectSubjectAndUser(Subject subject, User user) + throws LTIException + { + if ( subject == null || user == null ) { + throw new LTIException( "plus.plusservice.null.parameters", "subject or user", null); + } + + if ( isEmpty(subject.getId()) ) { + throw new LTIException( "plus.plusservice.not.persisted", "subject", null); + } + + if ( isEmpty(user.getId()) ) { + throw new LTIException( "plus.plusservice.not.persisted", "user", null); + } + + // After all that error checking, it is pretty simple + // TODO: Should we make sure there is only one (Tenant / SakaiUserId) record? + // This can lead to more than one subject with a particular SakaiID + subject.setSakaiUserId(user.getId()); + subjectRepository.save(subject); + } + + /* + * Make sure the Context knows about the chosen site + */ + @Override + public void connectContextAndSite(Context context, Site site) + throws LTIException + { + if ( context == null || site == null ) { + throw new LTIException( "plus.plusservice.null.parameters", "context or site", null); + } + + if ( isEmpty(context.getId()) ) { + throw new LTIException( "plus.plusservice.not.persisted", "context", null); + } + + if ( isEmpty(site.getId()) ) { + throw new LTIException( "plus.plusservice.site.not.persisted", "site", null); + } + + // After all that error checking, it is prety simple + context.setSakaiSiteId(site.getId()); + contextRepository.save(context); + } + + /* + * Make sure the Link knows about the chosen placement + */ + @Override + public void connectLinkAndPlacement(Link link, String placementId) + throws LTIException + { + if ( link == null ) { + throw new LTIException( "plus.plusservice.null.parameters", "link", null); + } + + if ( isEmpty(link.getId()) ) { + throw new LTIException( "plus.plusservice.not.persisted", "link", null); + } + + if ( isEmpty(placementId) ) { + throw new LTIException( "plus.plusservice.site.null.parameters", "placement", null); + } + + // After all that error checking, it is prety simple + link.setSakaiToolId(placementId); + linkRepository.save(link); + } + + /* + * Retrieve Context Memberships from calling LMS and update the site in Sakai + */ + @Override + public void syncSiteMemberships(String contextGuid, Site site) throws LTIException { + + log.debug("synchSiteMemberships"); + + if (!serverConfigurationService.getBoolean(PlusService.PLUS_ROSTER_SYCHRONIZATION, PlusService.PLUS_ROSTER_SYCHRONIZATION_DEFAULT)) { + log.info("LTI Memberships synchronization disabled."); + return; + } + + if (isEmpty(contextGuid) ) { + log.error("Context GUID is required. Memberships will NOT be synchronized."); + return; + } + + Optional optContext = contextRepository.findById(contextGuid); + Context context = null; + if ( optContext.isPresent() ) { + context = optContext.get(); + } + + if ( context == null ) { + log.info("Context notfound {}", contextGuid); + return; + } + + String tenantGuid = context.getTenant().getId(); + String contextMemberships = context.getContextMemberships(); + + if (isEmpty(tenantGuid)) { + log.info("Context {} does not have a tenant. Memberships will NOT be synchronized.", contextGuid); + return; + } + + if (isEmpty(contextMemberships)) { + log.info("Context {} does not have Memberships URL. Memberships will NOT be synchronized.", contextGuid); + return; + } + + // Load the Tenant + Optional optTenant = tenantRepository.findById(tenantGuid); + Tenant tenant = null; + if ( optTenant.isPresent() ) { + tenant = optTenant.get(); + } + + if ( tenant == null ) { + log.info("Tenant notfound {}", tenantGuid); + return; + } + + String clientId = tenant.getClientId(); + String deploymentId = context.getDeploymentId(); + String oidcTokenUrl = tenant.getOidcToken(); + String oidcAudience = tenant.getOidcAudience(); + if ( isEmpty(oidcAudience) ) oidcAudience = oidcTokenUrl; + + if (isEmpty(clientId)) { + log.info("Tenant {} does not have clientId. Memberships will NOT be synchronized.", tenantGuid); + return; + } + + if (isEmpty(deploymentId)) { + log.info("Context {} does not have deploymentId. Memberships will NOT be synchronized.", contextGuid); + return; + } + + if (isEmpty(oidcTokenUrl)) { + log.info("Tenant {} does not have an OIDC Token URL. Memberships will NOT be synchronized.", tenantGuid); + return; + } + + boolean isEmailTrustedConsumer = ! Boolean.FALSE.equals(tenant.getTrustEmail()); + + // Prepare for Per-Context log + ContextLog cLog = new ContextLog(); + cLog.setContext(context); + cLog.setType(ContextLog.LOG_TYPE.NRPS_TOKEN); + cLog.setAction("syncSiteMemberships getting access token from context="+context.getId()+" tenant="+context.getTenant()+" oidcTokenUrl="+oidcTokenUrl); + cLog.setSuccess(Boolean.FALSE); + + // Looks like we have the requisite strings in variables :) + KeyPair keyPair = SakaiKeySetUtil.getCurrent(); + StringBuffer dbs = new StringBuffer(); + dbs.append("Getting NRPS Token...\n"); + AccessToken nrpsAccessToken = LTI13AccessTokenUtil.getNRPSToken(oidcTokenUrl, keyPair, clientId, deploymentId, oidcAudience, dbs); + if ( nrpsAccessToken == null || isEmpty(nrpsAccessToken.access_token) ) { + log.error(dbs.toString()); + log.error("Could not retrieve NRPS (Names and Roles) token from {}. Memberships will NOT be synchronized.", oidcTokenUrl); + cLog.setDebugLog(dbs.toString()); + contextLogRepository.save(cLog); + return; + } + if ( verbose(tenant) ) { + log.info("Debug Log:\n{}", dbs.toString()); + } else { + log.debug("Debug Log:\n{}", dbs.toString()); + } + + cLog.setAction("syncSiteMemberships context="+context.getId()+" tenant="+context.getTenant()+" contextMemberships="+contextMemberships+" access_token="+nrpsAccessToken.access_token); + + Map headers = new TreeMap<>(); + headers.put("Authorization", "Bearer "+nrpsAccessToken.access_token); + headers.put("Accept", LTI13ConstantsUtil.MEDIA_TYPE_MEMBERSHIPS); + headers.put("Content-Type", LTI13ConstantsUtil.MEDIA_TYPE_MEMBERSHIPS); // TODO: Remove when certification is fixed + + // Get ready + context.setNrpsStart(Instant.now()); + context.setNrpsFinish(null); + context.setNrpsCount(Long.valueOf(0)); + context.setNrpsStatus("Started"); + contextRepository.save(context); + + dbs = new StringBuffer(); + dbs.append("Loading Context Memberships...\n"); + InputStream is; + try { + HttpResponse response = HttpClientUtil.sendGetStream(contextMemberships, null, headers, dbs); + if ( verbose(tenant) ) { + log.info("Debug Log:\n{}", dbs.toString()); + } else { + log.debug("Debug Log:\n{}", dbs.toString()); + } + is = response.body(); + } catch (Exception e) { + log.error("Error retrieving NRPS (Names and Roles) data from {}", contextMemberships); + cLog.setStatus("Error retrieving NRPS (Names and Roles) data"); + cLog.setDebugLog(dbs.toString()); + contextLogRepository.save(cLog); + return; + } + + // https://cassiomolin.com/2019/08/19/combining-jackson-streaming-api-with-objectmapper-for-parsing-json/ + // Create and configure an ObjectMapper instance + ObjectMapper mapper = JacksonUtil.getLaxObjectMapper(); + + cLog = new ContextLog(); + cLog.setContext(context); + cLog.setType(ContextLog.LOG_TYPE.NRPS_LIST); + cLog.setStatus("Started syncSiteMemberships at="+Instant.now()); + cLog.setSuccess(Boolean.TRUE); + dbs = new StringBuffer(); + + Long count = Long.valueOf(0); + // Create a JsonParser instance + try { + JsonParser jsonParser = mapper.getFactory().createParser(is); + + // Check the first token + String lastText = null; + JsonToken nextToken = null; + while (true) { + nextToken = jsonParser.nextToken(); + if ( nextToken == null ) break; + if ( nextToken == JsonToken.START_ARRAY && "members".equals(lastText) ) break; + lastText = jsonParser.getText(); + } + + while (true) { + nextToken = jsonParser.nextToken(); + if ( nextToken == null ) break; + if ( nextToken == JsonToken.END_ARRAY ) break; + Member member = mapper.readValue(jsonParser, Member.class); + + if ( verbose(tenant) ) { + log.info("processing member={}",member.email); + } else { + log.debug("processing member={}",member.email); + } + + count = count + 1; + + if ( count < 200 ) { + dbs.append("processing member="+member.email+" user_id="+member.user_id+" count="+count+"\n"); + } + + SakaiLaunchJWT launchJWT = new SakaiLaunchJWT(); + launchJWT.subject = member.user_id; + launchJWT.email = member.email; + launchJWT.given_name = member.given_name; + launchJWT.family_name = member.family_name; + launchJWT.roles = member.roles; + + Subject subject = createOrUpdateSubject(tenant, member.user_id, launchJWT); + if ( subject == null ) { + log.error("Failed createOrUpdateSubject subject={}", member.user_id); + dbs.append("Failed createOrUpdateSubject subject="+member.user_id); + cLog.setSuccess(Boolean.FALSE); + continue; + } + + // Upsert the roles + Membership membership = new Membership(); + membership.setSubject(subject); + membership.setContext(context); + if (launchJWT.isInstructor() ) { + membership.setRole(Membership.ROLE_INSTRUCTOR); + } else { + membership.setRole(Membership.ROLE_LEARNER); + } + membership = membershipRepository.upsert(membership); + + Map payload = getPayloadFromLaunchJWT(tenant, launchJWT); + payload.put("tenant_guid", contextGuid); + payload.put("subject_guid", subject.getId()); + + User user = userFinderOrCreator.findOrCreateUser(payload, false, isEmailTrustedConsumer); + if ( user == null ) { + log.error("Failed findOrCreateUser subject={}", member.user_id); + dbs.append("Failed findOrCreateUser subject="+member.user_id); + cLog.setSuccess(Boolean.FALSE); + continue; + } + + connectSubjectAndUser(subject, user); + + siteEmailPreferenceSetter.setupUserEmailPreferenceForSite(payload, user, site, false); + + site = siteMembershipUpdater.addOrUpdateSiteMembership(payload, false, user, site); + + cLog.setStatus("Completed syncSiteMemberships count="+count+" at="+Instant.now()); + } + } catch (IOException | LTIException e) { + log.error("Error processing contextMemberships stream context={}", contextGuid, e); + cLog.setSuccess(Boolean.FALSE); + cLog.setStatus("Exception processing Names and Roles data="+e.getMessage()); + } + + // Update the job status + context.setNrpsFinish(Instant.now()); + context.setNrpsCount(count); + context.setNrpsStatus("Done"); + + // Store the log entry + cLog.setDebugLog(dbs.toString()); + contextRepository.save(context); + contextLogRepository.save(cLog); + } + +/* +{ + "id" : "https://lms.example.com/sections/2923/memberships", + "context": { + "id": "2923-abc", + "label": "CPS 435", + "title": "CPS 435 Learning Analytics", + }, + "members" : [ + { + "status" : "Active", + "name": "Jane Q. Public", + "picture" : "https://platform.example.edu/jane.jpg", + "given_name" : "Jane", + "family_name" : "Doe", + "middle_name" : "Marie", + "email": "jane@platform.example.edu", + "user_id" : "0ae836b9-7fc9-4060-006f-27b2066ac545", + "lis_person_sourcedid": "59254-6782-12ab", + "roles": [ + "http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor" + ] + } + ] +} + */ + + /* + * Create a lineItem for a gradebook Column + */ + @Override + public String createLineItem(Site site, Long assignmentId, + final org.sakaiproject.grading.api.Assignment assignmentDefinition) + { + String contextGuid = site.getId(); + Optional optContext = contextRepository.findById(contextGuid); + Context context = null; + if ( optContext.isPresent() ) { + context = optContext.get(); + } + + if ( context == null ) { + log.info("Context notfound {}", contextGuid); + return null; + } + + String tenantGuid = context.getTenant().getId(); + String lineItemsUrl = context.getLineItems(); + + if (isEmpty(tenantGuid)) { + log.info("Context {} does not have a tenant. Scores will NOT be synchronized.", contextGuid); + return null; + } + + if (isEmpty(lineItemsUrl)) { + log.info("Context {} does not have LineItems URL. Scores will NOT be synchronized.", contextGuid); + return null; + } + + // Load the Tenant + Optional optTenant = tenantRepository.findById(tenantGuid); + Tenant tenant = null; + if ( optTenant.isPresent() ) { + tenant = optTenant.get(); + } + + if ( tenant == null ) { + log.info("Tenant notfound {}", tenantGuid); + return null; + } + + String clientId = tenant.getClientId(); + String oidcTokenUrl = tenant.getOidcToken(); + String oidcAudience = tenant.getOidcAudience(); + String deploymentId = context.getDeploymentId(); + if ( isEmpty(oidcAudience) ) oidcAudience = oidcTokenUrl; + + if (isEmpty(clientId)) { + log.info("Tenant {} does not have clientId. Scores will NOT be synchronized.", tenantGuid); + return null; + } + + if (isEmpty(deploymentId)) { + log.info("Tenant {} does not have deploymentId. Scores will NOT be synchronized.", tenantGuid); + return null; + } + + if (isEmpty(oidcTokenUrl)) { + log.info("Tenant {} does not have an OIDC Token URL. Scores will NOT be synchronized.", contextGuid); + return null; + } + + // Create the lineItem to send. + LineItem li = new LineItem(); + li.scoreMaximum = assignmentDefinition.getPoints(); + li.label = assignmentDefinition.getName(); + li.tag = "42"; + li.resourceId = assignmentId.toString(); + // li.startDateTime + Date dueDate = assignmentDefinition.getDueDate(); + if ( dueDate != null ) li.endDateTime = BasicLTIUtil.getISO8601(dueDate); + String body = li.prettyPrintLog(); + + // Track this in our local database including success / failure of the LMS interaction + org.sakaiproject.plus.api.model.LineItem dbli = new org.sakaiproject.plus.api.model.LineItem(); + dbli.setId(assignmentId); + dbli.setContext(context); + dbli.setScoreMaximum(li.scoreMaximum); + dbli.setLabel(li.label); + dbli.setTag(li.tag); + dbli.setResourceId(li.resourceId); + if ( dueDate != null ) dbli.setEndDateTime(dueDate.toInstant()); + dbli.setUpdatedAt(Instant.now()); + dbli.setSentAt(Instant.now()); + dbli.setSuccess(Boolean.FALSE); + dbli.setStatus(null); + dbli.setDebugLog(null); + + ContextLog cLog = new ContextLog(); + cLog.setContext(context); + cLog.setType(ContextLog.LOG_TYPE.LineItem_TOKEN); + cLog.setAction("createLineItem assignmentId="+assignmentId+" label="+li.label+" scoreMaximum="+li.scoreMaximum+" dueDate="+dueDate); + + // Looks like we have the requisite strings in variables :) + // https://www.imsglobal.org/spec/lti-ags/v2p0/#creating-a-new-line-item + KeyPair keyPair = SakaiKeySetUtil.getCurrent(); + StringBuffer dbs = new StringBuffer(); + AccessToken lineItemsAccessToken = LTI13AccessTokenUtil.getLineItemsToken(oidcTokenUrl, keyPair, clientId, deploymentId, oidcAudience, dbs); + if ( lineItemsAccessToken == null || isEmpty(lineItemsAccessToken.access_token) ) { + dbli.setStatus("Could not get LineItems token from "+oidcTokenUrl); + dbli.setDebugLog(dbs.toString()); + lineItemRepository.save(dbli); + log.error("Could not retrieve lineItems token from {}. Scores will NOT be synchronized.", oidcTokenUrl); + log.error(dbs.toString()); + cLog.setStatus(dbli.getStatus()); + cLog.setDebugLog(dbli.getDebugLog()); + contextLogRepository.save(cLog); + return null; + } + dbs = new StringBuffer(); + dbs.append("Sending LineItem\n"); + + // lineItem + Map headers = new TreeMap<>(); + headers.put("Authorization", "Bearer "+lineItemsAccessToken.access_token); + headers.put("Content-Type", LineItem.MIME_TYPE); + + String method = "POST"; + + try { + HttpResponse response = HttpClientUtil.sendBody(method, lineItemsUrl, body, headers, dbs); + body = response.body(); + log.debug("CREATE RESPONSE BODY={}", body); + dbs.append("response body\n"); + dbs.append(StringUtils.truncate(body, 1000)); + + if ( verbose(tenant) ) { + log.info("Debug Log:\n{}", dbs.toString()); + } else { + log.debug("Debug Log:\n{}", dbs.toString()); + } + } catch (Exception e) { + dbli.setStatus("Error creating lineItem at "+lineItemsUrl+" "+e.getMessage()); + dbli.setDebugLog(dbs.toString()); + log.error(dbs.toString()); + log.error(dbli.getStatus()); + lineItemRepository.save(dbli); + + cLog.setStatus(dbli.getStatus()); + cLog.setDebugLog(dbli.getDebugLog()); + contextLogRepository.save(cLog); + return null; + } + + // Create and configure an ObjectMapper instance + ObjectMapper mapper = JacksonUtil.getLaxObjectMapper(); + try { + LineItem returnedItem = mapper.readValue(body, LineItem.class); + + if ( returnedItem != null ) { + String lineItemId = returnedItem.id; + if ( isNotEmpty(lineItemId) ) { + dbli.setStatus("created lineitem id="+lineItemId); + dbli.setSuccess(Boolean.TRUE); + dbli.setDebugLog(dbs.toString()); + lineItemRepository.save(dbli); + log.debug("Returning lineItemId={}", lineItemId); + return lineItemId; // Caller saves this as appropriate + } + dbli.setStatus("did not find returned lineitem id"); + dbli.setDebugLog(dbs.toString()); + lineItemRepository.save(dbli); + + cLog.setStatus(dbli.getStatus()); + cLog.setType(ContextLog.LOG_TYPE.LineItem_ERROR); + cLog.setDebugLog(dbli.getDebugLog()); + contextLogRepository.save(cLog); + } + } catch ( Exception e ) { + dbli.setStatus("Error parsing lineItem at "+lineItemsUrl+" "+e.getMessage()); + log.error(dbs.toString()); + log.error(dbli.getStatus()); + dbli.setDebugLog(dbs.toString()); + lineItemRepository.save(dbli); + + cLog.setStatus(dbli.getStatus()); + cLog.setType(ContextLog.LOG_TYPE.LineItem_ERROR); + cLog.setDebugLog(dbli.getDebugLog()); + contextLogRepository.save(cLog); + return null; + } + + // Store this locally + lineItemRepository.save(dbli); + + cLog.setSuccess(Boolean.TRUE); + cLog.setDebugLog(dbli.getDebugLog()); + cLog.setType(ContextLog.LOG_TYPE.LineItem_CREATE); + contextLogRepository.save(cLog); + + return null; + } + + /* + * Update a lineItem associated with a gradebook Column + */ + @Override + public String updateLineItem(Site site, + final org.sakaiproject.grading.api.Assignment assignmentDefinition) + { + if ( assignmentDefinition == null ) return null; + Long assignmentId = assignmentDefinition.getId(); + String lineItemId = assignmentDefinition.getLineItem(); + log.debug("updateLineItem site={} assignmentId={} lineItemId={}", site.getId(), assignmentId, lineItemId); + + String contextGuid = site.getId(); + Optional optContext = contextRepository.findById(contextGuid); + Context context = null; + if ( optContext.isPresent() ) { + context = optContext.get(); + } + + if ( context == null ) { + log.info("Context notfound {}", contextGuid); + return null; + } + + String tenantGuid = context.getTenant().getId(); + String lineItemsUrl = context.getLineItems(); + + if (isEmpty(tenantGuid)) { + log.info("Context {} does not have a tenant. Scores will NOT be synchronized.", contextGuid); + return null; + } + + if (isEmpty(lineItemsUrl)) { + log.info("Context {} does not have LineItems URL. Scores will NOT be synchronized.", contextGuid); + return null; + } + + // Load the Tenant + Optional optTenant = tenantRepository.findById(tenantGuid); + Tenant tenant = null; + if ( optTenant.isPresent() ) { + tenant = optTenant.get(); + } + + if ( tenant == null ) { + log.info("Tenant notfound {}", tenantGuid); + return null; + } + + String clientId = tenant.getClientId(); + String deploymentId = context.getDeploymentId(); + String oidcTokenUrl = tenant.getOidcToken(); + String oidcAudience = tenant.getOidcAudience(); + if ( isEmpty(oidcAudience) ) oidcAudience = oidcTokenUrl; + + if (isEmpty(clientId)) { + log.info("Tenant {} does not have clientId. Scores will NOT be synchronized.", tenantGuid); + return null; + } + + if (isEmpty(deploymentId)) { + log.info("Context {} does not have deploymentId. Scores will NOT be synchronized.", contextGuid); + return null; + } + + if (isEmpty(oidcTokenUrl)) { + log.info("Tenant {} does not have an OIDC Token URL. Scores will NOT be synchronized.", tenantGuid); + return null; + } + + // Create the lineItem to send. + LineItem li = new LineItem(); + li.scoreMaximum = assignmentDefinition.getPoints(); + li.label = assignmentDefinition.getName(); + li.tag = "42"; + li.resourceId = assignmentId.toString(); + // li.startDateTime + Date dueDate = assignmentDefinition.getDueDate(); + if ( dueDate != null ) li.endDateTime = BasicLTIUtil.getISO8601(dueDate); + String body = li.prettyPrintLog(); + + // In Update + // Check if we already have a lineitem in our database + org.sakaiproject.plus.api.model.LineItem dbli = null; + Optional optLineItem = lineItemRepository.findById(assignmentId); + String restEndPoint = lineItemsUrl; + String method = "POST"; + if ( optLineItem.isPresent() ) { + dbli = optLineItem.get(); + restEndPoint = lineItemId; + method = "PUT"; + } else { + dbli = new org.sakaiproject.plus.api.model.LineItem(); + } + + // Track this in our local database including success / failure of the LMS interaction + dbli.setId(assignmentId); + dbli.setContext(context); + dbli.setScoreMaximum(li.scoreMaximum); + dbli.setLabel(li.label); + dbli.setTag(li.tag); + dbli.setResourceId(li.resourceId); + if ( dueDate != null ) dbli.setEndDateTime(dueDate.toInstant()); + dbli.setUpdatedAt(Instant.now()); + dbli.setSentAt(Instant.now()); + dbli.setSuccess(Boolean.FALSE); + dbli.setStatus(null); + dbli.setDebugLog(null); + + ContextLog cLog = new ContextLog(); + cLog.setContext(context); + cLog.setType(ContextLog.LOG_TYPE.LineItem_TOKEN); + cLog.setAction("createLineItem assignmentId="+assignmentId+" label="+li.label+" scoreMaximum="+li.scoreMaximum+" dueDate="+dueDate); + + // Looks like we have the requisite strings in variables :) + // https://www.imsglobal.org/spec/lti-ags/v2p0/#creating-a-new-line-item + KeyPair keyPair = SakaiKeySetUtil.getCurrent(); + StringBuffer dbs = new StringBuffer(); + AccessToken lineItemsAccessToken = LTI13AccessTokenUtil.getLineItemsToken(oidcTokenUrl, keyPair, clientId, deploymentId, oidcAudience, dbs); + if ( lineItemsAccessToken == null || isEmpty(lineItemsAccessToken.access_token) ) { + dbli.setStatus("Could not get LineItems token from "+oidcTokenUrl); + dbli.setDebugLog(dbs.toString()); + lineItemRepository.save(dbli); + log.error("Could not retrieve lineItems token from {}. Scores will NOT be synchronized.", oidcTokenUrl); + log.error(dbs.toString()); + cLog.setStatus(dbli.getStatus()); + cLog.setDebugLog(dbli.getDebugLog()); + contextLogRepository.save(cLog); + return null; + } + dbs = new StringBuffer(); + dbs.append("Sending LineItem\n"); + + // lineItem + Map headers = new TreeMap<>(); + headers.put("Authorization", "Bearer "+lineItemsAccessToken.access_token); + headers.put("Content-Type", LineItem.MIME_TYPE); + + try { + HttpResponse response = HttpClientUtil.sendBody(method, restEndPoint, body, headers, dbs); + body = response.body(); + log.debug("UPDATE RESPONSE BODY={}", body); + dbs.append("response body\n"); + dbs.append(StringUtils.truncate(body, 1000)); + + if ( verbose(tenant) ) { + log.info("Debug Log:\n{}", dbs.toString()); + } else { + log.debug("Debug Log:\n{}", dbs.toString()); + } + } catch (Exception e) { + dbli.setStatus("Error creating lineItem at "+lineItemsUrl+" "+e.getMessage()); + dbli.setDebugLog(dbs.toString()); + log.error(dbs.toString()); + log.error(dbli.getStatus()); + lineItemRepository.save(dbli); + + cLog.setStatus(dbli.getStatus()); + cLog.setDebugLog(dbli.getDebugLog()); + contextLogRepository.save(cLog); + return null; + } + + // Create and configure an ObjectMapper instance + ObjectMapper mapper = JacksonUtil.getLaxObjectMapper(); + try { + LineItem returnedItem = mapper.readValue(body, LineItem.class); + + if ( returnedItem != null ) { + lineItemId = returnedItem.id; + if ( isNotEmpty(lineItemId) ) { + dbli.setStatus("created lineitem id="+lineItemId); + dbli.setSuccess(Boolean.TRUE); + dbli.setDebugLog(dbs.toString()); + lineItemRepository.save(dbli); + return lineItemId; + } + dbli.setStatus("did not find returned lineitem id"); + dbli.setDebugLog(dbs.toString()); + lineItemRepository.save(dbli); + + cLog.setStatus(dbli.getStatus()); + cLog.setType(ContextLog.LOG_TYPE.LineItem_ERROR); + cLog.setDebugLog(dbli.getDebugLog()); + contextLogRepository.save(cLog); + } + } catch ( JsonProcessingException e ) { + // If the PUT gave us no valid data it is no big deal + if ( method.equals("PUT") ) { + dbli.setStatus("No lineItem response to PUT at "+lineItemsUrl+" "+e.getMessage()); + log.debug(dbs.toString()); + log.debug(dbli.getStatus()); + } else { + dbli.setStatus("Error parsing lineItem response to POST at "+lineItemsUrl+" "+e.getMessage()); + log.error(dbs.toString()); + log.error(dbli.getStatus()); + } + dbli.setDebugLog(dbs.toString()); + lineItemRepository.save(dbli); + + cLog.setStatus(dbli.getStatus()); + cLog.setType(ContextLog.LOG_TYPE.LineItem_ERROR); + cLog.setDebugLog(dbli.getDebugLog()); + contextLogRepository.save(cLog); + return null; + } + + // Store this locally + lineItemRepository.save(dbli); + + cLog.setSuccess(Boolean.TRUE); + cLog.setDebugLog(dbli.getDebugLog()); + cLog.setType(ContextLog.LOG_TYPE.LineItem_CREATE); + contextLogRepository.save(cLog); + + return null; + } + + /* + * Send a score to the calling LMS + */ + // https://www.imsglobal.org/spec/lti-ags/v2p0#score-publish-service + // https://www.imsglobal.org/spec/lti-ags/v2p0#comment-0 + @Transactional + @Override + public void processGradeEvent(Event event) + { + // /gradebookng/7/12/55a0c76a-69e2-4ca7-816b-3c2e8fe38ce0/42/OK/instructor[m, 2] + log.debug("processGradeEvent sees event {}", event.getResource()); + String eventResource = event.getResource(); + if ( eventResource == null ) return; + String[] parts = eventResource.split("/"); + + if (parts.length < 6) return; + + final String source = parts[1]; + String itemId; + String studentId; + String scoreStr; + String siteId; + + // From the UI business logic + // /gradebookng/7/12/55a0c76a-69e2-4ca7-816b-3c2e8fe38ce0/42/OK/instructor[m, 2] + // 1 2 3 4 5 + if ( "gradebookng".equals(source) ) { + log.debug("processGradeEvent UI {}", event.getResource()); + itemId = parts[3]; + studentId = parts[4]; + scoreStr = parts[5]; + siteId = event.getContext(); + // From a web service + // /gradebook/a77ed1b6-ceea-4339-ad60-8bbe7219f3b5/Trophy/55a0c76a-69e2-4ca7-816b-3c2e8fe38ce0/99.0/student[m, 2] + // 1 2 3 4 5 + } else if ( "gradebook".equals(source) ) { + log.debug("processGradeEvent WS {}", event.getResource()); + siteId = parts[2]; + itemId = parts[3]; + studentId = parts[4]; + scoreStr = parts[5]; + } else { // not our event... + return; + } + + log.debug("Updating score for user {} for item {} with score {} in gradebook {} by {}", studentId, itemId, scoreStr, siteId, source); + + // 2.4.4 scoreGiven and scoreMaximum + // All scoreGiven values MUST be positive number (including 0). scoreMaximum represents the denominator + // and MUST be present when scoreGiven is present. When scoreGiven is not present or null, this indicates + // there is presently no score for that user, and the platform should clear any previous score value it + // may have previously received from the tool and stored for that user and line item. + Double scoreGiven; + try { + scoreGiven = Double.valueOf(scoreStr); + } catch (NumberFormatException e) { + scoreGiven = null; + } + + Subject subject = subjectRepository.findBySakaiUserIdAndSakaiSiteId(studentId, siteId); + log.debug("subject={}", subject); + if ( subject == null ) { + // This is debug because it is really just a local Sakai user w/o an email address + log.debug("Can't retrieve subject for {}", studentId); + return; + } + + org.sakaiproject.grading.api.Assignment gradebookAssignment; + try { + gradebookAssignment = gradingService.getAssignmentByNameOrId(siteId, itemId); + } catch (AssessmentNotFoundException anfe) { + log.warn("Can't retrieve gradebook assignment for gradebook {} and item {}, {}", siteId, itemId, anfe.getMessage()); + return; + } + + String lineItem = gradebookAssignment.getLineItem(); + if ( isEmpty(lineItem) ) { + // This is info because it is really just a local gradebook column + log.info("No lineItem for gradebookAssignment {}", gradebookAssignment.getId()); + return; + } + + Long gradebookColumnId = gradebookAssignment.getId(); + CommentDefinition commentDef = gradingService.getAssignmentScoreComment(siteId, gradebookColumnId, studentId); + String comment = null; + if ( commentDef != null ) comment = commentDef.getCommentText(); + + Tenant tenant = subject.getTenant(); + if ( tenant == null ) { + log.error("Cannot find tenant for subject {}", subject.getId()); + return; + } + + Optional optContext = contextRepository.findById(siteId); + Context context = null; + if ( optContext.isPresent() ) { + context = optContext.get(); + } + if ( context == null ) { + log.error("Cannot find context for site {}", siteId); + return; + } + + String clientId = tenant.getClientId(); + String deploymentId = context.getDeploymentId(); + String oidcTokenUrl = tenant.getOidcToken(); + String oidcAudience = tenant.getOidcAudience(); + if ( isEmpty(oidcAudience) ) oidcAudience = oidcTokenUrl; + + Score score = new Score(); + score.scoreGiven = scoreGiven; + score.scoreMaximum = gradebookAssignment.getPoints(); + score.comment = comment; + score.userId = subject.getSubject(); + score.timestamp = BasicLTIUtil.getISO8601(); + + // TODO: Think more about this - Canvas requires this but we don't know what various values mean in Canvas + // TODO: Review the Blackboard state diagram + score.activityProgress = Score.ACTIVITY_COMPLETED; + score.gradingProgress = Score.GRADING_FULLYGRADED; + + // Delete any old "in-flight" score update, only keep the latest + scoreRepository.deleteBySubjectAndColumn(subject, gradebookColumnId); + + // Track this in our local database including success / failure of the LMS interaction + org.sakaiproject.plus.api.model.Score dbsc = new org.sakaiproject.plus.api.model.Score(); + dbsc.setGradeBookColumnId(gradebookColumnId); + dbsc.setSubject(subject); + + dbsc.setScoreGiven(score.scoreGiven); + dbsc.setScoreMaximum(score.scoreMaximum); + dbsc.setComment(score.comment); + dbsc.setUpdatedAt(Instant.now()); + dbsc.setSentAt(Instant.now()); + dbsc.setSuccess(Boolean.FALSE); + dbsc.setStatus(null); + dbsc.setDebugLog(null); + + // Prepare for Per-Context log + ContextLog cLog = new ContextLog(); + cLog.setContext(context); + cLog.setSubject(subject); + cLog.setType(ContextLog.LOG_TYPE.Score_TOKEN); + cLog.setAction("processGradeEvent siteId="+siteId+" itemId="+itemId+" studentId="+studentId+" scoreGiven="+score.scoreGiven); + cLog.setSuccess(Boolean.FALSE); + + // Lets get an access token if we can so we can send the score + KeyPair keyPair = SakaiKeySetUtil.getCurrent(); + StringBuffer dbs = new StringBuffer(); + AccessToken scoreAccessToken = LTI13AccessTokenUtil.getScoreToken(oidcTokenUrl, keyPair, clientId, deploymentId, oidcAudience, dbs); + if ( scoreAccessToken == null || isEmpty(scoreAccessToken.access_token) ) { + log.info("Could not retrieve score token from {}. Scores will NOT be synchronized.", oidcTokenUrl); + dbsc.setStatus("Could not get score token from "+oidcTokenUrl); + dbsc.setSuccess(Boolean.FALSE); + dbsc.setDebugLog(dbs.toString()); + scoreRepository.save(dbsc); + + cLog.setStatus(dbsc.getStatus()); + cLog.setDebugLog(dbsc.getDebugLog()); + contextLogRepository.save(cLog); + return; + } + if ( verbose(tenant) ) { + log.info("Debug Log:\n{}", dbs.toString()); + } else { + log.debug("Debug Log:\n{}", dbs.toString()); + } + + // Lets send a score + // https://www.imsglobal.org/spec/lti-ags/v2p0#score-publish-service + // https://www.imsglobal.org/spec/lti-ags/v2p0#comment-0 + Map headers = new TreeMap(); + headers.put("Authorization", "Bearer "+scoreAccessToken.access_token); + headers.put("Content-Type", Score.MIME_TYPE); + + String body = score.prettyPrintLog(); + String scoreUrl = LTI13Util.getScoreUrlForLineItem(lineItem); + dbs = new StringBuffer(); + dbs.append("Sending score\n"); + + try { + HttpResponse response = HttpClientUtil.sendBody("POST", scoreUrl, body, headers, dbs); + body = response.body(); + log.debug("GRADEEVENT RESPONSE BODY={}", body); + dbs.append("response body\n"); + dbs.append(StringUtils.truncate(body, 1000)); + dbsc.setDebugLog(dbs.toString()); + if ( verbose(tenant) ) { + log.info("Debug Log:\n{}", dbs.toString()); + } else { + log.debug("Debug Log:\n{}", dbs.toString()); + } + dbsc.setSuccess(Boolean.TRUE); + scoreRepository.save(dbsc); + + cLog.setStatus(dbsc.getStatus()); + cLog.setDebugLog(dbsc.getDebugLog()); + cLog.setSuccess(Boolean.TRUE); + cLog.setType(ContextLog.LOG_TYPE.Score_SEND); + contextLogRepository.save(cLog); + } catch (Exception e) { + log.error("Error setting score at {}", scoreUrl); + dbsc.setStatus("Error setting score at url="+oidcTokenUrl+" message="+e.getMessage()); + dbsc.setSuccess(Boolean.FALSE); + dbsc.setDebugLog(dbs.toString()); + scoreRepository.save(dbsc); + + cLog.setStatus(dbsc.getStatus()); + cLog.setType(ContextLog.LOG_TYPE.Score_SEND); + cLog.setDebugLog(dbsc.getDebugLog()); + contextLogRepository.save(cLog); + } + + } + +/* + +https://www.imsglobal.org/spec/lti-ags/v2p0#score-service-media-type-and-schema + +POST lineitem URL/scores +Content-Type: application/vnd.ims.lis.v1.score+json +Authentication: Bearer 89042.hfkh84390xaw3m +{ + "timestamp": "2017-04-16T18:54:36.736+00:00", + "scoreGiven" : 83, + "scoreMaximum" : 100, + "comment" : "This is exceptional work.", + "activityProgress" : "Completed", + "gradingProgress": "FullyGraded", + "userId" : "5323497" +} + * +*/ + +} + diff --git a/plus/impl/src/main/java/org/sakaiproject/plus/impl/jobs/SiteMembershipsSyncJob.java b/plus/impl/src/main/java/org/sakaiproject/plus/impl/jobs/SiteMembershipsSyncJob.java new file mode 100644 index 000000000000..d5ac68f88dc1 --- /dev/null +++ b/plus/impl/src/main/java/org/sakaiproject/plus/impl/jobs/SiteMembershipsSyncJob.java @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2009-2017 The Apereo Foundation + * + * Licensed under the Educational Community License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://opensource.org/licenses/ecl2 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.sakaiproject.plus.impl.jobs; + + +import lombok.extern.slf4j.Slf4j; + +import org.quartz.JobExecutionContext; +import org.quartz.JobExecutionException; +import org.quartz.StatefulJob; + +import org.sakaiproject.plus.api.PlusService; + +@Slf4j +public class SiteMembershipsSyncJob implements StatefulJob { + + public void setPlusService(PlusService plusService) { + } + + @Override + public void execute(JobExecutionContext context) throws JobExecutionException { + + log.info("SiteMembershipsSyncJob.execute"); + + } +} diff --git a/plus/impl/src/main/java/org/sakaiproject/plus/impl/repository/ContextLogRepositoryImpl.java b/plus/impl/src/main/java/org/sakaiproject/plus/impl/repository/ContextLogRepositoryImpl.java new file mode 100644 index 000000000000..0d6bbb829d93 --- /dev/null +++ b/plus/impl/src/main/java/org/sakaiproject/plus/impl/repository/ContextLogRepositoryImpl.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2021- Charles R. Severance + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package org.sakaiproject.plus.impl.repository; + +import java.util.List; +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +import org.sakaiproject.plus.api.model.Context; +import org.sakaiproject.plus.api.model.ContextLog; +import org.sakaiproject.plus.api.repository.ContextLogRepository; +import org.sakaiproject.springframework.data.SpringCrudRepositoryImpl; + +import javax.persistence.criteria.CriteriaBuilder; +import javax.persistence.criteria.CriteriaQuery; +import javax.persistence.criteria.CriteriaDelete; +import javax.persistence.criteria.Root; +import javax.persistence.criteria.Predicate; + +import org.hibernate.Session; + +import org.springframework.transaction.annotation.Transactional; + +public class ContextLogRepositoryImpl extends SpringCrudRepositoryImpl implements ContextLogRepository { + + @Transactional(readOnly = true) + @Override + public List getLogEntries(Context context, Boolean success, int limit) + { + CriteriaBuilder cb = sessionFactory.getCriteriaBuilder(); + CriteriaQuery cr = cb.createQuery(ContextLog.class); + Root root = cr.from(ContextLog.class); + + Predicate cond; + if ( success == null ) { + cond = cb.equal(root.get("context"), context); + } else { + cond = cb.and( + cb.equal(root.get("success"), success), + cb.equal(root.get("context"), context) + ); + } + + CriteriaQuery cq = cr + .select(root) + .where(cond) + .orderBy(cb.desc(root.get("createdAt"))) + ; + + List result = sessionFactory.getCurrentSession() + .createQuery(cq) + .setMaxResults(limit) + .list(); + + return result; + } + + // https://stackoverflow.com/questions/9449003/compare-date-entities-in-jpa-criteria-api + // https://stackoverflow.com/questions/4902653/java-util-date-seven-days-ago + @Transactional + @Override + public int deleteOlderThanDays(int days){ + + Instant previousDate = Instant.now().minus(days, ChronoUnit.DAYS); + + Session session = sessionFactory.getCurrentSession(); + + CriteriaBuilder cb = session.getCriteriaBuilder(); + CriteriaDelete delete = cb.createCriteriaDelete(ContextLog.class); + Root contextLogDelete = delete.from(ContextLog.class); + delete.where(cb.lessThanOrEqualTo(contextLogDelete.get("createdAt"), previousDate)); + + return session.createQuery(delete).executeUpdate(); + } + +} diff --git a/plus/impl/src/main/java/org/sakaiproject/plus/impl/repository/ContextRepositoryImpl.java b/plus/impl/src/main/java/org/sakaiproject/plus/impl/repository/ContextRepositoryImpl.java new file mode 100644 index 000000000000..f386abc6aeff --- /dev/null +++ b/plus/impl/src/main/java/org/sakaiproject/plus/impl/repository/ContextRepositoryImpl.java @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2021- Charles R. Severance + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package org.sakaiproject.plus.impl.repository; + +import java.util.List; + +import javax.persistence.criteria.CriteriaBuilder; +import javax.persistence.criteria.CriteriaQuery; +import javax.persistence.criteria.Root; +import javax.persistence.criteria.Predicate; + +import org.sakaiproject.plus.api.model.Context; +import org.sakaiproject.plus.api.model.Tenant; +import org.sakaiproject.plus.api.repository.ContextRepository; +import org.sakaiproject.springframework.data.SpringCrudRepositoryImpl; + +import org.springframework.transaction.annotation.Transactional; + +public class ContextRepositoryImpl extends SpringCrudRepositoryImpl implements ContextRepository { + + @Transactional(readOnly = true) + @Override + public List findByTenant(Tenant tenant) { + + CriteriaBuilder cb = sessionFactory.getCriteriaBuilder(); + CriteriaQuery cr = cb.createQuery(Context.class); + Root root = cr.from(Context.class); + + Predicate cond = cb.equal(root.get("tenant"), tenant); + CriteriaQuery cq = cr.select(root).where(cb.equal(root.get("tenant"), tenant)); + + List result = sessionFactory.getCurrentSession() + .createQuery(cq) + .list(); + return result; + } + + @Transactional(readOnly = true) + @Override + public Context findByContextAndTenant(String context, Tenant tenant) { + CriteriaBuilder cb = sessionFactory.getCriteriaBuilder(); + CriteriaQuery cr = cb.createQuery(Context.class); + Root root = cr.from(Context.class); + + Predicate cond = cb.and( + cb.equal(root.get("context"), context), + cb.equal(root.get("tenant"), tenant) + ); + + CriteriaQuery cq = cr.select(root).where(cond); + + Context result = sessionFactory.getCurrentSession() + .createQuery(cq) + .uniqueResult(); + return result; + } + + @Transactional(readOnly = true) + @Override + public Context findBySakaiSiteId(String sakaiSiteId) { + CriteriaBuilder cb = sessionFactory.getCriteriaBuilder(); + CriteriaQuery cr = cb.createQuery(Context.class); + Root root = cr.from(Context.class); + + Predicate cond = cb.equal(root.get("sakaiSiteId"), sakaiSiteId); + + CriteriaQuery cq = cr.select(root).where(cond); + + Context result = sessionFactory.getCurrentSession() + .createQuery(cq) + .uniqueResult(); + return result; + } + +} diff --git a/plus/impl/src/main/java/org/sakaiproject/plus/impl/repository/LineItemRepositoryImpl.java b/plus/impl/src/main/java/org/sakaiproject/plus/impl/repository/LineItemRepositoryImpl.java new file mode 100644 index 000000000000..9cda4de519bb --- /dev/null +++ b/plus/impl/src/main/java/org/sakaiproject/plus/impl/repository/LineItemRepositoryImpl.java @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2021- Charles R. Severance + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package org.sakaiproject.plus.impl.repository; + +import org.sakaiproject.plus.api.model.LineItem; +import org.sakaiproject.plus.api.repository.LineItemRepository; +import org.sakaiproject.springframework.data.SpringCrudRepositoryImpl; + +public class LineItemRepositoryImpl extends SpringCrudRepositoryImpl implements LineItemRepository { + +} diff --git a/plus/impl/src/main/java/org/sakaiproject/plus/impl/repository/LinkRepositoryImpl.java b/plus/impl/src/main/java/org/sakaiproject/plus/impl/repository/LinkRepositoryImpl.java new file mode 100644 index 000000000000..6b8480b7bb7f --- /dev/null +++ b/plus/impl/src/main/java/org/sakaiproject/plus/impl/repository/LinkRepositoryImpl.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2021- Charles R. Severance + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package org.sakaiproject.plus.impl.repository; + +import javax.persistence.criteria.CriteriaBuilder; +import javax.persistence.criteria.CriteriaQuery; +import javax.persistence.criteria.Root; +import javax.persistence.criteria.Predicate; + +import org.sakaiproject.plus.api.model.Context; +import org.sakaiproject.plus.api.model.Link; +import org.sakaiproject.plus.api.repository.LinkRepository; +import org.sakaiproject.springframework.data.SpringCrudRepositoryImpl; + +import org.springframework.transaction.annotation.Transactional; + +public class LinkRepositoryImpl extends SpringCrudRepositoryImpl implements LinkRepository { + + @Transactional(readOnly = true) + @Override + public Link findByLinkAndContext(String link, Context context) { + CriteriaBuilder cb = sessionFactory.getCriteriaBuilder(); + CriteriaQuery cr = cb.createQuery(Link.class); + Root root = cr.from(Link.class); + + Predicate cond = cb.and( + cb.equal(root.get("link"), link), + cb.equal(root.get("context"), context) + ); + + CriteriaQuery cq = cr.select(root).where(cond); + + Link result = sessionFactory.getCurrentSession() + .createQuery(cq) + .uniqueResult(); + return result; + } +} diff --git a/plus/impl/src/main/java/org/sakaiproject/plus/impl/repository/MembershipRepositoryImpl.java b/plus/impl/src/main/java/org/sakaiproject/plus/impl/repository/MembershipRepositoryImpl.java new file mode 100644 index 000000000000..e5c6e1c286b8 --- /dev/null +++ b/plus/impl/src/main/java/org/sakaiproject/plus/impl/repository/MembershipRepositoryImpl.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2021- Charles R. Severance + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package org.sakaiproject.plus.impl.repository; + +import java.util.Objects; + +import javax.persistence.criteria.CriteriaBuilder; +import javax.persistence.criteria.CriteriaQuery; +import javax.persistence.criteria.Root; +import javax.persistence.criteria.Predicate; + +import org.sakaiproject.plus.api.model.Membership; +import org.sakaiproject.plus.api.model.Subject; +import org.sakaiproject.plus.api.model.Context; +import org.sakaiproject.plus.api.repository.MembershipRepository; +import org.sakaiproject.springframework.data.SpringCrudRepositoryImpl; + +import org.springframework.transaction.annotation.Transactional; + +public class MembershipRepositoryImpl extends SpringCrudRepositoryImpl implements MembershipRepository { + + @Transactional(readOnly = true) + @Override + public Membership findBySubjectAndContext(Subject subject, Context context) { + + CriteriaBuilder cb = sessionFactory.getCriteriaBuilder(); + CriteriaQuery cr = cb.createQuery(Membership.class); + Root root = cr.from(Membership.class); + + Predicate cond = cb.and( + cb.equal(root.get("subject"), subject), + cb.equal(root.get("context"), context) + ); + + CriteriaQuery cq = cr.select(root).where(cond); + + Membership result = sessionFactory.getCurrentSession() + .createQuery(cq) + .uniqueResult(); + return result; + } + + // TODO: Tell Earle that in this particular area, JPA sucks! + // TODO: Make sure this f'n does what I think it f'n does given that I think I am f'n forced to do it + @Transactional + @Override + public Membership upsert(Membership entity) { + if ( entity.getId() != null ) return save(entity); + + // Painfully and inefficiently check for UPSERT + Membership newEntity = findBySubjectAndContext(entity.getSubject(), entity.getContext()); + if ( newEntity == null ) return save(entity); + + boolean unchanged = + Objects.equals(entity.getRole(), newEntity.getRole()) + && Objects.equals(entity.getRoleOverride(), newEntity.getRoleOverride()) + ; + + if ( unchanged ) return newEntity; + + // Do the UPDATE variant of UPSERT + newEntity.setRole(entity.getRole()); + newEntity.setRoleOverride(entity.getRoleOverride()); + return save(newEntity); + } + +} diff --git a/plus/impl/src/main/java/org/sakaiproject/plus/impl/repository/ScoreRepositoryImpl.java b/plus/impl/src/main/java/org/sakaiproject/plus/impl/repository/ScoreRepositoryImpl.java new file mode 100644 index 000000000000..c43b982b477c --- /dev/null +++ b/plus/impl/src/main/java/org/sakaiproject/plus/impl/repository/ScoreRepositoryImpl.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2021- Charles R. Severance + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package org.sakaiproject.plus.impl.repository; + +import javax.persistence.criteria.CriteriaBuilder; +import javax.persistence.criteria.CriteriaQuery; +import javax.persistence.criteria.CriteriaDelete; +import javax.persistence.criteria.Root; +import javax.persistence.criteria.Predicate; + +import org.sakaiproject.plus.api.model.Score; +import org.sakaiproject.plus.api.model.Subject; +import org.sakaiproject.plus.api.repository.ScoreRepository; +import org.sakaiproject.springframework.data.SpringCrudRepositoryImpl; + +import org.springframework.transaction.annotation.Transactional; + +public class ScoreRepositoryImpl extends SpringCrudRepositoryImpl implements ScoreRepository { + + // We are mostly loading individual Scores to Update them if they exist + @Transactional + @Override + public Score findBySubjectAndColumn(Subject subject, Long gradeBookColumn) + { + CriteriaBuilder cb = sessionFactory.getCriteriaBuilder(); + CriteriaQuery cr = cb.createQuery(Score.class); + Root root = cr.from(Score.class); + + Predicate cond = cb.and(cb.equal(root.get("subject"), subject), cb.equal(root.get("gradeBookColumnId"), gradeBookColumn)); + + CriteriaQuery cq = cr.select(root).where(cond); + + Score result = sessionFactory.getCurrentSession() + .createQuery(cq) + .uniqueResult(); + return result; + } + + // https://www.baeldung.com/hibernate-criteria-queries + // https://www.logicbig.com/tutorials/java-ee-tutorial/jpa/criteria-delete.html + @Override + public Integer deleteBySubjectAndColumn(Subject subject, Long gradeBookColumn) + { + CriteriaBuilder cb = sessionFactory.getCriteriaBuilder(); + CriteriaDelete cd = cb.createCriteriaDelete(Score.class); + Root root = cd.from(Score.class); + + Predicate cond = cb.and(cb.equal(root.get("subject"), subject), cb.equal(root.get("gradeBookColumnId"), gradeBookColumn)); + + cd.where(cond); + + Integer count = sessionFactory.getCurrentSession() + .createQuery(cd) + .executeUpdate(); + return count; + } +} diff --git a/plus/impl/src/main/java/org/sakaiproject/plus/impl/repository/SubjectRepositoryImpl.java b/plus/impl/src/main/java/org/sakaiproject/plus/impl/repository/SubjectRepositoryImpl.java new file mode 100644 index 000000000000..d64908a1f0f9 --- /dev/null +++ b/plus/impl/src/main/java/org/sakaiproject/plus/impl/repository/SubjectRepositoryImpl.java @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2021- Charles R. Severance + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package org.sakaiproject.plus.impl.repository; + +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; + +import org.sakaiproject.plus.api.model.Subject; +import org.sakaiproject.plus.api.model.Tenant; +import org.sakaiproject.plus.api.model.Context; +import org.sakaiproject.plus.api.repository.SubjectRepository; +import org.sakaiproject.plus.api.repository.ContextRepository; +import org.sakaiproject.springframework.data.SpringCrudRepositoryImpl; + +import javax.persistence.criteria.CriteriaBuilder; +import javax.persistence.criteria.CriteriaQuery; +import javax.persistence.criteria.Root; +import javax.persistence.criteria.Predicate; + +import org.springframework.transaction.annotation.Transactional; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class SubjectRepositoryImpl extends SpringCrudRepositoryImpl implements SubjectRepository { + + @Autowired private ContextRepository contextRepository; + + @Transactional(readOnly = true) + @Override + public Subject findBySubjectAndTenant(String subject, Tenant tenant) { + CriteriaBuilder cb = sessionFactory.getCriteriaBuilder(); + CriteriaQuery cr = cb.createQuery(Subject.class); + Root root = cr.from(Subject.class); + + Predicate cond = cb.and( + cb.equal(root.get("subject"), subject), + cb.equal(root.get("tenant"), tenant) + ); + + CriteriaQuery cq = cr.select(root).where(cond); + + Subject result = sessionFactory.getCurrentSession() + .createQuery(cq) + .uniqueResult(); + return result; + } + + @Transactional(readOnly = true) + @Override + public Subject findByEmailAndTenant(String email, Tenant tenant) { + CriteriaBuilder cb = sessionFactory.getCriteriaBuilder(); + CriteriaQuery cr = cb.createQuery(Subject.class); + Root root = cr.from(Subject.class); + + Predicate cond = cb.and( + cb.equal(root.get("email"), email), + cb.equal(root.get("tenant"), tenant) + ); + + CriteriaQuery cq = cr.select(root).where(cond); + + Subject result = sessionFactory.getCurrentSession() + .createQuery(cq) + .uniqueResult(); + return result; + } + + // This uses the one-to-one betwee sakai sites and contexts to work back to the tenant + // so we get the correct subject for this particular sakaiUserId + @Transactional(readOnly = true) + @Override + public Subject findBySakaiUserIdAndSakaiSiteId(String sakaiUserId, String siteId) { + + Context context = contextRepository.findBySakaiSiteId(siteId); + if ( context == null ) return null; + Tenant tenant = context.getTenant(); + + CriteriaBuilder cb = sessionFactory.getCriteriaBuilder(); + CriteriaQuery cr = cb.createQuery(Subject.class); + Root root = cr.from(Subject.class); + + Predicate cond = cb.and( + cb.equal(root.get("sakaiUserId"), sakaiUserId), + cb.equal(root.get("tenant"), tenant) + ); + + // If bollooxed, this might not return a unique result - While it is a bit of a punt, + // we will use the most recent subject associated with this SakaiUserId + CriteriaQuery cq = cr + .select(root) + .where(cond) + .orderBy(cb.desc(root.get("modifiedAt"))) + ; + + List results = sessionFactory.getCurrentSession() + .createQuery(cq) + .list(); + + if ( results == null || results.size() < 1 ) return null; + Subject first = results.get(0); + if ( results.size() == 1 ) return first; + + // One way this can get bolloxed is to edit existing tenant data (like deployment_id) + // after a bunch of subjects have been created. In SakaiPlus we see that as the same tenant, + // but the controlling LMS might generate quite different subjects to keep us + // from tracking a human across deployments... Sigh. + Subject second = results.get(1); + log.warn("Multiple subjects for siteId={} tenant={} userId={} subject1={} at={} subject2={} at={}", + siteId, tenant.getId(), sakaiUserId, + first.getSubject(), first.getCreatedAt(), + second.getSubject(), second.getCreatedAt() ); + + return first; + } + +} diff --git a/plus/impl/src/main/java/org/sakaiproject/plus/impl/repository/TenantRepositoryImpl.java b/plus/impl/src/main/java/org/sakaiproject/plus/impl/repository/TenantRepositoryImpl.java new file mode 100644 index 000000000000..bae3b03cae4d --- /dev/null +++ b/plus/impl/src/main/java/org/sakaiproject/plus/impl/repository/TenantRepositoryImpl.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2021- Charles R. Severance + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package org.sakaiproject.plus.impl.repository; + +import org.sakaiproject.plus.api.model.Tenant; +import org.sakaiproject.plus.api.repository.TenantRepository; +import org.sakaiproject.springframework.data.SpringCrudRepositoryImpl; + +import javax.persistence.criteria.CriteriaBuilder; +import javax.persistence.criteria.CriteriaQuery; +import javax.persistence.criteria.Root; +import javax.persistence.criteria.Predicate; + +public class TenantRepositoryImpl extends SpringCrudRepositoryImpl implements TenantRepository { + + public Tenant findByIssuerClientIdAndDeploymentId(String issuer, String clientId, String deploymentId) + { + CriteriaBuilder cb = sessionFactory.getCriteriaBuilder(); + CriteriaQuery cr = cb.createQuery(Tenant.class); + Root root = cr.from(Tenant.class); + + Predicate cond = cb.and( + cb.equal(root.get("issuer"), issuer), + cb.equal(root.get("clientId"), clientId), + cb.equal(root.get("deploymentId"), deploymentId) + ); + + CriteriaQuery cq = cr.select(root).where(cond); + + Tenant result = sessionFactory.getCurrentSession() + .createQuery(cq) + .uniqueResult(); + return result; + + /* + // import org.hibernate.criterion.Order; + // import org.hibernate.criterion.Restrictions; + // import org.hibernate.criterion.Projections; + + Tenant retval = (Tenant) sessionFactory.getCurrentSession().createCriteria(Tenant.class) + .add(Restrictions.eq("issuer", issuer)) + .add(Restrictions.eq("clientId", clientId)) + .add(Restrictions.eq("deploymentId", deploymentId)) + .uniqueResult(); + return retval; + */ + } + +} diff --git a/plus/impl/src/main/webapp/WEB-INF/components.xml b/plus/impl/src/main/webapp/WEB-INF/components.xml new file mode 100644 index 000000000000..230e052664ba --- /dev/null +++ b/plus/impl/src/main/webapp/WEB-INF/components.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + org.sakaiproject.plus.api.model.Tenant + org.sakaiproject.plus.api.model.Subject + org.sakaiproject.plus.api.model.Context + org.sakaiproject.plus.api.model.ContextLog + org.sakaiproject.plus.api.model.Link + org.sakaiproject.plus.api.model.LineItem + org.sakaiproject.plus.api.model.Score + org.sakaiproject.plus.api.model.Membership + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plus/impl/src/test/org/sakaiproject/plus/impl/PlusModelTests.java b/plus/impl/src/test/org/sakaiproject/plus/impl/PlusModelTests.java new file mode 100644 index 000000000000..e9ebacc97ccd --- /dev/null +++ b/plus/impl/src/test/org/sakaiproject/plus/impl/PlusModelTests.java @@ -0,0 +1,521 @@ +/* + * Copyright (c) 2021- Charles R. Severance + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package org.sakaiproject.plus.impl; + +import java.time.Instant; + +import org.sakaiproject.plus.api.Launch; +import org.sakaiproject.plus.api.PlusService; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.AbstractTransactionalJUnit4SpringContextTests; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import java.util.List; +import java.util.Optional; + +import static org.mockito.Mockito.*; + +import static org.junit.Assert.*; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.json.simple.JSONObject; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.DeserializationFeature; + +import org.sakaiproject.lti13.util.SakaiLaunchJWT; + +import org.tsugi.lti13.LTI13JwtUtil; + +import org.sakaiproject.authz.api.SecurityService; +import org.sakaiproject.tool.api.SessionManager; +import org.sakaiproject.user.api.User; +import org.sakaiproject.user.api.UserDirectoryService; + +import org.sakaiproject.plus.api.model.Tenant; +import org.sakaiproject.plus.api.model.Subject; +import org.sakaiproject.plus.api.model.Context; +import org.sakaiproject.plus.api.model.ContextLog; +import org.sakaiproject.plus.api.model.Link; +import org.sakaiproject.plus.api.model.LineItem; +import org.sakaiproject.plus.api.model.Score; +import org.sakaiproject.plus.api.model.Membership; + +import org.sakaiproject.plus.api.repository.TenantRepository; +import org.sakaiproject.plus.api.repository.SubjectRepository; +import org.sakaiproject.plus.api.repository.ContextRepository; +import org.sakaiproject.plus.api.repository.ContextLogRepository; +import org.sakaiproject.plus.api.repository.LinkRepository; +import org.sakaiproject.plus.api.repository.LineItemRepository; +import org.sakaiproject.plus.api.repository.ScoreRepository; +import org.sakaiproject.plus.api.repository.MembershipRepository; + +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = {PlusTestConfiguration.class}) +public class PlusModelTests extends AbstractTransactionalJUnit4SpringContextTests { + + @Autowired private SecurityService securityService; + @Autowired private SessionManager sessionManager; + @Autowired private UserDirectoryService userDirectoryService; + @Autowired private TenantRepository tenantRepository; + @Autowired private SubjectRepository subjectRepository; + @Autowired private ContextRepository contextRepository; + @Autowired private ContextLogRepository contextLogRepository; + @Autowired private LinkRepository linkRepository; + @Autowired private LineItemRepository lineItemRepository; + @Autowired private ScoreRepository scoreRepository; + @Autowired private MembershipRepository membershipRepository; + @Autowired private PlusService plusService; + + User user1User = null; + User user2User = null; + + @Before + public void setup() { + + reset(sessionManager); + reset(securityService); + reset(userDirectoryService); + user1User = mock(User.class); + when(user1User.getDisplayName()).thenReturn("User 1"); + user2User = mock(User.class); + when(user2User.getDisplayName()).thenReturn("User 2"); + } + + @Test + public void testModelObjects() { + + Instant now = Instant.now(); + Tenant tenant = new Tenant(); + tenant.setCreatedAt(now); + tenant.setTitle("Yada"); + tenant.setIssuer("https://www.example.com"); + tenant.setClientId("42"); + tenant.setOidcAuth("https://www.example.com/auth"); + tenant.setOidcKeySet("https://www.example.com/keyset"); + assertTrue(tenant.isDraft()); + tenant.setOidcToken("https://www.example.com/token"); + assertFalse(tenant.isDraft()); + + // Lets mess with deployment id validation logic + // Null should allow anything + assertTrue(tenant.validateDeploymentId("42")); + tenant.setDeploymentId("*"); + assertTrue(tenant.validateDeploymentId("hello")); + assertTrue(tenant.validateDeploymentId("world")); + tenant.setDeploymentId("1"); + assertTrue(tenant.validateDeploymentId("1")); + assertFalse(tenant.validateDeploymentId("42")); + tenant.setDeploymentId("hello:world:42:zap"); + assertFalse(tenant.validateDeploymentId("1")); + assertTrue(tenant.validateDeploymentId("hello")); + assertTrue(tenant.validateDeploymentId("world")); + assertTrue(tenant.validateDeploymentId("42")); + assertTrue(tenant.validateDeploymentId("zap")); + tenant.setDeploymentId("1"); + + // Map settings = tenant.getSettings(); + // settings.put("secret", "42"); + tenantRepository.save(tenant); + String tenantId = tenant.getId(); + + Optional optTenant = tenantRepository.findById(tenantId); + Tenant newTenant = null; + if ( optTenant.isPresent() ) { + newTenant = optTenant.get(); + } + assertNotNull(newTenant); + assertEquals(newTenant.getTitle(), "Yada"); + assertEquals(newTenant.getIssuer(), "https://www.example.com"); + assertEquals(newTenant.getClientId(), "42"); + assertEquals(newTenant.getDeploymentId(), "1"); + assertEquals(newTenant.getOidcAuth(), "https://www.example.com/auth"); + assertEquals(newTenant.getOidcKeySet(), "https://www.example.com/keyset"); + assertEquals(newTenant.getVerbose(), Boolean.FALSE); + + newTenant = tenantRepository.findByIssuerClientIdAndDeploymentId("https://www.example.com", "42", "1"); + assertNotNull(newTenant); + assertEquals(newTenant.getTitle(), "Yada"); + assertEquals(newTenant.getIssuer(), "https://www.example.com"); + assertEquals(newTenant.getClientId(), "42"); + assertEquals(newTenant.getDeploymentId(), "1"); + assertEquals(newTenant.getOidcAuth(), "https://www.example.com/auth"); + assertEquals(newTenant.getOidcKeySet(), "https://www.example.com/keyset"); + + newTenant = tenantRepository.findByIssuerClientIdAndDeploymentId("https://www.not-example.com", "42", "1"); + assertNull(newTenant); + + + Subject subject = new Subject(); + subject.setSubject("Yada"); + subject.setTenant(tenant); + subject.setEmail("hirouki@p.com"); + subject.setSakaiUserId("user-12345"); + subjectRepository.save(subject); + assertNotNull(subject.getCreatedAt()); + assertNotNull(subject.getModifiedAt()); + + Subject newSubject = subjectRepository.findBySubjectAndTenant("Yada", tenant); + assertEquals(subject.getEmail(), newSubject.getEmail()); + assertEquals(subject.getSubject(), newSubject.getSubject()); + assertEquals(subject.getSakaiUserId(), newSubject.getSakaiUserId()); + + newSubject = subjectRepository.findByEmailAndTenant("hirouki@p.com", tenant); + assertEquals(subject.getEmail(), newSubject.getEmail()); + assertEquals(subject.getSubject(), newSubject.getSubject()); + assertEquals(subject.getSakaiUserId(), newSubject.getSakaiUserId()); + + Context context = new Context(); + context.setContext("SI364"); + context.setTenant(tenant); + context.setSakaiSiteId("site-123"); + context.setDeploymentId("42"); + contextRepository.save(context); + + Context newContext = contextRepository.findBySakaiSiteId("site-123-wrong"); + assertNull(newContext); + newContext = contextRepository.findBySakaiSiteId("site-123"); + assertEquals(context.getContext(), newContext.getContext()); + assertEquals(context.getTenant(), newContext.getTenant()); + assertEquals(context.getSakaiSiteId(), newContext.getSakaiSiteId()); + + newSubject = subjectRepository.findBySakaiUserIdAndSakaiSiteId("user-12345-wrong", "site-123"); + assertNull(newSubject); + newSubject = subjectRepository.findBySakaiUserIdAndSakaiSiteId("user-12345", "site-123-wrong"); + assertNull(newSubject); + + newSubject = subjectRepository.findBySakaiUserIdAndSakaiSiteId("user-12345", "site-123"); + assertEquals(subject.getEmail(), newSubject.getEmail()); + assertEquals(subject.getSubject(), newSubject.getSubject()); + assertEquals(subject.getSakaiUserId(), newSubject.getSakaiUserId()); + + Membership ms = new Membership(); + ms.setSubject(subject); + ms.setContext(context); + ms.setRole(Membership.ROLE_INSTRUCTOR); + ms = membershipRepository.upsert(ms); + // Save should do the same + // ms = membershipRepository.save(ms); + assertNotNull(ms.getId()); + + // Upsert is in effect a SELECT when there are no changes + Membership dms = new Membership(); + dms.setSubject(subject); + dms.setContext(context); + dms.setRole(Membership.ROLE_INSTRUCTOR); + dms = membershipRepository.upsert(dms); + // Save should do the same - but lets test all upsert() use cases + // ms = membershipRepository.save(ms); + assertNotNull(dms.getId()); + assertEquals(ms.getId(), dms.getId()); + assertEquals(ms.getRole(),dms.getRole()); + assertEquals(ms.getRoleOverride(), dms.getRoleOverride()); + + // Is this a save/update operation? + // Does the unique contraint work at all? + Membership nms = new Membership(); + nms.setSubject(subject); + nms.setContext(context); + nms.setRole(Membership.ROLE_LEARNER); + nms = membershipRepository.upsert(nms); + assertNotNull(nms.getId()); + assertEquals(ms.getId(), nms.getId()); + + Membership lms = membershipRepository.findBySubjectAndContext(subject, context); + assertEquals(lms.getId(), nms.getId()); + assertEquals(lms.getRole(), nms.getRole()); + assertEquals(lms.getRoleOverride(), nms.getRoleOverride()); + + newContext = contextRepository.findByContextAndTenant("SI364", tenant); + + LineItem lineItem = new LineItem(); + lineItem.setId(42l); + lineItem.setResourceId("YADA"); + lineItem.setContext(context); + lineItem.setUpdatedAt(Instant.now()); + lineItem.setSentAt(Instant.now()); + lineItem.setStatus("Test Status"); + lineItem.setDebugLog("Debug goes here"); + lineItem.setSuccess(Boolean.TRUE); + lineItemRepository.save(lineItem); + + Link link = new Link(); + link.setLink("YADA"); + link.setContext(context); + linkRepository.save(link); + + Score score = new Score(); + // Set the logical keys + score.setGradeBookColumnId(42l); + score.setSubject(subject); + score.setComment("Yada"); + + score.setActivityProgress(org.tsugi.ags2.objects.Score.ACTIVITY_INITIALIZED); + score.setActivityProgress(org.tsugi.ags2.objects.Score.ACTIVITY_STARTED); + score.setActivityProgress(org.tsugi.ags2.objects.Score.ACTIVITY_INPROGRESS); + score.setActivityProgress(org.tsugi.ags2.objects.Score.ACTIVITY_SUBMITTED); + score.setActivityProgress(org.tsugi.ags2.objects.Score.ACTIVITY_COMPLETED); + Enum ap = score.getActivityProgress(); + assertEquals(ap, Score.ACTIVITY_PROGRESS.Completed); + assertEquals(ap.name(), org.tsugi.ags2.objects.Score.ACTIVITY_COMPLETED); + try { + score.setActivityProgress("Yada"); + fail("score.setActivityProgress(\"Yada\"); should fail with a RunTime exception"); + } catch (Exception e) { /* no Problem */ } + + + score.setGradingProgress(org.tsugi.ags2.objects.Score.GRADING_FULLYGRADED); + score.setGradingProgress(org.tsugi.ags2.objects.Score.GRADING_PENDING); + score.setGradingProgress(org.tsugi.ags2.objects.Score.GRADING_PENDINGMANUAL); + score.setGradingProgress(org.tsugi.ags2.objects.Score.GRADING_FAILED); + Enum gp = score.getGradingProgress(); + assertEquals(gp, Score.GRADING_PROGRESS.Failed); + assertEquals(gp.name(), org.tsugi.ags2.objects.Score.GRADING_FAILED); + try { + score.setGradingProgress("Yada"); + fail("score.setGradingProgress(\"Yada\"); should fail with a RunTime exception"); + } catch (Exception e) { /* no Problem */ } + + String scoreGuid = score.getId(); + assertNull(scoreGuid); + + scoreRepository.save(score); + assertEquals(score.getComment(), "Yada"); + + // See if JPA can do INSERT ON DUPLICATE KEY UPDATE? NO. + // https://stackoverflow.com/questions/48568921/how-to-do-on-duplicate-key-update-in-spring-data-jpa + // https://stackoverflow.com/questions/913341/can-hibernate-work-with-mysqls-on-duplicate-key-update-syntax + // https://stackoverflow.com/questions/69373529/spring-data-jpa-on-duplicate-key-update-amount-account-amount-somevalue + scoreGuid = score.getId(); + assertNotNull(scoreGuid); + + // Change one thing and save + score.setGradingProgress(org.tsugi.ags2.objects.Score.GRADING_PENDINGMANUAL); + scoreRepository.save(score); + String newGuid = score.getId(); + assertEquals(scoreGuid, newGuid); + gp = score.getGradingProgress(); + assertEquals(gp, Score.GRADING_PROGRESS.PendingManual); + + // Load up the score and check + Long gradeBookColumn = 42l; + Score loadScore = scoreRepository.findBySubjectAndColumn(subject, gradeBookColumn); + assertNotNull(loadScore); + assertEquals(score.getId(), loadScore.getId()); + assertEquals(score.getGradingProgress(), loadScore.getGradingProgress()); + assertEquals(score.getComment(), loadScore.getComment()); + + // Load something that is not there + gradeBookColumn = 43l; + loadScore = scoreRepository.findBySubjectAndColumn(subject, gradeBookColumn); + assertNull(loadScore); + + // Make a fresh Score with duplicate logical keys and re-save + gradeBookColumn = 42l; + score = scoreRepository.findBySubjectAndColumn(subject, gradeBookColumn); + assertNotNull(score); + score.setActivityProgress(org.tsugi.ags2.objects.Score.ACTIVITY_INITIALIZED); + + newGuid = score.getId(); + assertNotNull(newGuid); + assertEquals(scoreGuid, newGuid); + + scoreRepository.save(score); + newGuid = score.getId(); + assertNotNull(newGuid); + assertEquals(scoreGuid, newGuid); + gp = score.getGradingProgress(); + assertEquals(gp, Score.GRADING_PROGRESS.PendingManual); + assertEquals(score.getComment(), "Yada"); + + ap = score.getActivityProgress(); + assertEquals(ap, Score.ACTIVITY_PROGRESS.Initialized); + + // Lets delete the score record + Integer count = scoreRepository.deleteBySubjectAndColumn(subject, gradeBookColumn); + assertEquals(count, new Integer(1)); + + score = scoreRepository.findBySubjectAndColumn(subject, gradeBookColumn); + assertNull(score); + + // Lets do a delete of non-existant score + gradeBookColumn = 1000l; + count = scoreRepository.deleteBySubjectAndColumn(subject, gradeBookColumn); + assertEquals(count, new Integer(0)); + + // Test ContextLog + ContextLog cLog = new ContextLog(); + cLog.setContext(context); + cLog.setType(ContextLog.LOG_TYPE.NRPS_TOKEN); + cLog.setAction("cool action 1"); + cLog.setSuccess(Boolean.FALSE); + cLog.setDebugLog("I am debug string"); + contextLogRepository.save(cLog); + + List logList = contextLogRepository.getLogEntries(context, Boolean.FALSE, 5); + assertEquals(logList.size(), 1); + logList = contextLogRepository.getLogEntries(context, Boolean.TRUE, 5); + assertEquals(logList.size(), 0); + logList = contextLogRepository.getLogEntries(context, null, 5); + assertEquals(logList.size(), 1); + + ContextLog cl0 = logList.get(0); + assertNotNull(cl0.getCreatedAt()); + assertEquals(cl0.getType(), ContextLog.LOG_TYPE.NRPS_TOKEN); + assertEquals(cl0.getAction(), "cool action 1"); + + Instant reallyOld = Instant.ofEpochSecond(1234567); + + cLog = new ContextLog(); + cLog.setContext(context); + cLog.setType(ContextLog.LOG_TYPE.NRPS_TOKEN); + cLog.setAction("old action 1"); + cLog.setSuccess(Boolean.FALSE); + cLog.setDebugLog("I am debug string"); + cLog.setCreatedAt(reallyOld); + contextLogRepository.save(cLog); + + logList = contextLogRepository.getLogEntries(context, null, 5); + assertEquals(logList.size(), 2); + + cl0 = logList.get(0); + assertNotNull(cl0.getCreatedAt()); + assertEquals(cl0.getType(), ContextLog.LOG_TYPE.NRPS_TOKEN); + assertEquals(cl0.getAction(), "cool action 1"); + + ContextLog cl1 = logList.get(1); + assertEquals(cl1.getCreatedAt(), reallyOld); + assertEquals(cl1.getType(), ContextLog.LOG_TYPE.NRPS_TOKEN); + assertEquals(cl1.getAction(), "old action 1"); + + logList = contextLogRepository.getLogEntries(context, Boolean.TRUE, 5); + assertEquals(logList.size(), 0); + + // Do some deleting + int howMany = contextLogRepository.deleteOlderThanDays(5); + assertEquals(howMany, 1); + howMany = contextLogRepository.deleteOlderThanDays(5); + assertEquals(howMany, 0); + } + + @Test + public void testReceiveJWT() + throws com.fasterxml.jackson.core.JsonProcessingException, org.sakaiproject.lti.api.LTIException + { + String id_token = getIdToken(); + JSONObject header = LTI13JwtUtil.jsonJwtHeader(id_token); + assertNotNull(header); + String kid = (String) header.get("kid"); + assertNotNull(kid); + JSONObject body = LTI13JwtUtil.jsonJwtBody(id_token); + assertNotNull(body); + String rawbody = LTI13JwtUtil.rawJwtBody(id_token); + assertNotNull(rawbody); + + // https://www.baeldung.com/jackson-deserialize-json-unknown-properties + ObjectMapper mapper = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + SakaiLaunchJWT launchJWT = mapper.readValue(rawbody, SakaiLaunchJWT.class); + assertNotNull(launchJWT); + + // Make sure funky stuff is ignored + String funkybody = rawbody.replace("{\"iss\":", "{\"funky\":\"town\",\"iss\":"); + launchJWT = mapper.readValue(funkybody, SakaiLaunchJWT.class); + assertNotNull(launchJWT); + + Instant now = Instant.now(); + Tenant tenant = null; + Launch launch = null; + try { + launch = plusService.updateAll(launchJWT, tenant); + fail("SakaiLaunchJWT and tenant both null should throw a runtime exception"); + } catch (Exception e) { /* no Problem */ } + + tenant = new Tenant(); + try { + launch = plusService.updateAll(launchJWT, tenant); + fail("Tenant without issuer should throw a runtime exception"); + } catch (Exception e) { /* no Problem */ } + + tenant.setTitle("Example Tenant"); + tenant.setCreatedAt(now); + tenant.setOidcAuth("https://www.example.com/auth"); + tenant.setOidcKeySet("https://www.example.com/keyset"); + tenant.setOidcToken("https://www.example.com/token"); + tenant.setDeploymentId("1"); + + tenant.setIssuer("wrong"); + tenant.setClientId("wrong"); + try { + launch = plusService.updateAll(launchJWT, tenant); + fail("Tenant / SakaiLaunchJWT issuer mismatch should throw a runtime exception"); + } catch (Exception e) { /* no Problem */ } + + tenant.setIssuer(launchJWT.issuer); + tenant.setClientId(launchJWT.audience); + tenant.setDeploymentId(launchJWT.deployment_id); + try { + launch = plusService.updateAll(launchJWT, tenant); + fail("Valid tenant that is not persisted should throw a runtime exception"); + } catch (Exception e) { /* no Problem */ } + + tenantRepository.save(tenant); + String tenantId = tenant.getId(); + assertNotNull(tenantId); + + launch = plusService.updateAll(launchJWT, tenant); + assertNotNull(launch); + assertEquals(launch.getSubject().getDisplayName(), "Chuck P"); + assertEquals(launch.getContext().getTitle(), "Yada"); + + launch = plusService.updateAll(launchJWT, tenant); + assertNotNull(launch); + assertEquals(launch.getSubject().getDisplayName(), "Chuck P"); + assertEquals(launch.getContext().getTitle(), "Yada"); + + launchJWT.name = "Sakaiger"; + launchJWT.context.title = "SI664"; + launch = plusService.updateAll(launchJWT, tenant); + assertNotNull(launch); + assertEquals(launch.getSubject().getDisplayName(), "Sakaiger"); + assertEquals(launch.getContext().getTitle(), "SI664"); + + // Construct from first and last + launchJWT.name = null; + launch = plusService.updateAll(launchJWT, tenant); + assertNotNull(launch); + assertEquals(launch.getSubject().getDisplayName(), "Chuck P"); + assertEquals(launch.getContext().getTitle(), "SI664"); + + // Lets load from database + Context loadContext = contextRepository.findByContextAndTenant(launch.getContext().getContext(), tenant); + assertEquals(launch.getContext().getTitle(), loadContext.getTitle()); + assertEquals(launch.getContext().getId(), loadContext.getId()); + + } + + public String getIdToken() + { + return "eyJraWQiOiIxNzkzNTI2OTg4IiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAiLCJhdWQiOiJhMmUwZjU4Yi0xZWFkLTQ3MjAtYWY3MC05OTExOGQwNmI2OTMiLCJzdWIiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvdXNlci80MmNjNTAxYi03ZWFiLTRhYmItOTQwZC1iMGUwZWRkZjUzMmIiLCJub25jZSI6IjYxY2RiMjU3YmNkNTgiLCJpYXQiOjE2NDA4NzA0ODcsImV4cCI6MTY0MDg3NDA4NywiaHR0cHM6Ly9wdXJsLmltc2dsb2JhbC5vcmcvc3BlYy9sdGkvY2xhaW0vZGVwbG95bWVudF9pZCI6IjEiLCJodHRwczovL3B1cmwuaW1zZ2xvYmFsLm9yZy9zcGVjL2x0aS9jbGFpbS90YXJnZXRfbGlua191cmkiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODgvcHk0ZS9tb2QvbG1zdGVzdC8iLCJodHRwczovL3B1cmwuaW1zZ2xvYmFsLm9yZy9zcGVjL2x0aS9jbGFpbS9tZXNzYWdlX3R5cGUiOiJMdGlSZXNvdXJjZUxpbmtSZXF1ZXN0IiwiaHR0cHM6Ly9wdXJsLmltc2dsb2JhbC5vcmcvc3BlYy9sdGkvY2xhaW0vdmVyc2lvbiI6IjEuMy4wIiwiZ2l2ZW5fbmFtZSI6IkNodWNrIiwiZmFtaWx5X25hbWUiOiJQIiwiZW1haWwiOiJwQHAuY29tIiwibmFtZSI6IkNodWNrIFAiLCJsb2NhbGUiOiJlbl9VUyIsImh0dHBzOi8vcHVybC5pbXNnbG9iYWwub3JnL3NwZWMvbHRpL2NsYWltL2N1c3RvbSI6eyJhdmFpbGFibGVlbmQiOiIkUmVzb3VyY2VMaW5rLmF2YWlsYWJsZS5lbmREYXRlVGltZSIsImF2YWlsYWJsZXN0YXJ0IjoiJFJlc291cmNlTGluay5hdmFpbGFibGUuc3RhcnREYXRlVGltZSIsImNhbnZhc19jYWxpcGVyX3VybCI6IiRDYWxpcGVyLnVybCIsImNvbnRleHRfaWRfaGlzdG9yeSI6IiRDb250ZXh0LmlkLmhpc3RvcnkiLCJyZXNvdXJjZWxpbmtfaWRfaGlzdG9yeSI6IiRSZXNvdXJjZUxpbmsuaWQuaGlzdG9yeSIsInN1Ym1pc3Npb25lbmQiOiIkUmVzb3VyY2VMaW5rLnN1Ym1pc3Npb24uZW5kRGF0ZVRpbWUiLCJzdWJtaXNzaW9uc3RhcnQiOiIkUmVzb3VyY2VMaW5rLnN1Ym1pc3Npb24uc3RhcnREYXRlVGltZSJ9LCJodHRwczovL3B1cmwuaW1zZ2xvYmFsLm9yZy9zcGVjL2x0aS9jbGFpbS9yb2xlcyI6WyJodHRwOi8vcHVybC5pbXNnbG9iYWwub3JnL3ZvY2FiL2xpcy92Mi9tZW1iZXJzaGlwI0luc3RydWN0b3IiXSwiaHR0cHM6Ly9wdXJsLmltc2dsb2JhbC5vcmcvc3BlYy9sdGkvY2xhaW0vcm9sZV9zY29wZV9tZW50b3IiOltdLCJodHRwczovL3B1cmwuaW1zZ2xvYmFsLm9yZy9zcGVjL2x0aS9jbGFpbS9sYXVuY2hfcHJlc2VudGF0aW9uIjp7ImRvY3VtZW50X3RhcmdldCI6ImlmcmFtZSIsInJldHVybl91cmwiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvaW1zb2lkYy9sdGkxMS9yZXR1cm4tdXJsL3NpdGUvNjJjOWQ2NWItODk1OS00M2QyLWI2NjQtNWNmYmZjMjczMzgwIiwiY3NzX3VybCI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MC9saWJyYXJ5L3NraW4vdG9vbF9iYXNlLmNzcyJ9LCJodHRwczovL3B1cmwuaW1zZ2xvYmFsLm9yZy9zcGVjL2x0aS9jbGFpbS9yZXNvdXJjZV9saW5rIjp7ImlkIjoiY29udGVudDoxMCIsInRpdGxlIjoiTE1TIFRlc3QiLCJkZXNjcmlwdGlvbiI6IlRoaXMgdG9vbCBleGVyY2lzZXMgdmFyaW91cyBMTVMgQWN0aXZpdGllcy4ifSwiaHR0cHM6Ly9wdXJsLmltc2dsb2JhbC5vcmcvc3BlYy9sdGkvY2xhaW0vY29udGV4dCI6eyJpZCI6IjYyYzlkNjViLTg5NTktNDNkMi1iNjY0LTVjZmJmYzI3MzM4MCIsImxhYmVsIjoiWWFkYSIsInRpdGxlIjoiWWFkYSIsInR5cGUiOlsiaHR0cDovL3B1cmwuaW1zZ2xvYmFsLm9yZy92b2NhYi9saXMvdjIvY291cnNlI0NvdXJzZU9mZmVyaW5nIl19LCJodHRwczovL3B1cmwuaW1zZ2xvYmFsLm9yZy9zcGVjL2x0aS9jbGFpbS90b29sX3BsYXRmb3JtIjp7Im5hbWUiOiJTYWthaSIsImRlc2NyaXB0aW9uIjoibG9jYWxob3N0LnNha2FpbG1zIiwicHJvZHVjdF9mYW1pbHlfY29kZSI6InNha2FpIiwidmVyc2lvbiI6IjIzLVNOQVBTSE9UIn0sImh0dHBzOi8vcHVybC5pbXNnbG9iYWwub3JnL3NwZWMvbHRpL2NsYWltL2xpcyI6eyJwZXJzb25fc291cmNlZGlkIjoiY3NldiIsImNvdXJzZV9vZmZlcmluZ19zb3VyY2VkaWQiOiI2MmM5ZDY1Yi04OTU5LTQzZDItYjY2NC01Y2ZiZmMyNzMzODAiLCJjb3Vyc2Vfc2VjdGlvbl9zb3VyY2VkaWQiOiI2MmM5ZDY1Yi04OTU5LTQzZDItYjY2NC01Y2ZiZmMyNzMzODAiLCJ2ZXJzaW9uIjpbIjEuMC4wIiwiMS4xLjAiXX0sImh0dHBzOi8vcHVybC5pbXNnbG9iYWwub3JnL3NwZWMvbHRpLWFncy9jbGFpbS9lbmRwb2ludCI6eyJzY29wZSI6WyJodHRwczovL3B1cmwuaW1zZ2xvYmFsLm9yZy9zcGVjL2x0aS1hZ3Mvc2NvcGUvbGluZWl0ZW0iXSwibGluZWl0ZW1zIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwL2ltc2JsaXMvbHRpMTMvbGluZWl0ZW1zLzhmZGQyNjcyYzk1MTM1Y2QwNjRkOGFjMjgwNzI3NWE0Njc0MDY4NmY4YjQ5MmYxNzk5NWVhMDAyYzA1YzM4YzA6Ojo2MmM5ZDY1Yi04OTU5LTQzZDItYjY2NC01Y2ZiZmMyNzMzODA6Ojpjb250ZW50OjEwIiwibGluZWl0ZW0iOiJodHRwOi8vbG9jYWxob3N0OjgwODAvaW1zYmxpcy9sdGkxMy9saW5laXRlbS84ZmRkMjY3MmM5NTEzNWNkMDY0ZDhhYzI4MDcyNzVhNDY3NDA2ODZmOGI0OTJmMTc5OTVlYTAwMmMwNWMzOGMwOjo6NjJjOWQ2NWItODk1OS00M2QyLWI2NjQtNWNmYmZjMjczMzgwOjo6Y29udGVudDoxMCJ9LCJodHRwczovL3B1cmwuaW1zZ2xvYmFsLm9yZy9zcGVjL2x0aS1ucnBzL2NsYWltL25hbWVzcm9sZXNlcnZpY2UiOnsiY29udGV4dF9tZW1iZXJzaGlwc191cmwiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvaW1zYmxpcy9sdGkxMy9uYW1lc2FuZHJvbGVzLzhmZGQyNjcyYzk1MTM1Y2QwNjRkOGFjMjgwNzI3NWE0Njc0MDY4NmY4YjQ5MmYxNzk5NWVhMDAyYzA1YzM4YzA6Ojo2MmM5ZDY1Yi04OTU5LTQzZDItYjY2NC01Y2ZiZmMyNzMzODA6Ojpjb250ZW50OjEwIiwic2VydmljZV92ZXJzaW9ucyI6WyIyLjAiXX0sImh0dHBzOi8vcHVybC5pbXNnbG9iYWwub3JnL3NwZWMvbHRpL2NsYWltL2x0aTFwMSI6eyJ1c2VyX2lkIjoiNDJjYzUwMWItN2VhYi00YWJiLTk0MGQtYjBlMGVkZGY1MzJiIiwib2F1dGhfY29uc3VtZXJfa2V5IjoiNTQzMjEiLCJvYXV0aF9jb25zdW1lcl9rZXlfc2lnbiI6IndtaVM2MWVnck5hQ3hPNHFHUTg1aXA0T3ZRb3pNVDBKcm12a0hDeUxrczA9In0sImh0dHBzOi8vd3d3LnNha2FpbG1zLm9yZy9zcGVjL2x0aS9jbGFpbS9leHRlbnNpb24iOnsic2FrYWlfbGF1bmNoX3ByZXNlbnRhdGlvbl9jc3NfdXJsX2xpc3QiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvbGlicmFyeS9za2luL3Rvb2xfYmFzZS5jc3MsaHR0cDovL2xvY2FsaG9zdDo4MDgwL2xpYnJhcnkvc2tpbi9tb3JwaGV1cy1kZWZhdWx0L3Rvb2wuY3NzP3ZlcnNpb249ZTIzMjhhNmMiLCJzYWthaV9hY2FkZW1pY19zZXNzaW9uIjoiT1RIRVIiLCJzYWthaV9yb2xlIjoibWFpbnRhaW4iLCJzYWthaV9zZXJ2ZXIiOiJodHRwOi8vbG9jYWxob3N0OjgwODAiLCJzYWthaV9zZXJ2ZXJpZCI6Ik1hY0Jvb2stUHJvLTEwNS5sb2NhbCJ9fQ.S5IHPoUKbsOnjhmYOUcJCCfnNsdJGfNeqMuWnDRTApb7b44m-K5PZe5L2AgQkuRHebeu8bqGDZQpWGcEeoXJuBwGUsrQkWR6a95NSGUCiijWe5PsfJ1sGtuS7MqS5QLU-yaW9_x2fChR04LznyVMhsAPf3LLsvyaZm_36S-SpvtJkIyTLUKP0GITRNjmuk325701QUq4iFg2_n8SGR7T8azQxC5TrrkuXVz2kCz_91UG0mOASrq0bJAb6YbWxeF_hK27wKlDGnCkGe3_icXi_GJ7Vh_vQIBwJqAsrzUESJcka73dWL5ZFkfCRdH_pal-nI1jvVNAw3ceUIcas8AyOg"; + } + +} diff --git a/plus/impl/src/test/org/sakaiproject/plus/impl/PlusServiceImplTests.java b/plus/impl/src/test/org/sakaiproject/plus/impl/PlusServiceImplTests.java new file mode 100644 index 000000000000..8f70897d428b --- /dev/null +++ b/plus/impl/src/test/org/sakaiproject/plus/impl/PlusServiceImplTests.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2021- Charles R. Severance + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package org.sakaiproject.plus.impl; + +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.AbstractTransactionalJUnit4SpringContextTests; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import static org.junit.Assert.*; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.apache.commons.lang3.StringUtils; + +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = {PlusTestConfiguration.class}) +public class PlusServiceImplTests extends AbstractTransactionalJUnit4SpringContextTests { + + @Before + public void setup() { + } + + @Test + public void testSplit() { + String normal = "gradebook.updateItemScore@/gradebookng/7/8/55a0c76a-69e2-4ca7-816b-3c2e8fe38ce0/70/OK/instructor"; + String twodelim = "gradebook.updateItemScore@/gradebookng/7/8/55a0c76a-69e2-4ca7-816b-3c2e8fe38ce0//OK/instructor"; + String[] parts = normal.split("/"); + assertEquals(parts.length, 8); + parts = twodelim.split("/"); + assertEquals(parts.length, 8); + + // StringUtils.split() treats two successive delimiters as one - Sheesh + // Don't use it :) + parts = StringUtils.split(normal, '/'); + assertEquals(parts.length, 8); + parts = StringUtils.split(twodelim, '/'); + assertEquals(parts.length, 7); + } + +} diff --git a/plus/impl/src/test/org/sakaiproject/plus/impl/PlusTestConfiguration.java b/plus/impl/src/test/org/sakaiproject/plus/impl/PlusTestConfiguration.java new file mode 100644 index 000000000000..70b3af09ef5c --- /dev/null +++ b/plus/impl/src/test/org/sakaiproject/plus/impl/PlusTestConfiguration.java @@ -0,0 +1,205 @@ +/* + * Copyright (c) 2021- Charles R. Severance + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package org.sakaiproject.plus.impl; + +import static org.mockito.Mockito.mock; + +import java.io.IOException; +import java.util.Properties; +import javax.sql.DataSource; + +import org.hibernate.SessionFactory; +import org.hibernate.boot.registry.StandardServiceRegistry; +import org.hibernate.boot.registry.StandardServiceRegistryBuilder; +import org.hibernate.dialect.HSQLDialect; +import org.hibernate.id.factory.internal.MutableIdentifierGeneratorFactoryInitiator; +import org.hsqldb.jdbcDriver; + +import org.sakaiproject.hibernate.AssignableUUIDGenerator; +import org.sakaiproject.springframework.orm.hibernate.AdditionalHibernateMappings; + +import org.sakaiproject.authz.api.AuthzGroupService; +import org.sakaiproject.authz.api.FunctionManager; +import org.sakaiproject.authz.api.SecurityService; +import org.sakaiproject.event.api.EventTrackingService; +import org.sakaiproject.memory.api.MemoryService; +import org.sakaiproject.sitestats.api.StatsManager; +import org.sakaiproject.time.api.UserTimeService; +import org.sakaiproject.tool.api.SessionManager; +import org.sakaiproject.user.api.UserDirectoryService; +import org.sakaiproject.grading.api.GradingService; +import org.sakaiproject.lti.api.SiteEmailPreferenceSetter; +import org.sakaiproject.lti.api.SiteMembershipUpdater; +import org.sakaiproject.lti.api.UserFinderOrCreator; +import org.sakaiproject.lti.api.UserLocaleSetter; +import org.sakaiproject.lti.api.UserPictureSetter; +import org.sakaiproject.component.api.ServerConfigurationService; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.ImportResource; +import org.springframework.context.annotation.PropertySource; +import org.springframework.core.env.Environment; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.orm.hibernate5.HibernateTransactionManager; +import org.springframework.orm.hibernate5.LocalSessionFactoryBuilder; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +@Configuration +@EnableTransactionManagement +@ImportResource("classpath:/WEB-INF/components.xml") +@PropertySource("classpath:/hibernate.properties") +public class PlusTestConfiguration { + + @Autowired + private Environment environment; + + @Autowired + @Qualifier("plusHibernateMappings") + private AdditionalHibernateMappings hibernateMappings; + + @Bean(name = "org.sakaiproject.springframework.orm.hibernate.GlobalSessionFactory") + public SessionFactory sessionFactory() throws IOException { + + DataSource dataSource = dataSource(); + LocalSessionFactoryBuilder sfb = new LocalSessionFactoryBuilder(dataSource); + StandardServiceRegistryBuilder srb = sfb.getStandardServiceRegistryBuilder(); + srb.applySetting(org.hibernate.cfg.Environment.DATASOURCE, dataSource); + srb.applySettings(hibernateProperties()); + StandardServiceRegistry sr = srb.build(); + sr.getService(MutableIdentifierGeneratorFactoryInitiator.INSTANCE.getServiceInitiated()) + .register("uuid2", AssignableUUIDGenerator.class); + hibernateMappings.processAdditionalMappings(sfb); + return sfb.buildSessionFactory(sr); + } + + @Bean(name = "javax.sql.DataSource") + public DataSource dataSource() { + + DriverManagerDataSource db = new DriverManagerDataSource(); + db.setDriverClassName(environment.getProperty(org.hibernate.cfg.Environment.DRIVER, jdbcDriver.class.getName())); + db.setUrl(environment.getProperty(org.hibernate.cfg.Environment.URL, "jdbc:hsqldb:mem:test")); + db.setUsername(environment.getProperty(org.hibernate.cfg.Environment.USER, "sa")); + db.setPassword(environment.getProperty(org.hibernate.cfg.Environment.PASS, "")); + return db; + } + + @Bean + public Properties hibernateProperties() { + + return new Properties() { + { + setProperty(org.hibernate.cfg.Environment.DIALECT, environment.getProperty(org.hibernate.cfg.Environment.DIALECT, HSQLDialect.class.getName())); + setProperty(org.hibernate.cfg.Environment.HBM2DDL_AUTO, environment.getProperty(org.hibernate.cfg.Environment.HBM2DDL_AUTO)); + setProperty(org.hibernate.cfg.Environment.ENABLE_LAZY_LOAD_NO_TRANS, environment.getProperty(org.hibernate.cfg.Environment.ENABLE_LAZY_LOAD_NO_TRANS, "true")); + setProperty(org.hibernate.cfg.Environment.USE_SECOND_LEVEL_CACHE, environment.getProperty(org.hibernate.cfg.Environment.USE_SECOND_LEVEL_CACHE)); + setProperty(org.hibernate.cfg.Environment.CURRENT_SESSION_CONTEXT_CLASS, environment.getProperty(org.hibernate.cfg.Environment.CURRENT_SESSION_CONTEXT_CLASS)); + } + }; + } + + @Bean(name = "org.sakaiproject.springframework.orm.hibernate.GlobalTransactionManager") + public HibernateTransactionManager transactionManager(SessionFactory sessionFactory) { + + HibernateTransactionManager txManager = new HibernateTransactionManager(); + txManager.setSessionFactory(sessionFactory); + return txManager; + } + + @Bean(name = "org.sakaiproject.authz.api.AuthzGroupService") + public AuthzGroupService authzGroupService() { + return mock(AuthzGroupService.class); + } + + @Bean(name = "org.sakaiproject.event.api.EventTrackingService") + public EventTrackingService eventTrackingService() { + return mock(EventTrackingService.class); + } + + @Bean(name = "org.sakaiproject.authz.api.FunctionManager") + public FunctionManager functionManager() { + return mock(FunctionManager.class); + } + + @Bean(name = "org.sakaiproject.authz.api.SecurityService") + public SecurityService securityService() { + return mock(SecurityService.class); + } + + @Bean(name = "org.sakaiproject.tool.api.SessionManager") + public SessionManager sessionManager() { + return mock(SessionManager.class); + } + + @Bean(name = "org.sakaiproject.sitestats.api.StatsManager") + public StatsManager statsManager() { + return mock(StatsManager.class); + } + + @Bean(name = "org.sakaiproject.memory.api.MemoryService") + public MemoryService memoryService() { + return mock(MemoryService.class); + } + + @Bean(name = "org.sakaiproject.user.api.UserDirectoryService") + public UserDirectoryService userDirectoryService() { + return mock(UserDirectoryService.class); + } + + @Bean(name = "org.sakaiproject.time.api.UserTimeService") + public UserTimeService userTimeService() { + return mock(UserTimeService.class); + } + + @Bean(name = "org.sakaiproject.grading.api.GradingService") + public GradingService gradingService() { + return mock(GradingService.class); + } + + @Bean(name = "org.sakaiproject.lti.api.UserFinderOrCreator") + public UserFinderOrCreator userFinderOrCreator() { + return mock(UserFinderOrCreator.class); + } + + @Bean(name = "org.sakaiproject.lti.api.UserLocaleSetter") + public UserLocaleSetter userLocaleSetter() { + return mock(UserLocaleSetter.class); + } + + @Bean(name = "org.sakaiproject.lti.api.UserPictureSetter") + public UserPictureSetter userPictureSetter() { + return mock(UserPictureSetter.class); + } + + @Bean(name = "org.sakaiproject.lti.api.SiteEmailPreferenceSetter") + public SiteEmailPreferenceSetter siteEmailPreferenceSetter() { + return mock(SiteEmailPreferenceSetter.class); + } + + @Bean(name = "org.sakaiproject.lti.api.SiteMembershipUpdater") + public SiteMembershipUpdater siteMembershipUpdater() { + return mock(SiteMembershipUpdater.class); + } + + @Bean(name = "org.sakaiproject.component.api.ServerConfigurationService") + public ServerConfigurationService serverConfigurationService() { + return mock(ServerConfigurationService.class); + } + +} diff --git a/plus/impl/src/test/resources/hibernate.properties b/plus/impl/src/test/resources/hibernate.properties new file mode 100644 index 000000000000..73aa2d3611f2 --- /dev/null +++ b/plus/impl/src/test/resources/hibernate.properties @@ -0,0 +1,19 @@ +# Base Hibernate settings +# hibernate.show_sql=true +hibernate.hbm2ddl.auto=create +hibernate.enable_lazy_load_no_trans=true +hibernate.cache.use_second_level_cache=false +hibernate.current_session_context_class=org.springframework.orm.hibernate5.SpringSessionContext + +# Connection definition to the HSQLDB database +hibernate.connection.driver_class=org.hsqldb.jdbcDriver +hibernate.connection.url=jdbc:hsqldb:mem:test +hibernate.dialect=org.hibernate.dialect.HSQLDialect +hibernate.connection.username=sa +hibernate.connection.password= + +#hibernate.connection.driver_class=com.mysql.jdbc.Driver +#hibernate.connection.url=jdbc:mysql://localhost:3306/sakai?useUnicode=true&characterEncoding=UTF-8 +#hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect +#hibernate.connection.username=sakai +#hibernate.connection.password=sakai diff --git a/plus/pom.xml b/plus/pom.xml new file mode 100644 index 000000000000..6a9c815b915d --- /dev/null +++ b/plus/pom.xml @@ -0,0 +1,63 @@ + + + + 4.0.0 + + + org.sakaiproject + master + 23-SNAPSHOT + ../master/pom.xml + + + SakaiPlus LTI Advantage Provider + org.sakaiproject.plus + sakai-plus-base + 23-SNAPSHOT + pom + + + api + impl + provider + tool + + + + src/main/java + + + ${basedir}/src/main/resources + + **/* + + + + + + maven-war-plugin + + src/main/webapp + + + + + + + + + org.sakaiproject.plus + sakai-plus-api + ${project.version} + provided + + + org.sakaiproject.grading + sakai-grading-api + ${project.version} + + + + + diff --git a/plus/provider/maven.xml b/plus/provider/maven.xml new file mode 100644 index 000000000000..f3a6cb7f135e --- /dev/null +++ b/plus/provider/maven.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/plus/provider/pom.xml b/plus/provider/pom.xml new file mode 100644 index 000000000000..21a4aaf1472d --- /dev/null +++ b/plus/provider/pom.xml @@ -0,0 +1,146 @@ + + + 4.0.0 + + + org.sakaiproject.plus + sakai-plus-base + 23-SNAPSHOT + ../pom.xml + + + org.sakaiproject.plus + plus + war + + + + org.sakaiproject.basiclti + basiclti-api + + + org.sakaiproject.basiclti + basiclti-util + + + com.nimbusds + nimbus-jose-jwt + + + org.sakaiproject.basiclti + basiclti-common + + + org.sakaiproject.kernel + sakai-kernel-util + + + org.sakaiproject.kernel + sakai-kernel-api + + + org.sakaiproject.kernel + sakai-component-manager + + + javax.servlet + javax.servlet-api + + + javax.portlet + portlet-api + + + + + org.sakaiproject.entitybroker + entitybroker-api + + + org.sakaiproject.entitybroker + entitybroker-utils + + + org.springframework + spring-core + + + org.springframework + spring-context + + + org.springframework + spring-web + + + org.sakaiproject.grading + sakai-grading-api + + + org.hibernate + hibernate-core + + + org.apache.commons + commons-lang3 + + + org.apache.commons + commons-text + + + com.fasterxml.jackson.core + jackson-core + + + com.fasterxml.jackson.core + jackson-annotations + + + com.fasterxml.jackson.core + jackson-databind + + + org.sakaiproject.portal + sakai-portal-util + + + org.sakaiproject.plus + sakai-plus-api + + + io.jsonwebtoken + jjwt-api + + + io.jsonwebtoken + jjwt-impl + + + io.jsonwebtoken + jjwt-jackson + + + org.apache.httpcomponents + httpclient + + + com.googlecode.json-simple + json-simple + ${json.simple.version} + + + + + + + ${basedir}/src/bundle + + **/*.properties + **/*.xml + **/*.vm + + + + + diff --git a/plus/provider/src/bundle/plus.properties b/plus/provider/src/bundle/plus.properties new file mode 100644 index 000000000000..028cb25defef --- /dev/null +++ b/plus/provider/src/bundle/plus.properties @@ -0,0 +1,176 @@ +sakai.site.title=Sakai Plus +sakai.site.description=This link will launch a complete Sakai site with the ability to manage and add tools with roster and gradebook synchronization with the calling LMS. + +plus.oidc_login.format=The OIDC Login URL has an incorrect format +plus.launch.state.notfound=Missing state on oidc_launch +plus.launch.state.signature=Could not verify signature for state +plus.tenant.notfound=Could not find tenant +plus.launch.id_token.notfound=Missing id_token on oidc_launch +plus.launch.id_token.missing.data=Launch id_token must contain issuer, clientId/audience, and deploymentId +plus.launch.tenant.check=LaunchJWT issuer, clientId/audience, and deploymentId must match Tenant values +plus.launch.kid.notfound=Could not find key id (kid) in id_token on oidc_launch +plus.launch.keyset.blank=Tenant does not have a configured OIDC KeySet URL +plus.launch.keyset.load.fail=Could not load OIDC KeySet from URL +plus.launch.kid.load.fail=OIDC KeySet did not contain key id (kid) +plus.launch.id_token.signature=Signature from id_token did not verify in oidc_launch +plus.launch.id_token.load.fail=Could not parse claims in the id_token in an oidc_launch +plus.target_link_uri.missing=Missing target_link_uri in launch data +plus.message_type.unsupported=Unsupported Launch Message type + +plus.repost.new.window=Open Sakai Plus in a New Window +plus.repost.loaded=This tool was successfully loaded in a new browser window. Reload the page to access the tool again. + +plus.payload.state.signature=Could not verify signature for session payload + +plus.plusservice.null=launchJWT and tenant must be non-null +plus.plusservice.tenant.check=LaunchJWT issuer, clientId/audience, and deploymentId must match Tenant values +plus.plusservice.null.parameters=Required parameters are null +plus.plusservice.not.persisted=Required parameters must be saved / persisted + +plus.server.title=Sakai Plus +plus.server.description=Open source LMS and tools available for integration into your LMS. + +plus.deeplink.details=Details +plus.deeplink.tool.not.selected=No tool selected +plus.deeplink.sakai.plus=Sakai Plus +plus.deeplink.deep.link=Sakai Tools +plus.deeplink.context.launch=Sakai Plus +plus.deeplink.privacy.launch=Privacy Launch + +plus.dynamic.request.missing=Missing required parameters for Dynamic Registration +plus.dynamic.unlock.mismatch=Incorrect unlock_token for Dynamic Registration +plus.dynamic.starting=Starting IMS LTI Dynamic Registration Process for tenant +plus.dynamic.specification=specification +plus.dynamic.config.url=OpenID Configuration URL +plus.dynamic.badurl=Error in OIDC Configuration URL +plus.dynamic.parse=Unable to parse OIDC Configuration data +plus.dynamic.missing=OIDC Provider configuration must include issuer, authorization_endpoint, token_endpoint, jwks_uri, and registration_endpoint +plus.dynamic.issuer.mismatch=Retrieved issuer does not match stored issuer +plus.dynamic.missing.domain=Could not determine local server's domain - internal configuration error +plus.dynamic.registration.post=Post of OIDC Client Registration Request failed + +plus.dynamic.retrieved.openid=Retrieved OpenID Configuration +plus.dynamic.client.request=Sending OpenID Client Registration Request +plus.dynamic.client.response=Received OpenID Client Registration Response +plus.dynamic.continue=Continue Registration in the LMS +plus.dynamic.debug=Debug Log + +plus.dynamic.characters=characters +plus.dynamic.hide.show=Show/Hide + +lti.forward = Press to start external tool +noiframes = Your browser does not support iframes. +noiframe.press.here = Press here to launch. +not.configured = This tool has not yet been configured. + +new.page.launch = (We attempted to launch {0} in a new window. To launch pop-up windows automatically in the future, configure your browser to allow {1} to open pop-up windows.) +tool.name = your tool + +edit.exit = Cancel +edit.clear.prefs = Clear Stored Preferences +edit.nothing = Configuration has been pre-set and cannot be edited for this placement. + +edit.clear.yes = YES +edit.clear.no = NO + +are.you.sure = Are you sure your want to remove the settings for this Tool Interoperability Placement? + +select.url = URL and Password +select.xml = XML Paste +required.information = Required Information +display.information = Display Information +launch.information = Optional Launch Information +tool.xml.detail =

Some external tools will provide you an XML descriptor that includes the URL and other configuration information. If your external tool supports the XML descriptor, copy and paste the descriptor in this field. +tool.url = Remote Tool Url: +tool.url.detail = +tool.key = Remote Tool Key: +tool.key.detail = +tool.secret = Remote Tool Secret: +tool.secret.detail = +page.title = Set Button Text: +page.title.detail = (Text in tool menu) +tool.title = Set Tool Title: +tool.title.detail = (Above the tool) +tool.fa_icon = Choose an icon for this tool: +new.page = Open in a New Window: +new.page.detail = +maximize.page = Maximize Window Width: +maximize.page.detail = +iframe.height = iFrame Height: +iframe.height.detail = +toolorder = Tool Order: +toolorder.detail = +debug.launch = Debug Launch: +debug.launch.detail =
When Debug Launch is selected, the tool pauses before launching and displays launch data. +launch.privacy = Releasing Roster Information +launch.privacy.detail =
These options allow you to control which information is released to the external tool. Some tools may require roster information to function. +privacy.releasename = Send Names to the External Tool +privacy.releaseemail = Send Email Addresses to the External Tool +privacy.allowroster = Allow the External Tool to retrieve the course roster +allowroster.detail = +allowsettings.information = Storing Tool Settings +privacy.allowsettings = Allow the External Tool to store and retrieve its settings +allowsettings.detail =
This allows the External Tool to store and retrieve its own settings in this placement. This only gives the tool access a scratch area to store and retrieve its own settings. It does not give the tool any access to any of the other setting. +contentlink.legend = Content Link Setting +contentlink.label = Content Link URL +contentlink.detail =
This allows you to specify a content link to be sent to the external tool. This content should be available to unauthenticated users. The typical use of this feature is to send a file (such as a SCORM object) to the external tool to be played by the external tool. +launch.custom = Custom Launch Parameters +launch.custom.detail =
You can add a series of launch parameters using keyword=value. You can either put separate parameters on their own lines or separate them by semicolons (;). +update.options = Update Options + +launch.splash = Splash Screen +launch.splash.detail =
This text will be displayed to users before they are sent to the External Tool. You cannot use HTML in this field. + +error.modify.prefs = Unable to modify preferences. +error.xml.input = Error in XML input. +error.no.input = You must set Launch URL, key, and secret +error.bad.url = Incorrectly formatted Launch URL +error.bad.height = Height must be a positive integer +error.page.title = Unable to set page title. + +launch.missing = Missing required field for LTI Launch +launch.invalid = Invalid value for LTI Launch +launch.tool_id.required = Tool is not found on launch URL +launch.tool.missing = Missing tool identifier +launch.tool.notallowed = Tool is not configured to allow LTI access +launch.tool.notfound = Tool ID is not registered on this system +launch.key.notfound = OAuth Consumer Key Not found +launch.no.validate = OAuth does not validate this request +launch.no.session = Unable to find session +launch.create.user = Unable to create user account +launch.site.save = Unable to save site +launch.create.site = Unable to create site +launch.role.missing = Could not find role +launch.join.site = Could not add user to site +launch.tool.search = Error searching through tools +launch.tool.add = Could not add tool to site +launch.continue = Press to continue to tool +launch.user.invalid = Unable to find user +launch.site.invalid = Unable to find site +launch.site.user.missing = Unable to find user in site +launch.site.tool.missing = Unable to find tool in site +launch.site.tool.denied = Not allowed to access tool +launch.provided.eid.invalid = The provided eid was invalid +launch.user.site.unknown = Could not determine the user's site ID +launch.user.multiple.emailaddress=multiple user id's exist for emailaddress + +content_item.install.button = Install +content_item.return_url.notfound = Missing content_item_return_url from launch data +content_item.back.to.store = Back to List + +canvas.title = Sakai Tools +canvas.description = This server hosts Sakai tools that you can launch from Canvas. + +canvas.error.missing.domain = Unable to parse domain (internal error) + +gradable.information=Routing Grades to the Gradebook +gradable.title=Select Gradebook Item:
+gradable.nograde=Do not accept grades from this tool +gradable.detail=
Be careful when changing this value. If you change this value, existing grades will not be moved to the new Gradebook item. Only future grades received from the tool will be routed to the new Gradebook item. +gradable.newassignment=Create Gradebook Item +gradable.newassignment.detail=
This creates a new Gradebook item and routes grades to this new item. You only need to create the item once. +error.gradable.badassign=Invalid Gradebook Item Selected +error.gradable.badcreate=Unable to create Gradebook item + +error.submit.timeout=Unable to send launch to remote URL + diff --git a/plus/provider/src/main/java/org/sakaiproject/plus/ProviderServlet.java b/plus/provider/src/main/java/org/sakaiproject/plus/ProviderServlet.java new file mode 100644 index 000000000000..7d7c0b87fe08 --- /dev/null +++ b/plus/provider/src/main/java/org/sakaiproject/plus/ProviderServlet.java @@ -0,0 +1,1653 @@ +/** + * $URL$ + * $Id$ + * + * Copyright (c) 2009- The Sakai Foundation + * + * Licensed under the Educational Community License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.opensource.org/licenses/ECL-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.sakaiproject.plus; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.InputStream; + +import java.net.URISyntaxException; +import java.net.URL; +import java.net.MalformedURLException; +import java.net.http.HttpResponse; // Thanks Java 11 +import java.util.*; + +import javax.servlet.ServletConfig; +import javax.servlet.ServletException; +import javax.servlet.ServletContext; +import javax.servlet.RequestDispatcher; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import lombok.extern.slf4j.Slf4j; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtBuilder; +import java.security.Key; +import java.security.KeyPair; +import java.security.interfaces.RSAPublicKey; + +import java.time.Instant; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.json.simple.JSONValue; +import org.json.simple.JSONObject; +import org.json.simple.JSONArray; + +import org.tsugi.basiclti.BasicLTIConstants; +import org.tsugi.basiclti.BasicLTIUtil; +import org.tsugi.lti13.LTI13JwtUtil; +import org.tsugi.lti13.LTI13KeySetUtil; +import org.tsugi.jackson.JacksonUtil; + +import org.tsugi.http.HttpClientUtil; + +import org.sakaiproject.authz.api.SecurityAdvisor; +import org.sakaiproject.authz.api.SecurityService; +import org.sakaiproject.lti.api.LTIException; +import org.sakaiproject.lti.api.LTIService; +import org.sakaiproject.lti.api.SiteEmailPreferenceSetter; +import org.sakaiproject.lti.api.UserFinderOrCreator; +import org.sakaiproject.lti.api.UserLocaleSetter; +import org.sakaiproject.lti.api.UserPictureSetter; +import org.sakaiproject.lti.api.SiteMembershipUpdater; +import org.sakaiproject.lti.api.SiteMembershipsSynchroniser; +import org.sakaiproject.basiclti.util.SakaiBLTIUtil; +import org.sakaiproject.basiclti.util.SakaiKeySetUtil; +import org.sakaiproject.component.api.ServerConfigurationService; +import org.sakaiproject.event.api.UsageSessionService; +import org.sakaiproject.exception.IdUnusedException; +import org.sakaiproject.site.api.Site; +import org.sakaiproject.site.api.SitePage; +import static org.sakaiproject.site.api.SiteService.SITE_TITLE_MAX_LENGTH; +import org.sakaiproject.site.api.SiteService.SiteTitleValidationStatus; +import org.sakaiproject.site.api.ToolConfiguration; +import org.sakaiproject.site.api.SiteService; +import org.sakaiproject.tool.api.Session; +import org.sakaiproject.tool.api.Tool; +import org.sakaiproject.tool.api.SessionManager; +import org.sakaiproject.tool.api.ToolManager; +import org.sakaiproject.user.api.User; +import org.sakaiproject.util.ResourceLoader; +import org.sakaiproject.util.api.FormattedText; + +import org.springframework.context.ApplicationContext; +import org.springframework.web.context.support.WebApplicationContextUtils; + +import org.tsugi.lti13.objects.LaunchJWT; +import org.tsugi.lti13.objects.OpenIDProviderConfiguration; +import org.tsugi.lti13.objects.OpenIDClientRegistration; +import org.tsugi.lti13.objects.LTIToolConfiguration; +import org.tsugi.lti13.objects.LTILaunchMessage; +import org.sakaiproject.lti13.util.SakaiLaunchJWT; +import org.tsugi.lti13.LTI13Util; +import org.tsugi.lti13.LTI13ConstantsUtil; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.text.StringEscapeUtils; +import static org.apache.commons.lang3.StringUtils.isEmpty; +import static org.apache.commons.lang3.StringUtils.isNotEmpty; +import org.apache.http.client.utils.URIBuilder; + +import org.tsugi.deeplink.objects.LtiResourceLink; + +import org.sakaiproject.plus.api.PlusService; + +import org.sakaiproject.plus.api.model.Tenant; + +import org.sakaiproject.plus.api.repository.TenantRepository; +import org.sakaiproject.plus.api.repository.ContextRepository; + +import org.sakaiproject.plus.api.Launch; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.context.support.SpringBeanAutowiringSupport; + +@SuppressWarnings("deprecation") +@Slf4j +public class ProviderServlet extends HttpServlet { + + private static final long serialVersionUID = 1L; + private static ResourceLoader rb = new ResourceLoader("plus"); + private static final String BASICLTI_RESOURCE_LINK = "blti:resource_link_id"; + + private static final String SAKAI_SITE_LAUNCH = "sakai.site"; + private static final String SAKAI_DEEPLINK_LAUNCH = "sakai.deeplink"; + + private static final String DEFAULT_PRIVACY_URL = "https://www.sakailms.com/plus-privacylaunch"; + + // Wait five or 30 minutes between successive calls to NRPS. + public final long delayNRPSInstructor = 300; + public final long delayNRPSLearner = 30*60; // 30 minutes + + @Autowired private ServerConfigurationService serverConfigurationService; + @Autowired private SiteMembershipUpdater siteMembershipUpdater; + @Autowired private SiteMembershipsSynchroniser siteMembershipsSynchroniser; + @Autowired private SiteEmailPreferenceSetter siteEmailPreferenceSetter; + @Autowired private UserFinderOrCreator userFinderOrCreator; + @Autowired private UserLocaleSetter userLocaleSetter; + @Autowired private UserPictureSetter userPictureSetter; + @Autowired private PlusService plusService; + @Autowired private TenantRepository tenantRepository; + @Autowired private SecurityService securityService; + @Autowired private UsageSessionService usageSessionService; + @Autowired private SiteService siteService; + @Autowired private SessionManager sessionManager; + @Autowired private ToolManager toolManager; + @Autowired private FormattedText formattedText; + + private String randomUUID = UUID.randomUUID().toString(); + + private KeyPair localKeyPair = LTI13Util.generateKeyPair(); + + /** + * Setup a security advisor. + */ + public void pushAdvisor() { + // setup a security advisor + securityService.pushAdvisor(new SecurityAdvisor() { + public SecurityAdvice isAllowed(String userId, String function, + String reference) { + return SecurityAdvice.ALLOWED; + } + }); + } + + /** + * Remove our security advisor. + */ + public void popAdvisor() { + securityService.popAdvisor(); + } + + // TODO: Make this a *lot* prettier and add forward to knowledge base feature :) + public void doError(HttpServletRequest request,HttpServletResponse response, String s, String message, Throwable e) throws java.io.IOException { + if (e != null) { + log.error(e.getLocalizedMessage(), e); + } + log.info("{}: {}", rb.getString(s), message); + PrintWriter out = response.getWriter(); + out.println(rb.getString(s)); + out.println(htmlEscape(message)); + if ( e != null ) out.println(e.getMessage()); + } + + // TODO: Make this a *lot* prettier and add forward to knowledge base feature :) + public void addError(PrintWriter out, String s, String message, Throwable e) throws java.io.IOException { + if (e != null) { + log.error(e.getLocalizedMessage(), e); + } + log.info("{}: {}", rb.getString(s), message); + out.println(rb.getString(s)); + out.println(htmlEscape(message)); + if ( e != null ) out.println(e.getMessage()); + } + + @Override + public void init(ServletConfig config) throws ServletException { + + super.init(config); + SpringBeanAutowiringSupport.processInjectionBasedOnCurrentContext(this); + + ApplicationContext ac = WebApplicationContextUtils.getWebApplicationContext(config.getServletContext()); + + // Warm up the keyset + KeyPair kp = SakaiKeySetUtil.getCurrent(); + } + + protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { + + doPost(request, response); + } + + @SuppressWarnings("unchecked") + protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { + log.debug("doPost {}", request.getPathInfo()); + + String ipAddress = request.getRemoteAddr(); + if (log.isDebugEnabled()) { + log.debug("Sakai Plus Provider request from IP={}", ipAddress); + } + + if ( !plusService.enabled() ) { + log.warn("LTI Advantage Provider is Disabled IP={}", ipAddress); + response.sendError(HttpServletResponse.SC_FORBIDDEN, + "LTI Advantage Provider is Disabled"); + return; + } + + String uri = request.getPathInfo(); // plus/sakai/oidc_login/4444 + String[] parts = uri.split("/"); + + // /plus/sakai/oidc_login/44guid44 + if (parts.length >= 3 && "oidc_login".equals(parts[1])) { + if ( parts.length == 3 && isNotEmpty(parts[2])) { + handleOIDCLogin(request, response, parts[2]); + return; + } + doError(request, response, "plus.oidc_login.format", uri, null); + return; + } + + // /plus/sakai/dynamic/44guid44?reg_token=..&openid_configuration=https:.. + // https://github.com/IMSGlobal/lti-dynamic-registration/blob/develop/docs/lti-dynamic-registration.md + if (parts.length >= 3 && "dynamic".equals(parts[1])) { + if ( parts.length == 3 && isNotEmpty(parts[2])) { + handleDynamicRegistration(request, response, parts[2]); + return; + } + doError(request, response, "plus.oidc_login.format", uri, null); + return; + } + + if (log.isDebugEnabled()) { + Map params = (Map) request + .getParameterMap(); + for (Map.Entry param : params.entrySet()) { + log.debug("{}:{}", param.getKey(), param.getValue()[0]); + } + } + + if ( "/canvas-config.json".equals(request.getPathInfo()) ) { + if ( serverConfigurationService.getBoolean(PlusService.PLUS_CANVAS_ENABLED, PlusService.PLUS_CANVAS_ENABLED_DEFAULT)) { + handleCanvasConfig(request, response); + return; + } else { + log.warn("Canvas config is Disabled IP={}", ipAddress); + response.sendError(HttpServletResponse.SC_FORBIDDEN, + "Canvas config is Disabled"); + return; + } + } + + // REQUIRED. The issuer identifier identifying the learning platform. + String id_token = request.getParameter("id_token"); + String state = request.getParameter("state"); + String payloadStr = request.getParameter("payload"); + + // TODO: Check for and add Extra Canvas error detail... + if ( StringUtils.isBlank(id_token) ) { + doError(request, response, "plus.launch.id_token.notfound", null, null); + return; + } + + // Parse the id_token and check for format and missing values (validation is later) + String rawbody = LTI13JwtUtil.rawJwtBody(id_token); + ObjectMapper mapper = JacksonUtil.getLaxObjectMapper(); + + SakaiLaunchJWT launchJWT = mapper.readValue(rawbody, SakaiLaunchJWT.class); + + String issuer = launchJWT.issuer; + String clientId = launchJWT.audience; + String deploymentId = launchJWT.deployment_id; + + if ( isEmpty(issuer) || isEmpty(clientId) || isEmpty(deploymentId) ) { + doError(request, response, "plus.launch.id_token.missing.data", null, null); + return; + } + + // Check if we are in the install-phase of a DeepLink process (i.e. payloadStr is defined) + if ( LaunchJWT.MESSAGE_TYPE_DEEP_LINK.equals(launchJWT.message_type) && isNotEmpty(payloadStr)) { + if ( ! serverConfigurationService.getBoolean(PlusService.PLUS_DEEPLINK_ENABLED, PlusService.PLUS_DEEPLINK_ENABLED_DEFAULT)) { + + log.warn("DeepLink is Disabled IP={}", ipAddress); + response.sendError(HttpServletResponse.SC_FORBIDDEN, + "DeepLink is Disabled"); + return; + } else { + log.debug("DeepLink Install"); + String install_id = (String) request.getParameter("install"); + if ( install_id == null ) { + doError(request, response, "launch.tool.missing", install_id, null); + return; + } + + handleDeepLinkInstall(request, response, install_id, launchJWT, payloadStr); + return; + } + } + + // Verify the state + if ( StringUtils.isBlank(state) ) { + doError(request, response, "plus.launch.state.notfound", null, null); + return; + } + + // First check the signature on state - this is inexpensive and requires no DB + Key stateKey = localKeyPair.getPublic(); + // Chaos Monkey : stateKey = LTI13Util.generateKeyPair().getPublic(); + Claims claims = null; + try { + Jws jws = Jwts.parser().setAllowedClockSkewSeconds(60).setSigningKey(stateKey).parseClaimsJws(state); + claims = jws.getBody(); + } catch (io.jsonwebtoken.security.SignatureException e) { + doError(request, response, "plus.launch.state.signature", null, null); + return; + } + + // TODO: Double check the browser signature + + // Load tenant and make sure it matches the Launch + String tenant_guid = (String) claims.get("tenant_guid"); + + Optional optTenant = tenantRepository.findById(tenant_guid); + Tenant tenant = null; + if ( optTenant.isPresent() ) { + tenant = optTenant.get(); + } + + if ( tenant == null ) { + doError(request, response, "plus.tenant.notfound", tenant_guid, null); + return; + } + + // For Canvas, it makes *lots* of deployment_id values for each clientId + // so we just leave deployment_id blank for canvas and accept any deployment_id. + // We track each deployent_id on the Context for AccessToken calls + String missing = ""; + if (! issuer.equals(tenant.getIssuer()) ) missing = missing + "issuer mismatch " + issuer + "/" + tenant.getIssuer(); + if (! clientId.equals(tenant.getClientId()) ) missing = missing + "clientId mismatch " + clientId + "/" + tenant.getClientId(); + if (! tenant.validateDeploymentId(deploymentId) ) missing = missing + "deploymentId mismatch " + deploymentId + "/" + tenant.getDeploymentId(); + + if ( ! missing.equals("") ) { + doError(request, response, "plus.plusservice.tenant.check", missing, null); + } + + // Store this all in payload for future use and to share some of the + // processing code between LTI 1.1 and Advantage + Map payload = plusService.getPayloadFromLaunchJWT(tenant, launchJWT); + payload.put("tenant_guid", tenant_guid); + payload.put("id_token", id_token); + + log.debug("Message type="+launchJWT.message_type); + + String tool_id = null; + + // Look at the message type + if ( LaunchJWT.MESSAGE_TYPE_LTI_CONTEXT.equals(launchJWT.message_type) ) { + tool_id = SAKAI_SITE_LAUNCH; + } else if ( LaunchJWT.MESSAGE_TYPE_DEEP_LINK.equals(launchJWT.message_type) ) { + tool_id = SAKAI_DEEPLINK_LAUNCH; + // Resource Link Launch + } else if ( LaunchJWT.MESSAGE_TYPE_LAUNCH.equals(launchJWT.message_type) ) { + // For a normal launch, the target_link_uri is our guide to what is next + String target_link_uri = launchJWT.target_link_uri; + if ( LaunchJWT.MESSAGE_TYPE_LAUNCH.equals(launchJWT.message_type) && isEmpty(target_link_uri) ) { + doError(request, response, "plus.target_link_uri.missing", target_link_uri, null); + return; + } + + log.debug("Target_link_uri={}", target_link_uri); + // http://localhost:8080/plus/sakai/sakai.resources + // 0 1 2 3 4 5 + String [] pieces = target_link_uri.split("/"); + List allowedToolsList = getAllowedTools(tenant); + if ( pieces.length == 6 ) { + if ( allowedToolsList.contains(pieces[5])) { + tool_id = pieces[5]; + } else { + doError(request, response, "launch.tool.notallowed", pieces[4], null); + return; + } + } else { + if ( allowedToolsList.size() == 1 ) { + tool_id = allowedToolsList.get(0); + } else if ( allowedToolsList.contains(SAKAI_SITE_LAUNCH) ) { + tool_id = SAKAI_SITE_LAUNCH; + } else { + doError(request, response, "launch.tool.notfound", launchJWT.message_type, null); + return; + } + } + } else if ( LaunchJWT.MESSAGE_TYPE_LTI_DATA_PRIVACY_LAUNCH_REQUEST.equals(launchJWT.message_type) ) { + String privacyUrl = serverConfigurationService.getString(PlusService.PLUS_SERVER_POLICY_URI, + serverConfigurationService.getString(PlusService.PLUS_SERVER_TOS_URI, DEFAULT_PRIVACY_URL)); + log.debug("Redirecting privacyUrl="+privacyUrl); + response.sendRedirect(privacyUrl); + return; + } else { + doError(request, response, "plus.message_type.unsupported", launchJWT.message_type, null); + return; + } + + log.debug("Tool id="+tool_id); + + // We have payload - if this is deep link time, we set things up and display the tool selections + if ( tool_id.equals(SAKAI_DEEPLINK_LAUNCH) ) { + if ( ! serverConfigurationService.getBoolean(PlusService.PLUS_DEEPLINK_ENABLED, PlusService.PLUS_DEEPLINK_ENABLED_DEFAULT)) { + log.warn("DeepLink is Disabled IP={}", ipAddress); + response.sendError(HttpServletResponse.SC_FORBIDDEN, + "DeepLink is Disabled"); + return; + } else { + log.debug("DeepLink setup"); + handleDeepLinkSetup(request, response, tenant, id_token, launchJWT, payload); + return; + } + } + payload.put("tool_id", tool_id); + + // Since this is not deeplink, we might need to escape the iframe for various reasons + // Sometimes for a browser like Safari - we can't set a cookie so we need to launch in + // a new window even if it is not the ideal or requested UX + String repost = request.getParameter("repost"); + if ( isEmpty(repost) ) { + List newWindowTools = getNewWindowTools(tenant); + boolean forceNewWindow = SAKAI_SITE_LAUNCH.equals(tool_id) || newWindowTools.contains(tool_id); + handleRepost(request, response, forceNewWindow); + return; + } + + // Now we are in the oidc_launch process so we proceed with all the validation + log.debug("==== oidc_launch ===="); + + /* + * If this is true, multiple issuer/clientid/deploymentid/subject users will map to a single + * Sakai user in ths instance based on email address. This needs to be set to true if + * this Sakai is ever to become an enterprise-wde LMS using the email address as + * SSO without major conversion. It also means that this Sakai can be behing an SSO or if users + * reset their passwords - they can directly log in with their email addresses and + * see all their sites and history. + * + * If this is true, the user's email address will be their EID across multiple Tenants. + * even though this setting is per-Tenant. There is no account siloing between the Tenants + * that have this enabled. + */ + boolean isEmailTrustedConsumer = ! Boolean.FALSE.equals(tenant.getTrustEmail()); + + try { + Launch launch = validate(payload, launchJWT, tenant); + + User user = userFinderOrCreator.findOrCreateUser(payload, false, isEmailTrustedConsumer); + if ( plusService.verbose() ) { + log.info("user={}", user); + } else { + log.debug("user={}", user); + } + + plusService.connectSubjectAndUser(launch.getSubject(), user); + + // Check if we are loop-backing on the same server, and already logged in as same user + Session sess = sessionManager.getCurrentSession(); + String serverUrl = SakaiBLTIUtil.getOurServerUrl(); + String ext_sakai_server = (String) payload.get("ext_sakai_server"); + + loginUser(ipAddress, user); + + // Re-grab the session + sess = sessionManager.getCurrentSession(); + + // This needs to happen after login, when we have a session for the user. + userLocaleSetter.setupUserLocale(payload, user, false, isEmailTrustedConsumer); + + userPictureSetter.setupUserPicture(payload, user, false, isEmailTrustedConsumer); + + if ( launch.getContext() != null ) { + payload.put("lineitems_url", launch.getContext().getLineItems()); + payload.put("lineitems_token", launch.getContext().getLineItemsToken()); + + payload.put("grade_token", launch.getContext().getGradeToken()); + + payload.put("nrps_url", launch.getContext().getContextMemberships()); + payload.put("nrps_token", launch.getContext().getNrpsToken()); + } + + Site site = findOrCreateSite(payload); + if ( plusService.verbose() ) { + log.info("site={}", site); + } else { + log.debug("site={}", site); + } + + plusService.connectContextAndSite(launch.getContext(), site); + + siteEmailPreferenceSetter.setupUserEmailPreferenceForSite(payload, user, site, false); + + site = siteMembershipUpdater.addOrUpdateSiteMembership(payload, false, user, site); + + long delay = delayNRPSLearner; + String roles = payload.get("roles"); + if ( roles != null && roles.toLowerCase().contains("Instructor".toLowerCase()) ) delay = delayNRPSInstructor; + + String contextGuid = (String) payload.get("context_guid"); + if ( contextGuid != null && launch.getContext() != null ) { + Instant lastRun = launch.getContext().getNrpsStart(); + long delta = -1; + if ( lastRun != null ) { + long lastRunEpoch = lastRun.getEpochSecond(); + long nowEpoch = Instant.now().getEpochSecond(); + delta = nowEpoch - lastRunEpoch; + } + + if ( delta < 0 || delta > delay ) { + syncSiteMembershipsOnceThenSchedule(contextGuid, site); + } else { + log.info("Waiting {} seconds between NRPS calls context={} delta={}", delay, contextGuid, delta); + } + } + + // Construct a URL to site or tool + StringBuilder url = new StringBuilder(); + url.append(SakaiBLTIUtil.getOurServerUrl()); + url.append(serverConfigurationService.getString("portalPath", "/portal")); + + // Forward to a site + if ( SAKAI_SITE_LAUNCH.equals(tool_id) ) { + url.append("/site/"); + url.append(site.getId()); + + // Forward to a tool + } else { + String toolPlacementId = addOrCreateTool(payload, user, site); + + plusService.connectLinkAndPlacement(launch.getLink(), toolPlacementId); + + // Continue wth tool oriented URL + url.append("/plus/"); + url.append(site.getId()); + url.append("/tool/"); + url.append(toolPlacementId); + url.append("?panel=Main"); + } + + if (log.isDebugEnabled()) { + log.debug("url={}", url.toString()); + } + + response.setContentType("text/html"); + response.setStatus(HttpServletResponse.SC_FOUND); + response.sendRedirect(url.toString()); + + } catch (LTIException ltiException) { + doError(request, response, ltiException.getErrorKey(), ltiException.getMessage(), ltiException.getCause()); + } + + } + + // https://www.imsglobal.org/spec/security/v1p0/ + // https://www.imsglobal.org/spec/security/v1p0/#step-1-third-party-initiated-login + // https://openid.net/specs/openid-connect-core-1_0.html#ThirdPartyInitiatedLogin + // https://www.imsglobal.org/spec/lti/v1p3/#additional-login-parameters + protected void handleOIDCLogin(HttpServletRequest request, HttpServletResponse response, String tenant_guid) throws ServletException, IOException { + log.debug("==== oidc_login ===="); + + // REQUIRED. Hint to the Authorization Server about the login identifier the End-User might use to log in. The permitted values will be defined in the host specification. + String login_hint = request.getParameter("login_hint"); + + // OPTIONAL. The actual LTI message that is being launched. + String lti_message_hint = request.getParameter("lti_message_hint"); + + // Legacy lookup is our tenant_guid from the URL since most LMS's don't send the optional stuff + // TODO: Future lookup allows for, iss, client_id, and deployment_id to uniquely define a tenant + Optional optTenant = tenantRepository.findById(tenant_guid); + Tenant tenant = null; + if ( optTenant.isPresent() ) { + tenant = optTenant.get(); + } + + if ( tenant == null ) { + doError(request, response, "plus.tenant.notfound", tenant_guid, null); + return; + } + + String browserSig = BasicLTIUtil.getBrowserSignature(request); + String stateSig = LTI13Util.sha256(randomUUID + browserSig); + Key privateKey = localKeyPair.getPrivate(); + String seconds = (Instant.now().getEpochSecond()+""); + JwtBuilder jwt = Jwts.builder(); + jwt.claim("internal", stateSig); + jwt.claim("time", seconds); + jwt.claim("tenant_guid", tenant_guid); + + String jws = jwt.signWith(privateKey).compact(); + + String redirect_uri = plusService.getOidcLaunch(); + try { + URIBuilder redirect = new URIBuilder(tenant.getOidcAuth().trim()); + redirect.addParameter("scope", "openid"); + redirect.addParameter("response_type", "id_token"); + redirect.addParameter("response_mode", "form_post"); + redirect.addParameter("prompt", "none"); + redirect.addParameter("nonce", UUID.randomUUID().toString()); + if ( lti_message_hint != null ) redirect.addParameter("lti_message_hint", lti_message_hint); + redirect.addParameter("client_id", tenant.getClientId()); + redirect.addParameter("login_hint", login_hint); + redirect.addParameter("redirect_uri", redirect_uri); + redirect.addParameter("state", jws); + String redirect_url = redirect.build().toString(); + response.sendRedirect(redirect_url); + } catch (URISyntaxException e) { + log.error("Syntax exception building the URL with the params: {}.", e.getMessage()); + } + } + + // https://www.imsglobal.org/spec/lti-dr/v1p0 + // http://localhost:8080/plus/sakai/dynamic/123456?reg_token=..&openid_configuration=https:.. + // See also tsugi/settings/key/auto_common.php + protected void handleDynamicRegistration(HttpServletRequest request, HttpServletResponse response, String tenant_guid) throws ServletException, IOException { + log.debug("==== dynamic ===="); + + String openid_configuration = request.getParameter("openid_configuration"); + String registration_token = request.getParameter("registration_token"); + String unlock_token_request = request.getParameter("unlock_token"); + log.info("openid_configuration={} registration_token={} unlock_token={} tenant_guid={}", openid_configuration, registration_token, unlock_token_request, tenant_guid); + + // registration_token is optional + String missing = ""; + if (isEmpty(openid_configuration) ) missing = missing + " openid_configuration"; + if (isEmpty(unlock_token_request) ) missing = missing + " unlock_token"; + + if ( ! missing.equals("") ) { + doError(request, response, "plus.dynamic.request.missing", missing, null); + return; + } + + Optional optTenant = tenantRepository.findById(tenant_guid); + Tenant tenant = null; + if ( optTenant.isPresent() ) { + tenant = optTenant.get(); + } + + if ( tenant == null ) { + doError(request, response, "plus.tenant.notfound", tenant_guid, null); + return; + } + + if ( ! unlock_token_request.equals(tenant.getOidcRegistrationLock()) ) { + doError(request, response, "plus.dynamic.unlock.mismatch", tenant_guid, null); + return; + } + + PrintWriter out = response.getWriter(); + out.print("

"); + out.println(rb.getString("plus.dynamic.starting")); + out.println(htmlEscape(tenant_guid)); + out.print(" ("); + out.print(rb.getString("plus.dynamic.specification")); + out.println(")

"); + + out.print("

"); + out.print(rb.getString("plus.dynamic.config.url")); + out.println("
"); + out.print(htmlEscape(openid_configuration)); + out.println("

"); + + String body; + StringBuffer dbs = new StringBuffer(); + try { + HttpResponse httpResponse = HttpClientUtil.sendGet(openid_configuration, null, null, dbs); + body = httpResponse.body(); + } catch (Exception e) { + log.error("Error retrieving openid_configuration at {}", openid_configuration); + log.error(dbs.toString()); + addError(out, "plus.dynamic.badurl", openid_configuration, e); + tenant.setStatus("Error retrieving openid_configuration at "+openid_configuration); + dbs.append("Exception\n"); + dbs.append(e.getMessage()); + tenant.setDebugLog(dbs.toString()); + tenantRepository.save(tenant); + return; + } + + out.println(togglePre(rb.getString("plus.dynamic.retrieved.openid"), body)); + + // Create and configure an ObjectMapper instance + ObjectMapper mapper = JacksonUtil.getLaxObjectMapper(); + OpenIDProviderConfiguration openIDConfig; + try { + // Moodle returns an array of strings - the spec returns an array of objects + body = org.tsugi.HACK.HackMoodle.hackOpenIdConfiguration(body); + openIDConfig = mapper.readValue(body, OpenIDProviderConfiguration.class); + + if ( openIDConfig == null ) { + addError(out, "plus.dynamic.parse", openid_configuration, null); + } + } catch ( Exception e ) { + openIDConfig = null; + addError(out, "plus.dynamic.parse", openid_configuration, e); + dbs.append("Exception\n"); + dbs.append(e.getMessage()); + } + + if ( openIDConfig == null ) { + log.error("Error parsing openid_configuration at {}", openid_configuration); + log.error(dbs.toString()); + tenant.setStatus("Error parsing openid_configuration at "+openid_configuration); + tenant.setDebugLog(dbs.toString()); + tenantRepository.save(tenant); + return; + } + + // TODO: Make sure issuer matches + String issuer = openIDConfig.issuer; + String authorization_endpoint = openIDConfig.authorization_endpoint; + String token_endpoint = openIDConfig.token_endpoint; + String jwks_uri = openIDConfig.jwks_uri; + String registration_endpoint = openIDConfig.registration_endpoint; + + // Check for required items + missing = ""; + if (isEmpty(issuer) ) missing = missing + " issuer"; + if (isEmpty(authorization_endpoint) ) missing = missing + " authorization_endpoint"; + if (isEmpty(token_endpoint) ) missing = missing + " token_endpoint"; + if (isEmpty(jwks_uri) ) missing = missing + " jwks_uri"; + if (isEmpty(registration_endpoint) ) missing = missing + " registration_endpoint"; + + if ( ! missing.equals("") ) { + addError(out, "plus.dynamic.missing", missing, null); + return; + } + + if ( ! issuer.equals(tenant.getIssuer()) ) { + log.error("Retrieved issuer {} does not match stored issuer {}", issuer, tenant.getIssuer()); + addError(out, "plus.dynamic.issuer.mismatch", issuer+" / "+tenant.getIssuer(), null); + return; + } + + OpenIDClientRegistration reg = new OpenIDClientRegistration(); + + String serverUrl = null; + String host = null; + String domain = null; + try { + serverUrl = SakaiBLTIUtil.getOurServerUrl(); + URL netUrl = new URL(serverUrl); + host = netUrl.getHost(); + domain = serverConfigurationService.getString("plus.dynamic.domain", host); + } catch (MalformedURLException e) { + addError(out, "plus.dynamic.missing.domain", e.getMessage(), e.getCause()); + return; + } + + String title = tenant.getTitle(); + if ( isEmpty(title) ) { + title = serverConfigurationService.getString(PlusService.PLUS_SERVER_TITLE, rb.getString(PlusService.PLUS_SERVER_TITLE)); + } + String description = tenant.getDescription(); + if ( isEmpty(description) ) { + description = serverConfigurationService.getString(PlusService.PLUS_SERVER_DESCRIPTION, rb.getString(PlusService.PLUS_SERVER_DESCRIPTION)); + } + + // Lets full up the registration request + reg.client_name = title; + reg.client_uri = serverUrl; + reg.initiate_login_uri = plusService.getOidcLogin(tenant); + reg.redirect_uris.add(plusService.getOidcLaunch()); + reg.jwks_uri = plusService.getOidcKeySet(); + reg.policy_uri = serverConfigurationService.getString(PlusService.PLUS_SERVER_POLICY_URI, null); + reg.tos_uri = serverConfigurationService.getString(PlusService.PLUS_SERVER_TOS_URI, null); + reg.logo_uri = serverConfigurationService.getString(PlusService.PLUS_SERVER_LOGO_URI, null); + reg.scope = LTI13ConstantsUtil.SCOPE_LINEITEM + " " + + LTI13ConstantsUtil.SCOPE_LINEITEM_READONLY + " " + + LTI13ConstantsUtil.SCOPE_SCORE + " " + + LTI13ConstantsUtil.SCOPE_RESULT_READONLY + " " + + LTI13ConstantsUtil.SCOPE_NAMES_AND_ROLES; + + // NOTE: IMS Issue #53 - Define placements... + // NOTE: ContextPlacementLaunch + + LTIToolConfiguration ltitc = new LTIToolConfiguration(); + ltitc.addCommonClaims(); + ltitc.domain = domain; + ltitc.description = description; + + // Check how we should install ourselves + List allowedToolsList = getAllowedTools(tenant); + + // No tools - kind of weird - launch to the top - use message type to decide + if ( allowedToolsList.size() == 0 ) { + LTILaunchMessage lm = new LTILaunchMessage(); + lm.type = LaunchJWT.MESSAGE_TYPE_DEEP_LINK; + lm.label = rb.getString("plus.deeplink.deep.link"); + lm.target_link_uri = plusService.getPlusServletPath() + "/"; + ltitc.messages.add(lm); + + // Single tool - Just install it with direct launch + } else if ( allowedToolsList.size() == 1 ) { + String tool_id = allowedToolsList.get(0); + LTILaunchMessage lm = new LTILaunchMessage(); + lm.type = LaunchJWT.MESSAGE_TYPE_LAUNCH; + lm.label = rb.getString("plus.deeplink.sakai.plus"); + Tool theTool = toolManager.getTool(tool_id); + if ( theTool != null) lm.label = theTool.getTitle(); + lm.target_link_uri = plusService.getPlusServletPath() + "/" + tool_id; + ltitc.messages.add(lm); + + // Multiple tools, no support for sakai.site - just install Deep Link + } else if ( ! allowedToolsList.contains(SAKAI_SITE_LAUNCH) ) { + LTILaunchMessage lm = new LTILaunchMessage(); + lm.type = LaunchJWT.MESSAGE_TYPE_DEEP_LINK; + lm.label = rb.getString("plus.deeplink.deep.link"); + lm.target_link_uri = plusService.getPlusServletPath() + "/" + SAKAI_DEEPLINK_LAUNCH ; + ltitc.messages.add(lm); + + // We have multiple tools including sakai.site - prefer deep.link for simple LMS's + // but LMS's with better placement options, have options + } else { + + // NOTE: IMS Issue #59 - Message parsing order - Sakai takes first, Moodle takes last + // Tell LMS's that know about and handle multiple message types to just go to the base URL + LTILaunchMessage lm = new LTILaunchMessage(); + lm.type = LaunchJWT.MESSAGE_TYPE_DEEP_LINK; + lm.label = rb.getString("plus.deeplink.deep.link"); + lm.target_link_uri = plusService.getPlusServletPath() + "/"; + ltitc.messages.add(lm); + + lm = new LTILaunchMessage(); + lm.type = LaunchJWT.MESSAGE_TYPE_LAUNCH; + lm.label = rb.getString("plus.deeplink.sakai.plus"); + lm.target_link_uri = plusService.getPlusServletPath() + "/"; + ltitc.messages.add(lm); + + lm = new LTILaunchMessage(); + lm.type = LaunchJWT.MESSAGE_TYPE_LTI_CONTEXT; + lm.label = rb.getString("plus.deeplink.context.launch"); + lm.target_link_uri = plusService.getPlusServletPath() + "/"; + ltitc.messages.add(lm); + + String privacyUrl = serverConfigurationService.getString(PlusService.PLUS_SERVER_POLICY_URI, + serverConfigurationService.getString(PlusService.PLUS_SERVER_TOS_URI, null)); + if ( privacyUrl != null ) { + lm = new LTILaunchMessage(); + lm.type = LaunchJWT.MESSAGE_TYPE_LTI_DATA_PRIVACY_LAUNCH_REQUEST; + lm.label = rb.getString("plus.deeplink.context.launch"); + lm.target_link_uri = privacyUrl; + ltitc.messages.add(lm); + } + + // For Moodle that takes one and one only - hope Deep Link is the best choice + // Either base or SAKAI_DEEPLINK_LAUNCH will work If Moodle switches to top down + // and multiple message types, things should jsut keep working. + lm = new LTILaunchMessage(); + lm.type = LaunchJWT.MESSAGE_TYPE_DEEP_LINK; + lm.label = rb.getString("plus.deeplink.deep.link"); + lm.target_link_uri = plusService.getPlusServletPath() + "/" + SAKAI_DEEPLINK_LAUNCH ; + ltitc.messages.add(lm); + } + + reg.lti_tool_configuration = ltitc; + + Map headers = new HashMap(); + if (! isEmpty(registration_token) ) headers.put("Authorization", "Bearer "+registration_token); + headers.put("Content-type", "application/json"); + + String regs = reg.prettyPrintLog(); + out.println(togglePre(rb.getString("plus.dynamic.client.request"), regs)); + body = null; + try { + HttpResponse registrationResponse = HttpClientUtil.sendPost(registration_endpoint, regs, headers, dbs); + body = registrationResponse.body(); + if ( isEmpty(body) ) { + addError(out, "plus.dynamic.registration.post", null, null); + } + } catch (Exception e) { + body = null; + log.error("Error posting client registration {}", registration_endpoint, e); + addError(out, "plus.dynamic.registration.post", null, e); + } + + out.println(togglePre(rb.getString("plus.dynamic.client.response"), body)); + + // Remember the registration + tenant.setOidcRegistration(body); + + if ( isEmpty(body) ) { + tenant.setStatus("Error posting client registration "+registration_endpoint); + tenant.setDebugLog(dbs.toString()); + log.error(dbs.toString()); + tenantRepository.save(tenant); + return; + } + + // Create and configure an ObjectMapper instance + mapper = JacksonUtil.getLaxObjectMapper(); + OpenIDClientRegistration platformResponse; + try { + platformResponse = mapper.readValue(body, OpenIDClientRegistration.class); + + if ( platformResponse == null ) { + addError(out, "plus.dynamic.parse", openid_configuration, null); + } + } catch ( Exception e ) { + platformResponse = null; + addError(out, "plus.dynamic.parse", openid_configuration, e); + dbs.append("Exception\n"); + dbs.append(e.getMessage()); + } + + if ( platformResponse == null || platformResponse.client_id == null || platformResponse.lti_tool_configuration == null) { + tenant.setStatus("Error parsing client registration "+registration_endpoint); + tenant.setDebugLog(dbs.toString()); + log.error(dbs.toString()); + tenantRepository.save(tenant); + return; + } + + LTIToolConfiguration tcResponse = platformResponse.lti_tool_configuration; + String deployment_id = tcResponse.deployment_id; + + tenant.setOidcAuth(openIDConfig.authorization_endpoint); + tenant.setOidcToken(openIDConfig.token_endpoint); + tenant.setOidcKeySet(openIDConfig.jwks_uri); + tenant.setOidcRegistrationEndpoint(openIDConfig.registration_endpoint); + + tenant.setClientId(platformResponse.client_id); + if ( ! isEmpty(deployment_id) ) { + tenant.setDeploymentId(deployment_id); + tenant.setStatus("Registration "+tenant_guid+" complete with deployment_id "+deployment_id); + } else { + tenant.setStatus("Registration "+tenant_guid+" finished, but without deployment_id"); + } + tenant.setDebugLog(dbs.toString()); + + tenantRepository.save(tenant); + log.info(tenant.getStatus()); + + + + out.println("

"); + out.println(tenant.getStatus()); + out.println("

"); + out.println("

\n
\n"); + out.println(togglePre(rb.getString("plus.dynamic.debug"), dbs.toString())); + } + + protected void handleRepost(HttpServletRequest request, HttpServletResponse response, boolean forceNewWindow) throws ServletException, IOException { + log.debug("==== oidc_repost ===="); + + StringBuilder r = new StringBuilder(); + r.append("\n"); + r.append("\n"); + r.append("
\n"); + r.append("\n"); + for (Enumeration e = request.getParameterNames(); e.hasMoreElements(); ) { + String key = (String)e.nextElement(); + String value = request.getParameter(key); + r.append("\n"); + } + r.append("\n"); + r.append("\n"); + r.append("
\n"); + r.append("
\n"); + + r.append("\n"); + BasicLTIUtil.sendHTMLPage(response, r.toString()); + } + + protected Launch validate(Map payload, SakaiLaunchJWT launchJWT, Tenant tenant) throws LTIException + { + //check parameters + String id_token = (String) payload.get("id_token"); + String tenant_guid = (String) payload.get("tenant_guid"); + String context_id = (String) payload.get(BasicLTIConstants.CONTEXT_ID); + + // Begin Validation + + JSONObject header = LTI13JwtUtil.jsonJwtHeader(id_token); + String kid = (String) header.get("kid"); + if ( StringUtils.isBlank(kid) ) { + throw new LTIException( "plus.launch.kid.notfound", null, null); + } + + // Find the pubic key for the id_token, first check the most recent keyset + RSAPublicKey tokenKey = null; + String cacheKeySet = tenant.getCacheKeySet(); + if ( ! StringUtils.isBlank(cacheKeySet) ) { + try { + tokenKey = LTI13KeySetUtil.getKeyFromKeySetString(kid, cacheKeySet); + } catch(Exception e) { + // No big thing - just ignore it and move on + log.debug("Exception loading kid={} tenant={} keyset={}", kid, tenant_guid, cacheKeySet); + } + } + + if ( tokenKey == null ) { + String oidcKeySet = tenant.getOidcKeySet(); + if ( StringUtils.isBlank(oidcKeySet) ) { + throw new LTIException( "plus.launch.keyset.blank", tenant_guid, null); + } + log.debug("loading kid={} from keyset={}",kid, oidcKeySet); + com.nimbusds.jose.jwk.JWKSet keySet = null; + try { + keySet = LTI13KeySetUtil.getKeySetFromUrl(oidcKeySet); + } catch(Exception e) { + throw new LTIException( "plus.launch.keyset.load.fail", oidcKeySet, null); + } + log.debug("loaded keyset={} result={}",oidcKeySet, keySet.toString()); + try { + tokenKey = LTI13KeySetUtil.getKeyFromKeySet(kid, keySet); + } catch(Exception e) { + throw new LTIException( "plus.launch.kid.load.fail", oidcKeySet, null); + } + + // Store the new keyset in the Tenant + if ( keySet != null ) { + tenant.setCacheKeySet(keySet.toString()); + tenantRepository.save(tenant); + log.debug("Stored new keyset in tenant"); + } + + } + + if ( tokenKey == null ) { + throw new LTIException( "plus.launch.kid.load.fail", kid, null); + } + + // Check the signature on incoming id_token + try { + // If you want a Chaos Monkey uncomment this :) + // Jws jwsClaims = Jwts.parser().setAllowedClockSkewSeconds(60).setSigningKey(stateKey).parseClaimsJws(id_token); + Jws jwsClaims = Jwts.parser().setAllowedClockSkewSeconds(60).setSigningKey(tokenKey).parseClaimsJws(id_token); + } catch (Exception e) { + throw new LTIException( "plus.launch.id_token.signature", kid, e); + } + + Launch launch = null; + try { + launch = plusService.updateAll(launchJWT, tenant); + // tenant_guid is already there + if ( launch.getLink() != null ) payload.put("link_guid", launch.getLink().getId()); + if ( launch.getContext() != null ) payload.put("context_guid", launch.getContext().getId()); + if ( launch.getSubject() != null ) payload.put("subject_guid", launch.getSubject().getId()); + } catch (Exception e) { + throw new LTIException( "plus.launch.id_token.load.fail", tenant_guid, e); + } + + final Session sess = sessionManager.getCurrentSession(); + + if (sess == null) { + throw new LTIException( "launch.no.session", context_id, null); + } + + return launch; + + } + + private String addOrCreateTool(Map payload, User user, Site site) throws LTIException { + // Check if the site already has the tool + String toolPlacementId = null; + String tool_id = (String) payload.get("tool_id"); + try { + site = siteService.getSite(site.getId()); + ToolConfiguration toolConfig = site.getToolForCommonId(tool_id); + if(toolConfig != null) { + toolPlacementId = toolConfig.getId(); + } + } catch (Exception e) { + log.warn(e.getLocalizedMessage(), e); + throw new LTIException( "launch.tool.search", "tool_id="+tool_id, e); + } + + if (log.isDebugEnabled()) { + log.debug("toolPlacementId={}", toolPlacementId); + } + + // If tool not in site, and we are a trusted consumer, error + // Otherwise, add tool to the site + ToolConfiguration toolConfig = null; + if(StringUtils.isBlank(toolPlacementId)) { + try { + SitePage sitePageEdit = null; + sitePageEdit = site.addPage(); + sitePageEdit.setTitle(tool_id); + + toolConfig = sitePageEdit.addTool(); + toolConfig.setTool(tool_id, toolManager.getTool(tool_id)); + toolConfig.setTitle(tool_id); + + Properties propsedit = toolConfig.getPlacementConfig(); + propsedit.setProperty(BASICLTI_RESOURCE_LINK, (String) payload.get(BasicLTIConstants.RESOURCE_LINK_ID)); + pushAdvisor(); + try { + siteService.save(site); + log.info("Tool added, tool_id={}, siteId={}", tool_id, site.getId()); + } catch (Exception e) { + throw new LTIException( "launch.site.save", "tool_id="+tool_id + ", siteId="+site.getId(), e); + } finally { + popAdvisor(); + } + toolPlacementId = toolConfig.getId(); + + } catch (Exception e) { + throw new LTIException( "launch.tool.add", "tool_id="+tool_id + ", siteId="+site.getId(), e); + } + } + + // Get ToolConfiguration for tool if not already setup + if(toolConfig == null){ + toolConfig = site.getToolForCommonId(tool_id); + } + + // Check user has access to this tool in this site + if(!toolManager.isVisible(site, toolConfig)) { + log.warn("Not allowed to access tool user_id={} site={} tool={}", user.getId(), site.getId(), tool_id); + throw new LTIException( "launch.site.tool.denied", "user_id=" + user.getId() + " site="+ site.getId() + " tool=" + tool_id, null); + + } + return toolPlacementId; + } + + protected Site findOrCreateSite(Map payload) throws LTIException { + + String context_guid = (String) payload.get("context_guid"); + String siteId = context_guid; + + if (log.isDebugEnabled()) { + log.debug("siteId={}", siteId); + } + + final String context_title_orig = (String) payload.get(BasicLTIConstants.CONTEXT_TITLE); + final String context_label = (String) payload.get(BasicLTIConstants.CONTEXT_LABEL); + + // Site title is editable; cannot but null/empty after HTML stripping, and cannot exceed max length + String context_title = formattedText.stripHtmlFromText(context_title_orig, true, true); + SiteTitleValidationStatus status = siteService.validateSiteTitle(context_title_orig, context_title); + + if (SiteTitleValidationStatus.STRIPPED_TO_EMPTY.equals(status)) { + log.warn("Provided context_title is empty after HTML stripping: {}", context_title_orig); + } else if (SiteTitleValidationStatus.EMPTY.equals(status)) { + log.warn("Provided context_title is empty after trimming: {}", context_title_orig); + } else if (SiteTitleValidationStatus.TOO_LONG.equals(status)) { + log.warn("Provided context_title is longer than max site title length of {}: {}", SITE_TITLE_MAX_LENGTH, context_title_orig); + } + + Site site = null; + + // Get the site if it exists + try { + site = siteService.getSite(siteId); + if ( plusService.verbose() ) { + log.info("Loaded existing site={}", site.getId()); + } else { + log.debug("Loaded existing site={}", site.getId()); + } + updateSiteDetailsIfChanged(site, context_title, context_label); + return site; + } catch (Exception e) { + if (log.isDebugEnabled()) { + log.debug(e.getLocalizedMessage(), e); + } + } + + // If site does not exist, create the site + pushAdvisor(); + try { + String sakai_type = PlusService.PLUS_NEW_SITE_TYPE_DEFAULT; + + // BLTI-154. If an autocreation site template has been specced in sakai.properties, use it. + String autoSiteTemplateId = + serverConfigurationService.getString(PlusService.PLUS_NEW_SITE_TEMPLATE, PlusService.PLUS_NEW_SITE_TEMPLATE_DEFAULT); + + boolean templateSiteExists = siteService.siteExists(autoSiteTemplateId); + + if(!templateSiteExists) { + log.warn("A template site id was specced ({}) but no site with this id exists. A default lti site will be created instead.", autoSiteTemplateId); + } + + if(autoSiteTemplateId == null || !templateSiteExists) { + //BLTI-151 If the new site type has been specified in sakai.properties, use it. + sakai_type = serverConfigurationService.getString(PlusService.PLUS_NEW_SITE_TYPE, PlusService.PLUS_NEW_SITE_TYPE_DEFAULT); + if(StringUtils.isBlank(sakai_type)) { + // It wasn't specced in the props. Test for the ims course context type. + final String context_type = (String) payload.get(BasicLTIConstants.CONTEXT_TYPE); + if (BasicLTIUtil.equalsIgnoreCase(context_type, "course")) { + sakai_type = "course"; + } else { + sakai_type = BasicLTIConstants.NEW_SITE_TYPE; + } + } + site = siteService.addSite(siteId, sakai_type); + site.setType(sakai_type); + } else { + Site autoSiteTemplate = siteService.getSite(autoSiteTemplateId); + site = siteService.addSite(siteId, autoSiteTemplate); + } + + if (BasicLTIUtil.isNotBlank(context_title)) { + site.setTitle(context_title); + } + if (BasicLTIUtil.isNotBlank(context_label)) { + site.setShortDescription(context_label); + } + site.setJoinable(false); + site.setPublished(true); + site.setPubView(false); + + site.getPropertiesEdit().addProperty(PlusService.PLUS_PROPERTY, "true"); + + try { + siteService.save(site); + log.info("Created site={} label={} type={} title={}", siteId, context_label, sakai_type, context_title); + } catch (Exception e) { + throw new LTIException("launch.site.save", "siteId=" + siteId, e); + } + + } catch (Exception e) { + throw new LTIException("launch.create.site", "siteId=" + siteId, e); + } finally { + popAdvisor(); + } + + // Now lets retrieve that new site! + try { + return siteService.getSite(site.getId()); + } catch (IdUnusedException e) { + throw new LTIException( "launch.site.invalid", "siteId="+siteId, e); + + } + } + + private final void updateSiteDetailsIfChanged(Site site, String context_title, String context_label) { + + boolean changed = false; + + if (BasicLTIUtil.isNotBlank(context_title) && !context_title.equals(site.getTitle())) { + site.setTitle(context_title); + changed = true; + } + + if (BasicLTIUtil.isNotBlank(context_label) && !context_label.equals(site.getShortDescription())) { + site.setShortDescription(context_label); + changed = true; + } + + String plus_property = site.getProperties().getProperty(PlusService.PLUS_PROPERTY); + if ( ! "true".equals(plus_property) ) { + site.getPropertiesEdit().addProperty(PlusService.PLUS_PROPERTY, "true"); + changed = true; + } + + if(changed) { + try { + siteService.save(site); + log.info("Updated site={} title={} label={}", site.getId(), context_title, context_label); + } catch (Exception e) { + log.warn("Failed to update site title and/or label"); + } + } + } + + private void loginUser(String ipAddress, User user) { + Session sess = sessionManager.getCurrentSession(); + usageSessionService.login(user.getId(), user.getEid(), ipAddress, null, UsageSessionService.EVENT_LOGIN_WS); + sess.setUserId(user.getId()); + sess.setUserEid(user.getEid()); + } + + + // https://www.imsglobal.org/spec/lti-dl/v2p0 + private void handleDeepLinkInstall(HttpServletRequest request, HttpServletResponse response, String tool_id, SakaiLaunchJWT launchJWT, String payloadStr) + throws ServletException, IOException + { + // Parse and verify the payload + Key stateKey = localKeyPair.getPublic(); + // Chaos Monkey : stateKey = LTI13Util.generateKeyPair().getPublic(); + Claims claims = null; + try { + Jws jws = Jwts.parser().setAllowedClockSkewSeconds(600).setSigningKey(stateKey).parseClaimsJws(payloadStr); + claims = jws.getBody(); + } catch (io.jsonwebtoken.security.SignatureException e) { + doError(request, response, "plus.payload.state.signature", null, null); + return; + } + + // Load tenant + String tenant_guid = (String) claims.get("tenant_guid"); + + Optional optTenant = tenantRepository.findById(tenant_guid); + Tenant tenant = null; + if ( optTenant.isPresent() ) { + tenant = optTenant.get(); + } + + if ( tenant == null ) { + doError(request, response, "plus.tenant.notfound", tenant_guid, null); + return; + } + + List allowedToolsList = getAllowedTools(tenant); + + // TODO: Should we make it so we can turn off the sakai.site + String title = null; + if ( tool_id.equals(SAKAI_SITE_LAUNCH) ) { + title = serverConfigurationService.getString(PlusService.SAKAI_SITE_TITLE, rb.getString(PlusService.SAKAI_SITE_TITLE)); + } else { + + if ( ! allowedToolsList.contains(tool_id) ) { + doError(request, response, "launch.tool.notallowed", tool_id, null); + return; + } + final Tool toolCheck = toolManager.getTool(tool_id); + if ( toolCheck == null) { + doError(request, response, "launch.tool.notfound", tool_id, null); + return; + } + title = toolCheck.getTitle(); + } + + String tool_launch = plusService.getPlusServletPath() + "/" + tool_id; + + // Build the reponse + org.tsugi.deeplink.objects.DeepLinkResponse dlr = new org.tsugi.deeplink.objects.DeepLinkResponse(); + + LtiResourceLink ltiResourceLink = new LtiResourceLink(); + ltiResourceLink.title = title; + ltiResourceLink.url = tool_launch; + ltiResourceLink.setWindowTarget("_blank"); + dlr.content_items.add(ltiResourceLink); + if ( launchJWT.deep_link != null && isNotEmpty(launchJWT.deep_link.data) ) { + dlr.data = launchJWT.deep_link.data; + } + dlr.deployment_id = launchJWT.deployment_id; + dlr.issuer = launchJWT.audience; // ClientId? + dlr.audience = launchJWT.issuer; + String deep_link_return_url = launchJWT.deep_link.deep_link_return_url; + + String dlrs = JacksonUtil.prettyPrint(dlr); + + KeyPair kp = SakaiKeySetUtil.getCurrent(); + Key privateKey = kp.getPrivate(); + Key publicKey = kp.getPublic(); + if ( privateKey == null | publicKey == null ) { + doError(request, response, "error.no.pki", null, null); + return; + } + + String kid = LTI13KeySetUtil.getPublicKID(publicKey); + + String jws = Jwts.builder().setHeaderParam("kid", kid). + setPayload(dlrs).signWith(privateKey).compact(); + + // TODO: More elegant + String launch_error = "Error Launching"; + boolean dodebug = true; + String state = null; + String html = SakaiBLTIUtil.getJwsHTMLForm(deep_link_return_url, "JWT", jws, dlrs, state, launch_error, dodebug); + + BasicLTIUtil.sendHTMLPage(response, html); + } + + public List getAllowedTools(Tenant tenant) + { + String allowedToolsConfig = tenant.getAllowedTools(); + if ( isEmpty(allowedToolsConfig) ) allowedToolsConfig = + serverConfigurationService.getString(PlusService.PLUS_TOOLS_ALLOWED, PlusService.PLUS_TOOLS_ALLOWED_DEFAULT); + String[] allowedTools = allowedToolsConfig.split(":"); + return Arrays.asList(allowedTools); + } + + public List getNewWindowTools(Tenant tenant) + { + String newWindowToolsConfig = tenant.getNewWindowTools(); + if ( isEmpty(newWindowToolsConfig) ) newWindowToolsConfig = + serverConfigurationService.getString(PlusService.PLUS_TOOLS_NEW_WINDOW, PlusService.PLUS_TOOLS_NEW_WINDOW_DEFAULT); + String[] newWindowTools = newWindowToolsConfig.split(":"); + return Arrays.asList(newWindowTools); + } + + private void handleDeepLinkSetup(HttpServletRequest request, HttpServletResponse response, Tenant tenant, String id_token, + SakaiLaunchJWT launchJWT, Map payload) + throws ServletException, IOException + { + + List allowedToolsList = getAllowedTools(tenant); + + ArrayList tools = new ArrayList(); + for (String toolId : allowedToolsList) { + Tool theTool = toolManager.getTool(toolId); + if ( theTool == null ) continue; + tools.add(theTool); + } + + String browserSig = BasicLTIUtil.getBrowserSignature(request); + String stateSig = LTI13Util.sha256(randomUUID + browserSig); + Key privateKey = localKeyPair.getPrivate(); + String seconds = (Instant.now().getEpochSecond()+""); + JwtBuilder jwt = Jwts.builder(); + jwt.claim("_internal", stateSig); + jwt.claim("_time", seconds); + for (Map.Entry mapElement : payload.entrySet()) { + jwt.claim((String)mapElement.getKey(), (String) mapElement.getValue()); + } + + String payloadJWT = jwt.signWith(privateKey).compact(); + + request.setAttribute("tools",tools); + request.setAttribute("id_token",id_token); + request.setAttribute("payload", payloadJWT); + request.setAttribute("details_text",rb.getString("plus.deeplink.details")); + request.setAttribute("site_title",serverConfigurationService.getString(PlusService.SAKAI_SITE_TITLE, rb.getString(PlusService.SAKAI_SITE_TITLE))); + request.setAttribute("site_description",serverConfigurationService.getString(PlusService.SAKAI_SITE_DESCRIPTION, rb.getString(PlusService.SAKAI_SITE_DESCRIPTION))); + + // If there is only one tool in the allowedTools list, lets just install it :) + if ( allowedToolsList.size() == 1 ) { + String short_circuit = allowedToolsList.get(0); + log.debug("DeepLink Short Circuit tool={}", short_circuit); + handleDeepLinkInstall(request, response, short_circuit, launchJWT, payloadJWT); + return; + } + + // Forward to the JSP + ServletContext sc = this.getServletContext(); + RequestDispatcher rd = sc.getRequestDispatcher("/deeplink.jsp"); + try { + rd.forward(request, response); + } + catch (Exception e) { + log.error(e.getMessage(), e); + } + } + + // http://localhost:8080/plus/sakai/canvas-config.json?guid=123456 + // https://canvas.instructure.com/doc/api/file.navigation_tools.html + private void handleCanvasConfig(HttpServletRequest request, HttpServletResponse response) + throws IOException + { + String guid = request.getParameter("guid"); + + Optional optTenant = tenantRepository.findById(guid); + Tenant tenant = null; + if ( optTenant.isPresent() ) { + tenant = optTenant.get(); + } + + if ( tenant == null ) { + doError(request, response, "plus.tenant.notfound", guid, null); + return; + } + JSONObject canvas = canvasDescriptor(); + + String serverUrl = null; + String host = null; + String domain = null; + try { + serverUrl = SakaiBLTIUtil.getOurServerUrl(); + URL netUrl = new URL(serverUrl); + host = netUrl.getHost(); + domain = serverConfigurationService.getString(PlusService.PLUS_CANVAS_DOMAIN, host); + } catch (MalformedURLException e) { + doError(request, response, "canvas.error.missing.domain", e.getMessage(), e.getCause()); + return; + } + + String title = tenant.getTitle(); + if ( isEmpty(title) ) { + title = serverConfigurationService.getString(PlusService.PLUS_CANVAS_TITLE, rb.getString(PlusService.PLUS_CANVAS_TITLE)); + } + String description = tenant.getDescription(); + if ( isEmpty(description) ) { + description = serverConfigurationService.getString(PlusService.PLUS_CANVAS_DESCRIPTION, rb.getString(PlusService.PLUS_CANVAS_DESCRIPTION)); + } + canvas.put("title", title); + canvas.put("description", description); + canvas.put("oidc_initiation_url", plusService.getOidcLogin(tenant)); + canvas.put("redirect_uris", plusService.getOidcLaunch()); + canvas.put("oidc_redirect_uris", plusService.getOidcLaunch()); + canvas.put("target_link_uri", plusService.getOidcLaunch()); + canvas.put("public_jwk_url", plusService.getOidcKeySet()); + JSONArray extensions = (JSONArray) canvas.get("extensions"); + JSONObject ext0 = (JSONObject) extensions.get(0); + ext0.put("tool_id", guid); + ext0.put("domain", domain); + JSONObject settings = (JSONObject) ext0.get("settings"); + settings.put("icon_url", serverUrl + (String) settings.get("icon_url") ); + JSONArray placements = (JSONArray) settings.get("placements"); + + for ( int i=0; i < placements.size(); i++) { + JSONObject placement = (JSONObject) placements.get(i); + placement.put("text", title); + placement.put("icon_url", serverUrl + (String) placement.get("icon_url") ); + placement.put("target_link_uri", plusService.getPlusServletPath() + "/" + (String) placement.get("target_link_uri") ); + } + + PrintWriter out = response.getWriter(); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + out.print(canvas.toString()); + out.flush(); + } + + public JSONObject canvasDescriptor() + throws java.io.IOException + { + InputStream stream = getServletContext().getResourceAsStream("/WEB-INF/descriptor.json"); + String text = new String(stream.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + JSONObject canvas = (JSONObject) JSONValue.parse(text); + return canvas; + } + + private void syncSiteMembershipsOnceThenSchedule(String contextGuid, Site site) { + + log.debug("synchSiteMembershipsOnceThenSchedule"); + + (new Thread(new Runnable() { + + public void run() { + + long then = (new Date()).getTime(); + + if (plusService.verbose() || log.isDebugEnabled()) { + log.info("Starting memberships sync. guid={}", contextGuid); + } else { + log.debug("Starting memberships sync. guid={}", contextGuid); + } + + try { + plusService.syncSiteMemberships(contextGuid, site); + } catch (LTIException e) { + e.printStackTrace(); + } + + long now = (new Date()).getTime(); + if (plusService.verbose() || log.isDebugEnabled()) { + log.info("Memberships sync finished guid={}. It took {} seconds.", contextGuid, ((now - then)/1000)); + } else { + log.debug("Memberships sync finished guid={}. It took {} seconds.", contextGuid, ((now - then)/1000)); + } + } + }, "org.sakaiproject.plus.ProviderServlet.MembershipsSync")).start(); + + } + + private String htmlEscape(String text) + { + return StringEscapeUtils.escapeHtml4(text); + } + + // See also tsugi/lib/src/UI/Output.php + private String togglePre(String title, String text) + { + String divId = (title+text).hashCode()+""; + StringBuffer r = new StringBuffer(); + r.append("

"); + r.append(htmlEscape(title)); + r.append(" ("); + r.append(text.length()+""); + r.append(") "); + r.append(rb.getString("plus.dynamic.characters")); + r.append(" "); + r.append(rb.getString("plus.dynamic.hide.show")); + r.append("

\n"); + r.append("
");
+		r.append(htmlEscape(text));
+		r.append("\n
\n"); + return r.toString(); + } + +} + diff --git a/plus/provider/src/main/webapp/WEB-INF/applicationContext.xml b/plus/provider/src/main/webapp/WEB-INF/applicationContext.xml new file mode 100644 index 000000000000..88694bf95369 --- /dev/null +++ b/plus/provider/src/main/webapp/WEB-INF/applicationContext.xml @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/plus/provider/src/main/webapp/WEB-INF/descriptor.json b/plus/provider/src/main/webapp/WEB-INF/descriptor.json new file mode 100644 index 000000000000..3f02faf2714a --- /dev/null +++ b/plus/provider/src/main/webapp/WEB-INF/descriptor.json @@ -0,0 +1,103 @@ +{ + "title": "DJ4E", + "scopes": [ + "https:\/\/purl.imsglobal.org\/spec\/lti-ags\/scope\/lineitem", + "https:\/\/purl.imsglobal.org\/spec\/lti-ags\/scope\/lineitem.readonly", + "https:\/\/purl.imsglobal.org\/spec\/lti-ags\/scope\/result.readonly", + "https:\/\/purl.imsglobal.org\/spec\/lti-ags\/scope\/score", + "https:\/\/purl.imsglobal.org\/spec\/lti-nrps\/scope\/contextmembership.readonly" + ], + "extensions": [ + { + "platform": "canvas.instructure.com", + "privacy_level": "public", + "settings": { + "selection_width": 800, + "selection_height": 800, + "placements": [ + { + "text": "Lauch", + "enabled": true, + "icon_url": "\/library\/skin\/morpheus-default\/images\/logo-jewel-square.png", + "placement": "course_navigation", + "message_type": "LtiResourceLinkRequest", + "windowTarget": "_blank", + "target_link_uri": "sakai.site" + }, + { + "text": "Assignments", + "enabled": true, + "icon_url": "\/library\/skin\/morpheus-default\/images\/logo-jewel-square.png", + "placement": "assignment_selection", + "message_type": "LtiDeepLinkingRequest", + "selection_width": 800, + "selection_height": 800, + "target_link_uri": "deep.link?placement=assignment_selection" + }, + { + "text": "Links", + "enabled": true, + "icon_url": "\/library\/skin\/morpheus-default\/images\/logo-jewel-square.png", + "placement": "link_selection", + "message_type": "LtiDeepLinkingRequest", + "selection_width": 800, + "selection_height": 800, + "target_link_uri": "deep.link?placement=link_selection" + }, + { + "text": "Editor", + "enabled": true, + "icon_url": "\/library\/skin\/morpheus-default\/images\/logo-jewel-square.png", + "placement": "editor_button", + "message_type": "LtiDeepLinkingRequest", + "selection_width": 800, + "selection_height": 800, + "target_link_uri": "deep.link?placement=editor_button" + } + ], + "icon_url": "\/library\/skin\/morpheus-default\/images\/logo-jewel-square.png" + }, + "domain": "www.dj4e.com", + "tool_id": "1b6b50366a9b783a81740a90e4df6e57" + } + ], + "public_jwk_url": "https:\/\/www.dj4e.com\/tsugi\/lti\/keyset", + "description": "DJ4E", + "custom_fields": { + "availableStart": "$ResourceLink.available.startDateTime", + "availableEnd": "$ResourceLink.available.endDateTime", + "submissionStart": "$ResourceLink.submission.startDateTime", + "submissionEnd": "$ResourceLink.submission.endDateTime", + "resourcelink_id_history": "$ResourceLink.id.history", + "context_id_history": "$Context.id.history", + "canvas_caliper_url": "$Caliper.url", + "timezone": "$Person.address.timezone", + "pointsPossible": "$Canvas.assignment.pointsPossible", + "userPronouns": "$com.instructure.Person.pronouns", + "localAssignmentId": "$Canvas.assignment.id", + "prevCourses": "$Canvas.course.previousCourseIds", + "termName": "$Canvas.term.name", + "assignmentUnlockAt": "$Canvas.assignment.unlockAt.iso8601", + "courseId": "$Canvas.course.id", + "canvas_api_domain": "$Canvas.api.domain", + "canvas_module_id": "$Canvas.module.id", + "toolContextLinkUrl": "$Canvas.externalTool.url", + "canvas_assignment_id": "$Canvas.assignment.id", + "prevContexts": "$Canvas.course.previousContextIds", + "userDisplayName": "$Person.name.display", + "assignmentLockAt": "$Canvas.assignment.lockAt.iso8601", + "anonymousGrading": "$com.instructure.Assignment.anonymous_grading", + "canvas_module_item_id": "$Canvas.moduleItem.id", + "ltiGroupContextIds": "$Membership.course.groupIds", + "termStart": "$Canvas.term.startAt", + "canvas_course_id": "$Canvas.course.id", + "courseName": "$Canvas.course.name", + "sectionIds": "$Canvas.course.sectionIds", + "sourceUserId": "$Person.sourcedId" + }, + "oidc_initiation_url": "https:\/\/www.dj4e.com\/tsugi\/lti\/oidc_login\/3E9D49AD-6D15-7178-7E86-4C75557CB0F6", + "target_link_uri": "https:\/\/www.dj4e.com\/tsugi\/lti\/oidc_launch", + "note_from_sakai": "Canvas finds a default redirect_uris value from target_link_uri. So Sakai (and Tsugi) puts its redirect_uri there and makes sure to override target_link_uri in each of the placements so the global target_link_uri is in effect ignored.", + "redirect_uris": "https:\/\/www.dj4e.com\/tsugi\/lti\/oidc_launch", + "oidc_redirect_uris": "https:\/\/www.dj4e.com\/tsugi\/lti\/oidc_launch" +} diff --git a/plus/provider/src/main/webapp/WEB-INF/web.xml b/plus/provider/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 000000000000..1b97f72a2c86 --- /dev/null +++ b/plus/provider/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,35 @@ + + + IMS Plus Servlet + + + sakai.request + org.sakaiproject.util.RequestFilter + + + + sakai.request + /* + REQUEST + FORWARD + INCLUDE + + + + ProviderServlet + org.sakaiproject.plus.ProviderServlet + + + + + ProviderServlet + /sakai/* + + + + org.sakaiproject.util.SakaiContextLoaderListener + + + diff --git a/plus/provider/src/main/webapp/deeplink.jsp b/plus/provider/src/main/webapp/deeplink.jsp new file mode 100644 index 000000000000..cc20f4560553 --- /dev/null +++ b/plus/provider/src/main/webapp/deeplink.jsp @@ -0,0 +1,89 @@ + + +<%@ page import="java.util.List" %> +<%@ page import="org.sakaiproject.tool.api.Tool" %> +<%@ page import="org.sakaiproject.portal.util.PortalUtils" %> +<%@ page import="org.sakaiproject.portal.util.CSSUtils" %> +<%@ page import="org.sakaiproject.portal.util.ToolUtils" %> +IMS ContentItem Experimental Support +<%= CSSUtils.getCssToolSkinLink((String) null, ToolUtils.isInlineRequest(request)) %> + + + + +
+<% if ( request.getAttribute("tool") != null ) { + Tool tool = (Tool) request.getAttribute("tool"); +%> +

+"> +<%= tool.getTitle() %>

+

<%= tool.getDescription() %>

+<%= (String) request.getAttribute("launch_html") %> +<% } else { %> +
+
+

+ +<%= (String) request.getAttribute("site_title") %>

+

<%= (String) request.getAttribute("site_description") %>

+
+
+ + "/> + "/> + "/> +
+
+

+
+<% if (request != null && request.getAttribute("tools") != null) { for (Tool tool : (List) request.getAttribute("tools") ) { %> +
+

+"> +<%= tool.getTitle() %>

+

<%= tool.getDescription() %>

+
+
+ + "/> + "/> + "/> +
+
+

+
+<% }} %> +
+<%= PortalUtils.includeLatestJQuery("sakai.deeplink") %> + + +<% } %> +
+ diff --git a/plus/provider/src/main/webapp/descriptor.txt b/plus/provider/src/main/webapp/descriptor.txt new file mode 100644 index 000000000000..d34f856d3ec2 --- /dev/null +++ b/plus/provider/src/main/webapp/descriptor.txt @@ -0,0 +1,21 @@ + + + Simple Test + + 9780596800697 + + http://developers.imsglobal.org/BLTI/tool.php + + www.imsglobal.org + IMS Global Learning Consortium + + A simple validity checker. Takes key=lmsng.school.edu secret=secret. + + + cseverance@imsglobal.org + + http://www.imsglobal.org/ + + diff --git a/plus/provider/src/main/webapp/whatisthis.htm b/plus/provider/src/main/webapp/whatisthis.htm new file mode 100644 index 000000000000..a1ead33e1002 --- /dev/null +++ b/plus/provider/src/main/webapp/whatisthis.htm @@ -0,0 +1,53 @@ + + + + + + + +

+Configuring Simple Learning Tools Interoperability +

+

The Resource URL is only used by some External Tools. This is a URL +that is readible across the web. An example URL might be to a publically +downloadable IMS Content Package - which you are providing to the external +tool. +

+

All external tools support launch within an iFrame. Some tools +optionally support a widget launch. A widget launch means that the tool +produces HTML markup which will +be embedded directly in the page instead of in an iFrame. +If you check the Prefer Widget Launch option, the external tool will +be informed that your first preference is to use the in-lined +widget approach. +

+

+The value you enter for Preferred Width / Height will be passed into the +External Tool. If the tool an be resized - it may adjust itself to be this size. +

+

+The value if iFrame Height sets the height of the iFrame if the launch +ends up being an iFrame launch. +

+ + diff --git a/plus/provider/src/test/org/sakaiproject/plus/provider/ProviderTests.java b/plus/provider/src/test/org/sakaiproject/plus/provider/ProviderTests.java new file mode 100644 index 000000000000..9ec94d0624f6 --- /dev/null +++ b/plus/provider/src/test/org/sakaiproject/plus/provider/ProviderTests.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2022- Charles R. Severance + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package org.sakaiproject.plus.provider; + +import static org.junit.Assert.*; +import org.junit.Test; + +import org.json.simple.JSONObject; +import org.json.simple.JSONValue; +import org.json.simple.JSONArray; + +public class ProviderTests { + + @Test + public void testJSONCopyOrReference() { + // Do the simple JSON accessors retrieve a copy or a reference to + // the tree of values and can you change them to influence the underlying tree + String jsonStr = "{\"title\":\"SakaiLMS\",\"exts\":[{\"plat\":\"sakailms.com\",\"places\":[{\"text\":\"text 1\"},{\"text\":\"text 2\"}]}]}"; + JSONObject json1 = (JSONObject) JSONValue.parse(jsonStr); + assertNotNull(json1); + JSONArray exts = (JSONArray) json1.get("exts"); + JSONObject firstext = (JSONObject) exts.get(0); + JSONArray places = (JSONArray) firstext.get("places"); + JSONObject secondp = (JSONObject) places.get(0); + JSONObject secondp2 = (JSONObject) places.get(0); + secondp2.put("new", "42"); + assertTrue(secondp2.toString().contains("42")); + assertTrue(secondp.toString().contains("42")); + assertTrue(places.toString().contains("42")); + assertTrue(json1.toString().contains("42")); + } + +} diff --git a/plus/tool/pom.xml b/plus/tool/pom.xml new file mode 100644 index 000000000000..47df5dedf3a5 --- /dev/null +++ b/plus/tool/pom.xml @@ -0,0 +1,82 @@ + + + 4.0.0 + + org.sakaiproject.plus + sakai-plus-base + 23-SNAPSHOT + + + sakai-plus-tool + war + + sakai-plus-tool + + + + org.sakaiproject.plus + sakai-plus-api + + + javax.servlet + javax.servlet-api + + + org.sakaiproject.portal + sakai-portal-util + + + org.apache.commons + commons-lang3 + + + org.sakaiproject.kernel + sakai-kernel-api + + + org.sakaiproject.kernel + sakai-component-manager + + + org.sakaiproject.kernel + sakai-kernel-util + + + org.springframework + spring-core + + + org.springframework + spring-web + + + org.springframework + spring-webmvc + + + org.springframework.data + spring-data-jpa + + + org.thymeleaf + thymeleaf + + + org.thymeleaf + thymeleaf-spring5 + + + org.thymeleaf.extras + thymeleaf-extras-java8time + + + org.sakaiproject.basiclti + basiclti-api + + + org.sakaiproject.basiclti + basiclti-common + ${sakai.version} + + + diff --git a/plus/tool/src/main/java/org/sakaiproject/plus/tool/MainController.java b/plus/tool/src/main/java/org/sakaiproject/plus/tool/MainController.java new file mode 100644 index 000000000000..d279a69ecbfc --- /dev/null +++ b/plus/tool/src/main/java/org/sakaiproject/plus/tool/MainController.java @@ -0,0 +1,324 @@ +/* + * Copyright (c) 2003-2021 The Apereo Foundation + * + * Licensed under the Educational Community License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://opensource.org/licenses/ecl2 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.sakaiproject.plus.tool; + +import java.util.List; +import java.util.Optional; + +import org.apache.commons.lang3.StringUtils; + +import org.sakaiproject.portal.util.PortalUtils; +import org.sakaiproject.tool.api.Placement; +import org.sakaiproject.tool.api.Session; +import org.sakaiproject.tool.api.SessionManager; +import org.sakaiproject.tool.api.ToolManager; +import org.sakaiproject.site.api.SiteService; +import org.sakaiproject.plus.tool.exception.MissingSessionException; + +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.ModelAttribute; + +import org.sakaiproject.lti.api.LTIService; +import org.sakaiproject.basiclti.util.SakaiBLTIUtil; + +import org.springframework.beans.factory.annotation.Autowired; + +import javax.servlet.http.HttpServletRequest; + +import org.sakaiproject.plus.api.PlusService; +import org.sakaiproject.plus.api.model.Tenant; +import org.sakaiproject.plus.api.model.Context; +import org.sakaiproject.plus.api.model.ContextLog; +import org.sakaiproject.plus.api.repository.TenantRepository; +import org.sakaiproject.plus.api.repository.ContextRepository; +import org.sakaiproject.plus.api.repository.ContextLogRepository; + +import javax.annotation.Resource; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Controller +public class MainController { + + // The number of days to retain log entries + static int CONTEXT_LOG_EXPIRE = 14; + + @Resource + private SessionManager sessionManager; + + @Resource + private ToolManager toolManager; + + @Autowired + private TenantRepository tenantRepository; + + @Autowired + private ContextRepository contextRepository; + + @Autowired + private ContextLogRepository contextLogRepository; + + @Autowired + private LTIService ltiService; + + @Autowired + private SiteService siteService; + + @Autowired + private PlusService plusService; + + @GetMapping(value = {"/", "/index"}) + public String pageIndex(Model model, HttpServletRequest request) { + + contextLogRepository.deleteOlderThanDays(CONTEXT_LOG_EXPIRE); + + if ( isAdmin() ) return adminTenants(model, request); + + Placement placement = toolManager.getCurrentPlacement(); + String contextId = placement.getContext(); + if ( isInstructor(contextId) ) return contextDetail(model, contextId, request); + + return "notallow"; + } + + public String adminTenants(Model model, HttpServletRequest request) { + + if ( ! isAdmin() ) return "notallow"; + + loadModel(model, request); + Iterable tenants = tenantRepository.findAll(); + model.addAttribute("tenants", tenants); + model.addAttribute("enabled", plusService.enabled()); + return "index"; + } + + @GetMapping(value = {"/create"}) + public String createTenant(Model model, HttpServletRequest request) { + + if ( ! isAdmin() ) return "notallow"; + loadModel(model, request); + + Tenant tenant = new Tenant(); + model.addAttribute("tenant", tenant); + model.addAttribute("doUpdate", Boolean.FALSE); + return "form"; + } + + + @PostMapping("/tenant") + public String submitForm(@ModelAttribute("tenant") Tenant tenant) { + + String oldTenantId = tenant.getId(); + if ( oldTenantId != null ) { + Optional optTenant = tenantRepository.findById(oldTenantId); + if ( ! optTenant.isPresent() ) return "notfound"; + + Tenant editTenant = optTenant.get(); + editTenant.setTitle(tenant.getTitle()); + editTenant.setDescription(tenant.getDescription()); + editTenant.setIssuer(tenant.getIssuer()); + editTenant.setClientId(tenant.getClientId()); + editTenant.setDeploymentId(tenant.getDeploymentId()); + editTenant.setTrustEmail(tenant.getTrustEmail()); + editTenant.setTimeZone(tenant.getTimeZone()); + editTenant.setAllowedTools(tenant.getAllowedTools()); + editTenant.setNewWindowTools(tenant.getNewWindowTools()); + editTenant.setVerbose(tenant.getVerbose()); + editTenant.setOidcAuth(tenant.getOidcAuth()); + editTenant.setOidcKeySet(tenant.getOidcKeySet()); + editTenant.setOidcToken(tenant.getOidcToken()); + editTenant.setOidcAudience(tenant.getOidcAudience()); + editTenant.setOidcRegistrationLock(tenant.getOidcRegistrationLock()); + tenantRepository.save(editTenant); + log.info("Updating Plus Tenant id={}", oldTenantId); + + } else { + tenantRepository.save(tenant); + log.info("Created Plus Tenant id={}", tenant.getId()); + } + return "redirect:/"; + } + + @GetMapping(value = "/tenant/{tenantId}") + public String tenantDetail(Model model, @PathVariable String tenantId, HttpServletRequest request) { + + if ( ! isAdmin() ) return "notallow"; + + Optional optTenant = tenantRepository.findById(tenantId); + if ( ! optTenant.isPresent() ) return "notfound"; + Tenant tenant = optTenant.get(); + + loadModel(model, request); + model.addAttribute("tenant", tenant); + + model.addAttribute("oidcKeySet", plusService.getOidcKeySet()); + model.addAttribute("oidcLogin", plusService.getOidcLogin(tenant)); + model.addAttribute("oidcLaunch", plusService.getOidcLaunch()); + + // http://localhost:8080/plus/sakai/dynamic/123?unlock_token=42 + model.addAttribute("imsURL", plusService.getIMSDynamicRegistration(tenant)); + + // https://dev1.sakaicloud.com/plus/sakai/canvas-config.json?guid=123456 + model.addAttribute("canvasURL", plusService.getCanvasConfig(tenant)); + return "tenant"; + } + + @GetMapping(value = "/edit/{tenantId}") + public String tenantEdit(Model model, @PathVariable String tenantId, HttpServletRequest request) { + + if ( ! isAdmin() ) return "notallow"; + + Optional optTenant = tenantRepository.findById(tenantId); + if ( ! optTenant.isPresent() ) return "notfound"; + Tenant tenant = optTenant.get(); + + loadModel(model, request); + model.addAttribute("tenant", tenant); + model.addAttribute("doUpdate", Boolean.TRUE); + return "form"; + } + + @GetMapping(value = "/delete/{tenantId}") + public String tenantDelete(Model model, @PathVariable String tenantId, HttpServletRequest request) { + + if ( ! isAdmin() ) return "notallow"; + + Optional optTenant = tenantRepository.findById(tenantId); + if ( ! optTenant.isPresent() ) return "notfound"; + Tenant tenant = optTenant.get(); + + loadModel(model, request); + model.addAttribute("tenant", tenant); + return "delete"; + } + + @PostMapping(value = "/delete/{tenantId}") + public String tenantDeletePost(Model model, @PathVariable String tenantId, HttpServletRequest request) { + + if ( ! isAdmin() ) return "notallow"; + + Optional optTenant = tenantRepository.findById(tenantId); + if ( ! optTenant.isPresent() ) return "notfound"; + + log.info("Deleteing Plus Tenant id={}", tenantId); + + tenantRepository.deleteById(tenantId); + return "redirect:/"; + } + + @GetMapping(value = "/contexts/{tenantId}") + public String contexts(Model model, @PathVariable String tenantId, HttpServletRequest request) { + + if ( ! isAdmin() ) return "notallow"; + loadModel(model, request); + + Optional optTenant = tenantRepository.findById(tenantId); + if ( ! optTenant.isPresent() ) return "notfound"; + Tenant tenant = optTenant.get(); + + model.addAttribute("tenant", tenant); + + List contexts = null; + if ( tenant != null ) { + contexts = contextRepository.findByTenant(tenant); + model.addAttribute("contexts", contexts); + } + return "contexts"; + } + + @GetMapping(value = "/context/{contextId}") + public String contextDetailAdmin(Model model, @PathVariable String contextId, HttpServletRequest request) { + if ( ! isAdmin() ) return "notallow"; + return contextDetail(model, contextId, request); + } + + public String contextDetail(Model model, String contextId, HttpServletRequest request) { + + Optional optContext = contextRepository.findById(contextId); + if ( ! optContext.isPresent() ) return "notfound"; + Context context = optContext.get(); + + loadModel(model, request); + model.addAttribute("tenantId", context.getTenant().getId()); + model.addAttribute("context", context); + model.addAttribute("admin", isAdmin()); + + List failures = contextLogRepository.getLogEntries(context, Boolean.FALSE, 20); + model.addAttribute("failures", failures); + List successes = contextLogRepository.getLogEntries(context, Boolean.TRUE, 20); + model.addAttribute("successes", successes); + + return "context"; + } + + private void loadModel(Model model, HttpServletRequest request) { + + model.addAttribute("cdnQuery", PortalUtils.getCDNQuery()); + + Placement placement = toolManager.getCurrentPlacement(); + model.addAttribute("siteId", placement.getContext()); + String baseUrl = "/portal/site/" + placement.getContext() + "/tool/" + toolManager.getCurrentPlacement().getId(); + model.addAttribute("baseUrl", baseUrl); + String serverUrl = SakaiBLTIUtil.getOurServerUrl(); + model.addAttribute("serverUrl", serverUrl); + model.addAttribute("sakaiHtmlHead", (String) request.getAttribute("sakai.html.head")); + } + + /** + * Check for a valid session + * if not valid a 403 Forbidden will be returned + */ + private Session getSakaiSession() { + + try { + Session session = sessionManager.getCurrentSession(); + if (StringUtils.isBlank(session.getUserId())) { + log.error("Sakai user session is invalid"); + throw new MissingSessionException(); + } + return session; + } catch (IllegalStateException e) { + log.error("Could not retrieve the sakai session"); + throw new MissingSessionException(e.getCause()); + } + } + + /** + * Check if this is an admin placement + */ + private boolean isAdmin() { + getSakaiSession(); + Placement placement = toolManager.getCurrentPlacement(); + return ltiService.isAdmin(placement.getContext()); + } + + /** + * Check if this is an instructor in the site placement + */ + private boolean isInstructor(String contextId) { + + // Just to make sure. + getSakaiSession(); + + return siteService.allowUpdateSite(contextId); + } + +} diff --git a/plus/tool/src/main/java/org/sakaiproject/plus/tool/PlusConfiguration.java b/plus/tool/src/main/java/org/sakaiproject/plus/tool/PlusConfiguration.java new file mode 100644 index 000000000000..8d26cca285d7 --- /dev/null +++ b/plus/tool/src/main/java/org/sakaiproject/plus/tool/PlusConfiguration.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2003-2021 The Apereo Foundation + * + * Licensed under the Educational Community License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://opensource.org/licenses/ecl2 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.sakaiproject.plus.tool; + +import java.util.EnumSet; + +import javax.servlet.DispatcherType; +import javax.servlet.ServletContext; +import javax.servlet.ServletException; +import javax.servlet.ServletRegistration; + +import org.sakaiproject.util.RequestFilter; +import org.sakaiproject.util.SakaiContextLoaderListener; +import org.sakaiproject.util.ToolListener; +import org.springframework.web.WebApplicationInitializer; +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; +import org.springframework.web.servlet.DispatcherServlet; + +public class PlusConfiguration implements WebApplicationInitializer { + + @Override + public void onStartup(ServletContext servletContext) throws ServletException { + + AnnotationConfigWebApplicationContext rootContext = new AnnotationConfigWebApplicationContext(); + rootContext.setServletContext(servletContext); + rootContext.register(WebMvcConfiguration.class); + + servletContext.addListener(new ToolListener()); + servletContext.addListener(new SakaiContextLoaderListener(rootContext)); + + servletContext.addFilter("sakai.request", RequestFilter.class) + .addMappingForUrlPatterns( + EnumSet.of(DispatcherType.REQUEST, DispatcherType.FORWARD, DispatcherType.INCLUDE), + true, + "/*"); + + ServletRegistration.Dynamic servlet = servletContext.addServlet("sakai.plus", new DispatcherServlet(rootContext)); + servlet.addMapping("/"); + servlet.setLoadOnStartup(1); + } +} diff --git a/plus/tool/src/main/java/org/sakaiproject/plus/tool/WebMvcConfiguration.java b/plus/tool/src/main/java/org/sakaiproject/plus/tool/WebMvcConfiguration.java new file mode 100644 index 000000000000..0af290c5888c --- /dev/null +++ b/plus/tool/src/main/java/org/sakaiproject/plus/tool/WebMvcConfiguration.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2003-2021 The Apereo Foundation + * + * Licensed under the Educational Community License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://opensource.org/licenses/ecl2 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.sakaiproject.plus.tool; + +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.context.MessageSource; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.ViewResolver; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import org.springframework.web.servlet.config.annotation.ViewControllerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.thymeleaf.extras.java8time.dialect.Java8TimeDialect; +import org.thymeleaf.spring5.ISpringTemplateEngine; +import org.thymeleaf.spring5.SpringTemplateEngine; +import org.thymeleaf.spring5.templateresolver.SpringResourceTemplateResolver; +import org.thymeleaf.spring5.view.ThymeleafViewResolver; +import org.thymeleaf.templatemode.TemplateMode; +import org.thymeleaf.templateresolver.ITemplateResolver; + +import org.sakaiproject.util.ResourceLoaderMessageSource; + + +import lombok.Setter; + +@Configuration +@EnableWebMvc +@ComponentScan("org.sakaiproject.plus") +public class WebMvcConfiguration implements ApplicationContextAware, WebMvcConfigurer { + + private static final String UTF8 = "UTF-8"; + + @Setter private ApplicationContext applicationContext; + + @Bean + public MessageSource messageSource() { + ResourceLoaderMessageSource messages = new ResourceLoaderMessageSource(); + messages.setBasename("Messages"); + return messages; + } + + @Bean + public ViewResolver viewResolver() { + + ThymeleafViewResolver viewResolver = new ThymeleafViewResolver(); + viewResolver.setTemplateEngine(templateEngine()); + viewResolver.setCharacterEncoding(UTF8); + return viewResolver; + } + + private ISpringTemplateEngine templateEngine() { + + SpringTemplateEngine templateEngine = new SpringTemplateEngine(); + templateEngine.setEnableSpringELCompiler(true); + templateEngine.addDialect(new Java8TimeDialect()); + templateEngine.setTemplateResolver(templateResolver()); + templateEngine.setMessageSource(messageSource()); + return templateEngine; + } + + private ITemplateResolver templateResolver() { + + SpringResourceTemplateResolver templateResolver = new SpringResourceTemplateResolver(); + templateResolver.setApplicationContext(applicationContext); + templateResolver.setPrefix("/WEB-INF/templates/"); + templateResolver.setSuffix(".html"); + templateResolver.setTemplateMode(TemplateMode.HTML); + return templateResolver; + } + + @Override + public void addViewControllers(ViewControllerRegistry registry) { + registry.addViewController("/").setViewName("index"); + } +} diff --git a/plus/tool/src/main/java/org/sakaiproject/plus/tool/exception/MissingSessionException.java b/plus/tool/src/main/java/org/sakaiproject/plus/tool/exception/MissingSessionException.java new file mode 100644 index 000000000000..ea2affabfd2b --- /dev/null +++ b/plus/tool/src/main/java/org/sakaiproject/plus/tool/exception/MissingSessionException.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2003-2021 The Apereo Foundation + * + * Licensed under the Educational Community License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://opensource.org/licenses/ecl2 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.sakaiproject.plus.tool.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(value = HttpStatus.FORBIDDEN, reason = "Missing Sakai Session") +public class MissingSessionException extends RuntimeException { + + public MissingSessionException() { + super(); + } + + public MissingSessionException(Throwable cause) { + super(cause); + } + + public MissingSessionException(String message, Throwable cause) { + super(message, cause); + } + + public MissingSessionException(String message) { + super(message); + } +} diff --git a/plus/tool/src/main/resources/Messages.properties b/plus/tool/src/main/resources/Messages.properties new file mode 100644 index 000000000000..78afe5764539 --- /dev/null +++ b/plus/tool/src/main/resources/Messages.properties @@ -0,0 +1,66 @@ +plus.tool.not.enabled=SakaiPlus is not enabled on this server. It can be enabled by setting the plus.provider.enabled property in the sakai.properties file. +plus.tool.not.allowed=You are not authorized to use this tool +plus.tool.not.found=Unable to load object +plus.tool.toggle.detail=Toggle Detail +plus.tool.addtenant=Add Tenant +plus.tool.deletetenant=Delete Tenant: +plus.tool.title.tenants=Tenants +plus.tool.contexts.title=Contexts for: +plus.tool.tenant.title=Tenant: +plus.tool.tenant.draft=(Draft) +plus.tool.context.title=Context for: +plus.tool.job.start=Last process start: +plus.tool.job.finish=Last process end: +plus.tool.job.status=Last operation status: +plus.tool.job.log=Last process log +plus.tool.hide.show=Hide / Show +plus.tool.all.tenants=All Tenants +plus.tool.all.contexts=All Contexts +plus.tool.error.missing.unlock=You must set the registration lock code to a non-blank string to use IMS Dynamic Provisioning. + +plus.tool.contextlog.errors=Recent Log Errors: +plus.tool.contextlog.errors.none=No Recent Log Errors +plus.tool.contextlog.successes=Recent Log Entries: +plus.tool.contextlog.successes.none=No Recent Log Entries + +plus.tool.issuer=Issuer: +plus.tool.clientid=Client Id: +plus.tool.deploymentid=Deployment Id: +plus.tool.lms.oidcauth=LMS Authorization URL: +plus.tool.lms.oidkeyset=LMS KeySet URL: +plus.tool.lms.oidtoken=LMS Token URL: +plus.tool.lms.oidaudience=LMS Token Audience: + +plus.tool.plus.keyset=Keyset: +plus.tool.plus.login=Login: +plus.tool.plus.launch=Launch: + +plus.tool.imsurl=IMS Dynamic Provisioning URL: +plus.tool.plusurls=Manual Configuration URLs: +plus.tool.canvasurl=Canvas Configuration URL: + +plus.tool.edit=Edit +plus.tool.create=Add +plus.tool.update=Update +plus.tool.delete=Delete +plus.tool.cancel=Cancel +plus.tenant.sure=Are you sure you want to delete this tenant? This action can not be undone. + +plus.tool.title=Title +plus.tool.issuer=Issuer +plus.tool.clientid=Client ID +plus.tool.deploymentid=Deployment ID +plus.tool.trustemail=Trust Email +plus.tool.allowedtools=Allowed Tools +plus.tool.newwindowtools=New Window Tools +plus.tool.verbose=Verbose Debgging +plus.tool.oidckeyset=LMS Keyset Url +plus.tool.oidcauth=LMS Authorization Url +plus.tool.oidctoken=LMS Token Url +plus.tool.oidcaudience=LMS Token Audience +plus.tool.oidcregistrationlock=Registration Lock + +plus.tool.add=Add Tenant +plus.tool.on=On +plus.tool.off=Off + diff --git a/plus/tool/src/main/webapp/WEB-INF/templates/context.html b/plus/tool/src/main/webapp/WEB-INF/templates/context.html new file mode 100644 index 000000000000..c93cfb82d7df --- /dev/null +++ b/plus/tool/src/main/webapp/WEB-INF/templates/context.html @@ -0,0 +1,62 @@ + + + + + + [(${sakaiHtmlHead})] + + + +
+

Context: Sakai

+ +

Start: Start

+

Finish: Finish

+

Last operation status:status

+
+

Most Recent Debug Log + Hide/Show

+ +
+
+
+

Recent Log Errors:

+
    +
  • +

    Date

    +

    entry.action

    +

    entry.status

    +
    +entry.debugLog
    +
    + +
  • +
      +
+
+

No Recent Log Errors

+
+
+

Recent Log Entries:

+
    +
  • +

    Date

    +

    entry.action

    +

    entry.status

    +
    +entry.debugLog
    +
    + +
  • +
      +
+
+ + diff --git a/plus/tool/src/main/webapp/WEB-INF/templates/contexts.html b/plus/tool/src/main/webapp/WEB-INF/templates/contexts.html new file mode 100644 index 000000000000..0f5743ee7cf1 --- /dev/null +++ b/plus/tool/src/main/webapp/WEB-INF/templates/contexts.html @@ -0,0 +1,22 @@ + + + + + + [(${sakaiHtmlHead})] + + + +
+ +

Contexts for: Sakai

+ All Tenants

+ +
+ + diff --git a/plus/tool/src/main/webapp/WEB-INF/templates/delete.html b/plus/tool/src/main/webapp/WEB-INF/templates/delete.html new file mode 100644 index 000000000000..31b476017eba --- /dev/null +++ b/plus/tool/src/main/webapp/WEB-INF/templates/delete.html @@ -0,0 +1,26 @@ + + + + + + [(${sakaiHtmlHead})] + + + +
+

Delete: + Sakai / + Issuer + (draft) +

+

Are you sure?

+
+ +
+ + Cancel +
+
+
+ + diff --git a/plus/tool/src/main/webapp/WEB-INF/templates/form.html b/plus/tool/src/main/webapp/WEB-INF/templates/form.html new file mode 100644 index 000000000000..c4617249459d --- /dev/null +++ b/plus/tool/src/main/webapp/WEB-INF/templates/form.html @@ -0,0 +1,117 @@ + + + + + + [(${sakaiHtmlHead})] + + + + +
+

Tenant: + Sakai + (draft) +

+

All Tenants

+
+ +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ + + +
+
+
+ +
+ + + +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ + + Cancel +
+
+
+ + + diff --git a/plus/tool/src/main/webapp/WEB-INF/templates/index.html b/plus/tool/src/main/webapp/WEB-INF/templates/index.html new file mode 100644 index 000000000000..2ea6a7b18a0f --- /dev/null +++ b/plus/tool/src/main/webapp/WEB-INF/templates/index.html @@ -0,0 +1,27 @@ + + + + + + [(${sakaiHtmlHead})] + + + +
+

Tenants

+

Not Enabled

+

Add Tenant

+ +
+ + diff --git a/plus/tool/src/main/webapp/WEB-INF/templates/notallow.html b/plus/tool/src/main/webapp/WEB-INF/templates/notallow.html new file mode 100644 index 000000000000..dec43c6c9a14 --- /dev/null +++ b/plus/tool/src/main/webapp/WEB-INF/templates/notallow.html @@ -0,0 +1,14 @@ + + + + + + [(${sakaiHtmlHead})] + + + +
+

You are not authorized to run this tool

+
+ + diff --git a/plus/tool/src/main/webapp/WEB-INF/templates/notfound.html b/plus/tool/src/main/webapp/WEB-INF/templates/notfound.html new file mode 100644 index 000000000000..95e077670c9f --- /dev/null +++ b/plus/tool/src/main/webapp/WEB-INF/templates/notfound.html @@ -0,0 +1,14 @@ + + + + + + [(${sakaiHtmlHead})] + + + +
+

Unable to load object

+
+ + diff --git a/plus/tool/src/main/webapp/WEB-INF/templates/tenant.html b/plus/tool/src/main/webapp/WEB-INF/templates/tenant.html new file mode 100644 index 000000000000..8997a14b5f52 --- /dev/null +++ b/plus/tool/src/main/webapp/WEB-INF/templates/tenant.html @@ -0,0 +1,53 @@ + + + + + + [(${sakaiHtmlHead})] + + + +
+

Tenant: + Sakai + (draft) +

+

View Contexts | + All Tenants

+

Issuer: Issuer

+

Client Id: clientId

+

Deployment Id: deploymentId

+

LMS Authorization URL: oidcAuth

+

LMS KeySet URL: oidcKeySet

+

LMS Token URL: oidcToken

+

LMS Token Audience: oidcAudience

+
+

Missing unlock code +

+
+

IMS Dynamic Provisioning URL: + Hide/Show
+

+
+
+

Canvas Configuration URL: + Hide/Show
+

+
+

Manual Configuration URLs: + Hide/Show
+

+

+
+

Last operation status: status

+
+

Most Recent Debug Log Hide/Show

+ +
+
+ + diff --git a/plus/tool/src/main/webapp/WEB-INF/tools/sakai.plus.xml b/plus/tool/src/main/webapp/WEB-INF/tools/sakai.plus.xml new file mode 100644 index 000000000000..116fa36cde65 --- /dev/null +++ b/plus/tool/src/main/webapp/WEB-INF/tools/sakai.plus.xml @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/plus/tool/src/main/webapp/WEB-INF/web.xml b/plus/tool/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 000000000000..2b466f7adaf5 --- /dev/null +++ b/plus/tool/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,7 @@ + + + + diff --git a/pom.xml b/pom.xml index 2adc82dd82e2..58b03130ca66 100644 --- a/pom.xml +++ b/pom.xml @@ -86,6 +86,7 @@ myconnections oauth pasystem + plus podcasts polls portal @@ -173,6 +174,7 @@ mycalendar myconnections osp + plus podcasts polls portal diff --git a/portal/portal-impl/impl/pom.xml b/portal/portal-impl/impl/pom.xml index af7a818f02f2..4db048eb153c 100644 --- a/portal/portal-impl/impl/pom.xml +++ b/portal/portal-impl/impl/pom.xml @@ -79,7 +79,6 @@ org.sakaiproject.basiclti basiclti-api - ${project.version} commons-lang diff --git a/portal/portal-impl/impl/src/bundle/sitenav.properties b/portal/portal-impl/impl/src/bundle/sitenav.properties index 146892874379..459311ff9425 100644 --- a/portal/portal-impl/impl/src/bundle/sitenav.properties +++ b/portal/portal-impl/impl/src/bundle/sitenav.properties @@ -54,6 +54,7 @@ sit_toggle_nav_min = Minimize tool navigation sit_help = Help sit_helpfor = Help for {0} sit_edit = Edit +sit_closepage = Close sit_reset = Tool Home sit_unpublished = Unpublished Site sit_publish_now = Publish Now diff --git a/portal/portal-impl/impl/src/java/org/sakaiproject/portal/charon/SkinnableCharonPortal.java b/portal/portal-impl/impl/src/java/org/sakaiproject/portal/charon/SkinnableCharonPortal.java index 4e2dce47b195..5d5846074513 100644 --- a/portal/portal-impl/impl/src/java/org/sakaiproject/portal/charon/SkinnableCharonPortal.java +++ b/portal/portal-impl/impl/src/java/org/sakaiproject/portal/charon/SkinnableCharonPortal.java @@ -94,6 +94,7 @@ import org.sakaiproject.portal.charon.handlers.RoleSwitchOutHandler; import org.sakaiproject.portal.charon.handlers.RssHandler; import org.sakaiproject.portal.charon.handlers.SiteHandler; +import org.sakaiproject.portal.charon.handlers.PlusHandler; import org.sakaiproject.portal.charon.handlers.SiteResetHandler; import org.sakaiproject.portal.charon.handlers.StaticScriptsHandler; import org.sakaiproject.portal.charon.handlers.StaticStylesHandler; @@ -219,6 +220,7 @@ public class SkinnableCharonPortal extends HttpServlet implements Portal private WorksiteHandler worksiteHandler; private SiteHandler siteHandler; + private PlusHandler plusHandler; private String portalContext; @@ -2064,8 +2066,11 @@ public void init(ServletConfig config) throws ServletException worksiteHandler = new WorksiteHandler(); siteHandler = new SiteHandler(); - addHandler(siteHandler); + + plusHandler = new PlusHandler(); + addHandler(plusHandler); + addHandler(new SiteResetHandler()); addHandler(new ToolHandler()); diff --git a/portal/portal-impl/impl/src/java/org/sakaiproject/portal/charon/handlers/PlusHandler.java b/portal/portal-impl/impl/src/java/org/sakaiproject/portal/charon/handlers/PlusHandler.java new file mode 100644 index 000000000000..73383d62bcfd --- /dev/null +++ b/portal/portal-impl/impl/src/java/org/sakaiproject/portal/charon/handlers/PlusHandler.java @@ -0,0 +1,120 @@ +/********************************************************************************** + * $URL$ + * $Id$ + *********************************************************************************** + * + * Copyright (c) 2003, 2004, 2005, 2006, 2007, 2008 The Sakai Foundation + * + * Licensed under the Educational Community License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.opensource.org/licenses/ECL-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + **********************************************************************************/ + +package org.sakaiproject.portal.charon.handlers; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.sakaiproject.authz.api.Role; +import org.sakaiproject.authz.cover.SecurityService; +import org.sakaiproject.component.cover.ComponentManager; +import org.sakaiproject.component.cover.ServerConfigurationService; +import org.sakaiproject.entity.api.ResourceProperties; +import org.sakaiproject.event.api.Event; +import org.sakaiproject.event.cover.EventTrackingService; +import org.sakaiproject.exception.IdUnusedException; +import org.sakaiproject.exception.PermissionException; +import org.sakaiproject.portal.api.Portal; +import org.sakaiproject.portal.api.PortalHandlerException; +import org.sakaiproject.portal.api.PortalRenderContext; +import org.sakaiproject.portal.api.PortalService; +import org.sakaiproject.portal.api.SiteView; +import org.sakaiproject.portal.api.StoredState; +import org.sakaiproject.portal.charon.site.AllSitesViewImpl; +import org.sakaiproject.portal.charon.site.PortalSiteHelperImpl; +import org.sakaiproject.portal.util.ByteArrayServletResponse; +import org.sakaiproject.portal.util.ToolUtils; +import org.sakaiproject.portal.util.URLUtils; +import org.sakaiproject.presence.api.PresenceService; +import org.sakaiproject.site.api.Site; +import org.sakaiproject.site.api.SitePage; +import org.sakaiproject.site.api.ToolConfiguration; +import org.sakaiproject.site.cover.SiteService; +import org.sakaiproject.thread_local.cover.ThreadLocalManager; +import org.sakaiproject.tool.api.ActiveTool; +import org.sakaiproject.tool.api.Session; +import org.sakaiproject.tool.api.Tool; +import org.sakaiproject.tool.api.ToolException; +import org.sakaiproject.tool.api.ToolManager; +import org.sakaiproject.tool.api.ToolSession; +import org.sakaiproject.tool.cover.ActiveToolManager; +import org.sakaiproject.tool.cover.SessionManager; +import org.sakaiproject.user.api.Preferences; +import org.sakaiproject.user.api.UserNotDefinedException; +import org.sakaiproject.user.cover.PreferencesService; +import org.sakaiproject.user.cover.UserDirectoryService; +import org.sakaiproject.util.ResourceLoader; +import org.sakaiproject.util.Validator; +import org.sakaiproject.util.Web; +import org.sakaiproject.util.RequestFilter; + +import lombok.extern.slf4j.Slf4j; + + +/** + * @author ieb + * @since Sakai 2.4 + * @version $Rev$ + */ +@Slf4j +public class PlusHandler extends SiteHandler +{ + + public PlusHandler() + { + URL_FRAGMENT = "plus"; + setUrlFragment(URL_FRAGMENT); + // super() will be called but will not double register the URL_FRAGMENT + } + + /** + * Does the final render response, classes that extend this class + * may/will want to override this method to use their own template + * + * @param rcontext + * @param res + * @param object + * @param b + * @throws IOException + */ + @Override + protected void doSendResponse(PortalRenderContext rcontext, HttpServletResponse res, + String contentType) throws IOException + { +System.out.println("PlusHandler.java doSendResponse URL_FRAGMENT="+URL_FRAGMENT); + portal.sendResponse(rcontext, res, "plus", null); + } + +} diff --git a/portal/portal-impl/impl/src/java/org/sakaiproject/portal/charon/handlers/SiteHandler.java b/portal/portal-impl/impl/src/java/org/sakaiproject/portal/charon/handlers/SiteHandler.java index 80a4b086e095..4664b8f72de1 100644 --- a/portal/portal-impl/impl/src/java/org/sakaiproject/portal/charon/handlers/SiteHandler.java +++ b/portal/portal-impl/impl/src/java/org/sakaiproject/portal/charon/handlers/SiteHandler.java @@ -99,8 +99,9 @@ public class SiteHandler extends WorksiteHandler private static final String INCLUDE_TABS = "include-tabs"; - private static final String URL_FRAGMENT = "site"; - + // Cannot be static to allow for this class to be extended at a different fragment + protected String URL_FRAGMENT = "site"; + private static ResourceLoader rb = new ResourceLoader("sitenav"); // When these strings appear in the URL they will be replaced by a calculated value based on the context. @@ -142,7 +143,11 @@ public class SiteHandler extends WorksiteHandler public SiteHandler() { - setUrlFragment(SiteHandler.URL_FRAGMENT); + // Allow any sub-classes to register their own URL_FRAGMENT + // https://stackoverflow.com/questions/41566202/possible-to-avoid-default-call-to-super-in-java + if(this.getClass() == SiteHandler.class) { + setUrlFragment(URL_FRAGMENT); + } mutableSitename = ServerConfigurationService.getString("portal.mutable.sitename", "-"); mutablePagename = ServerConfigurationService.getString("portal.mutable.pagename", "-"); imageLogic = ComponentManager.get(ProfileImageLogic.class); @@ -152,7 +157,7 @@ public SiteHandler() public int doGet(String[] parts, HttpServletRequest req, HttpServletResponse res, Session session) throws PortalHandlerException { - if ((parts.length >= 2) && (parts[1].equals(SiteHandler.URL_FRAGMENT))) + if ((parts.length >= 2) && (parts[1].equals(URL_FRAGMENT))) { // This is part of the main portal so we simply remove the attribute session.setAttribute(PortalService.SAKAI_CONTROLLING_PORTAL, null); diff --git a/portal/portal-render-engine-impl/impl/src/webapp/vm/morpheus/includePageBody.vm b/portal/portal-render-engine-impl/impl/src/webapp/vm/morpheus/includePageBody.vm index 5599ca5902cb..681fce7b9a64 100644 --- a/portal/portal-render-engine-impl/impl/src/webapp/vm/morpheus/includePageBody.vm +++ b/portal/portal-render-engine-impl/impl/src/webapp/vm/morpheus/includePageBody.vm @@ -101,7 +101,7 @@ #if ( $toolDirectUrlEnabled && ${tool.showDirectToolUrl} ) - diff --git a/portal/portal-render-engine-impl/impl/src/webapp/vm/morpheus/plus.vm b/portal/portal-render-engine-impl/impl/src/webapp/vm/morpheus/plus.vm new file mode 100755 index 000000000000..bf729877a9cb --- /dev/null +++ b/portal/portal-render-engine-impl/impl/src/webapp/vm/morpheus/plus.vm @@ -0,0 +1,360 @@ +## ## Comments seen before doctype. Internet Explorer will go into the quirks mode. +## Create a variable that is a dollar sign for later +#set ( $d = "$") + + #parse("/vm/morpheus/includeStandardHead.vm") + + + + #if ( ${bufferedResponse} && ${responseHead} ) + #else + ${sakai_html_head_js} + #end ## END of IF ( ${bufferedResponse} && ${responseHead} ) + +
+ + +## +## the Page part of the standard view, with page navigation +## + + +
+ +
+ + #parse("/vm/morpheus/snippets/siteStatus-snippet.vm") + + ## This strangely adds a link to the site when inside an iframe + ## #parse("/vm/morpheus/includeSiteHierarchy.vm") + + #parse("/vm/morpheus/includePageBody.vm") + + + #parse("/vm/morpheus/includeFooter.vm") + +
+ +
+ + +
+ + + + +