ubjs/packages/ub/modules/endpoints.js

/**
 * UnityBase default endpoints implementation.
 * In UB <=4 these endpoints have been defined in the native code
 * @author pavel.mash on 13.10.2016.
 */

const {relToAbs} = process.binding('fs')
const fs = require('fs')
const path = require('path')
const _ = require('lodash')
const mime = require('mime-types')
const WebSockets = require('./web-sockets')
const UBDomain = require('@unitybase/base/UBDomain')

/**
 * @param {THTTPResponse} resp
 * @param {string} reason
 * @return {boolean}
 * @private
 */
function badRequest (resp, reason) {
  resp.statusCode = 400
  resp.writeEnd('Bad request')
  if (reason) console.error('Bad request.', reason)
  return false
}

/**
 * @param {THTTPResponse} resp
 * @param {string} reason
 * @return {boolean}
 * @private
 */
function notFound (resp, reason) {
  resp.statusCode = 404
  resp.writeEnd('Not found')
  if (reason) console.error('Not found', reason)
  return false
}

/**
 *
 * @param {string} reqPath
 * @param {THTTPResponse} resp
 * @private
 */
function resolveModelFile (reqPath, resp) {
  let entry = {
    fullPath: ''
  }
  // cache actual file path & type for success models/* request
  let cached = App.globalCacheGet(`UB_MODELS_REQ${reqPath}`)
  if (!cached) {
    let parts = reqPath.replace(/\\/g, '/').split('/')
    let modelName = parts.shift()
    if (!modelName) {
      return badRequest(resp, 'first part of path must be model name')
    }
    let model = App.domainInfo.models[modelName]
    if (!model) {
      return badRequest(resp, 'no such model ' + modelName)
    }
    entry.fullPath = relToAbs(model.realPublicPath, parts.join('\\'))
    if (!entry.fullPath) {
      return badRequest(resp, 'cant resolve relative path')
    }
    if (!entry.fullPath.startsWith(model.realPublicPath)) {
      return badRequest(resp, `resolved path "${entry.fullPath}" is not inside model folder ${model.realPublicPath}`)
    }
    if (!fs.existsSync(entry.fullPath)) {
      return notFound(resp, `"${entry.fullPath}"`)
    }
    let ct = mime.contentType(parts.pop())
    if (ct) {
      entry.mimeHead = 'Content-Type: ' + ct
    }
    App.globalCachePut(`UB_MODELS_REQ${reqPath}`, JSON.stringify(entry))
  } else {
    entry = JSON.parse(cached)
  }
  resp.writeEnd(entry.fullPath)
  resp.writeHead('Content-Type: !STATICFILE')
  if (entry.mimeHead) {
    resp.writeHead(entry.mimeHead)
  }
  resp.statusCode = 200
}

/**
 * The `models` endpoint. Responsible for return a static files content from a model publicPath folders
 *
 * For example request `GET http://host:port/models/modelName/fileName`
 * will return a file from a public folder of a model `modelName`
 *
 * @param {THTTPRequest} req
 * @param {THTTPResponse} resp
 * @member {App}
 */
function models (req, resp) {
  if ((req.method !== 'GET') && (req.method !== 'HEAD')) {
    return badRequest(resp, 'invalid request method ' + req.method)
  }
  let reqPath = req.decodedUri
  if (!reqPath || !reqPath.length || (reqPath.length > 250)) {
    return badRequest(resp, 'path too long (max is 250) ' + reqPath.length)
  }
  resolveModelFile(reqPath, resp)

  // cache forever - do not cache index*.html
    // resp.writeHead('Content-Type: text/html\r\nCache-Control: no-cache, no-store, max-age=0, must-revalidate\r\nPragma: no-cache\r\nExpires: Fri, 01 Jan 1990 00:00:00 GMT');
}

const MODULES_ROOT = path.join(process.configPath, 'node_modules')

