API Docs for: 2.0.0

src/node-utils.js

// SAGE2 is available for use under the SAGE2 Software License
//
// University of Illinois at Chicago's Electronic Visualization Laboratory (EVL)
// and University of Hawai'i at Manoa's Laboratory for Advanced Visualization and
// Applications (LAVA)
//
// See full text, terms and conditions in the LICENSE.txt included file
//
// Copyright (c) 2014

/**
 * Provides utility functions for the SAGE2 server
 *
 * @class node-utils
 * @module server
 * @submodule node-utils
 * @requires package.json, request, semver, chalk, strip-ansi
 */

// require variables to be declared
"use strict";

var SAGE2_version = require('../package.json');
try {
	var SAGE2_buildVersion = require('../VERSION.json');
} catch (e) {
	// nothing yet
}

var crypto  = require('crypto');              // https encryption
var exec    = require('child_process').exec;  // execute external application
var fs      = require('fs');                  // filesystem access
var path    = require('path');                // resolve directory paths
var tls     = require('tls');                 // https encryption

var querystring = require('querystring');     // utilities for dealing with URL

// npm external modules
var request   = require('request');           // http requests
var semver    = require('semver');            // parse version numbers
var fsmonitor = require('fsmonitor');         // file system monitoring
var sanitizer = require('sanitizer');         // Caja's HTML Sanitizer as a Node.js module
var chalk     = require('chalk');             // colorize console output
var stripansi = require('strip-ansi');        // remove ANSI color codes (dep. of chalk)
var rimraf    = require('rimraf');            // command rm -rf for node

/**
 * Parse and store NodeJS version number: detect version 0.10.x or newer
 *
 * @property _NODE_VERSION
 * @type {Number}
 */
var _NODE_VERSION = 0;
if (semver.gte(process.versions.node, '0.10.0')) {
	_NODE_VERSION = 10;
	if (semver.gte(process.versions.node, '0.11.0')) {
		_NODE_VERSION = 11;
	}
	if (semver.gte(process.versions.node, '0.12.0')) {
		_NODE_VERSION = 12;
	}
	if (semver.gte(process.versions.node, '1.0.0')) {
		_NODE_VERSION = 1;
	}
} else {
	throw new Error(" SAGE2>\tOld version of Node.js. Please update");
}

/**
 * Test if file is exists
 *
 * @method fileExists
 * @param filename {String} name of the file to be tested
 * @return {Bool} true if exists
 */
function fileExists(filename) {
	if (_NODE_VERSION === 10 || _NODE_VERSION === 11) {
		return fs.existsSync(filename);
	}
	// Versions 1.x or above
	try {
		var res = fs.statSync(filename);
		return res.isFile();
	} catch (err) {
		return false;
	}
}

/**
 * Test if folder is exists
 *
 * @method folderExists
 * @param directory {String} name of the folder to be tested
 * @return {Bool} true if exists
 */
function folderExists(directory) {
	if (_NODE_VERSION === 10 || _NODE_VERSION === 11) {
		return fs.existsSync(directory);
	}
	// Versions 1.x or above
	try {
		var res = fs.statSync(directory);
		return res.isDirectory();
	} catch (err) {
		return false;
	}
}

/**
 * Create a SSL context / credentials
 *
 * @method secureContext
 * @param key {String} public key
 * @param crt {String} private key
 * @param ca  {String} CA key
 * @return {Object} secure context
 */
function secureContext(key, crt, ca) {
	var ctx;
	if (_NODE_VERSION === 10) {
		ctx = crypto.createCredentials({key: key, cert: crt, ca: ca});
	} else {
		// Versions 11 or 1.x or above
		ctx = tls.createSecureContext({key: key, cert: crt, ca: ca});
	}
	return ctx.context;
}

/**
 * Load a CA bundle file and return an array of certificates
 *
 * @method loadCABundle
 * @param filename {String} name of the file to parse
 * @return {Array} array of certificates data
 */
