modules/UBConnection.js

/** UnityBase client. Internally contain instance of {@link http} and provide useful methods for work with UnityBase server.
 *
 *
        var UBConnection = require('UBConnection');
        var conn = new UBConnection({host: 'localhost', port: '888', path: 'autotest', keepAlive: true});
        // alternative way is:
        // var conn = new UBConnection('http://localhost:888/orm');
        // but in this case keepAlive is false
        conn.onRequestAuthParams = function(){ return {authSchema: 'UB', login: 'admin', password: 'admin'} }
        conn.get('getDomainInfo');


 * @module UBConnection
 *
 */

/*global nsha256*/
var
    http = require('http'),
 /*jshint -W079 */
    //MPV-already in global _ = require('lodash'),
    UBSession = require('UBSession'),
    CryptoJS = require('CryptoJS/core');
/*jshint +W079 */

CryptoJS.MD5 = require('CryptoJS/md5');
//regular expression for URLs server not require authorization.
var NON_AUTH_URLS_RE = /(\/|^)(models|auth|getAppInfo|downloads)(\/|\?|$)/;

/**
 * Create HTTP(s) connection to UnityBase server
 * @constructor
 * @param {Object} options Connection parameters. See {@link module:http http.request} for details
 */
function UBConnection(options){
    var
        me = this,
        client = http.request(options),
        appName,
        servicePath,
        ubSession = null,
        lookupCache = {},
        userDataDefault = {lang: 'en'},
        appInfo = {};

    /**
     * Internal instance of HTTP client
     * @type {ClientRequest}
     * @protected
     * @readonly
     */
    this.clientRequest = client;
    appName = client.options.path;
    servicePath = client.options.path;
    if (servicePath.charAt(servicePath.length-1) !== '/') servicePath = servicePath + '/'; //normalize path
    if (appName.length !== 1){
       if (appName.charAt(0) === '/') appName = appName.slice(1, 100); // remove leading / from app name
       if (appName.charAt(appName.length-1) === '/') appName = appName.slice(0, appName.length-1); // remove / from end of app name
    }

    /**
     * Root path to all application-level method
     * @type {String}
     * @readonly
     */
    this.servicePath = servicePath;

    /**
     * Name of UnityBase application
     * @type {String}
     * @readonly
     */
    this.appName = appName;
    /**
     * Callback for resolving user credential.
     * Take a {UBConnection} as a parameter, must return authorization parameters object:
     *
     *      {authSchema: authType, login: login, password: password, [apiKey: ]}
     *
     * For a internal usage (requests from a locahost or other systems, etc.) and in case authShema == 'UB' it is possible to pass a
     * `apiKey` instead of a password. apiKey is actually a `uba_user.uPasswordHashHexa` content
     *
     * @type {function}
     */
    this.onRequestAuthParams = null;

    /**
     * @deprecated Do not use this property doe to memory overuse - see http://forum.ub.softline.kiev.ua/viewtopic.php?f=12&t=85
     */
    appInfo = this.get('getAppInfo'); //non-auth request

    /**
     * Endpoint name for query (`runList` before 1.12, `ubql` after 1.12
     * @type {string}
     */
    this.queryMethod = appInfo.serverVersion.startsWith('1.9.') || appInfo.serverVersion.startsWith('1.10.') ? 'runList' : 'ubql';


    /**
     * Retrieve application information.
     * @method
     * @returns {Object}  result of getAppInfo method
     */
    this.getAppInfo = function() {
        return appInfo;
    };

    /** Is server require content encryption
     * @type {Boolean}
     * @readonly
     */
    this.encryptContent = appInfo.encryptContent || false;
    /** `base64` encoded server certificate used for cryptographic operation
     * @type {Boolean}
     * @readonly
     */
    this.serverCertificate =  appInfo.serverCertificate || '';
    /** Lifetime (in second) of session encryption
     * @type {Number}
     * @readonly
     */
    this.sessionKeyLifeTime = appInfo.sessionKeyLifeTime || 0;
    /**
     * Possible server authentication method
     * @type {Array.<string>}
     * @readonly
     */
    this.authMethods = appInfo.authMethods;

    /**
     * Is UnityBase server require authorization
     * @type {Boolean}
     * @readonly
     */
    this.authNeed = me.authMethods && (me.authMethods.length > 0);

    //noinspection JSUnusedGlobalSymbols
    /**
     * AdminUI settings
     * @type {Object}
     */
    this.appConfig = appInfo['adminUI'];

    /**
     *
     * @param {Boolean} isRepeat
     * @private
     * @return {UBSession}
     */
    this.authorize = function(isRepeat){
        var
            me = this,
            authParams, resp, serverNonce, clientNonce, secretWord, pwdHash,
            request2 = {};
        if (!ubSession || isRepeat){
            ubSession = null;
            if (!me.onRequestAuthParams){
                throw new Error ('set UBConnection.onRequestAuthParams function to perform authorized requests');
            }
            authParams = me.onRequestAuthParams(me);
            if (authParams.authSchema === 'UBIP'){
                if (isRepeat) {
                    throw new Error('UBIP authentication must not return false on the prev.step')
                }
                resp = me.xhr({endpoint: 'auth', headers: {'Authorization': authParams.authSchema + ' ' + authParams.login}});
                ubSession =  new UBSession(resp, '', authParams.authSchema);
            } else {
                resp = me.get('auth', {
                    AUTHTYPE: authParams.authSchema || 'UB',
                    userName: authParams.login
                });
                clientNonce = nsha256(new Date().toISOString().substr(0, 16));
                request2.clientNonce = clientNonce;
                if (resp.connectionID) {
                    request2.connectionID = resp.connectionID;
                }
                request2.AUTHTYPE = authParams.authSchema || 'UB';
                request2.userName = authParams.login;
                if (resp['realm']) { // LDAP
                    serverNonce = resp['nonce'];
                    if (!serverNonce) {
                        throw new Error('invalid LDAP auth response');
                    }
                    if (resp['useSasl']) {
                        pwdHash = CryptoJS.MD5(authParams.login.split('\\')[1].toUpperCase() + ':' + resp.realm + ':' + authParams.password);
                        // we must calculate md5(login + ':' + realm + ':' + password) in binary format
                        pwdHash.concat(CryptoJS.enc.Utf8.parse(':' + serverNonce + ':' + clientNonce));
                        request2.password = CryptoJS.MD5(pwdHash).toString();
                        secretWord = request2.password; //todo - must be pwdHash but UB server do not know it :( medium unsecured
                    } else {
                        request2.password = btoa(authParams.password);
                        secretWord = request2.password; //todo -  very unsecured!!
                    }
                } else {
                    serverNonce = resp.result;
                    if (!serverNonce) {
                        throw new Error('invalid auth response');
                    }
                    if (authParams.apiKey){
                        pwdHash = authParams.apiKey;
                    } else {
                        pwdHash = nsha256('salt' + authParams.password);
                    }
                    request2.password = nsha256(appName.toLowerCase() + serverNonce + clientNonce + authParams.login + pwdHash).toString();
                    secretWord = pwdHash;
                }
                resp = me.get('auth', request2);
                ubSession = new UBSession(resp, secretWord, authParams.authSchema);
            }
        }
        return ubSession;
    };

    /**
     * Check is current connection already perform authentication request
     * @returns {boolean}
     */
    this.isAuthorized = function(){
        return Boolean(ubSession);
    };

    /**
     * Return current user logon or '' in case not logged in
     * @returns {String}
     */
    this.userLogin = function(){
        return this.isAuthorized() ? ubSession.logonname : '';
    };

    /**
     * Return current user language or 'en' in case not logged in
     * @returns {String}
     */
    this.userLang = function(){
        return this.userData('lang');
    };

    /**
     * Return custom data for logged in user, or {lang: 'en'} in case not logged in
     *
     * If key is provided - return only key part of user data:
     *
     *      $App.connection.userData('lang');
     *      // or the same but dedicated alias
     *      $App.connection.userLang()
     *
     * @param {String} [key] Optional key
     * @returns {*}
     */
    this.userData = function(key){
        var uData = this.isAuthorized() ? ubSession.userData : userDataDefault;
        return key ? uData[key] : uData;
    };

    /**
     * Lookup value in entity using aCondition.
     *
     *      // create condition using UB.Repository
     *		var myID = conn.lookup('ubm_enum', 'ID',
     *           UB.Repository('ubm_enum').where('eGroup', '=', 'UBA_RULETYPE').where('code', '=', 'A').ubRequest().whereList
     *          );
     *		// or pass condition directly
     *		var adminID = conn.lookup('uba_user', 'ID', {
     *             expression: 'name', condition: 'equal', values: {nameVal: 'admin'}
     *          });
     *
     * @param {String} aEntity - entity to lookup
     * @param {String} lookupAttribute - attribute to lookup
     * @param {String|Object} aCondition - lookup condition. String in case of custom expression,
     *		or whereListItem {expression: condition: values: },
     *		or whereList {condition1: {expression: condition: values: }, condition2: {}, ....}
     * @param {Boolean} [doNotUseCache=false]
     * @return {*} `lookupAttribute` value of first result row or null if not found.
     */
    this.lookup = function(aEntity, lookupAttribute, aCondition, doNotUseCache) {
        var
            me = this,
            cKey = aEntity + JSON.stringify(aCondition) + lookupAttribute,
            resData, request;

        if (!doNotUseCache && lookupCache.hasOwnProperty(cKey)) {
            return lookupCache[cKey]; // found in cache
        } else {
            request = {
                entity: aEntity,
                method: 'select',
                fieldList: [lookupAttribute],
                __nativeDatasetFormat: true,
                options: {
                    start: 0,
                    limit: 1
                }
            };
            if (typeof aCondition === 'string'){
                request.whereList = {lookup: {expression: aCondition, condition: 'custom'}};
            } else if (aCondition.expression && (typeof aCondition.expression === 'string')){
                request.whereList = {lookup: aCondition};
            } else {
                request.whereList = aCondition;
            }

            resData = me.query(request).resultData;
            if ((resData.length === 1) && (resData[0][lookupAttribute] != null)) { // `!= null` is equal to (not null && not undefined)
                if (!doNotUseCache) {
                    lookupCache[cKey] = resData[0][lookupAttribute];
                }
                return resData[0][lookupAttribute];
            } else {
                return null;
            }
        }
    };
}

