@hosoft/restful-api-framework
Version:
Base framework of the headless cms HoServer provided by http://helloreact.cn
566 lines (488 loc) • 19.1 kB
JavaScript
/* eslint-disable camelcase */
/**
* HoServer API Server Ver 2.0
* Copyright http://hos.helloreact.cn
*
* create: 2020/06/06
**/
const _ = require('lodash')
const axios = require('axios')
const config = require('@hosoft/config')
const ErrorCodes = require('../base/constants/error-codes')
const fs = require('fs')
const hddserial = require('hddserial')
const i18next = require('i18next')
const jwt = require('jsonwebtoken')
const md5 = require('md5')
const moment = require('moment')
const path = require('path')
const { BaseHelper } = require('../base/helpers')
const { fileUtils } = require('../utils')
const { machineIdSync } = require('node-machine-id')
const { Model, Plugin } = require('../models')
const DEF_ENABLED_PLUGINS = [
'hos-plugin-auto-crud-page',
'hos-plugin-batch-import-export',
'hos-plugin-doc-gen',
'hos-plugin-sdk-gen'
]
const FREE_PLUGINS = [
'hos-plugin-ai',
'hos-plugin-batch-import-export',
'hos-plugin-cloud-storage',
'hos-plugin-email',
'hos-plugin-geo-location',
'hos-plugin-payment',
'hos-plugin-sms',
'hos-plugin-sns',
'hos-plugin-social',
'hos-plugin-sso',
'hos-plugin-wechat'
]
const HOS_API_URI = 'https://hosapi.helloreact.cn/official'
const CHECK_LICENSE_DURATION = 60000
/**
* Plugin manager
*/
class PluginManager {
constructor() {
this.installedPlugins = [] // local plugin files
this.savedPlugins = [] // db plugins
}
/**
* load plugin from dir
*/
loadPlugins(plugins, dir, searchLevel = 1) {
const loopScanPluginsDir = (pluginDir, level) => {
const pa = fs.readdirSync(pluginDir)
pa.forEach((ele, index) => {
const fullPath = pluginDir + '/' + ele
const info = fs.statSync(fullPath)
if (info.isDirectory() && ele.startsWith('hos-plugin')) {
const plugin = this.savedPlugins.find((p) => p.name === ele)
const pluginInstance = require(path.join(fullPath, 'index'))
const enableByDefault = DEF_ENABLED_PLUGINS.includes(ele) ? 1 : 0
const pluginMeta = {
name: ele,
enabled: plugin && plugin.enabled !== undefined ? plugin.enabled : enableByDefault,
packages: [{ type: 'server', dir: fullPath.substr(fullPath.indexOf(global.APP_PATH)) }]
}
if (pluginInstance) {
pluginMeta.instance = pluginInstance
plugins.push(pluginMeta)
}
}
})
}
if (dir instanceof Array) {
for (const dirItem of dir) {
loopScanPluginsDir(dirItem, searchLevel)
}
} else {
loopScanPluginsDir(dir, searchLevel)
}
}
/**
* check init plugin db tables
*/
async initPluginDb(plugin) {
const packageInfo = plugin.packages.find((p) => p.type === 'server')
if (!packageInfo) {
return
}
const dbPath = path.join(packageInfo.dir, 'schema')
if (!fs.existsSync(dbPath)) return
const pa = fs.readdirSync(dbPath)
let createNew = false
for (const ele of pa) {
if (ele.endsWith('.json')) {
const schemaFile = dbPath + '/' + ele
const dbSchema = fileUtils.getJsonFile(schemaFile)
if (dbSchema.name && !BaseHelper.getModel(dbSchema.name)) {
dbSchema.type = 2 // plugin
await Model.create(dbSchema)
// for rdb, we need sync db
const model = await BaseHelper.getDB('default').getModel(dbSchema)
await model.sync()
createNew = true
logger.warn(
`plugin db "${dbSchema.name}" not exist and has been auto created, ` +
`please restart service to take effect.`
)
}
}
}
return createNew
}
/**
* init all plugins by call plugin init function
*/
async initPlugins(container, router, app) {
// don't load at start, plugin also require pluginManager
const pluginDir = path.join(global.APP_PATH, 'node_modules', '@hosoft')
if (!fs.existsSync(pluginDir)) {
logger.debug(`loadPlugins, plugin directory not exist: ${pluginDir}`)
return
}
// read enable status from db
this.savedPlugins = await Plugin.find({})
this.loadPlugins(this.installedPlugins, pluginDir)
// check plugin license (comment line 2)
await this.initPluginLicenses()
setTimeout(() => this.startCheckLicenseTimer(), CHECK_LICENSE_DURATION)
// load package.json for version and description, url, main
for (const plugin of this.installedPlugins) {
const packageInfo = plugin.packages[0]
const packageJson = await fileUtils.getJsonFile(path.join(packageInfo.dir, 'package.json'))
if (packageJson) {
plugin.version = packageJson.version
packageInfo.main = packageJson.main
packageInfo.version = packageJson.version
packageInfo.dis_name = packageJson.description
}
}
logger.debug(`loadPlugins, total: ${this.installedPlugins.length} plugin packages`)
let needRestart = false
for (const plugin of this.installedPlugins) {
if (plugin.enabled === 1) {
needRestart = (await this.initPluginDb(plugin)) || needRestart
}
}
if (needRestart) {
process.exit()
}
// init plugin i18n first
for (const plugin of this.installedPlugins) {
if (plugin.enabled === 1) {
await this.loadPluginLocales(plugin)
}
}
await this.initPluginLocales()
// init plugin
for (const plugin of this.installedPlugins) {
if (plugin.enabled === 1 && plugin.instance && plugin.instance.init) {
await plugin.instance.init(container, router, app, this)
}
}
}
async initPluginLocales() {
const pluginLocales = {}
for (const plugin of this.installedPlugins) {
if (plugin.locales) {
const languages = _.keys(plugin.locales)
for (const lan of languages) {
if (!pluginLocales[lan]) {
pluginLocales[lan] = { plugin: {} }
}
pluginLocales[lan].plugin = Object.assign({}, pluginLocales[lan].plugin, plugin.locales[lan])
}
}
}
const tp = await i18next.createInstance().init({
// debug: true,
initImmediate: true,
fallbackLng: 'en',
lng: config.get('server.language') || 'en',
ns: ['plugin'],
defaultNS: 'plugin',
resources: pluginLocales
})
global.tp = tp
}
async loadPluginLocales(plugin) {
const packageInfo = plugin.packages.find((p) => p.type === 'server')
if (!packageInfo) {
return
}
const localesDir = path.join(packageInfo.dir, 'locales')
if (fs.existsSync(localesDir)) {
if (!plugin.locales) {
plugin.locales = {}
}
const subDirs = fileUtils.getDirectories(localesDir)
for (const local of subDirs) {
const localFiles = fileUtils.getFiles(path.join(localesDir, local), '.json')
for (const localFile of localFiles) {
const localResources = fileUtils.getJsonFile(localFile.filepath)
plugin.locales[local] = localResources
}
}
}
}
async initImplClass(dir, container, router, app) {
const pa = fs.readdirSync(dir)
pa.forEach((ele, index) => {
const fullPath = dir + '/' + ele
const info = fs.statSync(fullPath)
const implFile = path.join(fullPath, 'index')
if (info.isDirectory() && (fs.existsSync(implFile + '.js') || fs.existsSync(implFile + '.jsc'))) {
const implInst = require(implFile)
implInst.init && implInst.init(container, router, app, this)
}
})
}
async enablePlugin(name, enabled, package_info) {
const exsitPlugin = this.savedPlugins.find((p) => p.name === name)
if (!exsitPlugin) {
let version = package_info.version
if (package_info instanceof Array) {
version = package_info[0].version
}
const newPlugin = {
name: name,
version: version,
enabled: enabled ? 1 : 0,
packages: package_info instanceof Array ? package_info : [package_info]
}
this.savedPlugins.push(newPlugin)
return await Plugin.create(newPlugin)
}
exsitPlugin.enabled = enabled ? 1 : 0
return await Plugin.update({ name: name }, exsitPlugin)
}
getSavedPlugins(args) {
return this.savedPlugins
}
getInstalledPlugins(args) {
const savedPluginNames = {}
for (const plugin of this.savedPlugins) {
savedPluginNames[plugin.name] = plugin.enabled
}
// merge two list by plugin name
const result = []
for (const plugin of this.installedPlugins) {
if (plugin.name in savedPluginNames) {
plugin.enabled = savedPluginNames[plugin.name]
delete savedPluginNames[plugin.name]
}
result.push({
name: plugin.name,
version: plugin.version,
enabled: plugin.enabled,
expire_time: plugin.expire_time,
packages: plugin.packages
})
}
for (const pluginName in savedPluginNames) {
let hasRemoved = false
const plugin = this.savedPlugins.find((p) => p.name === pluginName)
if (!plugin) {
continue
}
// ignore removed server plugin
const packageInfo = plugin.packages.find((p) => p.type === 'manager')
if (!packageInfo) {
hasRemoved = true
}
if (!hasRemoved) {
result.push({
name: plugin.name,
version: plugin.version,
enabled: plugin.enabled,
packages: plugin.packages
})
}
}
if (args && args.enabled !== undefined) {
return result.filter((p) => p.enabled === args.enabled)
}
return result
}
getPlugin(name) {
return this.installedPlugins.find((p) => p.name === name)
}
/***************************************************
* license related
**************************************************/
async initPluginLicenses() {
const accessKey = config.get('server.accessKey')
const licenseFile = path.join(global.APP_PATH, 'config', 'license.pem')
const port = config.get('server.port')
const preCheckTime = this.getPreCheckTime()
let diskSerialNo = null
let license = null
try {
diskSerialNo = await this.getDisSerialNumber()
} catch (e) {
logger.warn('get disk serial number failed: ', e)
}
if (!(accessKey && fs.existsSync(licenseFile))) {
license = await this.loadDefaultLicense(port, diskSerialNo, licenseFile, preCheckTime)
} else {
license = await this.loadLicense(port, diskSerialNo, licenseFile, accessKey, preCheckTime)
}
this.enablePluginByLicense(license)
return license
}
async validatePluginLicense(pluginName) {
const plugin = this.getPlugin(pluginName)
if (!plugin) {
return Promise.reject({
message: `invalid plugin ${pluginName}`,
code: ErrorCodes.GENERAL_ERR_PLUGIN
})
}
if (plugin.enabled !== 1) {
return Promise.reject({
message: `plugin ${pluginName} has disabled or license expired`,
code: ErrorCodes.GENERAL_ERR_LICENSE
})
}
}
async startCheckLicenseTimer() {
if (!this.lastCheckTime) {
this.lastCheckTime = moment()
} else if (moment().diff(this.lastCheckTime, 'minutes') >= 60 * 12) {
await this.initPluginLicenses()
this.lastCheckTime = moment()
}
setTimeout(() => this.startCheckLicenseTimer(), CHECK_LICENSE_DURATION)
}
getPreCheckTime() {
const cacheFile = path.join(global.APP_PATH, 'config', '.pre_check_time')
if (fs.existsSync(cacheFile)) {
try {
const timestampStr = fileUtils.readFileContent(cacheFile).toString()
const timestamp = moment(timestampStr)
if (timestamp.isValid()) {
return timestamp
}
} catch (e) {
fs.unlinkSync(cacheFile)
}
}
return null
}
savePreCheckTime() {
const cacheFile = path.join(global.APP_PATH, 'config', '.pre_check_time')
fs.writeFileSync(cacheFile, moment().format('YYYY-MM-DD HH:mm:ss'), { encoding: 'utf-8' })
}
async loadDefaultLicense(port, diskSerialNo, licenseFile, preCheckTime) {
let license = null
if (fs.existsSync(licenseFile)) {
const encLicenseInfo = fileUtils.readFileContent(licenseFile).toString()
if (encLicenseInfo) {
try {
license = await jwt.verify(encLicenseInfo, 'HosAbc12#')
} catch (e) {
logger.info('error load pre-saved license file: ' + licenseFile, e)
}
}
}
// only check one time every 12 hours
if (license && preCheckTime && moment().diff(preCheckTime, 'minutes') <= 60 * 12) {
return license
}
if (!diskSerialNo) {
return
}
try {
const rep = await axios.post(`${HOS_API_URI}/api/v1/auth/licences/detail`, {
serial_no: diskSerialNo,
port: port,
plugin_list: this.getInstalledPlugins()
})
if (rep.status == 200 && rep.data && rep.data.code / 1 === 200) {
this.saveLicence(rep.data.data, licenseFile)
license = await jwt.verify(rep.data.data, 'HosAbc12#')
this.savePreCheckTime()
} else {
logger.info(`error load default license, status: ${rep.status}, code: ${_.get(rep.data, 'code')}`)
}
} catch (e) {
logger.info('error load default license: ', e)
}
return license
}
async loadLicense(port, diskSerialNo, licenseFile, accessKey, preCheckTime) {
let license = null
const encLicenseInfo = fileUtils.readFileContent(licenseFile).toString()
if (encLicenseInfo) {
try {
license = await jwt.verify(encLicenseInfo.trim(), accessKey)
} catch (e) {
logger.info('error load license file: ' + licenseFile, e)
}
}
if (license && preCheckTime && moment().diff(preCheckTime, 'minutes') <= 30) {
return license
}
// check if enterprise license
let isEnterprise = true
if (license && license.license_detail) {
for (const item of license.license_detail) {
if (item.auth_type != 2) {
isEnterprise = false
break
}
}
}
if (!isEnterprise && diskSerialNo && license && license.client_token) {
const timestamp = moment().format('YYYY-MM-DD HH:mm:ss')
const sign = md5(jwt.sign(`${diskSerialNo}${port}${timestamp}`, accessKey))
const pluginList = this.installedPlugins.filter((p) => !FREE_PLUGINS.includes(p.name))
try {
const rep = await axios.post(`${HOS_API_URI}/auth/licences/detail`, {
timestamp: timestamp,
sign: sign,
client_token: license.client_token,
// baisc info
serial_no: diskSerialNo,
port: port,
plugin_list: pluginList
})
if (rep.status == 200 && rep.data && rep.data.code / 1 === 200) {
this.saveLicence(rep.data.data, licenseFile)
license = await jwt.verify(rep.data.data, accessKey)
this.savePreCheckTime()
} else {
logger.info(`error load default license, status: ${rep.status}, code: ${_.get(rep.data, 'code')}`)
}
} catch (e) {
logger.info('error update license: ', e)
}
}
return license
}
saveLicence(licenseInfo, licenseFile) {
fs.writeFileSync(licenseFile, licenseInfo, { encoding: 'utf-8' })
}
enablePluginByLicense(license) {
const authorizedPlugins = [...FREE_PLUGINS]
const allPlugins = _.concat(this.installedPlugins, this.savedPlugins)
if (license && license.license_detail) {
for (const item of license.license_detail) {
const plugin = allPlugins.find((p) => p.name === item.product_name)
if (plugin) {
plugin.expire_time = item.expire_time
}
// still not expire
if (
!authorizedPlugins.includes(item.product_name) &&
moment(item.expire_time).diff(moment(), 'days') >= 0
) {
authorizedPlugins.push(item.product_name)
}
}
}
for (const plugin of allPlugins) {
if (plugin.enabled === 1 && !authorizedPlugins.includes(plugin.name)) {
plugin.enabled = 2
}
}
}
getDisSerialNumber() {
return new Promise((resolve, reject) => {
hddserial.first((err, serial) => {
if (err) {
serial = machineIdSync()
if (!serial) {
return reject(err)
}
}
resolve(serial)
})
})
}
}
module.exports = new PluginManager()