This commit is contained in:
peter
2019-08-03 13:50:46 +02:00
parent 4edf6c1a7f
commit d9ccd4d9d3
76 changed files with 22035 additions and 1902 deletions
+262
View File
@@ -0,0 +1,262 @@
'use strict'
import * as Logger from 'log4js'
import { Plugin, socketioRPC, ConfigLoader } from 'frontblock-generic/Plugin';
import { ErrorResponse, SuccessResponse } from 'frontblock-generic/Types';
import { PluginManagerPlugin } from './PluginManager/Plugin'
import express = require('express');
import http = require('http');
import bsock = require('bsock');
import { type } from 'os';
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<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 pm: PluginManagerPlugin
constructor(){
super("FrontblockAdmin")
this.pm = new PluginManagerPlugin()
this.initialize()
}
getDefaultConfig(): AdminConf {
return {httpPort: 8080}
}
private async initialize(){
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 = this.pm.getLoadedPlugins()
for(const name in loadedPlugins){
loadedPlugins[name].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':
socket.hook(api.name, api.info.fn)
break
case 'hook':
const hook = api.info.generator(socket)
hook.bind(this)
socket.hook(api.name, hook)
break
case 'unhook':
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'))
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) => {
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
}
}
(async() => {
new FrontblockAdmin()
})()
+76
View File
@@ -0,0 +1,76 @@
import { parseSubResponse, parseResponse } from "frontblock-generic/Types";
var bsock = require('bsock')
/**
* Dynamic library to communicate with FrontblockService remotely
*
* This will be automatically injected into the webpages served by FrontblockService
* Will ask it's service for available RPCs and parse them into methods of this object
* for convenient access.
*/
export class FrontblockConfigLib{
private socket
constructor(){
this.socket = bsock.connect(20000, 'localhost', false/*tls*/)
this.init()
}
// need this-context for eval-magic below
// DO NOT REMOVE. They're not really unused
private parseSubResponse = parseSubResponse
private parseResponse = parseResponse
private async init(){
const info = await this.info()
for (const i of info) {
let f: any
switch (i.info.type) {
case 'call':
f = this.callGenerator(i.name, i.args)
break
case 'hook':
f = this.hookGenerator(i.name, i.args)
break
case 'unhook':
f = this.unhookGenerator(i.name, i.args)
break
}
if(this[i.owner] == null)
this[i.owner] = {}
this[i.owner][i.name] = f
this[i.owner][i.name].bind(this)
}
}
async info(){
return await this.socket.call('info')
}
private callGenerator(fnName, fnArgs): Function{
return eval( '( () => async ('+fnArgs+') => { return await this.socket.call("'+fnName+'", '+fnArgs+')} )()' )
}
private hookGenerator(fnName, fnArgs): Function{
return eval( `( () => async (`+fnArgs+(fnArgs.length!==0?",":"")+` callback) => {
const r = await this.socket.call("`+fnName+`", `+fnArgs+`)
const res = await this.parseSubResponse(r);
if(res.uid != null){
this.socket.hook(res.uid, callback)
}
return res
} )()` )
}
private unhookGenerator(fnName, fnArgs): Function{
return eval( `( () => async (`+fnArgs+`) => {
const r = await this.socket.call("`+fnName+`", `+fnArgs+`)
const res = await this.parseResponse(r)
if(res.uid != null)
this.socket.unhook(res.uid)
return res
} )()` )
}
}
window['fb'] = new FrontblockConfigLib()
+88
View File
@@ -0,0 +1,88 @@
import { default as PluginManager } from "./PluginManager";
import { Plugin } from "frontblock-generic/Plugin";
import * as Logger from 'log4js';
Logger.configure({
appenders:
{
"PluginManager/Plugin": { type: 'stdout' },
//app: { type: 'file', filename: 'application.log' }
},
categories:
{
default: { appenders: [ 'PluginManager/Plugin' ], level: 'debug' }
}
})
const logger = Logger.getLogger("PluginManager/Plugin")
export class PluginManagerPlugin extends PluginManager implements Plugin{
name: string;
constructor(){
super()
this.loadedPlugins[this.name] = this
}
exportRPCs(): import("frontblock-generic/Plugin").socketioRPC[] {
return [
{
name: "getAvailablePluginNames",
visibility: "private",
rpc: async() => { return await this.getAvailablePluginNames() },
type: 'call'
},
{
name: "getInstalledPlugins",
visibility: "private",
rpc: async() => { return await this.getInstalledPlugins() },
type: 'call'
},
{
name: "getLoadedPlugins",
visibility: "private",
rpc: async () => { return this.getLoadedPlugins() },
type: 'call'
},
{
name: "installPlugin",
visibility: "private",
rpc: async(pluginName:string) => { return await this.installPlugin(pluginName) },
type: 'call'
},
{
name: "uninstallPlugin",
visibility: "private",
rpc: async(pluginName:string) => { return await this.uninstallPlugin(pluginName) },
type: 'call'
},
{
name: "loadPlugin",
visibility: "private",
rpc: async(pluginName:string) => { return await this.loadPlugin(pluginName) },
type: 'call'
},
{
name: "unloadPlugin",
visibility: "private",
rpc: async(pluginName:string) => { return this.unloadPlugin(pluginName) },
type: 'call'
},
{
name: "updateConfig",
visibility: "private",
rpc: async(conf) => { return this.updateConfig(conf) },
type: 'call'
}
]
}
async start(): Promise<void> {
await this.installPlugin("admin") //self-update
}
stop(): void {
throw new Error("Method not implemented.");
}
}
+177
View File
@@ -0,0 +1,177 @@
'use strict'
import * as Logger from 'log4js';
import fetch = require('node-fetch');
import fs = require('fs');
import path = require('path')
import unzip = require('unzip')
import { Plugin, ConfigLoader } from 'frontblock-generic/Plugin';
import { SemVer } from "semver";
Logger.configure({
appenders:
{
"PluginManager": { type: 'stdout' },
//app: { type: 'file', filename: 'application.log' }
},
categories:
{
default: { appenders: [ 'PluginManager' ], level: 'debug' }
}
})
const logger = Logger.getLogger("PluginManager")
type PluginVersion = {
installed?: SemVer,
cached?: SemVer,
loaded?: SemVer
}
export type PluginVersioning = {
[pluginName in string]?: PluginVersion | string
}
export type PluginMap = {[pluginName:string]: Plugin}
export type PluginManagerConfig = {
resourceLocation: string,
installDir:string
}
export default class PluginManager extends ConfigLoader<PluginManagerConfig&PluginVersioning>{
private cacheDir: string
protected loadedPlugins: PluginMap = {}
constructor(){
super("PluginManager")
this.cacheDir = path.join(this.conf.installDir, '.cache')
this.initialize()
this.loadPlugin("../../../../../../apiclient")
}
getDefaultConfig(): PluginManagerConfig&PluginVersioning{
return {resourceLocation: "https://gitea.frontblock.me/api/v1/repos/fb-vendor/", installDir: "./plugins"}
}
private initialize() {
logger.info('Initializing manager')
try {
fs.access(this.cacheDir, async err => {
if (err && err.code === 'ENOENT') {
await fs.promises.mkdir(this.cacheDir, {recursive: true})
logger.info('Manager installed to ' + this.conf.installDir)
} else {
logger.info('Manager already installed at ' + this.conf.installDir)
}
})
} catch (error) {
logger.error(error)
}
}
public async getAvailablePluginNames(): Promise<string[]> {
//TODO query api
return [
'apiclient',
'paymentmanager',
'wallet',
'htmlsupplier'
]
}
public async getInstalledPlugins(): Promise<string[]> {
try {
const installed = await fs.promises.readdir(this.conf.installDir)
return installed.filter( entry => entry[0] != '.')
} catch (error) {
logger.error(error)
throw error
}
}
public getLoadedPlugins(): PluginMap {
return this.loadedPlugins
}
private async downloadFile(url: URL, dest: string) {
try {
fs.access(path.dirname(dest), err => {
if (err && err.code === 'ENOENT') {
fs.mkdirSync(path.dirname(dest), {recursive: true})
}
})
const file = fs.createWriteStream(dest)
file.on('error', err => {throw new Error(err)})
const response = await fetch(url)
if (!response.ok) throw new Error(response.statusText)
response.body.pipe(file)
} catch (error) {
logger.error(error)
await fs.promises.unlink(dest)
}
}
private async downloadPlugin(pluginName: string) {
try {
logger.info("Downloading Plugin")
const response = await fetch(this.conf.resourceLocation + pluginName + '/releases')
if (!response.ok) throw new Error(response.statusText)
const releases = await response.json()
if (!releases || !releases.length) throw new Error('Empty response')
const latest = releases[0]
const version = latest.tag_name
const archiveURL = latest.assets.filter(asset => asset.name === pluginName + '.zip')[0].browser_download_url
const checksumURL = latest.assets.filter(asset => asset.name === 'md5sum.txt')[0].browser_download_url
if (!version || ! archiveURL || !checksumURL) throw new Error('Malformed response')
await this.downloadFile(archiveURL, path.join(this.cacheDir, pluginName, version, 'plugin.zip'))
await this.downloadFile(checksumURL, path.join(this.cacheDir, pluginName, version, 'md5sum.txt'))
} catch (error) {
logger.error(error)
}
}
public async installPlugin(pluginName: string) {
try {
await this.downloadPlugin(pluginName)
//TODO do something
} catch (error) {
logger.error(error)
}
}
public async uninstallPlugin(pluginName: string) {
//TODO check for exists
logger.info("Deleting plugin", pluginName)
await fs.promises.rmdir(path.join(this.conf.installDir,pluginName))
}
public async loadPlugin(pluginName:string) {
const pth = path.join(this.conf.installDir, pluginName, "Plugin")
logger.info("Loading plugin from fs", pth)
const clazz = await import(pth)
let obj:Plugin = new clazz.default()
await obj.start()
this.loadedPlugins[pluginName] = obj
}
public unloadPlugin(pluginName:string):void {
if(this.loadedPlugins[pluginName] == null){
logger.warn("Cannt unload plugin: Unknown:", pluginName)
return
}
logger.info("Unloading plugin ", pluginName)
this.loadedPlugins[pluginName].stop()
delete this.loadedPlugins[pluginName]
}
}