Files
fb-admin/src/backend/FrontblockAdmin.ts
T

318 lines
9.5 KiB
TypeScript

'use strict'
import * as Logger from 'log4js'
import { socketioRPC, ConfigLoader, Plugin } from 'frontblock-generic/Plugin';
import { ErrorResponse, SuccessResponse, SubscriptionResponse } 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, RepoFolderStatus, } from "./GitUpdater";
import * as path from "path"
import { UpdateManager } from './UpdateManger';
const kfs = require("key-file-storage").default('kfs')
Logger.configure({
appenders:
{
"admin": { type: 'stdout' },
//app: { type: 'file', filename: 'application.log' }
},
categories:
{
default: { appenders: [ 'admin' ], level: 'debug' }
}
})
const logger = Logger.getLogger("admin")
type hookRPC =
{
type: 'hook',
generator: (socket) => Function,
unhook:(uid:string)=>Promise<ErrorResponse|SuccessResponse>
}
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<AdminConf>{
private hookToUids:{[hookName:string]:string[]} = {}
private express
private httpServer
private io = bsock.createServer()
private wsServer = http.createServer()
private updateManager:UpdateManager = new UpdateManager()
constructor(){
super("FrontblockAdmin")
this.initialize()
}
getDefaultConfig(): AdminConf {
return {httpPort: 8080}
}
private async initialize(){
await this.updateManager.updateAdmin()
await this.updateManager.updateDashboard()
await this.updateManager.updateFrontblockLib()
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 }
}
}
]
//translate RPCs to socket-bound function metadata
const loadedPlugins:Plugin[] = [this.updateManager, ...this.updateManager.getLoadedPlugins()]
loadedPlugins.forEach(plugin => plugin.exportRPCs().forEach(rpc => {
const info = this.rpcToRpcInfo(plugin.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 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", async (request, response) => {
const pth = path.resolve("plugins/"+request.params.id, "FrontendPlugin.js");
logger.info("Loading frontend plugin from fs", pth)
const file = await fs.readFile(pth)
const frontend = file.toString()
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"] = <AdminConf>{ 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): (socket)=>(...args:any[])=>Promise<SubscriptionResponse> => {
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( );
})