function loadCABundle(filename) {
	// Initialize the array of certs
	var certs_array = [];
	var certs_idx   = -1;
	// Read the file
	if (fileExists(filename)) {
		var rawdata = fs.readFileSync(filename, {encoding: 'utf8'});
		var lines   = rawdata.split('\n');
		lines.forEach(function(line) {
			if (line === "-----BEGIN CERTIFICATE-----") {
				certs_idx = certs_idx + 1;
				certs_array[certs_idx] = line + '\n';
			} else if (line === "-----END CERTIFICATE-----") {
				certs_array[certs_idx] += line;
			} else {
				certs_array[certs_idx] += line + '\n';
			}
		});
	} else {
		log('loadCABundle', 'Could not find CA file:', filename);
	}
	return certs_array;
}


/**
 * Base version comes from evaluating the package.json file
 *
 * @method getShortVersion
 * @return {String} version number as x.x.x
 */
function getShortVersion() {
	// try to get the version from the VERSION.json file
	if (SAGE2_buildVersion && SAGE2_buildVersion.version) {
		SAGE2_version.version = SAGE2_buildVersion.version;
	}
	return SAGE2_version.version;
}


/**
 * Node.js version
 *
 * @method getNodeVersion
 * @return {String} version number
 */
function getNodeVersion() {
	// return _NODE_VERSION.toString() +  " (v" +  process.versions.node + ")";
	return process.versions.node;
}

/**
 * Full version is processed from git information
 *
 * @method getFullVersion
 * @param callback {Function} function to be run when finished, parameter is an object containing base, branch,
 *        commit and date fields
 */
function getFullVersion(callback) {
	var fullVersion  = {base: "", branch: "", commit: "", date: ""};
	// get the base version from package.json file
	fullVersion.base = SAGE2_version.version;
	// Pick up the date from package.json, if any
	fullVersion.date = SAGE2_version.date || "";

	// get to the root folder of the sources
	var dirroot = path.resolve(__dirname, '..');
	var cmd1 = "git rev-parse --abbrev-ref HEAD";
	exec(cmd1, { cwd: dirroot, timeout: 3000}, function(err1, stdout1, stderr1) {
		if (err1) {
			callback(fullVersion);
			return;
		}

		var branch = stdout1.substring(0, stdout1.length - 1);
		var cmd2 = "git log --date=\"short\" --format=\"%h|%ad\" -n 1";
		exec(cmd2, { cwd: dirroot, timeout: 3000}, function(err2, stdout2, stderr2) {
			if (err2) {
				callback(fullVersion);
				return;
			}

			// parsing the results
			var result = stdout2.replace(/\r?\n|\r/g, "");
			var parse  = result.split("|");

			// filling up the object
			fullVersion.branch = branch;
			fullVersion.commit = parse[0];
			fullVersion.date   = parse[1].replace(/-/g, "/");

			// return the object in the callback paramter
			callback(fullVersion);
		});
	});
}

/**
 * Upate the source code using git
 *
 * @method updateWithGIT
 * @param branch {String} name of the remote branch
 * @param callback {Function} function to be run when finished
 */
function updateWithGIT(branch, callback) {
	// get to the root folder of the sources
	var dirroot = path.resolve(__dirname, '..');
	var cmd1 = "git pull origin " + branch;
	exec(cmd1, { cwd: dirroot, timeout: 5000}, function(err, stdout, stderr) {
		// return the messages in the callback paramter
		if (err) {
			callback(stdout + ' : ' + stderr, null);
		} else {
			callback(null, stdout);
		}
	});
}

/**
 * Cleanup URL from XSS attempts
 *
 * @method sanitizedURL
 * @param aURL {String} a URL we received from a request
 * @return {String} cleanup string
 */
function sanitizedURL(aURL) {
	// replace several consecutive forward slashes into one
	var cleaner = aURL.replace(/\/+/g, "/");
	// var cleaner = aURL;
	// convert HTML encoded content
	// Node doc: It will try to use decodeURIComponent in the first place, but if that fails it falls back
	// to a safer equivalent that doesn't throw on malformed URLs.
	var decode = querystring.unescape(cleaner);
	// Then, remove the bad parts
	return sanitizer.sanitize(decode);
}

