UNPKG

@hosoft/restful-api-framework

Version:

Base framework of the headless cms HoServer provided by http://helloreact.cn

566 lines (488 loc) 19.1 kB
/* 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()