diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 8c98236..0000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "javascript-templates"] - path = javascript-templates - url = https://github.com/51degrees/javascript-templates diff --git a/javascript-templates b/javascript-templates deleted file mode 160000 index 1230271..0000000 --- a/javascript-templates +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 12302710e81ea268c70158f4482f8f668c6dd316 diff --git a/javascript-templates/JavaScriptResource.mustache b/javascript-templates/JavaScriptResource.mustache new file mode 100644 index 0000000..620b84b --- /dev/null +++ b/javascript-templates/JavaScriptResource.mustache @@ -0,0 +1,617 @@ +fiftyoneDegreesManager = function() { + 'use-strict'; + var json = {{&_jsonObject}}; + var parameters = {{&_parameters}}; + var sessionId = "{{&_sessionId}}"; + var sessionKey = "{{_objName}}_" + sessionId; + this.sessionId = sessionId; + var sequence = {{&_sequence}}; + + // Log any errors returned in the JSON object. + if(json.error !== undefined){ + console.log(json.error); + } + + // Log any warnings returned in the JSON object. + if (json.warnings !== undefined) { + console.log(json.warnings); + } + + // Set to true when the JSON object is complete. + var completed = false; + + // changeFuncs is an array of functions. When onChange is called and passed + // a function, the function is registered and is called when processing is + // complete. + var changeFuncs = []; + + // Counter is used to count how many pieces of callbacks are expected. Every + // time the completedCallback method is called, the counter is decremented + // by 1. + var callbackCounter = 0; + + // Array of JavaScript properties that have started evaluation. + var jsPropertiesStarted = []; + + // startsWith polyfill. + var startsWith = function(source, searchValue) { + return source.lastIndexOf(searchValue, 0) === 0; + } + + // endsWith polyfill. + var endsWith = function(source, searchValue) { + return source.substring(source.length - searchValue.length, source.length) === searchValue; + } + + var clearCache = function() { + if (sessionStorage) { + for (i = 0; i < sessionStorage.length; i++) { + key = sessionStorage.key(i); + if (startsWith(key, sessionKey)) { + sessionStorage.removeItem(key); + } + } + } + } + + var loadParameters = function() { + if (sessionStorage) { + var parametersString = sessionStorage.getItem(sessionKey + "_parameters"); + if (parametersString) { + parameters = JSON.parse(parametersString); + } + } + return parameters; + } + + var saveParameters = function(sourceParams) { + if (sourceParams) { + parameters = sourceParams + } + + if (sessionStorage) { + var parametersString = JSON.stringify(parameters); + sessionStorage.setItem(sessionKey + "_parameters", parametersString); + } + } + + // Get stored values with the '51D_' prefix that have been added to the request + // and return the data as key value pairs. This method is needed to extract + // stored values for inclusion in the GET or POST request for situations + // where CORS will prevent them from being sent to third parties. + var getFodSavedValues = function(){ + let fodValues = {}; + {{#_enableCookies}} + { + let keyValuePairs = document.cookie.split(/; */); + for(let nextPair of keyValuePairs) { + let firstEqualsLocation = nextPair.indexOf('='); + let name = nextPair.substring(0, firstEqualsLocation); + if(startsWith(name, "51D_")){ + let value = nextPair.substring(firstEqualsLocation+1); + fodValues[name] = value; + } + } + }; + {{/_enableCookies}} + {{^_enableCookies}} + { + // Collect values from session storage + let session51DataPrefix = sessionKey + "_data_"; + for(let i = 0, n = window.sessionStorage.length; i < n; ++i) { + let nextKey = window.sessionStorage.key(i); + if(startsWith(nextKey, session51DataPrefix)){ + let value = window.sessionStorage[nextKey]; + fodValues[nextKey.substring(session51DataPrefix.length)] = value; + } + } + }; + {{/_enableCookies}} + return fodValues; + }; + + // Extract key value pairs from the '51D_' prefixed values and concatenates + // them to form a query string for the subsequent json refresh. + var getParametersFromStorage = function(){ + var fodValues = getFodSavedValues(); + var keyValuePairs = []; + for (var key in fodValues) { + if (fodValues.hasOwnProperty(key)) { + // Url encode the value. + // This is done to ensure that invalid characters (e.g. = chars at the end of + // base 64 encoded strings) reach the server intact. + // The server will automatically decode the value before passing it into the + // Pipeline API. + keyValuePairs.push(key+"="+encodeURIComponent(fodValues[key])); + } + } + return keyValuePairs; + }; + + // Fetch a value safely from the json object. If a key somewhere down the + // '.' separated hierarchy of keys is not present then 'undefined' is + // returned rather than letting an exception occur. + var getFromJson = function(key, allowObjects, allowBooleans) { + var result = undefined; + if(typeof allowObjects === 'undefined') { allowObjects = false; } + if(typeof allowBooleans === 'undefined') { allowBooleans = false; } + + if (typeof(key) === 'string') { + var functions = json; + var segments = key.split('.'); + var i = 0; + while (functions !== undefined && i < segments.length) { + functions = functions[segments[i++]]; + } + if (typeof(functions) === "string") { + result = functions; + } else if (allowBooleans && typeof(functions) === "boolean") { + result = functions; + } else if (allowObjects && typeof functions === 'object' && functions !== null) { + result = functions; + } + } + return result; + } + + // Executed at the end of the processJSproperties method or for each piece + // of JavaScript which has 51D code injected. When there are 0 pieces of + // JavaScript left to process then reload the JSON object. + var completedCallback = function(resolve, reject){ + callbackCounter--; + if (callbackCounter === 0) { +{{#_updateEnabled}} + processRequest(resolve, reject); +{{/_updateEnabled}} + } else if (callbackCounter < 0){ + reject('Too many callbacks.'); + } + } + + // Executes any JavaScript contained in the JSON data. Session storage is + // used to check the process state of the JavaScript property, if the name + // of the property exists as a key then it has been processed. If all the + // processed JavaScript properties have been flagged as processed already + // then session storage is checked again for a JSON payload. If it exists + // then this is loaded into the managers internal data store. If not or if + // JavaScript properties have been processed then the call-back is + // processed with any new evidence produced by the JavaScript properties. + // If JavaScript properties are processed then a key containing the name of + // the JavaScript property is added to session storage. The complete flag is + // set to true when there is no further JavaScript to be processed. + var processJsProperties = function(resolve, reject, jsProperties, ignoreDelayFlag) { + var executeCallback = true; + var started = 0; + var cached = 0; + var cachedResponse = undefined; + var toProcess = 0; + + // If there is no cached response and there are JavaScript code snippets + // then process them and perform any call-backs required. + if (jsProperties !== undefined && jsProperties.length > 0) { + + {{^_enableCookies}} + let valueSetPrefix = new RegExp('document\\.cookie\\s*=\\s*(("([A-Za-z0-9_"\\s\\+]+)\\s*=\\s*"\\s*\\+\\s*([^\\s};]+))|(`([A-Za-z0-9_]+)\\s*=\\s*\\$\\{([^}]+)\\}`))', 'g'); + let session51DataPrefix = sessionKey + "_data_"; + let sessionSetPatch = 'window.sessionStorage["' + session51DataPrefix + '$3$6"]=$4$7'; + {{/_enableCookies}} + + // Execute each of the JavaScript property code snippets using the + // index of the value to access the value to avoid problems with + // JavaScript returning erroneous values. + for (var index = 0; index < jsProperties.length; index++) { + var name = jsProperties[index]; + if (jsPropertiesStarted.indexOf(name) === -1) { + var body = getFromJson(name); + + // If there is a body then this property should be processed. + if (body) { + toProcess++; + } + var isCached = sessionStorage && sessionStorage.getItem(sessionKey + "_property_" + name); + + if (!isCached) { + // Create new function bound to this instance and execute it. + // This is needed to ensure the scope of the function is + // associated with this instance if any members are altered or + // added. Avoids global scoped variables. + + var delay = getFromJson(name + 'delayexecution', false, true); + + if ((ignoreDelayFlag || (delay === undefined || delay === false)) && + body !== undefined) { + var func = undefined; + var searchString = '// 51D replace this comment with callback function.'; + completed = false; + jsPropertiesStarted.push(name); + started++; + + {{^_enableCookies}} + body = body.replaceAll(valueSetPrefix, sessionSetPatch); + {{/_enableCookies}} + + if (body.indexOf(searchString) !== -1){ + callbackCounter++; + body = body.replace(/\/\/ 51D replace this comment with callback function./g, 'callbackFunc(resolveFunc, rejectFunc);'); + func = new Function('callbackFunc', 'resolveFunc', 'rejectFunc', + "try {\n" + + body + "\n" + + "} catch (err) {\n" + + "console.log(err);" + + "}" + ); + func(completedCallback, resolve, reject); + executeCallback = false; + } else { + func = new Function( + "try {\n" + + body + "\n" + + "} catch (err) {\n" + + "console.log(err);" + + "}" + ); + func(); + } + if (sessionStorage) { + sessionStorage.setItem(sessionKey + "_property_" + name, true) + } + } + } else { + cached++; + } + } + } + } + if(cached === toProcess || started === 0) { + if (sessionStorage) { + var cachedResponse = sessionStorage.getItem(sessionKey); + if (cachedResponse) { + loadJSON(resolve, reject, cachedResponse); + executeCallback = false; + } + } + } + + if (started === 0) { + executeCallback = false; + completed = true; + } + if (executeCallback) { + callbackCounter = 1; + completedCallback(resolve, reject); + } + }; + +{{#_updateEnabled}} +{{^_supportsFetch}} + // Standard method to create a CORS HTTP request ready to send data. + var createCORSRequest = function(method, url) { + var xhr; + try { + xhr = new XMLHttpRequest(); + } catch(err){ + xhr = null; + } + if (xhr !== null && "withCredentials" in xhr) { + + // Check if the XMLHttpRequest object has a "withCredentials" + // property. + // "withCredentials" only exists on XMLHTTPRequest2 objects. + xhr.open(method, url, true); + } else if (typeof XDomainRequest != "undefined") { + + // Otherwise, check if XDomainRequest. + // XDomainRequest only exists in IE, and is IE's way of making CORS + // requests. + xhr = new XDomainRequest(); + xhr.open(method, url); + } else { + + // Otherwise, CORS is not supported by the browser. + xhr = null; + } + return xhr; + }; +{{/_supportsFetch}} +{{/_updateEnabled}} + + // Check if the JSON object still has any JavaScript snippets to run. + var hasJSFunctions = function() { + for (var i = i; i < json.javascriptProperties; i++) { + var body = getFromJson(json.javascriptProperties[i]); + if (body !== undefined && body.length > 0) { + return true; + } + } + return false; + } + + // Process the JavaScript properties. + var process = function(resolve, reject){ + processJsProperties(resolve, reject, json.javascriptProperties, false); + } + + var fireChangeFuncs = function(json) { + for (var i = 0; i < changeFuncs.length; i++) { + if (typeof changeFuncs[i] === 'function' && + changeFuncs[i].length === 1) { + changeFuncs[i](json); + } + } + } + +{{#_updateEnabled}} + // Process the response as json and call the resolve method. + var loadJSON = function(resolve, reject, responseText) { + json = JSON.parse(responseText); + + if (hasJSFunctions()) { + // json updated so fire 'on change' functions + // before executing any new JS properties that + // have come back. + fireChangeFuncs(json); + process(resolve, reject); + } else { + completed = true; + // json updated so fire 'on change' functions + // This must happen after completed = true in order + // for 'complete' functions to fire. + fireChangeFuncs(json); + resolve(json); + } + } + + // Sends a POST request to the call-back URL to retrieve and updated + // JSON payload from the cloud service. A POST request is used so that + // parameters can be passed in the request body, this is to get around + // the limitations on the length of query strings. As POST requests are not + // cached by browsers, the result of the POST request is stored in session + // storage on a successful response. This can then be checked before making + // repeat requests to the call-back URL. + // Any saved value parameters that have been set by the executed JavaScript + // properties are added to the list of parameters, this list is then + // serialized as Form Data and sent in the POST body to the call-back URL, + // refreshing the JSON data. The new JSON is then loaded if the request is + // returned with a success status code. If there was a problem then the + // session storage items are invalidated and reject is called. + var processRequest = function(resolve, reject){ + loadParameters(); + + // Get additional parameters in case they are not sent + // by the browser. + var savedValueParams = getParametersFromStorage(); + for(var savedValueIndex in savedValueParams) { + var parts = savedValueParams[savedValueIndex].split('='); + parameters[parts[0]] = parts[1]; + } + + saveParameters(); + + var params = []; + for (var param in parameters) { + if (parameters.hasOwnProperty(param)) { + params.push(param+"="+parameters[param]) + } + } + + // Add the session and sequence to the request + if (sessionId) { + params.push("session-id=" + sessionId); + } + if (sequence) { + params.push("sequence=" + sequence); + } + + var postBody = ""; + if (params.length > 0) { + postBody = params.join('&').replace(/%20/g, '+'); + } + +{{#_supportsFetch}} + fetch('{{{_url}}}', { + method: 'POST', + mode: 'cors', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: postBody + }) + .then(response => { + return response.text(); + }) + .then(responseText => { +{{/_supportsFetch}} +{{^_supportsFetch}} + // Request callback URL with additional parameters. + var xhr = createCORSRequest('POST', '{{{_url}}}'); + + // If there is no support for HTTP requests then call reject and throw + // a no CORS support error. + if (!xhr) { + reject(new Error('CORS not supported')); + return; + } + + // Add the HTTP header for POST form data. + xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); + xhr.setRequestHeader('Accept', 'application/json'); + + xhr.onload = function () { + + // Get the response body from the request. + var responseText = xhr.responseText; + +{{/_supportsFetch}} + // Cache the response text. + if (sessionStorage) { + sessionStorage.setItem(sessionKey, responseText); + } + // Load the JSON object from the response text + loadJSON(resolve, reject, responseText); + // Increment the sequence on a successful request + sequence++; +{{#_supportsFetch}} + }) + .catch(error => { + // Invalidate the cache on error. + clearCache(); + reject(error); + }); +{{/_supportsFetch}} +{{^_supportsFetch}} + }; + + xhr.onerror = function () { + // An error occurred with the request. Invalidate the cache and + // return the details in the call to reject method. + clearCache(); + reject(Error(xhr.statusText)); + }; + + // Send the GET request. + xhr.send(postBody); +{{/_supportsFetch}} + } +{{/_updateEnabled}} + + // Function logs errors, used to 'reject' a promise or for error callbacks. + var catchError = function(value) { + console.log(value.message || value); + } + + // Populate this instance of the FOD object with getters to access the + // properties. If the value is null then get the noValueMessage from the + // JSON object corresponding to the property. + var update = function(data){ + var self = this; + Object.getOwnPropertyNames(data).forEach(function(key) { + self[key] = {}; + for(var i in data[key]){ + var obj = self[key]; + (function(i) { + Object.defineProperty(obj, i, { + get: function (){ + if(data[key][i] === null && (i !== "javascriptProperties")){ + return data[key][i + "nullreason"]; + } else { + return data[key][i]; + } + } + }) + })(i); + } + }); + } + +{{#_hasDelayedProperties}} + // Get the JS property(s) that, when evaluated, will populate + // evidence that can be used to determine the value of the + // supplied property. + // The supplied name can either be a complete property name or a top level + // aspect name. + // Where the aspect name is given, ALL evidence properties under that + // key will be returned. + // Example property names are 'location.country' or 'devices.profiles.hardwarename' + // Example aspect names are 'location' or 'devices' + var getEvidenceProperties = function (name) { + var evidenceProperties = getFromJson(name + 'evidenceproperties'); + if(typeof evidenceProperties === "undefined") { + var item = getFromJson(name, true); + evidenceProperties = getEvidencePropertiesFromObject(item); + } + return evidenceProperties; + } + + // Get all values in any 'evidenceproperty' fields on this object + // or sub-objects. + var getEvidencePropertiesFromObject = function (dataObject) { + evidenceProperties = []; + + for (var prop in dataObject) { + if (dataObject.hasOwnProperty(prop)) { + var value = dataObject[prop]; + // Property name ends with 'evidenceproperties' so is + // what we're looking for. + // Add the values to the array if we don't already have it. + if (value !== null && Array.isArray(value) && endsWith(prop, 'evidenceproperties')) { + value.forEach(function(item, index) { + if(evidenceProperties.indexOf(item) === -1) { + evidenceProperties.push(item); + } + }); + } + // Item is an object so recursively call this method + // and add any resulting evidence properties to the list. + else if(typeof value === 'object' && value !== null) { + getEvidencePropertiesFromObject(value).forEach(function(item, index) { + if(evidenceProperties.indexOf(item) === -1) { + evidenceProperties.push(item); + } + }); + } + } + } + + return evidenceProperties; + } +{{/_hasDelayedProperties}} + +{{#_supportsPromises}} + this.promise = new Promise(function(resolve, reject) { + process(resolve,reject); + }); +{{/_supportsPromises}} + + this.onChange = function(resolve) { + changeFuncs.push(resolve); + } + + this.complete = function(resolve, properties) { +{{#_hasDelayedProperties}} + // If properties is set then check if we need to kick off + // processing of anything. + if(typeof properties !== "undefined") { + // If properties is a string then split on comma to produce + // an array of one or more key names. + if(typeof properties === "string") { + properties = properties.split(','); + } + if(Array.isArray(properties)) { + properties.forEach(function(key, i) { + // We pass an empty function rather than 'resolve' because we + // don't want to call resolve when a single evidence function + // evaluates but after all of them have completed. + // This is handled by the 'if(complete)' code below. + processJsProperties(function(json) {}, catchError, getEvidenceProperties(key), true); + }); + } + } + +{{/_hasDelayedProperties}} + if(completed){ + resolve(json); + }else{ + this.onChange(function(data) { + if(completed){ + resolve(data); + } + }) + } + }; + + // Update this instance with the initial JSON payload. + update.call(this, json); +{{#_supportsPromises}} + var parent = this; + this.promise.then(function(value) { + // JSON has been updated so replace the current instance. + update.call(parent, value); + completed = true; + }).catch(catchError); +{{/_supportsPromises}} +{{^_supportsPromises}} + process(function(json) {}, catchError); +{{/_supportsPromises}} +} + +var {{_objName}} = new fiftyoneDegreesManager(); \ No newline at end of file diff --git a/javascript-templates/README.md b/javascript-templates/README.md new file mode 100644 index 0000000..71f735a --- /dev/null +++ b/javascript-templates/README.md @@ -0,0 +1,8 @@ +## Purpose + +Store shared (mustache) templates to be used by the implementation of `JavaScriptBuilderElement` and other components across the languages. + +## Background + +The [language-independent specification](https://github.com/51Degrees/specifications) describes how JavaScriptBuilderElement is used [here](https://github.com/51Degrees/specifications/blob/36ff732360acb49221dc81237281264dac4eb897/pipeline-specification/pipeline-elements/javascript-builder.md). The mechanics is: javascript file is requested from the server (on-premise web integration) and is created from this mustache template by the JavaScriptBuilderElement. It then collects more evidence, sends it to the server and upon response calls a callback function providing the client with more precise device data. +