diff --git a/Source/Plugins/Core/com.equella.base/src/com/tle/beans/user/UserInfoBackup.java b/Source/Plugins/Core/com.equella.base/src/com/tle/beans/user/UserInfoBackup.java index 5c6e75cdf3..55de2e19d6 100644 --- a/Source/Plugins/Core/com.equella.base/src/com/tle/beans/user/UserInfoBackup.java +++ b/Source/Plugins/Core/com.equella.base/src/com/tle/beans/user/UserInfoBackup.java @@ -35,11 +35,7 @@ */ @Entity @AccessType("field") -@Table( - uniqueConstraints = { - @UniqueConstraint(columnNames = {"username", "institutionId"}), - @UniqueConstraint(columnNames = {"uniqueId", "institutionId"}) - }) +@Table(uniqueConstraints = {@UniqueConstraint(columnNames = {"uniqueId", "institutionId"})}) public class UserInfoBackup implements UserBean { private static final long serialVersionUID = 1L; diff --git a/Source/Plugins/Core/com.equella.core/plugin-jpf.xml b/Source/Plugins/Core/com.equella.core/plugin-jpf.xml index ac9654d141..95a3bda560 100644 --- a/Source/Plugins/Core/com.equella.core/plugin-jpf.xml +++ b/Source/Plugins/Core/com.equella.core/plugin-jpf.xml @@ -4271,6 +4271,10 @@ + + + + @@ -5144,6 +5148,11 @@ + + + + + diff --git a/Source/Plugins/Core/com.equella.core/resources/lang/i18n-resource-centre.properties b/Source/Plugins/Core/com.equella.core/resources/lang/i18n-resource-centre.properties index 44d23cde89..b53560c656 100644 --- a/Source/Plugins/Core/com.equella.core/resources/lang/i18n-resource-centre.properties +++ b/Source/Plugins/Core/com.equella.core/resources/lang/i18n-resource-centre.properties @@ -78,6 +78,7 @@ /com.tle.core.entity.services.migration.v64.thumbnail.columnsize.title=Increasing column size for item thumbnails /com.tle.core.entity.services.migration.v20192.unknownuser.lastowner=Add last owner to item /com.tle.core.entity.services.migration.v20192.unknownuser.alluser=Create a new table for all users +/com.tle.core.entity.services.migration.v20192.removelastknownuserconstraint=Remove last known user composite constraint (username and institution ID) /com.tle.core.entity.services.query.contains={0} is {1} /com.tle.core.entity.services.query.date.after={0} after {1} /com.tle.core.entity.services.query.date.before={0} before {1} @@ -1716,8 +1717,12 @@ export.added.summary.singleresource.contentpackage=This content package export.added.summary.singleresource.resourcesummary=This summary page export.added.summary.singleresourcemultilocations={0} was published to {1} locations export.attachments=Include files +export.authorization.newtab.description=This external connector needs to be launched in a new tab. Once the authorization flow is complete, close the new tab, and click OK in this dialog. +export.authorization.newtab.launch=Launch Authorization in New Tab +export.authorization.newtab.receipt=Authorization is complete. Please close this tab and click OK in the dialog in the original tab. export.button.auth=Authorise external system export.button.export=Add selected resources +export.button.refreshcache=Refresh Course Cache export.error.accessdenied=Access denied export.error.nolocationsselected=No locations are selected to add resources to export.error.noresourcesselected=No resources are selected to add diff --git a/Source/Plugins/Core/com.equella.core/resources/view/blackboardrestconnector.ftl b/Source/Plugins/Core/com.equella.core/resources/view/blackboardrestconnector.ftl index 346339d174..5884f9702f 100644 --- a/Source/Plugins/Core/com.equella.core/resources/view/blackboardrestconnector.ftl +++ b/Source/Plugins/Core/com.equella.core/resources/view/blackboardrestconnector.ftl @@ -7,31 +7,12 @@ <@css "blackboardconnector.css" /> -<@setting label='' help=b.key('bb.editor.help.installmodule')> -
- - -<@ajax.div id="blackboardsetup"> +<@ajax.div id="blackboardrestsetup"> <#include "/com.tle.web.connectors@/field/serverurl.ftl" /> <#if m.testedUrl??> - <@ajax.div id="testdiv"> - - <@setting - label='' - error=m.errors["blackboardwebservice"] - help=b.key('editor.help.testwebservice') - rowStyle="testBlackboardRow"> - - <@button section=s.testWebServiceButton showAs="verify" /> - <#if m.testWebServiceStatus??> - ${b.key('bb.editor.label.testwebservice.' + m.testWebServiceStatus)} - - - - <@setting label=b.key('blackboardrest.editor.label.apikey') error=m.errors["apikey"] help=b.key('blackboardrest.editor.help.apikey') diff --git a/Source/Plugins/Core/com.equella.core/resources/view/dialog/lmsauth.ftl b/Source/Plugins/Core/com.equella.core/resources/view/dialog/lmsauth.ftl index adaa047126..c5ef7f40ca 100644 --- a/Source/Plugins/Core/com.equella.core/resources/view/dialog/lmsauth.ftl +++ b/Source/Plugins/Core/com.equella.core/resources/view/dialog/lmsauth.ftl @@ -4,5 +4,24 @@ <@css "auth.css" />
- -
\ No newline at end of file + <#if m.showReceipt > +

${b.key('export.authorization.newtab.receipt')}

+ <#else> + <#if m.showNewTabLauncher > +

${b.key('export.authorization.newtab.description')}

+ + + + + <#else> + + + + diff --git a/Source/Plugins/Core/com.equella.core/resources/view/lmsexporter.ftl b/Source/Plugins/Core/com.equella.core/resources/view/lmsexporter.ftl index 7657378dc6..0b8915ecb4 100644 --- a/Source/Plugins/Core/com.equella.core/resources/view/lmsexporter.ftl +++ b/Source/Plugins/Core/com.equella.core/resources/view/lmsexporter.ftl @@ -87,8 +87,13 @@ - - + + <#if m.courseCaching> +
+ <@render s.refreshCourseCacheButton /> +
+ +
<@render s.publishButton />
@@ -96,4 +101,4 @@ - \ No newline at end of file + 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 a275bb2638..8a898a1a0e 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 @@ -624,7 +624,7 @@ Contribution wizard inputs /************************/ .wizard-controls .indent1 { - width: 556px; + width: 95%; } .wizard-controls .indent2 { @@ -848,6 +848,24 @@ a[title="move-down"]:before { width: 476px; } +@media only screen and (max-width: 768px) { + .wizard-layout .indent0 .shuffle-box-inner select { + width: 30%; + } +} + +@media only screen and (min-width: 769px) and (max-width: 1200px) { + .wizard-layout .indent0 .shuffle-box-inner select { + width: 38%; + } +} + +@media only screen and (min-width: 1201px) { + .wizard-layout .indent0 .shuffle-box-inner select { + width: 45%; + } +} + .wizard-layout .indent1 .shuffle-box-inner select { width: 236px; } @@ -1906,11 +1924,17 @@ div.skinnysearch { .autocompleteControl .autocomplete-container { float: left; - width: 513px; + // 120px for width of the select button and padding with default text 'select'. + width: calc(100% - 120px); +} + +.autocompleteControl .btn { + float: right; } #wizard-controls .autocompleteControl input[type="text"] { height: 34px; + width: 100%; } .ac_odd { @@ -2652,7 +2676,17 @@ Contribution wizard styling .wizard-layout #col2, #affix-div { width: 221px; - top: 55px; +} + +.wizard-layout .contribution-rightNav { + position: fixed; + right: 30px; +} + +.wizard-layout .moderation-rightNav { + position: fixed; + right: 30px; + top: 70px; } .wizard-layout #col2 { @@ -2721,14 +2755,9 @@ Contribution wizard styling #wizard-navigation input[type="button"] { margin: 0 0 0 3px; } -#wizard-major-actions { - display: flex; -} #wizard-major-actions .action-button.save { background-image: url("../../images/baseline_save_white_18dp.png"); - text-align: left; - padding-left: 42px; } #wizard-major-actions .action-button.edit { diff --git a/Source/Plugins/Core/com.equella.core/resources/web/scripts/wizardctrl.js b/Source/Plugins/Core/com.equella.core/resources/web/scripts/wizardctrl.js index 246d57c0e0..998bf3a88e 100644 --- a/Source/Plugins/Core/com.equella.core/resources/web/scripts/wizardctrl.js +++ b/Source/Plugins/Core/com.equella.core/resources/web/scripts/wizardctrl.js @@ -1,24 +1,53 @@ +/* + * 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. + */ var WizardCtrl = { - setMessage : function(ctrlid, message) - { - var $ctrl = $("#" + ctrlid); - var $content = $ctrl.children("div:first-child"); - var $msg = $content.children("p.ctrlinvalidmessage"); - if (!message) - { - $content.removeClass("ctrlinvalid"); - $msg.empty(); - } - else - { - $content.addClass("ctrlinvalid"); - $msg.html(message); - } - }, - affixDiv: function() { - var ad = $("#affix-div"); - ad.attr("data-spy", "affix"); - var offset = (ad.offset().top) - 55; - ad.attr("data-offset-top",offset); - } -}; \ No newline at end of file + setMessage: function (ctrlid, message) { + var $ctrl = $("#" + ctrlid); + var $content = $ctrl.children("div:first-child"); + var $msg = $content.children("p.ctrlinvalidmessage"); + if (!message) { + $content.removeClass("ctrlinvalid"); + $msg.empty(); + } else { + $content.addClass("ctrlinvalid"); + $msg.html(message); + } + }, + affixDiv: function () { + var ad = $("#affix-div"); + ad.attr("data-spy", "affix"); + var offset = ad.offset().top - 55; + ad.attr("data-offset-top", offset); + }, + affixDivNewUI: function () { + const moderationPanel = $("#moderate"); + const affixDiv = $("#affix-div"); + if (moderationPanel.length > 0) { + $(window).on("scroll", function () { + // Use outerHeight to include margin. + const moderationPanelHeight = moderationPanel.outerHeight(true); + const currentWindowYPos = $(window).scrollTop(); + currentWindowYPos >= moderationPanelHeight + ? affixDiv.addClass("moderation-rightNav") + : affixDiv.removeClass("moderation-rightNav"); + }); + } else { + affixDiv.addClass("contribution-rightNav"); + } + }, +}; diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/core/auditlog/AuditLogService.java b/Source/Plugins/Core/com.equella.core/src/com/tle/core/auditlog/AuditLogService.java index 4bdb96df01..5ad9813da8 100644 --- a/Source/Plugins/Core/com.equella.core/src/com/tle/core/auditlog/AuditLogService.java +++ b/Source/Plugins/Core/com.equella.core/src/com/tle/core/auditlog/AuditLogService.java @@ -83,6 +83,14 @@ void logItemContentViewed( void logItemPurged(Item item); + // Note: This is specific to the Blackboard REST connector, + // however, no other connector uses the audit log yet. Maybe need to refactor in the future + void logExternalConnectorUsed( + String externalConnectorUrl, + String requestLimit, + String requestRemaining, + String timeToReset); + void logGeneric( String category, String type, String data1, String data2, String data3, String data4); diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/core/auditlog/impl/AuditLogServiceImpl.java b/Source/Plugins/Core/com.equella.core/src/com/tle/core/auditlog/impl/AuditLogServiceImpl.java index 4252ec8a6f..0019364ee1 100644 --- a/Source/Plugins/Core/com.equella.core/src/com/tle/core/auditlog/impl/AuditLogServiceImpl.java +++ b/Source/Plugins/Core/com.equella.core/src/com/tle/core/auditlog/impl/AuditLogServiceImpl.java @@ -50,6 +50,7 @@ public class AuditLogServiceImpl implements AuditLogService { private static final String ENTITY_CATEGORY = "ENTITY"; private static final String SEARCH_CATEGORY = "SEARCH"; private static final String ITEM_CATEGORY = "ITEM"; + private static final String EXTERNAL_CONN_CATEGORY = "EXTERNAL_CONNECTOR"; private static final String CREATED_TYPE = "CREATED"; private static final String MODIFIED_TYPE = "MODIFIED"; @@ -60,6 +61,8 @@ public class AuditLogServiceImpl implements AuditLogService { private static final String SEARCH_FEDERATED_TYPE = "FEDERATED"; + private static final String USED_TYPE = "USED"; + private static final String TRUNCED = "..."; private PluginTracker extensionTracker; @@ -197,6 +200,21 @@ public void logItemPurged(Item item) { null); } + @Override + public void logExternalConnectorUsed( + String externalConnectorUrl, + String requestLimit, + String requestRemaining, + String timeToReset) { + logGeneric( + EXTERNAL_CONN_CATEGORY, + USED_TYPE, + externalConnectorUrl, + requestLimit, + requestRemaining, + timeToReset); + } + private void logEntityGeneric(String type, long entityId) { logGeneric(ENTITY_CATEGORY, type, CurrentUser.getUserID(), Long.toString(entityId), null, null); } diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/BlackboardRESTConnectorConstants.java b/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/BlackboardRESTConnectorConstants.java index 6ae4ae0d9b..71b4ee795e 100644 --- a/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/BlackboardRESTConnectorConstants.java +++ b/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/BlackboardRESTConnectorConstants.java @@ -18,16 +18,28 @@ package com.tle.core.connectors.blackboard; -/** @author Aaron */ @SuppressWarnings("nls") public final class BlackboardRESTConnectorConstants { - private BlackboardRESTConnectorConstants() { - throw new Error(); - } + + public static final String AUTHENTICATIONCODE_SERVICE_URI_PATH = + "/learn/api/public/v1/oauth2/authorizationcode"; + + public static final String SESSION_KEY_USER_ID = "BbRest.UserId"; + public static final String SESSION_COURSES = "BbRest.UserCourses"; + public static final String SESSION_CODE = "BbRest.Code"; + public static final String SESSION_TOKEN = "BbRest.Token"; public static final String CONNECTOR_TYPE = "blackboardrest"; - public static final String FIELD_TESTED_WEBSERVICE = "testedWebservice"; public static final String FIELD_API_KEY = "apiKey"; public static final String FIELD_API_SECRET = "apiSecret"; + + public static final String STATE_KEY_FORWARD_URL = "forwardUrl"; + public static final String STATE_KEY_POSTFIX_KEY = "postfixKey"; + + public static final String AUTH_URL = "blackboardrestauth"; + + private BlackboardRESTConnectorConstants() { + throw new Error(); + } } diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/BlackboardRestAppContext.java b/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/BlackboardRestAppContext.java new file mode 100644 index 0000000000..55f6011079 --- /dev/null +++ b/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/BlackboardRestAppContext.java @@ -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.connectors.blackboard; + +import com.tle.annotation.Nullable; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URLEncoder; + +public class BlackboardRestAppContext { + private static final String STATE_PARAMETER = "state"; + private static final String FIELD_REDIRECT_URI = "redirect_uri"; + private static final String KEY_VALUE_RESPONSE_TYPE_CODE = "response_type=code"; + private static final String FIELD_CLIENT_ID = "client_id"; + private static final String FIELD_SCOPE = "scope"; + private static final String VALUE_READ_WRITE_DELETE = "read write delete"; + + private final String _appId; + private final String _appKey; + private String _url; + + /** + * Constructs a BlackboardRestAppContext with the provided application values + * + * @param appId The application ID provided by the key tool + * @param appKey The application key provided by the key tool + * @param url The url of the Bb instance + */ + public BlackboardRestAppContext(String appId, String appKey, String url) { + _appId = appId; + _appKey = appKey; + if (url != null && url.endsWith("/")) { + _url = url.substring(0, url.lastIndexOf("/")); + } else { + _url = url; + } + } + + public URI createWebUrlForAuthentication(URI redirectUrl, @Nullable String state) { + try { + URI uri = + new URI( + _url + + BlackboardRESTConnectorConstants.AUTHENTICATIONCODE_SERVICE_URI_PATH + + "?" + + buildAuthenticationCodeUriQueryString(redirectUrl, state)); + return uri; + } catch (URISyntaxException e) { + return null; + } + } + + private String buildAuthenticationCodeUriQueryString(URI callbackUri, @Nullable String state) { + String callbackUriString = callbackUri.toString(); + String result = KEY_VALUE_RESPONSE_TYPE_CODE; + result += "&" + FIELD_REDIRECT_URI + "=" + callbackUriString; + result += "&" + FIELD_CLIENT_ID + "=" + _appId; + result += "&" + FIELD_SCOPE + "=" + URLEncoder.encode(VALUE_READ_WRITE_DELETE); + if (state != null) { + result += "&" + STATE_PARAMETER + "=" + URLEncoder.encode(state); + } + return result; + } +} diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/beans/Availability.java b/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/beans/Availability.java index d660416b5c..a87780a462 100644 --- a/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/beans/Availability.java +++ b/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/beans/Availability.java @@ -18,10 +18,11 @@ package com.tle.core.connectors.blackboard.beans; +import java.io.Serializable; import javax.xml.bind.annotation.XmlRootElement; @XmlRootElement -public class Availability { +public class Availability implements Serializable { public static final String YES = "Yes"; public static final String NO = "No"; private String available; // Yes diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/beans/Course.java b/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/beans/Course.java index e242699fd7..a870710055 100644 --- a/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/beans/Course.java +++ b/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/beans/Course.java @@ -18,10 +18,11 @@ package com.tle.core.connectors.blackboard.beans; +import java.io.Serializable; import javax.xml.bind.annotation.XmlRootElement; @XmlRootElement -public class Course { +public class Course implements Serializable { private String id; private String uuid; private String externalId; @@ -168,7 +169,7 @@ public void setGuestAccessUrl(String guestAccessUrl) { } @XmlRootElement - public static class Enrollment { + public static class Enrollment implements Serializable { private String type; // InstructorLed public String getType() { @@ -181,7 +182,7 @@ public void setType(String type) { } @XmlRootElement - public static class Locale { + public static class Locale implements Serializable { private Boolean force; public Boolean getForce() { diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/beans/CourseByUser.java b/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/beans/CourseByUser.java new file mode 100644 index 0000000000..6f0ddbc8e6 --- /dev/null +++ b/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/beans/CourseByUser.java @@ -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.connectors.blackboard.beans; + +import javax.xml.bind.annotation.XmlRootElement; + +@XmlRootElement +public class CourseByUser { + private Course course; + + public Course getCourse() { + return course; + } + + public void setCourse(Course course) { + this.course = course; + } +} diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/beans/CoursesByUser.java b/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/beans/CoursesByUser.java new file mode 100644 index 0000000000..f06cd5ccd9 --- /dev/null +++ b/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/beans/CoursesByUser.java @@ -0,0 +1,24 @@ +/* + * 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.connectors.blackboard.beans; + +import javax.xml.bind.annotation.XmlRootElement; + +@XmlRootElement +public class CoursesByUser extends PagedResults {} diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/beans/Token.java b/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/beans/Token.java index 486d49ab41..4d4d396922 100644 --- a/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/beans/Token.java +++ b/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/beans/Token.java @@ -32,6 +32,12 @@ public class Token { @JsonProperty("expires_in") private Integer expiresIn; + @JsonProperty("scope") + private String scope; + + @JsonProperty("user_id") + private String userId; + public String getAccessToken() { return accessToken; } @@ -55,4 +61,20 @@ public Integer getExpiresIn() { public void setExpiresIn(Integer expiresIn) { this.expiresIn = expiresIn; } + + public String getScope() { + return scope; + } + + public void setScope(String scope) { + this.scope = scope; + } + + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } } diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/service/BlackboardRESTConnectorService.java b/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/service/BlackboardRESTConnectorService.java index a067b2d106..989baf0c4e 100644 --- a/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/service/BlackboardRESTConnectorService.java +++ b/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/service/BlackboardRESTConnectorService.java @@ -18,6 +18,44 @@ package com.tle.core.connectors.blackboard.service; +import com.tle.annotation.Nullable; +import com.tle.common.connectors.entity.Connector; import com.tle.core.connectors.service.ConnectorRepositoryImplementation; -public interface BlackboardRESTConnectorService extends ConnectorRepositoryImplementation {} +public interface BlackboardRESTConnectorService extends ConnectorRepositoryImplementation { + // TODO may need more method sigs + + /** + * Admin setup function + * + * @param appId + * @param appKey + * @param brightspaceServerUrl + * @param forwardUrl + * @param postfixKey + * @return + */ + String getAuthorisationUrl( + String appId, + String appKey, + String brightspaceServerUrl, + String forwardUrl, + @Nullable String postfixKey); + + /** + * The connector object will need to store an encrypted admin token in the DB. Use this method to + * encrypt the one returned from Blackboard. + * + * @param token + * @return + */ + String encrypt(String data); + + String decrypt(String encryptedData); + + void setToken(Connector connector, String value); + + void setUserId(Connector connector, String value); + + void removeCachedCoursesForConnector(Connector connector); +} diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/service/impl/BlackboardRESTConnectorServiceImpl.java b/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/service/impl/BlackboardRESTConnectorServiceImpl.java index a99c64a592..e9004cf588 100644 --- a/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/service/impl/BlackboardRESTConnectorServiceImpl.java +++ b/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/service/impl/BlackboardRESTConnectorServiceImpl.java @@ -20,19 +20,18 @@ import com.dytech.devlib.Base64; import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.base.Strings; import com.google.common.base.Throwables; -import com.google.common.cache.CacheBuilder; -import com.google.common.cache.CacheLoader; -import com.google.common.cache.LoadingCache; import com.tle.annotation.NonNullByDefault; import com.tle.annotation.Nullable; -import com.tle.beans.Institution; import com.tle.beans.item.IItem; import com.tle.beans.item.ViewableItemType; +import com.tle.common.Check; import com.tle.common.PathUtils; import com.tle.common.connectors.ConnectorContent; import com.tle.common.connectors.ConnectorCourse; @@ -41,30 +40,35 @@ import com.tle.common.connectors.entity.Connector; import com.tle.common.searching.SearchResults; import com.tle.common.util.BlindSSLSocketFactory; +import com.tle.core.auditlog.AuditLogService; import com.tle.core.connectors.blackboard.BlackboardRESTConnectorConstants; +import com.tle.core.connectors.blackboard.BlackboardRestAppContext; import com.tle.core.connectors.blackboard.beans.*; import com.tle.core.connectors.blackboard.service.BlackboardRESTConnectorService; import com.tle.core.connectors.exception.LmsUserNotFoundException; import com.tle.core.connectors.service.AbstractIntegrationConnectorRespository; import com.tle.core.connectors.service.ConnectorRepositoryService; import com.tle.core.connectors.service.ConnectorService; -import com.tle.core.encryption.EncryptionService; import com.tle.core.guice.Bind; -import com.tle.core.institution.InstitutionCache; import com.tle.core.institution.InstitutionService; import com.tle.core.plugins.AbstractPluginService; import com.tle.core.services.HttpService; import com.tle.core.services.http.Request; import com.tle.core.services.http.Response; +import com.tle.core.services.user.UserSessionService; import com.tle.core.settings.service.ConfigurationService; +import com.tle.exceptions.AuthenticationException; import com.tle.web.integration.Integration; import com.tle.web.selection.SelectedResource; import java.io.IOException; import java.net.URI; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; import javax.inject.Inject; import javax.inject.Singleton; import org.apache.log4j.Level; @@ -80,15 +84,22 @@ public class BlackboardRESTConnectorServiceImpl extends AbstractIntegrationConne private static final String KEY_PFX = AbstractPluginService.getMyPluginId(BlackboardRESTConnectorService.class) + "."; - private static final String API_ROOT = "/learn/api/public/v1"; + private static final String API_ROOT_V1 = "/learn/api/public/v1/"; + private static final String API_ROOT_V3 = "/learn/api/public/v3/"; + + // Used to encrypt and decrypt state information (such as connector uuid) + // during the integration flows. Actual values are not important. + // TODO expose as a user configuration. + private static final byte[] SHAREPASS = + new byte[] {45, 12, -112, 2, 89, 97, 19, 74, 0, 24, -118, -2, 5, 108, 92, 7}; + private static final IvParameterSpec INITVEC = new IvParameterSpec("thisis16byteslog".getBytes()); @Inject private HttpService httpService; @Inject private ConfigurationService configService; @Inject private ConnectorService connectorService; - @Inject private EncryptionService encryptionService; - - private static final String TOKEN_KEY = "TOKEN"; - private InstitutionCache>> tokenCache; + @Inject private UserSessionService userSessionService; + @Inject private InstitutionService institutionService; + @Inject private AuditLogService auditService; private static final ObjectMapper jsonMapper = new ObjectMapper(); private static final ObjectMapper prettyJsonMapper = new ObjectMapper(); @@ -109,77 +120,6 @@ public BlackboardRESTConnectorServiceImpl() { Logger.getLogger("org.apache.commons.httpclient.HttpMethodDirector").setLevel(Level.ERROR); } - @Inject - public void setInstitutionService(InstitutionService service) { - tokenCache = - service.newInstitutionAwareCache( - new CacheLoader>>() { - @Override - public LoadingCache> load(Institution key) { - // MaximumSize is set to 200, which would allow for 200 Blackboard REST connectors, - // which should be more than enough for anyone. - return CacheBuilder.newBuilder() - .maximumSize(200) - .expireAfterAccess(60, TimeUnit.MINUTES) - .build( - new CacheLoader>() { - @Override - public LoadingCache load(final String connectorUuid) - throws Exception { - // BB tokens last one hour, so no point holding onto it longer than - // that. Of course, we need to handle the case - // where we are still holding onto an expired token. - - return CacheBuilder.newBuilder() - .expireAfterWrite(60, TimeUnit.MINUTES) - .build( - new CacheLoader() { - @Override - public String load(String fixedKey) { - // fixedKey is ignored. It's always TOKEN - final Connector connector = - connectorService.getByUuid(connectorUuid); - final String apiKey = - connector.getAttribute( - BlackboardRESTConnectorConstants.FIELD_API_KEY); - final String apiSecret = - encryptionService.decrypt( - connector.getAttribute( - BlackboardRESTConnectorConstants - .FIELD_API_SECRET)); - final String b64 = - new Base64() - .encode((apiKey + ":" + apiSecret).getBytes()) - .replace("\n", "") - .replace("\r", ""); - - final Request req = - new Request( - PathUtils.urlPath( - connector.getServerUrl(), - "learn/api/public/v1/oauth2/token")); - req.setMethod(Request.Method.POST); - req.setMimeType("application/x-www-form-urlencoded"); - req.addHeader("Authorization", "Basic " + b64); - req.setBody("grant_type=client_credentials"); - try (final Response resp = - httpService.getWebContent( - req, configService.getProxyDetails())) { - final Token token = - jsonMapper.readValue( - resp.getInputStream(), Token.class); - return token.getAccessToken(); - } catch (Exception e) { - throw Throwables.propagate(e); - } - } - }); - } - }); - } - }); - } - @Override protected ViewableItemType getViewableItemType() { return ViewableItemType.GENERIC; @@ -197,12 +137,64 @@ protected boolean isRelativeUrls() { @Override public boolean isRequiresAuthentication(Connector connector) { - return false; + try { + getToken(connector); + getUserId(connector); + LOGGER.debug( + "User session does not require auth for connector [" + connector.getUuid() + "]"); + return false; + } catch (AuthenticationException ex) { + LOGGER.debug("User session requires auth for connector [" + connector.getUuid() + "]"); + return true; + } } @Override - public String getAuthorisationUrl(Connector connector, String forwardUrl, String authData) { - return null; + public String getAuthorisationUrl( + Connector connector, String forwardUrl, @Nullable String authData) { + final BlackboardRestAppContext appContext = getAppContext(connector); + return getAuthorisationUrl(appContext, forwardUrl, authData, connector.getUuid()); + } + + @Override + public String getAuthorisationUrl( + String appId, + String appKey, + String bbServerUrl, + String forwardUrl, + @Nullable String postfixKey) { + final BlackboardRestAppContext appContext = getAppContext(appId, appKey, bbServerUrl); + return getAuthorisationUrl(appContext, forwardUrl, postfixKey, null); + } + + private String getAuthorisationUrl( + BlackboardRestAppContext appContext, + String forwardUrl, + @Nullable String postfixKey, + String connectorUuid) { + LOGGER.trace("Requesting auth url for [" + connectorUuid + "]"); + final ObjectMapper mapper = new ObjectMapper(); + final ObjectNode stateJson = mapper.createObjectNode(); + final String fUrl = + institutionService.getInstitutionUrl() + + "/api/connector/" + + stateJson.put(BlackboardRESTConnectorConstants.STATE_KEY_FORWARD_URL, forwardUrl); + if (postfixKey != null) { + stateJson.put(BlackboardRESTConnectorConstants.STATE_KEY_POSTFIX_KEY, postfixKey); + } + stateJson.put("connectorUuid", connectorUuid); + URI uri; + try { + uri = + appContext.createWebUrlForAuthentication( + URI.create( + institutionService.institutionalise(BlackboardRESTConnectorConstants.AUTH_URL)), + encrypt(mapper.writeValueAsString(stateJson))); + } catch (JsonProcessingException e) { + LOGGER.trace("Unable to provide the auth url for [" + connectorUuid + "]"); + throw Throwables.propagate(e); + } + return uri.toString(); } @Override @@ -211,43 +203,72 @@ public String getCourseCode(Connector connector, String username, String courseI return null; } + /** + * Requests courses the user has access to from Blackboard and caches them (per user & connector) + * + * @param connector + * @param username + * @param editableOnly If true as list of courses that the user can add content to should be + * returned. If false then ALL courses will be returned. + * @param archived + * @param management Is this for manage resources? + * @return + */ @Override public List getCourses( Connector connector, String username, boolean editableOnly, boolean archived, - boolean management) - throws LmsUserNotFoundException { - final List list = new ArrayList<>(); + boolean management) { + if (!isCoursesCached(connector)) { + String url = + API_ROOT_V1 + + "users/" + + getUserIdType() + + getUserId(connector) + + "/courses?fields=course"; + + final List allCourses = new ArrayList<>(); + + // TODO (post new UI): a more generic way of doing paged results. Contents also does paging + CoursesByUser courses = + sendBlackboardData(connector, url, CoursesByUser.class, null, Request.Method.GET); + for (CourseByUser cbu : courses.getResults()) { + allCourses.add(cbu.getCourse()); + } + Paging paging = courses.getPaging(); + + while (paging != null && paging.getNextPage() != null) { + courses = + sendBlackboardData( + connector, paging.getNextPage(), CoursesByUser.class, null, Request.Method.GET); + for (CourseByUser cbu : courses.getResults()) { + allCourses.add(cbu.getCourse()); + } + paging = courses.getPaging(); + } - // FIXME: courses for current user...? - // TODO - since v3400.8.0, this endpoint should use v2 - String url = API_ROOT + "/courses"; - /* - if( !archived ) - { - url += "&active=true"; - }*/ - final List allCourses = new ArrayList<>(); - - // TODO: a more generic way of doing paged results. Contents also does paging - Courses courses = sendBlackboardData(connector, url, Courses.class, null, Request.Method.GET); - allCourses.addAll(courses.getResults()); - Paging paging = courses.getPaging(); - - while (paging != null && paging.getNextPage() != null) { - // FIXME: construct nextUrl from the base URL we know about and the relative URL from - // getNextPage - final String nextUrl = paging.getNextPage(); - courses = sendBlackboardData(connector, nextUrl, Courses.class, null, Request.Method.GET); - allCourses.addAll(courses.getResults()); - paging = courses.getPaging(); + setCachedCourses(connector, allCourses); } + return getWrappedCachedCourses(connector, archived); + } + private boolean isCoursesCached(Connector connector) { + try { + return getCachedCourses(connector) != null; + } catch (AuthenticationException ae) { + return false; + } + } + + private List getWrappedCachedCourses( + Connector connector, boolean includeArchived) { + final List list = new ArrayList<>(); + final List allCourses = getCachedCourses(connector); for (Course course : allCourses) { // Display all courses if the archived flag is set, otherwise, just the 'available' ones - if (archived || Availability.YES.equals(course.getAvailability().getAvailable())) { + if (includeArchived || Availability.YES.equals(course.getAvailability().getAvailable())) { final ConnectorCourse cc = new ConnectorCourse(course.getId()); cc.setCourseCode(course.getCourseId()); cc.setName(course.getName()); @@ -255,14 +276,11 @@ public List getCourses( list.add(cc); } } - return list; } private Course getCourseBean(Connector connector, String courseID) { - // FIXME: courses for current user...? - // TODO - since v3400.8.0, this endpoint should use v2 - String url = API_ROOT + "/courses/" + courseID; + String url = API_ROOT_V3 + "courses/" + courseID; final Course course = sendBlackboardData(connector, url, Course.class, null, Request.Method.GET); @@ -270,9 +288,7 @@ private Course getCourseBean(Connector connector, String courseID) { } private Content getContentBean(Connector connector, String courseID, String folderID) { - // FIXME: courses for current user...? - // TODO - since v3400.8.0, this endpoint should use v2 - String url = API_ROOT + "/courses/" + courseID + "/contents/" + folderID; + String url = API_ROOT_V1 + "courses/" + courseID + "/contents/" + folderID; final Content folder = sendBlackboardData(connector, url, Content.class, null, Request.Method.GET); @@ -283,26 +299,24 @@ private Content getContentBean(Connector connector, String courseID, String fold public List getFoldersForCourse( Connector connector, String username, String courseId, boolean management) throws LmsUserNotFoundException { - // FIXME: courses for current user...? - final String url = API_ROOT + "/courses/" + courseId + "/contents"; + final String url = API_ROOT_V1 + "courses/" + courseId + "/contents"; - return retrieveFolders(connector, url, username, courseId, management); + return retrieveFolders(connector, url, courseId, management); } @Override public List getFoldersForFolder( - Connector connector, String username, String courseId, String folderId, boolean management) - throws LmsUserNotFoundException { - // FIXME: courses for current user...? - final String url = API_ROOT + "/courses/" + courseId + "/contents/" + folderId + "/children/"; + Connector connector, String username, String courseId, String folderId, boolean management) { + // Username not needed to since we authenticate via 3LO. + + final String url = API_ROOT_V1 + "courses/" + courseId + "/contents/" + folderId + "/children/"; - return retrieveFolders(connector, url, username, courseId, management); + return retrieveFolders(connector, url, courseId, management); } private List retrieveFolders( - Connector connector, String url, String username, String courseId, boolean management) { + Connector connector, String url, String courseId, boolean management) { final List list = new ArrayList<>(); - final Contents contents = sendBlackboardData(connector, url, Contents.class, null, Request.Method.GET); final ConnectorCourse course = new ConnectorCourse(courseId); @@ -316,7 +330,6 @@ private List retrieveFolders( if (content.getAvailability() != null) { cc.setAvailable(Availability.YES.equals(content.getAvailability().getAvailable())); } else { - // FIXME: Is this an appropriate default? cc.setAvailable(false); } cc.setName(content.getTitle()); @@ -337,7 +350,7 @@ public ConnectorFolder addItemToCourse( IItem item, SelectedResource selectedResource) throws LmsUserNotFoundException { - final String url = API_ROOT + "/courses/" + courseId + "/contents/" + folderId + "/children"; + final String url = API_ROOT_V1 + "courses/" + courseId + "/contents/" + folderId + "/children"; final Integration.LmsLinkInfo linkInfo = getLmsLink(item, selectedResource); final Integration.LmsLink lmsLink = linkInfo.getLmsLink(); @@ -365,15 +378,21 @@ public ConnectorFolder addItemToCourse( content.setAvailability(availability); sendBlackboardData(connector, url, null, content, Request.Method.POST); - LOGGER.trace("Returning a courseId = [" + courseId + "], and folderId = [" + folderId + "]"); + LOGGER.debug("Returning a courseId = [" + courseId + "], and folderId = [" + folderId + "]"); ConnectorFolder cf = new ConnectorFolder(folderId, new ConnectorCourse(courseId)); - // CB: Is there a better way to get the name of the folder and the course? - // AH: Unfortunately not. We could cache them, but it probably isn't worth the additional - // complexity + // TODO If folders end up being cached, pull the folder name from the cache. Content folder = getContentBean(connector, courseId, folderId); cf.setName(folder.getTitle()); - Course course = getCourseBean(connector, courseId); - cf.getCourse().setName(course.getName()); + final Course cachedCourse = getCachedCourse(connector, courseId); + if (cachedCourse == null) { + // This should never happen since the course will always be cached prior to adding content to + // it + final Course course = getCourseBean(connector, courseId); + cf.getCourse().setName(course.getName()); + } else { + cf.getCourse().setName(cachedCourse.getName()); + } + return cf; } @@ -435,6 +454,7 @@ public boolean moveContent( @Override public ConnectorTerminology getConnectorTerminology() { + LOGGER.debug("Requesting Bb REST connector terminology"); final ConnectorTerminology terms = new ConnectorTerminology(); terms.setShowArchived(getKey("finduses.showarchived")); terms.setShowArchivedLocations(getKey("finduses.showarchived.courses")); @@ -516,28 +536,38 @@ private T sendBlackboardData( if (body.length() > 0) { request.addHeader("Content-Type", "application/json"); } - if (LOGGER.isTraceEnabled()) { - LOGGER.trace("Sending " + prettyJson(body)); + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Sending " + prettyJson(body)); } + // attach cached token. (Cache knows how to get a new one) - request.addHeader("Authorization", "Bearer " + getToken(connector.getUuid())); + final String authHeaderValue = "Bearer " + getToken(connector); + LOGGER.trace( + "Setting Authorization header to [" + + authHeaderValue + + "]. Connector [" + + connector.getUuid() + + "]"); + + request.addHeader("Authorization", authHeaderValue); try (Response response = httpService.getWebContent(request, configService.getProxyDetails())) { final String responseBody = response.getBody(); + captureBlackboardRateLimitMetrics(uri.toString(), response); final int code = response.getCode(); - if (LOGGER.isTraceEnabled()) { - LOGGER.trace("Received from Blackboard (" + code + "):"); - LOGGER.trace(prettyJson(responseBody)); + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Received from Blackboard (" + code + "):"); + LOGGER.debug(prettyJson(responseBody)); } if (code == 401 && firstTime) { // Unauthorized request. Retry once to obtain a new token (assumes the current token is // expired) - LOGGER.trace( + LOGGER.debug( "Received a 401 from Blackboard. Token for connector [" + connector.getUuid() + "] is likely expired. Retrying..."); - tokenCache.getCache().get(connector.getUuid()).invalidate(TOKEN_KEY); + removeCachedValuesForConnector(connector); return sendBlackboardData(connector, path, returnType, data, method, false); } if (code >= 300) { @@ -550,11 +580,21 @@ private T sendBlackboardData( } return null; } - } catch (ExecutionException | IOException ex) { + } catch (IOException ex) { throw Throwables.propagate(ex); } } + private void captureBlackboardRateLimitMetrics(String url, Response response) { + final String xrlLimit = response.getHeader("X-Rate-Limit-Limit"); + final String xrlRemaining = response.getHeader("X-Rate-Limit-Remaining"); + final String xrlReset = response.getHeader("X-Rate-Limit-Reset"); + LOGGER.debug("X-Rate-Limit-Limit = [" + xrlLimit + "]"); + LOGGER.debug("X-Rate-Limit-Remaining = [" + xrlRemaining + "]"); + LOGGER.debug("X-Rate-Limit-Reset = [" + xrlReset + "]"); + auditService.logExternalConnectorUsed(url, xrlLimit, xrlRemaining, xrlReset); + } + @Nullable private String prettyJson(@Nullable String json) { if (Strings.isNullOrEmpty(json)) { @@ -567,15 +607,172 @@ private String prettyJson(@Nullable String json) { } } - private String getToken(String connectorUuid) { - try { - return tokenCache.getCache().get(connectorUuid).get(TOKEN_KEY); - } catch (ExecutionException e) { - throw Throwables.propagate(e); + private String getKey(String partKey) { + return KEY_PFX + "blackboardrest." + partKey; + } + + private BlackboardRestAppContext getAppContext(Connector connector) { + if (LOGGER.isTraceEnabled()) { + LOGGER.trace( + "Blackboard REST connector attributes: " + + Arrays.toString(connector.getAttributes().keySet().toArray())); } + return getAppContext( + connector.getAttribute(BlackboardRESTConnectorConstants.FIELD_API_KEY), + connector.getAttribute(BlackboardRESTConnectorConstants.FIELD_API_SECRET), + connector.getServerUrl()); } - private String getKey(String partKey) { - return KEY_PFX + "blackboardrest." + partKey; + private BlackboardRestAppContext getAppContext(String appId, String appKey, String serverUrl) { + return new BlackboardRestAppContext(appId, appKey, serverUrl); + } + + @Override + public String encrypt(String data) { + LOGGER.debug("Encrypting data"); + if (!Check.isEmpty(data)) { + try { + SecretKey key = new SecretKeySpec(SHAREPASS, "AES"); + Cipher ecipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + ecipher.init(Cipher.ENCRYPT_MODE, key, INITVEC); + + // Encrypt + byte[] enc = ecipher.doFinal(data.getBytes()); + return new Base64().encode(enc); + + } catch (Exception e) { + throw new RuntimeException("Error encrypting", e); + } + } + + return data; + } + + @Override + public String decrypt(String encryptedData) { + LOGGER.debug("Decrypting data"); + if (!Check.isEmpty(encryptedData)) { + try { + byte[] bytes = new Base64().decode(encryptedData); + SecretKey key = new SecretKeySpec(SHAREPASS, "AES"); + Cipher ecipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + ecipher.init(Cipher.DECRYPT_MODE, key, INITVEC); + return new String(ecipher.doFinal(bytes)); + } catch (Exception e) { + throw new RuntimeException("Error decrypting ", e); + } + } + + return encryptedData; + } + + public String getToken(Connector connector) { + return getCachedSessionValue(connector, BlackboardRESTConnectorConstants.SESSION_TOKEN); + } + + public String getUserId(Connector connector) { + return getCachedSessionValue(connector, BlackboardRESTConnectorConstants.SESSION_KEY_USER_ID); + } + + private void removeCachedValuesForConnector(Connector connector) { + removeCachedSessionValue(connector, BlackboardRESTConnectorConstants.SESSION_TOKEN); + removeCachedSessionValue(connector, BlackboardRESTConnectorConstants.SESSION_KEY_USER_ID); + removeCachedCoursesForConnector(connector); + } + + public void removeCachedCoursesForConnector(Connector connector) { + removeCachedSessionValue(connector, BlackboardRESTConnectorConstants.SESSION_COURSES); + } + + private String getUserIdType() { + // According to the Bb Support team, accessing the REST APIs in this manner should always return + // a userid as a uuid + return "uuid:"; + } + + public void setToken(Connector connector, String token) { + setCachedSessionValue(connector, BlackboardRESTConnectorConstants.SESSION_TOKEN, token); + } + + public void setUserId(Connector connector, String userId) { + setCachedSessionValue(connector, BlackboardRESTConnectorConstants.SESSION_KEY_USER_ID, userId); + } + + private void setCachedCourses(Connector connector, List courses) { + final String key = BlackboardRESTConnectorConstants.SESSION_COURSES; + LOGGER.debug( + "Setting user session " + + key + + " for Bb REST connector [" + + connector.getUuid() + + "] - number of cached courses [" + + courses.size() + + "]"); + + userSessionService.setAttribute(connector.getUuid() + key, courses); + } + + private List getCachedCourses(Connector connector) { + final String key = BlackboardRESTConnectorConstants.SESSION_COURSES; + final List cachedValue = userSessionService.getAttribute(connector.getUuid() + key); + if (cachedValue == null) { + LOGGER.debug( + "No user session " + key + " for Bb REST connector [" + connector.getUuid() + "]"); + throw new AuthenticationException("User was not able to obtain cached " + key + "."); + } + LOGGER.debug( + "Found a user session " + + key + + " for Bb REST connector [" + + connector.getUuid() + + "] - number of courses returned [" + + cachedValue.size() + + "]"); + return cachedValue; + } + + private Course getCachedCourse(Connector connector, String courseId) { + final List cache = getCachedCourses(connector); + for (Course c : cache) { + if (c.getId().equals(courseId)) { + return c; + } + } + return null; + } + + private String getCachedSessionValue(Connector connector, String key) { + final String cachedValue = userSessionService.getAttribute(connector.getUuid() + key); + if (cachedValue == null) { + LOGGER.debug( + "No user session " + key + " for Bb REST connector [" + connector.getUuid() + "]"); + throw new AuthenticationException("User was not able to obtain cached " + key + "."); + } + logSensitiveDetails( + "Found a user session " + key + " for Bb REST connector [" + connector.getUuid() + "]", + " - value [" + cachedValue + "]"); + return cachedValue; + } + + private void setCachedSessionValue(Connector connector, String key, String value) { + logSensitiveDetails( + "Setting user session " + key + " for Bb REST connector [" + connector.getUuid() + "]", + " to [" + value + "]"); + userSessionService.setAttribute(connector.getUuid() + key, value); + } + + private void removeCachedSessionValue(Connector connector, String key) { + LOGGER.debug( + "Removing user session " + key + " for Bb REST connector [" + connector.getUuid() + "]"); + userSessionService.removeAttribute(connector.getUuid() + key); + } + + private void logSensitiveDetails(String msg, String sensitiveMsg) { + if (LOGGER.isTraceEnabled()) { + // NOTE: Use with care - exposes sensitive details. Only to be used for investigations + LOGGER.trace(msg + sensitiveMsg); + } else { + LOGGER.debug(msg); + } } } diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/core/institution/migration/v20192/RemoveLastKnownUserConstraint.java b/Source/Plugins/Core/com.equella.core/src/com/tle/core/institution/migration/v20192/RemoveLastKnownUserConstraint.java new file mode 100644 index 0000000000..26b038c15c --- /dev/null +++ b/Source/Plugins/Core/com.equella.core/src/com/tle/core/institution/migration/v20192/RemoveLastKnownUserConstraint.java @@ -0,0 +1,98 @@ +/* + * 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.v20192; + +import com.google.inject.Singleton; +import com.thoughtworks.xstream.annotations.XStreamOmitField; +import com.tle.core.guice.Bind; +import com.tle.core.hibernate.impl.HibernateMigrationHelper; +import com.tle.core.migration.AbstractHibernateSchemaMigration; +import com.tle.core.migration.MigrationInfo; +import com.tle.core.migration.MigrationResult; +import java.util.ArrayList; +import java.util.List; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import org.hibernate.Query; +import org.hibernate.annotations.AccessType; +import org.hibernate.classic.Session; + +@Bind +@Singleton +public class RemoveLastKnownUserConstraint extends AbstractHibernateSchemaMigration { + private static final String TABLE_NAME = "user_info_backup"; + private static final String COLUMN_NAME = "username"; + private static final String TEMP_COLUMN_NAME = "username1"; + + @Override + protected List getAddSql(HibernateMigrationHelper helper) { + List sql = new ArrayList(); + // Rename column username to username1 and add a new column named username. + sql.addAll(helper.getRenameColumnSQL(TABLE_NAME, COLUMN_NAME, TEMP_COLUMN_NAME)); + sql.addAll(helper.getAddColumnsSQL(TABLE_NAME, COLUMN_NAME)); + return sql; + } + + @Override + protected void executeDataMigration( + HibernateMigrationHelper helper, MigrationResult result, Session session) throws Exception { + // Copy data from username1 to username. + Query query = + session.createQuery("UPDATE UserInfoBackup SET " + COLUMN_NAME + " = " + TEMP_COLUMN_NAME); + query.executeUpdate(); + } + + @Override + protected int countDataMigrations(HibernateMigrationHelper helper, Session session) { + return 1; + } + + @Override + protected List getDropModifySql(HibernateMigrationHelper helper) { + // Drop username1. + return helper.getDropColumnSQL(TABLE_NAME, TEMP_COLUMN_NAME); + } + + @Override + public MigrationInfo createMigrationInfo() { + return new MigrationInfo( + "com.tle.core.entity.services.migration.v20192.removelastknownuserconstraint"); + } + + @Override + protected Class[] getDomainClasses() { + return new Class[] {FakeUserInfoBackup.class}; + } + + @Entity(name = "UserInfoBackup") + @AccessType("field") + public static class FakeUserInfoBackup { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @XStreamOmitField + long id; + + @Column public String username; + + @Column public String username1; + } +} diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/core/services/user/impl/UserServiceImpl.java b/Source/Plugins/Core/com.equella.core/src/com/tle/core/services/user/impl/UserServiceImpl.java index e1eece3aa2..3005a32698 100644 --- a/Source/Plugins/Core/com.equella.core/src/com/tle/core/services/user/impl/UserServiceImpl.java +++ b/Source/Plugins/Core/com.equella.core/src/com/tle/core/services/user/impl/UserServiceImpl.java @@ -133,8 +133,8 @@ public class UserServiceImpl private boolean useXForwardedFor; @Override - public UserInfoBackup findUserInfoBackup(String username) { - return userInfoBackupDao.findUserInfoBackup(username); + public UserInfoBackup findUserInfoBackup(String userUniqueId) { + return userInfoBackupDao.findUserInfoBackup(userUniqueId); } @Transactional diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/web/connectors/blackboard/editor/BlackboardRESTConnectorEditor.java b/Source/Plugins/Core/com.equella.core/src/com/tle/web/connectors/blackboard/editor/BlackboardRESTConnectorEditor.java index 8cab19fc85..5f68da4b37 100644 --- a/Source/Plugins/Core/com.equella.core/src/com/tle/web/connectors/blackboard/editor/BlackboardRESTConnectorEditor.java +++ b/Source/Plugins/Core/com.equella.core/src/com/tle/web/connectors/blackboard/editor/BlackboardRESTConnectorEditor.java @@ -20,7 +20,6 @@ import com.tle.common.connectors.entity.Connector; import com.tle.core.connectors.blackboard.BlackboardRESTConnectorConstants; -import com.tle.core.connectors.blackboard.service.BlackboardRESTConnectorService; import com.tle.core.connectors.service.ConnectorEditingBean; import com.tle.core.encryption.EncryptionService; import com.tle.core.entity.EntityEditingSession; @@ -29,39 +28,26 @@ import com.tle.web.freemarker.FreemarkerFactory; import com.tle.web.freemarker.annotations.ViewFactory; import com.tle.web.sections.SectionInfo; -import com.tle.web.sections.SectionTree; import com.tle.web.sections.annotations.EventFactory; -import com.tle.web.sections.annotations.EventHandlerMethod; -import com.tle.web.sections.equella.annotation.PlugKey; import com.tle.web.sections.events.RenderContext; import com.tle.web.sections.events.RenderEventContext; import com.tle.web.sections.events.js.EventGenerator; -import com.tle.web.sections.render.Label; import com.tle.web.sections.render.SectionRenderable; -import com.tle.web.sections.standard.Button; import com.tle.web.sections.standard.TextField; import com.tle.web.sections.standard.annotations.Component; import java.util.Map; import javax.inject.Inject; +import org.apache.log4j.Logger; @SuppressWarnings("nls") @Bind public class BlackboardRESTConnectorEditor extends AbstractConnectorEditorSection< BlackboardRESTConnectorEditor.BlackboardRESTConnectorEditorModel> { - @PlugKey("bb.editor.error.testwebservice.mandatory") - private static Label LABEL_TEST_WEBSERVICE_MANDATORY; + private static final Logger LOGGER = Logger.getLogger(BlackboardRESTConnectorEditor.class); - @PlugKey("editor.error.testwebservice.enteruser") - private static Label LABEL_TEST_WEBSERVICE_ENTERUSER; - - @Inject private BlackboardRESTConnectorService blackboardService; @Inject private EncryptionService encryptionService; - @PlugKey("editor.button.testwebservice") - @Component - private Button testWebServiceButton; - @Component(name = "ak", stateful = false) private TextField apiKey; @@ -77,39 +63,9 @@ protected SectionRenderable renderFields( return view.createResult("blackboardrestconnector.ftl", context); } - @Override - public void registered(String id, SectionTree tree) { - super.registered(id, tree); - - testWebServiceButton.setClickHandler( - ajax.getAjaxUpdateDomFunction( - tree, this, events.getEventHandler("testWebService"), "testdiv")); - } - @Override protected String getAjaxDivId() { - return "blackboardsetup"; - } - - @EventHandlerMethod - public void testWebService(SectionInfo info) { - // final EntityEditingSession session = saveToSession(info); - // - // - // final ConnectorEditingBean connector = session.getBean(); - // - // final String result = blackboardService.testConnection(connector.getServerUrl(), ""); - // if( result == null ) - // { - // connector.setAttribute(BlackboardRESTConnectorConstants.FIELD_TESTED_WEBSERVICE, true); - // getModel(info).setTestWebServiceStatus("ok"); - // } - // else - // { - // connector.setAttribute(BlackboardRESTConnectorConstants.FIELD_TESTED_WEBSERVICE, false); - // getModel(info).setTestWebServiceStatus("fail"); - // session.getValidationErrors().put("blackboardwebservice", result); - // } + return "blackboardrestsetup"; } @Override @@ -125,12 +81,7 @@ protected Connector createNewConnector() { @Override protected void customValidate( SectionInfo info, ConnectorEditingBean connector, Map errors) { - // FIXME: actual validation of key and secret - // if( !connector.getAttribute(BlackboardRESTConnectorConstants.FIELD_TESTED_WEBSERVICE, false) - // ) - // { - // errors.put("blackboardwebservice", LABEL_TEST_WEBSERVICE_MANDATORY.getText()); - // } + // no op } @Override @@ -140,12 +91,6 @@ protected void customLoad(SectionInfo info, ConnectorEditingBean connector) { info, encryptionService.decrypt( connector.getAttribute(BlackboardRESTConnectorConstants.FIELD_API_SECRET))); - final boolean testedWebservice = - connector.getAttribute(BlackboardRESTConnectorConstants.FIELD_TESTED_WEBSERVICE, false); - if (testedWebservice) { - final BlackboardRESTConnectorEditorModel model = getModel(info); - // model.setTestWebServiceStatus("ok"); - } } @Override @@ -161,10 +106,6 @@ public Object instantiateModel(SectionInfo info) { return new BlackboardRESTConnectorEditorModel(); } - public Button getTestWebServiceButton() { - return testWebServiceButton; - } - public TextField getApiKey() { return apiKey; } diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/web/connectors/blackboard/servlet/BlackboardRestOauthSignonServlet.java b/Source/Plugins/Core/com.equella.core/src/com/tle/web/connectors/blackboard/servlet/BlackboardRestOauthSignonServlet.java new file mode 100644 index 0000000000..3ad327effe --- /dev/null +++ b/Source/Plugins/Core/com.equella.core/src/com/tle/web/connectors/blackboard/servlet/BlackboardRestOauthSignonServlet.java @@ -0,0 +1,167 @@ +/* + * 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.connectors.blackboard.servlet; + +import com.dytech.devlib.Base64; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.base.Throwables; +import com.tle.annotation.NonNullByDefault; +import com.tle.common.PathUtils; +import com.tle.common.connectors.entity.Connector; +import com.tle.core.connectors.blackboard.BlackboardRESTConnectorConstants; +import com.tle.core.connectors.blackboard.beans.Token; +import com.tle.core.connectors.blackboard.service.BlackboardRESTConnectorService; +import com.tle.core.connectors.service.ConnectorService; +import com.tle.core.encryption.EncryptionService; +import com.tle.core.guice.Bind; +import com.tle.core.institution.InstitutionService; +import com.tle.core.services.HttpService; +import com.tle.core.services.http.Request; +import com.tle.core.services.http.Response; +import com.tle.core.services.user.UserSessionService; +import com.tle.core.settings.service.ConfigurationService; +import com.tle.exceptions.AuthenticationException; +import com.tle.web.oauth.response.ErrorResponse; +import java.io.IOException; +import javax.inject.Inject; +import javax.inject.Singleton; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.apache.log4j.Logger; + +/** Served up at /blackboardrestauth */ +@SuppressWarnings("nls") +@NonNullByDefault +@Bind +@Singleton +public class BlackboardRestOauthSignonServlet extends HttpServlet { + private static final String STATE_CALLBACK_PARAMETER = "state"; + + private static final Logger LOGGER = Logger.getLogger(BlackboardRestOauthSignonServlet.class); + @Inject private HttpService httpService; + @Inject private ConnectorService connectorService; + @Inject private EncryptionService encryptionService; + @Inject private ConfigurationService configService; + @Inject private UserSessionService sessionService; + @Inject private BlackboardRESTConnectorService blackboardRestConnectorService; + @Inject private InstitutionService institutionService; + + private static final ObjectMapper jsonMapper = new ObjectMapper(); + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) + throws ServletException, IOException { + LOGGER.trace("Requesting OAuth Sign-on"); + String postfixKey = ""; + String connectorUuid = ""; + String forwardUrl = null; + String state = req.getParameter(STATE_CALLBACK_PARAMETER); + + if (state != null) { + ObjectNode stateJson = + (ObjectNode) new ObjectMapper().readTree(blackboardRestConnectorService.decrypt(state)); + JsonNode forwardUrlNode = + stateJson.get(BlackboardRESTConnectorConstants.STATE_KEY_FORWARD_URL); + if (forwardUrlNode != null) { + forwardUrl = forwardUrlNode.asText(); + } + + JsonNode postfixKeyNode = + stateJson.get(BlackboardRESTConnectorConstants.STATE_KEY_POSTFIX_KEY); + if (postfixKeyNode != null) { + postfixKey = postfixKeyNode.asText(); + } + + JsonNode connectorUuidNode = stateJson.get("connectorUuid"); + if (connectorUuidNode != null) { + connectorUuid = connectorUuidNode.asText(); + } + } + String code = req.getParameter("code"); + sessionService.setAttribute(BlackboardRESTConnectorConstants.SESSION_CODE + postfixKey, code); + + // Ask for the token. + final Connector connector = connectorService.getByUuid(connectorUuid); + final String apiKey = connector.getAttribute(BlackboardRESTConnectorConstants.FIELD_API_KEY); + final String apiSecret = + encryptionService.decrypt( + connector.getAttribute(BlackboardRESTConnectorConstants.FIELD_API_SECRET)); + final String b64 = + new Base64() + .encode((apiKey + ":" + apiSecret).getBytes()) + .replace("\n", "") + .replace("\r", ""); + + final Request oauthReq = + new Request( + PathUtils.urlPath( + connector.getServerUrl(), + "learn/api/public/v1/oauth2/token?code=" + + code + + "&redirect_uri=" + + institutionService.institutionalise( + BlackboardRESTConnectorConstants.AUTH_URL))); + oauthReq.setMethod(Request.Method.POST); + oauthReq.setMimeType("application/x-www-form-urlencoded"); + oauthReq.addHeader("Authorization", "Basic " + b64); + oauthReq.setBody("grant_type=authorization_code"); + try (final Response resp2 = + httpService.getWebContent(oauthReq, configService.getProxyDetails())) { + if (resp2.isOk()) { + LOGGER.trace("Blackboard response: " + resp2.getBody()); + final Token tokenJson = jsonMapper.readValue(resp2.getBody(), Token.class); + LOGGER.warn("Gathered Blackboard access token for [" + connectorUuid + "]"); + blackboardRestConnectorService.setToken(connector, tokenJson.getAccessToken()); + blackboardRestConnectorService.setUserId(connector, tokenJson.getUserId()); + + } else { + final ErrorResponse bbErr = jsonMapper.readValue(resp2.getBody(), ErrorResponse.class); + LOGGER.warn( + "Unable to gather Blackboard access token for [" + + connectorUuid + + "] - Code [" + + resp2.getCode() + + "] - Msg [" + + resp2.getMessage() + + "] - Body [" + + resp2.getBody() + + "]"); + throw new AuthenticationException( + "Unable to authenticate with Blackboard - " + bbErr.getErrorDescription()); + } + } catch (Exception e) { + LOGGER.warn( + "Unable to gather Blackboard access token for [" + + connectorUuid + + "] - " + + e.getMessage(), + e); + throw Throwables.propagate(e); + } + + // close dialog OR redirect... + if (forwardUrl != null) { + resp.sendRedirect(forwardUrl); + } + } +} diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/web/connectors/dialog/LMSAuthDialog.java b/Source/Plugins/Core/com.equella.core/src/com/tle/web/connectors/dialog/LMSAuthDialog.java index 87108059e1..a9f923e755 100644 --- a/Source/Plugins/Core/com.equella.core/src/com/tle/web/connectors/dialog/LMSAuthDialog.java +++ b/Source/Plugins/Core/com.equella.core/src/com/tle/web/connectors/dialog/LMSAuthDialog.java @@ -22,9 +22,11 @@ import com.tle.annotation.NonNullByDefault; import com.tle.annotation.Nullable; import com.tle.common.connectors.entity.Connector; +import com.tle.core.connectors.blackboard.BlackboardRESTConnectorConstants; import com.tle.core.connectors.service.ConnectorRepositoryService; import com.tle.core.connectors.service.ConnectorService; import com.tle.core.guice.Bind; +import com.tle.core.services.user.UserSessionService; import com.tle.web.freemarker.FreemarkerFactory; import com.tle.web.freemarker.annotations.ViewFactory; import com.tle.web.sections.SectionInfo; @@ -41,16 +43,20 @@ import com.tle.web.sections.render.SectionRenderable; import com.tle.web.sections.standard.dialog.model.DialogModel; import javax.inject.Inject; +import org.apache.log4j.Logger; /** @author Aaron */ @NonNullByDefault @Bind public class LMSAuthDialog extends AbstractOkayableDialog { + private static final Logger LOGGER = Logger.getLogger(LMSAuthDialog.class); + @PlugKey("dialog.lmsauth.title") private static Label LABEL_TITLE; @Inject private ConnectorService connectorService; @Inject private ConnectorRepositoryService repositoryService; + @Inject private UserSessionService userSessionService; @ViewFactory private FreemarkerFactory view; @@ -64,9 +70,9 @@ public LMSAuthDialog() { @Override protected SectionRenderable getRenderableContents(RenderContext context) { final Model model = getModel(context); - - final String forwardUrl = + String forwardUrl = new BookmarkAndModify(context, events.getNamedModifier("finishedAuth")).getHref(); + try { final String authUrl; if (authUrlCallable != null) { @@ -77,8 +83,16 @@ protected SectionRenderable getRenderableContents(RenderContext context) { throw new RuntimeException("No connector UUID supplied to LMSAuthDialog"); } final Connector connector = connectorService.getByUuid(connectorUuid); + if (connector.getLmsType().equals(BlackboardRESTConnectorConstants.CONNECTOR_TYPE)) { + model.setShowNewTabLauncher(true); + forwardUrl = + new BookmarkAndModify(context, events.getNamedModifier("finishedAuthNewTab")) + .getHref(); + } + authUrl = repositoryService.getAuthorisationUrl(connector, forwardUrl, null); } + LOGGER.trace("Setting authUrl to [" + authUrl + "]."); model.setAuthUrl(authUrl); } catch (Exception e) { throw Throwables.propagate(e); @@ -95,19 +109,19 @@ public void treeFinished(String id, SectionTree tree) { @EventHandlerMethod public void finishedAuth(SectionInfo info) { - // final Model model = getModel(info); - // final String connectorUuid = model.getConnectorUuid(); - // if( connectorUuid == null ) - // { - // throw new RuntimeException("No connector UUID supplied to LMSAuthDialog"); - // } - // final Connector connector = connectorService.getByUuid(connectorUuid); - // repositoryService.finishedAuthorisation(connector); - - // Close the dialog + LOGGER.trace("Finishing up the auth sequence."); closeDialog(info, parentCallback, (Object) null); } + @EventHandlerMethod + public void finishedAuthNewTab(SectionInfo info) { + LOGGER.trace("Finishing up the auth sequence via new tab."); + // Dialog is on a different tab, not able to close it. + // This is just a workaround until this flow is converted to + // the modern UI and we are done with FTL. + getModel(info).setShowReceipt(true); + } + @Override public String getWidth() { return "1024px"; @@ -142,6 +156,12 @@ public static class Model extends DialogModel { private String authUrl; + // Default behavior + private boolean showNewTabLauncher = false; + + // Default behavior + private boolean showReceipt = false; + public String getConnectorUuid() { return connectorUuid; } @@ -157,5 +177,21 @@ public String getAuthUrl() { public void setAuthUrl(String authUrl) { this.authUrl = authUrl; } + + public void setShowNewTabLauncher(boolean showNewTabLauncher) { + this.showNewTabLauncher = showNewTabLauncher; + } + + public boolean isShowNewTabLauncher() { + return this.showNewTabLauncher; + } + + public void setShowReceipt(boolean showReceipt) { + this.showReceipt = showReceipt; + } + + public boolean isShowReceipt() { + return showReceipt; + } } } diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/web/connectors/export/LMSExportSection.java b/Source/Plugins/Core/com.equella.core/src/com/tle/web/connectors/export/LMSExportSection.java index 250d8d531e..b7bf308c66 100644 --- a/Source/Plugins/Core/com.equella.core/src/com/tle/web/connectors/export/LMSExportSection.java +++ b/Source/Plugins/Core/com.equella.core/src/com/tle/web/connectors/export/LMSExportSection.java @@ -41,6 +41,8 @@ import com.tle.common.i18n.CurrentLocale; import com.tle.common.settings.standard.CourseDefaultsSettings; import com.tle.common.usermanagement.user.CurrentUser; +import com.tle.core.connectors.blackboard.BlackboardRESTConnectorConstants; +import com.tle.core.connectors.blackboard.service.BlackboardRESTConnectorService; import com.tle.core.connectors.exception.LmsUserNotFoundException; import com.tle.core.connectors.service.ConnectorRepositoryService; import com.tle.core.connectors.service.ConnectorService; @@ -195,6 +197,7 @@ public class LMSExportSection extends AbstractContentSection selectableAttachments; @Inject private ConnectorService connectorService; @Inject private ConnectorRepositoryService repositoryService; + @Inject private BlackboardRESTConnectorService blackboardRestConnectorService; @Inject private ReceiptService receiptService; @Inject private ViewAttachmentWebService viewAttachmentWebService; @Inject private ConfigurationService systemConstantsService; @@ -230,6 +233,10 @@ public class LMSExportSection extends AbstractContentSection attachmentRowDisplays = viewAttachmentWebService.createViewsForItem( @@ -454,6 +466,8 @@ public void registered(String id, SectionTree tree) { .addValidator(locationValidator) .addValidator(resourceValidator)); + refreshCourseCacheButton.addClickStatements(events.getNamedHandler("refreshCourseCache")); + folderTree.setModel(new CourseTreeModel()); folderTree.setLazyLoad(true); folderTree.setAllowMultipleOpenBranches(true); @@ -525,6 +539,19 @@ private boolean checkErrors(SectionInfo info) { return model.getError() != null; } + @EventHandlerMethod + public void refreshCourseCache(SectionInfo info) { + final BaseEntityLabel value = connectorsList.getSelectedValue(info); + + if (value != null) { + final Connector connector = connectorService.getByUuid(value.getUuid()); + if (connector != null + && connector.getLmsType().equals(BlackboardRESTConnectorConstants.CONNECTOR_TYPE)) { + blackboardRestConnectorService.removeCachedCoursesForConnector(connector); + } + } + } + @EventHandlerMethod public void publish(SectionInfo info) { ensurePriv(info); @@ -764,6 +791,10 @@ public Button getPublishButton() { return publishButton; } + public Button getRefreshCourseCacheButton() { + return refreshCourseCacheButton; + } + public Checkbox getShowArchived() { return showArchived; } @@ -786,6 +817,7 @@ public static class LMSExporterModel extends BaseLMSExportModel { private ConnectorTerminology terms; private List filteredCourses; private boolean copyrighted; + private boolean courseCaching = false; public Label getSingleConnectorName() { return singleConnectorName; @@ -803,6 +835,14 @@ public void setConnectorsCache(List connectorsCache) { this.connectorsCache = connectorsCache; } + public boolean isCourseCaching() { + return courseCaching; + } + + public void setCourseCaching(boolean courseCaching) { + this.courseCaching = courseCaching; + } + public boolean isAuthRequired() { return authRequired; } diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/web/wizard/WizardJSLibrary.java b/Source/Plugins/Core/com.equella.core/src/com/tle/web/wizard/WizardJSLibrary.java index 0cb319ede0..8ea3143f84 100644 --- a/Source/Plugins/Core/com.equella.core/src/com/tle/web/wizard/WizardJSLibrary.java +++ b/Source/Plugins/Core/com.equella.core/src/com/tle/web/wizard/WizardJSLibrary.java @@ -37,6 +37,8 @@ public final class WizardJSLibrary { new ExternallyDefinedFunction("WizardCtrl", INCLUDE); public static final JSCallable AffixDiv = new ExternallyDefinedFunction(WIZARDCTRLCLASS, "affixDiv", 0); + public static final JSCallable AffixDivNewUI = + new ExternallyDefinedFunction(WIZARDCTRLCLASS, "affixDivNewUI", 0); private WizardJSLibrary() { throw new Error(); diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/web/wizard/section/WizardBodySection.java b/Source/Plugins/Core/com.equella.core/src/com/tle/web/wizard/section/WizardBodySection.java index dfc046cf86..6d842c203c 100644 --- a/Source/Plugins/Core/com.equella.core/src/com/tle/web/wizard/section/WizardBodySection.java +++ b/Source/Plugins/Core/com.equella.core/src/com/tle/web/wizard/section/WizardBodySection.java @@ -37,6 +37,7 @@ import com.tle.web.sections.events.js.JSHandler; import com.tle.web.sections.events.js.SubmitValuesFunction; import com.tle.web.sections.generic.CachedData.CacheFiller; +import com.tle.web.sections.js.JSCallable; import com.tle.web.sections.js.generic.OverrideHandler; import com.tle.web.sections.js.generic.statement.ReturnStatement; import com.tle.web.sections.js.validators.Confirm; @@ -278,9 +279,16 @@ public SectionResult renderHtml(RenderEventContext context) throws Exception { Decorations decorations = Decorations.getDecorations(context); decorations.addContentBodyClasses(cssClass); } + + // To make the Navigation bar stick to top of the page, Old UI uses Bootstrap affix + // whereas New UI dynamically adds a CSS style by JS. + JSCallable affixScript = null; if (!RenderNewTemplate.isNewLayout(context)) { - model.getFixedDiv().getTagState().addReadyStatements(WizardJSLibrary.AffixDiv); + affixScript = WizardJSLibrary.AffixDiv; + } else { + affixScript = WizardJSLibrary.AffixDivNewUI; } + model.getFixedDiv().getTagState().addReadyStatements(affixScript); return viewFactory.createTemplateResult("wizard/body.ftl", context); } diff --git a/build.sbt b/build.sbt index 610e5055f0..1062985433 100644 --- a/build.sbt +++ b/build.sbt @@ -116,7 +116,7 @@ name := "Equella" equellaMajor in ThisBuild := 2019 equellaMinor in ThisBuild := 2 -equellaPatch in ThisBuild := 3 +equellaPatch in ThisBuild := 4 equellaStream in ThisBuild := "Stable" equellaBuild in ThisBuild := buildConfig.value.getString("build.buildname")