plug-ins system

Massimo Melina committed Jan 3, 2022 at 21:00 UTC bf7d1eb83ef942cab3ca07952b4a526ccc053c93
10 files changed +90 -4
README.md
+6
@@ -85,3 +85,9 @@ For this reason HFS needs that your accounts file is writable.
85 You'll find the output in `dist` folder.
86
87 Now to run it you should `cd dist` and `node .`
88 +
89 +# Plugins
90 +
91 +We are slowly introducing a plug-ins system.
92 +Each plug-in is a `plugin.yaml` file sub-folder of `plugins` folder.
93 +You can quickly disable a plug-in by appending `-disabled` to the plug-in folder.
frontend/src/App.tsx
+4
@@ -2,14 +2,18 @@ import { BrowserRouter, Route, Routes } from "react-router-dom"
2 import { createElement as h, Fragment } from 'react'
3 import { BrowseFiles } from "./BrowseFiles";
4 import { Dialogs } from './dialog'
5 +import { useApi } from "./api";
6
7 function App() {
8 + const extras = useApi('extras_to_load')
9 return h(Fragment, {},
10 h(BrowserRouter, {},
11 h(Routes, {},
12 h(Route, { path:'*', element:h(BrowseFiles) })
13 )
14 ),
15 + extras?.css?.map((href:string) =>
16 + h('link', { key:href, rel:"stylesheet", type:"text/css", href })),
17 h(Dialogs)
18 )
19 }
plugins/example-disabled/plugin.yaml new
+4
@@ -0,0 +1,4 @@
1 +ver: 1
2 +frontend:
3 + load:
4 + css: style.css
plugins/example-disabled/public/star.png
Binary files /dev/null and b/plugins/example-disabled/public/star.png differ
plugins/example-disabled/public/style.css new
+1
@@ -0,0 +1 @@
1 +header { background:#fff url(star.png) top right no-repeat; }
src/const.ts
+1
@@ -8,6 +8,7 @@ if (DEV)
8 const SPECIAL_URI = '/~/'
9 export const FRONTEND_URI = SPECIAL_URI + 'front/'
10 export const API_URI = SPECIAL_URI + 'api/'
11 +export const PLUGINS_PUB_URI = SPECIAL_URI + 'plugins/'
12
13 export const argv = minimist(process.argv.slice(2))
14
src/frontEndApis.ts
+11 -1
@@ -5,6 +5,9 @@ import { basename } from 'path'
5 import { getCurrentUsername, updateAccount, verifyLogin } from './perm'
6 import { stat } from 'fs/promises'
7 import { ApiHandlers } from './apis'
8 +import { plugins } from './plugins'
9 +import { wantArray } from './misc'
10 +import { PLUGINS_PUB_URI } from './const'
11
12 export const frontEndApis: ApiHandlers = {
13
@@ -86,7 +89,14 @@ export const frontEndApis: ApiHandlers = {
89 account.password = newPassword
90 })
91 return true
89 - }
92 + },
93 +
94 + async extras_to_load() {
95 + const css = []
96 + for (const k in plugins)
97 + css.push(...wantArray(plugins[k].data.frontend?.load?.css).map(f => PLUGINS_PUB_URI + k + '/' + f))
98 + return { css }
99 + },
100 }
101
102 interface DirEntry { n:string, s?:number, m?:Date, c?:Date }
src/index.ts
+13 -1
@@ -3,7 +3,7 @@ import mount from 'koa-mount'
3 import bodyParser from 'koa-bodyparser'
4 import { apiMiddleware } from './apis'
5 import { serveFrontend } from './serveFrontend'
6 -import { API_URI, DEV, FRONTEND_URI } from './const'
6 +import { API_URI, DEV, FRONTEND_URI, PLUGINS_PUB_URI } from './const'
7 import { serveFile } from './serveFile'
8 import { vfs } from './vfs'
9 import { isDirectory } from './misc'
@@ -17,6 +17,10 @@ import session from 'koa-session'
17 import { zipStreamFromFolder } from './zip'
18 import { frontEndApis } from './frontEndApis'
19 import { log } from './log'
20 +import './plugins'
21 +import { createReadStream } from 'fs'
22 +import { PATH as PLUGINS_PATH } from './plugins'
23 +import mime from 'mime-types'
24
25 const BUILD_TIMESTAMP = ""
26
@@ -47,10 +51,18 @@ app.use(mount(API_URI, new Koa()
51 const serveFrontendPrefixed = mount(FRONTEND_URI.slice(0,-1), serveFrontend)
52 app.use(async (ctx, next) => {
53 const { path } = ctx
54 + if (path.includes('..'))
55 + ctx.throw(500)
56 if (ctx.body)
57 return await next()
58 if (path.startsWith(FRONTEND_URI))
59 return await serveFrontendPrefixed(ctx,next)
60 + if (path.startsWith(PLUGINS_PUB_URI)) { // expose public plugins' files
61 + const a = path.substring(PLUGINS_PUB_URI.length).split('/')
62 + a.splice(1,0,'public')
63 + ctx.type = mime.lookup(path) || ''
64 + return ctx.body = createReadStream(PLUGINS_PATH + '/' + a.join('/'))
65 + }
66 const decoded = decodeURI(path)
67 const node = await vfs.urlToNode(decoded, ctx)
68 if (!node)
src/misc.ts
+4 -2
@@ -2,6 +2,7 @@ import fs from 'fs/promises'
2 import { objSameKeys } from './obj'
3 import { FSWatcher, watch } from 'fs'
4 import yaml from 'yaml'
5 +import _ from 'lodash'
6
7 export function enforceFinal(sub:string, s:string) {
8 return s.endsWith(sub) ? s : s+sub
@@ -50,10 +51,11 @@ export function wantArray(x:any) {
51 export function watchLoad(path:string, parser:(data:any)=>void|Promise<void>) {
52 let doing = false
53 let watcher: FSWatcher
54 + const debounced = _.debounce(load, 100)
55 const timer = setInterval(()=>{
56 try {
55 - watcher = watch(path, load)
56 - load().then()
57 + watcher = watch(path, debounced)
58 + debounced()
59 clearInterval(timer)
60 }
61 catch(e){
src/plugins.ts new
+46
@@ -0,0 +1,46 @@
1 +import { watch } from 'fs'
2 +import glob from 'fast-glob'
3 +import { watchLoad } from './misc'
4 +import _ from 'lodash'
5 +
6 +export const PATH = 'plugins'
7 +
8 +try {
9 + const debounced = _.debounce(rescan, 1000)
10 + watch(PATH, debounced)
11 + debounced()
12 +}
13 +catch(e){
14 + console.debug('plugins not found')
15 +}
16 +
17 +interface Plugin {
18 + data: any
19 + unwatch: ()=>void
20 +}
21 +export const plugins: Record<string, Plugin> = {}
22 +
23 +async function rescan() {
24 + console.debug('scanning plugins')
25 + const found = []
26 + for (const f of await glob(PATH+'/*/plugin.yaml')) {
27 + const k = f.split('/').slice(-2)[0]
28 + if (k.endsWith('-disabled')) continue
29 + found.push(k)
30 + if (plugins[k]) // already loaded
31 + continue
32 + const unwatch = watchLoad(f, data => {
33 + console.log('loading plugin', k)
34 + plugins[k] = { data, unwatch }
35 + })
36 + }
37 + for (const k in plugins)
38 + if (!found.includes(k))
39 + unloadPlugin(k)
40 +}
41 +
42 +function unloadPlugin(k: string) {
43 + console.log('unloading plugin', k)
44 + plugins[k]?.unwatch()
45 + delete plugins[k]
46 +}