/**
 * Perform authorized UBQL request.
 * Can take one QB Query or an array of UB Query and execute it at once.
 * @param {UBQ|Array<UBQ>} ubq
 * @returns {Object|Array}
 */
UBConnection.prototype.query = function(ubq){
    if (Array.isArray(ubq)) {
        return this.xhr({endpoint: this.queryMethod, data: ubq});
    } else {
        return this.xhr({endpoint: this.queryMethod, data: [ubq]})[0];
    }
};

/**
 * HTTP request to UB server. In case of success response return body parsed to {Object} or {ArrayBuffer} depending of Content-Type response header
 *
 * @example
 *  conn.xhr({
 *      endpoint: 'runSQL',
 *      URLParams: {CONNECTION: 'dba'},
 *      data: 'DROP SCHEMA IF EXISTS ub_autotest CASCADE; DROP USER IF EXISTS ub_autotest;'
 *  });
 *
 * @protected
 * @param {Object} options
 * @param {String} options.endpoint
 * @param {String} [options.UBMethod] This parameter is **DEPRECATED**. Use `options.endpoint` instead
 * @param {String} [options.HTTPMethod='POST']
 * @param {Object} [options.headers] Optional request headers in format {headerName: headerValue, ..}
 * @param {Boolean} [options.simpleTextResult=false] do not parse response and return it as is even if response content type is JSON
 * @param {*} [options.URLParams] Optional parameters added to URL using http.buildURL
 * @param {ArrayBuffer|Object|String} [options.data] Optional body
 * @param  {String} [options.responseType] see <a href="https://developer.mozilla.org/en-US/docs/DOM/XMLHttpRequest#responseType">responseType</a>.
 *                                          Currently only `arraybuffer` suported.
 * @returns {ArrayBuffer|Object|String}
 */