/**
 * The `clientRequire` endpoint. Used by client side loaders (SystemJS for example) to emulate commonJS require
 *
 * **Security note**: It is __very important__ to prevent loading a server-side logic to the client - server-side logic MUST be hidden from clients
 *
 * To do this `clientRequire` endpoint:
 *
 *   - allow access only to modules inside application `node_modules` folder
 *   - in case requested module is a UnityBase model (present in ubConfig.json) then restrict access to non-public part of such model
 *
 * So developer MUST listen all modules what contains a sensitive server-side business logic in the application
 * config and set a `moduleName` parameter correctly for such models
 *
 * @param {THTTPRequest} req
 * @param {THTTPResponse} resp
 */
function clientRequire (req, resp) {
  console.log(`Call clientRequire`)
  if ((req.method !== 'GET') && (req.method !== 'HEAD')) {
    return badRequest(resp, 'invalid request method ' + req.method)
  }
  let reqPath = req.decodedUri
  // cache actual file path & type for success clientRequire/* request
  let cached = App.globalCacheGet(`UB_CLIENT_REQ${reqPath}`)
  let entry = {
    fullPath: ''
  }
  if (!cached) {
    if (!reqPath || !reqPath.length || (reqPath.length > 250)) {
      return badRequest(resp, 'path too long (max is 250) ' + reqPath.length)
    }

    if (reqPath.startsWith('models/')) {
      resolveModelFile(reqPath.slice('models/'.length), resp)
      return
    }

    if (reqPath.indexOf('..') !== -1) { // prevent clientRequire/../../../secret.txt attack
      return badRequest(resp, `Relative path (${reqPath}) not allowed`)
    }
    if (path.isAbsolute(reqPath)) { // prevent clientRequire/d:/secret.txt attack
      return badRequest(resp, `Absolute path (${reqPath}) not allowed`)
    }
    let resolvedPath
    try {
      console.debug(`Try to resolve ${reqPath}`)
      resolvedPath = require.resolve(reqPath)
    } catch (e) {
      resolvedPath = undefined
    }
    if (!resolvedPath) {
      console.error(`Package ${reqPath} not found`)
      return notFound(resp, `"${reqPath}"`)
    }
    if (!resolvedPath.startsWith(MODULES_ROOT)) {
      return badRequest(resp, `Path (${reqPath}) must be inside application node_modules folder but instead resolved to ${resolvedPath}`)
    }

    let models = App.domainInfo.models
    let restrictAccess = false
    // allow access to package.json for dynamically load a module from UI
    if (! reqPath.endsWith('/package.json')) {
      // in case this is request to UnityBase model - check resolved file is inside model public folder
      _.forEach(models, (model) => {
        if (model.moduleName &&
          // do not compare req @unitybase/ub-pub with module @unitybase/ub
          ((reqPath === model.moduleName) || reqPath.startsWith(model.moduleName + '/')) &&
          !resolvedPath.startsWith(model.realPublicPath)
        ) {
          restrictAccess = true
          return false
        }
      })
    }

    if (restrictAccess) {
      return badRequest(resp, `Request to UnityBase model ${reqPath} resolved to (${resolvedPath}) which is not inside any of public models folder`)
    }
    entry.fullPath = resolvedPath
    let ct = mime.contentType(path.extname(resolvedPath))
    if (ct) {
      entry.mimeHead = 'Content-Type: ' + ct
    }
    App.globalCachePut(`UB_CLIENT_REQ${reqPath}`, JSON.stringify(entry))
    console.debug(`Resolve ${reqPath} -> ${resolvedPath}`)
  } else {
    entry = JSON.parse(cached)
    console.debug(`Retrieve cached ${reqPath} -> ${entry.fullPath}`)
  }
  resp.writeEnd(entry.fullPath)
  resp.writeHead('Content-Type: !STATICFILE')
  if (entry.mimeHead) {
    resp.writeHead(entry.mimeHead)
  }
  resp.statusCode = 200
}

/**
 * The `getAppInfo` endpoint. Responsible for return a information about application required for a
 * initial client side connectivity and UI setup
 *
 * @param {THTTPRequest} req
 * @param {THTTPResponse} resp
 */