/**
 * Utility function to create a header for console messages
 *
 * @method header
 * @param h {String} header text
 * @return header {String} formatted text
 */
function header(h) {
	if (h.length <= 6) {
		return chalk.green.bold.dim(h + ">\t\t");
	}
	return chalk.green.bold.dim(h + ">\t");
}

/**
 * Log function for SAGE2, adds a header with color
 *
 * @method     log
 * @param      {String}  head    The header
 * @param      {Array}   params  The parameters
 */
function log(head, ...params) {
	// Adds the header strings in a new argument array
	if (!global.quiet) {
		console.log.apply(console, [header(head)].concat(params));
	}
	if (global.emitLog) {
		global.emitLog(stripansi(head + "> " + params + "\n"));
	}
	if (global.logger) {
		global.logger.log(head, stripansi(params.toString()));
	}
}

/**
 * Utility function to compare two strings independently of case.
 * Used for sorting
 *
 * @method compareString
 * @param a {String} first string
 * @param b {String} second string
 */
function compareString(a, b) {
	var nA  = a.toLowerCase();
	var nB  = b.toLowerCase();
	var res = 0;
	if (nA < nB) {
		res = -1;
	} else if (nA > nB) {
		res = 1;
	}
	return res;
}

/**
 * Utility function, used while sorting, to compare two objects based on filename independently of case.
 * Needs a .exif.FileName field
 *
 * @method compareFilename
 * @param a {Object} first object
 * @param b {Object} second object
 */
function compareFilename(a, b) {
	var nA  = a.exif.FileName.toLowerCase();
	var nB  = b.exif.FileName.toLowerCase();
	var res = 0;
	if (nA < nB) {
		res = -1;
	} else if (nA > nB) {
		res = 1;
	}
	return res;
}

/**
 * Utility function to compare two objects based on title independently of case.
 * Needs a .exif.metadata.title field
 * Used for sorting
 *
 * @method compareTitle
 * @param a {Object} first object
 * @param b {Object} second object
 */
function compareTitle(a, b) {
	var nA  = a.exif.metadata.title.toLowerCase();
	var nB  = b.exif.metadata.title.toLowerCase();
	var res = 0;
	if (nA < nB) {
		res = -1;
	} else if (nA > nB) {
		res = 1;
	}
	return res;
}

/**
 * Utility function to test if a string or number represents a true value.
 * Used for parsing JSON values
 *
 * @method isTrue
 * @param value {Object} value to test
 */
function isTrue(value) {
	if (typeof value === 'string') {
		value = value.toLowerCase();
	}
	switch (value) {
		case true:
		case "true":
		case 1:
		case "1":
		case "on":
		case "yes": {
			return true;
		}
		default: {
			return false;
		}
	}
}

/**
 * Compare the installed pacakges versus the specified ones in packages.json
 *   warms the user of outdated packages
 *
 * @method checkPackages
 * @param inDevelopement {Bool} whether or not to check in production mode (no devel packages)
 */
function checkPackages(inDevelopement) {
	var packages = {missing: [], outdated: []};
	// check the commonly used NODE_ENV variable (development or production)
	var indevel  = (process.env.NODE_ENV === 'development') || isTrue(inDevelopement);
	var command  = "npm outdated --depth 0 --json  --production";
	if (indevel) {
		command = "npm outdated --depth 0 --json";
	}
	exec(command, {cwd: path.normalize(path.join(__dirname, "..")), timeout: 30000},
		function(error, stdout, stderr) {
			// returns error code 1 if found outdated packages
			if (error && error.code !== 1) {
				log("Packages", "Warning, error running update [ " + error.cmd + '] ',
					'code: ' + error.code + ' signal: ' + error.signal);
				return;
			}

			var key;
			var output = stdout ? JSON.parse(stdout) : {};
			for (key in output) {
				// if it is not a git repository
				if (output[key].wanted != "git") {
					// if not a valid version number
					if (!semver.valid(output[key].current)) {
						packages.missing.push(key);
					} else if (semver.lt(output[key].current, output[key].wanted)) {
						// if the version is strictly lower than requested
						packages.outdated.push(key);
					}
				}
			}

			if (packages.missing.length > 0 || packages.outdated.length > 0) {
				log("Packages", chalk.yellow.bold("Warning") + " - Packages not up to date");
				if (packages.missing.length  > 0) {
					log("Packages", chalk.red.bold("Missing:"), chalk.red.bold(packages.missing));
				}
				if (packages.outdated.length > 0) {
					log("Packages", chalk.yellow.bold("Outdated:"), chalk.yellow.bold(packages.outdated));
				}
				log("Packages", "To update, execute: " + chalk.yellow.bold("npm run in"));
			} else {
				log("Packages", chalk.green.bold("All packages up to date"));
			}
		}
	);
}


