/* * $URL: https://source.sakaiproject.org/svn/basiclti/trunk/basiclti-util/src/java/org/imsglobal/lti2/LTI2Util.java $ * $Id: LTI2Util.java 132745 2013-12-18 16:29:22Z csev@umich.edu $ * * Copyright (c) 2013 IMS GLobal Learning Consortium * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or * implied. See the License for the specific language governing * permissions and limitations under the License. */ package org.imsglobal.lti2; import java.net.URL; import java.util.ArrayList; import java.util.Enumeration; import java.util.Iterator; import java.util.List; import java.util.Properties; import java.util.logging.Logger; import javax.servlet.http.HttpServletRequest; import org.imsglobal.basiclti.BasicLTIUtil; import org.imsglobal.lti2.objects.Service_offered; import org.imsglobal.lti2.objects.StandardServices; import org.imsglobal.lti2.objects.ToolConsumer; import org.json.simple.JSONArray; import org.json.simple.JSONObject; import org.json.simple.JSONValue; public class LTI2Util { // We use the built-in Java logger because this code needs to be very generic private static Logger M_log = Logger.getLogger(LTI2Util.class.toString()); public static final String SCOPE_LtiLink = "LtiLink"; public static final String SCOPE_ToolProxyBinding = "ToolProxyBinding"; public static final String SCOPE_ToolProxy = "ToolProxy"; private static final String EMPTY_JSON_OBJECT = "{\n}\n"; // Validate the incoming tool_services against a tool consumer public static String validateServices(ToolConsumer consumer, JSONObject providerProfile) { // Mostly to catch casting errors from bad JSON try { JSONObject security_contract = (JSONObject) providerProfile.get(LTI2Constants.SECURITY_CONTRACT); if ( security_contract == null ) { return "JSON missing security_contract"; } JSONArray tool_services = (JSONArray) security_contract.get(LTI2Constants.TOOL_SERVICE); List<Service_offered> services_offered = consumer.getService_offered(); if ( tool_services != null ) for (Object o : tool_services) { JSONObject tool_service = (JSONObject) o; String json_service = (String) tool_service.get(LTI2Constants.SERVICE); boolean found = false; for (Service_offered service : services_offered ) { String service_endpoint = service.getEndpoint(); if ( service_endpoint.equals(json_service) ) { found = true; break; } } if ( ! found ) return "Service not allowed: "+json_service; } return null; } catch (Exception e) { return "Exception:"+ e.getLocalizedMessage(); } } // Validate incoming capabilities requested against out ToolConsumer public static String validateCapabilities(ToolConsumer consumer, JSONObject providerProfile) { List<Properties> theTools = new ArrayList<Properties> (); Properties info = new Properties(); // Mostly to catch casting errors from bad JSON try { String retval = parseToolProfile(theTools, info, providerProfile); if ( retval != null ) return retval; if ( theTools.size() < 1 ) return "No tools found in profile"; // Check all the capabilities requested by all the tools comparing against consumer List<String> capabilities = consumer.getCapability_offered(); for ( Properties theTool : theTools ) { String ec = (String) theTool.get("enabled_capability"); JSONArray enabled_capability = (JSONArray) JSONValue.parse(ec); if ( enabled_capability != null ) for (Object o : enabled_capability) { ec = (String) o; if ( capabilities.contains(ec) ) continue; return "Capability not permitted="+ec; } } return null; } catch (Exception e ) { return "Exception:"+ e.getLocalizedMessage(); } } public static void allowEmail(List<String> capabilities) { capabilities.add("Person.email.primary"); } public static void allowName(List<String> capabilities) { capabilities.add("User.username"); capabilities.add("Person.name.fullname"); capabilities.add("Person.name.given"); capabilities.add("Person.name.family"); capabilities.add("Person.name.full"); } public static void allowResult(List<String> capabilities) { capabilities.add("Result.sourcedId"); capabilities.add("Result.autocreate"); capabilities.add("Result.url"); } public static void allowSettings(List<String> capabilities) { capabilities.add("LtiLink.custom.url"); capabilities.add("ToolProxy.custom.url"); capabilities.add("ToolProxyBinding.custom.url"); } // If this code looks like a hack - it is because the spec is a hack. // There are five possible scenarios for GET and two possible scenarios // for PUT. I begged to simplify the business logic but was overrulled. // So we write obtuse code. @SuppressWarnings({ "unchecked", "unused" }) public static Object getSettings(HttpServletRequest request, String scope, JSONObject link_settings, JSONObject binding_settings, JSONObject proxy_settings, String link_url, String binding_url, String proxy_url) { // Check to see if we are doing the bubble String bubbleStr = request.getParameter("bubble"); String acceptHdr = request.getHeader("Accept"); String contentHdr = request.getContentType(); if ( bubbleStr != null && bubbleStr.equals("all") && acceptHdr.indexOf(StandardServices.TOOLSETTINGS_FORMAT) < 0 ) { return "Simple format does not allow bubble=all"; } if ( SCOPE_LtiLink.equals(scope) || SCOPE_ToolProxyBinding.equals(scope) || SCOPE_ToolProxy.equals(scope) ) { // All good } else { return "Bad Setttings Scope="+scope; } boolean bubble = bubbleStr != null && "GET".equals(request.getMethod()); boolean distinct = bubbleStr != null && "distinct".equals(bubbleStr); boolean bubbleAll = bubbleStr != null && "all".equals(bubbleStr); // Check our output format boolean acceptComplex = acceptHdr == null || acceptHdr.indexOf(StandardServices.TOOLSETTINGS_FORMAT) >= 0; if ( distinct && link_settings != null && scope.equals(SCOPE_LtiLink) ) { Iterator<String> i = link_settings.keySet().iterator(); while ( i.hasNext() ) { String key = (String) i.next(); if ( binding_settings != null ) binding_settings.remove(key); if ( proxy_settings != null ) proxy_settings.remove(key); } } if ( distinct && binding_settings != null && scope.equals(SCOPE_ToolProxyBinding) ) { Iterator<String> i = binding_settings.keySet().iterator(); while ( i.hasNext() ) { String key = (String) i.next(); if ( proxy_settings != null ) proxy_settings.remove(key); } } // Lets get this party started... JSONObject jsonResponse = null; if ( (distinct || bubbleAll) && acceptComplex ) { jsonResponse = new JSONObject(); jsonResponse.put(LTI2Constants.CONTEXT,StandardServices.TOOLSETTINGS_CONTEXT); JSONArray graph = new JSONArray(); boolean started = false; if ( link_settings != null && SCOPE_LtiLink.equals(scope) ) { JSONObject cjson = new JSONObject(); cjson.put(LTI2Constants.JSONLD_ID,link_url); cjson.put(LTI2Constants.TYPE,SCOPE_LtiLink); cjson.put(LTI2Constants.CUSTOM,link_settings); graph.add(cjson); started = true; } if ( binding_settings != null && ( started || SCOPE_ToolProxyBinding.equals(scope) ) ) { JSONObject cjson = new JSONObject(); cjson.put(LTI2Constants.JSONLD_ID,binding_url); cjson.put(LTI2Constants.TYPE,SCOPE_ToolProxyBinding); cjson.put(LTI2Constants.CUSTOM,binding_settings); graph.add(cjson); started = true; } if ( proxy_settings != null && ( started || SCOPE_ToolProxy.equals(scope) ) ) { JSONObject cjson = new JSONObject(); cjson.put(LTI2Constants.JSONLD_ID,proxy_url); cjson.put(LTI2Constants.TYPE,SCOPE_ToolProxy); cjson.put(LTI2Constants.CUSTOM,proxy_settings); graph.add(cjson); } jsonResponse.put(LTI2Constants.GRAPH,graph); } else if ( distinct ) { // Simple format output jsonResponse = proxy_settings; if ( SCOPE_LtiLink.equals(scope) ) { jsonResponse.putAll(binding_settings); jsonResponse.putAll(link_settings); } else if ( SCOPE_ToolProxyBinding.equals(scope) ) { jsonResponse.putAll(binding_settings); } } else { // bubble not specified jsonResponse = new JSONObject(); jsonResponse.put(LTI2Constants.CONTEXT,StandardServices.TOOLSETTINGS_CONTEXT); JSONObject theSettings = null; String endpoint = null; if ( SCOPE_LtiLink.equals(scope) ) { endpoint = link_url; theSettings = link_settings; } else if ( SCOPE_ToolProxyBinding.equals(scope) ) { endpoint = binding_url; theSettings = binding_settings; } if ( SCOPE_ToolProxy.equals(scope) ) { endpoint = proxy_url; theSettings = proxy_settings; } if ( acceptComplex ) { JSONArray graph = new JSONArray(); JSONObject cjson = new JSONObject(); cjson.put(LTI2Constants.JSONLD_ID,endpoint); cjson.put(LTI2Constants.TYPE,scope); cjson.put(LTI2Constants.CUSTOM,theSettings); graph.add(cjson); jsonResponse.put(LTI2Constants.GRAPH,graph); } else { jsonResponse = theSettings; } } return jsonResponse; } // Parse a provider profile with lots of error checking... public static String parseToolProfile(List<Properties> theTools, Properties info, JSONObject jsonObject) { try { return parseToolProfileInternal(theTools, info, jsonObject); } catch (Exception e) { M_log.warning("Internal error parsing tool proxy\n"+jsonObject.toString()); e.printStackTrace(); return "Internal error parsing tool proxy:"+e.getLocalizedMessage(); } } // Parse a provider profile with lots of error checking... @SuppressWarnings("unused") private static String parseToolProfileInternal(List<Properties> theTools, Properties info, JSONObject jsonObject) { Object o = null; JSONObject tool_profile = (JSONObject) jsonObject.get("tool_profile"); if ( tool_profile == null ) { return "JSON missing tool_profile"; } JSONObject product_instance = (JSONObject) tool_profile.get("product_instance"); if ( product_instance == null ) { return "JSON missing product_instance"; } String instance_guid = (String) product_instance.get("guid"); if ( instance_guid == null ) { return "JSON missing product_info / guid"; } info.put("instance_guid",instance_guid); JSONObject product_info = (JSONObject) product_instance.get("product_info"); if ( product_info == null ) { return "JSON missing product_info"; } // Look for required fields JSONObject product_name = product_info == null ? null : (JSONObject) product_info.get("product_name"); String productTitle = product_name == null ? null : (String) product_name.get("default_value"); JSONObject description = product_info == null ? null : (JSONObject) product_info.get("description"); String productDescription = description == null ? null : (String) description.get("default_value"); JSONObject product_family = product_info == null ? null : (JSONObject) product_info.get("product_family"); String productCode = product_family == null ? null : (String) product_family.get("code"); JSONObject product_vendor = product_family == null ? null : (JSONObject) product_family.get("vendor"); description = product_vendor == null ? null : (JSONObject) product_vendor.get("description"); String vendorDescription = description == null ? null : (String) description.get("default_value"); String vendorCode = product_vendor == null ? null : (String) product_vendor.get("code"); if ( productTitle == null || productDescription == null ) { return "JSON missing product_name or description "; } if ( productCode == null || vendorCode == null || vendorDescription == null ) { return "JSON missing product code, vendor code or description"; } info.put("product_name", productTitle); info.put("description", productDescription); // Backwards compatibility info.put("product_description", productDescription); info.put("product_code", productCode); info.put("vendor_code", vendorCode); info.put("vendor_description", vendorDescription); o = tool_profile.get("base_url_choice"); if ( ! (o instanceof JSONArray)|| o == null ) { return "JSON missing base_url_choices"; } JSONArray base_url_choices = (JSONArray) o; String secure_base_url = null; String default_base_url = null; for ( Object i : base_url_choices ) { JSONObject url_choice = (JSONObject) i; secure_base_url = (String) url_choice.get("secure_base_url"); default_base_url = (String) url_choice.get("default_base_url"); } String launch_url = secure_base_url; if ( launch_url == null ) launch_url = default_base_url; if ( launch_url == null ) { return "Unable to determine launch URL"; } o = (JSONArray) tool_profile.get("resource_handler"); if ( ! (o instanceof JSONArray)|| o == null ) { return "JSON missing resource_handlers"; } JSONArray resource_handlers = (JSONArray) o; // Loop through resource handlers, read, and check for errors for(Object i : resource_handlers ) { JSONObject resource_handler = (JSONObject) i; JSONObject resource_type_json = (JSONObject) resource_handler.get("resource_type"); String resource_type_code = (String) resource_type_json.get("code"); if ( resource_type_code == null ) { return "JSON missing resource_type code"; } o = (JSONArray) resource_handler.get("message"); if ( ! (o instanceof JSONArray)|| o == null ) { return "JSON missing resource_handler / message"; } JSONArray messages = (JSONArray) o; JSONObject titleObject = (JSONObject) resource_handler.get("name"); String title = titleObject == null ? null : (String) titleObject.get("default_value"); if ( title == null || titleObject == null ) { return "JSON missing resource_handler / name / default_value"; } JSONObject buttonObject = (JSONObject) resource_handler.get("short_name"); String button = buttonObject == null ? null : (String) buttonObject.get("default_value"); JSONObject descObject = (JSONObject) resource_handler.get("description"); String resourceDescription = descObject == null ? null : (String) descObject.get("default_value"); String path = null; JSONArray parameter = null; JSONArray enabled_capability = null; for ( Object m : messages ) { JSONObject message = (JSONObject) m; String message_type = (String) message.get("message_type"); if ( ! "basic-lti-launch-request".equals(message_type) ) continue; if ( path != null ) { return "A resource_handler cannot have more than one basic-lti-launch-request message RT="+resource_type_code; } path = (String) message.get("path"); if ( path == null ) { return "A basic-lti-launch-request message must have a path RT="+resource_type_code; } o = (JSONArray) message.get("parameter"); if ( ! (o instanceof JSONArray)) { return "Must be an array: parameter RT="+resource_type_code; } parameter = (JSONArray) o; o = (JSONArray) message.get("enabled_capability"); if ( ! (o instanceof JSONArray)) { return "Must be an array: enabled_capability RT="+resource_type_code; } enabled_capability = (JSONArray) o; } // Ignore everything except launch handlers if ( path == null ) continue; // Check the URI String thisLaunch = launch_url; if ( ! thisLaunch.endsWith("/") && ! path.startsWith("/") ) thisLaunch = thisLaunch + "/"; thisLaunch = thisLaunch + path; try { URL url = new URL(thisLaunch); } catch ( Exception e ) { return "Bad launch URL="+thisLaunch; } // Passed all the tests... Lets keep it... Properties theTool = new Properties(); theTool.put("resource_type", resource_type_code); // Backwards compatibility theTool.put("resource_type_code", resource_type_code); if ( title == null ) title = productTitle; if ( title != null ) theTool.put("title", title); if ( button != null ) theTool.put("button", button); if ( resourceDescription == null ) resourceDescription = productDescription; if ( resourceDescription != null ) theTool.put("description", resourceDescription); if ( parameter != null ) theTool.put("parameter", parameter.toString()); if ( enabled_capability != null ) theTool.put("enabled_capability", enabled_capability.toString()); theTool.put("launch", thisLaunch); theTools.add(theTool); } return null; // All good } public static JSONObject parseSettings(String settings) { if ( settings == null || settings.length() < 1 ) { settings = EMPTY_JSON_OBJECT; } return (JSONObject) JSONValue.parse(settings); } /* Two possible formats: key=val;key2=val2; key=val key2=val2 */ public static boolean mergeLTI1Custom(Properties custom, String customstr) { if ( customstr == null || customstr.length() < 1 ) return true; String [] params = customstr.split("[\n;]"); for (int i = 0 ; i < params.length; i++ ) { String param = params[i]; if ( param == null ) continue; if ( param.length() < 1 ) continue; int pos = param.indexOf("="); if ( pos < 1 ) continue; if ( pos+1 > param.length() ) continue; String key = mapKeyName(param.substring(0,pos)); if ( key == null ) continue; if ( custom.containsKey(key) ) continue; String value = param.substring(pos+1); if ( value == null ) continue; value = value.trim(); if ( value.length() < 1 ) continue; setProperty(custom, key, value); } return true; } /* "custom" : { "isbn" : "978-0321558145", "style" : "jazzy" } */ public static boolean mergeLTI2Custom(Properties custom, String customstr) { if ( customstr == null || customstr.length() < 1 ) return true; JSONObject json = null; try { json = (JSONObject) JSONValue.parse(customstr.trim()); } catch(Exception e) { M_log.warning("mergeLTI2Custom could not parse\n"+customstr); M_log.warning(e.getLocalizedMessage()); return false; } // This could happen if the old settings service was used // on an LTI 2.x placement to put in settings that are not // JSON - we just ignore it. if ( json == null ) return false; Iterator<?> keys = json.keySet().iterator(); while( keys.hasNext() ){ String key = (String)keys.next(); if ( custom.containsKey(key) ) continue; Object value = json.get(key); if ( value instanceof String ){ setProperty(custom, key, (String) value); } } return true; } /* "parameter" : [ { "name" : "result_url", "variable" : "Result.url" }, { "name" : "discipline", "fixed" : "chemistry" } ] */ public static boolean mergeLTI2Parameters(Properties custom, String customstr) { if ( customstr == null || customstr.length() < 1 ) return true; JSONArray json = null; try { json = (JSONArray) JSONValue.parse(customstr.trim()); } catch(Exception e) { M_log.warning("mergeLTI2Parameters could not parse\n"+customstr); M_log.warning(e.getLocalizedMessage()); return false; } Iterator<?> parameters = json.iterator(); while( parameters.hasNext() ){ Object o = parameters.next(); JSONObject parameter = null; try { parameter = (JSONObject) o; } catch(Exception e) { M_log.warning("mergeLTI2Parameters did not find list of objects\n"+customstr); M_log.warning(e.getLocalizedMessage()); return false; } String name = (String) parameter.get("name"); if ( name == null ) continue; if ( custom.containsKey(name) ) continue; String fixed = (String) parameter.get("fixed"); String variable = (String) parameter.get("variable"); if ( variable != null ) { setProperty(custom, name, "$"+variable); continue; } if ( fixed != null ) { setProperty(custom, name, fixed); } } return true; } public static void substituteCustom(Properties custom, Properties lti2subst) { if ( custom == null || lti2subst == null ) return; Enumeration<?> e = custom.propertyNames(); while (e.hasMoreElements()) { String key = (String) e.nextElement(); String value = custom.getProperty(key); if ( value == null || (! value.startsWith("$")) || value.length() < 1 ) continue; String subst = value.trim().substring(1); String newValue = lti2subst.getProperty(subst); if ( newValue == null || newValue.length() < 1 ) continue; setProperty(custom, key, (String) newValue); } } // Place the custom values into the launch public static void addCustomToLaunch(Properties ltiProps, Properties custom) { Enumeration<?> e = custom.propertyNames(); while (e.hasMoreElements()) { String keyStr = (String) e.nextElement(); String value = custom.getProperty(keyStr); setProperty(ltiProps,"custom_"+keyStr,value); } } @SuppressWarnings("deprecation") public static void setProperty(Properties props, String key, String value) { BasicLTIUtil.setProperty(props, key, value); } public static String mapKeyName(String keyname) { return BasicLTIUtil.mapKeyName(keyname); } }