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