function getAppInfo (req, resp) {
  const serverConfig = App.serverConfig
  let DSTU = serverConfig.security && serverConfig.security.dstu

  let appInfo = {
    serverVersion: process.version,
    defaultLang: serverConfig.application.defaultLang || 'en',
    simpleCertAuth: !!serverConfig.security.simpleCertAuth,

    trafficEncryption: DSTU ? DSTU.trafficEncryption : false,
    serverCertificate: (DSTU && DSTU.trafficEncryption) ? App.serverPublicCert : '',
    encryptionKeyLifetime: (DSTU && DSTU.trafficEncryption) ? DSTU.encryptionKeyLifeTime : 0,

    authMethods: serverConfig.security.authenticationMethods || [],

    supportedLanguages: serverConfig.application.domain.supportedLanguages || ['en'],

    supportedWSProtocols: process.isWebSocketEnabled ? WebSockets.registeredProtocols() : [],

    uiSettings: serverConfig.uiSettings || {}
  }
  resp.writeEnd(appInfo)
  resp.statusCode = 200
  resp.validateETag()
}

// const RESTRICTED_ENTITY_PROPS = ['connectionConfig', 'dbExtensions', 'mapping'] // adv allow mapping
// const RESTRICTED_ATTRIBUTES_PROPS = ['physicalDataType', 'generateFK', 'mapping'] // adv allow mapping
// const RESTRICTED_MODEL_PROPS = ['realPublicPath'] // adv allow realPublicPath
// const advancedDomainInfoEntityProps = []
//
// function domainReplacer (key, val) {
//   if (!this) return undefined
//   if (this instanceof UBDomain.UBEntity) {
//     // at last one of entity method is accessible
//     if (!this.haveAccessToAnyMethods(Object.keys(this.entityMethods))) return undefined
//     // serialize only allowed properties
//     return RESTRICTED_ENTITY_PROPS.find(elm => elm === key) ? undefined : val
//   } else if (this instanceof UBDomain.UBEntity.UBEntityAttribute) {
//     // skip empty string
//     if ((typeof val === 'string') && !val) return undefined
//     if ((key === 'customSettings') && !Object.keys(val).length) return undefined
//     return RESTRICTED_ATTRIBUTES_PROPS.find(elm => elm === key) ? undefined : val
//   } else if (this instanceof UBDomain.UBModel) {
//     return RESTRICTED_MODEL_PROPS.find(elm => elm === key) ? undefined : val
//   } else {
//     return val
//   }
// }

const UBA_COMMON = require('@unitybase/base').uba_common
const queryString = require('querystring')
const appBinding = process.binding('ub_app')
const authenticationHandled = appBinding.handleAuthentication
const nativeGetDomainInfo = appBinding.getDomainInfo
/**
 * The `getDomainInfo` endpoint.
 * Return JSON representation of application Domain according to caller rights
 *
 * @param {THTTPRequest} req
 * @param {THTTPResponse} resp
 */
function getDomainInfo (req, resp) {
  // implementation below is SLOW. The bottlenack is JSON.stringify() for huge JSON
  // let restrictedDomain = {
  //   domain: App.domainInfo.entities, // _.filter(App.domainInfo.entities, (e) => e.code.startsWith('ubm')),
  //   models: App.domainInfo.models
  // }
  // let res = JSON.stringify(restrictedDomain, domainReplacer)

  let params = queryString.parse(req.parameters);
  let isExtended = (params['extended'] === 'true')
  if (isExtended && authenticationHandled && !UBA_COMMON.isSuperUser()) {
    return badRequest(resp, 'Extended domain info allowed only for member of admin group of if authentication is disabled')
  }
  if (!params['userName'] || params['userName'] !== Session.uData.login) {
    return badRequest(resp, 'userName=login parameter is required')
  }

  let res = nativeGetDomainInfo(isExtended)
  // let res = nativeGetDomainInfo(false)

  resp.writeEnd(res)
  resp.statusCode = 200
  resp.validateETag()
}

module.exports = {
  models,
  getAppInfo,
  clientRequire,
  getDomainInfo
}