diff --git a/Source/Plugins/Core/com.equella.core/resources/view/googlepage.ftl b/Source/Plugins/Core/com.equella.core/resources/view/googlepage.ftl
index c459ad3cba..d54c56fb04 100644
--- a/Source/Plugins/Core/com.equella.core/resources/view/googlepage.ftl
+++ b/Source/Plugins/Core/com.equella.core/resources/view/googlepage.ftl
@@ -8,10 +8,11 @@
${b.key("analytics.pagetitle")}
+
${b.key("analytics.description")}
-
+
${b.key("account.dont.have")} ${b.key("account.signup")}
-
+
<@setting label=b.key("account.enter") section=s.accountId help=b.key("account.example") />
<@setting label=b.key("analytics.status")>
@@ -24,9 +25,9 @@
@setting>
-
${b.key("analytics.help.trackingCode")}
-
${b.key("analytics.help.visitorStats")}
-
+
${b.key("analytics.help.trackingCode")}
+
${b.key("analytics.help.visitorStats")}
+
<@button section=s.save showAs="save" />
diff --git a/Source/Plugins/Core/com.equella.core/resources/view/googlescript.ftl b/Source/Plugins/Core/com.equella.core/resources/view/googlescript.ftl
index 25aa7c5e18..df6259682d 100644
--- a/Source/Plugins/Core/com.equella.core/resources/view/googlescript.ftl
+++ b/Source/Plugins/Core/com.equella.core/resources/view/googlescript.ftl
@@ -1,11 +1,14 @@
-<#assign PART_READY>
- var _gaq = _gaq || [];
- _gaq.push(['_setAccount', "${m.googleAccountId?js_string}"]);
- _gaq.push(['_trackPageview']);
-
- (function() {
- var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true;
- ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js';
- var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s);
- })();
+<#assign PART_HEAD>
+
+
#assign>
diff --git a/Source/Plugins/Core/com.equella.core/resources/view/layouts/outer/react.ftl b/Source/Plugins/Core/com.equella.core/resources/view/layouts/outer/react.ftl
index 3ba553b65c..283c52f822 100644
--- a/Source/Plugins/Core/com.equella.core/resources/view/layouts/outer/react.ftl
+++ b/Source/Plugins/Core/com.equella.core/resources/view/layouts/outer/react.ftl
@@ -11,8 +11,6 @@
-
-
<@render template["header"]/>
diff --git a/Source/Plugins/Core/com.equella.core/resources/view/webdav/webdav.ftl b/Source/Plugins/Core/com.equella.core/resources/view/webdav/webdav.ftl
new file mode 100644
index 0000000000..d6d3768de9
--- /dev/null
+++ b/Source/Plugins/Core/com.equella.core/resources/view/webdav/webdav.ftl
@@ -0,0 +1,46 @@
+<#include "/com.tle.web.freemarker@/macro/sections.ftl">
+<#include "/com.tle.web.sections.standard@/dialog.ftl"/>
+<#include "/com.tle.web.sections.equella@/component/button.ftl"/>
+
+<@script "webdav.js"/>
+
+<#if !m.hideDetails>
+
+
+
+ URL:
+ |
+
+ ${m.webdavUrl}
+ |
+
+
+ |
+
+
+
+ ${b.gkey('webdav.username')}:
+ |
+
+ ${m.webdavUsername}
+ |
+
+
+ |
+
+
+
+ ${b.gkey('webdav.password')}:
+ |
+
+ ${m.webdavPassword}
+ |
+
+
+ |
+
+
+
+ <@render section=s.refreshButton class="ctrlbuttonNW">${b.gkey('wizard.controls.file.refresh')}@render>
+#if>
+<@render s.filesTable />
diff --git a/Source/Plugins/Core/com.equella.core/resources/web/.gitignore b/Source/Plugins/Core/com.equella.core/resources/web/.gitignore
deleted file mode 100644
index 1164cd5a93..0000000000
--- a/Source/Plugins/Core/com.equella.core/resources/web/.gitignore
+++ /dev/null
@@ -1,3 +0,0 @@
-inplaceedit.jar
-nativelibs.jar
-
diff --git a/Source/Plugins/Core/com.equella.core/resources/web/inplaceEditor.jnlp b/Source/Plugins/Core/com.equella.core/resources/web/inplaceEditor.jnlp
deleted file mode 100644
index 927cd323bf..0000000000
--- a/Source/Plugins/Core/com.equella.core/resources/web/inplaceEditor.jnlp
+++ /dev/null
@@ -1,29 +0,0 @@
-
-
-
-
- EQUELLA: Open File With
- Apereo
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/Source/Plugins/Core/com.equella.core/resources/web/nativelibs.jnlp b/Source/Plugins/Core/com.equella.core/resources/web/nativelibs.jnlp
deleted file mode 100644
index 6828e25014..0000000000
--- a/Source/Plugins/Core/com.equella.core/resources/web/nativelibs.jnlp
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
- EQUELLA: Extension libraries for open file with
- Apereo
-
-
-
-
-
-
-
-
-
-
diff --git a/Source/Plugins/Core/com.equella.core/resources/web/sass/legacy.scss b/Source/Plugins/Core/com.equella.core/resources/web/sass/legacy.scss
index fb4f5be40e..95a8d2650e 100644
--- a/Source/Plugins/Core/com.equella.core/resources/web/sass/legacy.scss
+++ b/Source/Plugins/Core/com.equella.core/resources/web/sass/legacy.scss
@@ -1451,9 +1451,24 @@ div[id^="pportlet"] {
}
}
-div.load-action button.dropdown-toggle {
- top: 134px;
- right: 30px;
+//overwrite the css in `bulkscript.css` to show `load script button` in UI (it was covered by editor area).
+div.load-action {
+ // align button and title
+ top: -40px;
+ position: relative;
+ text-align: right;
+
+ button.dropdown-toggle {
+ width: max-content;
+ float: right;
+ }
+
+ ul.dropdown-menu {
+ position: relative;
+ text-align: left;
+ // add some space between button and dropdown-menu
+ top: 40px;
+ }
}
/*****************************************************************************
diff --git a/Source/Plugins/Core/com.equella.core/resources/web/scripts/inplaceedit.js b/Source/Plugins/Core/com.equella.core/resources/web/scripts/inplaceedit.js
deleted file mode 100644
index 3082d7080e..0000000000
--- a/Source/Plugins/Core/com.equella.core/resources/web/scripts/inplaceedit.js
+++ /dev/null
@@ -1,170 +0,0 @@
-function inPlaceCreateApplet($placeholder, width, height, parameters, id)
-{
- var $elem = $placeholder;
- var colour = $elem.css('background-color');
- //Chrome gives rgba(0, 0, 0, 0)
- while (colour == 'transparent' || colour == 'rgba(0, 0, 0, 0)')
- {
- $elem = $elem.parent();
- if (parent == null)
- {
- break;
- }
- colour = $elem.css('background-color');
- }
- parameters['jnlp.BACKGROUND'] = colour;
- parameters['jnlp.WIDTH'] = width;
- parameters['jnlp.HEIGHT'] = height;
-
- var attributes =
- {
- id: id,
- name: id,
- code: parameters.code, /* Chrome sucks */
- /*codebase:parameters.codebase,*/
- archive: parameters.archive, /* Safari sucks */
- width: width,
- height: height
- };
-
- var $applet = writeAppletTagsNew($placeholder, attributes, parameters);
-}
-
-
-/**
- *
- * @param appletId (string)
- * @param cb (function) A callback to load the applet if none is available
- * @param openWith (boolean)
- */
-function inPlaceOpen(appletId, cb, openWith)
-{
- var applet = getAppletNew(appletId);
-
- if (applet)
- {
- try
- {
- if (openWith)
- {
- debug('inPlaceOpen: invoking openWith');
- applet.openWith();
- }
- else
- {
- debug('inPlaceOpen: invoking open');
- applet.open();
- }
- }
- catch (e)
- {
- if (e.message)
- {
- debug('inPlaceOpen: error invoking applet method: ' + e.message);
- }
- throw e;
- }
- }
- else
- {
- debug('inPlaceOpen: no applet available. checking callback...');
- if (cb)
- {
- debug('inPlaceOpen: invoking callback');
- cb();
- }
- }
-}
-
-var callbackInvoked = false;
-
-function inPlaceCheckSynced(appletId, submitCallback, beingUploadedMessageCallback, changesDetectedConfirmationMessageCallback)
-{
- $('BODY').addClass('waitcursor');
-
- var doSubmit = function()
- {
- if (callbackInvoked)
- {
- debug('inPlaceCheckSynced: callback already invoked');
- }
-
- if (submitCallback && !callbackInvoked)
- {
- callbackInvoked = true;
- debug('inPlaceCheckSynced: invoking callback');
- submitCallback();
-
- //$('BODY').removeClass('waitcursor'); or not??
-
- // assumed that no user interaction can actually happen now
- callbackInvoked = false;
- }
- }
-
- var applet = getAppletNew(appletId);
-
- if (applet)
- {
- // ensures the applet is not still synchronising before the callback is invoked
- var delayedSubmit = function()
- {
- //set a timer...
- var timer = setInterval(function()
- {
- debug('inPlaceCheckSynced: timer check...');
- if (!applet.hasPendingSync())
- {
- clearInterval(timer);
- debug('inPlaceCheckSynced: no pending sync, form submitting');
- doSubmit();
- }
- }, 300);
- }
-
- if (applet.isSynchronising())
- {
- alert(beingUploadedMessageCallback());
- delayedSubmit(); //will not double submit if the user clicks twice
- return false;
- }
-
- else if (applet.hasPendingSync())
- {
- var answer = confirm(changesDetectedConfirmationMessageCallback())
- if (answer)
- {
- debug('inPlaceCheckSynced: has pending sync, invoking syncFile');
- applet.syncFile();
- delayedSubmit();
- return false;
- }
- }
- }
-
- debug('inPlaceCheckSynced: no applet available OR no pending sync OR user selected no to sync changes, simply invoking submit');
- doSubmit();
-
- return false;
-}
-
-/**
- * $editLinks is the div which holds both the edit and editWith links (as well as the applet)
- * $openWithLink the 'open with another editor' link
- */
-function initInPlace($editLinks, $openWithLink)
-{
- if (navigator.javaEnabled())
- {
- $editLinks.show();
- /*
- * Ongoing issues with Applets on the Firefox-on-Mac configuration compels us to
- * hide the "Open file with ..." function for those clients. Chrome browser on Mac seems okay with applets.
- */
- var isMac = /Mac/.test(navigator.platform);
- if (isMac && $.browser.mozilla)
- {
- $openWithLink.hide();
- }
- }
-}
diff --git a/Source/Plugins/Core/com.equella.core/resources/web/scripts/selectionsession.js b/Source/Plugins/Core/com.equella.core/resources/web/scripts/selectionsession.js
new file mode 100644
index 0000000000..14f648324b
--- /dev/null
+++ b/Source/Plugins/Core/com.equella.core/resources/web/scripts/selectionsession.js
@@ -0,0 +1,11 @@
+// This JavaScript file is dedicated to the 'Select Session' page in the new UI.
+// It triggers the `clearSelectionForSingleSelectionMode` event first and
+// then utilizes the `history.back` callback to navigate back to the 'Search' page
+// while preserving all parameters.
+function continueSelection() {
+ const form = document.getElementById("eqpageForm");
+
+ if(typeof EQ !== "undefined") {
+ EQ.postAjax(form, '.clearSelectionForSingleSelectionMode', [], () => history.back());
+ }
+}
diff --git a/Source/Plugins/Core/com.equella.core/resources/web/scripts/treenav.js b/Source/Plugins/Core/com.equella.core/resources/web/scripts/treenav.js
index 18c1478207..0634843282 100644
--- a/Source/Plugins/Core/com.equella.core/resources/web/scripts/treenav.js
+++ b/Source/Plugins/Core/com.equella.core/resources/web/scripts/treenav.js
@@ -21,42 +21,42 @@ TreeNav.prototype.nodecreated;
TreeNav.prototype.selectionChanged;
-TreeNav.prototype.initialise = function()
+TreeNav.prototype.initialise = function()
{
var tree = this;
-
+
this.lastSelectedUrl = null
-
+
this.nav = true;
this.split = false;
this.g_firstlink = null;
this.g_nextlink = null;
this.g_prevlink = null;
this.g_lastlink = null;
-
+
this.closedToggler = "images/folderclosed.gif";
this.openToggler = "images/folderopen.gif";
this.hiddenToggler = "images/hiddentoggle.gif";
-
+
//events
this.nodecreated = this.nodeCreatedCallback;
this.selectionChanged = this.selectionChangedCallback;
-
+
this.rootNodes = [];
}
TreeNav.prototype.nodeCreatedCallback = function(node)
{
- if (!node.details.url)
+ if (!node.details.url)
{
node.link.removeAttr("href");
node.label.filter("a").hide();
node.link.unbind("click");
node.nodeline.removeClass("selectable");
node.nodeline.toggleClass("file folder");
- }
- else
+ }
+ else
{
node.label.filter("span").hide();
}
@@ -65,10 +65,10 @@ TreeNav.prototype.nodeCreatedCallback = function(node)
TreeNav.prototype.selectionChangedCallback = function(old)
{
- if (this.selected)
+ if (this.selected)
{
this.lastSelectedUrl = this.selected.details.url;
- $(".content-base").attr("src", this.lastSelectedUrl);
+ $(".content-base").attr("src", this.lastSelectedUrl);
this.updateNavigation();
}
return false;
@@ -77,7 +77,7 @@ TreeNav.prototype.selectionChangedCallback = function(old)
TreeNav.prototype.select = function(node)
{
- if (node && this.selected != node)
+ if (node && this.selected != node)
{
TreeLib.clickNode(this, node);
TreeLib.ensureVisible(this, node);
@@ -87,62 +87,62 @@ TreeNav.prototype.select = function(node)
}
-TreeNav.prototype.setupTopNav = function(nextlink, prevlink, firstlink, lastlink)
+TreeNav.prototype.setupTopNav = function(nextlink, prevlink, firstlink, lastlink)
{
var tree = this;
-
+
this.g_firstlink = firstlink;
this.g_nextlink = nextlink;
this.g_prevlink = prevlink;
this.g_lastlink = lastlink;
-
- nextlink.bind("click", function (event)
+
+ nextlink.bind("click", function (event)
{
return tree.select(tree.findNext(tree.selected, false, true));
});
-
- prevlink.bind("click", function (event)
+
+ prevlink.bind("click", function (event)
{
return tree.select(tree.findPrev(tree.selected, false));
});
-
- firstlink.bind("click", function (event)
+
+ firstlink.bind("click", function (event)
{
return tree.select(tree.findNext(null, false, true));
});
-
- lastlink.bind("click", function (event)
+
+ lastlink.bind("click", function (event)
{
return tree.select(tree.findPrev(null, false));
});
-
+
this.updateNavigation();
}
-TreeNav.prototype.findNext = function(current, checkthis, intochild)
+TreeNav.prototype.findNext = function(current, checkthis, intochild)
{
- if (current == null)
+ if (current == null)
{
current = this.rootNodes[0];
checkthis = true;
intochild = true;
}
- if (checkthis && current.details.url)
+ if (checkthis && current.details.url)
{
return current;
}
- if (intochild && current.children.length > 0)
+ if (intochild && current.children.length > 0)
{
return this.findNext(current.children[0], true, true);
}
-
+
var ind = current.childIndex + 1;
var parentList = TreeLib.getParentList(this, current);
-
- if (ind >= parentList.length)
+
+ if (ind >= parentList.length)
{
- if (!current.parent)
+ if (!current.parent)
{
return null;
}
@@ -152,36 +152,36 @@ TreeNav.prototype.findNext = function(current, checkthis, intochild)
}
-TreeNav.prototype.findPrev = function(current, checkthis)
+TreeNav.prototype.findPrev = function(current, checkthis)
{
- if (current == null)
+ if (current == null)
{
current = this.rootNodes[this.rootNodes.length - 1];
- while (current.children.length > 0)
+ while (current.children.length > 0)
{
current = current.children[current.children.length - 1];
}
checkthis = true;
}
-
- if (checkthis && current.details.url)
+
+ if (checkthis && current.details.url)
{
return current;
}
-
+
var ind = current.childIndex - 1;
var parentList = TreeLib.getParentList(this, current);
- if (ind < 0)
+ if (ind < 0)
{
- if (!current.parent)
+ if (!current.parent)
{
return null;
}
return this.findPrev(current.parent, true);
}
-
+
current = parentList[ind];
- while (current.children.length > 0)
+ while (current.children.length > 0)
{
current = current.children[current.children.length - 1];
}
@@ -189,30 +189,30 @@ TreeNav.prototype.findPrev = function(current, checkthis)
}
-TreeNav.prototype.updateNavigation = function()
+TreeNav.prototype.updateNavigation = function()
{
- if (!this.g_nextlink || !this.g_prevlink)
+ if (!this.g_nextlink || !this.g_prevlink)
{
return;
}
-
- if (this.findNext(this.selected, false, true))
+
+ if (this.findNext(this.selected, false, true))
{
this.g_nextlink.removeAttr("disabled");
this.g_lastlink.removeAttr("disabled");
- }
- else
+ }
+ else
{
this.g_nextlink.attr("disabled", "disabled");
this.g_lastlink.attr("disabled", "disabled");
}
-
- if (this.findPrev(this.selected, false))
+
+ if (this.findPrev(this.selected, false))
{
this.g_prevlink.removeAttr("disabled");
this.g_firstlink.removeAttr("disabled");
- }
- else
+ }
+ else
{
this.g_prevlink.attr("disabled", "disabled");
this.g_firstlink.attr("disabled", "disabled");
@@ -237,27 +237,50 @@ function getWinHeight() {
);
}
+/**
+ * Get accurate inner width from computed style.
+ * inner width = width + padding
+ *
+ * @param selector string selector used to find the element
+ */
+const getComputedInnerWidth = (selector) => {
+ const {width, paddingLeft, paddingRight} = getComputedStyle($(selector).get(0));
+ const elements = [width, paddingLeft, paddingRight];
+ return elements.reduce((pre, cur) => parseFloat(pre) + parseFloat(cur));
+}
+
+/**
+ * Get accurate outer width from computed style.
+ * outer width = width + padding + border
+ *
+ * @param selector string selector used to find the element
+ */
+const getComputedOuterWidth = (selector) => {
+ const {width, paddingLeft, paddingRight, borderLeftWidth, borderRightWidth} = getComputedStyle($(selector).get(0));
+ const elements = [width, paddingLeft, paddingRight, borderLeftWidth, borderRightWidth];
+ return elements.reduce((pre, cur) => parseFloat(pre) + parseFloat(cur));
+}
+
function resizeContent(hideBar) {
- var newWidth = $('#pv-content').innerWidth();
+ var newWidth = getComputedInnerWidth('#pv-content');
if( !hideBar ) {
- newWidth -= $('#pv-divider').outerWidth();
+ newWidth -= getComputedOuterWidth('#pv-divider');
}
- var left = $('#pv-content-left');
- if( left.is(':visible') ) {
- newWidth -= left.outerWidth();
+ const leftSelector = "#pv-content-left"
+ if( $(leftSelector).is(':visible') ) {
+ newWidth -= getComputedOuterWidth(leftSelector);
}
- $('#pv-content-right').width(newWidth);
- //$('#content1 iframe').width(newWidth);
+ $('#pv-content-right').width(newWidth);
}
function resizeCols(resize, hideBar) {
var h = resize ? getWinHeight() : getDocHeight();
var msie = $.browser.msie ? 1 : 0;
var barSize = hideBar ? 0 : $('.navbar').height();
-
+
$('#pv-content-left').height(h - barSize - msie);
$('#pv-content-right').height(h - barSize - msie);
$('#pv-content-right-inner').height(h - barSize - msie - 3);
@@ -274,25 +297,25 @@ function positionDivider(resize, hideBar) {
var h = resize ? getWinHeight() : getDocHeight();
var msie = $.browser.msie ? 1 : 0;
var barSize = hideBar ? 0 : 33;
-
+
$('#pv-divider').css('height', h - barSize - 2 - msie);
$('#pv-divider-inner').css('height', h - barSize - 4 - msie);
}
-function initTreeNav(treedef, nextId, prevId, firstId, lastId)
-{
+function initTreeNav(treedef, nextId, prevId, firstId, lastId)
+{
var tree = new TreeNav();
-
+
tree.rootElement = $("#root");
tree.names = {open:"open", selected:"sel"};
-
+
tree.initialise();
-
+
TreeLib.addNodes(tree, null, treedef);
-
+
tree.updateNavigation();
tree.setupTopNav($("#"+nextId), $("#"+prevId), $("#"+firstId), $("#"+lastId));
tree.select(tree.findNext(null, false, false));
-
+
return tree;
}
diff --git a/Source/Plugins/Core/com.equella.core/resources/web/scripts/webdav.js b/Source/Plugins/Core/com.equella.core/resources/web/scripts/webdav.js
new file mode 100644
index 0000000000..0e81e96579
--- /dev/null
+++ b/Source/Plugins/Core/com.equella.core/resources/web/scripts/webdav.js
@@ -0,0 +1 @@
+const copyToClipboard = (str) => navigator.clipboard.writeText(str);
diff --git a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/core/jwks/WebKeySetConverter.scala b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/core/jwks/WebKeySetConverter.scala
new file mode 100644
index 0000000000..bc7531e8d8
--- /dev/null
+++ b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/core/jwks/WebKeySetConverter.scala
@@ -0,0 +1,102 @@
+/*
+ * Licensed to The Apereo Foundation under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * The Apereo Foundation licenses this file to you 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 com.tle.core.jwks
+
+import com.tle.beans.Institution
+import com.tle.beans.webkeyset.WebKeySet
+import com.tle.common.NameValue
+import com.tle.common.filesystem.handle.{SubTemporaryFile, TemporaryFileHandle}
+import com.tle.common.institution.CurrentInstitution
+import com.tle.core.guice.Bind
+import com.tle.core.institution.convert.service.AbstractJsonConverter
+import com.tle.core.institution.convert.ConverterParams
+import com.tle.core.institution.convert.service.InstitutionImportService.ConvertType
+import com.tle.core.institution.convert.service.impl.InstitutionImportServiceImpl.ConverterTasks
+import com.tle.core.webkeyset.service.WebKeySetService
+import scala.jdk.CollectionConverters._
+import javax.inject.{Inject, Singleton}
+import java.time.Instant
+
+/**
+ * Case class for the export model.
+ */
+case class WebKeySetExport(keyId: String,
+ algorithm: String,
+ publicKey: String,
+ privateKey: String,
+ created: Instant,
+ deactivated: Option[Instant])
+
+object WebKeySetExport {
+ def apply(keySet: WebKeySet): WebKeySetExport =
+ WebKeySetExport(
+ keyId = keySet.keyId,
+ algorithm = keySet.algorithm,
+ publicKey = keySet.publicKey,
+ privateKey = keySet.privateKey,
+ created = keySet.created,
+ deactivated = Option(keySet.deactivated)
+ )
+}
+
+/**
+ * This Converter is used to support
+ * 1. Exporting RSA keys to JSON files under directory 'keyset';
+ * 2. Reading the JSON files and importing the keys to the current Institution;
+ * 3. Deleting all the keys when the Institution is deleted.
+ */
+@Bind
+@Singleton
+class WebKeySetConverter extends AbstractJsonConverter[Object] {
+ final val EXPORT_FOLDER = "keyset"
+ final val ID = "Web_Key_Set"
+
+ @Inject var webKeySetService: WebKeySetService = _
+
+ override def doExport(staging: TemporaryFileHandle,
+ institution: Institution,
+ callback: ConverterParams): Unit =
+ webKeySetService.getAll.foreach(
+ keySet =>
+ json.write(new SubTemporaryFile(staging, s"${EXPORT_FOLDER}/${keySet.algorithm}"),
+ s"${keySet.id}.json",
+ WebKeySetExport(keySet)))
+
+ override def doImport(staging: TemporaryFileHandle,
+ institution: Institution,
+ params: ConverterParams): Unit = {
+ val dir = new SubTemporaryFile(staging, EXPORT_FOLDER)
+ json
+ .getFileList(dir)
+ .asScala
+ .map(json.read(dir, _, classOf[WebKeySet]))
+ .foreach(keySet => {
+ keySet.institution = CurrentInstitution.get()
+ webKeySetService.createOrUpdate(keySet)
+ })
+ }
+
+ override def doDelete(institution: Institution, callback: ConverterParams): Unit =
+ webKeySetService.deleteAll()
+
+ override def addTasks(convertType: ConvertType,
+ tasks: ConverterTasks,
+ params: ConverterParams): Unit =
+ tasks.add(new NameValue("Web key set", "web_key_set"))
+}
diff --git a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/core/lti13/LtiPlatformConverter.scala b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/core/lti13/LtiPlatformConverter.scala
new file mode 100644
index 0000000000..23440ae176
--- /dev/null
+++ b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/core/lti13/LtiPlatformConverter.scala
@@ -0,0 +1,91 @@
+/*
+ * Licensed to The Apereo Foundation under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * The Apereo Foundation licenses this file to you 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 com.tle.core.lti13
+
+import com.tle.beans.Institution
+import com.tle.common.NameValue
+import com.tle.common.filesystem.handle.{SubTemporaryFile, TemporaryFileHandle}
+import com.tle.core.guice.Bind
+import com.tle.core.institution.convert.ConverterParams
+import com.tle.core.institution.convert.service.AbstractJsonConverter
+import com.tle.core.institution.convert.service.InstitutionImportService.ConvertType
+import com.tle.core.institution.convert.service.impl.InstitutionImportServiceImpl.ConverterTasks
+import com.tle.core.lti13.bean.LtiPlatformBean
+import com.tle.core.lti13.service.LtiPlatformService
+import cats.implicits._
+import com.tle.beans.lti.LtiPlatform
+
+import scala.jdk.CollectionConverters._
+import javax.inject.{Inject, Singleton}
+
+@Bind
+@Singleton
+class LtiPlatformConverter extends AbstractJsonConverter[Object] {
+ final val EXPORT_FOLDER = "ltiplatform"
+ final val ID = "lti_platform"
+
+ @Inject var ltiPlatformService: LtiPlatformService = _
+
+ override def doExport(staging: TemporaryFileHandle,
+ institution: Institution,
+ callback: ConverterParams): Unit = {
+ def writePlatformToJson(platform: LtiPlatformBean): Unit =
+ json.write(new SubTemporaryFile(staging, s"$EXPORT_FOLDER/${platform.platformId}"),
+ s"${platform.platformId}.json",
+ LtiPlatformBean)
+
+ Either
+ .catchNonFatal {
+ ltiPlatformService.getAll.foreach(writePlatformToJson)
+ }
+ .leftMap(error =>
+ throw new RuntimeException(s"Failed to export LTI platforms: ${error.getMessage}"))
+ }
+
+ override def doImport(staging: TemporaryFileHandle,
+ institution: Institution,
+ params: ConverterParams): Unit = {
+ val dir = new SubTemporaryFile(staging, EXPORT_FOLDER)
+
+ Either
+ .catchNonFatal {
+ json
+ .getFileList(dir)
+ .asScala
+ .toList
+ .map(json.read(dir, _, classOf[LtiPlatformBean]))
+ .map(ltiPlatformService.create)
+ }
+ .leftMap(error =>
+ throw new RuntimeException(s"Failed to import LTI platforms: ${error.getMessage}"))
+ }
+
+ override def doDelete(institution: Institution, callback: ConverterParams): Unit =
+ Either
+ .catchNonFatal {
+ ltiPlatformService.deleteAll
+ }
+ .leftMap(error =>
+ throw new RuntimeException(s"Failed to delete LTI platforms: ${error.getMessage}"))
+
+ override def addTasks(convertType: ConvertType,
+ tasks: ConverterTasks,
+ params: ConverterParams): Unit =
+ tasks.add(new NameValue("LTI platform", "lti_platform"))
+}
diff --git a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/core/lti13/bean/LtiPlatformBean.scala b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/core/lti13/bean/LtiPlatformBean.scala
new file mode 100644
index 0000000000..2158416a1a
--- /dev/null
+++ b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/core/lti13/bean/LtiPlatformBean.scala
@@ -0,0 +1,196 @@
+/*
+ * Licensed to The Apereo Foundation under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * The Apereo Foundation licenses this file to you 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 com.tle.core.lti13.bean
+
+import cats.data.Validated
+import cats.implicits._
+import com.tle.beans.lti.{LtiPlatform, LtiPlatformCustomRole}
+import com.tle.common.Check
+import com.tle.common.institution.CurrentInstitution
+import com.tle.common.usermanagement.user.CurrentUser
+import com.tle.core.security.impl.AclExpressionEvaluator
+import com.tle.integration.lti13.UnknownUserHandling
+import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder}
+import java.net.URL
+import scala.jdk.CollectionConverters._
+
+/**
+ * Data structure to represent LTI Platform details.
+ *
+ * @param platformId ID of the learning platform
+ * @param name The name of the platform
+ * @param clientId Client ID provided by the platform
+ * @param authUrl The platform's authentication request URL
+ * @param keysetUrl JWKS keyset URL where to get the keys
+ * @param usernamePrefix Prefix added to the user ID from the LTI request
+ * @param usernameSuffix Suffix added to the user ID from the LTI request
+ * @param unknownUserHandling How to handle unknown users by one of the three options - ERROR, GUEST OR CREATE.
+ * @param unknownUserDefaultGroups The list of groups to be added to the user object If the unknown user handling is CREATE.
+ * @param instructorRoles A list of roles to be assigned to a LTI instructor role
+ * @param unknownRoles A list of roles to be assigned to a LTI role that is neither the instructor or in the list of custom roles
+ * @param customRoles Mappings from LTI roles to OEQ roles
+ * @param allowExpression The ACL Expression to control access from this platform
+ * @param kid The activated key pair key ID (Readonly)
+ * @param enabled `true` if the platform is enabled
+ */
+case class LtiPlatformBean(
+ platformId: String,
+ name: String,
+ clientId: String,
+ authUrl: String,
+ keysetUrl: String,
+ usernamePrefix: Option[String],
+ usernameSuffix: Option[String],
+ unknownUserHandling: String,
+ unknownUserDefaultGroups: Option[Set[String]],
+ instructorRoles: Set[String],
+ unknownRoles: Set[String],
+ customRoles: Map[String, Set[String]],
+ allowExpression: Option[String],
+ kid: Option[String],
+ enabled: Boolean
+)
+
+object LtiPlatformBean {
+ implicit val encoder = deriveEncoder[LtiPlatformBean]
+ implicit val decoder = deriveDecoder[LtiPlatformBean]
+
+ def apply(platform: LtiPlatform): LtiPlatformBean = {
+ new LtiPlatformBean(
+ platformId = platform.platformId,
+ name = platform.name,
+ clientId = platform.clientId,
+ authUrl = platform.authUrl,
+ keysetUrl = platform.keysetUrl,
+ usernamePrefix = Option(platform.usernamePrefix),
+ usernameSuffix = Option(platform.usernameSuffix),
+ unknownUserHandling = platform.unknownUserHandling,
+ unknownUserDefaultGroups = Option(platform.unknownUserDefaultGroups).map(_.asScala.toSet),
+ instructorRoles = platform.instructorRoles.asScala.toSet,
+ unknownRoles = platform.unknownRoles.asScala.toSet,
+ customRoles = platform.customRoles.asScala
+ .map(mapping => mapping.ltiRole -> mapping.oeqRoles.asScala.toSet)
+ .toMap,
+ allowExpression = Option(platform.allowExpression),
+ kid = platform.keyPairs.asScala
+ .filter(k => Option(k.deactivated).isEmpty)
+ .headOption
+ .map(_.keyId),
+ enabled = platform.enabled
+ )
+ }
+
+ // Populate all the fields of LtiPlatform.
+ def populatePlatform(platform: LtiPlatform, bean: LtiPlatformBean): LtiPlatform = {
+ platform.platformId = bean.platformId
+ platform.name = bean.name
+ platform.clientId = bean.clientId
+ platform.authUrl = bean.authUrl
+ platform.keysetUrl = bean.keysetUrl
+ platform.unknownUserHandling = bean.unknownUserHandling
+
+ platform.usernamePrefix = bean.usernamePrefix.orNull
+ platform.usernameSuffix = bean.usernameSuffix.orNull
+ platform.allowExpression = bean.allowExpression.orNull
+
+ platform.unknownRoles = bean.unknownRoles.asJava
+ platform.unknownUserDefaultGroups =
+ bean.unknownUserDefaultGroups.getOrElse(Set.empty[String]).asJava
+ platform.instructorRoles = bean.instructorRoles.asJava
+
+ platform.institution = CurrentInstitution.get()
+ platform.enabled = bean.enabled
+
+ val roleMappingUpdate = bean.customRoles
+ .map {
+ case (ltiRole, newTarget) =>
+ LtiPlatformCustomRole(ltiRole, newTarget.asJava)
+ }
+ .toSet
+ .asJava
+
+ Option(platform.customRoles) match {
+ case Some(mappings) =>
+ mappings.clear()
+ mappings.addAll(roleMappingUpdate)
+ case None =>
+ platform.customRoles = roleMappingUpdate
+ }
+
+ platform
+ }
+
+ /**
+ * Check values of mandatory fields for LtiPlatformBean and accumulate all the errors.
+ * Return a ValidatedNel which is either a list of error messages or the checked LtiPlatformBean.
+ */
+ def validateLtiPlatformBean(bean: LtiPlatformBean): Validated[List[String], LtiPlatformBean] = {
+ def checkIDs =
+ Map(
+ ("platform ID", bean.platformId),
+ ("client ID", bean.clientId),
+ ).map {
+ case (fieldName, value) =>
+ Option
+ .unless(Check.isEmpty(value))(value)
+ .toValidNel(s"Missing value for required field $fieldName")
+ }
+ .toList
+ .sequence
+
+ def checkUrls =
+ Map(
+ ("Auth URL", bean.authUrl),
+ ("Key set URL", bean.keysetUrl)
+ ).map {
+ case (fieldName, value) =>
+ Either
+ .catchNonFatal {
+ new URL(value)
+ }
+ .leftMap(err => s"Invalid value for $fieldName : ${err.getMessage}")
+ .toValidatedNel
+ }
+ .toList
+ .sequence
+
+ def checkUnknownUserHandling =
+ Either
+ .catchNonFatal(UnknownUserHandling.withName(bean.unknownUserHandling))
+ .leftMap(err => s"Unknown handling for unknown users: ${err.getMessage}")
+ .toValidatedNel
+
+ def checkACLExpression =
+ bean.allowExpression match {
+ case Some(expression) =>
+ val evaluator = new AclExpressionEvaluator
+ Either
+ // We only check whether the provided ACl Expression is valid so what User State to be used
+ // and whether the user is owner do not really matter.
+ .catchNonFatal(evaluator.evaluate(expression, CurrentUser.getUserState, false))
+ .leftMap(err => s"Invalid value for ACL expression: ${err.getMessage}")
+ .toValidatedNel
+ case None => Validated.valid()
+ }
+
+ (checkIDs, checkUrls, checkUnknownUserHandling, checkACLExpression)
+ .mapN((_, _, _, _) => bean)
+ .leftMap(_.toList)
+ }
+}
diff --git a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/core/lti13/dao/LtiPlatformDAO.scala b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/core/lti13/dao/LtiPlatformDAO.scala
new file mode 100644
index 0000000000..023b07007f
--- /dev/null
+++ b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/core/lti13/dao/LtiPlatformDAO.scala
@@ -0,0 +1,34 @@
+/*
+ * Licensed to The Apereo Foundation under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * The Apereo Foundation licenses this file to you 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 com.tle.core.lti13.dao
+
+import com.tle.beans.lti.LtiPlatform
+import com.tle.core.hibernate.dao.GenericInstitutionalDao
+
+trait LtiPlatformDAO extends GenericInstitutionalDao[LtiPlatform, java.lang.Long] {
+
+ /**
+ * Retrieve a LTI Platform by Platform ID. An exception will be thrown if more than one
+ * platforms match the ID.
+ *
+ * @param platformId Unique ID of a LTI Platform.
+ * @return Option of the LTI Platform, or None if it does not exist.
+ */
+ def getByPlatformId(platformId: String): Option[LtiPlatform]
+}
diff --git a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/core/lti13/dao/LtiPlatformDAOImpl.scala b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/core/lti13/dao/LtiPlatformDAOImpl.scala
new file mode 100644
index 0000000000..655660945e
--- /dev/null
+++ b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/core/lti13/dao/LtiPlatformDAOImpl.scala
@@ -0,0 +1,37 @@
+/*
+ * Licensed to The Apereo Foundation under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * The Apereo Foundation licenses this file to you 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 com.tle.core.lti13.dao
+
+import com.tle.beans.lti.LtiPlatform
+import com.tle.common.institution.CurrentInstitution
+import com.tle.core.guice.Bind
+import com.tle.core.hibernate.dao.{DAOHelper, GenericInstitionalDaoImpl}
+import javax.inject.Singleton
+
+@Singleton
+@Bind(classOf[LtiPlatformDAO])
+class LtiPlatformDAOImpl
+ extends GenericInstitionalDaoImpl[LtiPlatform, java.lang.Long](classOf[LtiPlatform])
+ with LtiPlatformDAO {
+
+ override def getByPlatformId(platformId: String): Option[LtiPlatform] =
+ DAOHelper.getOnlyOne(this,
+ "getByPlatformID",
+ Map("platformId" -> platformId, "institution" -> CurrentInstitution.get()))
+}
diff --git a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/core/lti13/service/LtiPlatformService.scala b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/core/lti13/service/LtiPlatformService.scala
new file mode 100644
index 0000000000..f7eaaf18ee
--- /dev/null
+++ b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/core/lti13/service/LtiPlatformService.scala
@@ -0,0 +1,85 @@
+/*
+ * Licensed to The Apereo Foundation under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * The Apereo Foundation licenses this file to you 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 com.tle.core.lti13.service
+
+import com.tle.core.lti13.bean.LtiPlatformBean
+import java.security.interfaces.RSAPrivateKey
+
+trait LtiPlatformService {
+
+ /**
+ * Retrieve a LTI platform from the current Institution by platform ID.
+ *
+ * @param platformID The ID identifying a LTI platform.
+ * @return Option of a LtiPlatformBean, or None if no platforms match the ID.
+ */
+ def getByPlatformID(platformID: String): Option[LtiPlatformBean]
+
+ /**
+ * Retrieve all the LTI platforms from the current Institution.
+ *
+ * @return List of LtiPlatformBean belonging to the current Institution.
+ */
+ def getAll: List[LtiPlatformBean]
+
+ /**
+ * Return ID of the activated RSA key pair dedicated to the platform as well as the private key.
+ *
+ * @param platformID The ID identifying a LTI platform.
+ * @return A tuple of the RSAPrivateKey and the JWKS key ID, or an error message if no such a keypair found.
+ */
+ def getPrivateKeyForPlatform(platformID: String): Either[String, (String, RSAPrivateKey)]
+
+ /**
+ * Rotate the activated key pair for an LTI platform.
+ *
+ * @param platformID The ID identifying a LTI platform.
+ * @return Key ID of the new activated key pair, or an error message describing why failed to rotate.
+ */
+ def rotateKeyPairForPlatform(platformID: String): Either[String, String]
+
+ /**
+ * Create a LTI platform based on the provided bean in the current Institution.
+ *
+ * @param bean LtiPlatformBean which provides information of a LTI platform.
+ * @return platform ID of the new platform.
+ */
+ def create(bean: LtiPlatformBean): String
+
+ /**
+ * Update an existing LTI Platform based on the provided bean.
+ *
+ * @param bean LtiPlatformBean which provides updates for an existing LTI platform.
+ * @return ID of the platform if the update is successful, or None if no such a platform can be updated.
+ */
+ def update(bean: LtiPlatformBean): Option[String]
+
+ /**
+ * Delete all the platforms from the current Institution.
+ */
+ def deleteAll: Unit
+
+ /**
+ * Delete a LTI platform from the current Institution by ID.
+ *
+ * @param platformId The ID identifying a LTI platform.
+ * @return Option of Unit to indicate the deletion is successful, or None if no such a platform can be deleted.
+ */
+ def delete(platformId: String): Option[Unit]
+}
diff --git a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/core/lti13/service/LtiPlatformServiceImpl.scala b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/core/lti13/service/LtiPlatformServiceImpl.scala
new file mode 100644
index 0000000000..a64b0385b6
--- /dev/null
+++ b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/core/lti13/service/LtiPlatformServiceImpl.scala
@@ -0,0 +1,130 @@
+/*
+ * Licensed to The Apereo Foundation under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * The Apereo Foundation licenses this file to you 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 com.tle.core.lti13.service
+
+import com.tle.beans.lti.LtiPlatform
+import com.tle.beans.webkeyset.WebKeySet
+import com.tle.core.guice.Bind
+import com.tle.core.lti13.bean.LtiPlatformBean
+import com.tle.core.lti13.bean.LtiPlatformBean.populatePlatform
+import com.tle.core.lti13.dao.LtiPlatformDAO
+import org.springframework.transaction.annotation.Transactional
+import com.tle.common.usermanagement.user.CurrentUser
+import com.tle.core.webkeyset.helper.WebKeySetHelper
+import com.tle.core.webkeyset.service.WebKeySetService
+import org.slf4j.{Logger, LoggerFactory}
+import java.security.interfaces.RSAPrivateKey
+import java.time.Instant
+import javax.inject.{Inject, Singleton}
+import scala.jdk.CollectionConverters._
+
+@Singleton
+@Bind(classOf[LtiPlatformService])
+class LtiPlatformServiceImpl extends LtiPlatformService {
+ @Inject var lti13Dao: LtiPlatformDAO = _
+ @Inject var webKeySetService: WebKeySetService = _
+
+ private var logger: Logger = LoggerFactory.getLogger(classOf[LtiPlatformServiceImpl])
+ private def log(action: String): Unit = logger.info(s"User ${CurrentUser.getUserID} $action")
+
+ private def getPlatformOrError(platformId: String): Either[String, LtiPlatform] =
+ lti13Dao.getByPlatformId(platformId).toRight(s"No LTI platform matching ID $platformId")
+
+ private def getActivatedKeyPair(platform: LtiPlatform): Either[String, WebKeySet] = {
+ val activatedKeyPairs = platform.keyPairs.asScala.filter(k => Option(k.deactivated).isEmpty)
+
+ Either.cond(
+ activatedKeyPairs.size == 1,
+ activatedKeyPairs.head,
+ s"An LTI platform must have only one activated key pair."
+ )
+ }
+
+ override def getByPlatformID(platformID: String): Option[LtiPlatformBean] =
+ lti13Dao.getByPlatformId(platformID).map(LtiPlatformBean.apply)
+
+ override def getAll: List[LtiPlatformBean] =
+ lti13Dao.enumerateAll.asScala.map(LtiPlatformBean.apply).toList
+
+ override def getPrivateKeyForPlatform(
+ platformID: String): Either[String, (String, RSAPrivateKey)] =
+ for {
+ platform <- getPlatformOrError(platformID)
+ activatedKeyPair <- getActivatedKeyPair(platform)
+ rsaPrivateKey = WebKeySetHelper
+ .buildKeyPair(activatedKeyPair)
+ .getPrivate
+ .asInstanceOf[RSAPrivateKey]
+ } yield (activatedKeyPair.keyId, rsaPrivateKey)
+
+ def rotateKeyPairForPlatform(platformID: String): Either[String, String] =
+ for {
+ platform <- getPlatformOrError(platformID)
+ activatedKeyPair <- getActivatedKeyPair(platform)
+ } yield {
+ val newActivatedKeyPair = webKeySetService.rotateKeyPair(activatedKeyPair)
+ platform.keyPairs.add(newActivatedKeyPair)
+ lti13Dao.update(platform)
+ newActivatedKeyPair.keyId
+ }
+
+ @Transactional
+ override def create(bean: LtiPlatformBean): String = {
+ log(s"creates LTI platform by ${bean.platformId}")
+
+ val newPlatform = populatePlatform(new LtiPlatform, bean)
+
+ newPlatform.keyPairs = Set(webKeySetService.generateKeyPair).asJava
+ newPlatform.dateCreated = Instant.now
+ newPlatform.createdBy = CurrentUser.getUserID
+ lti13Dao.save(newPlatform)
+
+ newPlatform.platformId
+ }
+
+ @Transactional
+ override def update(bean: LtiPlatformBean): Option[String] = {
+ val id = bean.platformId
+ log(s"updates LTI platform - $id")
+
+ lti13Dao
+ .getByPlatformId(id)
+ .map(platform => {
+ platform.dateLastModified = Instant.now
+ platform.lastModifiedBy = CurrentUser.getUserID
+ lti13Dao.update(populatePlatform(platform, bean))
+ id
+ })
+ }
+
+ @Transactional
+ override def delete(platFormId: String): Option[Unit] = {
+ log(s"Deletes LTI platform - $platFormId")
+
+ lti13Dao
+ .getByPlatformId(platFormId)
+ .map(lti13Dao.delete)
+ }
+
+ override def deleteAll: Unit = {
+ log(s"deletes all the LTI platforms")
+
+ lti13Dao.enumerateAll.asScala.foreach(lti13Dao.delete)
+ }
+}
diff --git a/Source/Plugins/Applet/com.tle.web.filemanager.applet/src/com/tle/web/filemanager/FileManagerConstants.java b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/core/webkeyset/dao/WebKeySetDAO.scala
similarity index 64%
rename from Source/Plugins/Applet/com.tle.web.filemanager.applet/src/com/tle/web/filemanager/FileManagerConstants.java
rename to Source/Plugins/Core/com.equella.core/scalasrc/com/tle/core/webkeyset/dao/WebKeySetDAO.scala
index 14911069ea..32ae5a91ec 100644
--- a/Source/Plugins/Applet/com.tle.web.filemanager.applet/src/com/tle/web/filemanager/FileManagerConstants.java
+++ b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/core/webkeyset/dao/WebKeySetDAO.scala
@@ -16,17 +16,18 @@
* limitations under the License.
*/
-package com.tle.web.filemanager;
+package com.tle.core.webkeyset.dao
-import com.tle.web.resources.ResourcesService;
+import com.tle.beans.webkeyset.WebKeySet
+import com.tle.core.hibernate.dao.GenericInstitutionalDao
-@SuppressWarnings("nls")
-public final class FileManagerConstants {
- public static final String FILEMANAGER_APPLET_JAR_URL =
- ResourcesService.getResourceHelper(FileManagerConstants.class)
- .plugUrl("com.tle.web.filemanager.applet", "filemanager.jar");
+trait WebKeySetDAO extends GenericInstitutionalDao[WebKeySet, java.lang.Long] {
- private FileManagerConstants() {
- throw new Error();
- }
+ /**
+ * Retrieve a SecurityKey by key ID.
+ *
+ * @param keyId Unique ID of the key pair.
+ * @return Option of the retrieved SecurityKey, or None if not found.
+ */
+ def getByKeyID(keyId: String): Option[WebKeySet]
}
diff --git a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/core/webkeyset/dao/WebKeySetDAOImpl.scala b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/core/webkeyset/dao/WebKeySetDAOImpl.scala
new file mode 100644
index 0000000000..dd46b1a49d
--- /dev/null
+++ b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/core/webkeyset/dao/WebKeySetDAOImpl.scala
@@ -0,0 +1,35 @@
+/*
+ * Licensed to The Apereo Foundation under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * The Apereo Foundation licenses this file to you 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 com.tle.core.webkeyset.dao
+
+import com.tle.beans.webkeyset.WebKeySet
+import com.tle.common.institution.CurrentInstitution
+import com.tle.core.guice.Bind
+import com.tle.core.hibernate.dao.DAOHelper.getOnlyOne
+import com.tle.core.hibernate.dao.GenericInstitionalDaoImpl
+import javax.inject.Singleton
+
+@Bind(classOf[WebKeySetDAO])
+@Singleton
+class WebKeySetDAOImpl
+ extends GenericInstitionalDaoImpl[WebKeySet, java.lang.Long](classOf[WebKeySet])
+ with WebKeySetDAO {
+ override def getByKeyID(keyId: String): Option[WebKeySet] =
+ getOnlyOne(this, "getByKeyID", Map("keyId" -> keyId, "institution" -> CurrentInstitution.get()))
+}
diff --git a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/core/webkeyset/helper/WebKeySetHelper.scala b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/core/webkeyset/helper/WebKeySetHelper.scala
new file mode 100644
index 0000000000..f36553adc7
--- /dev/null
+++ b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/core/webkeyset/helper/WebKeySetHelper.scala
@@ -0,0 +1,82 @@
+/*
+ * Licensed to The Apereo Foundation under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * The Apereo Foundation licenses this file to you 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 com.tle.core.webkeyset.helper
+
+import com.tle.beans.webkeyset.WebKeySet
+import com.tle.legacy.LegacyGuice
+import org.bouncycastle.util.io.pem.{PemObject, PemReader, PemWriter}
+import java.io.{StringReader, StringWriter}
+import java.security.spec.{PKCS8EncodedKeySpec, X509EncodedKeySpec}
+import java.security.{Key, KeyFactory, KeyPair, KeyPairGenerator}
+
+object WebKeySetHelper {
+ val PUBLIC_KEY_HEADER = "PUBLIC KEY"
+ val PRIVATE_KEY_HEADER = "PRIVATE KEY"
+ val RSA = "RSA"
+ val KEY_SIZE = 2048
+
+ /**
+ * Convert a Key into a string in X.509 PEM format.
+ *
+ * @param key Either a public key or private key.
+ * @param header Header to be used in the PEM format.
+ */
+ def toPEM(key: Key, header: String): String = {
+ val sw = new StringWriter
+ val pw = new PemWriter(sw)
+ pw.writeObject(new PemObject(header, key.getEncoded))
+ pw.flush()
+ pw.close()
+ sw.flush()
+ sw.toString
+ }
+
+ /**
+ * Given a WebKeySet, convert the X.509 PEM format strings into a private key and a public key
+ * and return the key pair.
+ *
+ * @param key Instance of SecurityKey where the private and public keys are saved X.509 PEM format strings.
+ */
+ def buildKeyPair(key: WebKeySet): KeyPair = {
+ def getPemContent(key: String) = {
+ val pemReader = new PemReader(new StringReader(key))
+ val pemObject = pemReader.readPemObject()
+ pemObject.getContent
+ }
+
+ val factory = KeyFactory.getInstance(key.algorithm)
+
+ val decryptedPrivateKey = LegacyGuice.encryptionService.decrypt(key.privateKey)
+ val originalPrivateKey =
+ factory.generatePrivate(new PKCS8EncodedKeySpec(getPemContent(decryptedPrivateKey)))
+
+ val publicKey = factory.generatePublic(new X509EncodedKeySpec(getPemContent(key.publicKey)))
+
+ new KeyPair(publicKey, originalPrivateKey)
+ }
+
+ /**
+ * Generate a new key pair. The cryptographic algorithm is RSA and the key size is 2048.
+ */
+ def generateRSAKeyPair: KeyPair = {
+ val generator = KeyPairGenerator.getInstance(RSA)
+ generator.initialize(KEY_SIZE)
+ generator.generateKeyPair()
+ }
+}
diff --git a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/core/webkeyset/service/WebKeySetService.scala b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/core/webkeyset/service/WebKeySetService.scala
new file mode 100644
index 0000000000..ef6b25308b
--- /dev/null
+++ b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/core/webkeyset/service/WebKeySetService.scala
@@ -0,0 +1,80 @@
+/*
+ * Licensed to The Apereo Foundation under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * The Apereo Foundation licenses this file to you 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 com.tle.core.webkeyset.service
+
+import com.tle.beans.webkeyset.WebKeySet
+import java.security.KeyPair
+
+trait WebKeySetService {
+
+ /**
+ * Retrieve a key pair by key ID.
+ *
+ * @param keyId ID of a key pair.
+ * @return KeyPair matching the provided key ID, or None if no key pair is found.
+ */
+ def getKeypairByKeyID(keyId: String): Option[KeyPair]
+
+ /**
+ * Retrieve all the key pairs, including those deactivated.
+ *
+ * @return A list of WebKeySet
+ */
+ def getAll: List[WebKeySet]
+
+ /**
+ * Generate a new key pair for the current Institution.
+ *
+ * @return The new key pair.
+ */
+ def generateKeyPair: WebKeySet
+
+ /**
+ * Retrieve all the key pairs of the current Institution and return a JWKS representing the them.
+ *
+ * @return JSON string representing the JWKS.
+ */
+ def generateJWKS: String
+
+ /**
+ * Deactivate the provided key pair and then create a new one.
+ *
+ * @return The new activated key pair.
+ */
+ def rotateKeyPair(activatedKeyPair: WebKeySet): WebKeySet
+
+ /**
+ * Delete an existing key pair by key ID.
+ *
+ * @param keyId ID of a key pair.
+ */
+ def delete(keyId: String): Unit
+
+ /**
+ * Delete all the key pairs for the current Institution.
+ */
+ def deleteAll(): Unit
+
+ /**
+ * Save the provided key pair for the current Institution. If it exists already, then update it.
+ *
+ * @param keySet Key pair to be either saved or updated.
+ */
+ def createOrUpdate(keySet: WebKeySet): Unit
+}
diff --git a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/core/webkeyset/service/WebKeySetServiceImpl.scala b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/core/webkeyset/service/WebKeySetServiceImpl.scala
new file mode 100644
index 0000000000..b96042aa4c
--- /dev/null
+++ b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/core/webkeyset/service/WebKeySetServiceImpl.scala
@@ -0,0 +1,104 @@
+/*
+ * Licensed to The Apereo Foundation under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * The Apereo Foundation licenses this file to you 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 com.tle.core.webkeyset.service
+
+import com.tle.beans.webkeyset._
+import com.tle.common.institution.CurrentInstitution
+import com.tle.core.guice.Bind
+import com.tle.core.webkeyset.dao.WebKeySetDAO
+import com.tle.core.webkeyset.helper.WebKeySetHelper._
+import com.tle.legacy.LegacyGuice
+import io.circe.syntax._
+import org.springframework.transaction.annotation.Transactional
+import java.security.KeyPair
+import java.security.interfaces.RSAPublicKey
+import java.time.Instant
+import java.util.{Base64, UUID}
+import javax.inject.{Inject, Singleton}
+import scala.jdk.CollectionConverters._
+
+@Singleton
+@Bind(classOf[WebKeySetService])
+class WebKeySetServiceImpl extends WebKeySetService {
+ @Inject var webKeySetDAO: WebKeySetDAO = _
+
+ def getKeypairByKeyID(keyId: String): Option[KeyPair] =
+ webKeySetDAO.getByKeyID(keyId).map(buildKeyPair)
+
+ def getAll: List[WebKeySet] = webKeySetDAO.enumerateAll.asScala.toList
+
+ @Transactional
+ def generateKeyPair: WebKeySet = {
+ val keyPair = generateRSAKeyPair
+
+ val securityKey = new WebKeySet
+ securityKey.keyId = UUID.randomUUID().toString
+ securityKey.algorithm = RSA
+ securityKey.created = Instant.now
+ securityKey.privateKey =
+ LegacyGuice.encryptionService.encrypt(toPEM(keyPair.getPrivate, PRIVATE_KEY_HEADER))
+ securityKey.publicKey = toPEM(keyPair.getPublic, PUBLIC_KEY_HEADER)
+ securityKey.institution = CurrentInstitution.get()
+
+ webKeySetDAO.save(securityKey)
+ securityKey
+ }
+
+ @Transactional
+ def delete(keyId: String): Unit = webKeySetDAO.getByKeyID(keyId).foreach(webKeySetDAO.delete)
+
+ @Transactional
+ def deleteAll(): Unit = getAll.foreach(webKeySetDAO.delete)
+
+ @Transactional
+ def rotateKeyPair(activatedKeyPair: WebKeySet): WebKeySet = {
+ activatedKeyPair.deactivated = Instant.now
+ webKeySetDAO.update(activatedKeyPair)
+ generateKeyPair
+ }
+
+ @Transactional
+ def generateJWKS: String = {
+ def base64UrlEncode(bytes: Array[Byte]): String = Base64.getUrlEncoder.encodeToString(bytes)
+ def exponent(key: RSAPublicKey) = base64UrlEncode(key.getPublicExponent.toByteArray)
+ // According to spec RFC7518
,
+ // if the value of 'modulus' has a prefix of a zero-valued octet, the extra octet must be
+ // omitted before encoding . In Java, using `BigInteger.toByteArray()` will result in such a prefix.
+ // So we must drop the first element.
+ def modulus(key: RSAPublicKey) = base64UrlEncode(key.getModulus.toByteArray.drop(1))
+ def buildJWK(keyPair: WebKeySet) = {
+ val publicKey = buildKeyPair(keyPair).getPublic.asInstanceOf[RSAPublicKey]
+ JsonWebKey(kty = JWKKeyType.RSA,
+ e = exponent(publicKey),
+ n = modulus(publicKey),
+ kid = keyPair.keyId,
+ alg = JWKAlg.RS256,
+ use = JWKUse.sig)
+ }
+
+ def publicKeys =
+ getAll
+ .map(buildJWK)
+ .toArray
+
+ JsonWebKeySet(publicKeys).asJson.spaces2
+ }
+
+ def createOrUpdate(keySet: WebKeySet): Unit = webKeySetDAO.saveOrUpdate(keySet)
+}
diff --git a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/integration/lti13/Lti13AuthService.scala b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/integration/lti13/Lti13AuthService.scala
new file mode 100644
index 0000000000..9e81648691
--- /dev/null
+++ b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/integration/lti13/Lti13AuthService.scala
@@ -0,0 +1,599 @@
+/*
+ * Licensed to The Apereo Foundation under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * The Apereo Foundation licenses this file to you 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 com.tle.integration.lti13
+
+import com.auth0.jwk.{Jwk, JwkProvider, JwkProviderBuilder}
+import com.auth0.jwt.JWT
+import com.auth0.jwt.algorithms.Algorithm
+import com.auth0.jwt.interfaces.DecodedJWT
+import com.google.common.cache.{Cache, CacheBuilder, CacheLoader}
+import com.google.common.hash.HashCode
+import com.google.common.hash.Hashing.murmur3_128
+import com.tle.beans.Institution
+import com.tle.beans.user.TLEUser
+import com.tle.common.institution.CurrentInstitution
+import com.tle.common.usermanagement.user.{UserState, WebAuthenticationDetails}
+import com.tle.core.guice.Bind
+import com.tle.core.institution.{InstitutionCache, InstitutionService, RunAsInstitution}
+import com.tle.core.lti13.service.LtiPlatformService
+import com.tle.core.security.impl.AclExpressionEvaluator
+import com.tle.core.services.user.UserService
+import com.tle.core.usermanagement.standard.service.{TLEGroupService, TLEUserService}
+import com.tle.exceptions.UsernameNotFoundException
+import com.tle.integration.lti13.{Lti13Params => LTI13, OpenIDConnectParams => OIDC}
+import io.lemonlabs.uri.{QueryString, Url}
+import org.slf4j.LoggerFactory
+
+import java.net.{URI, URL}
+import java.security.interfaces.RSAPublicKey
+import java.util.concurrent.TimeUnit
+import javax.inject.{Inject, Singleton}
+import scala.annotation.tailrec
+import scala.jdk.CollectionConverters._
+import scala.util.{Failure, Success, Try}
+
+/**
+ * Captures the parameters which make up the first part of a 'Third-party Initiated Login' as part
+ * of an 'OpenID Connect Launch Flow' which is detailed in section 5.1.1 of version 1.1 of the
+ * 1EdTech Security Framework. The first three values (`iss`, `login_hint` and `target_link_uri`)
+ * are specified in the Security Framework, where as the remaining additional parameters are part
+ * of the LTI 1.3 specification - section 4.1 Additional login parameters.
+ *
+ * @param iss REQUIRED. The issuer identifier identifying the learning platform.
+ * @param login_hint 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.
+ * @param target_link_uri REQUIRED. The actual end-point that should be executed at the end of the
+ * OpenID Connect authentication flow.
+ * @param lti_message_hint The new optional parameter lti_message_hint may be used alongside the
+ * login_hint to carry information about the actual LTI message that is
+ * being launched.
+ * @param lti_deployment_id The new optional parameter lti_deployment_id that if included, MUST
+ * contain the same deployment id that would be passed in the
+ * https://purl.imsglobal.org/spec/lti/claim/deployment_id claim for the
+ * subsequent LTI message launch.
+ * @param client_id The new optional parameter client_id specifies the client id for the
+ * authorization server that should be used to authorize the subsequent LTI message
+ * request. This allows for a platform to support multiple registrations from a
+ * single issuer, without relying on the initiate_login_uri as a key.
+ */
+case class InitiateLoginRequest(iss: String,
+ login_hint: String,
+ target_link_uri: URI,
+ lti_message_hint: Option[String],
+ lti_deployment_id: Option[String],
+ client_id: Option[String])
+object InitiateLoginRequest {
+ def apply(params: Map[String, Array[String]]): Option[InitiateLoginRequest] = {
+ val param = getParam(params)
+ val uriParam = getUriParam(param)
+
+ for {
+ iss <- param(Lti13Params.ISSUER)
+ login_hint <- param(Lti13Params.LOGIN_HINT)
+ target_link_uri <- uriParam(Lti13Params.TARGET_LINK_URI)
+ lti_message_hint = param(Lti13Params.LTI_MESSAGE_HINT)
+ lti_deployment_id = param(Lti13Params.LTI_DEPLOYMENT_ID)
+ client_id = param(Lti13Params.CLIENT_ID)
+ } yield
+ InitiateLoginRequest(iss,
+ login_hint,
+ target_link_uri,
+ lti_message_hint,
+ lti_deployment_id,
+ client_id)
+ }
+}
+
+/**
+ * Captures the parameters which make up the third step of a 'Third-party Initiated Login' as part
+ * of an 'OpenID Connect Launch Flow' which is detailed in section 5.1.1 of version 1.1 of the
+ * 1EdTech Security Framework. These values are ultimately the authentication details to be
+ * validated by openEQUELLA to establish a user session.
+ *
+ * These parameters are the standard ones outlined in section 3.2.2.5 (Successful Authentication
+ * Response) of the OpenID Connect Core 1.0 specification.
+ *
+ * @param state OAuth 2.0 state value. REQUIRED if the state parameter is present in the
+ * Authorization Request. Clients MUST verify that the state value is equal to the
+ * value of state parameter in the Authorization Request.
+ * @param id_token REQUIRED. ID Token. (As in OAuth 2.0 ID Token.)
+ */
+case class AuthenticationResponse(state: String, id_token: String)
+object AuthenticationResponse {
+ def apply(params: Map[String, Array[String]]): Option[AuthenticationResponse] = {
+ val param = getParam(params)
+
+ for {
+ state <- param(OpenIDConnectParams.STATE)
+ id_token <- param(OpenIDConnectParams.ID_TOKEN)
+ } yield AuthenticationResponse(state, id_token)
+ }
+}
+
+/**
+ * Represents an LTI User's details.
+ *
+ * @param platformId the ID of the LTI Platform from which the user is from
+ * @param userId a stable locally unique (to the platform) identifier - typically supplied in a
+ * `sub` claim
+ * @param roles supplied in the 'roles claim' which is defined as: The required https://purl.imsglobal.org/spec/lti/claim/roles
+ * claim's value contains a (possibly empty) array of URI values for roles that the
+ * user has within the message's associated context.
+ *
+ * If this list is not empty, it MUST contain at least one role from the role
+ * vocabularies described in role vocabularies.
+ * @param firstName the users first name (OIDC 'given_name') - TLEUser requires one of these, so we
+ * make it mandatory
+ * @param lastName the users last name (OIDC 'family_name') - TLEUser requires one of these, so we
+ * make it mandatory
+ * @param email the users email address (OIDC 'email') - TLEUser allows this to be null, so hence we
+ * treat it as optional with Option.
+ */
+case class UserDetails(platformId: String,
+ userId: String,
+ roles: List[String],
+ firstName: String,
+ lastName: String,
+ email: Option[String])
+object UserDetails {
+ def apply(jwt: DecodedJWT): Either[InvalidJWT, UserDetails] = {
+ val claim = getClaim(jwt)
+
+ for {
+ platformId <- Option(jwt.getIssuer)
+ .toRight(
+ InvalidJWT(
+ "No issuer claim provided in the JWT, unable to determine origin LTI platform."))
+ userId <- Option(jwt.getSubject)
+ .toRight(InvalidJWT("No subject claim provided in JWT, unable to determine user id."))
+ emptyRoles = List() // helper for readability
+ // Attempt to get the claim with the roles. However it could be absent or empty
+ // in which case we just go with an 'empty' list of roles. (There is also the case where
+ // perhaps the claim is in a format other than we expect, so catch that error situation.)
+ roles <- Option(jwt.getClaim(Lti13Claims.ROLES))
+ .filterNot(_.isNull)
+ .map(c =>
+ Try {
+ Option(c.asList(classOf[String])).map(_.asScala.toList)
+ } match {
+ case Failure(_) => Left(InvalidJWT("Provided roles claim is not a valid format."))
+ case Success(value) => Right(value.getOrElse(emptyRoles))
+ })
+ .getOrElse(Right(emptyRoles))
+
+ // Get the additional identify information - used to later setup a TLEUser. The LMS may need
+ // to have configuration set to include these in the result. (For example, in Moodle you
+ // need to go into the 'Privacy' settings for the External Tool.)
+ firstName <- claim(OIDC.GIVEN_NAME)
+ .toRight(InvalidJWT(s"No first name (${OIDC.GIVEN_NAME}) was provided."))
+ lastName <- claim(OIDC.FAMILY_NAME)
+ .toRight(InvalidJWT(s"No last name (${OIDC.FAMILY_NAME}) was provided."))
+ email = claim(OIDC.EMAIL)
+ } yield UserDetails(platformId, userId, roles, firstName, lastName, email)
+ }
+}
+
+/**
+ * Responsible for the authentication of LTI requests, and where necessary establishing an
+ * authenticated openEQUELLA user session (with `UserState`) for an LTI user.
+ */
+@Bind
+@Singleton
+class Lti13AuthService {
+ private val LOGGER = LoggerFactory.getLogger(classOf[Lti13AuthService])
+
+ @Inject private var nonceService: Lti13NonceService = _
+ @Inject private var platformService: LtiPlatformService = _
+ @Inject private var runAs: RunAsInstitution = _
+ @Inject private var stateService: Lti13StateService = _
+ @Inject private var tleGroupService: TLEGroupService = _
+ @Inject private var tleUserService: TLEUserService = _
+ @Inject private var userService: UserService = _
+
+ /**
+ * The JWK Provider Cache is here to cache instances of JWK Providers for each keyset URL
+ * (for an institution). Doing so enables the code to benefit from the internal caching features
+ * of `JwkProvider` (instead of recreating each time) while not keeping around stale instances
+ * (such as if we just store them in a simple `TrieMap`.
+ *
+ * The key benefit being that the JWKS endpoint of the platforms will get queried a reduced number
+ * of times. This also speeds up the JWT validation process - and thereby faster launches.
+ */
+ private var jwkProviderCache: InstitutionCache[Cache[String, JwkProvider]] = _
+
+ @Inject
+ def setupJwkProviderCache(institutionService: InstitutionService): Unit =
+ jwkProviderCache = institutionService.newInstitutionAwareCache(
+ CacheLoader.from(
+ (_: Institution) =>
+ CacheBuilder
+ .newBuilder()
+ .maximumSize(10)
+ .expireAfterAccess(1, TimeUnit.DAYS)
+ .build[String, JwkProvider]()))
+
+ private def getJwkProvider(jwksUrl: URL): Either[ServerError, JwkProvider] =
+ Try(
+ jwkProviderCache.getCache.get(
+ jwksUrl.toString,
+ () => {
+ LOGGER.debug(s"Creating new JwkProvider($jwksUrl)")
+ // The default cache for the JwkProvider is size 5 and 10 hours, but 10 hours seems rather
+ // long to wait for any issues to be resolved - so reduced to 1 hour.
+ new JwkProviderBuilder(jwksUrl).cached(5, 1, TimeUnit.HOURS).build()
+ }
+ )).toEither.left.map(t =>
+ ServerError(
+ s"Failed to establish key (JWK) provider to validate JWT signature: ${t.getMessage}"))
+
+ /**
+ * In response to a Third-Party Initiated Login, create the resulting URL which the UA should be
+ * redirected so that the Authentication process can begin.
+ *
+ * @return a URL containing the query params as per section 5.1.1.2 of the LTI 1.3 spec
+ */
+ def buildAuthReqUrl(initReq: InitiateLoginRequest): Option[String] = {
+ for {
+ platformDetails <- getPlatform(initReq.iss)
+ authUrl <- Url.parseOption(platformDetails.authUrl.toString)
+ lti_message_hint <- initReq.lti_message_hint
+ state = stateService.createState(
+ Lti13StateDetails(initReq.iss, initReq.login_hint, initReq.target_link_uri))
+ } yield
+ authUrl
+ .withQueryString(
+ QueryString.fromPairs(
+ OIDC.SCOPE -> OIDC.SCOPE_OPENID,
+ OIDC.RESPONSE_TYPE -> OIDC.RESPONSE_TYPE_ID_TOKEN,
+ OIDC.CLIENT_ID -> platformDetails.clientId,
+ OIDC.REDIRECT_URI -> getRedirectUri.toString,
+ LTI13.LOGIN_HINT -> initReq.login_hint,
+ OIDC.STATE -> state,
+ LTI13.RESPONSE_MODE -> LTI13.RESPONSE_MODE_FORM_POST,
+ LTI13.NONCE -> nonceService.createNonce(state),
+ LTI13.PROMPT -> LTI13.PROMPT_NONE,
+ LTI13.LTI_MESSAGE_HINT -> lti_message_hint
+ ))
+ .toString()
+ }
+
+ /**
+ * Given a previously established `state` with a freshly received ID Token (JWT) will attempt
+ * to verify the token inline with the guidance in section 5.1.3 (Authentication Response
+ * Validation) of the 1EdTech Security Framework (version 1.1).
+ *
+ * See:
+ *
+ * @param state the value of the `state` param sent across in an authentication request which
+ * is expected to have been provided from the server in a previous login init
+ * request.
+ * @param token an 'ID Token' in JWT format
+ * @return Either a error string detailing how things failed, or the actual decoded JWT and details of the LTI platform.
+ */
+ def verifyToken(state: String,
+ token: String): Either[Lti13Error, (DecodedJWT, PlatformDetails)] = {
+ val result = for {
+ // -- first, basic validation of the state
+ // Short circuit if this isn't even a state we know about
+ stateDetails <- stateService
+ .getState(state)
+ .toRight(InvalidState(s"Invalid state provided: $state"))
+ // Decode the token to validate the platform this is for
+ decodedToken <- Try(JWT.decode(token)).toEither.left.map(t =>
+ InvalidJWT(s"Failed to decode token: ${t.getMessage}"))
+ // Validate the platform by ensure it matches the previous state we setup
+ platformId <- Option(stateDetails.platformId)
+ .filter(decodedToken.getIssuer.equals)
+ .toRight(InvalidJWT(s"Issuer in token did not match stored state."))
+
+ // -- next, validation of the actual JWT
+ platform <- getPlatform(platformId)
+ .toRight(PlatformDetailsError(s"Unable to retrieve platform details of ${platformId}"))
+ jwkProvider <- getJwkProvider(platform.keysetUrl)
+ // Setup some helper functions
+ // Both are: DecodedJWT -> Either[String, DecodedJWT]
+ verifyJwt = buildJwtVerifierForPlatform(jwkProvider.get(decodedToken.getKeyId), platform)
+ verifyNonce = (jwt: DecodedJWT) =>
+ getRequiredClaim(jwt, OIDC.NONCE)
+ .flatMap(
+ // If the nonce is valid, then make sure we return the valid JWT
+ nonceService.validateNonce(_, state).map(_ => jwt))
+ .left
+ .map(err => InvalidJWT(s"Provided ID token (JWT) failed nonce verification: ${err}"))
+ // now finally do the verification
+ verifiedToken <- verifyJwt(decodedToken).flatMap(verifyNonce)
+ } yield (verifiedToken, platform)
+
+ // And the result is:
+ result
+ }
+
+ /**
+ * Given the details for a user authenticating via LTI (in `userDetails`) attempt to establish a
+ * session for them. The result (on success) will be a new `UserState` instance that will be
+ * stored against the user's session.
+ *
+ * @param wad the details of the HTTP request for this authentication attempt
+ * @param userDetails the details retrieved from an attempted LTI authentication
+ * @return a new `UserState` being used for the new session OR a string representing what failed.
+ */
+ def loginUser(wad: WebAuthenticationDetails,
+ userDetails: UserDetails): Either[Lti13Error, UserState] = {
+ LOGGER.debug(s"loginUser(${userDetails})")
+
+ val loginResult = for {
+ platformDetails <- getPlatform(userDetails.platformId)
+ .toRight(
+ PlatformDetailsError(s"Unable to retrieve platform details of ${userDetails.platformId}"))
+
+ // Setup the user - including adding roles
+ userState <- mapUser(
+ user = userDetails,
+ platform = platformDetails,
+ authenticate = username => Try(userService.authenticateAsUser(username, wad)),
+ asGuest = () => userService.authenticateAsGuest(wad)
+ ).left.map(error => {
+ LOGGER.error(
+ s"Failed to authenticate user ${userDetails.userId} from platform ${userDetails.platformId}: ${error}")
+ error
+ })
+ _ = addRolesToUser(userState, userDetails, platformDetails)
+
+ // And check against any ACLExpression
+ allowedUserState <- platformDetails.allowExpression match {
+ case Some(expression) =>
+ val aclExpressionEvaluator = new AclExpressionEvaluator
+ if (!aclExpressionEvaluator.evaluate(expression, userState, false))
+ Left(NotAuthorized(
+ s"User ${userDetails.userId} from platform ${userDetails.platformId} is currently not permitted access: ACL Expression violation"))
+ else Right(userState)
+ case None => Right(userState)
+ }
+ } yield {
+ userService.login(allowedUserState, true)
+ allowedUserState
+ }
+
+ loginResult
+ }
+
+ def getRedirectUri: URI =
+ new URI(s"${CurrentInstitution.get().getUrl}lti13/launch")
+
+ private def buildJwtVerifierForPlatform(
+ jwk: Jwk,
+ platform: PlatformDetails): DecodedJWT => Either[Lti13Error, DecodedJWT] = {
+ val verifier = Try {
+ // Section 5.1.3 of the Security Framework says that RS256 SHOULD be used - but there are
+ // some others which are allowed as per the 'best practices'. Perhaps we should add code
+ // to determine the others and use them too.
+ // Best practices: https://www.imsglobal.org/spec/security/v1p1#approved-jwt-signing-algorithms
+ val alg = Algorithm.RSA256(jwk.getPublicKey.asInstanceOf[RSAPublicKey], null)
+ JWT
+ .require(alg)
+ // The issuer has kind of been validated already above - so that we could get the platform
+ // ID to be able to get the JWKS URL. But we might as well explicitly validate it as part
+ // of the JWT validation - as that's what you're meant to do.
+ .withIssuer(platform.platformId)
+ .withAnyOfAudience(platform.clientId)
+ .build()
+ }.toEither.left.map(t => ServerError(s"Failed to initialise a JWT verifier: ${t.getMessage}"))
+
+ (decodedToken: DecodedJWT) =>
+ verifier.flatMap(
+ v =>
+ Try(v.verify(decodedToken)).toEither.left
+ .map(t => InvalidJWT(s"Provided ID token (JWT) failed verification: ${t.getMessage}")))
+ }
+
+ /**
+ * Given `UserDetails` from an LTI (OAuth2) ID Token, and the configuration details for the
+ * platform (`PlatformDetails`) attempt to 'map' the LTI User to an openEQUELLA `UserState`.
+ * Depending on the platform configuration for unknown user handling, this could be a 'Guest'
+ * `UserState`, an existing user's `UserState`, or a newly created user's `UserState`. Any errors
+ * during this process will be returned as a `Left[String]`, including if the platform's
+ * configuration for 'unknown user handling' is to return an authentication error.
+ *
+ * @param user the details of a user from an 'ID token' to be mapped
+ * @param platform the configuration details of the platform from which the user is
+ * authenticating
+ * @param authenticate a function which given an openEQUELLA username, will return a matching
+ * `UserState`
+ * @param asGuest a function which can produce a `UserState` representing a guest user
+ * @return Will return a `UserState` representing the provided `user` adhering to the
+ * configuration of the `platform`. Or on error, will return a `Left[String]` of what the
+ * issue was.
+ */
+ private def mapUser(user: UserDetails,
+ platform: PlatformDetails,
+ authenticate: String => Try[UserState],
+ asGuest: () => UserState): Either[Lti13Error, UserState] = {
+ val ltiUserId = user.userId
+ // The username which will be seen and used in the system
+ val username = platform.usernamePrefix.getOrElse("") + ltiUserId + platform.usernameSuffix
+ .getOrElse("")
+
+ def handleUnknownUser(): Either[Lti13Error, UserState] = {
+ // A unique ID for the user in the oEQ DB - not used elsewhere for authentication, but we
+ // need to meeting existing requirements of the tle_user table.
+ val oeqUserId = genId(platform.platformId, ltiUserId)
+ val (unknownUserHandling, unknownUserDefaultGroups) = platform.unknownUserHandling
+
+ unknownUserHandling match {
+ case UnknownUserHandling.ERROR =>
+ Left(AccessDenied(s"Failed to authenticate (Unknown User)"))
+ case UnknownUserHandling.GUEST => Right(asGuest())
+ case UnknownUserHandling.CREATE =>
+ LOGGER.info(s"Creating new user $username($oeqUserId).")
+ // - Create a TLEUser
+ val newUser = new TLEUser()
+ newUser.setUuid(oeqUserId)
+ newUser.setUsername(username)
+ newUser.setFirstName(user.firstName)
+ newUser.setLastName(user.lastName)
+ user.email.foreach(newUser.setEmailAddress)
+ newUser.setPassword(generateRandomHexString(20))
+ // - Add the TLEUser, and set them up with any configured groups
+ runAs.executeAsSystem(
+ CurrentInstitution.get(),
+ () => {
+ // TODO: Could use some error handling - although, the old LTI code didn't for some reason
+ LOGGER.debug(s"tleUserService.add(${newUser.getUsername})")
+ tleUserService.add(newUser)
+ unknownUserDefaultGroups.map(_.foreach(groupId => {
+ LOGGER.debug(s"tleGroupService.addUserToGroup($groupId, ${newUser.getUuid})")
+ tleGroupService.addUserToGroup(groupId, newUser.getUuid)
+ }))
+ }
+ )
+
+ // We have a new user, so establish UserState for them
+ authenticate(username).toEither.left.map(t => {
+ LOGGER.error(
+ s"Failed to authenticate with newly created LTI 1.3 user - $username($oeqUserId): ${t.getMessage}")
+ ServerError(s"Failed to authenticate as newly created user: $username")
+ })
+ }
+ }
+
+ authenticate(username) match {
+ case Failure(_: UsernameNotFoundException) => handleUnknownUser()
+ case Failure(exception) => Left(ServerError(exception.getMessage))
+ case Success(userState) => Right(userState)
+ }
+ }
+
+ /**
+ * Inspects the roles in the provided `UserDetails` for the standard LIS (v2) context role
+ * identifying the user as an 'Instructor'. Does _not_ support 'simple names' as this method
+ * is considered deprecated and so "by best practice, vendors should use the full URIs for all
+ * roles (context roles included)".
+ *
+ * @param userDetails the user to check for the instructor role
+ * @return `true` if the target user is an instructor
+ */
+ private def isInstructor(userDetails: UserDetails): Boolean =
+ userDetails.roles.exists(Lti13Claims.instructorRolePredicate)
+
+ /**
+ * Based on the details of the user (`userDetails`) add the configuration for the `platform`, add
+ * the required roles to the provided `userState` - i.e. mutate it in place.
+ *
+ * @param userState the target UserState to have the roles added to
+ * @param userDetails the details of the user to determine which roles should be added
+ * @param platform configuration of the platform which includes the role configuration
+ */
+ private def addRolesToUser(userState: UserState,
+ userDetails: UserDetails,
+ platform: PlatformDetails): Unit = {
+ // 1. Determine instructor roles - if instructor
+ val instructorRoles = if (isInstructor(userDetails)) platform.instructorRoles else Set.empty
+
+ // 2. Determine custom roles - if matching roles are on user
+ val customRoles = platform.customRoles
+ .filter({
+ case (role, _) => userDetails.roles.contains(role)
+ })
+ .flatMap {
+ case (_, oeqRoles) => oeqRoles
+ }
+ .toSet
+
+ // 3. Determine if additional roles are needed - if any were unknown
+ val hasUnknownRoles = userDetails.roles
+ .filterNot(Lti13Claims.instructorRolePredicate)
+ .filterNot(platform.customRoles.contains)
+ .nonEmpty
+ val additionalRoles = if (hasUnknownRoles) platform.unknownRoles else Set.empty
+
+ // Finally - update the UserState with all the values
+ // This is horrible (using a getter to access an internal object to mutate) - however this
+ // is the official way it's done with oEQ UserState management.
+ userState.getUsersRoles.addAll(
+ (instructorRoles ++ customRoles ++ additionalRoles).asJavaCollection)
+ }
+
+ private def getPlatform(platform: String): Option[PlatformDetails] =
+ platformService.getByPlatformID(platform).filter(_.enabled) match {
+ case Some(bean) => Option(PlatformDetails(bean))
+ case None =>
+ LOGGER.error(s"Attempt to access unauthorised platform $platform")
+ None
+ }
+
+ /**
+ * Generates a unique structured identifier for the provided user from the specified platform.
+ * The identifier has the structure of `LTI13:_` which is further explained
+ * as:
+ *
+ * - `platformid` is the first 10 characters of a base64 representation of a hash, with
+ * the intention that this should be unique enough to identify the platform from other
+ * platforms - similar to how short hashes are used in git.
+ * - `userid` is a 128bit hash of `userId` which is represented in base 64 format.
+ *
+ * NOTE: Above where base64 is referred to this is in a numerical encoding sense, not a byte
+ * encoding sense as often used on the internet.
+ *
+ * @param platformId the id of the platform within which `userId` is relevant
+ * @param userId a user id specific to `platformId`
+ * @return an (ideally) unique structured ID that will fit within 40 characters.
+ */
+ private def genId(platformId: String, userId: String): String = {
+
+ /**
+ * Given a hash (which after all is a number represented by an array of bytes), generate a base
+ * 64 representation of that number. This implementation is not planned to be a generic base 64
+ * representation method, and so has not been properly validated - only validated within this
+ * context.
+ *
+ * @param hashCode the hash to be represented in base 64
+ * @return `hashCode` as a base 64 number
+ */
+ def base64HashCode(hashCode: HashCode): String = {
+ val base = 64
+ val lookup = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz+/"
+ // The hashcode byte array is prefixed with a zero byte to force it positive
+ val bigIntHashCode = new BigInt(
+ new java.math.BigInteger(Array[Byte](0x00) ++ hashCode.asBytes()))
+
+ @tailrec
+ def enc(x: BigInt, result: String): String = {
+ if (x > 0) enc(x / base, s"${lookup.charAt(x.mod(base).intValue)}" + result)
+ else result
+ }
+
+ enc(bigIntHashCode, "")
+ }
+
+ // Only a hash for uniqueness is required, not for security.
+ // So we go with the NCH algorithm murmur3.
+ def base64Murmur3(input: String): String = {
+ val hash = murmur3_128().hashBytes(input.getBytes)
+ base64HashCode(hash)
+ }
+
+ val pid = base64Murmur3(platformId)
+ val uid = base64Murmur3(userId)
+
+ // To keep things short, we only use the first 10 bytes for the platform id - that should be
+ // unique enough.
+ s"LTI13:${pid.substring(0, 10)}_$uid"
+ }
+}
diff --git a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/integration/lti13/Lti13Claims.scala b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/integration/lti13/Lti13Claims.scala
new file mode 100644
index 0000000000..2d6c6ee90b
--- /dev/null
+++ b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/integration/lti13/Lti13Claims.scala
@@ -0,0 +1,101 @@
+/*
+ * Licensed to The Apereo Foundation under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * The Apereo Foundation licenses this file to you 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 com.tle.integration.lti13
+
+/**
+ * OAuth 2.0 claims specific to LTI 1.3.
+ */
+object Lti13Claims {
+
+ /**
+ * Contains a (possibly empty) array of URI values for roles that the user has within the
+ * message's associated context.
+ *
+ * If this list is not empty, it MUST contain at least one role from the role vocabularies
+ * described in role vocabularies.
+ *
+ * If the sender of the message wants to include a role from another vocabulary namespace, by best
+ * practice it should use a fully-qualified URI to identify the role. By best practice, systems
+ * should not use roles from another role vocabulary, as this may limit interoperability.
+ */
+ val ROLES = "https://purl.imsglobal.org/spec/lti/claim/roles"
+
+ /**
+ * Contains a string value to provide a fully qualified URL which a tool provider must redirect the workflow to
+ * once the resource selection is completed.
+ */
+ val TARGET_LINK_URI = "https://purl.imsglobal.org/spec/lti/claim/target_link_uri"
+
+ /**
+ * Contains a string value to specify which kind of the message type is.
+ */
+ val MESSAGE_TYPE = "https://purl.imsglobal.org/spec/lti/claim/message_type"
+
+ /**
+ * Contains a key-value map which provides a list of custom properties configured for a platform.
+ * Map values must be strings, and "empty-string" is a valid value. However, null is not valid.
+ */
+ val CUSTOM_PARAMETERS = "https://purl.imsglobal.org/spec/lti/claim/custom"
+
+ /**
+ * Contains a list of deep linking settings for a platform as a JSON string.
+ */
+ val DEEP_LINKING_SETTINGS = "https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings"
+
+ /**
+ * Contains a case-sensitive string that identifies the platform-tool integration governing
+ * the message. It MUST NOT exceed 255 ASCII characters in length.
+ */
+ val DEPLOYMENT_ID = "https://purl.imsglobal.org/spec/lti/claim/deployment_id"
+
+ /**
+ * Contains an opaque value which must be returned by the tool in its response if it was passed in on the request.
+ */
+ val DATA = "https://purl.imsglobal.org/spec/lti-dl/claim/data"
+
+ /**
+ * Contains a JSON array of selected content items. An empty array or the absence of this claim indicates there
+ * should be no item added as a result of this interaction. This claim is optional.
+ */
+ val CONTENT_ITEMS = "https://purl.imsglobal.org/spec/lti-dl/claim/content_items"
+
+ /**
+ * Contains a string that indicates the version of LTI to which the message conforms.
+ * For conformance with LTI 1.3 specification, the claim must have the value 1.3.0.
+ */
+ val VERSION = "https://purl.imsglobal.org/spec/lti/claim/version"
+
+ /**
+ * Contains a JSON object to provide properties for the context from within which the resource link launch occurs.
+ */
+ val CONTEXT = "https://purl.imsglobal.org/spec/lti/claim/context"
+
+ /**
+ * Checks the provided role for the standard LIS (v2) context role identifying a role claim for an
+ * 'Instructor'. Does _not_ support 'simple names' as this method is considered deprecated and so
+ * "by best practice, vendors should use the full URIs for all roles (context roles included)".
+ *
+ * @param role a role claim to be inspected
+ * @return `true` if the claim is for an Instructor
+ */
+ def instructorRolePredicate(role: String): Boolean =
+ role.contains("http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor") ||
+ // support instructor context sub roles too - https://www.imsglobal.org/spec/lti/v1p3/#context-sub-roles
+ "http://purl.imsglobal.org/vocab/lis/v2/membership/Instructor#.+".r.matches(role)
+}
diff --git a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/integration/lti13/Lti13Error.scala b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/integration/lti13/Lti13Error.scala
new file mode 100644
index 0000000000..99912c1694
--- /dev/null
+++ b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/integration/lti13/Lti13Error.scala
@@ -0,0 +1,111 @@
+/*
+ * Licensed to The Apereo Foundation under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * The Apereo Foundation licenses this file to you 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 com.tle.integration.lti13
+
+import com.tle.integration.lti13.ErrorResponseCode.Code
+
+import java.net.URI
+import scala.util.Try
+
+/**
+ * Valid error codes for Error Responses as per section 4.1.2.1 (Error Response) of the RFC 6749
+ * (OAuth 2).
+ */
+object ErrorResponseCode extends Enumeration {
+ type Code = Value
+
+ val invalid_request, unauthorized_client, access_denied, unsupported_response_type, invalid_scope,
+ server_error, temporarily_unavailable = Value
+}
+
+/**
+ * An Error Responses as per section 4.1.2.1 (Error Response) of the RFC 6749 (OAuth 2).
+ *
+ * @param error REQUIRED. A single ASCII [USASCII] error code from {@link ErrorResponseCode}
+ * @param error_description OPTIONAL. Human-readable ASCII [USASCII] text providing additional
+ * information, used to assist the client developer in understanding the
+ * error that occurred. Values for the "error_description" parameter
+ * MUST NOT include characters outside the set %x20-21 / %x23-5B / %x5D-7E.
+ * @param error_uri OPTIONAL. A URI identifying a human-readable web page with information about
+ * the error, used to provide the client developer with additional information
+ * about the error. Values for the "error_uri" parameter MUST conform to the
+ * URI-reference syntax and thus MUST NOT include characters outside the set
+ * %x21 / %x23-5B / %x5D-7E.
+ * @param state REQUIRED if a "state" parameter was present in the client authorization request.
+ * The exact value received from the client.
+ */
+case class ErrorResponse(error: ErrorResponseCode.Code,
+ error_description: Option[String],
+ error_uri: Option[URI],
+ state: String)
+object ErrorResponse {
+ val PARAM_ERRORCODE = "error"
+ val PARAM_DESCRIPTION = "error_description"
+ val PARAM_URI = "error_uri"
+ val PARAM_STATE = "state"
+
+ def apply(params: Map[String, Array[String]]): Option[ErrorResponse] = {
+ val param = getParam(params)
+ val uriParam = getUriParam(param)
+
+ for {
+ error <- param(PARAM_ERRORCODE).flatMap(asString =>
+ Try(ErrorResponseCode.withName(asString)).toOption)
+ error_description = param(PARAM_DESCRIPTION)
+ error_uri = uriParam(PARAM_URI)
+ state <- param(PARAM_STATE)
+ } yield ErrorResponse(error, error_description, error_uri, state)
+ }
+}
+
+sealed abstract class Lti13Error {
+ val code: Code
+}
+
+trait HasMessage {
+ val msg: String
+}
+
+case class InvalidState(msg: String) extends Lti13Error with HasMessage {
+ override val code: Code = ErrorResponseCode.invalid_request
+}
+
+case class InvalidJWT(msg: String) extends Lti13Error with HasMessage {
+ override val code: Code = ErrorResponseCode.invalid_request
+}
+
+case class PlatformDetailsError(msg: String) extends Lti13Error with HasMessage {
+ override val code: Code = ErrorResponseCode.server_error
+}
+
+/**
+ * Indicates a generic server error that can't be better classified with one of the other
+ * `Lti13Error`s.
+ */
+case class ServerError(msg: String) extends Lti13Error with HasMessage {
+ override val code: Code = ErrorResponseCode.server_error
+}
+
+case class NotAuthorized(msg: String) extends Lti13Error with HasMessage {
+ override val code: Code = ErrorResponseCode.unauthorized_client
+}
+
+case class AccessDenied(msg: String) extends Lti13Error with HasMessage {
+ override val code: Code = ErrorResponseCode.access_denied
+}
diff --git a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/integration/lti13/Lti13IntegrationService.scala b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/integration/lti13/Lti13IntegrationService.scala
new file mode 100644
index 0000000000..bbaf17e11f
--- /dev/null
+++ b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/integration/lti13/Lti13IntegrationService.scala
@@ -0,0 +1,350 @@
+/*
+ * Licensed to The Apereo Foundation under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * The Apereo Foundation licenses this file to you 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 com.tle.integration.lti13
+
+import com.tle.core.guice.Bind
+import com.tle.core.webkeyset.service.WebKeySetService
+import com.tle.web.integration.service.IntegrationService
+import com.tle.web.integration.{
+ AbstractIntegrationService,
+ IntegrationSessionData,
+ SingleSignonForm
+}
+import com.tle.web.sections.equella.{AbstractScalaSection, ModalSession}
+import com.tle.web.sections.generic.DefaultSectionTree
+import com.tle.web.sections.registry.TreeRegistry
+import com.tle.web.sections.{SectionInfo, SectionNode, SectionsController}
+import com.tle.web.selection.{
+ SelectedResource,
+ SelectionSession,
+ SelectionsMadeCallback,
+ TreeLookupSelectionCallback
+}
+
+import java.time.Instant
+import javax.inject.{Inject, Singleton}
+import javax.servlet.http.{HttpServletRequest, HttpServletResponse}
+import scala.jdk.CollectionConverters._
+import com.auth0.jwt.JWT
+import com.auth0.jwt.algorithms.Algorithm
+import com.tle.core.lti13.service.LtiPlatformService
+import com.tle.web.integration.guice.IntegrationModule
+import com.tle.web.sections.header.{FormTag, SimpleFormAction}
+import com.tle.web.sections.jquery.{JQuerySelector, JQueryStatement}
+import com.tle.web.sections.render.HiddenInput
+import com.tle.web.selection.section.RootSelectionSection
+import com.tle.web.template.{Decorations, RenderNewTemplate}
+import com.tle.web.viewable.ViewItemLinkFactory
+
+/**
+ * Data required to support the LTI 1.3 content selection workflow.
+ */
+case class Lti13IntegrationSessionData(deepLinkingSettings: LtiDeepLinkingSettings,
+ context: Option[LtiDeepLinkingContext])
+ extends IntegrationSessionData {
+ override def isForSelection: Boolean = true
+ override def getIntegrationType: String = "lti13"
+}
+
+object Lti13IntegrationSessionData {
+ def apply(request: LtiDeepLinkingRequest): Lti13IntegrationSessionData = {
+ Lti13IntegrationSessionData(request.deepLinkingSettings, request.context)
+ }
+}
+
+/**
+ * This Integration Service is dedicated to the integration established by LTI 1.3. It provides
+ * core functions that are used in the context of Selection Session, such as launching Selection
+ * Session and communicating with LMS to complete content selections.
+ *
+ */
+@Bind
+@Singleton
+class Lti13IntegrationService extends AbstractIntegrationService[Lti13IntegrationSessionData] {
+ private var integrationService: IntegrationService = _
+ private var sectionsController: SectionsController = _
+ private var treeRegistry: TreeRegistry = _
+ private var webKeySetService: WebKeySetService = _
+ private var ltiPlatformService: LtiPlatformService = _
+ private var linkFactory: ViewItemLinkFactory = _
+ private val LTI13_INTEGRATION_CALLBACK = "$LTI13$INTEG$RETURNER"
+
+ @Inject
+ def this(integrationService: IntegrationService,
+ webKeySetService: WebKeySetService,
+ ltiPlatformService: LtiPlatformService,
+ sectionsController: SectionsController,
+ treeRegistry: TreeRegistry,
+ linkFactory: ViewItemLinkFactory) = {
+ this()
+ this.treeRegistry = treeRegistry
+ this.integrationService = integrationService
+ this.sectionsController = sectionsController
+ this.webKeySetService = webKeySetService
+ this.ltiPlatformService = ltiPlatformService
+ this.linkFactory = linkFactory
+ }
+
+ // The value of each ContentItem must be constructed as a `Map` so that it will be accepted by `com.auth0.jwt.JWTCreator`.
+ private def buildDeepLinkingContentItems(
+ info: SectionInfo,
+ session: SelectionSession): java.util.List[java.util.Map[String, Object]] = {
+ def buildSelectedContent(resource: SelectedResource): java.util.Map[String, Object] = {
+ val item = getItemForResource(resource)
+
+ def buildUrl: String =
+ getLinkForResource(info,
+ createViewableItem(item, resource),
+ resource,
+ false,
+ session.isAttachmentUuidUrls).getLmsLink.getUrl
+
+ def buildIcon: Option[java.util.Map[String, Any]] = {
+ val itemID = resource.createItemId()
+
+ // If the selected resource is an Attachment or an Item which has attachments, build
+ // the thumbnail link for the resource.
+ val thumbnailLink = resource.getType match {
+ case SelectedResource.TYPE_PATH =>
+ Option(item.getAttachments)
+ .filterNot(_.isEmpty)
+ .map(_ => linkFactory.createThumbnailAttachmentLink(itemID, null))
+ case SelectedResource.TYPE_ATTACHMENT =>
+ Some(linkFactory.createThumbnailAttachmentLink(itemID, resource.getAttachmentUuid))
+ case _ => None
+ }
+
+ thumbnailLink.map(link => Map("url" -> link.getHref, "width" -> 48, "height" -> 48).asJava)
+ }
+
+ val selectedContent = Map(
+ "type" -> "ltiResourceLink",
+ "title" -> resource.getTitle,
+ "url" -> buildUrl,
+ "text" -> resource.getDescription,
+ )
+
+ buildIcon
+ .map(icon => selectedContent ++ Map("icon" -> icon))
+ .getOrElse(selectedContent)
+ .asJava
+ }
+
+ session.getSelectedResources.asScala
+ .map(buildSelectedContent)
+ .toList
+ .asJava
+ }
+
+ /**
+ * According to the spec for LTI 1.3 workflow ,
+ * we (tool provider) MUST redirect the workflow to the return URL provided in the Deep linking setting once the user has
+ * completed the selection or creation portion of the overall flow. To do this, we MUST always perform this redirection using
+ * an auto-submitted form as an HTTP POST request using the JWT parameter.
+ *
+ * In New UI, we have to rely on `LegacyContentApi` to return the form back to the front-end. So we need to build a `FormTag` and
+ * add it to the render context of `SectionInfo`.
+ * But in Old UI, we can directly output the form and a script to submit the form in the response.
+ *
+ * @param deepLinkReturnUrl The URL redirected to to complete a selection
+ * @param jwt JWT generated based on the Deep linking response to be sent back to platform.
+ * @param info The Legacy SectionInfo used to help submit the form in New UI.
+ * @param response HTTP servlet response used to help submit the form in Old UI.
+ */
+ private def submitForm(deepLinkReturnUrl: String,
+ jwt: String,
+ info: SectionInfo,
+ response: HttpServletResponse): Unit = {
+ val formId = "deep_linking_response"
+
+ if (RenderNewTemplate.isNewUIEnabled) {
+ // Setting this flag to `true` is important. It will make sure this form is available in the page.
+ // Otherwise, this form would be nested under form `eqForm`, which is invalid and the browser will remove this form. So the submit will fail.
+ // Check the usage of `LegacyContent#noForm` for details.
+ Decorations.getDecorations(info).setExcludeForm(true)
+ val form = new FormTag
+ form.setId(formId)
+ form.setAction(new SimpleFormAction(deepLinkReturnUrl))
+ form.addHidden(new HiddenInput("JWT", jwt))
+ form.addReadyStatements(new JQueryStatement(JQuerySelector.Type.ID, formId, "submit()"))
+
+ info.getRootRenderContext.setRenderedBody(form)
+ } else {
+ val formHtml =
+ s"""
+ |
+ |
+ |""".stripMargin
+
+ response.setContentType("text/html")
+
+ val p = response.getWriter
+ p.write(formHtml)
+ p.flush()
+ }
+ }
+
+ // Build a custom call back which will be fired when a selection is either confirmed or cancelled.
+ private def buildSelectionMadeCallback(deepLinkingRequest: LtiDeepLinkingRequest,
+ platformDetails: PlatformDetails,
+ response: HttpServletResponse): SelectionsMadeCallback =
+ new SelectionsMadeCallback {
+ override def executeSelectionsMade(info: SectionInfo, session: SelectionSession): Boolean =
+ ltiPlatformService.getPrivateKeyForPlatform(platformDetails.platformId) match {
+ case Right((keyId, privateKey)) =>
+ val deepLinkingResponse = JWT
+ .create()
+ .withIssuer(deepLinkingRequest.aud)
+ .withAudience(deepLinkingRequest.iss)
+ .withIssuedAt(Instant.now)
+ .withNotBefore(Instant.now)
+ .withExpiresAt(Instant.now.plusSeconds(60))
+ .withKeyId(keyId)
+ .withClaim(Lti13Claims.MESSAGE_TYPE, LtiMessageType.LtiDeepLinkingResponse.toString)
+ .withClaim(Lti13Claims.VERSION, deepLinkingRequest.version)
+ .withClaim(OpenIDConnectParams.NONCE, deepLinkingRequest.nonce)
+ .withClaim(Lti13Claims.DEPLOYMENT_ID, deepLinkingRequest.deploymentId)
+ .withClaim(
+ Lti13Claims.CONTENT_ITEMS,
+ buildDeepLinkingContentItems(info, session)
+ )
+
+ // If `data` is present in the Deep linking settings, include it in the response.
+ for {
+ d <- deepLinkingRequest.deepLinkingSettings.data
+ } yield deepLinkingResponse.withClaim(Lti13Claims.DATA, d)
+
+ val token = deepLinkingResponse.sign(Algorithm.RSA256(privateKey))
+
+ submitForm(deepLinkingRequest.deepLinkingSettings.deepLinkReturnUrl.toString,
+ token,
+ info,
+ response)
+ false // Return `false` so selections are not maintained, which is what `IntegrationSection` does.
+ case Left(error) =>
+ throw new RuntimeException(
+ s"Failed to process selections as unable to find details for provided platform: $error")
+ }
+
+ override def executeModalFinished(info: SectionInfo, session: ModalSession): Unit =
+ throw new UnsupportedOperationException
+ }
+
+ override protected def canSelect(data: Lti13IntegrationSessionData): Boolean = data.isForSelection
+
+ override protected def getIntegrationType = "lti13"
+
+ // Usually, the implementation of this method created in `GenericIntegrationService` will be used because the viewing type
+ // is `integ/gen`. So here we don't really need to implement it.
+ override def createDataForViewing(info: SectionInfo): Lti13IntegrationSessionData =
+ throw new UnsupportedOperationException
+
+ // This method should not be used in the context of LTI 1.3 integration because of the custom selection callback.
+ override def getClose(data: Lti13IntegrationSessionData): String =
+ throw new UnsupportedOperationException
+
+ override def getCourseInfoCode(data: Lti13IntegrationSessionData): String =
+ data.context.map(_.id).orNull
+
+ // This method should not be used in the context of LTI 1.3 integration because of the custom selection callback.
+ override def select(info: SectionInfo,
+ data: Lti13IntegrationSessionData,
+ session: SelectionSession): Boolean = throw new UnsupportedOperationException
+
+ override def setupSelectionSession(info: SectionInfo,
+ data: Lti13IntegrationSessionData,
+ session: SelectionSession,
+ model: SingleSignonForm): SelectionSession = {
+ session.setSelectMultiple(data.deepLinkingSettings.acceptMultiple.getOrElse(true))
+ super.setupSelectionSession(info, data, session, model)
+ }
+
+ /**
+ * Launch Selection Session for LTI 1.3. This is achieved by
+ * 1. Build a temporary SectionInfo;
+ * 2. Use LTI deep linking request details to build an IntegrationData and IntegrationActionInfo.
+ * 3. Use `IntegrationService#standardForward` to navigate the page to Selection Session.
+ *
+ * @param deepLinkingRequest Deep linking request details providing claims to be used to configure Selection Session.
+ * @param platformDetails Details of the LTI platform to be used to build a JWT.
+ * @param req HTTP Servlet request to be used to build a SectionInfo.
+ * @param resp HTTP Servlet response to be used to build a SectionInfo.
+ */
+ def launchSelectionSession(deepLinkingRequest: LtiDeepLinkingRequest,
+ platformDetails: PlatformDetails,
+ req: HttpServletRequest,
+ resp: HttpServletResponse): Unit = {
+
+ def buildSectionInfo = {
+ val section = new AbstractScalaSection {
+ // We are not really using any Section so it's fine to use Unit as the model type.
+ override type M = Unit
+ override def newModel: SectionInfo => Unit = _ => ()
+ }
+ val sectionNode = new SectionNode("LTI13SelectionSession", section)
+ val blankTree = new DefaultSectionTree(treeRegistry, sectionNode)
+
+ val info = sectionsController.createInfo(blankTree,
+ "/",
+ req,
+ resp,
+ null,
+ Map.empty[String, Array[String]].asJava,
+ null)
+
+ // For those wondering why saving the callback in Section Tree, the reason being that in cluster environment,
+ // the callback, which is an anonymous class, will be serialised as part of `SelectionSession`.
+ // This means many things like this Service itself and LtiPlatformService will need to support serialisation.
+ // In order not to do this, we must use `TreeLookupSelectionCallback`, which basically is like serialising
+ // the index of our callback and using the index to find the real callback when needed.
+ // Because this callback is used when a selection is made, we save it in `RootSelectionSection`.
+ Option(
+ info.lookupSection[RootSelectionSection, RootSelectionSection](
+ classOf[RootSelectionSection]))
+ .flatMap(section => Option(section.getTree)) match {
+ case Some(tree) =>
+ tree.setAttribute(LTI13_INTEGRATION_CALLBACK,
+ buildSelectionMadeCallback(deepLinkingRequest, platformDetails, resp))
+ case None =>
+ throw new RuntimeException(
+ s"Missing RootSelectionSection to create a callback for making selections in LTI 1.3 integration.")
+ }
+
+ info.fireBeforeEvents()
+ info
+ }
+
+ val integrationData = Lti13IntegrationSessionData(deepLinkingRequest)
+
+ // When Selection Session is launched with LTI 1.3, the display mode is `selectOrAdd` Only.
+ // This is because there is no standard way with Deep Linking response to return links targeting
+ // different course sections like is done with structured selection sessions.
+ integrationService.standardForward(
+ buildSectionInfo,
+ "",
+ integrationData,
+ integrationService
+ .getActionInfo(IntegrationModule.SELECT_OR_ADD_DEFAULT_ACTION, null),
+ new SingleSignonForm,
+ new TreeLookupSelectionCallback(LTI13_INTEGRATION_CALLBACK)
+ )
+ }
+}
diff --git a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/integration/lti13/Lti13NonceService.scala b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/integration/lti13/Lti13NonceService.scala
new file mode 100644
index 0000000000..5f526f156b
--- /dev/null
+++ b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/integration/lti13/Lti13NonceService.scala
@@ -0,0 +1,123 @@
+/*
+ * Licensed to The Apereo Foundation under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * The Apereo Foundation licenses this file to you 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 com.tle.integration.lti13
+
+import com.tle.core.guice.Bind
+import com.tle.core.replicatedcache.ReplicatedCacheService
+import com.tle.core.replicatedcache.ReplicatedCacheService.ReplicatedCache
+
+import java.time.Instant
+import java.util.concurrent.TimeUnit
+import javax.inject.{Inject, Singleton}
+
+object NonceExpiry {
+ val inSeconds = 10
+}
+
+/**
+ * Provides the concrete details behind a nonce to be used for validation.
+ *
+ * @param state the state representing the session for which this nonce can be used
+ * @param timestamp the timestamp of when this was generated, so that it can be checked for currency
+ */
+@SerialVersionUID(1)
+case class Lti13NonceDetails(state: String, timestamp: Instant) extends Serializable
+
+/**
+ * Manages the nonce values for LTI 1.3 Authentication processes.
+ */
+@Bind
+@Singleton
+class Lti13NonceService(nonceStorage: ReplicatedCache[Lti13NonceDetails]) {
+
+ @Inject def this(rcs: ReplicatedCacheService) {
+ // Not keen on the idea of having a limit on the number of cache entries, but that's the way
+ // RCS works. And although we set a TTL of 10 seconds, that's only until first access. After
+ // that it's been hard coded to last for 1 day!
+ // (See com.tle.core.replicatedcache.impl.ReplicatedCacheServiceImpl.ReplicatedCacheImpl.ReplicatedCacheImpl)
+ this(
+ rcs
+ .getCache[Lti13NonceDetails]("lti13-nonces", 1000, NonceExpiry.inSeconds, TimeUnit.SECONDS))
+ }
+
+ /**
+ * Given an existing `state` value, will create a unique `nonce` value and store it in a
+ * replicated datastore against the current timestamp. Allowing for both a time window and the
+ * state (representing a session) to be used at validation time.
+ *
+ * @param state the `state` value of an existing session
+ * @return a new unique nonce
+ */
+ def createNonce(state: String): String = {
+ // We need to make sure nonces are unique, so up to 10 will be generated
+ // after which we have no option but to trigger a runtime exception!
+ val nonce = LazyList
+ .fill(10) {
+ generateRandomHexString(16)
+ }
+ .find(!nonceStorage.get(_).isPresent) match {
+ case Some(uniqueNonce) => uniqueNonce
+ case None => throw new RuntimeException("Failed to generate a unique nonce!")
+ }
+
+ nonceStorage.put(nonce, Lti13NonceDetails(state, Instant.now()))
+
+ nonce
+ }
+
+ /**
+ * Validates the provided `nonce` by checking:
+ *
+ * 1. That it still exists in the nonce store
+ * 2. It is being used within a suitable time
+ * 3. It was registered against the provided `state`
+ *
+ * At completion, if all is valid it will remove the `nonce` as it has effectively then been used
+ * _once_.
+ *
+ * @param nonce the `nounce` value to validate
+ * @param state the state to which the it is expected the `nonce` was registered against
+ * @return true if valid, false otherwise
+ */
+ def validateNonce(nonce: String, state: String): Either[String, Boolean] = {
+ def notExpired(ts: Instant) = {
+ val validUntil = ts.plusSeconds(NonceExpiry.inSeconds)
+ Instant
+ .now()
+ .isBefore(validUntil)
+ }
+
+ val valid = Option(nonceStorage.get(nonce).orNull()) match {
+ // Could do a few matches to capture errors around expired, doesn't match state, etc.
+ case Some(nonceDetails: Lti13NonceDetails)
+ if nonceDetails.state == state && notExpired(nonceDetails.timestamp) =>
+ nonceStorage.invalidate(nonce)
+ Right(true)
+ case Some(nonceDetails: Lti13NonceDetails) if nonceDetails.state == state =>
+ // cases with matching timestamp would've been above, so this means it had expired
+ Left("Provided nonce has expired")
+ case Some(_: Lti13NonceDetails) =>
+ // Lastly, this means we have found some the specified nonce - but it's for the wrong session
+ Left("Provided nonce does not match the 'state' for this request")
+ case None => Left("Provided nonce does not exist")
+ }
+
+ valid
+ }
+}
diff --git a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/integration/lti13/Lti13Params.scala b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/integration/lti13/Lti13Params.scala
new file mode 100644
index 0000000000..154923f159
--- /dev/null
+++ b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/integration/lti13/Lti13Params.scala
@@ -0,0 +1,93 @@
+/*
+ * Licensed to The Apereo Foundation under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * The Apereo Foundation licenses this file to you 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 com.tle.integration.lti13
+
+/**
+ * HTTP Parameters for the various LTI HTTP requests.
+ */
+object Lti13Params {
+
+ /**
+ * The new optional parameter `client_id` specifies the client id for the authorization server
+ * that should be used to authorize the subsequent LTI message request. This allows for a platform
+ * to support multiple registrations from a single issuer, without relying on the
+ * `initiate_login_uri` as a key.
+ */
+ val CLIENT_ID = "client_id"
+
+ /**
+ * The issuer identifier identifying the learning platform.
+ */
+ val ISSUER = "iss"
+
+ /**
+ * 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.
+ */
+ val LOGIN_HINT = "login_hint"
+
+ /**
+ * The new optional parameter `lti_deployment_id` that if included, MUST contain the same
+ * deployment id that would be passed in the
+ * claim for the subsequent LTI message
+ * launch.
+ *
+ * This parameter may be used by the tool to perform actions that are dependent on a specific
+ * deployment. An example of this would be, using the deployment id to identify the region in
+ * which a tenant linked to the deployment lives. Subsequently changing the `redirect_url` the
+ * final launch will be directed to.
+ */
+ val LTI_DEPLOYMENT_ID = "lti_deployment_id"
+
+ /**
+ * The new optional parameter `lti_message_hint` may be used alongside the `login_hint` to carry
+ * information about the actual LTI message that is being launched.
+ *
+ * Similarly to the `login_hint` parameter, `lti_message_hint` value is opaque to the tool. If
+ * present in the login initiation request, the tool MUST include it back in the authentication
+ * request unaltered.
+ */
+ val LTI_MESSAGE_HINT = "lti_message_hint"
+
+ /**
+ * REQUIRED. String value used to associate a Client session with an ID Token, and to mitigate
+ * replay attacks. The value is passed through unmodified from the Authentication Request to the
+ * ID Token.
+ */
+ val NONCE = "nonce"
+
+ /**
+ * REQUIRED. Since the message launch is meant to be sent from a platform where the user is
+ * already logged in. If the user has no session, a platform must just fail the flow rather than
+ * ask the user to log in.
+ */
+ val PROMPT = "prompt"
+ val PROMPT_NONE = "none"
+
+ /**
+ * REQUIRED. The Token can be lengthy and thus should be passed over as a form POST.
+ */
+ val RESPONSE_MODE = "response_mode"
+ val RESPONSE_MODE_FORM_POST = "form_post"
+
+ /**
+ * The actual end-point that should be executed at the end of the OpenID Connect authentication flow.
+ */
+ val TARGET_LINK_URI = "target_link_uri"
+}
diff --git a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/integration/lti13/Lti13Request.scala b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/integration/lti13/Lti13Request.scala
new file mode 100644
index 0000000000..2601ba4912
--- /dev/null
+++ b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/integration/lti13/Lti13Request.scala
@@ -0,0 +1,207 @@
+/*
+ * Licensed to The Apereo Foundation under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * The Apereo Foundation licenses this file to you 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 com.tle.integration.lti13
+
+import com.auth0.jwt.interfaces.DecodedJWT
+import com.tle.integration.lti13.Lti13Claims.CUSTOM_PARAMETERS
+import com.tle.integration.lti13.LtiMessageType.MessageType
+import cats.implicits._
+import io.circe.Decoder
+import io.circe.generic.extras.Configuration
+import io.circe.generic.extras.semiauto.deriveConfiguredDecoder
+import io.circe.generic.semiauto.deriveDecoder
+import io.circe.parser.decode
+import java.net.URL
+import scala.jdk.CollectionConverters._
+
+/**
+ * Data structure for LTI 1.3 deep linking settings as per .
+ *
+ * @param deepLinkReturnUrl URL where the tool redirects the user back to the platform.
+ * @param acceptTypes A list of resource types accepted such as "link" and "ltiResourceLink".
+ * See for more accepted types.
+ * @param acceptPresentationDocumentTargets A list of supported document targets (e.g. `iframe` & window`).
+ * @param acceptMediaTypes A list of accepted media types. Only applies to File types.
+ * @param acceptMultiple Whether selecting multiple resources in a single response is allowed.
+ * @param acceptLineItem Whether the platform supports line items.
+ * @param autoCreate Whether to persist the selected resource in the platform.
+ * @param title Default text for the selected resource.
+ * @param text Default description for the selected resources.
+ * @param data An opaque value which must be included in the response if it's present in the request.
+ */
+case class LtiDeepLinkingSettings(
+ deepLinkReturnUrl: URL,
+ acceptTypes: Array[String],
+ acceptPresentationDocumentTargets: Array[String],
+ acceptMediaTypes: Option[String],
+ acceptMultiple: Option[Boolean],
+ acceptLineItem: Option[Boolean],
+ autoCreate: Option[Boolean],
+ title: Option[String],
+ text: Option[String],
+ data: Option[String]
+)
+
+object LtiDeepLinkingSettings {
+ // Name of each settings is defined in the format of snake case so add this config to help decode.
+ implicit val config: Configuration = Configuration.default.withSnakeCaseMemberNames
+ implicit val urlDecoder: Decoder[URL] = Decoder.decodeString.emap { str =>
+ Either
+ .catchNonFatal(new URL(str))
+ .leftMap(error => s"Failed to decode URL: ${error.getMessage}")
+ }
+ implicit val decoder: Decoder[LtiDeepLinkingSettings] = deriveConfiguredDecoder
+
+ private def decodeDeepLinkingSettings(
+ settings: String): Either[InvalidJWT, LtiDeepLinkingSettings] =
+ decode[LtiDeepLinkingSettings](settings)
+ .leftMap(error => InvalidJWT(s"Failed to decode deep linking settings: ${error.getMessage}"))
+
+ def apply(jwt: DecodedJWT): Either[InvalidJWT, LtiDeepLinkingSettings] = {
+ getClaimStringRepr(jwt, Lti13Claims.DEEP_LINKING_SETTINGS)
+ .toRight(InvalidJWT(s"Failed to extract Deep linking settings from JWT"))
+ .flatMap(decodeDeepLinkingSettings)
+ }
+}
+
+/**
+ * Data structure for LTI 1.3 deep linking context claim (`https://purl.imsglobal.org/spec/lti/claim/context`) as per .
+ *
+ * @param id Stable identifier that uniquely identifies the context from which the LTI message initiates.
+ * @param `type` An array of URI values for context types. If present, the array MUST include at least one
+ * context type from the context type vocabulary described in
+ * @param label Short descriptive name for the context. This often carries the "course code" for a course offering or course section context.
+ * @param title Full descriptive name for the context. This often carries the "course title" or "course name" for a course offering context.
+ */
+case class LtiDeepLinkingContext(id: String,
+ `type`: Option[Array[String]],
+ label: Option[String],
+ title: Option[String])
+
+object LtiDeepLinkingContext {
+ implicit val decoder: Decoder[LtiDeepLinkingContext] = deriveDecoder
+
+ def apply(jwt: DecodedJWT): Option[LtiDeepLinkingContext] =
+ getClaimStringRepr(jwt, Lti13Claims.CONTEXT).flatMap(decode(_).toOption)
+
+}
+
+object LtiMessageType extends Enumeration {
+ type MessageType = Value
+ val LtiDeepLinkingRequest, LtiDeepLinkingResponse, LtiResourceLinkRequest = Value
+}
+
+sealed trait Lti13Request {
+ val messageType: MessageType
+ val version: String = "1.3.0"
+}
+
+/**
+ * Data structure for LTI 1.3 deep linking request as per
+ * and
+ *
+ * @param iss Issuer Identifier for the Issuer of the message.
+ * @param aud Audience(s) for whom this Tool JWT is intended.
+ * @param nonce A case-sensitive string value used to associate a Tool session with a Tool JWT.
+ * @param deploymentId The claim identifies the platform-tool integration governing the message.
+ * @param deepLinkingSettings The claim that composes properties that characterize the kind of deep linking request the platform user is making.
+ * @param customParams Optional claim that provides a key-value map of defined custom properties. The values must be strings.
+ * @param context Optional claim that composes properties for the platform context from within which the deep linking request occurs.
+ */
+case class LtiDeepLinkingRequest(iss: String,
+ aud: String,
+ nonce: String,
+ deploymentId: String,
+ deepLinkingSettings: LtiDeepLinkingSettings,
+ customParams: Option[Map[String, String]],
+ context: Option[LtiDeepLinkingContext])
+ extends Lti13Request {
+ override val messageType: MessageType = LtiMessageType.LtiDeepLinkingRequest
+}
+
+/**
+ * Data structure for LTI 1.3 resource link request.
+ *
+ * todo: Update the structure as needed. Maybe need another case class for the resource link. Check claim "https://purl.imsglobal.org/spec/lti/claim/resource_link".
+ */
+case class LtiResourceLinkRequest(targetLinkUri: String) extends Lti13Request {
+ override val messageType: MessageType = LtiMessageType.LtiResourceLinkRequest
+}
+
+object Lti13Request {
+
+ /**
+ * Extract custom params from the provided decoded JWT. Custom params are expected to be `String`s,
+ * so non-String values can be ignored (discarded).
+ *
+ * @param decodedJWT Decoded JWT which provides the claim of custom params.
+ * @return A key-value map for custom params, or `None` if no such a claim available.
+ */
+ def getCustomParamsFromClaim(decodedJWT: DecodedJWT): Option[Map[String, String]] = {
+ getClaimAsMap(decodedJWT, CUSTOM_PARAMETERS)
+ .map(
+ _.collect {
+ case (k, v) if v.isInstanceOf[String] => (k, v.asInstanceOf[String])
+ }
+ )
+ }
+
+ /**
+ * Return the details of a LTI 1.3 request message from the provided decoded token.
+ * The request message vary, depending on the message type.
+ *
+ * @param decodedJWT a token containing claims which provides details of an LTI request message.
+ * @return Details of an LTI 1.3 request message, or `Lti13Error` if failed to extract the details.
+ */
+ def getLtiRequestDetails(decodedJWT: DecodedJWT): Either[Lti13Error, Lti13Request] = {
+ def requiredClaim(claim: String) = getRequiredClaim(decodedJWT, claim)
+
+ def getRequest(messageType: MessageType): Either[InvalidJWT, Lti13Request] = messageType match {
+ case LtiMessageType.LtiDeepLinkingRequest =>
+ for {
+ aud <- decodedJWT.getAudience.asScala.headOption
+ .toRight(InvalidJWT(s"Failed to extract audience from JWT"))
+ nonce <- requiredClaim(OpenIDConnectParams.NONCE)
+ deploymentId <- requiredClaim(Lti13Claims.DEPLOYMENT_ID)
+ deepLinkingSettings <- LtiDeepLinkingSettings(decodedJWT)
+ iss = decodedJWT.getIssuer
+ customParams = getCustomParamsFromClaim(decodedJWT)
+ context = LtiDeepLinkingContext(decodedJWT)
+ } yield
+ LtiDeepLinkingRequest(iss,
+ aud,
+ nonce,
+ deploymentId,
+ deepLinkingSettings,
+ customParams,
+ context)
+ case LtiMessageType.LtiResourceLinkRequest =>
+ getRequiredClaim(decodedJWT, Lti13Claims.TARGET_LINK_URI)
+ .map(LtiResourceLinkRequest)
+ }
+
+ for {
+ messageType <- getRequiredClaim(decodedJWT, Lti13Claims.MESSAGE_TYPE)
+ validType <- Either
+ .catchNonFatal(LtiMessageType.withName(messageType))
+ .leftMap(_ => InvalidJWT(s"Unknown LTI message type: $messageType"))
+ request <- getRequest(validType)
+ } yield request
+ }
+}
diff --git a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/integration/lti13/Lti13StateService.scala b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/integration/lti13/Lti13StateService.scala
new file mode 100644
index 0000000000..1d043dd89c
--- /dev/null
+++ b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/integration/lti13/Lti13StateService.scala
@@ -0,0 +1,90 @@
+/*
+ * Licensed to The Apereo Foundation under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * The Apereo Foundation licenses this file to you 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 com.tle.integration.lti13
+
+import com.tle.core.guice.Bind
+import com.tle.core.replicatedcache.ReplicatedCacheService
+import com.tle.core.replicatedcache.ReplicatedCacheService.ReplicatedCache
+
+import java.net.URI
+import java.util.concurrent.TimeUnit
+import javax.inject.{Inject, Singleton}
+
+/**
+ * Details for an entry being stored in state.
+ *
+ * @param platformId The ID of the platform which this state is linked to. (VALIDATION)
+ * @param loginHint The initial login hint for the connection. (VALIDATION - maybe)
+ * @param targetLinkUri The intended link to be launched as per the initial request. Once
+ * authentication is complete, this link should be redirected to.
+ */
+@SerialVersionUID(1)
+case class Lti13StateDetails(platformId: String, loginHint: String, targetLinkUri: URI)
+ extends Serializable
+
+/**
+ * Manages the `state` values for LTI 1.3 Authentication processes.
+ */
+@Bind
+@Singleton
+class Lti13StateService(stateStorage: ReplicatedCache[Lti13StateDetails]) {
+
+ @Inject def this(rcs: ReplicatedCacheService) = {
+ // Not keen on the idea of having a limit on the number of cache entries, but that's the way
+ // RCS works. And although we set a TTL of 10 seconds, that's only until first access. After
+ // that it's been hard coded to last for 1 day!
+ // (See com.tle.core.replicatedcache.impl.ReplicatedCacheServiceImpl.ReplicatedCacheImpl.ReplicatedCacheImpl)
+ this(
+ rcs
+ .getCache[Lti13StateDetails]("lti13-state", 1000, 10, TimeUnit.SECONDS))
+ }
+
+ /**
+ * Given the key details of an LTI Request, store those and create a 'state' value that can
+ * later be used to retrieve these.
+ *
+ * @param details key information for an LTI request
+ * @return a unique value representing this request which can be used as the `state` values
+ * in subsequent requests.
+ */
+ def createState(details: Lti13StateDetails): String = {
+ val state = generateRandomHexString(16)
+ stateStorage.put(state, details)
+
+ state
+ }
+
+ /**
+ * Given the state from a request, return what (if any) state details are stored against that
+ * identifier.
+ *
+ * @param state the `state` value received from an LTI request.
+ * @return The stored state details if available, or `None` if there are none - most likely
+ * indicating an invalid `state` value has been provided.
+ */
+ def getState(state: String): Option[Lti13StateDetails] = Option(stateStorage.get(state).orNull())
+
+ /**
+ * Given an `state` which is no longer required - already been used perhaps - invalidate it by
+ * removing it from the state storage.
+ *
+ * @param state the `state` to be invalidated
+ */
+ def invalidateState(state: String): Unit = stateStorage.invalidate(state)
+}
diff --git a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/integration/lti13/OpenIDConnectLaunchServlet.scala b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/integration/lti13/OpenIDConnectLaunchServlet.scala
new file mode 100644
index 0000000000..b5db50090c
--- /dev/null
+++ b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/integration/lti13/OpenIDConnectLaunchServlet.scala
@@ -0,0 +1,179 @@
+/*
+ * Licensed to The Apereo Foundation under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * The Apereo Foundation licenses this file to you 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 com.tle.integration.lti13
+
+import com.tle.common.usermanagement.user.WebAuthenticationDetails
+import com.tle.core.guice.Bind
+import com.tle.core.services.user.UserService
+import com.tle.integration.lti13.Lti13Request.getLtiRequestDetails
+import org.apache.http.HttpStatus
+import org.slf4j.LoggerFactory
+import javax.inject.{Inject, Singleton}
+import javax.servlet.http.{HttpServlet, HttpServletRequest, HttpServletResponse}
+import scala.jdk.CollectionConverters._
+
+/**
+ * Handles the OpenID Connect Launch Flow as outlined in section 5.1 of the LTI 1.3 spec.
+ */
+@Bind
+@Singleton
+class OpenIDConnectLaunchServlet extends HttpServlet {
+ private val LOGGER = LoggerFactory.getLogger(classOf[OpenIDConnectLaunchServlet])
+
+ @Inject private var lti13AuthService: Lti13AuthService = _
+ @Inject private var stateService: Lti13StateService = _
+ @Inject private var userService: UserService = _
+ @Inject private var lti13IntegrationService: Lti13IntegrationService = _
+
+ override def doGet(req: HttpServletRequest, resp: HttpServletResponse): Unit = {
+ LOGGER.debug("doGet() called")
+
+ // the initial launch should also be supported by GET - the other requests
+ // (e.g. AuthenticationResponse) only require POST
+ InitiateLoginRequest(req.getParameterMap.asScala.toMap) match {
+ case Some(initReq) => handleInitiateLoginRequest(initReq, resp)
+ case None =>
+ resp.sendError(HttpServletResponse.SC_BAD_REQUEST,
+ "Unsupported 'GET' LTI 1.3 launch request received.")
+ }
+
+ LOGGER.debug("doGet() complete")
+ }
+
+ override def doPost(req: HttpServletRequest, resp: HttpServletResponse): Unit = {
+ LOGGER.debug("doPost() called")
+
+ // TODO Needs validation around the request - was it a form POST request, does it have any params, etc.
+
+ val params = req.getParameterMap.asScala.toMap
+ val processedRequest = InitiateLoginRequest(params) orElse AuthenticationResponse(params) orElse ErrorResponse(
+ params)
+
+ processedRequest match {
+ case Some(validRequest) =>
+ validRequest match {
+ case initReq: InitiateLoginRequest => handleInitiateLoginRequest(initReq, resp)
+ case authResp: AuthenticationResponse =>
+ handleAuthenticationResponse(authResp,
+ userService.getWebAuthenticationDetails(req),
+ req,
+ resp)
+ case errorResponse: ErrorResponse => handleErrorResponse(errorResponse, resp)
+ }
+ case None =>
+ resp.sendError(HttpServletResponse.SC_BAD_REQUEST,
+ "Unsupported LTI 1.3 launch request received.")
+ }
+
+ LOGGER.debug("doPost() complete")
+ }
+
+ private def handleInitiateLoginRequest(initLogin: InitiateLoginRequest,
+ resp: HttpServletResponse): Unit = {
+ LOGGER.debug("Received a request to initiate a login. Supplied values:")
+ LOGGER.debug(initLogin.toString)
+ lti13AuthService.buildAuthReqUrl(initLogin).map(resp.encodeRedirectURL) match {
+ case Some(authRedirectUrl) => resp.sendRedirect(authRedirectUrl)
+ case None =>
+ resp.sendError(HttpServletResponse.SC_BAD_REQUEST,
+ "Unable to start launch with provided request.")
+ }
+ }
+
+ private def handleAuthenticationResponse(auth: AuthenticationResponse,
+ wad: WebAuthenticationDetails,
+ req: HttpServletRequest,
+ resp: HttpServletResponse): Unit = {
+ LOGGER.debug("Received an authentication response. Supplied values:")
+ LOGGER.debug(auth.toString)
+
+ // If this was not just for LTI Launch then we'd actually need to also support returning
+ // the error details to a redirect URL. As the 1EdTech Security framework spec in section
+ // 5.1.1.5 (Authentication Error Response) it says to follow OpenID spec section 3.1.2.6
+ // which for starters says, "Unless the Redirection URI is invalid, the Authorization
+ // Server returns the Client to the Redirection URI specified in the Authorization
+ // Request with the appropriate error and state parameters. Other parameters SHOULD
+ // NOT be returned."
+ def onAuthFailure(error: Lti13Error): Unit = {
+ val errorMsg = error match {
+ case message: HasMessage => message.msg
+ case _ => "No further information"
+ }
+
+ LOGGER.error(s"Authentication failed [${error.code}]: $errorMsg")
+
+ val output =
+ s"""Authentication failed:
+ |
+ |Error code: ${error.code}
+ |Description: $errorMsg
+ |
+ |Please contact your system administrator
+ |""".stripMargin
+
+ resp.setContentType("text/plain")
+ resp.setStatus(HttpStatus.SC_FORBIDDEN)
+ resp.getWriter.print(output)
+ }
+
+ val authResult: Either[Lti13Error, (Lti13Request, PlatformDetails)] = for {
+ verifiedResult <- lti13AuthService.verifyToken(auth.state, auth.id_token)
+ decodedJWT = verifiedResult._1
+ platformDetails = verifiedResult._2
+ userDetails <- UserDetails(decodedJWT)
+ _ <- lti13AuthService.loginUser(wad, userDetails)
+ lti13Request <- getLtiRequestDetails(decodedJWT)
+ } yield (lti13Request, platformDetails)
+
+ authResult match {
+ case Left(error) => onAuthFailure(error)
+ case Right((ltiRequest, platformDetails)) =>
+ ltiRequest match {
+ case deepLinkingRequest: LtiDeepLinkingRequest =>
+ lti13IntegrationService.launchSelectionSession(deepLinkingRequest,
+ platformDetails,
+ req,
+ resp)
+ case resourceLinkRequest: LtiResourceLinkRequest =>
+ resp.sendRedirect(resp.encodeRedirectURL(resourceLinkRequest.targetLinkUri))
+ }
+ }
+
+ // Finished with `state` - it's been used once, so let's dump it
+ stateService.invalidateState(auth.state)
+ }
+
+ private def handleErrorResponse(errorResponse: ErrorResponse, resp: HttpServletResponse): Unit = {
+ LOGGER.error(s"Received Error Response from LTI Platform: $errorResponse")
+
+ val output =
+ s"""Received Error Response from LTI Platform:
+ |
+ |Error code: ${errorResponse.error.toString}
+ |Description: ${errorResponse.error_description.getOrElse("None provided")}
+ |
+ |Please contact your system administrator.""".stripMargin
+
+ resp.setContentType("text/plain")
+ resp.setStatus(HttpStatus.SC_OK)
+ resp.getWriter.print(output)
+
+ stateService.invalidateState(errorResponse.state)
+ }
+}
diff --git a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/integration/lti13/OpenIDConnectParams.scala b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/integration/lti13/OpenIDConnectParams.scala
new file mode 100644
index 0000000000..c47282cc7a
--- /dev/null
+++ b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/integration/lti13/OpenIDConnectParams.scala
@@ -0,0 +1,76 @@
+/*
+ * Licensed to The Apereo Foundation under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * The Apereo Foundation licenses this file to you 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 com.tle.integration.lti13
+
+/**
+ * Parameters and claims specific to the OpenID Connect part of OAuth 2.0 exchanges used in LTI 1.3.
+ */
+object OpenIDConnectParams {
+
+ /**
+ * REQUIRED. OAuth 2.0 Client Identifier valid at the Authorization Server.
+ */
+ val CLIENT_ID = "client_id"
+
+ val EMAIL = "email"
+
+ val FAMILY_NAME = "family_name"
+
+ val GIVEN_NAME = "given_name"
+
+ /**
+ * REQUIRED. ID Token.
+ */
+ val ID_TOKEN = "id_token"
+
+ /**
+ * REQUIRED. String value used to associate a Client session with an ID Token, and to mitigate
+ * replay attacks. The value is passed through unmodified from the Authentication Request to the
+ * ID Token. Sufficient entropy MUST be present in the nonce values used to prevent attackers from
+ * guessing values. For implementation notes, see Section 15.5.2.
+ */
+ val NONCE = "nonce"
+
+ /**
+ * REQUIRED. Redirection URI to which the response will be sent. This URI MUST exactly match one
+ * of the Redirection URI values for the Client pre-registered at the OpenID Provider, with
+ * the matching performed as described in Section 6.2.1 of [RFC3986] (Simple String Comparison).
+ * When using this flow, the Redirection URI SHOULD use the https scheme; however, it MAY use the
+ * http scheme, provided that the Client Type is confidential, as defined in Section 2.1 of OAuth
+ * 2.0, and provided the OP allows the use of http Redirection URIs in this case. The Redirection
+ * URI MAY use an alternate scheme, such as one that is intended to identify a callback into a
+ * native application.
+ */
+ val REDIRECT_URI = "redirect_uri"
+
+ val RESPONSE_TYPE = "response_type"
+
+ val RESPONSE_TYPE_ID_TOKEN = "id_token"
+
+ val SCOPE = "scope"
+
+ val SCOPE_OPENID = "openid"
+
+ /**
+ * OAuth 2.0 state value. REQUIRED if the `state` parameter is present in the Authorization
+ * Request. Clients MUST verify that the `state` value is equal to the value of `state` parameter
+ * in the Authorization Request.
+ */
+ val STATE = "state"
+}
diff --git a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/integration/lti13/PlatformDetails.scala b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/integration/lti13/PlatformDetails.scala
new file mode 100644
index 0000000000..04896972cb
--- /dev/null
+++ b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/integration/lti13/PlatformDetails.scala
@@ -0,0 +1,94 @@
+/*
+ * Licensed to The Apereo Foundation under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * The Apereo Foundation licenses this file to you 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 com.tle.integration.lti13
+
+import com.tle.core.lti13.bean.LtiPlatformBean
+
+import java.net.URL
+
+/**
+ * Definition for how unknown users should be handled:
+ *
+ * - `ERROR`: Report an authentication error
+ * - `GUEST`: Authenticate the user as a 'guest' user
+ * - `CREATE`: a new local oEQ user and authenticate as that user
+ */
+object UnknownUserHandling extends Enumeration {
+ type Handling = Value
+
+ val ERROR, GUEST, CREATE = Value
+}
+
+/**
+ * The details registered with oEQ regarding the platform identified with `platformId`. But with
+ * some slightly more concete types (`URL` and `UnknownUserHandling`) compared to `LtiPlatformBean`
+ * which needed to be looser for REST endpoints etc.
+ *
+ * @param platformId The issuer identifier identifying the learning platform.
+ * @param name The name display in oEQ for the learning platform.
+ * @param clientId The clientId for oEQ issued by the platform
+ * @param authUrl The platform's authentication request URL
+ * @param keysetUrl The platform's JWKS keyset URL for us to get the keys from
+ * @param usernamePrefix a value to prefix the userId from the LTI request with
+ * @param usernameSuffix value to concatenate to the userId from the LTI request
+ * @param unknownUserHandling a tuple where the first value indicates how unknown users should be
+ * handled, and the second value is only present if the handling is to
+ * create user objects. In which case, the list of groups (either UUIDs
+ * or internal oEQ group names) will be added to the new user object.
+ * @param instructorRoles a list of roles (either UUIDs or internal oEQ group names) to assign to
+ * the user's session if they have an LTI Instructor role.
+ * @param unknownRoles a list of roles (either UUIDs or internal oEQ group names) to assign to
+ * the user's session for any roles which are not an instructor role or in
+ * the list of custom roles.
+ * @param customRoles a mapping of LTI roles to oEQ roles (either UUIDs or internal oEQ group
+ * names) to assign to the user's session.
+ * @param allowExpression an ACL Expression to control access from this platform - None is the same
+ * as an ACL Expression of 'Everyone' or '*'
+ */
+case class PlatformDetails(platformId: String,
+ name: String,
+ clientId: String,
+ authUrl: URL,
+ keysetUrl: URL,
+ usernamePrefix: Option[String],
+ usernameSuffix: Option[String],
+ unknownUserHandling: (UnknownUserHandling.Handling, Option[Set[String]]),
+ instructorRoles: Set[String],
+ unknownRoles: Set[String],
+ customRoles: Map[String, Set[String]],
+ allowExpression: Option[String])
+
+object PlatformDetails {
+ def apply(bean: LtiPlatformBean): PlatformDetails =
+ PlatformDetails(
+ platformId = bean.platformId,
+ name = bean.name,
+ clientId = bean.clientId,
+ authUrl = new URL(bean.authUrl),
+ keysetUrl = new URL(bean.keysetUrl),
+ usernamePrefix = bean.usernamePrefix,
+ usernameSuffix = bean.usernameSuffix,
+ unknownUserHandling =
+ (UnknownUserHandling.withName(bean.unknownUserHandling), bean.unknownUserDefaultGroups),
+ instructorRoles = bean.instructorRoles,
+ unknownRoles = bean.unknownRoles,
+ customRoles = bean.customRoles,
+ allowExpression = bean.allowExpression
+ )
+}
diff --git a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/integration/lti13/package.scala b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/integration/lti13/package.scala
new file mode 100644
index 0000000000..188347122f
--- /dev/null
+++ b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/integration/lti13/package.scala
@@ -0,0 +1,122 @@
+/*
+ * Licensed to The Apereo Foundation under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * The Apereo Foundation licenses this file to you 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 com.tle.integration
+
+import com.auth0.jwt.interfaces.DecodedJWT
+
+import java.net.URI
+import java.security.SecureRandom
+import scala.util.Try
+import scala.jdk.CollectionConverters._
+import cats.implicits._
+
+package object lti13 {
+ private val secureRandom = SecureRandom.getInstanceStrong
+
+ /**
+ * Helper function for dealing with params sent the servlet endpoint. On the expectation that
+ * all the params are a one-to-one value - so navigates around the need to handle the potential
+ * list.
+ *
+ * @param params a collection of params received at one of the servlet request handlers.
+ * @return a function which has wrapped over the `params` and can now be used simply with the
+ * name of an expected parameter. The function will return `None` if the param is not
+ * present
+ */
+ def getParam(params: Map[String, Array[String]]): String => Option[String] =
+ (param: String) => params.get(param).flatMap(_.headOption)
+
+ /**
+ * Helper function for dealing with URI params, building on top of getParam. It takes the function
+ * created from a call to getParam.
+ *
+ * @param param a function generated by `getParam`
+ * @return a function which can be used to extract URI parameters from a list of params
+ */
+ def getUriParam(param: String => Option[String]): String => Option[URI] =
+ (p: String) => param(p).flatMap(maybeUri => Try(new URI(maybeUri)).toOption)
+
+ /**
+ * From the provided decoded JWT return the specified claim which is expected to be a string.
+ *
+ * @param jwt a token containing the claim
+ * @param claim name of a string based claim
+ * @return If available will return the string value of the claim, or `None`
+ */
+ def getClaim(jwt: DecodedJWT, claim: String): Option[String] =
+ Option(jwt.getClaim(claim)).flatMap(c => Option(c.asString()))
+
+ /**
+ * Return the specified claim which must be present in the provided decoded JWT as a string.
+ *
+ * @param jwt a token containing the claim
+ * @param claim name of a string based claim
+ * @return The string value of the claim, or `InvalidJWT` if the claim is absent.
+ */
+ def getRequiredClaim(jwt: DecodedJWT, claim: String): Either[InvalidJWT, String] =
+ Option(jwt.getClaim(claim))
+ .flatMap(c => Option(c.asString()))
+ .toRight(InvalidJWT(s"Failed to extract $claim from JWT"))
+
+ /**
+ * For a claim where type of the value is non-textual, use this method to get the string representation
+ * of the claim value.
+ *
+ * @param jwt a token containing the claim
+ * @param claim name of a string based claim
+ * @return If available will return the string representation of the claim, or `None`
+ */
+ def getClaimStringRepr(jwt: DecodedJWT, claim: String): Option[String] =
+ Option(jwt.getClaim(claim)).flatMap(c => Option(c.toString))
+
+ /**
+ * Get value of a claim as a Map.
+ *
+ * @param jwt a token containing the claim
+ * @param claim name of a string based claim
+ * @return If transforming the value to a Map is successful return the Map, or `None`
+ */
+ def getClaimAsMap(jwt: DecodedJWT, claim: String): Option[Map[String, AnyRef]] =
+ Option(jwt.getClaim(claim))
+ // `asMap` may return null or throw JWTDecodeException.
+ .map(c => Either.catchNonFatal(Option(c.asMap)))
+ .flatMap(_.toOption)
+ .flatten
+ .map(_.asScala.toMap)
+
+ /**
+ * Given a decoded JWT will return a partially applied function which can then receive the name
+ * of a claim and return the value as a `String` or `None` if not present in the claims.
+ *
+ * @param jwt a token containing claims which will be wrapped in the returned function
+ * @return a function which given the name of a claim will optionally return its value.
+ */
+ def getClaim(jwt: DecodedJWT): String => Option[String] =
+ (claim: String) => getClaim(jwt, claim)
+
+ /**
+ * Generates a string of random bytes represented as hexadecimal values.
+ *
+ * @param length number of random bytes
+ * @return a string which is twice the length of `length` with each two characters representing
+ * one byte
+ */
+ def generateRandomHexString(length: Int): String =
+ Range(0, length).map(_ => "%02x".format(secureRandom.nextInt(255))).mkString
+}
diff --git a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/api/ApiErrorResponse.scala b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/api/ApiErrorResponse.scala
index 3b58f21fba..dfc78bbf1e 100644
--- a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/api/ApiErrorResponse.scala
+++ b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/api/ApiErrorResponse.scala
@@ -42,6 +42,10 @@ object ApiErrorResponse {
buildErrorResponse(Status.FORBIDDEN, errors)
}
+ def serverError(errors: String*): Response = {
+ buildErrorResponse(Status.INTERNAL_SERVER_ERROR, errors)
+ }
+
private def buildErrorResponse(status: Status, errors: Seq[String]): Response = {
Response.status(status).entity(responseBody(errors)).build()
}
diff --git a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/api/lti/LtiPlatformResource.scala b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/api/lti/LtiPlatformResource.scala
new file mode 100644
index 0000000000..26b9b8f71e
--- /dev/null
+++ b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/api/lti/LtiPlatformResource.scala
@@ -0,0 +1,277 @@
+/*
+ * Licensed to The Apereo Foundation under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * The Apereo Foundation licenses this file to you 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 com.tle.web.api.lti
+
+import cats.data.Validated.{Invalid, Valid}
+import cats.implicits._
+import com.tle.core.lti13.bean.LtiPlatformBean
+import com.tle.core.lti13.bean.LtiPlatformBean.validateLtiPlatformBean
+import com.tle.legacy.LegacyGuice
+import com.tle.web.api.{ApiBatchOperationResponse, ApiErrorResponse}
+import com.tle.web.lti13.platforms.security.LTI13PlatformsSettingsPrivilegeTreeProvider
+import io.swagger.annotations.{Api, ApiOperation, ApiParam}
+import org.jboss.resteasy.annotations.cache.NoCache
+import org.slf4j.{Logger, LoggerFactory}
+import java.net.{URI, URLDecoder, URLEncoder}
+import java.nio.charset.StandardCharsets
+import javax.ws.rs.core.{MediaType, Response}
+import javax.ws.rs._
+
+class UpdateEnabledStatusRequest {
+ var platformId: String = _
+ var enabled: Boolean = _
+
+ def getPlatformId(): String = platformId
+
+ def setPlatformId(value: String): Unit = {
+ platformId = value
+ }
+
+ def getEnabled(): Boolean = enabled
+
+ def setEnabled(value: Boolean): Unit = {
+ enabled = value
+ }
+}
+
+@NoCache
+@Path("ltiplatform")
+@Produces(Array("application/json"))
+@Api("LTI 1.3 Platform")
+class LtiPlatformResource {
+ private val aclProvider: LTI13PlatformsSettingsPrivilegeTreeProvider = LegacyGuice.ltiPrivProvider
+ private val ltiPlatformService = LegacyGuice.ltiPlatformService
+ private val logger: Logger = LoggerFactory.getLogger(classOf[LtiPlatformResource])
+
+ // Lazily evaluated the result and capture the potential exceptions in an Either. Use the provided handler to build a response for success, or
+ // return a server error response if any exception is triggered.
+ private def processResult[A](result: => A, onSuccess: A => Response, onFailureMessage: String) =
+ Either.catchNonFatal(result) match {
+ case Left(error) =>
+ logger.error(onFailureMessage, error)
+ ApiErrorResponse.serverError(s"$onFailureMessage : ${error.getMessage}")
+ case Right(result) => onSuccess(result)
+ }
+
+ // Similar to `processResult` but handle the situation where the target LTI platform may not exist.
+ private def processOptionResult[A](
+ result: => Option[A],
+ onSome: A => Response,
+ onNone: () => Response = () =>
+ ApiErrorResponse.resourceNotFound("Target LTI platform is not found."),
+ onFailureMessage: String) = {
+ def processOption(maybeResult: Option[A]): Response =
+ maybeResult.map(onSome).getOrElse(onNone())
+
+ processResult[Option[A]](result, processOption, onFailureMessage)
+ }
+
+ private def decodeId(id: String): String = URLDecoder.decode(id, StandardCharsets.UTF_8.name())
+
+ @GET
+ @Path("/{id}")
+ @ApiOperation(
+ value = "Get LTI Platform by ID",
+ notes = "This endpoints retrieves a LTI Platform by Platform ID",
+ response = classOf[LtiPlatformBean],
+ )
+ def getPlatform(
+ @ApiParam(
+ "The Platform ID has to be double URL encoded to protect against premature decoding.") @PathParam(
+ "id") id: String): Response = {
+ aclProvider.checkAuthorised()
+
+ processOptionResult[LtiPlatformBean](ltiPlatformService.getByPlatformID(decodeId(id)),
+ Response.ok(_).build,
+ onFailureMessage = s"Failed to get LTI platform by ID $id")
+
+ }
+
+ @GET
+ @ApiOperation(
+ value = "Get a list of LTI Platforms",
+ notes = "This endpoints retrieves a list of enabled or disabled LTI Platforms",
+ response = classOf[LtiPlatformBean],
+ responseContainer = "List"
+ )
+ def getPlatforms: Response = {
+ aclProvider.checkAuthorised()
+
+ processResult[List[LtiPlatformBean]](ltiPlatformService.getAll,
+ Response.ok.entity(_).build,
+ "Failed to get a list of LTI platform")
+ }
+
+ @POST
+ @ApiOperation(
+ value = "Create a new LTI platform",
+ notes = "This endpoint creates a new LTI platform and includes ID of the created platform in the response header. " +
+ "The ID has to be double URL encoded to protect against premature decoding.",
+ response = classOf[String],
+ )
+ def createPlatform(bean: LtiPlatformBean): Response = {
+ aclProvider.checkAuthorised()
+
+ def urlEncode(s: String): String = URLEncoder.encode(s, StandardCharsets.UTF_8.name())
+ def create(bean: LtiPlatformBean): Response =
+ processResult[String](
+ ltiPlatformService.create(bean),
+ id =>
+ Response
+ // Double encoding the ID to make sure any forward slash is fully escaped due to the Tomcat 'no slash' issue.
+ .created(new URI(s"/ltiplatform/${urlEncode(urlEncode(id))}"))
+ .build(),
+ "Failed to create a new LTI platform"
+ )
+
+ validateLtiPlatformBean(bean) match {
+ case Invalid(error) => ApiErrorResponse.badRequest(error: _*)
+ case Valid(bean) => create(bean)
+ }
+ }
+
+ @PUT
+ @ApiOperation(
+ value = "Update an existing LTI platform",
+ notes = "This endpoint updates an existing LTI platform",
+ response = classOf[Unit],
+ )
+ def updatePlatform(updates: LtiPlatformBean): Response = {
+ aclProvider.checkAuthorised()
+
+ def update: Response =
+ processOptionResult[String](ltiPlatformService.update(updates),
+ Response.ok(_).build,
+ onFailureMessage = "Failed to update LTI platform")
+
+ validateLtiPlatformBean(updates) match {
+ case Invalid(error) => ApiErrorResponse.badRequest(error: _*)
+ case Valid(_) => update
+ }
+ }
+
+ @DELETE
+ @Path("/{id}")
+ @ApiOperation(
+ value = "Delete a LTI platform by ID",
+ notes = "This endpoints deletes an existing LTI platform by platform ID",
+ response = classOf[Int],
+ )
+ def deletePlatform(
+ @ApiParam(
+ "The Platform ID has to be double URL encoded to protect against premature decoding.") @PathParam(
+ "id") id: String): Response = {
+ aclProvider.checkAuthorised()
+
+ processOptionResult[Unit](ltiPlatformService.delete(decodeId(id)),
+ Response.ok(_).build,
+ onFailureMessage = s"Failed to delete LTI platform by ID $id")
+ }
+
+ @DELETE
+ @ApiOperation(
+ value = "Delete multiple LTI platforms by a list of Platform ID",
+ notes = "This endpoints deletes multiple LTI platforms by a list of platform ID",
+ response = classOf[ApiBatchOperationResponse],
+ responseContainer = "List"
+ )
+ def deletePlatforms(
+ @ApiParam(value = "List of Platform ID") @QueryParam("ids") ids: Array[String]): Response = {
+ aclProvider.checkAuthorised()
+
+ def delete(id: String): ApiBatchOperationResponse = {
+ Either.catchNonFatal(ltiPlatformService.delete(id)) match {
+ case Left(error) =>
+ ApiBatchOperationResponse(id,
+ 500,
+ s"Failed to delete platform for $id : ${error.getMessage}")
+ case Right(maybeDeleted) =>
+ maybeDeleted
+ .map(_ => ApiBatchOperationResponse(id, 200, s"Platform $id has been deleted."))
+ .getOrElse(ApiBatchOperationResponse(id, 404, s"No LTI Platform matching $id"))
+ }
+ }
+
+ val responses = ids
+ .map(delete)
+ .toList
+
+ Response.status(207).entity(responses).build()
+ }
+
+ @PUT
+ @Path("/enabled")
+ @Consumes(Array(MediaType.APPLICATION_JSON))
+ @ApiOperation(
+ value = "Enable or disable multiple LTI platforms by a list of Platform ID with boolean flag",
+ notes =
+ "This endpoints enables/disables multiple LTI platforms by a list of platform ID with boolean flag",
+ response = classOf[ApiBatchOperationResponse],
+ responseContainer = "List"
+ )
+ def enabledPlatforms(idWithStatus: Array[UpdateEnabledStatusRequest]): Response = {
+ aclProvider.checkAuthorised()
+
+ def updateEnabledStatus(idWithStatus: UpdateEnabledStatusRequest): ApiBatchOperationResponse = {
+ val id = idWithStatus.platformId
+
+ ltiPlatformService
+ .getByPlatformID(id)
+ .toRight(ApiBatchOperationResponse(id, 404, s"No LTI Platform matching $id"))
+ .flatMap { p =>
+ {
+ val updatedPlatform = p.copy(enabled = idWithStatus.enabled)
+ ltiPlatformService
+ .update(updatedPlatform)
+ .toRight(ApiBatchOperationResponse(id, 500, s"Failed to update platform $id"))
+ }
+ }
+ .fold(e => identity(e),
+ r => ApiBatchOperationResponse(r, 200, s"Platform $id has been updated."))
+ }
+
+ val responses = idWithStatus.map(updateEnabledStatus).toList
+
+ Response.status(207).entity(responses).build()
+ }
+
+ @GET
+ @Path("/{id}/rotated-keys")
+ @ApiOperation(
+ value = "Rotate Key Pair For the specified Platform",
+ notes = "This endpoint will rotate the activated key pair for an LTI platform.",
+ response = classOf[String],
+ )
+ def rotateKeyPair(
+ @ApiParam(
+ "The Platform ID has to be double URL encoded to protect against premature decoding.") @PathParam(
+ "id") id: String): Response = {
+ aclProvider.checkAuthorised()
+
+ val result = ltiPlatformService.rotateKeyPairForPlatform(decodeId(id));
+
+ result match {
+ case Right(keySetId) =>
+ Response.ok(keySetId).build()
+ case Left(errorMessage) =>
+ logger.error(errorMessage)
+ ApiErrorResponse.serverError(s"Failed to rotate key pair : ${errorMessage}")
+ }
+ }
+}
diff --git a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/api/search/SearchHelper.scala b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/api/search/SearchHelper.scala
index 1d72124582..836afe4abd 100644
--- a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/api/search/SearchHelper.scala
+++ b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/api/search/SearchHelper.scala
@@ -412,6 +412,7 @@ object SearchHelper {
.flatMap(
_.find(isViewable(hasRestrictedAttachmentPrivileges))
)
+ .map(sanitiseAttachmentBean)
.map(toSearchResultAttachment(itemKey, _))
.map(
a =>
diff --git a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/api/users/UserQueryResource.scala b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/api/users/UserQueryResource.scala
index fc2527159a..7a7f46d45c 100644
--- a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/api/users/UserQueryResource.scala
+++ b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/api/users/UserQueryResource.scala
@@ -32,22 +32,22 @@ import scala.jdk.CollectionConverters._
case class LookupQuery(users: Seq[String], groups: Seq[String], roles: Seq[String])
-case class GroupQueryResult(id: String, name: String)
+case class GroupDetails(id: String, name: String)
-case class RoleQueryResult(id: String, name: String)
+case class RoleDetails(id: String, name: String)
-object GroupQueryResult {
- def apply(gb: GroupBean): GroupQueryResult =
- GroupQueryResult(gb.getUniqueID, gb.getName)
+object GroupDetails {
+ def apply(gb: GroupBean): GroupDetails =
+ GroupDetails(gb.getUniqueID, gb.getName)
}
-object RoleQueryResult {
- def apply(rb: RoleBean): RoleQueryResult = RoleQueryResult(rb.getUniqueID, rb.getName)
+object RoleDetails {
+ def apply(rb: RoleBean): RoleDetails = RoleDetails(rb.getUniqueID, rb.getName)
}
case class LookupQueryResult(users: Iterable[UserDetails],
- groups: Iterable[GroupQueryResult],
- roles: Iterable[RoleQueryResult])
+ groups: Iterable[GroupDetails],
+ roles: Iterable[RoleDetails])
@Path("userquery/")
@Produces(value = Array("application/json"))
@@ -64,8 +64,8 @@ class UserQueryResource {
val groups = us.getInformationForGroups(queries.groups.asJava)
val roles = us.getInformationForRoles(queries.roles.asJava)
LookupQueryResult(users.asScala.values.map(UserDetails.apply),
- groups.asScala.values.map(GroupQueryResult.apply),
- roles.asScala.values.map(RoleQueryResult.apply))
+ groups.asScala.values.map(GroupDetails.apply),
+ roles.asScala.values.map(RoleDetails.apply))
}
@GET
@@ -82,8 +82,8 @@ class UserQueryResource {
val groups = if (sgroups) us.searchGroups(q).asScala else Iterable.empty
val roles = if (sroles) us.searchRoles(q).asScala else Iterable.empty
LookupQueryResult(users.map(UserDetails.apply),
- groups.map(GroupQueryResult.apply),
- roles.filterNot(r => exclude(r.getUniqueID)).map(RoleQueryResult.apply))
+ groups.map(GroupDetails.apply),
+ roles.filterNot(r => exclude(r.getUniqueID)).map(RoleDetails.apply))
}
@GET
@@ -114,6 +114,29 @@ class UserQueryResource {
}
}
+ @GET
+ @Path("filtered-groups")
+ @ApiOperation(
+ value = "Search for groups",
+ notes = "Searches for groups, but filters the results based on the byGroups parameter.",
+ response = classOf[GroupDetails],
+ responseContainer = "List"
+ )
+ def filteredGroups(
+ @QueryParam("q") @ApiParam("Query string") q: String,
+ @QueryParam("byGroups") @ApiParam("A list of group UUIDs to filter the search by") groups: Array[
+ String]
+ ): Response = {
+ val us = LegacyGuice.userService
+
+ Option
+ .when(groups.nonEmpty)(groups.flatMap(us.searchGroups(q, _).asScala))
+ .orElse(Option(us.searchGroups(q).asScala.toArray))
+ .filter(_.nonEmpty)
+ .map(groups => Response.ok.entity(groups.toSet.map(GroupDetails.apply)).build())
+ .getOrElse(resourceNotFound("No groups were found matching the specified criteria"))
+ }
+
@GET
@Path("tokens")
def listTokens = {
diff --git a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/controls/universal/handlers/FileUploadHandlerNew.scala b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/controls/universal/handlers/FileUploadHandlerNew.scala
index 2337b77f14..9ab52af7a9 100644
--- a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/controls/universal/handlers/FileUploadHandlerNew.scala
+++ b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/controls/universal/handlers/FileUploadHandlerNew.scala
@@ -44,7 +44,6 @@ import com.tle.web.controls.universal.handlers.fileupload.WebFileUploads.validat
import com.tle.web.controls.universal.handlers.fileupload._
import com.tle.web.controls.universal.handlers.fileupload.details._
import com.tle.web.freemarker.FreemarkerFactory
-import com.tle.web.inplaceeditor.service.InPlaceEditorWebService
import com.tle.web.myresource.MyResourceConstants
import com.tle.web.resources.ResourcesService
import com.tle.web.sections._
@@ -132,7 +131,6 @@ class FileUploadHandlerNew extends AbstractAttachmentHandler[FileUploadHandlerMo
@Inject var attachmentResourceService: AttachmentResourceService = _
@Inject var thumbnailService: ThumbnailService = _
@Inject var videoService: VideoService = _
- @Inject var inplaceEditorService: InPlaceEditorWebService = _
@AjaxFactory var ajax: AjaxGenerator = _
@Component
@@ -258,15 +256,8 @@ class FileUploadHandlerNew extends AbstractAttachmentHandler[FileUploadHandlerMo
val showRestrict = hasInstitutionPrivilege(AttachmentConfigConstants.RESTRICT_ATTACHMENTS)
private def getEditingAttachment(info: SectionInfo) = getEditState.a
- val fileEditDetails = new FileEditDetails(id,
- tree,
- this,
- this,
- this,
- this,
- showRestrict,
- getEditingAttachment,
- inplaceEditorService)
+ val fileEditDetails =
+ new FileEditDetails(id, tree, this, this, this, this, showRestrict, getEditingAttachment)
val packageEditDetails =
new PackageEditDetails(id, tree, this, this, showRestrict, getEditingAttachment)
val fileOptions =
diff --git a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/controls/universal/handlers/fileupload/details/FileEditDetails.scala b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/controls/universal/handlers/fileupload/details/FileEditDetails.scala
index 15d1ee2a1b..ab2a5e53a9 100644
--- a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/controls/universal/handlers/fileupload/details/FileEditDetails.scala
+++ b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/controls/universal/handlers/fileupload/details/FileEditDetails.scala
@@ -20,7 +20,6 @@ package com.tle.web.controls.universal.handlers.fileupload.details
import java.util
import java.util.UUID
-import com.tle.beans.item.ItemId
import com.tle.beans.item.attachments.{Attachment, FileAttachment, ZipAttachment}
import com.tle.common.NameValue
import com.tle.common.filesystem.FileEntry
@@ -34,7 +33,6 @@ import com.tle.web.controls.universal.{
}
import com.tle.web.freemarker.FreemarkerFactory
import com.tle.web.freemarker.annotations.ViewFactory
-import com.tle.web.inplaceeditor.service.InPlaceEditorWebService
import com.tle.web.sections.ajax.AjaxGenerator
import com.tle.web.sections.ajax.handler.{AjaxFactory, AjaxMethod}
import com.tle.web.sections.annotations.{Bookmarked, EventFactory, EventHandlerMethod}
@@ -42,8 +40,6 @@ import com.tle.web.sections.equella.AbstractScalaSection
import com.tle.web.sections.equella.annotation.PlugKey
import com.tle.web.sections.events.RenderContext
import com.tle.web.sections.events.js.{EventGenerator, JSHandler}
-import com.tle.web.sections.jquery.JQuerySelector.Type
-import com.tle.web.sections.jquery.Jq
import com.tle.web.sections.js.generic.expression.{ObjectExpression, ScriptVariable}
import com.tle.web.sections.js.generic.function.{ExternallyDefinedFunction, IncludeFile}
import com.tle.web.sections.js.generic.statement.AssignStatement
@@ -65,10 +61,6 @@ object FileEditDetails {
private val SELECT_FUNCTION = new ExternallyDefinedFunction("zipSelect", INCLUDE)
private val SELECTION_TREE = new ScriptVariable("zipTree")
-
- private val INPLACE_APPLET_ID = "inplace_applet"
- private val INPLACE_APPLET_HEIGHT = "50px"
- private val INPLACE_APPLET_WIDTH = "320px"
}
class FileEditDetails(parentId: String,
@@ -78,8 +70,7 @@ class FileEditDetails(parentId: String,
editingHandler: EditingHandler,
viewerHandler: ViewerHandler,
showRestrict: Boolean,
- val editingAttachment: SectionInfo => Attachment,
- inplaceEditorService: InPlaceEditorWebService)
+ val editingAttachment: SectionInfo => Attachment)
extends AbstractScalaSection
with RenderHelper
with DetailsPage {
@@ -95,10 +86,6 @@ class FileEditDetails(parentId: String,
@Component(name = "e") var editFileDiv: Div = _
- @Component
- @PlugKey("handlers.file.details.link.editfile") var editFileLink: Link = _
- @Component
- @PlugKey("handlers.file.details.link.editfilewith") var editFileWithLink: Link = _
@Component
@PlugKey("handlers.file.details.unzipfile") var executeUnzip: Button = _
@Component
@@ -137,7 +124,7 @@ class FileEditDetails(parentId: String,
model.appletMode = if (openWith) "openwith" else "open"
}
- @EventHandlerMethod def inplaceSave(info: SectionInfo): Unit = {
+ @EventHandlerMethod def save(info: SectionInfo): Unit = {
ctx.controlState.save(info)
}
@@ -161,24 +148,7 @@ class FileEditDetails(parentId: String,
events.getEventHandler("editFile"),
"editFileAjaxDiv")
- editFileLink.setClickHandler(
- inplaceEditorService.createOpenHandler(
- INPLACE_APPLET_ID,
- false,
- Js.function(Js.call_s(editFileAjaxFunction, false.asInstanceOf[AnyRef]))))
- editFileWithLink.setClickHandler(
- inplaceEditorService.createOpenHandler(
- INPLACE_APPLET_ID,
- true,
- Js.function(Js.call_s(editFileAjaxFunction, true.asInstanceOf[AnyRef]))))
-
- editFileLink.addReadyStatements(
- inplaceEditorService.createHideLinksStatements(Jq.$(Type.CLASS, "editLinks"),
- Jq.$(editFileWithLink)))
-
- saveClickHandler = inplaceEditorService.createUploadHandler(
- INPLACE_APPLET_ID,
- events.getSubmitValuesFunction("inplaceSave"))
+ saveClickHandler = new OverrideHandler(events.getSubmitValuesFunction("save"))
executeUnzip.setClickHandler(events.getNamedHandler("unzipFile"))
removeUnzip.setClickHandler(events.getNamedHandler("removeZip"))
@@ -198,22 +168,6 @@ class FileEditDetails(parentId: String,
)
}
- private def createInplaceApplet(info: SectionInfo) = {
- val model = getModel(info)
- val wizardStagingId = new ItemId(ctx.stagingContext.stgFile.getUuid, 0)
- inplaceEditorService.createAppletFunction(
- INPLACE_APPLET_ID,
- wizardStagingId,
- editingHandler.editingArea,
- model.inplaceFilepath,
- model.appletMode == "openwith",
- "invoker/file.inplaceedit.service",
- Jq.$(editFileDiv),
- INPLACE_APPLET_WIDTH,
- INPLACE_APPLET_HEIGHT
- )
- }
-
def newModel = FileEditDetailsModel.apply
case class FileEditDetailsModel(info: SectionInfo) {
@@ -226,8 +180,6 @@ class FileEditDetails(parentId: String,
def getCommonPrefix = AbstractDetailsAttachmentHandler.COMMON_PREFIX
- def inplaceFilepath = a.getUrl
-
var validate = false
def getEditTitle = new TextLabel(displayName.getValue(info))
@@ -255,10 +207,6 @@ class FileEditDetails(parentId: String,
def getViewers = viewers
- def getEditFileLink = editFileLink
-
- def getEditFileWithLink = editFileWithLink
-
def getEditFileDiv = editFileDiv
def getRemoveUnzip = removeUnzip
@@ -324,7 +272,6 @@ class FileEditDetails(parentId: String,
def renderDetails(context: RenderContext): (SectionRenderable, DialogRenderOptions => Unit) = {
val m = getModel(context)
- if (m.appletMode != null) editFileDiv.addReadyStatements(context, createInplaceApplet(context))
(renderModel("file/file-edit.ftl", m), _.setSaveClickHandler(saveClickHandler))
}
diff --git a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/core/servlet/webdav/WebDavAuthService.scala b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/core/servlet/webdav/WebDavAuthService.scala
new file mode 100644
index 0000000000..074a003645
--- /dev/null
+++ b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/core/servlet/webdav/WebDavAuthService.scala
@@ -0,0 +1,87 @@
+/*
+ * Licensed to The Apereo Foundation under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * The Apereo Foundation licenses this file to you 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 com.tle.web.core.servlet.webdav
+
+/**
+ * Details of the user from the oEQ Web UI side.
+ *
+ * @param uniqueId the unique id of the user - typically a UUID
+ * @param username the username of the user - typically human readable
+ */
+@SerialVersionUID(1)
+case class WebUserDetails(uniqueId: String, username: String) extends Serializable
+
+sealed abstract class WebDavAuthError(msg: String) {
+ override def toString: String = msg
+}
+
+case class InvalidCredentials() extends WebDavAuthError("Invalid username and/or password")
+
+case class InvalidContext() extends WebDavAuthError("No matching context ID.")
+
+/**
+ * A Service used to authenticate credentials of WebDAV sessions targeted to a specific context.
+ * The logic encapsulated here is to support the requirements of the WebDavServlet used for uploading
+ * files as attachments to items. With the context then being a reference to the WebDAV location -
+ * and as a result, a degree of authorisation is also being provided.
+ */
+trait WebDavAuthService {
+
+ /**
+ * Given an `id` representing a context, return a unique (to that context) set of credentials
+ * which can later be used to authenticate against that context. Some implementations are expected
+ * to store these credentials in a TTL based store (cache) to provide automatic expiry. However
+ * that is optional and left to the implementation.
+ *
+ * @param id a unique value representing a context for which these credentials will then be valid.
+ * @param oeqUserId the unique id of the user who will be authenticating with these credentials - typically a UUID
+ * @param oeqUsername the username of the user who will be authenticating with these credentials - typically human readable
+ * @return a tuple containing a 'username' and 'password'.
+ */
+ def createCredentials(id: String, oeqUserId: String, oeqUsername: String): (String, String)
+
+ /**
+ * Destroys the credentials for the provided context.
+ *
+ * @param id the id of the context for which credentials should be cleared.
+ */
+ def removeCredentials(id: String): Unit
+
+ /**
+ * Receives the value of a HTTP Basic Authorization header (`authRequest`) and validates this
+ * against the credentials stored for the specified context (`id`).
+ *
+ * @param id the context for which the provided credentials are expected to be valid.
+ * @param authRequest the payload of the HTTP Basic Authorization header as described at
+ *
+ * @return either a `Left[WebDavAuthError]` with an error message describing what failed, otherwise
+ * `Right(true)` indicate valid credentials.
+ */
+ def validateCredentials(id: String, authRequest: String): Either[WebDavAuthError, Boolean]
+
+ /**
+ * Given an `id` representing a context, return the details of the user who created the credentials
+ * for that context.
+ *
+ * @param id the identifier used for the context
+ * @return if there are credentials registered for the specified `id` then the details of the user
+ * who created them, otherwise `None`.
+ */
+ def whois(id: String): Option[WebUserDetails]
+}
diff --git a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/core/servlet/webdav/WebDavAuthServiceImpl.scala b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/core/servlet/webdav/WebDavAuthServiceImpl.scala
new file mode 100644
index 0000000000..d9b6739b12
--- /dev/null
+++ b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/core/servlet/webdav/WebDavAuthServiceImpl.scala
@@ -0,0 +1,104 @@
+/*
+ * Licensed to The Apereo Foundation under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * The Apereo Foundation licenses this file to you 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 com.tle.web.core.servlet.webdav
+
+import com.tle.core.guice.Bind
+import com.tle.core.replicatedcache.ReplicatedCacheService
+import com.tle.core.replicatedcache.ReplicatedCacheService.ReplicatedCache
+import org.apache.commons.text.{CharacterPredicates, RandomStringGenerator}
+
+import java.util.Base64
+import java.util.concurrent.TimeUnit
+import javax.inject.{Inject, Singleton}
+
+/**
+ * Storage format for the credentials stored in cache. The username and password are stored
+ * so that if credentials for the same id are requested, the currently valid ones can be provided.
+ *
+ * @param username the username part.
+ * @param password the password part.
+ * @param encoded the HTTP Basic Auth encoded representation - for easy validation.
+ * @param webUserDetails the details of the user from the oEQ Web UI side which is expected to use
+ * these credentials.
+ */
+@SerialVersionUID(1)
+case class WebDavCredentials(username: String,
+ password: String,
+ encoded: String,
+ webUserDetails: WebUserDetails)
+ extends Serializable
+object WebDavCredentials {
+ def apply(username: String, password: String, webUserDetails: WebUserDetails): WebDavCredentials =
+ WebDavCredentials(username,
+ password,
+ Base64.getEncoder.encodeToString(s"$username:$password".getBytes),
+ webUserDetails)
+}
+
+@Bind(classOf[WebDavAuthService])
+@Singleton
+class WebDavAuthServiceImpl(credStorage: ReplicatedCache[WebDavCredentials])
+ extends WebDavAuthService {
+ private val stringGenerator =
+ (new RandomStringGenerator.Builder).filteredBy(CharacterPredicates.ASCII_ALPHA_NUMERALS).build()
+
+ @Inject def this(rcs: ReplicatedCacheService) {
+ // Not keen on the idea of having a limit on the number of cache entries, but that's the way
+ // RCS works.
+ this(
+ rcs
+ .getCache[WebDavCredentials]("webdav-creds", 1000, 1, TimeUnit.HOURS))
+
+ }
+
+ override def createCredentials(id: String,
+ oeqUserId: String,
+ oeqUsername: String): (String, String) =
+ Option(credStorage.get(id).orNull())
+ .map(c => (c.username, c.password))
+ .getOrElse({
+ // For username and password it is sufficient to have randomly generated alpha-numeric
+ // strings between 10 and 20 characters.
+ def generateString = stringGenerator.generate(10, 20)
+
+ val username = generateString
+ val password = generateString
+
+ credStorage
+ .put(id, WebDavCredentials(username, password, WebUserDetails(oeqUserId, oeqUsername)))
+
+ (username, password)
+ })
+
+ override def removeCredentials(id: String): Unit = credStorage.invalidate(id)
+
+ override def validateCredentials(id: String,
+ authRequest: String): Either[WebDavAuthError, Boolean] =
+ Option(credStorage.get(id).orNull())
+ .map {
+ case WebDavCredentials(_, _, expectedPayload, _) =>
+ if (expectedPayload == authRequest) Right(true)
+ else Left(InvalidCredentials())
+ }
+ .getOrElse(Left(InvalidContext()))
+
+ override def whois(id: String): Option[WebUserDetails] =
+ Option(credStorage.get(id).orNull())
+ .map(_.webUserDetails)
+}
diff --git a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/jwks/JwksServlet.scala b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/jwks/JwksServlet.scala
new file mode 100644
index 0000000000..40f8941660
--- /dev/null
+++ b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/jwks/JwksServlet.scala
@@ -0,0 +1,48 @@
+/*
+ * Licensed to The Apereo Foundation under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * The Apereo Foundation licenses this file to you 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 com.tle.web.jwks
+
+import com.tle.core.guice.Bind
+import com.tle.core.webkeyset.service.WebKeySetService
+import javax.inject.{Inject, Singleton}
+import javax.servlet.http.{HttpServlet, HttpServletRequest, HttpServletResponse}
+
+/**
+ * This Servlet responds to GET requests coming from `/.well-known/jwks.json` which is the standard endpoint
+ * for web servers to publish JSON Web Key Sets(JWKS).
+ *
+ * The response content is a string in JSON format to represent a set of keys containing the public keys
+ * used to verify any JSON Web Token (JWT) issued.
+ *
+ * Reference links:
+ * - https://www.rfc-editor.org/rfc/rfc8414
+ * - https://auth0.com/docs/secure/tokens/json-web-tokens/json-web-key-sets
+ */
+@Bind
+@Singleton
+class JwksServlet extends HttpServlet {
+ @Inject var webKeySetService: WebKeySetService = _
+
+ override protected def doGet(req: HttpServletRequest, resp: HttpServletResponse): Unit = {
+ resp.setContentType("application/json")
+ val out = resp.getWriter
+ out.print(webKeySetService.generateJWKS)
+ out.flush()
+ }
+}
diff --git a/Source/Plugins/Applet/com.tle.common.applet/src/com/tle/web/appletcommon/dnd/DropHandler.java b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/settings/AnalyticsSettings.scala
similarity index 72%
rename from Source/Plugins/Applet/com.tle.common.applet/src/com/tle/web/appletcommon/dnd/DropHandler.java
rename to Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/settings/AnalyticsSettings.scala
index 18b3e65aef..f6e987c97d 100644
--- a/Source/Plugins/Applet/com.tle.common.applet/src/com/tle/web/appletcommon/dnd/DropHandler.java
+++ b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/settings/AnalyticsSettings.scala
@@ -16,15 +16,15 @@
* limitations under the License.
*/
-package com.tle.web.appletcommon.dnd;
+package com.tle.web.settings
-import java.awt.dnd.DropTargetDragEvent;
-import java.awt.dnd.DropTargetDropEvent;
+import com.tle.common.institution.CurrentInstitution
+import com.tle.legacy.LegacyGuice
-public interface DropHandler {
- int getDropHandlerPriority();
+object AnalyticsSettings {
+ private val AnalyticsPropName = "GOOGLE_ANALYTICS"
- boolean supportsDrop(DropTargetDragEvent e);
-
- boolean handleDrop(DropTargetDropEvent e) throws Exception;
+ def getAnalyticsId: Option[String] = {
+ Option(LegacyGuice.configService.getProperty(AnalyticsPropName))
+ }
}
diff --git a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/settings/SettingsList.scala b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/settings/SettingsList.scala
index d0b9e62f90..c096c1d53f 100644
--- a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/settings/SettingsList.scala
+++ b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/settings/SettingsList.scala
@@ -21,12 +21,10 @@ package com.tle.web.settings
import com.tle.common.connectors.ConnectorConstants.{PRIV_CREATE_CONNECTOR, PRIV_EDIT_CONNECTOR}
import com.tle.common.externaltools.constants.ExternalToolConstants
import com.tle.common.lti.consumers.LtiConsumerConstants
-import com.tle.common.security.SecurityConstants
import com.tle.common.userscripts.UserScriptsConstants
import com.tle.core.echo.EchoConstants
import com.tle.core.i18n.CoreStrings
import com.tle.core.oauth.OAuthConstants
-import com.tle.core.security.ACLChecks.hasAcl
import com.tle.legacy.LegacyGuice._
import com.tle.web.cloudprovider.CloudProviderConstants
import com.tle.web.mimetypes.MimeEditorUtils
@@ -125,6 +123,15 @@ object SettingsList {
.isEmpty
)
+ val lti13PlatformsSettings = CoreSettingsPage(
+ "lti1.3platforms",
+ Integration,
+ "lti13.settings.title",
+ "lti13.settings.description",
+ "page/lti13platforms",
+ () => lti13PrivilegeTreeProvider.isAuthorised
+ )
+
val userScriptSettings = CoreSettingsPage(
"scripts",
General,
@@ -207,6 +214,7 @@ object SettingsList {
connectorSettings,
echoSettings,
ltiConsumersSettings,
+ lti13PlatformsSettings,
userScriptSettings,
oauthSettings,
htmlEditorSettings,
diff --git a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/template/RenderNewTemplate.scala b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/template/RenderNewTemplate.scala
index 423ea76189..baaf866165 100644
--- a/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/template/RenderNewTemplate.scala
+++ b/Source/Plugins/Core/com.equella.core/scalasrc/com/tle/web/template/RenderNewTemplate.scala
@@ -26,7 +26,7 @@ import com.tle.core.plugins.AbstractPluginService
import com.tle.legacy.LegacyGuice
import com.tle.web.DebugSettings
import com.tle.web.freemarker.FreemarkerFactory
-import com.tle.web.resources.{ResourcesService}
+import com.tle.web.resources.ResourcesService
import com.tle.web.sections._
import com.tle.web.sections.equella.ScalaSectionRenderable
import com.tle.web.sections.events._
@@ -38,7 +38,7 @@ import com.tle.web.selection.section.RootSelectionSection
import com.tle.web.integration.IntegrationSection
import com.tle.web.sections.render.CssInclude.{Priority, include}
import com.tle.web.selection.section.RootSelectionSection.Layout
-import com.tle.web.settings.UISettings
+import com.tle.web.settings.{AnalyticsSettings, UISettings}
import javax.servlet.http.HttpServletRequest
import org.jsoup.Jsoup
import org.jsoup.nodes.{Document, Element}
@@ -69,6 +69,19 @@ object RenderNewTemplate {
val inpStream = getClass.getResourceAsStream(s"/web/$reactJsPath$filename")
if (inpStream == null) sys.error(s"Failed to find $filename react html bundle")
val htmlDoc = Jsoup.parse(inpStream, "UTF-8", "")
+ // Regex to match name of the CSS file generated by JS bundler.
+ // For example: "entrypoint.77de5109.css"
+ val EntrypointCss = "^(entrypoint\\..+\\.css)$".r
+
+ def buildCssLink(element: Element) = {
+ val link = element.attr("href") match {
+ // The entrypoint css file must be created under the 'reactjs' folder.
+ case EntrypointCss(href) => reactJsPath + href
+ case other => other
+ }
+ r.url(link)
+ }
+
inpStream.close()
htmlDoc.getElementsByTag("script").asScala.foreach { e =>
val srcAttrKey = "src"
@@ -81,7 +94,7 @@ object RenderNewTemplate {
info.preRender(JQueryCore.PRERENDER)
supportIEPolyFills(info)
info.preRender(bundleJs)
- links.foreach(l => info.addCss(r.url(l.attr("href"))))
+ links.foreach(l => info.addCss(buildCssLink(l)))
addKalturaCss(info)
info.addCss(RenderTemplate.CUSTOMER_CSS)
}
@@ -221,7 +234,9 @@ object RenderNewTemplate {
"selectionSessionInfo",
getSelectionSessionInfo(context).orNull,
"viewedFromIntegration",
- java.lang.Boolean.valueOf(isViewingItemFromIntegration(req))
+ java.lang.Boolean.valueOf(isViewingItemFromIntegration(req)),
+ "analyticsId",
+ AnalyticsSettings.getAnalyticsId.orNull
)
val renderData =
Option(req.getAttribute(SetupJSKey).asInstanceOf[ObjectExpression => ObjectExpression])
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/core/activation/index/ActivationIndex.java b/Source/Plugins/Core/com.equella.core/src/com/tle/core/activation/index/ActivationIndex.java
index 6769722b99..e269b54a6a 100644
--- a/Source/Plugins/Core/com.equella.core/src/com/tle/core/activation/index/ActivationIndex.java
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/core/activation/index/ActivationIndex.java
@@ -25,10 +25,12 @@
import com.tle.core.activation.ActivationResult;
import com.tle.core.freetext.index.MultipleIndex;
import com.tle.core.guice.Bind;
+import com.tle.freetext.FreetextIndex;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
+import javax.inject.Inject;
import javax.inject.Singleton;
import org.apache.lucene.document.Document;
@@ -42,6 +44,11 @@ public class ActivationIndex extends MultipleIndex {
ActivationConstants.DELETE_ACTIVATION_ITEM,
ActivationConstants.DELETE_ACTIVATION_ITEM_PFX);
+ @Inject
+ public ActivationIndex(FreetextIndex freetextIndex) {
+ super(freetextIndex);
+ }
+
@Override
protected Set getKeyFields() {
return new HashSet(
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/core/freetext/index/ItemIndex.java b/Source/Plugins/Core/com.equella.core/src/com/tle/core/freetext/index/ItemIndex.java
index 82c87e8709..a3580e9dcd 100644
--- a/Source/Plugins/Core/com.equella.core/src/com/tle/core/freetext/index/ItemIndex.java
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/core/freetext/index/ItemIndex.java
@@ -78,7 +78,6 @@
import java.util.TimeZone;
import java.util.regex.Pattern;
import javax.annotation.PostConstruct;
-import javax.inject.Inject;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
@@ -143,7 +142,7 @@ public abstract class ItemIndex extends AbstractIndexE
"DOWNLOAD_ITEM",
"ACLL-");
- @Inject private FreetextIndex freetextIndex;
+ protected FreetextIndex freetextIndex;
private float titleBoost;
private float descriptionBoost;
@@ -154,6 +153,10 @@ public abstract class ItemIndex extends AbstractIndexE
private FieldSelector keyFieldSelector;
+ public ItemIndex(FreetextIndex freetextIndex) {
+ this.freetextIndex = freetextIndex;
+ }
+
@PostConstruct
@Override
public void afterPropertiesSet() throws IOException {
@@ -161,7 +164,6 @@ public void afterPropertiesSet() throws IOException {
setDefaultOperator(freetextIndex.getDefaultOperator());
setAnalyzerLanguage(freetextIndex.getAnalyzerLanguage());
keyFieldSelector = new SetBasedFieldSelector(getKeyFields(), new HashSet());
-
super.afterPropertiesSet();
}
@@ -1055,7 +1057,7 @@ protected DateFilter createDateFilter(
/**
* Takes a search request and prepares a Lucene Sort object, or null if no sorting is required.
*/
- private Sort getSorter(Search request) {
+ protected Sort getSorter(Search request) {
com.tle.common.searching.SortField[] sortfields = request.getSortFields();
if (sortfields != null) {
SortField[] convFields = new SortField[sortfields.length];
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/core/freetext/index/MultipleIndex.java b/Source/Plugins/Core/com.equella.core/src/com/tle/core/freetext/index/MultipleIndex.java
index 6f30247a8d..e5b755b852 100644
--- a/Source/Plugins/Core/com.equella.core/src/com/tle/core/freetext/index/MultipleIndex.java
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/core/freetext/index/MultipleIndex.java
@@ -26,13 +26,15 @@
import java.util.Collection;
import java.util.List;
import javax.annotation.PostConstruct;
-import javax.inject.Inject;
import org.apache.lucene.document.Document;
import org.apache.lucene.search.NRTManager;
import org.apache.lucene.search.NRTManager.TrackingIndexWriter;
public abstract class MultipleIndex extends ItemIndex {
- @Inject private FreetextIndex freetextIndex;
+
+ public MultipleIndex(FreetextIndex freetextIndex) {
+ super(freetextIndex);
+ }
public abstract String getIndexId();
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/core/freetext/index/NormalItemIndex.java b/Source/Plugins/Core/com.equella.core/src/com/tle/core/freetext/index/NormalItemIndex.java
index a724fe72fb..9d1ae3a7c3 100644
--- a/Source/Plugins/Core/com.equella.core/src/com/tle/core/freetext/index/NormalItemIndex.java
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/core/freetext/index/NormalItemIndex.java
@@ -33,7 +33,11 @@
@Singleton
@SuppressWarnings("nls")
public class NormalItemIndex extends ItemIndex {
- @Inject private FreetextIndex freetextIndex;
+
+ @Inject
+ public NormalItemIndex(FreetextIndex freetextIndex) {
+ super(freetextIndex);
+ }
@PostConstruct
@Override
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/core/freetext/indexer/StandardIndexer.java b/Source/Plugins/Core/com.equella.core/src/com/tle/core/freetext/indexer/StandardIndexer.java
index bc220f9b27..21c24a57a2 100644
--- a/Source/Plugins/Core/com.equella.core/src/com/tle/core/freetext/indexer/StandardIndexer.java
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/core/freetext/indexer/StandardIndexer.java
@@ -229,10 +229,18 @@ public List getBasicFields(IndexedItem indexedItem) {
+ "."
+ attachment.getData(DATA_VERSION))); // $NON-NLS-1$
}
- String cloudBodyText = CloudProviderService.collectBodyText(attachments);
StringBuilder bodyTextBuf = gatherLanguageBundles(item.getDescription());
- bodyTextBuf.append(cloudBodyText);
+
+ // The below if statement is added to make this class testable. The challenge is that
+ // CloudProviderService is an Scala object which cannot be mocked by either Mockito
+ // or Scalamock. As a result, we have a find a way to avoid any use of CloudProviderService
+ // in the testing environment. The string literal 'cloud' refers to the custom attachment
+ // type defined in CloudProviderService.
+ if (!attachments.getCustomList("cloud").isEmpty()) {
+ bodyTextBuf.append(CloudProviderService.collectBodyText(attachments));
+ }
+
if (!Check.isEmpty(item.getComments())) {
for (Comment comment : item.getComments()) {
if (!Check.isEmpty(comment.getComment())) {
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/core/imagemagick/ImageMagickServiceImpl.java b/Source/Plugins/Core/com.equella.core/src/com/tle/core/imagemagick/ImageMagickServiceImpl.java
index a2ebf78771..64cf832283 100644
--- a/Source/Plugins/Core/com.equella.core/src/com/tle/core/imagemagick/ImageMagickServiceImpl.java
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/core/imagemagick/ImageMagickServiceImpl.java
@@ -118,10 +118,6 @@ public void generateThumbnailAdvanced(File srcFile, File dstFile, ThumbnailOptio
opts.add(sizeX + "x" + sizeY);
}
- // Make sure we don't output CMYK as IE won't show it
- opts.add("-colorspace");
- opts.add("RGB");
-
opts.add(srcFile.getAbsolutePath());
if (!options.isNoSize()) {
@@ -179,7 +175,7 @@ public void generateThumbnailAdvanced(File srcFile, File dstFile, ThumbnailOptio
"-threshold",
"99%",
"-format",
- "\"%[fx:100*image.mean]\"",
+ "\"%[fx:100*mean]\"",
"info:"); //$NON-NLS-2$//$NON-NLS-4$
exec2.ensureOk();
if (exec2.getStdout().contains("100")) {
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/core/institution/convert/JsonHelper.java b/Source/Plugins/Core/com.equella.core/src/com/tle/core/institution/convert/JsonHelper.java
index 6d1e5b5db3..adb61f0451 100644
--- a/Source/Plugins/Core/com.equella.core/src/com/tle/core/institution/convert/JsonHelper.java
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/core/institution/convert/JsonHelper.java
@@ -21,6 +21,8 @@
import com.dytech.common.io.UnicodeReader;
import com.dytech.edge.common.Constants;
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import com.fasterxml.jackson.module.scala.DefaultScalaModule;
import com.tle.common.filesystem.handle.TemporaryFileHandle;
import com.tle.core.guice.Bind;
import com.tle.core.jackson.ObjectMapperService;
@@ -47,6 +49,8 @@ public class JsonHelper {
public synchronized ObjectMapper getMapper() {
if (mapper == null) {
mapper = objectMapperService.createObjectMapper();
+ mapper.registerModule(new DefaultScalaModule());
+ mapper.registerModule(new JavaTimeModule());
}
return mapper;
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/core/institution/migration/v20231/CreateLtiPlatformTable.java b/Source/Plugins/Core/com.equella.core/src/com/tle/core/institution/migration/v20231/CreateLtiPlatformTable.java
new file mode 100644
index 0000000000..d707330fb4
--- /dev/null
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/core/institution/migration/v20231/CreateLtiPlatformTable.java
@@ -0,0 +1,59 @@
+/*
+ * Licensed to The Apereo Foundation under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * The Apereo Foundation licenses this file to you 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 com.tle.core.institution.migration.v20231;
+
+import com.tle.beans.Institution;
+import com.tle.beans.lti.LtiPlatform;
+import com.tle.beans.lti.LtiPlatformCustomRole;
+import com.tle.beans.webkeyset.WebKeySet;
+import com.tle.core.guice.Bind;
+import com.tle.core.hibernate.impl.HibernateCreationFilter;
+import com.tle.core.hibernate.impl.HibernateMigrationHelper;
+import com.tle.core.hibernate.impl.TablesOnlyFilter;
+import com.tle.core.migration.AbstractCreateMigration;
+import com.tle.core.migration.MigrationInfo;
+import javax.inject.Singleton;
+
+@Bind
+@Singleton
+public class CreateLtiPlatformTable extends AbstractCreateMigration {
+ @Override
+ protected HibernateCreationFilter getFilter(HibernateMigrationHelper helper) {
+ return new TablesOnlyFilter(
+ "lti_platform",
+ "lti_platform_unknown_groups",
+ "lti_platform_instructor_roles",
+ "lti_platform_unknown_roles",
+ "lti_platform_custom_role",
+ "lti_platform_custom_target",
+ "lti_platform_key_pairs");
+ }
+
+ @Override
+ protected Class>[] getDomainClasses() {
+ return new Class>[] {
+ LtiPlatform.class, LtiPlatformCustomRole.class, Institution.class, WebKeySet.class
+ };
+ }
+
+ @Override
+ public MigrationInfo createMigrationInfo() {
+ return new MigrationInfo("com.tle.core.entity.services.migration.v20231.lti.platform");
+ }
+}
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/core/institution/migration/v20231/CreateWebKeySetTable.java b/Source/Plugins/Core/com.equella.core/src/com/tle/core/institution/migration/v20231/CreateWebKeySetTable.java
new file mode 100644
index 0000000000..cb7eabc088
--- /dev/null
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/core/institution/migration/v20231/CreateWebKeySetTable.java
@@ -0,0 +1,49 @@
+/*
+ * Licensed to The Apereo Foundation under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * The Apereo Foundation licenses this file to you 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 com.tle.core.institution.migration.v20231;
+
+import com.tle.beans.Institution;
+import com.tle.beans.webkeyset.WebKeySet;
+import com.tle.core.guice.Bind;
+import com.tle.core.hibernate.impl.HibernateCreationFilter;
+import com.tle.core.hibernate.impl.HibernateMigrationHelper;
+import com.tle.core.hibernate.impl.TablesOnlyFilter;
+import com.tle.core.migration.AbstractCreateMigration;
+import com.tle.core.migration.MigrationInfo;
+import javax.inject.Singleton;
+
+@Bind
+@Singleton
+public class CreateWebKeySetTable extends AbstractCreateMigration {
+
+ @Override
+ protected HibernateCreationFilter getFilter(HibernateMigrationHelper helper) {
+ return new TablesOnlyFilter("web_key_set");
+ }
+
+ @Override
+ protected Class>[] getDomainClasses() {
+ return new Class>[] {WebKeySet.class, Institution.class};
+ }
+
+ @Override
+ public MigrationInfo createMigrationInfo() {
+ return new MigrationInfo("com.tle.core.entity.services.migration.v20231.web.key.set");
+ }
+}
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/core/migration/initial/InitialSchema.java b/Source/Plugins/Core/com.equella.core/src/com/tle/core/migration/initial/InitialSchema.java
index d129493053..0ee4cd3b1b 100644
--- a/Source/Plugins/Core/com.equella.core/src/com/tle/core/migration/initial/InitialSchema.java
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/core/migration/initial/InitialSchema.java
@@ -37,6 +37,8 @@
import com.tle.beans.item.*;
import com.tle.beans.item.attachments.*;
import com.tle.beans.item.cal.request.CourseInfo;
+import com.tle.beans.lti.LtiPlatform;
+import com.tle.beans.lti.LtiPlatformCustomRole;
import com.tle.beans.mime.MimeEntry;
import com.tle.beans.newentity.Entity;
import com.tle.beans.security.ACLEntryMapping;
@@ -48,6 +50,7 @@
import com.tle.beans.user.UserInfoBackup;
import com.tle.beans.viewcount.ViewcountAttachment;
import com.tle.beans.viewcount.ViewcountItem;
+import com.tle.beans.webkeyset.WebKeySet;
import com.tle.common.security.TargetListEntry;
import com.tle.common.workflow.Workflow;
import com.tle.common.workflow.WorkflowItemStatus;
@@ -65,7 +68,6 @@
import com.tle.core.hibernate.impl.HibernateMigrationHelper;
import com.tle.core.migration.AbstractCreateMigration;
import com.tle.core.migration.MigrationInfo;
-import com.tle.core.migration.beans.SystemConfig;
import com.tle.core.plugins.PluginService;
import com.tle.core.plugins.PluginTracker;
import com.tle.web.resources.PluginResourceHelper;
@@ -151,7 +153,10 @@ public class InitialSchema extends AbstractCreateMigration {
AuditLogEntry.class,
ViewcountItem.class,
ViewcountAttachment.class,
- Entity.class
+ Entity.class,
+ WebKeySet.class,
+ LtiPlatformCustomRole.class,
+ LtiPlatform.class
};
@SuppressWarnings("nls")
@@ -242,9 +247,6 @@ protected void addExtraStatements(HibernateMigrationHelper helper, List
protected HibernateCreationFilter getFilter(HibernateMigrationHelper helper) {
AllDataHibernateMigrationFilter filter = new AllDataHibernateMigrationFilter();
Session session = helper.getFactory().openSession();
- if (helper.tableExists(session, SystemConfig.TABLE_NAME)) {
- filter.setIncludeGenerators(false);
- }
session.close();
return filter;
}
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/core/notification/standard/indexer/NotificationIndex.java b/Source/Plugins/Core/com.equella.core/src/com/tle/core/notification/standard/indexer/NotificationIndex.java
index af6199c907..2d6f0e56b4 100644
--- a/Source/Plugins/Core/com.equella.core/src/com/tle/core/notification/standard/indexer/NotificationIndex.java
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/core/notification/standard/indexer/NotificationIndex.java
@@ -22,9 +22,11 @@
import com.tle.beans.item.ItemIdKey;
import com.tle.core.freetext.index.MultipleIndex;
import com.tle.core.guice.Bind;
+import com.tle.freetext.FreetextIndex;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
+import javax.inject.Inject;
import javax.inject.Singleton;
import org.apache.lucene.document.Document;
@@ -37,6 +39,11 @@ public class NotificationIndex extends MultipleIndex {
public static final String FIELD_REASON = "note_reason"; // $NON-NLS-1$
public static final String FIELD_DATE = "note_date"; // $NON-NLS-1$
+ @Inject
+ public NotificationIndex(FreetextIndex freetextIndex) {
+ super(freetextIndex);
+ }
+
@Override
public String getIndexId() {
return INDEXID;
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/core/system/migration/InitialMigration.java b/Source/Plugins/Core/com.equella.core/src/com/tle/core/system/migration/InitialMigration.java
index a0fdbe135d..d57242ce58 100644
--- a/Source/Plugins/Core/com.equella.core/src/com/tle/core/system/migration/InitialMigration.java
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/core/system/migration/InitialMigration.java
@@ -32,9 +32,11 @@
import com.tle.core.migration.MigrationService;
import com.tle.core.migration.beans.SystemConfig;
import com.tle.core.plugins.impl.PluginServiceImpl;
+import java.util.ArrayList;
import java.util.List;
import javax.inject.Singleton;
import org.hibernate.Session;
+import org.hibernate.id.enhanced.SequenceStyleGenerator;
/**
* This migration creates two tables: sys_system_config and sys_database_schema. Usually, it's
@@ -76,16 +78,18 @@ public class InitialMigration extends AbstractHibernateSchemaMigration {
@Override
protected List getAddSql(HibernateMigrationHelper helper) {
+ // Create the sequence used as ID generator in the initial migration.
+ List sql = new ArrayList<>(getSqlCreateStringForSequence(helper));
+
Session session = helper.getFactory().openSession();
TablesOnlyFilter filter =
new TablesOnlyFilter(SystemConfig.TABLE_NAME, DatabaseSchema.TABLE_NAME);
- if (!helper.tableExists(session, ConfigurationProperty.TABLE_NAME)) {
- filter.setIncludeGenerators(true);
- }
session.close();
// Because `SystemConfig` and `DatabaseSchema` are classified as system tables,
// pass `true` to `getCreationSql`.
- return helper.getCreationSql(filter, true);
+ sql.addAll(helper.getCreationSql(filter, true));
+
+ return sql;
}
@Override
@@ -175,6 +179,10 @@ private void createConfig(Session session, String property, String value) {
session.save(sc);
}
+ private List getSqlCreateStringForSequence(HibernateMigrationHelper helper) {
+ return helper.getSqlCreateStringForSequence(SequenceStyleGenerator.DEF_SEQUENCE_NAME);
+ }
+
@Override
public MigrationInfo createMigrationInfo() {
return new MigrationInfo(
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/core/workflow/freetext/WorkflowTaskIndex.java b/Source/Plugins/Core/com.equella.core/src/com/tle/core/workflow/freetext/WorkflowTaskIndex.java
index a01b4f47e7..207220ee36 100644
--- a/Source/Plugins/Core/com.equella.core/src/com/tle/core/workflow/freetext/WorkflowTaskIndex.java
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/core/workflow/freetext/WorkflowTaskIndex.java
@@ -24,15 +24,23 @@
import com.tle.core.freetext.index.MultipleIndex;
import com.tle.core.guice.Bind;
import com.tle.core.services.item.TaskResult;
+import com.tle.freetext.FreetextIndex;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
+import javax.inject.Inject;
import javax.inject.Singleton;
import org.apache.lucene.document.Document;
@Bind
@Singleton
public class WorkflowTaskIndex extends MultipleIndex {
+
+ @Inject
+ public WorkflowTaskIndex(FreetextIndex freetextIndex) {
+ super(freetextIndex);
+ }
+
@Override
protected Set getKeyFields() {
return new HashSet(
diff --git a/Source/Plugins/Applet/com.tle.common.inplaceeditor/src/com/tle/common/inplaceeditor/InPlaceEditorServerBackend.java b/Source/Plugins/Core/com.equella.core/src/com/tle/freetext/FreetextIndexConfiguration.java
similarity index 75%
rename from Source/Plugins/Applet/com.tle.common.inplaceeditor/src/com/tle/common/inplaceeditor/InPlaceEditorServerBackend.java
rename to Source/Plugins/Core/com.equella.core/src/com/tle/freetext/FreetextIndexConfiguration.java
index 784a8f1033..ab73fd0e6f 100644
--- a/Source/Plugins/Applet/com.tle.common.inplaceeditor/src/com/tle/common/inplaceeditor/InPlaceEditorServerBackend.java
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/freetext/FreetextIndexConfiguration.java
@@ -16,10 +16,18 @@
* limitations under the License.
*/
-package com.tle.common.inplaceeditor;
+package com.tle.freetext;
-public interface InPlaceEditorServerBackend {
- String getDownloadUrl(String itemUuid, int itemVersion, String stagingId, String filename);
+import java.io.File;
- void write(String stagingId, String filename, boolean append, byte[] upload);
+public interface FreetextIndexConfiguration {
+ File getIndexPath();
+
+ String getDefaultOperator();
+
+ int getSynchroniseMinutes();
+
+ File getStopWordsFile();
+
+ String getAnalyzerLanguage();
}
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/freetext/FreetextIndexConfigurationImpl.java b/Source/Plugins/Core/com.equella.core/src/com/tle/freetext/FreetextIndexConfigurationImpl.java
new file mode 100644
index 0000000000..42f562915e
--- /dev/null
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/freetext/FreetextIndexConfigurationImpl.java
@@ -0,0 +1,75 @@
+/*
+ * Licensed to The Apereo Foundation under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * The Apereo Foundation licenses this file to you 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 com.tle.freetext;
+
+import com.google.inject.Inject;
+import com.google.inject.name.Named;
+import com.tle.core.guice.Bind;
+import java.io.File;
+import javax.inject.Singleton;
+
+@Bind(FreetextIndexConfiguration.class)
+@Singleton
+public class FreetextIndexConfigurationImpl implements FreetextIndexConfiguration {
+
+ @Inject
+ @Named("freetext.index.location")
+ File indexPath;
+
+ @Inject(optional = true)
+ @Named("freetextIndex.defaultOperator")
+ private String defaultOperator = "AND";
+
+ @Inject(optional = true)
+ @Named("freetextIndex.synchroiseMinutes")
+ private int synchroniseMinutes = 5;
+
+ @Inject
+ @Named("freetext.stopwords.file")
+ private File stopWordsFile;
+
+ @Inject
+ @Named("freetext.analyzer.language")
+ private String analyzerLanguage;
+
+ @Override
+ public File getIndexPath() {
+ return indexPath;
+ }
+
+ @Override
+ public String getDefaultOperator() {
+ return defaultOperator;
+ }
+
+ @Override
+ public int getSynchroniseMinutes() {
+ return synchroniseMinutes;
+ }
+
+ @Override
+ public File getStopWordsFile() {
+ return stopWordsFile;
+ }
+
+ @Override
+ public String getAnalyzerLanguage() {
+ return analyzerLanguage;
+ }
+}
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/freetext/FreetextIndexImpl.java b/Source/Plugins/Core/com.equella.core/src/com/tle/freetext/FreetextIndexImpl.java
index f7440afb39..8e87ffffb2 100644
--- a/Source/Plugins/Core/com.equella.core/src/com/tle/freetext/FreetextIndexImpl.java
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/freetext/FreetextIndexImpl.java
@@ -23,7 +23,6 @@
import com.dytech.edge.exceptions.SearchingException;
import com.google.common.collect.Multimap;
import com.google.inject.Inject;
-import com.google.inject.name.Named;
import com.tle.beans.Institution;
import com.tle.beans.item.Item;
import com.tle.beans.item.ItemPack;
@@ -73,58 +72,73 @@
import org.slf4j.LoggerFactory;
import org.springframework.transaction.annotation.Transactional;
-/** @author jmaginnis */
@Bind(FreetextIndex.class)
@Singleton
@SuppressWarnings("nls")
public class FreetextIndexImpl
implements FreetextIndex, InstitutionListener, ServiceCheckRequestListener {
+
private static final Logger LOGGER = LoggerFactory.getLogger(FreetextIndexImpl.class);
private static final String KEY_PFX =
PluginServiceImpl.getMyPluginId(FreetextIndexImpl.class) + '.';
- private final File indexPath;
-
- @Inject private ConfigurationService configConstants;
- @Inject private ItemDao itemDao;
- @Inject private ItemService itemService;
- @Inject private ItemHelper itemHelper;
- @Inject private RunAsInstitution runAs;
- @Inject private EventService eventService;
- @Inject private ZookeeperService zkService;
-
- @Inject(optional = true)
- @Named("freetextIndex.defaultOperator")
- private String defaultOperator = "AND";
-
- @Inject(optional = true)
- @Named("freetextIndex.synchroiseMinutes")
- private int synchroniseMinutes = 5;
-
- @Inject
- @Named("freetext.stopwords.file")
- private File stopWordsFile;
-
- @Inject
- @Named("freetext.analyzer.language")
- private String analyzerLanguage;
+ private final ConfigurationService configConstants;
+ private final ItemDao itemDao;
+ private final ItemService itemService;
+ private final ItemHelper itemHelper;
+ private final RunAsInstitution runAs;
+ private final EventService eventService;
+ private final ZookeeperService zkService;
+ private final UserPreferenceService userPrefs;
- @Inject private UserPreferenceService userPrefs;
+ private final FreetextIndexConfiguration config;
+ private int synchroniseMinutes;
+ private String defaultOperator;
private int maxBooleanClauses = 8192;
- private PluginTracker indexingTracker;
- private PluginTracker> indexTracker;
+ private final PluginTracker indexingTracker;
+ private final PluginTracker> indexTracker;
private boolean indexesHaveBeenInited;
@Inject
- public FreetextIndexImpl(@Named("freetext.index.location") File indexPath) {
- this.indexPath = indexPath;
+ public FreetextIndexImpl(
+ FreetextIndexConfiguration config,
+ ConfigurationService configConstants,
+ ItemDao itemDao,
+ ItemService itemService,
+ ItemHelper itemHelper,
+ RunAsInstitution runAs,
+ EventService eventService,
+ ZookeeperService zkService,
+ UserPreferenceService userPrefs,
+ PluginService pluginService) {
+ this.config = config;
+ this.defaultOperator = config.getDefaultOperator();
+ this.synchroniseMinutes = config.getSynchroniseMinutes();
+
+ this.configConstants = configConstants;
+
+ this.runAs = runAs;
+ this.eventService = eventService;
+ this.zkService = zkService;
+ this.userPrefs = userPrefs;
+
+ this.itemDao = itemDao;
+ this.itemService = itemService;
+ this.itemHelper = itemHelper;
+
+ indexingTracker =
+ new PluginTracker<>(pluginService, "com.tle.core.freetext", "indexingExtension", null);
+ indexingTracker.setBeanKey("class");
+ indexTracker =
+ new PluginTracker<>(pluginService, "com.tle.core.freetext", "freetextIndex", "id");
+ indexTracker.setBeanKey("class");
}
public File getIndexPath() {
- return this.indexPath;
+ return config.getIndexPath();
}
/** Invoked by Spring framework. */
@@ -132,11 +146,7 @@ public void setSynchroniseMinutes(int synchroniseMinutes) {
this.synchroniseMinutes = synchroniseMinutes;
}
- /**
- * For backwards compatibility.
- *
- * @param synchroniseMinutes
- */
+ /** For backwards compatibility. */
public void setSynchroiseMinutes(int synchroniseMinutes) {
this.synchroniseMinutes = synchroniseMinutes;
}
@@ -276,21 +286,6 @@ public Collection getIndexingExtensions() {
return beans.values();
}
- @Inject
- public void setPluginService(PluginService pluginService) {
- indexingTracker =
- new PluginTracker(
- pluginService, "com.tle.core.freetext", "indexingExtension", null); // $NON-NLS-1$
- indexingTracker.setBeanKey("class"); // $NON-NLS-1$
- indexTracker =
- new PluginTracker>(
- pluginService,
- "com.tle.core.freetext",
- "freetextIndex",
- "id"); //$NON-NLS-1$ //$NON-NLS-2$
- indexTracker.setBeanKey("class"); // $NON-NLS-1$
- }
-
@Transactional(readOnly = true)
@Override
public void prepareItemsForIndexing(Collection indexitems) {
@@ -348,12 +343,12 @@ public void setDefaultOperator(String defaultOperator) {
@Override
public File getStopWordsFile() {
- return stopWordsFile;
+ return config.getStopWordsFile();
}
@Override
public String getAnalyzerLanguage() {
- return analyzerLanguage;
+ return config.getAnalyzerLanguage();
}
@Override
@@ -363,7 +358,7 @@ public String getDefaultOperator() {
@Override
public File getRootIndexPath() {
- return indexPath;
+ return config.getIndexPath();
}
@Override
@@ -387,7 +382,6 @@ public void institutionEvent(InstitutionEvent event) {
/**
* @see com.tle.freetext.FreetextIndex#suggestTerm(com.tle.common.searching.Search,
* java.lang.String)
- * @throws InvalidSearchQueryException
*/
@Override
public String suggestTerm(Search request, String prefix) {
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/integration/blackboard/BlackboardIntegration.java b/Source/Plugins/Core/com.equella.core/src/com/tle/integration/blackboard/BlackboardIntegration.java
index 023b582b01..1548d9fdf4 100644
--- a/Source/Plugins/Core/com.equella.core/src/com/tle/integration/blackboard/BlackboardIntegration.java
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/integration/blackboard/BlackboardIntegration.java
@@ -55,7 +55,6 @@
import com.tle.web.selection.SelectedResource;
import com.tle.web.selection.SelectionSession;
import com.tle.web.viewable.ViewableItem;
-import com.tle.web.viewable.ViewableItemResolver;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Reader;
@@ -88,7 +87,6 @@ public class BlackboardIntegration extends AbstractIntegrationService> ViewableItem createViewableItem(
+ public > ViewableItem createViewableItem(
I item, SelectedResource resource) {
final ViewableItem vitem =
viewableItemResolver.createIntegrationViewableItem(
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/integration/lti/brightspace/BrightspaceIntegration.java b/Source/Plugins/Core/com.equella.core/src/com/tle/integration/lti/brightspace/BrightspaceIntegration.java
index 72a84bcf8a..33f81e6b4d 100644
--- a/Source/Plugins/Core/com.equella.core/src/com/tle/integration/lti/brightspace/BrightspaceIntegration.java
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/integration/lti/brightspace/BrightspaceIntegration.java
@@ -23,12 +23,9 @@
import com.tle.annotation.NonNullByDefault;
import com.tle.annotation.Nullable;
import com.tle.beans.item.IItem;
-import com.tle.beans.item.ItemId;
-import com.tle.beans.item.ViewableItemType;
import com.tle.beans.item.attachments.Attachment;
import com.tle.beans.item.attachments.AttachmentType;
import com.tle.beans.item.attachments.IAttachment;
-import com.tle.common.NameValue;
import com.tle.common.URLUtils;
import com.tle.common.connectors.entity.Connector;
import com.tle.common.usermanagement.user.CurrentUser;
@@ -57,7 +54,6 @@
import com.tle.web.selection.SelectedResource;
import com.tle.web.selection.SelectionSession;
import com.tle.web.selection.section.RootSelectionSection;
-import com.tle.web.viewable.ViewableItem;
import com.tle.web.viewable.ViewableItemResolver;
import java.net.URI;
import java.util.Collection;
@@ -424,35 +420,8 @@ public String getCourseInfoCode(BrightspaceSessionData data) {
return data.getCourseInfoCode();
}
- @Nullable
- @Override
- public NameValue getLocation(BrightspaceSessionData data) {
- return null;
- }
-
@Override
protected boolean canSelect(BrightspaceSessionData data) {
return true;
}
-
- @Override
- protected > ViewableItem createViewableItem(
- I item, SelectedResource resource) {
- final ViewableItem vitem =
- viewableItemResolver.createIntegrationViewableItem(
- item,
- resource.isLatest(),
- ViewableItemType.GENERIC,
- resource.getKey().getExtensionType());
- return vitem;
- }
-
- @Override
- public > ViewableItem createViewableItem(
- ItemId itemId, boolean latest, @Nullable String itemExtensionType) {
- final ViewableItem vitem =
- viewableItemResolver.createIntegrationViewableItem(
- itemId, latest, ViewableItemType.GENERIC, itemExtensionType);
- return vitem;
- }
}
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/integration/lti/canvasextension/CanvasIntegration.java b/Source/Plugins/Core/com.equella.core/src/com/tle/integration/lti/canvasextension/CanvasIntegration.java
index 513dd12370..7466b27976 100644
--- a/Source/Plugins/Core/com.equella.core/src/com/tle/integration/lti/canvasextension/CanvasIntegration.java
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/integration/lti/canvasextension/CanvasIntegration.java
@@ -32,11 +32,8 @@
import com.tle.annotation.NonNullByDefault;
import com.tle.annotation.Nullable;
import com.tle.beans.item.IItem;
-import com.tle.beans.item.ItemId;
-import com.tle.beans.item.ViewableItemType;
import com.tle.beans.item.attachments.IAttachment;
import com.tle.common.Check;
-import com.tle.common.NameValue;
import com.tle.common.connectors.ConnectorFolder;
import com.tle.common.connectors.entity.Connector;
import com.tle.common.usermanagement.user.CurrentUser;
@@ -443,37 +440,10 @@ public String getCourseInfoCode(CanvasSessionData data) {
return data.getCourseInfoCode();
}
- @Nullable
- @Override
- public NameValue getLocation(CanvasSessionData data) {
- return null;
- }
-
@Override
protected boolean canSelect(CanvasSessionData data) {
// can be select_link, embed_content etc
final String sd = data.getSelectionDirective();
return sd != null || "ContentItemSelectionRequest".equals(data.getLtiMessageType());
}
-
- @Override
- protected > ViewableItem createViewableItem(
- I item, SelectedResource resource) {
- final ViewableItem vitem =
- viewableItemResolver.createIntegrationViewableItem(
- item,
- resource.isLatest(),
- ViewableItemType.GENERIC,
- resource.getKey().getExtensionType());
- return vitem;
- }
-
- @Override
- public > ViewableItem createViewableItem(
- ItemId itemId, boolean latest, @Nullable String itemExtensionType) {
- final ViewableItem vitem =
- viewableItemResolver.createIntegrationViewableItem(
- itemId, latest, ViewableItemType.GENERIC, itemExtensionType);
- return vitem;
- }
}
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/integration/lti/generic/GenericLtiIntegration.java b/Source/Plugins/Core/com.equella.core/src/com/tle/integration/lti/generic/GenericLtiIntegration.java
index 97b932f564..47722a61ef 100644
--- a/Source/Plugins/Core/com.equella.core/src/com/tle/integration/lti/generic/GenericLtiIntegration.java
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/integration/lti/generic/GenericLtiIntegration.java
@@ -30,11 +30,8 @@
import com.tle.annotation.NonNullByDefault;
import com.tle.annotation.Nullable;
import com.tle.beans.item.IItem;
-import com.tle.beans.item.ItemId;
-import com.tle.beans.item.ViewableItemType;
import com.tle.beans.item.attachments.IAttachment;
import com.tle.common.Check;
-import com.tle.common.NameValue;
import com.tle.common.connectors.ConnectorFolder;
import com.tle.common.connectors.entity.Connector;
import com.tle.common.usermanagement.user.CurrentUser;
@@ -510,37 +507,10 @@ public String getCourseInfoCode(GenericLtiSessionData data) {
return data.getCourseInfoCode();
}
- @Nullable
- @Override
- public NameValue getLocation(GenericLtiSessionData data) {
- return null;
- }
-
@Override
protected boolean canSelect(GenericLtiSessionData data) {
// can be select_link, embed_content etc
final String sd = data.getSelectionDirective();
return sd != null || "ContentItemSelectionRequest".equals(data.getLtiMessageType());
}
-
- @Override
- protected > ViewableItem createViewableItem(
- I item, SelectedResource resource) {
- final ViewableItem vitem =
- viewableItemResolver.createIntegrationViewableItem(
- item,
- resource.isLatest(),
- ViewableItemType.GENERIC,
- resource.getKey().getExtensionType());
- return vitem;
- }
-
- @Override
- public > ViewableItem createViewableItem(
- ItemId itemId, boolean latest, @Nullable String itemExtensionType) {
- final ViewableItem vitem =
- viewableItemResolver.createIntegrationViewableItem(
- itemId, latest, ViewableItemType.GENERIC, itemExtensionType);
- return vitem;
- }
}
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/legacy/LegacyGuice.java b/Source/Plugins/Core/com.equella.core/src/com/tle/legacy/LegacyGuice.java
index 4e051c4399..15b964baa0 100644
--- a/Source/Plugins/Core/com.equella.core/src/com/tle/legacy/LegacyGuice.java
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/legacy/LegacyGuice.java
@@ -47,6 +47,7 @@
import com.tle.core.item.service.ItemService;
import com.tle.core.item.standard.service.ItemCommentService;
import com.tle.core.jackson.ObjectMapperService;
+import com.tle.core.lti13.service.LtiPlatformService;
import com.tle.core.mimetypes.MimeTypeService;
import com.tle.core.newentity.service.EntityService;
import com.tle.core.oauth.service.OAuthService;
@@ -82,6 +83,7 @@
import com.tle.web.loggedinusers.LoggedInUsersPrivilegeTreeProvider;
import com.tle.web.login.LoginSettingsPrivilegeTreeProvider;
import com.tle.web.loginnotice.LoginNoticeEditorPrivilegeTreeProvider;
+import com.tle.web.lti13.platforms.security.LTI13PlatformsSettingsPrivilegeTreeProvider;
import com.tle.web.mail.MailSettingsPrivilegeTreeProvider;
import com.tle.web.manualdatafixes.ManualDataFixesPrivilegeTreeProvider;
import com.tle.web.mimetypes.MimeSearchPrivilegeTreeProvider;
@@ -191,6 +193,10 @@ public class LegacyGuice extends AbstractModule {
@Inject public static LanguageService languageService;
+ @Inject public static LtiPlatformService ltiPlatformService;
+
+ @Inject public static LTI13PlatformsSettingsPrivilegeTreeProvider ltiPrivProvider;
+
@Inject public static LanguageSettingsPrivilegeTreeProvider langPrivProvider;
@Inject public static LoggedInUsersPrivilegeTreeProvider liuPrivProvider;
@@ -198,6 +204,8 @@ public class LegacyGuice extends AbstractModule {
@Inject
public static LoginNoticeEditorPrivilegeTreeProvider loginNoticeEditorPrivilegeTreeProvider;
+ @Inject public static LTI13PlatformsSettingsPrivilegeTreeProvider lti13PrivilegeTreeProvider;
+
@Inject public static LoginSettingsPrivilegeTreeProvider loginPrivProvider;
@Inject public static MailSettingsPrivilegeTreeProvider mailPrivProvider;
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/web/controls/flickr/FlickrSearchResultsSection.java b/Source/Plugins/Core/com.equella.core/src/com/tle/web/controls/flickr/FlickrSearchResultsSection.java
index 5dc5ac9619..f4516b6210 100644
--- a/Source/Plugins/Core/com.equella.core/src/com/tle/web/controls/flickr/FlickrSearchResultsSection.java
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/web/controls/flickr/FlickrSearchResultsSection.java
@@ -65,7 +65,7 @@
import java.util.List;
import javax.inject.Inject;
import org.jsoup.Jsoup;
-import org.jsoup.safety.Whitelist;
+import org.jsoup.safety.Safelist;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -369,7 +369,7 @@ public String stripHtmlFrom(String original) {
return original;
}
- String cleaned = Jsoup.clean(original, Whitelist.simpleText());
+ String cleaned = Jsoup.clean(original, Safelist.simpleText());
return cleaned;
}
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/web/controls/universal/handlers/FileHandlerInplaceEditorBackend.java b/Source/Plugins/Core/com.equella.core/src/com/tle/web/controls/universal/handlers/FileHandlerInplaceEditorBackend.java
deleted file mode 100644
index dfd55fefab..0000000000
--- a/Source/Plugins/Core/com.equella.core/src/com/tle/web/controls/universal/handlers/FileHandlerInplaceEditorBackend.java
+++ /dev/null
@@ -1,68 +0,0 @@
-/*
- * Licensed to The Apereo Foundation under one or more contributor license
- * agreements. See the NOTICE file distributed with this work for additional
- * information regarding copyright ownership.
- *
- * The Apereo Foundation licenses this file to you 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 com.tle.web.controls.universal.handlers;
-
-import com.tle.common.URLUtils;
-import com.tle.common.filesystem.handle.FileHandle;
-import com.tle.common.filesystem.handle.StagingFile;
-import com.tle.common.i18n.CurrentLocale;
-import com.tle.common.inplaceeditor.InPlaceEditorServerBackend;
-import com.tle.core.guice.Bind;
-import com.tle.core.institution.InstitutionService;
-import com.tle.core.services.FileSystemService;
-import java.io.ByteArrayInputStream;
-import java.io.IOException;
-import javax.inject.Inject;
-import javax.inject.Singleton;
-
-/** @author Aaron */
-@Bind
-@Singleton
-public class FileHandlerInplaceEditorBackend implements InPlaceEditorServerBackend {
- @Inject private FileSystemService fileSystemService;
- @Inject private InstitutionService institutionService;
-
- @Override
- public String getDownloadUrl(
- String wizardStagingId, int itemVersion, String stagingId, String filename) {
- // if the file doesn't exist in staging then copy it across
- // NOTE: this is completely different staging to the Wizard staging
- // (which has hijacked the itemUuid parameter)
- final FileHandle stagingFile = new StagingFile(stagingId);
- if (!fileSystemService.fileExists(stagingFile, filename)) {
- fileSystemService.copy(new StagingFile(wizardStagingId), filename, stagingFile, filename);
- }
- return institutionService.institutionalise(
- "file/" + stagingId + "/$/" + URLUtils.urlEncode(filename, false));
- }
-
- @Override
- public void write(String stagingId, String filename, boolean append, byte[] upload) {
- try {
- final FileHandle stagingFile = new StagingFile(stagingId);
- fileSystemService.write(stagingFile, filename, new ByteArrayInputStream(upload), append);
- } catch (IOException ex) {
- throw new RuntimeException(
- CurrentLocale.get(
- "com.tle.web.wizard.controls.universal.handlers.file.inplacebackend.error.write",
- filename),
- ex);
- }
- }
-}
diff --git a/Source/Plugins/Applet/com.tle.web.filemanager.applet/src/com/tle/web/controls/filemanager/FileManagerWebControl.java b/Source/Plugins/Core/com.equella.core/src/com/tle/web/controls/webdav/WebDavControl.java
similarity index 58%
rename from Source/Plugins/Applet/com.tle.web.filemanager.applet/src/com/tle/web/controls/filemanager/FileManagerWebControl.java
rename to Source/Plugins/Core/com.equella.core/src/com/tle/web/controls/webdav/WebDavControl.java
index 9efc456cd4..d8465516e5 100644
--- a/Source/Plugins/Applet/com.tle.web.filemanager.applet/src/com/tle/web/controls/filemanager/FileManagerWebControl.java
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/web/controls/webdav/WebDavControl.java
@@ -16,14 +16,16 @@
* limitations under the License.
*/
-package com.tle.web.controls.filemanager;
+package com.tle.web.controls.webdav;
import com.dytech.edge.wizard.beans.control.CustomControl;
import com.tle.beans.item.attachments.AttachmentType;
import com.tle.beans.item.attachments.FileAttachment;
-import com.tle.common.i18n.CurrentLocale;
+import com.tle.beans.item.attachments.ModifiableAttachments;
+import com.tle.common.usermanagement.user.CurrentUser;
import com.tle.core.guice.Bind;
-import com.tle.web.controls.filemanager.popup.FileManagerDialog;
+import com.tle.core.wizard.LERepository;
+import com.tle.web.core.servlet.webdav.WebDavAuthService;
import com.tle.web.freemarker.FreemarkerFactory;
import com.tle.web.freemarker.annotations.ViewFactory;
import com.tle.web.sections.SectionContext;
@@ -35,17 +37,10 @@
import com.tle.web.sections.equella.component.model.SelectionsTableSelection;
import com.tle.web.sections.events.RenderEventContext;
import com.tle.web.sections.js.ElementId;
-import com.tle.web.sections.js.generic.OverrideHandler;
-import com.tle.web.sections.js.generic.function.AnonymousFunction;
-import com.tle.web.sections.js.generic.function.ExternallyDefinedFunction;
-import com.tle.web.sections.js.generic.function.PassThroughFunction;
-import com.tle.web.sections.js.generic.function.SimpleFunction;
-import com.tle.web.sections.js.generic.statement.FunctionCallStatement;
import com.tle.web.sections.render.SectionRenderable;
import com.tle.web.sections.render.TextLabel;
import com.tle.web.sections.standard.Button;
import com.tle.web.sections.standard.annotations.Component;
-import com.tle.web.sections.standard.js.modules.StandardModule;
import com.tle.web.sections.standard.model.HtmlLinkState;
import com.tle.web.sections.standard.renderers.LinkRenderer;
import com.tle.web.wizard.controls.AbstractWebControl;
@@ -53,16 +48,16 @@
import com.tle.web.wizard.impl.WebRepository;
import java.util.List;
import javax.inject.Inject;
+import scala.Tuple2;
@SuppressWarnings("nls")
@Bind
-public class FileManagerWebControl extends AbstractWebControl {
+public class WebDavControl extends AbstractWebControl {
@ViewFactory(name = "wizardFreemarkerFactory")
private FreemarkerFactory factory;
- @Inject @Component private FileManagerDialog dialog;
+ @Inject private WebDavAuthService webDavAuthService;
- @Component private Button openWebdav;
@Component private Button refreshButton;
@Component(name = "f")
@@ -70,13 +65,15 @@ public class FileManagerWebControl extends AbstractWebControl {
@Override
public SectionResult renderHtml(RenderEventContext context) throws Exception {
- addDisabler(context, dialog.getOpener());
- setupWebdavUrl(context);
- return factory.createResult("filemanager/filemanager.ftl", context);
+ setupModel(context);
+ return factory.createResult("webdav/webdav.ftl", context);
}
private List getFiles() {
- return getRepository().getAttachments().getList(AttachmentType.FILE);
+ LERepository repo = getRepository();
+ ModifiableAttachments attachments = repo.getAttachments();
+
+ return attachments.getList(AttachmentType.FILE);
}
@Override
@@ -86,7 +83,7 @@ public boolean isEmpty() {
@Override
public void doEdits(SectionInfo info) {
- if (isAutoMarkAsResource() && isWebdav()) {
+ if (isAutoMarkAsResource()) {
WebRepository repository = (WebRepository) control.getRepository();
repository.selectTopLevelFilesAsAttachments();
}
@@ -98,67 +95,50 @@ public void registered(String id, SectionTree tree) {
refreshButton.setClickHandler(getReloadFunction(true, null));
- dialog.setFileManagerControl(this);
filesTable.setSelectionsModel(new FilesModel());
}
- @Override
- public void treeFinished(String id, SectionTree tree) {
- super.treeFinished(id, tree);
-
- if (dialog.isAjax()) {
- SimpleFunction delayedReload =
- new SimpleFunction(
- "reloadFileman",
- new FunctionCallStatement(
- StandardModule.SET_TIMEOUT,
- new AnonymousFunction(new FunctionCallStatement(getReloadFunction(true, null))),
- 800));
- dialog.setDialogClosedCallback(new PassThroughFunction("fin" + id, delayedReload));
- }
- }
-
public boolean isAutoMarkAsResource() {
CustomControl c = (CustomControl) getControlBean();
Boolean b = (Boolean) c.getAttributes().get("autoMarkAsResource");
return b == null || b.booleanValue();
}
- private void setupWebdavUrl(SectionContext context) {
- if (isWebdav()) {
- WebRepository repository = (WebRepository) control.getRepository();
- String webdav = repository.getWebUrl() + "wd/" + repository.getStagingid() + '/';
-
- openWebdav.setClickHandler(
- context,
- new OverrideHandler(
- new ExternallyDefinedFunction("openWebDav"),
- getSectionId(),
- CurrentLocale.get("wizard.controls.file.url", webdav),
- webdav));
- addDisablers(context, openWebdav, refreshButton);
- } else {
- openWebdav.setDisplayed(context, false);
+ private void setupModel(SectionContext context) {
+ WebDavControlModel model = getModel(context);
+
+ // If the control is currently disabled (say, in Moderation view) then we should
+ // simply not show the WebDAV link. As there will be no Staging ID.
+ if (!control.isEnabled()) {
+ model.setHideDetails(true);
+ return;
}
- }
- public boolean isWebdav() {
- CustomControl c = (CustomControl) getControlBean();
- Boolean b = (Boolean) c.getAttributes().get("allowWebDav");
- return b == null || b.booleanValue();
- }
+ WebRepository repository = (WebRepository) control.getRepository();
+ String webdav = repository.getWebUrl() + "wd/" + repository.getStagingid() + '/';
- @Override
- public Class getModelClass() {
- return WebControlModel.class;
- }
+ // It would also be preferable to call
+ // webDavAuthService.removeCredentials(repository.getStagingid())
+ // when the wizard is finished with this control. However I have not been able to find anything
+ // in sections which would support this. If anything we'd have to add something into
+ // com.tle.web.wizard.impl.WizardServiceImpl.removeFromSession to support this.
+ //
+ // For now though, there is the standard timeout for credentials in webDavAuthService, as well
+ // as
+ // once the staging area is deleted no WebDAV operations will work anyway (even if
+ // authenticated).
+ Tuple2 creds =
+ webDavAuthService.createCredentials(
+ repository.getStagingid(), CurrentUser.getUserID(), CurrentUser.getUsername());
- public FileManagerDialog getDialog() {
- return dialog;
+ model.setWebdavUrl(webdav);
+ model.setWebdavUsername(creds._1());
+ model.setWebdavPassword(creds._2());
}
- public Button getOpenWebdav() {
- return openWebdav;
+ @Override
+ public Class getModelClass() {
+ return WebDavControlModel.class;
}
public Button getRefreshButton() {
@@ -169,6 +149,11 @@ public SelectionsTable getFilesTable() {
return filesTable;
}
+ @Override
+ protected ElementId getIdForLabel() {
+ return null;
+ }
+
private class FilesModel extends DynamicSelectionsTableModel {
@Override
protected List getSourceList(SectionInfo info) {
@@ -191,8 +176,42 @@ protected void transform(
}
}
- @Override
- protected ElementId getIdForLabel() {
- return null;
+ public static class WebDavControlModel extends WebControlModel {
+ private boolean hideDetails = false;
+ private String webdavUrl;
+ private String webdavUsername;
+ private String webdavPassword;
+
+ public String getWebdavUrl() {
+ return webdavUrl;
+ }
+
+ public void setWebdavUrl(String webdavUrl) {
+ this.webdavUrl = webdavUrl;
+ }
+
+ public String getWebdavUsername() {
+ return webdavUsername;
+ }
+
+ public void setWebdavUsername(String webdavUsername) {
+ this.webdavUsername = webdavUsername;
+ }
+
+ public String getWebdavPassword() {
+ return webdavPassword;
+ }
+
+ public void setWebdavPassword(String webdavPassword) {
+ this.webdavPassword = webdavPassword;
+ }
+
+ public boolean isHideDetails() {
+ return hideDetails;
+ }
+
+ public void setHideDetails(boolean hideDetails) {
+ this.hideDetails = hideDetails;
+ }
}
}
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/web/core/servlet/WebdavServlet.java b/Source/Plugins/Core/com.equella.core/src/com/tle/web/core/servlet/WebdavServlet.java
index 511e204b32..b0222e872a 100644
--- a/Source/Plugins/Core/com.equella.core/src/com/tle/web/core/servlet/WebdavServlet.java
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/web/core/servlet/WebdavServlet.java
@@ -22,15 +22,22 @@
import com.dytech.devlib.PropBagEx;
import com.dytech.edge.common.Constants;
import com.google.common.io.CharStreams;
+import com.rometools.utils.Strings;
import com.tle.common.Check;
import com.tle.common.Utils;
import com.tle.common.beans.exception.NotFoundException;
import com.tle.common.filesystem.FileEntry;
import com.tle.common.filesystem.handle.StagingFile;
+import com.tle.core.auditlog.AuditLogService;
import com.tle.core.guice.Bind;
import com.tle.core.institution.InstitutionService;
import com.tle.core.mimetypes.MimeTypeService;
+import com.tle.core.replicatedcache.ReplicatedCacheService;
+import com.tle.core.replicatedcache.ReplicatedCacheService.ReplicatedCache;
import com.tle.core.services.FileSystemService;
+import com.tle.web.core.servlet.webdav.InvalidCredentials;
+import com.tle.web.core.servlet.webdav.WebDavAuthService;
+import com.tle.web.core.servlet.webdav.WebUserDetails;
import com.tle.web.core.servlet.webdav.WebdavProps;
import com.tle.web.stream.ContentStreamWriter;
import com.tle.web.stream.FileContentStream;
@@ -46,7 +53,9 @@
import java.net.URLEncoder;
import java.rmi.RemoteException;
import java.util.HashSet;
+import java.util.Optional;
import java.util.Set;
+import java.util.concurrent.TimeUnit;
import javax.inject.Inject;
import javax.inject.Singleton;
import javax.servlet.ServletException;
@@ -68,11 +77,13 @@
@Singleton
public class WebdavServlet extends HttpServlet {
private static final Logger LOGGER = LoggerFactory.getLogger(WebdavServlet.class);
+ private static final String AUDIT_CATEGORY = "WEBDAV";
private static final String CONTENT_TYPE_XML = "text/xml; charset=utf-8";
private static final int SC_MULTI_STATUS = 207;
private static final String METHODS_ALLOWED =
"OPTIONS, GET, HEAD, POST, DELETE,"
+ " TRACE, PROPFIND, PROPPATCH, COPY, MOVE, PUT, LOCK, UNLOCK";
+ private static final String BASIC_AUTH = "Basic";
public static final class HttpMethod {
public static final String GET = "GET";
@@ -100,19 +111,146 @@ private HttpMethod() {
@Inject private InstitutionService institutionService;
@Inject private MimeTypeService mimeTypeService;
@Inject private ContentStreamWriter contentStreamWriter;
+ @Inject private WebDavAuthService webDavAuthService;
+ @Inject private AuditLogService auditLogService;
+
+ /**
+ * Cache of authentication attempts. This is used to throttle auditing of authentication attempts
+ * to prevent a flood of audit log entries.
+ */
+ private final ReplicatedCache authAudited;
+
+ @Inject
+ WebdavServlet(ReplicatedCacheService replicatedCacheService) {
+ authAudited = replicatedCacheService.getCache("webdav-auth-audited", 100, 5, TimeUnit.MINUTES);
+ }
@Override
public void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException {
try {
- doProcessing(req, resp);
+ if (isAuthenticated(req)) {
+ doProcessing(req, resp);
+ } else {
+ resp.setHeader("WWW-Authenticate", "Basic realm=\"openEQUELLA WebDAV\"");
+ resp.sendError(HttpServletResponse.SC_UNAUTHORIZED);
+ }
} catch (Exception ex) {
LOGGER.error("Error in Webdav Servlet", ex);
throw new ServletException(ex);
}
}
+ /**
+ * The Staging ID is always the first part of the path. The path is always of the form
+ * /{stagingId}/path/to/file
+ */
+ private Optional getStagingId(HttpServletRequest request) {
+ return Optional.ofNullable(request.getPathInfo())
+ .map(pathInfo -> pathInfo.split("/"))
+ .filter(parts -> parts.length > 0)
+ .map(parts -> parts[1]);
+ }
+
+ private Optional getAuthenticationHeader(HttpServletRequest request) {
+ return Optional.ofNullable(request.getHeader("Authorization")).filter(Strings::isNotEmpty);
+ }
+
+ private WebUserDetails getUserDetails(String authContext) {
+ return webDavAuthService
+ .whois(authContext)
+ .getOrElse(
+ () -> {
+ LOGGER.error("Error getting user details behind request to {}", authContext);
+ return new WebUserDetails("none", "unknown");
+ });
+ }
+
+ private void auditAuthFailed(String authContext, String remoteAddr) {
+ WebUserDetails userDetails = getUserDetails(authContext);
+ auditLogService.logGeneric(
+ AUDIT_CATEGORY,
+ "AUTH ERROR",
+ remoteAddr,
+ userDetails.username(),
+ userDetails.uniqueId(),
+ authContext);
+ }
+
+ private void auditAuthSuccess(String authContext, String remoteAddr) {
+ LOGGER.debug("WebDav Authentication succeeded to {} from {}", authContext, remoteAddr);
+
+ // Because WebDAV authentication is done with basic auth, we don't want to log a successful
+ // authentication with every request. Instead we cache the fact that the user has authenticated
+ // successfully for a period of time and only log the success if the user has not authenticated.
+ authAudited
+ .get(authContext)
+ .or(
+ () -> {
+ authAudited.put(authContext, true);
+
+ WebUserDetails userDetails = getUserDetails(authContext);
+ auditLogService.logGeneric(
+ AUDIT_CATEGORY,
+ "AUTH SUCCESS",
+ remoteAddr,
+ userDetails.username(),
+ userDetails.uniqueId(),
+ authContext);
+
+ return true;
+ });
+ }
+
+ /**
+ * Checks to see if the request includes a Staging ID and a HTTP Basic authentication header. If
+ * so it uses the WebDavAuthService to validate the credentials. It is assumed the
+ * WebDavAuthService has been used elsewhere prior to establish credentials for this
+ * 'context'/Staging ID. (For example, in the WebDavControl.)
+ */
+ private boolean isAuthenticated(HttpServletRequest request) {
+ final String WEB_DAV_AUTH_FAILED = "WebDav Authentication failed";
+
+ Optional stagingId = getStagingId(request);
+ if (stagingId.isEmpty()) {
+ LOGGER.warn(WEB_DAV_AUTH_FAILED + ": Staging ID not present");
+ return false;
+ }
+
+ return getAuthenticationHeader(request)
+ .filter(
+ header -> {
+ if (!header.startsWith(BASIC_AUTH)) {
+ LOGGER.warn(WEB_DAV_AUTH_FAILED + ": Authentication Header not 'Basic'");
+ return false;
+ }
+ return true;
+ })
+ .map(header -> header.substring(BASIC_AUTH.length()).trim())
+ .filter(Strings::isNotEmpty)
+ .flatMap(
+ authz ->
+ stagingId.map(context -> webDavAuthService.validateCredentials(context, authz)))
+ .map(
+ authResult ->
+ authResult.fold(
+ error -> {
+ LOGGER.warn(
+ WEB_DAV_AUTH_FAILED + " [Staging ID: {}]: {}", stagingId.get(), error);
+ if (error.getClass() == InvalidCredentials.class) {
+ auditAuthFailed(stagingId.get(), request.getRemoteAddr());
+ }
+
+ return false;
+ },
+ success -> {
+ auditAuthSuccess(stagingId.get(), request.getRemoteAddr());
+ return true;
+ }))
+ .orElse(false);
+ }
+
protected void doProcessing(HttpServletRequest request, HttpServletResponse response)
- throws IOException, Exception {
+ throws IOException, NotFoundException {
String requestedUrl = request.getRequestURI();
LOGGER.info("URL:" + requestedUrl);
@@ -211,7 +349,7 @@ public void handleMethod(
String requestedUrl,
String stagingid,
String filename)
- throws IOException, Exception {
+ throws IOException, NotFoundException, IllegalArgumentException {
StagingFile stagingFile = null;
if (stagingid.length() > 0) {
@@ -256,23 +394,14 @@ public void handleMethod(
}
}
- /**
- * Process a GET request
- *
- * @param req
- * @param resp
- * @param itemurl
- * @param fileSystem
- * @param actualserve
- * @throws Exception
- */
+ /** Process a GET request */
private void getMethod(
HttpServletRequest request,
HttpServletResponse response,
StagingFile staging,
String fname,
boolean actualserve)
- throws Exception {
+ throws IOException, NotFoundException {
if (fileSystemService.fileExists(staging, fname)) {
if (Check.isEmpty(fname) && actualserve) {
// tell n00bs not to paste the URL in the browser
@@ -422,7 +551,7 @@ private void getMethod(
*/
public void propFindMethod(
HttpServletRequest req, HttpServletResponse resp, StagingFile staging, String filename)
- throws IOException, Exception {
+ throws IOException {
// Don't want double //'s
final String basepath =
institutionService.getInstitutionUrl() + req.getServletPath().substring(1);
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/web/customisation/RootThemeSection.java b/Source/Plugins/Core/com.equella.core/src/com/tle/web/customisation/RootThemeSection.java
index 8d0c08d892..857a66f966 100644
--- a/Source/Plugins/Core/com.equella.core/src/com/tle/web/customisation/RootThemeSection.java
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/web/customisation/RootThemeSection.java
@@ -31,6 +31,7 @@
import com.tle.web.sections.SectionInfo;
import com.tle.web.sections.SectionResult;
import com.tle.web.sections.SectionTree;
+import com.tle.web.sections.SimpleBookmarkModifier;
import com.tle.web.sections.ajax.AjaxGenerator;
import com.tle.web.sections.ajax.handler.AjaxFactory;
import com.tle.web.sections.ajax.handler.AjaxMethod;
@@ -58,6 +59,7 @@
import com.tle.web.settings.menu.SettingsUtils;
import com.tle.web.template.Breadcrumbs;
import com.tle.web.template.Decorations;
+import com.tle.web.template.RenderNewTemplate;
import com.tle.web.template.section.HelpAndScreenOptionsSection;
import java.io.IOException;
import javax.inject.Inject;
@@ -140,8 +142,14 @@ public SectionResult renderHtml(RenderEventContext context) throws IOException {
context, viewFactory.createResult("themesettingshelp.ftl", this));
if (model.isExistsCurrentTheme()) {
+
download.setBookmark(
- context, new BookmarkAndModify(context, events.getNamedModifier("download")));
+ context,
+ new BookmarkAndModify(
+ context,
+ events.getNamedModifier("download"),
+ // add disable new UI parameter for the download request
+ new SimpleBookmarkModifier(RenderNewTemplate.DisableNewUI(), "true")));
gtr.addNamedResult(
OneColumnLayout.BODY, viewFactory.createResult("currentcustomisation.ftl", context));
} else {
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/web/inplaceeditor/service/InPlaceEditorWebService.java b/Source/Plugins/Core/com.equella.core/src/com/tle/web/inplaceeditor/service/InPlaceEditorWebService.java
deleted file mode 100644
index ec93b77c2f..0000000000
--- a/Source/Plugins/Core/com.equella.core/src/com/tle/web/inplaceeditor/service/InPlaceEditorWebService.java
+++ /dev/null
@@ -1,76 +0,0 @@
-/*
- * Licensed to The Apereo Foundation under one or more contributor license
- * agreements. See the NOTICE file distributed with this work for additional
- * information regarding copyright ownership.
- *
- * The Apereo Foundation licenses this file to you 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 com.tle.web.inplaceeditor.service;
-
-import com.tle.beans.item.ItemId;
-import com.tle.web.sections.events.js.JSHandler;
-import com.tle.web.sections.jquery.JQuerySelector;
-import com.tle.web.sections.js.JSCallable;
-import com.tle.web.sections.js.JSFunction;
-import com.tle.web.sections.js.JSStatements;
-
-/** @author Aaron */
-public interface InPlaceEditorWebService {
- /**
- * @param divSelector The div surrounding the two links and applet
- * @param openWithLinkSelector The 'open with another editor' link
- * @return
- */
- JSStatements createHideLinksStatements(
- JQuerySelector divSelector, JQuerySelector openWithLinkSelector);
-
- /**
- * @param appletId
- * @param itemId
- * @param stagingId
- * @param filename
- * @param openWith
- * @param service
- * @param divSelector
- * @param height
- * @param width
- * @return
- */
- JSCallable createAppletFunction(
- String appletId,
- ItemId itemId,
- String stagingId,
- String filename,
- boolean openWith,
- String service,
- JQuerySelector divSelector,
- String width,
- String height);
-
- /**
- * @param appletId
- * @param openWith
- * @param noAppletCallback A function to load the applet if it is not found
- * @return
- */
- JSHandler createOpenHandler(String appletId, boolean openWith, JSFunction noAppletCallback);
-
- /**
- * @param appletId
- * @param doneUploadingCallback A function that is performed when the applet is done uploading or
- * has no changes to upload
- * @return
- */
- JSHandler createUploadHandler(String appletId, JSFunction doneUploadingCallback);
-}
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/web/inplaceeditor/service/InPlaceEditorWebServiceImpl.java b/Source/Plugins/Core/com.equella.core/src/com/tle/web/inplaceeditor/service/InPlaceEditorWebServiceImpl.java
deleted file mode 100644
index 11b0b6e48f..0000000000
--- a/Source/Plugins/Core/com.equella.core/src/com/tle/web/inplaceeditor/service/InPlaceEditorWebServiceImpl.java
+++ /dev/null
@@ -1,189 +0,0 @@
-/*
- * Licensed to The Apereo Foundation under one or more contributor license
- * agreements. See the NOTICE file distributed with this work for additional
- * information regarding copyright ownership.
- *
- * The Apereo Foundation licenses this file to you 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 com.tle.web.inplaceeditor.service;
-
-import com.tle.beans.item.ItemId;
-import com.tle.common.i18n.CurrentLocale;
-import com.tle.core.guice.Bind;
-import com.tle.core.institution.InstitutionService;
-import com.tle.core.mimetypes.MimeTypeService;
-import com.tle.core.settings.service.ConfigurationService;
-import com.tle.web.appletcommon.AppletWebCommon;
-import com.tle.web.resources.PluginResourceHelper;
-import com.tle.web.resources.ResourcesService;
-import com.tle.web.sections.events.RenderContext;
-import com.tle.web.sections.events.js.JSHandler;
-import com.tle.web.sections.jquery.JQuerySelector;
-import com.tle.web.sections.js.JSCallable;
-import com.tle.web.sections.js.JSFunction;
-import com.tle.web.sections.js.JSStatements;
-import com.tle.web.sections.js.generic.Js;
-import com.tle.web.sections.js.generic.OverrideHandler;
-import com.tle.web.sections.js.generic.SimpleElementId;
-import com.tle.web.sections.js.generic.expression.ObjectExpression;
-import com.tle.web.sections.js.generic.function.CallAndReferenceFunction;
-import com.tle.web.sections.js.generic.function.ExternallyDefinedFunction;
-import com.tle.web.sections.js.generic.function.IncludeFile;
-import com.tle.web.sections.js.generic.function.RuntimeFunction;
-import com.tle.web.sections.js.generic.statement.ReturnStatement;
-import java.util.UUID;
-import javax.inject.Inject;
-import javax.inject.Singleton;
-
-/** @author Aaron */
-@SuppressWarnings("nls")
-@Bind(InPlaceEditorWebService.class)
-@Singleton
-public class InPlaceEditorWebServiceImpl implements InPlaceEditorWebService {
- private static final PluginResourceHelper resources =
- ResourcesService.getResourceHelper(InPlaceEditorWebService.class);
-
- // com.tle.applet.inplaceeditor provides the codebase ...
- private static String JNLP_URL =
- resources.plugUrl("com.tle.web.inplaceeditor", "inplaceEditor.jnlp");
-
- private static final String INPLACEEDIT_APPLET_JARBASE_URL =
- resources.plugUrl("com.tle.web.inplaceeditor", "");
- private static final String INPLACEEDIT_APPLET_JAR = "inplaceedit.jar";
-
- // private static final String INPLACEEDIT_APPLET_JAR_URL =
- // resources.plugUrl("com.tle.applet.inplaceeditor",
- // "inplaceedit.jar");
- public static final String CROSSDOMAINXML_URL = resources.url("crossdomain.xml");
-
- private static final IncludeFile INPLACE_INCLUDE =
- new IncludeFile(resources.url("scripts/inplaceedit.js"), AppletWebCommon.INCLUDE);
-
- /**
- * @param $placeholder
- * @param width
- * @param height
- * @param parameters
- * @param id
- */
- private static final JSCallable CREATE_FUNCTION =
- new ExternallyDefinedFunction("inPlaceCreateApplet", INPLACE_INCLUDE);
-
- private static final JSCallable OPEN_FUNCTION =
- new ExternallyDefinedFunction("inPlaceOpen", INPLACE_INCLUDE);
- /**
- * @param appletId
- * @param submitCallback
- * @param beingUploadedMessageCallback
- * @param changesDetectedConfirmationMessageCallback
- */
- private static final JSCallable CHECK_SYNCED_FUNCTION =
- new ExternallyDefinedFunction("inPlaceCheckSynced", INPLACE_INCLUDE);
- /**
- * @param $editLinks
- * @param $openWithLink
- */
- private static final JSCallable INIT_INPLACE_FUNCTION =
- new ExternallyDefinedFunction("initInPlace", INPLACE_INCLUDE);
-
- @Inject private InstitutionService institutionService;
- @Inject private ConfigurationService configService;
- @Inject private MimeTypeService mimeTypeService;
-
- private final JSFunction beingUploadedMessageCallback =
- new RuntimeFunction() {
- @Override
- protected JSCallable createFunction(RenderContext info) {
- return CallAndReferenceFunction.get(
- Js.function(new ReturnStatement(resources.getString("alert.beinguploaded"))),
- new SimpleElementId("bup"));
- }
- };
- private final JSFunction changesDetectedConfirmationMessageCallback =
- new RuntimeFunction() {
- @Override
- protected JSCallable createFunction(RenderContext info) {
- return CallAndReferenceFunction.get(
- Js.function(new ReturnStatement(resources.getString("confirm.changesdetected"))),
- new SimpleElementId("cdt"));
- }
- };
-
- @Override
- public JSStatements createHideLinksStatements(
- JQuerySelector divSelector, JQuerySelector openWithLinkSelector) {
- return Js.call_s(INIT_INPLACE_FUNCTION, divSelector, openWithLinkSelector);
- }
-
- @Override
- public JSCallable createAppletFunction(
- String appletId,
- ItemId itemId,
- String stagingId,
- String filename,
- boolean openWith,
- String service,
- JQuerySelector divSelector,
- String width,
- String height) {
- final ObjectExpression options = new ObjectExpression();
- options.put(AppletWebCommon.PARAMETER_PREFIX + "SERVICE", service);
- options.put(AppletWebCommon.PARAMETER_PREFIX + "STAGINGID", stagingId);
- if (itemId != null) {
- options.put(AppletWebCommon.PARAMETER_PREFIX + "ITEMUUID", itemId.getUuid());
- options.put(AppletWebCommon.PARAMETER_PREFIX + "ITEMVERSION", itemId.getVersion());
- }
- options.put(AppletWebCommon.PARAMETER_PREFIX + "OPENWITH", openWith);
- options.put(AppletWebCommon.PARAMETER_PREFIX + "FILENAME", filename);
- options.put(
- AppletWebCommon.PARAMETER_PREFIX + "MIMETYPE",
- mimeTypeService.getMimeTypeForFilename(filename));
- options.put(AppletWebCommon.PARAMETER_PREFIX + "DEBUG", configService.isDebuggingMode());
- options.put(
- AppletWebCommon.PARAMETER_PREFIX + "CROSSDOMAIN",
- institutionService.institutionalise(CROSSDOMAINXML_URL));
- options.put(AppletWebCommon.PARAMETER_PREFIX + "LOCALE", CurrentLocale.getLocale().toString());
- options.put(
- AppletWebCommon.PARAMETER_PREFIX + "ENDPOINT",
- institutionService.getInstitutionUrl().toString());
- options.put(AppletWebCommon.PARAMETER_PREFIX + "INSTANCEID", UUID.randomUUID().toString());
-
- options.put("jnlp_href", institutionService.institutionalise(JNLP_URL));
- options.put("code", "com.tle.web.inplaceeditor.InPlaceEditAppletLauncher");
-
- final String base = institutionService.institutionalise(INPLACEEDIT_APPLET_JARBASE_URL);
- options.put("codebase", base);
- options.put("archive", base + INPLACEEDIT_APPLET_JAR);
-
- return CallAndReferenceFunction.get(
- Js.function(Js.call_s(CREATE_FUNCTION, divSelector, width, height, options, appletId)),
- new SimpleElementId("cap"));
- }
-
- @Override
- public JSHandler createOpenHandler(
- String appletId, boolean openWith, JSFunction noAppletCallback) {
- return new OverrideHandler(OPEN_FUNCTION, appletId, noAppletCallback, openWith);
- }
-
- @Override
- public JSHandler createUploadHandler(String appletId, JSFunction doneUploadingCallback) {
- return new OverrideHandler(
- CHECK_SYNCED_FUNCTION,
- appletId,
- doneUploadingCallback,
- beingUploadedMessageCallback,
- changesDetectedConfirmationMessageCallback);
- }
-}
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/web/integration/AbstractIntegrationService.java b/Source/Plugins/Core/com.equella.core/src/com/tle/web/integration/AbstractIntegrationService.java
index 3c12b6633e..4b0d90c09c 100644
--- a/Source/Plugins/Core/com.equella.core/src/com/tle/web/integration/AbstractIntegrationService.java
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/web/integration/AbstractIntegrationService.java
@@ -26,9 +26,11 @@
import com.tle.annotation.Nullable;
import com.tle.beans.item.IItem;
import com.tle.beans.item.ItemId;
+import com.tle.beans.item.ViewableItemType;
import com.tle.beans.item.attachments.IAttachment;
import com.tle.beans.item.attachments.UnmodifiableAttachments;
import com.tle.common.Check;
+import com.tle.common.NameValue;
import com.tle.common.i18n.CurrentLocale;
import com.tle.core.item.service.ItemResolver;
import com.tle.core.plugins.PluginTracker;
@@ -42,6 +44,7 @@
import com.tle.web.template.Decorations;
import com.tle.web.template.Decorations.MenuMode;
import com.tle.web.viewable.ViewableItem;
+import com.tle.web.viewable.ViewableItemResolver;
import com.tle.web.viewurl.ItemUrlExtender;
import com.tle.web.viewurl.ViewItemUrl;
import com.tle.web.viewurl.ViewableResource;
@@ -52,7 +55,13 @@
import org.java.plugin.registry.Extension;
import org.java.plugin.registry.Extension.Parameter;
-/** @author aholland */
+/**
+ * This abstract class is used in the integration between OEQ and an LMS through Selection Session.
+ * Each subclass must be registered in the JPF plugin file.
+ *
+ *