UNPKG

@annoto/ims-lti

Version:

Module for building an LTI Tool Provider and accept LTI launch requests

360 lines (313 loc) 10.1 kB
/* * decaffeinate suggestions: * DS101: Remove unnecessary use of Array.from * DS102: Remove unnecessary code created because of implicit returns * DS103: Rewrite code to no longer use __guard__ * DS205: Consider reworking code to avoid use of IIFEs * DS206: Consider reworking classes to avoid initClass * DS207: Consider shorter variations of null checks * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md */ const crypto = require('crypto'); const http = require('http'); const https = require('https'); const url = require('url'); const uuid = require('uuid'); const xml2js = require('xml2js'); const xml_builder = require('xmlbuilder'); const errors = require('../errors'); const HMAC_SHA1 = require('../hmac-sha1'); const utils = require('../utils'); const navigateXml = function(xmlObject, path) { for (let part of path.split('.')) { xmlObject = __guard__( xmlObject != null ? xmlObject[part] : undefined, x => x[0] ); } return xmlObject; }; class OutcomeDocument { constructor(type, source_did, outcome_service) { // Build and configure the document this.outcome_service = outcome_service; const xmldec = { version: '1.0', encoding: 'UTF-8', }; this.doc = xml_builder.create('imsx_POXEnvelopeRequest', xmldec); this.doc.attribute( 'xmlns', 'http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0' ); this.head = this.doc.ele('imsx_POXHeader').ele('imsx_POXRequestHeaderInfo'); this.body = this.doc .ele('imsx_POXBody') .ele(type + 'Request') .ele('resultRecord'); // Generate a unique identifier and apply the version to the header information this.head.ele('imsx_version', 'V1.0'); this.head.ele('imsx_messageIdentifier', uuid.v1()); // Apply the source DID to the body this.body.ele('sourcedGUID').ele('sourcedId', source_did); } add_score(score, language) { if (typeof score !== 'number' || score < 0 || score > 1.0) { throw new errors.ParameterError( 'Score must be a floating point number >= 0 and <= 1' ); } const eScore = this._result_ele().ele('resultScore'); eScore.ele('language', language); return eScore.ele('textString', score); } add_text(text) { return this._add_payload('text', text); } add_url(url) { return this._add_payload('url', url); } finalize() { return this.doc.end({ pretty: true }); } _result_ele() { return this.result || (this.result = this.body.ele('result')); } _add_payload(type, value) { if (this.has_payload) { throw new errors.ExtensionError( 'Result data payload has already been set' ); } if (!this.outcome_service.supports_result_data(type)) { throw new errors.ExtensionError('Result data type is not supported'); } this._result_ele() .ele('resultData') .ele(type, value); return (this.has_payload = true); } } class OutcomeService { static initClass() { this.prototype.REQUEST_REPLACE = 'replaceResult'; this.prototype.REQUEST_READ = 'readResult'; this.prototype.REQUEST_DELETE = 'deleteResult'; } constructor(options) { if (options == null) { options = {}; } this.consumer_key = options.consumer_key; this.consumer_secret = options.consumer_secret; this.service_url = options.service_url; this.source_did = options.source_did; this.result_data_types = options.result_data_types || []; this.signer = options.signer || new HMAC_SHA1(); this.cert_authority = options.cert_authority || null; this.language = options.language || 'en'; // Break apart the service url into the url fragments for use by OAuth signing, additionally prepare the OAuth // specific url that used exclusively in the signing process. const parts = (this.service_url_parts = url.parse(this.service_url, true)); this.service_url_oauth = parts.protocol + '//' + parts.host + parts.pathname; } send_replace_result(score, callback) { const doc = new OutcomeDocument( this.REQUEST_REPLACE, this.source_did, this ); try { doc.add_score(score, this.language); return this._send_request(doc, callback); } catch (err) { return callback(err, false); } } send_replace_result_with_text(score, text, callback) { const doc = new OutcomeDocument( this.REQUEST_REPLACE, this.source_did, this ); try { doc.add_score(score, this.language, doc.add_text(text)); return this._send_request(doc, callback); } catch (err) { return callback(err, false); } } send_replace_result_with_url(score, url, callback) { const doc = new OutcomeDocument( this.REQUEST_REPLACE, this.source_did, this ); try { doc.add_score(score, this.language, doc.add_url(url)); return this._send_request(doc, callback); } catch (err) { return callback(err, false); } } send_read_result(callback) { const doc = new OutcomeDocument(this.REQUEST_READ, this.source_did, this); return this._send_request(doc, (err, result, xml) => { if (err) { return callback(err, result); } const score = parseFloat( navigateXml( xml, 'imsx_POXBody.readResultResponse.result.resultScore.textString' ), 10 ); if (isNaN(score)) { return callback( new errors.OutcomeResponseError('Invalid score response'), false ); } else { return callback(null, score); } }); } send_delete_result(callback) { const doc = new OutcomeDocument(this.REQUEST_DELETE, this.source_did, this); return this._send_request(doc, callback); } supports_result_data(type) { return ( this.result_data_types.length && (!type || this.result_data_types.indexOf(type) !== -1) ); } _send_request(doc, callback) { const xml = doc.finalize(); let body = ''; const is_ssl = this.service_url_parts.protocol === 'https:'; const options = { hostname: this.service_url_parts.hostname, path: this.service_url_parts.path, method: 'POST', headers: this._build_headers(xml), }; if (this.cert_authority && is_ssl) { options.ca = this.cert_authority; } else { options.agent = is_ssl ? https.globalAgent : http.globalAgent; } if (this.service_url_parts.port) { options.port = this.service_url_parts.port; } // Make the request to the TC, verifying that the status code is valid and fetching the entire response body. const req = (is_ssl ? https : http).request(options, res => { res.setEncoding('utf8'); res.on('data', chunk => (body += chunk)); return res.on('end', () => { return this._process_response(body, callback); }); }); req.on('error', err => { return callback(new errors.OutcomeResponseError( 'Error processing request', undefined, err ), false); }); req.write(xml); return req.end(); } _build_headers(body) { const headers = { oauth_version: '1.0', oauth_nonce: uuid.v4(), oauth_timestamp: Math.round(Date.now() / 1000), oauth_consumer_key: this.consumer_key, oauth_body_hash: crypto .createHash('sha1') .update(body) .digest('base64'), oauth_signature_method: 'HMAC-SHA1', }; headers.oauth_signature = this.signer.build_signature_raw( this.service_url_oauth, this.service_url_parts, 'POST', headers, this.consumer_secret ); return { Authorization: 'OAuth realm="",' + (() => { const result = []; for (let key in headers) { const val = headers[key]; result.push(`${key}="${utils.special_encode(val)}"`); } return result; })().join(','), 'Content-Type': 'application/xml', 'Content-Length': body.length, }; } _process_response(body, callback) { return xml2js.parseString(body, { trim: true }, (err, result) => { if (err) { return callback( new errors.OutcomeResponseError( 'The server responsed with an invalid XML document', body, err ), false ); } const response = result != null ? result.imsx_POXEnvelopeResponse : undefined; const code = navigateXml( response, 'imsx_POXHeader.imsx_POXResponseHeaderInfo.imsx_statusInfo.imsx_codeMajor' ); if (code !== 'success') { const msg = navigateXml( response, 'imsx_POXHeader.imsx_POXResponseHeaderInfo.imsx_statusInfo.imsx_description' ); return callback(new errors.OutcomeResponseError(msg, body), false); } else { return callback(null, true, response); } }); } } OutcomeService.initClass(); exports.init = function(provider) { if ( provider.body.lis_outcome_service_url && provider.body.lis_result_sourcedid ) { // The LTI 1.1 spec says that the language parameter is usually implied to be en, so the OutcomeService object // defaults to en until the spec updates and says there's other possible format options. const accepted_vals = provider.body.ext_outcome_data_values_accepted; return (provider.outcome_service = new OutcomeService({ consumer_key: provider.consumer_key, consumer_secret: provider.consumer_secret, service_url: provider.body.lis_outcome_service_url, source_did: provider.body.lis_result_sourcedid, result_data_types: (accepted_vals && accepted_vals.split(',')) || [], signer: provider.signer, })); } else { return (provider.outcome_service = false); } }; exports.OutcomeService = OutcomeService; function __guard__(value, transform) { return typeof value !== 'undefined' && value !== null ? transform(value) : undefined; }