ubjs/packages/base/UBConnection.js

/**
 * HTTP(s) connection to UnityBase server. Exports a {@link UBConnection} class
 *
 * @module @unitybase/base/UBConnection
 */

const http = require('http')
const _ = require('lodash')
const UBSession = require('./UBSession')
const CryptoJS = require('@unitybase/cryptojs/core')
const {ServerRepository} = require('./ServerRepository')
const UBDomain = require('./UBDomain')

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

/* global nsha256,btoa */
/**
 * Using instance of this class you can execute a authorized UnityBase server methods.
 *
 * The most used method is {@link UBConnection#query  UBConnection.query} - a authorized request to `ubql` endpoint.
 *

     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'} }
     var domain = conn.getDomainInfo();
     if (domain.has('my_entity')){
               ..
      }

 * @class
 * @param {Object} options Connection parameters. See {@link module:http http.request} for details
 */
function UBConnection (options) {
  let me = this
  let client = http.request(options)
  let /** @type UBDomain */
    _domain
  let ubSession = null
  let lookupCache = {}
  let userDataDefault = { lang: 'en' }
  let appInfo = {}

   /**
   * Internal instance of HTTP client
   * @type {ClientRequest}
   * @protected
   * @readonly
   */
  this.clientRequest = client
  let appName = client.options.path
  let 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 {@link 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
   * @private
   */
  appInfo = this.get('getAppInfo') // non-auth request

  /**
   * Return information about how application is configured as returned by `getAppInfo` endpoint
   * @return {Object}
   */
  this.getAppInfo = function () {
    return appInfo
  }

  /**
   * Retrieve application domain information.
   * @param {Boolean} [isExtended=false] For member of admin group cen return a addinitonal domain information, such as mappings, connection details, indexes
   * @return {UBDomain}
   */
  this.getDomainInfo = function (isExtended) {
    if (!_domain) {
      // authorize connection to get a valid user name
      if (this.authNeed) this.authorize(false)

      let domainData = this.get('getDomainInfo', {
        v: 4,
        userName: this.userLogin(),
        extended: isExtended || undefined
      })
      _domain = new UBDomain(domainData)
    }
    return _domain
  }

  /**
   * 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'

  /** 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) {
    let resp, serverNonce, secretWord, pwdHash
    if (!ubSession || isRepeat) {
      ubSession = null
      if (!this.onRequestAuthParams) {
        throw new Error('set UBConnection.onRequestAuthParams function to perform authorized requests')
      }
      let authParams = this.onRequestAuthParams(this)
      if (authParams.authSchema === 'UBIP') {
        if (isRepeat) {
          throw new Error('UBIP authentication must not return false on the prev.step')
        }
        resp = this.xhr({endpoint: 'auth', headers: {'Authorization': authParams.authSchema + ' ' + authParams.login}})
        ubSession = new UBSession(resp, '', authParams.authSchema)
      } else {
        resp = this.get('auth', {
          AUTHTYPE: authParams.authSchema || 'UB',
          userName: authParams.login
        })
        let clientNonce = nsha256(new Date().toISOString().substr(0, 16))
        let 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 = this.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 'anonymous' in case not logged in
   * @returns {String}
   */
  this.userLogin = function () {
    return this.isAuthorized() ? ubSession.logonname : 'anonymous'
  }

  /**
   * 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) {
    let uData = this.isAuthorized() ? ubSession.userData : userDataDefault
    return key ? uData[key] : uData
  }

  /**
   * Lookup value in entity using aCondition.
   *
   *      // create condition using Repository
   *      var myID = conn.lookup('ubm_enum', 'ID',
   *           conn.Repository('ubm_enum').where('eGroup', '=', 'UBA_RULETYPE').where('code', '=', 'A').ubql().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) {
    let me = this
    let cKey = aEntity + JSON.stringify(aCondition) + lookupAttribute
    let resData
    let request

    if (!doNotUseCache && lookupCache.hasOwnProperty(cKey)) {
      return lookupCache[cKey] // found in cache
    } else {
      request = this.Repository(aEntity).attrs(lookupAttribute).limit(1).ubql()

      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.data
      if ((resData.length === 1) && (resData[0][0] != null)) { // `!= null` is equal to (not null && not undefined)
        if (!doNotUseCache) {
          lookupCache[cKey] = resData[0][0]
        }
        return resData[0][0]
      } else {
        return null
      }
    }
  }
}

/**
 * Perform authorized UBQL request.
 * Can take one QB Query or an array of UB Query and execute it at once.
 * @param {Object|Array<Object>} 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;'
 *  });
 *
 * @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|Array<Object>}
 */
UBConnection.prototype.xhr = function (options) {
  let me = this
  let req = this.clientRequest
  let resp
  let result = {}

  let 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
    let 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()
  }
  let status = resp.statusCode

  if (status >= 200 && 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
      let 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) {
  let 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|Array<object>}
 */
UBConnection.prototype.post = function (endpoint, data) {
  return this.xhr({endpoint: endpoint, data: data})
}

/**
 * Shortcut method to perform authorized `POST` request to `ubql` endpoint
 * @private
 * @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 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 || 'POST', endpoint: endpoint, URLParams: aURLParams, 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]
}

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

/**
 * Set document method saves a file content as a potential value of the specified entity instance attribute,
 * the value is saved to temp store.
 * Call this function before entity insert of update. Result of this function is what shall be assigned to the
 * attribute value, to "execParams".
 * @param {string} entity Entity name
 * @param {string} attribute Entity attribute name
 * @param {number} id ID of the record
 * @param {ArrayBuffer} data File content
 * @param {string} origName
 * @param {string} [fileName] If not specified, origName will be used.
 * @return {string}
 *
 * @example
 * const myObj = conn.Repository(entityName)
 *     .attrs('ID', 'mi_modifyDate')
 *     .where('code', '=', code)
 *     .selectSingle();
 * const {ID, mi_modifyDate} = myObj;
 * const data = fs.readFileSync(fileName, {encoding: 'bin'});
 * const tempStoreResult = conn.setDocument(entityName, 'configuration', ID, data, fn);
 * conn.query({
 *      entity: entityName,
 *		method: 'update',
 *		execParams: {ID, configuration: tempStoreResult, mi_modifyDate}
 *	});
 */
UBConnection.prototype.setDocument = function (entity, attribute, id, data, origName, fileName) {
	const setDocumentResponse = this.xhr({
		HTTPMethod: 'POST',
		endpoint: 'setDocument',
		data,
		URLParams: {
			entity, attribute, id,
			origName: origName || fileName,
			filename: fileName || origName
		}
	})
	return JSON.stringify(setDocumentResponse.result)
}

/**
 * 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)
  let req = ubq
  req.method = req.method || 'insert'
  let 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
  }
}

/**
 * Create a new instance of repository
 * @param {String} entityName name of Entity we create for
 * @returns {ServerRepository}
 */
UBConnection.prototype.Repository = function (entityName) {
  return new ServerRepository(this, entityName)
}

module.exports = UBConnection