/**
 * Register SAGE2 with EVL server
 *
 * @method registerSAGE2
 * @param config {Object} local SAGE2 configuration
 */
function registerSAGE2(config) {
	request({
		rejectUnauthorized: false,
		url: 'https://sage.evl.uic.edu/register',
		// url: 'https://131.193.183.150/register',
		form: config,
		method: "POST"},
	function(err, response, body) {
		log("SAGE2", "Registration with EVL site:",
			(err === null) ? chalk.green.bold("success") : chalk.red.bold(err.code));
	});
}

/**
 * Unregister from EVL server
 *
 * @method deregisterSAGE2
 * @param config {Object} local SAGE2 configuration
 * @param callback {Function} to be called when done
 */
function deregisterSAGE2(config, callback) {
	request({
		rejectUnauthorized: false,
		url: 'https://sage.evl.uic.edu/unregister',
		// url: 'https://131.193.183.150/unregister',
		form: config,
		method: "POST"},
	function(err, response, body) {
		log("SAGE2", "Deregistration with EVL site:",
			(err === null) ? chalk.green.bold("success") : chalk.red.bold(err.code));
		if (callback) {
			callback();
		}
	});
}

/**
 * Return a safe URL string: convert odd characters to HTML representations
 *
 * @method encodeReservedURL
 * @param aUrl {String} URL to be sanitized
 * @return {String} cleanup version of the URL
 */
function encodeReservedURL(aUrl) {
	return encodeURI(aUrl).replace(/\$/g, "%24").replace(/&/g, "%26").replace(/\+/g, "%2B")
		.replace(/,/g, "%2C").replace(/:/g, "%3A").replace(/;/g, "%3B").replace(/=/g, "%3D")
		.replace(/\?/g, "%3F").replace(/@/g, "%40");
}

/**
 * Return a safe URL string: make Windows path to URL
 *
 * @method encodeReservedPath
 * @param aUrl {String} URL to be sanitized
 * @return {String} cleanup version of the URL
 */
function encodeReservedPath(aPath) {
	return encodeReservedURL(aPath.replace(/\\/g, "/"));
}


/**
 * Return a home directory on every platform
 *
 * @method getHomeDirectory
 * @return {String} string representing a folder path
 */
function getHomeDirectory() {
	return process.env[ (process.platform === 'win32') ? 'USERPROFILE' : 'HOME'];
}


/**
 * Creates recursively a series of folders if needed (synchronous function and throws error)
 *
 * @method mkdirParent
 * @param dirPath {String} path to be created
 * @return {String} null or directory created
 */
function mkdirParent(dirPath) {
	var made = null;
	dirPath = path.resolve(dirPath);
	try {
		fs.mkdirSync(dirPath);
		made = dirPath;
	} catch (err0) {
		switch (err0.code) {
			case 'ENOENT' : {
				made = mkdirParent(path.dirname(dirPath));
				made = mkdirParent(dirPath);
				break;
			}
			default: {
				var stat;
				try {
					stat = fs.statSync(dirPath);
				} catch (err1) {
					throw err0;
				}
				if (!stat.isDirectory()) {
					throw err0;
				}
				made = dirPath;
				break;
			}
		}
	}
	return made;
}