UBConnection.prototype.xhr = function(options){
    var
        me=this,
        req = this.clientRequest, resp, result = {};

    var path = this.servicePath + (options.endpoint || options.UBMethod);
    if (options.URLParams){
        path = http.buildURL(path, options.URLParams);
    }
    if (me.authNeed && !NON_AUTH_URLS_RE.test(path) ) { //request need authentication
        var session = me.authorize(me._inRelogin);
        req.setHeader('Authorization', session.authHeader());
    }
    req.setMethod(options.HTTPMethod || 'POST'); // must be after auth request!
    req.setPath(path);

    if (options.headers){
        _.forEach(options.headers, function(val, key){
            req.setHeader(key, val);
        })
    }
    if (options.data){
        if (options.data.toString() === '[object ArrayBuffer]'){
            req.setHeader('Content-Type', 'application/octet-stream');
        } else {
            req.setHeader('Content-Type', 'application/json;charset=utf-8');
        }
        resp = req.end(options.data);
    } else {
        resp = req.end();
    }
    var status =  resp.statusCode;

    if (200 <= status && status < 300){
        if (options.responseType === 'arraybuffer'){
            result = resp.read('bin');
        } else if ( ((resp.headers('content-type') || '').indexOf('json') >= 0) && !options.simpleTextResult) {
            result = JSON.parse(resp.read());
        } else {
            result = resp.read(); // return string readed as UTF-8
        }
    } else if (status === 401 && me.isAuthorized()) { // relogin
        if (me._inRelogin){
            me._inRelogin = false;
            throw new Error('invalid user name or password');
        }
        me._inRelogin = true;
        console.debug('Session expire - repeat auth request');
        try {
            result = me.xhr(options);
        } finally {
            me._inRelogin = false;
        }
    } else {
        if ((status === 500) && ((resp.headers('content-type') || '').indexOf('json') >= 0)) { // server report error and body is JSON
            var respObj = JSON.parse(resp.read());
            if (respObj.errMsg){
                throw new Error('Server error: "' + respObj.errMsg);
            } else {
                throw new Error('HTTP communication error: "' + status + ': ' + http.STATUS_CODES[status] + '" during request to: ' + path);
            }
        } else {
            throw new Error('HTTP communication error: "' + status + ': ' + http.STATUS_CODES[status] + '" during request to: ' + path);
        }
    }
    return result;
};

