plugins.config
Massimo Melina committed
Apr 14, 2022 at 23:03 UTC
3736220c90e549d8862d1e1dedfa6c9ec7dd293f
8 files changed
+134
-38
README.md
+51
-22
@@ -59,14 +59,11 @@ If your system is not covered, you can try this alternative version:
59
60
# Plug-ins
61
62
-We are slowly introducing a plug-ins system.
62
+If a `plugins` folder is present, HFS monitors it.
63
Each plug-in is a sub-folder of `plugins` folder.
64
-You can quickly disable a plug-in by appending `-disabled` to the plug-in's folder name.
65
-Plug-ins can be hot-swapped, and at some extent can be edited without restarting the server.
64
67
-Each plug-in has access to the same set of features.
68
-Normally you'll have a plug-in that's a theme, and another that's a firewall,
69
-but nothing is preventing a single plug-in from doing both tasks.
65
+Plug-ins can be hot-swapped, and at some extent can be edited without restarting the server.
66
+HFS will ignore all folders with `-disabled` at the end of the name.
67
68
# Developers section
69
@@ -98,10 +95,15 @@ In this latter case, the `DEV=1` you set before will make the server get the fil
95
96
## For plug-in makers
97
101
-You should find some examples within your installation.
98
+A plug-in is a folder with a `plugin.js` file in it.
99
103
-A plug-in must have a `plugin.js` file in its own folder.
104
-This file is javascript module that exports an `init` function like this:
100
+Each plug-in has access to the same set of features.
101
+Normally you'll have a plug-in that's a theme, and another that's a firewall,
102
+but nothing is preventing a single plug-in from doing both tasks.
103
+
104
+You can find some examples distributed as `plugins.zip`.
105
+
106
+`plugin.js` is a javascript module that exports an `init` function like this:
107
```js
108
exports.init = api => ({
109
frontend_css: 'mystyle.css'
@@ -111,12 +113,15 @@ exports.init = api => ({
113
The init function is called when the module is loaded and should return an object with things to customize.
114
In this example we are asking a css file to be loaded in the frontend.
115
The parameter `api` object contains some useful things we'll see later.
116
+You can decide to return things in the `init` function, or directly in the `exports`. Normally you should use `init`
117
+if you need to access the api, otherwise you can go directly with `exports`.
118
+
119
Let's first look at the things you can return:
120
116
-### Things a plugin can return
121
+### Things a plugin can return or export
122
118
-- `description: string` try to explain what this plugin is for
119
-- `version: number` use progressive numbers to distinguish each release
123
+- `description: string` try to explain what this plugin is for. This must go in `exports` and use "double quotes".
124
+- `version: number` use progressive numbers to distinguish each release. This must go in `exports`.
125
- `frontend_css: string | string[]` path to one or more css files that you want the frontend to load. These are to be placed in the `public` folder (refer below).
126
- `frontend_js: string | string[]` path to one or more js files that you want the frontend to load. These are to be placed in the `public` folder (refer below).
127
- `middleware: (Context) => void | true | function` a function that will be used as a middleware: it can interfere with http activity.
@@ -129,6 +134,39 @@ Let's first look at the things you can return:
134
- `unload: function` called when unloading a plugin. This is a good place for example to clearInterval().
135
- `onDirEntry: ({ entry: DirEntry, listPath: string }) => void | false` by providing this callback you can manipulate the record
136
that is sent to the frontend (`entry`), or you can return false to exclude this entry from the results.
137
+- `config: { [key]: FieldDescriptor }` declare a set of admin-configurable values owned by the plugin that will be displayed inside Admin panel for change.
138
+ Each property is identified by its key, and the descriptor is another object with options about the field.
139
+ A simple empty object `{}` is a text field.
140
+
141
+ Eg: you want a `message` text. You add this to your `plugin.js`:
142
+ ```js
143
+ exports.config = { message: {} }
144
+ ```
145
+
146
+ Once the admin has chosen a value for it, the value will be saved in the main config file, under the `plugins_config` property.
147
+ ```yaml
148
+ plugins_config:
149
+ name_of_the_plugin:
150
+ message: Hi there!
151
+ ```
152
+ When necessary your plugin will read its value using `api.getConfig('message')`.
153
+
154
+#### FieldDescriptor
155
+
156
+Currently, these properties are supported:
157
+- `type: 'string' | 'number' | 'boolean' | 'select' | 'multiselect'` . Default is `string`.
158
+- `label: string` what name to display next to the field. Default is based on `key`.
159
+- `helperText: string` extra text printed next to the field.
160
+
161
+Based on `type`, other properties are supported:
162
+- `string`
163
+ - `multiline: boolean`. Default is `false`.
164
+- `number`
165
+ - `min: number`
166
+ - `max: number`
167
+- `select`
168
+ - `options: { [label]: AnyJsonValue }`
169
+- `multiselect` it's like `select` but its result is an array of values.
170
171
### api object
172
@@ -136,16 +174,7 @@ The `api` object you get as parameter of the `init` contains the following:
174
175
- `require: function` use this instead of standard `require` function to access modules already loaded by HFS.
176
139
- - `getConfig(key: string): any` this is the way to go if you need some configuration to do your job.
140
-
141
- Eg: you want a `message` text. This should be put by the user in the main config file, under the `plugins_config` property.
142
- If for example your plugin is called `banner`, in the `config.yaml` you should have
143
- ```yaml
144
- plugins_config:
145
- banner:
146
- message: Hi there!
147
- ```
148
- Now you can use `api.getConfig('message')` to read it.
177
+ - `getConfig(key: string): any` get config's value set up by using `exports.config`.
178
179
- `srcDir: string` this can be useful if you need to import some extra function not available in `api`.
180
```js
admin/src/PluginsPage.ts
+48
-7
@@ -1,14 +1,22 @@
1
-import { createElement as h } from "react"
2
-import { apiCall, useApiList } from './api'
1
+import { createElement as h, FC, isValidElement } from "react"
2
+import { apiCall, useApiComp, useApiList } from './api'
3
import { DataGrid } from '@mui/x-data-grid'
4
import { Alert } from '@mui/material'
5
import { IconBtn } from './misc'
6
-import { PowerSettingsNew } from '@mui/icons-material'
6
+import { PowerSettingsNew, Settings } from '@mui/icons-material'
7
+import { alertDialog, formDialog } from './dialog'
8
+import { BoolField, MultiSelectField, NumberField, SelectField, StringField } from './Form'
9
+
10
+const PLUGINS_CONFIG = 'plugins_config'
11
12
export default function PluginsPage() {
13
const { list, error, initializing } = useApiList('get_plugins')
14
+ const [cfgRes, reloadCfg] = useApiComp('get_config', { only: [PLUGINS_CONFIG] })
15
+ if (isValidElement(cfgRes))
16
+ return cfgRes
17
if (error)
18
return h(Alert, { severity: 'error' }, error)
19
+ const cfg = cfgRes[PLUGINS_CONFIG]
20
return h(DataGrid, {
21
rows: list,
22
loading: initializing,
@@ -34,19 +42,52 @@ export default function PluginsPage() {
42
flex: 1,
43
},
44
{
37
- field: "Actions ",
45
+ field: "actions",
46
width: 80,
47
align: 'center',
48
renderCell({ row }) {
49
+ const { config, id } = row
50
return h('div', {},
51
h(IconBtn, {
52
icon: PowerSettingsNew,
44
- title: (row.started ? "Stop" : "Start") + ' ' + row.id,
45
- onClick: () => apiCall('set_plugin', { id: row.id, disable: !!row.started }),
46
- })
53
+ title: (row.started ? "Stop" : "Start") + ' ' + id,
54
+ onClick: () =>
55
+ apiCall('set_plugin', { id, disable: !!row.started }).then(() =>
56
+ alertDialog(row.started ? "Plugin is stopping" : "Plugin is starting")),
57
+ }),
58
+ h(IconBtn, {
59
+ icon: Settings,
60
+ title: "Configuration",
61
+ disabled: !config,
62
+ onClick() {
63
+ formDialog({
64
+ title: `${id} configuration`,
65
+ fields: makeFields(config),
66
+ values: cfg?.[id],
67
+ }).then(config => {
68
+ if (config)
69
+ apiCall('set_plugin', { id, config }).then(reloadCfg)
70
+ })
71
+ }
72
+ }),
73
)
74
}
75
},
76
]
77
})
78
}
79
+
80
+function makeFields(config: any) {
81
+ return Object.entries(config).map(([k,o]) => {
82
+ const comp = (type2comp as any)[(o as any)?.type] as FC | undefined
83
+ return ({ k, comp, ...(typeof o === 'object' ? o : null) })
84
+ })
85
+}
86
+
87
+const type2comp = {
88
+ string: StringField,
89
+ number: NumberField,
90
+ boolean: BoolField,
91
+ select: SelectField,
92
+ multiselect: MultiSelectField,
93
+}
admin/src/misc.ts
+5
-3
@@ -1,6 +1,6 @@
1
// This file is part of HFS - Copyright 2021-2022, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3
-import { createElement as h } from 'react'
3
+import { createElement as h, FC } from 'react'
4
import { Box, CircularProgress, IconButton, Link, Tooltip } from '@mui/material'
5
import { Link as RouterLink } from 'react-router-dom'
6
import { SxProps } from '@mui/system'
@@ -31,7 +31,7 @@ export function modifiedSx(is: boolean) {
31
32
export function IconBtn({ title, icon, onClick, ...rest }: { title?: string, icon: SvgIconComponent, [rest:string]:any }) {
33
const [loading, setLoading] = useStateMounted(false)
34
- const ret = h(IconButton, {
34
+ let ret: ReturnType<FC> = h(IconButton, {
35
disabled: loading,
36
...rest,
37
onClick() {
@@ -42,7 +42,9 @@ export function IconBtn({ title, icon, onClick, ...rest }: { title?: string, ico
42
}
43
}
44
}, h(icon))
45
- return title ? h(Tooltip, { title, children: ret }) : ret
45
+ if (title)
46
+ ret = h(Tooltip, { title, children: h('span',{},ret) })
47
+ return ret
48
}
49
50
export function iconTooltip(icon: SvgIconComponent, tooltip: string, sx?: SxProps) {
plugins/redirect-root/plugin.js
new
+18
@@ -0,0 +1,18 @@
1
+exports.description = "Redirect users trying to access root directly"
2
+exports.version = 1
3
+
4
+exports.config = {
5
+ url: { label:"URL", helperText: "Where to redirect" }
6
+}
7
+
8
+exports.init = api => ({
9
+ middleware(ctx) {
10
+ if (ctx.path === '/') {
11
+ const url = api.getConfig('url')
12
+ if (url) {
13
+ ctx.redirect(url)
14
+ return true
15
+ }
16
+ }
17
+ }
18
+})
server/src/adminApis.ts
+6
-2
@@ -3,7 +3,7 @@
3
import { ApiError, ApiHandlers } from './apiMiddleware'
4
import { defineConfig, getConfig, getWholeConfig, setConfig } from './config'
5
import { getStatus, getUrls } from './listen'
6
-import { BUILD_TIMESTAMP, FORBIDDEN, HFS_STARTED, IS_WINDOWS, VERSION } from './const'
6
+import { BUILD_TIMESTAMP, CFG_PLUGINS_CONFIG, FORBIDDEN, HFS_STARTED, IS_WINDOWS, VERSION } from './const'
7
import vfsApis from './api.vfs'
8
import accountsApis from './api.accounts'
9
import { Connection, getConnections } from './connections'
@@ -167,13 +167,17 @@ export const adminApis: ApiHandlers = {
167
}
168
},
169
170
- async set_plugin({ id, disable }) {
170
+ async set_plugin({ id, disable, config }) {
171
if (disable !== undefined) {
172
const cfgK = 'disable_plugins'
173
const a = getConfig(cfgK)
174
if (a.includes(id) !== disable)
175
setConfig({ [cfgK]: disable ? [...a, id] : a.filter((x: string) => x !== id) })
176
}
177
+ if (config) {
178
+ const o = { ...getConfig(CFG_PLUGINS_CONFIG), [id]: config }
179
+ setConfig({ [CFG_PLUGINS_CONFIG]: o })
180
+ }
181
return {}
182
},
183
}
server/src/const.ts
+2
@@ -26,6 +26,8 @@ export const FORBIDDEN = 403
26
27
export const IS_WINDOWS = process.platform === 'win32'
28
29
+export const CFG_PLUGINS_CONFIG = 'plugins_config'
30
+
31
// we want this to be the first stuff to be printed, then we print it in this module, that is executed at the beginning
32
if (DEV) console.clear()
33
else console.debug = ()=>{}
server/src/plugins.ts
+3
-3
@@ -4,7 +4,7 @@ import glob from 'fast-glob'
4
import { watchLoad } from './watchLoad'
5
import _ from 'lodash'
6
import { resolve } from 'path'
7
-import { PLUGINS_PUB_URI } from './const'
7
+import { CFG_PLUGINS_CONFIG, PLUGINS_PUB_URI } from './const'
8
import Koa from 'koa'
9
import { debounceAsync, getOrSet, onProcessExit, wantArray, watchDir } from './misc'
10
import { getConfig, subscribeConfig } from './config'
@@ -61,7 +61,7 @@ export function pluginsMiddleware(): Koa.Middleware {
61
}
62
}
63
64
-subscribeConfig({ k:'disable_plugins', defaultValue:['download-counter'] }, () => {
64
+subscribeConfig({ k:'disable_plugins', defaultValue:['download-counter', 'redirect-root'] }, () => {
65
try { watchDir(PATH, debounceAsync(rescan, 1000)) }
66
catch {
67
console.debug('plugins not found')
@@ -168,7 +168,7 @@ async function rescan() {
168
srcDir: __dirname,
169
require,
170
getConfig: (cfgKey: string) =>
171
- getConfig('plugins_config')?.[id]?.[cfgKey]
171
+ getConfig(CFG_PLUGINS_CONFIG)?.[id]?.[cfgKey]
172
})
173
Object.assign(data, res)
174
new Plugin(id, data, unwatch)
todo.md
+1
-1
@@ -1,4 +1,5 @@
1
# To do
2
+- plugin.api.subscribeConfig
3
- log exceptions
4
- watch certificates for change
5
- admin/fs: render virtual folders differently
@@ -14,7 +15,6 @@
15
- admin: in a group, show linked accounts
16
- admin: warn in case of items with same name
17
- command line help --help
17
-- admin/plugins: gui for config
18
- download-counter: expose results on admin
19
- frontend: make a "login" route, and link it in side the 404's suggest-login message
20
- block to support masks and CIDR