'use strict' import * as Logger from 'log4js' import { socketioRPC, ConfigLoader } from 'frontblock-generic/Plugin'; import { ErrorResponse, SuccessResponse } from 'frontblock-generic/Types'; import express = require('express'); import http = require('http'); import bsock = require('bsock'); import { FrontblockCherryPicker } from "git-cherrypicker" import { promises as fs } from "fs" import { GitUpdater } from "./GitUpdater"; import * as git from "simple-git/promise" const kfs = require("key-file-storage").default('kfs') Logger.configure({ appenders: { "frontblock-admin": { type: 'stdout' }, //app: { type: 'file', filename: 'application.log' } }, categories: { default: { appenders: [ 'frontblock-admin' ], level: 'debug' } } }) const logger = Logger.getLogger("frontblock-admin") type hookRPC = { type: 'hook', generator: (socket) => Function, unhook:(uid:string)=>Promise } type unhookRPC = { type: 'unhook', fn: Function } type callRPC = { type: 'call', fn: Function } export type rpcInfo = { owner: string, name: string, args: string, info: hookRPC | unhookRPC | callRPC } export type AdminConf = { httpPort: number} /** * FrontblockAdmin * * The customer-facing dynamic component of the customer backend * Supports (un)loading plugins which will be communicated to its library component (See FrontblockLib.ts and the info() RPC) * * The list of available plugins is published via the frontblock API and downloaded via gitea-releases */ export class FrontblockAdmin extends ConfigLoader{ private hookToUids:{[hookName:string]:string[]} = {} private express private httpServer private io = bsock.createServer() private wsServer = http.createServer() private pm constructor(){ super("FrontblockAdmin") this.initialize() } getDefaultConfig(): AdminConf { return {httpPort: 8080} } private async updateDashboard(){ const dashboard = new GitUpdater("./static") const status = await dashboard.cloneRepo("https://gitea.frontblock.me/fb-dist/dashboard.git", true) console.log(status) } private async updatePluginmanager(){ const dashboard = new GitUpdater("./plugins/PluginManager") const status = await dashboard.cloneRepo("https://gitea.frontblock.me/fb-dist/pluginmanager.git", true) console.log(status) } private async initialize(){ await this.updateDashboard() await this.updatePluginmanager() let str = "../plugins/PluginManager/Plugin" const PM = await import(str) this.pm = new PM.default() this.startWebsocket() this.startWebserver() } private initApis(socket){ //Declare own functions const rpcInfos:rpcInfo[] = [ { owner: 'Admin', name: 'restartWebserver', args: 'port', info:{ type:'call', fn: (port:number) => { this.restartWebserver(port) } } },{ owner: 'Admin', name: 'info', args: '', info:{ type:'call', fn: () => { return rpcInfos } } },{ owner: 'Admin', name: 'installPluginManager', args: '', info: { type: 'call', fn: () => {} } } ] //translate RPCs to socket-bound function metadata const loadedPlugins = this.pm.getLoadedPlugins() for(const name in loadedPlugins){ loadedPlugins[name].backend.exportRPCs().forEach(rpc => { const info = this.rpcToRpcInfo(name, rpc) rpcInfos.push(info) }) } //Hook up all the functions for(const api of rpcInfos){ switch(api.info.type){ case 'call': try{ socket.unhook(api.name) }catch(e){ } socket.hook(api.name, api.info.fn) break case 'hook': const hook = api.info.generator(socket) hook.bind(this) try{ socket.unhook(api.name) }catch(e){ } socket.hook(api.name, hook) break case 'unhook': try{ socket.unhook(api.name) }catch(e){ } socket.hook(api.name, api.info.fn) break } } //initialize the lists of open hooks rpcInfos .filter(rpc => rpc.info.type === "hook") .forEach(hook => { this.hookToUids[hook.name] = [] }) //On close, unhook open hooks socket.on('close', () => { logger.info("Client disconnected") rpcInfos.forEach((rpc) => { if(this.hookToUids[rpc.name] == null) return this.hookToUids[rpc.name].forEach((uid) => { if(rpc.info.type === "hook"){ logger.info("Closing consumer `"+uid+"` owned by `"+rpc.name+"`") rpc.info.unhook(uid) } }) }) }) } private installPluginManager(){ } private startWebserver(){ if(this.httpServer != null || this.express != null){ logger.warn("Webserver is already running") return } let port:number = this.conf.httpPort this.express = express() this.express.use('/', express.static('static')) /** * get the compiled FrontendPlugins.js */ this.express.get('/plugins/:id'+".js", (request, response) => { let frontend = this.pm.getFrontend(request.params.id) response.status(200) response.set('Content-Type', 'application/javascript') response.send(frontend) }) /** * serve the index.html from the static folder */ this.express.get("/", (request, response) => { response.status(200) response.sendFile('index.html'); }) /** * redirect all the other traffic to the single * page app to the main entry point from where * a webpacked and rolled up index.html is serverd * and angular takes over routing */ this.express.get("*", (request, response) => { response.status(301) response.redirect('/') }) this.httpServer = new http.Server(this.express) this.httpServer.listen(port, () => { logger.info('Admin panel listening for HTTP on *'+port) }) } private stopWebserver(){ if(this.httpServer == null || this.express == null){ logger.warn("Webserver is not running") return } this.httpServer.close() this.httpServer = null this.express = null logger.info("Webserver stopped") } private restartWebserver(port:number){ this.stopWebserver() kfs[this.name+".conf"] = { httpPort: port } this.startWebserver() } private startWebsocket(){ try{ this.io.attach(this.wsServer) this.io.on('socket', (socket) => { logger.info("New Websocket connection on port", socket.port) const handleError = (e: any) => { logger.info("Websocket closing", String(e)) socket.close() } socket.on('error', handleError) this.initApis(socket) }) logger.info('Admin websocket listening on *20000') this.wsServer.listen(20000) }catch(e){ logger.error(String(e)) } } private rpcToRpcInfo(owner:string, rpc:socketioRPC):rpcInfo{ switch(rpc.type){ case 'hook': let f = this.hookGenerator(rpc) return {owner: owner, name: rpc.name, args: this.extractArgs(f(null)), info: { type: 'hook', generator: f, unhook: rpc.unhook } } case 'unhook': return {owner: owner, name: rpc.name, args: this.extractArgs(rpc.rpc), info: { type: 'unhook', fn: rpc.rpc } } case 'call': return {owner: owner, name: rpc.name, args: this.extractArgs(rpc.rpc), info: { type: 'call', fn: rpc.rpc } } } } /** * Generates RPC hooks which support a callback. * * Note callbacks *need* to accept a singular argument and they have to be the last parameter! */ hookGenerator = (rpc:socketioRPC) => { const argsArr = this.extractArgs(rpc.rpc).split(',') argsArr.pop() const args = argsArr.join(',') return eval(`(socket) => async (`+args+`) => { const res = await rpc.rpc(`+args+(args.length!==0?',':'')+` (x) => { socket.call(res.uid, x).catch(e => { logger.debug(String(e)) }) }) if(res.uid != null){ this.hookToUids[rpc.name].push(res.uid) } return res }`) } private extractArgs(f:Function):string{ let fn = String(f) let args = fn.substr(0, fn.indexOf(")")) args = args.substr(fn.indexOf("(")+1) return args } } new FrontblockAdmin() process.on( 'SIGINT', function() { logger.info( "Gracefully shutting down from SIGINT (Ctrl-C)" ); // some other closing procedures go here process.exit( ); })