/**
 * Perform get request to `endpoint` with optional URLParams.
 * @param {String} endpoint
 * @param {*} [URLParams]
 * @returns {ArrayBuffer|Object|String}
 */
UBConnection.prototype.get = function(endpoint, URLParams){
    var params = {
        endpoint: endpoint, HTTPMethod: 'GET'
    };
    if (URLParams) { params.URLParams = URLParams; }
    return this.xhr(params);
};

/**
 * Shortcut method to perform authorized `POST` request to application we connected
 * @param {String} endpoint
 * @param {ArrayBuffer|Object|String} data
 * @returns {ArrayBuffer|Object|String}
 */
UBConnection.prototype.post = function(endpoint, data){
    return this.xhr({endpoint: endpoint, data: data});
};

/**
 * Shortcut method to perform authorized `POST` request to `ubql` endpoint
 * @deprecated Since UB 1.11 use UBConnection.query
 * @param {Array.<ubRequest>} runListData
 * @returns {Object}
 */
UBConnection.prototype.runList = function(runListData){
    return this.xhr({endpoint: this.queryMethod, data: runListData});
};

/**
 * Send request to any endpoint. For entity-level method execution (`ubql` endpoint) better to use {@link UBConnection.query}
 * @returns {*} body of HTTP request result. If !simpleTextResult and response type is json - then parsed to object
 */
UBConnection.prototype.runCustom = function(endpoint, aBody, aURLParams, simpleTextResult, aHTTPMethod){
    return this.xhr({HTTPMethod: aHTTPMethod ? aHTTPMethod : 'POST', endpoint: endpoint, data: aBody, simpleTextResult: simpleTextResult});
    //throw new Error ('Use one of runList/run/post/xhr UBConnection methods');
};

/**
 * Shortcut method to perform authorized `POST` request to `ubql` endpoint.
 * Can take one ubRequest and wrap it to array
 * @deprecated Since UB 1.11 use UBConnection.query
 * @param {ubRequest} request
 * @returns {Object}
 */
UBConnection.prototype.run = function(request){
    return this.xhr({endpoint: this.queryMethod, data: [request]})[0];
};

/**
 * Retrieve application domain information.
 * @method getDomainInfo
 * @return {Object}
 */
UBConnection.prototype.getDomainInfo = function(){
    return this.get('getDomainInfo');
};

/**
 * Logout from server if logged in
 */
UBConnection.prototype.logout = function(){
    if (this.isAuthorized()) {
        try {
            this.post('logout', '');
        } catch (e) {
        }
    }
};

/**
 * Execute insert method by add method: 'insert' to `ubq` query (if req.method not already set)
 *
 * If `ubq.fieldList` contain only `ID` return inserted ID, else return array of attribute values passed to `fieldList`.
 * If no field list passed at all - return response.resultData (null usually).
 *
        var testRole = conn.insert({
            entity: 'uba_role',
            fieldList: ['ID', 'mi_modifyDate'],
            execParams: {
                name: 'testRole1',
                allowedAppMethods: 'runList'
            }
        });
        console.log(testRole); //[3000000000200,"2014-10-21T11:56:37Z"]

        var testRoleID = conn.insert({
            entity: 'uba_role',
            fieldList: ['ID'],
            execParams: {
                name: 'testRole1',
                allowedAppMethods: 'runList'
            }
        });
        console.log(testRoleID); //3000000000200
 *
 * @param {ubRequest} ubq
 * @return {*}
 */
UBConnection.prototype.insert = function(ubq){
    var req = _.clone(ubq, true);
    req.method = req.method || 'insert';
    var res = this.query(req);
    if (req.fieldList) {
        return (req.fieldList.length === 1) && (req.fieldList[0] = 'ID') ? res.resultData.data[0][0] : res.resultData.data[0];
    } else {
        return res.resultData;
    }
};


module.exports = UBConnection;