/**
 * Place a callback on a list of folders to monitor
 *  callback triggered when a change is detected:
 *    this.root contains the monitored folder
 *  parameter contains the following list:
 *    addedFiles, modifiedFiles, removedFiles,
 *    addedFolders, modifiedFolders, removedFolders
 *
 * @method     monitorFolders
 * @param      {Array}    folders          list of folders to monitor
 * @param      {Array}    excludesFiles    The excludes files
 * @param      {Array}    excludesFolders  The excludes folders
 * @param      {Function}  callback         to be called when a change is detected
 */
function monitorFolders(folders, excludesFiles, excludesFolders, callback) {
	// for each folder
	for (var folder in folders) {
		// get a full path
		var folderpath = path.resolve(folders[folder]);
		// get information on the folder
		var stat       = fs.lstatSync(folderpath);
		// making sure it is a folder
		if (stat.isDirectory()) {
			log("Monitor", "watching folder " + chalk.yellow.bold(folderpath));
			var monitor = fsmonitor.watch(folderpath, {
				// excludes non-valid filenames
				matches:  function(relpath) {
					var condition = excludesFiles.every(function(e, i, a) {
						return !relpath.endsWith(e);
					});
					return condition;
				},
				// and ignores folders
				excludes: function(relpath) {
					var condition = excludesFolders.every(function(e, i, a) {
						return !relpath.startsWith(e);
					});
					return !condition;
				}
			});
			// place the callback the change event
			monitor.on('change', callback);
		}
	}
}

/**
 * Merges object a and b into b
 *
 * @method     mergeObjects
 * @param      {Object}            a       source object
 * @param      {Object}            b       destination
 * @param      {Array}            ignore  object fields to ignore during merge
 * @return     {Boolean}  return true if b was modified
 */
function mergeObjects(a, b, ignore) {
	var ig = ignore || [];
	var modified = false;
	// test in case of old sessions
	if (a === undefined || b === undefined) {
		return modified;
	}
	for (var key in b) {
		if (a[key] !== undefined && ig.indexOf(key) < 0) {
			var aRecurse = (a[key] === null || a[key] instanceof Array || typeof a[key] !== "object") ? false : true;
			var bRecurse = (b[key] === null || b[key] instanceof Array || typeof b[key] !== "object") ? false : true;
			if (aRecurse && bRecurse) {
				modified = mergeObjects(a[key], b[key]) || modified;
			} else if (!aRecurse && !bRecurse && a[key] !== b[key]) {
				b[key] = a[key];
				modified = true;
			}
		}
	}
	return modified;
}

/**
 * Delete files, with glob, and a callback when done
 *
 * @method     deleteFiles
 * @param      {String}    pattern  string
 * @param      {Function}  cb       callback when done
 */
function deleteFiles(pattern, cb) {
	// use the rimraf module
	if (cb) {
		rimraf(pattern, {glob: true}, cb);
	} else {
		rimraf(pattern, {glob: true}, function(err) {
			if (err) {
				log('Files', 'error deleting files ' + pattern);
			}
		});
	}
}


module.exports.nodeVersion       = _NODE_VERSION;
module.exports.getNodeVersion    = getNodeVersion;
module.exports.getShortVersion   = getShortVersion;
module.exports.getFullVersion    = getFullVersion;
module.exports.secureContext     = secureContext;
module.exports.fileExists        = fileExists;
module.exports.folderExists      = folderExists;
module.exports.header            = header;
module.exports.log               = log;
module.exports.compareString     = compareString;
module.exports.compareFilename   = compareFilename;
module.exports.compareTitle      = compareTitle;
module.exports.isTrue            = isTrue;
module.exports.updateWithGIT     = updateWithGIT;
module.exports.checkPackages     = checkPackages;
module.exports.registerSAGE2     = registerSAGE2;
module.exports.deregisterSAGE2   = deregisterSAGE2;
module.exports.loadCABundle      = loadCABundle;
module.exports.monitorFolders    = monitorFolders;
module.exports.getHomeDirectory  = getHomeDirectory;
module.exports.mkdirParent       = mkdirParent;
module.exports.deleteFiles       = deleteFiles;
module.exports.sanitizedURL      = sanitizedURL;
module.exports.mergeObjects      = mergeObjects;

module.exports.encodeReservedURL  = encodeReservedURL;
module.exports.encodeReservedPath = encodeReservedPath;