admin/options: server code
Massimo Melina committed
Aug 31, 2023 at 19:32 UTC
f0f32ec0f5c3fce6f586b51e4ee80d0c666aaa1c
7 files changed
+102
-67
admin/src/CustomHtmlPage.ts
+44
-23
@@ -1,9 +1,9 @@
1
// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3
-import { createElement as h, Fragment, useEffect, useMemo, useState } from 'react';
4
-import { Field, SelectField } from '@hfs/mui-grid-form'
3
+import { ComponentProps, createElement as h, Fragment, useEffect, useMemo, useState } from 'react';
4
+import { Field, FieldProps, SelectField } from '@hfs/mui-grid-form'
5
import { apiCall, useApiEx } from './api'
6
-import { Alert, Box } from '@mui/material'
6
+import { Alert, Box, FormHelperText, FormLabel } from '@mui/material'
7
import Editor from 'react-simple-code-editor'
8
import { Dict, IconBtn, isCtrlKey, modifiedSx, reloadBtn, wikiLink } from './misc';
9
import { Save } from '@mui/icons-material'
@@ -13,15 +13,15 @@ import md from './md'
13
14
export default function CustomHtmlPage() {
15
const { data, reload } = useApiEx<{ sections: Dict<string> }>('get_custom_html')
16
- const [sec, setSec] = useState('')
16
+ const [section, setSection] = useState('')
17
const [all, setAll] = useState<Dict<string>>({})
18
const [saved, setSaved] = useState({})
19
useEffect(() => data && setSaved(data?.sections), [data])
20
useEffect(() => setAll(saved), [saved])
21
const options = useMemo(() => {
22
const keys = Object.keys(all)
23
- if (!keys.includes(sec))
24
- setSec(keys?.[0] || '')
23
+ if (!keys.includes(section))
24
+ setSection(keys?.[0] || '')
25
return keys.map(x => ({ value: x, label: _.startCase(x) + (all[x]?.trim() ? ' *' : '') }))
26
}, [useDebounce(all, 500)])
27
const anyChange = useMemo(() => !_.isEqualWith(saved, all, (a,b) => !a && !b || undefined),
@@ -32,7 +32,7 @@ export default function CustomHtmlPage() {
32
wikiLink('customization', "More help")
33
),
34
h(Box, { display: 'flex', alignItems: 'center', gap: 1, mb: 1 },
35
- h(SelectField as Field<string>, { label: "Section", value: sec, options, onChange: setSec }),
35
+ h(SelectField as Field<string>, { label: "Section", value: section, options, onChange: setSection }),
36
reloadBtn(reload),
37
h(IconBtn, {
38
icon: Save,
@@ -41,27 +41,19 @@ export default function CustomHtmlPage() {
41
onClick: save,
42
}),
43
),
44
- h(Editor, {
45
- value: all?.[sec] || '',
46
- onValueChange: (v: string) =>
47
- setAll(all => ({ ...all, [sec]: v })),
48
- highlight: escapeHTML,
49
- onKeyDown: ev => {
44
+ h(TextEditor, {
45
+ value: all?.[section] || '',
46
+ style: { background: '#8881' },
47
+ // @ts-ignore TODO
48
+ onChange(v: string) {
49
+ setAll(all => ({ ...all, [section]: v }))
50
+ },
51
+ onKeyDown(ev) {
52
if (isCtrlKey(ev) === 's') {
53
save().then()
54
ev.preventDefault()
55
}
56
},
55
- padding: 10,
56
- tabSize: 4,
57
- insertSpaces: true,
58
- ignoreTabKey: false,
59
- style: {
60
- fontFamily: 'ui-monospace,SFMono-Regular,SF Mono,Consolas,Liberation Mono,Menlo,monospace',
61
- fontSize: '1em',
62
- flex: 1,
63
- background: '#8881',
64
- }
57
}),
58
)
59
@@ -73,4 +65,33 @@ export default function CustomHtmlPage() {
65
function escapeHTML(unsafe: string) {
66
return unsafe.replace(/[\u0000-\u002F\u003A-\u0040\u005B-\u0060\u007B-\u00FF]/g,
67
c => '&#' + ('000' + c.charCodeAt(0)).slice(-4) + ';')
68
+}
69
+
70
+type OP = ComponentProps<typeof Editor>
71
+type Already = 'highlight' | 'padding' | 'tabSize' | 'insertSpaces' | 'ignoreTabKey' | 'onValueChange'
72
+type TextEditorProps = FieldProps<string> & Omit<OP, Already> & Partial<Pick<OP, Already>>
73
+export function TextEditor({ label, helperText, onChange, setApi, style, ...props }: TextEditorProps) {
74
+ return h(Fragment, {},
75
+ label && h(FormLabel, { sx: { ml: 1 } }, label),
76
+ helperText && h(FormHelperText, {}, helperText),
77
+ h(Editor, {
78
+ highlight: escapeHTML,
79
+ padding: 10,
80
+ tabSize: 4,
81
+ insertSpaces: true,
82
+ ignoreTabKey: false,
83
+ style: {
84
+ fontFamily: 'ui-monospace,SFMono-Regular,SF Mono,Consolas,Liberation Mono,Menlo,monospace',
85
+ fontSize: '1em',
86
+ flex: 1,
87
+ background: '#8883',
88
+ borderBottom: '1px solid #bbb',
89
+ ...style,
90
+ },
91
+ onValueChange(v: string) {
92
+ onChange(v, { was: props.value, event: null })
93
+ },
94
+ ...props,
95
+ })
96
+ )
97
}
\ No newline at end of file
admin/src/HomePage.ts
+1
-1
@@ -129,7 +129,7 @@ export default function HomePage() {
129
}
130
131
function renderChangelog(s: string) {
132
- return md(s, { onText, linkTarget: '_blank' })
132
+ return md(s, { onText })
133
134
function onText(s: string) {
135
return replaceStringToReact(s, /(?<=^|\W)#(\d+)\b/g, m => // link issues
admin/src/OptionsPage.ts
+5
-1
@@ -5,7 +5,7 @@ import { createElement as h, Fragment, useEffect, useRef } from 'react';
5
import { apiCall, useApi, useApiEx } from './api'
6
import { state, useSnapState } from './state'
7
import { Info, Refresh, Warning } from '@mui/icons-material'
8
-import { Dict, Flex, iconTooltip, LinkBtn, modifiedSx, wikiLink, with_ } from './misc'
8
+import { Dict, Flex, iconTooltip, LinkBtn, modifiedSx, REPO_URL, wikiLink, with_ } from './misc'
9
import { Form, BoolField, NumberField, SelectField, FieldProps, Field, StringField } from '@hfs/mui-grid-form';
10
import { ArrayField } from './ArrayField'
11
import FileField from './FileField'
@@ -14,6 +14,7 @@ import { proxyWarning } from './HomePage'
14
import _ from 'lodash';
15
import { proxy, subscribe, useSnapshot } from 'valtio'
16
import md from './md'
17
+import { TextEditor } from './CustomHtmlPage'
18
19
let loaded: Dict | undefined
20
let exposedReloadStatus: undefined | (() => void)
@@ -169,6 +170,9 @@ export default function OptionsPage() {
170
fromField: (all:string) => all.split('\n').map(s => s.trim()).filter(Boolean).map(ip => ({ ip })),
171
toField: (all: any) => !Array.isArray(all) ? '' : all.map(x => x?.ip).filter(Boolean).join('\n')
172
},
173
+ { k: 'server_code', comp: TextEditor, sm: 12,
174
+ helperText: md("This code works similarly to a plugin (with some limitations). Please refer to [plugin documentation]("+ REPO_URL + "blob/main/dev-plugins.md).")
175
+ }
176
]
177
})
178
admin/src/md.ts
+1
-1
@@ -9,7 +9,7 @@ const TAGS = {
9
'**': 'b',
10
}
11
type OnText = (s: string) => ReactNode
12
-export default function md(text: string | TemplateStringsArray, { linkTarget='', onText=(x=>x) as OnText }={}) {
12
+export default function md(text: string | TemplateStringsArray, { linkTarget='_blank', onText=(x=>x) as OnText }={}) {
13
if (typeof text !== 'string')
14
text = text[0]
15
return replaceStringToReact(text, /(`|_|\*\*?)(.+)\1|(\n)|\[(.+)\]\((.+)\)|<([^ >/]+)>(.*)<\/\6>|<([^ >/]+) *\/>/g, m =>
src/config.ts
+3
-3
@@ -55,11 +55,11 @@ const CONFIG_CHANGE_EVENT_PREFIX = 'new.'
55
export const currentVersion = new Version(VERSION)
56
const configVersion = defineConfig('version', VERSION, v => new Version(v))
57
58
-type Subscriber<T,R=void> = (v:T, more: { was?: T, version?: Version, defaultValue: T }) => R
58
+type Subscriber<T,R=void> = (v:T, more: { was?: T, version?: Version, defaultValue: T, k: string }) => R
59
export function defineConfig<T, CT=T>(k: string, defaultValue: T, compiler?: Subscriber<T,CT>) {
60
configProps[k] = { defaultValue }
61
type Updater = (currentValue:T) => T
62
- let compiled = compiler?.(defaultValue, { version: currentVersion, defaultValue })
62
+ let compiled = compiler?.(defaultValue, { k, version: currentVersion, defaultValue })
63
const ret = { // consider a Class
64
key() {
65
return k
@@ -69,7 +69,7 @@ export function defineConfig<T, CT=T>(k: string, defaultValue: T, compiler?: Sub
69
},
70
sub(cb: Subscriber<T>) {
71
if (started) // initial event already passed, we'll make the first call
72
- cb(getConfig(k), { was: defaultValue, defaultValue, version: configVersion.compiled() })
72
+ cb(getConfig(k), { k, was: defaultValue, defaultValue, version: configVersion.compiled() })
73
const eventName = CONFIG_CHANGE_EVENT_PREFIX + k
74
return onOff(cfgEvents, {
75
[eventName]() {
src/customHtml.ts
+7
-12
@@ -5,24 +5,18 @@ import Dict = NodeJS.Dict
5
import { writeFile } from 'fs/promises'
6
import { mapPlugins } from './plugins'
7
8
+const FILE = 'custom.html'
9
+
10
export const customHtmlSections: ReadonlyArray<string> = ['beforeHeader', 'afterHeader', 'afterMenuBar', 'afterList',
11
'top', 'bottom', 'afterEntryName', 'beforeLogin']
12
13
export const customHtmlState = proxy({
12
- sections: newCustomHtmlState()
14
+ sections: watchLoadCustomHtml().state
15
})
16
15
-type CustomHtml = ReturnType<typeof newCustomHtmlState>
16
-export function newCustomHtmlState() {
17
- return new Map<string, string>()
18
-}
19
-
20
-const FILE = 'custom.html'
21
-
22
-watchLoadCustomHtml(customHtmlState.sections)
23
-
24
-export function watchLoadCustomHtml(state: CustomHtml, folder='') {
25
- return watchLoad(prefix('', folder, '/') + FILE, data => {
17
+export function watchLoadCustomHtml(folder='') {
18
+ const state = new Map<string, string>()
19
+ const res = watchLoad(prefix('', folder, '/') + FILE, data => {
20
const re = /^\[(\w+)] *$/gm
21
state.clear()
22
if (!data) return
@@ -36,6 +30,7 @@ export function watchLoadCustomHtml(state: CustomHtml, folder='') {
30
name = match?.[1]
31
} while (name)
32
})
33
+ return Object.assign(res, { state })
34
}
35
36
export function getSection(name: string) {
src/plugins.ts
+41
-26
@@ -29,7 +29,7 @@ import { mkdir, readFile } from 'fs/promises'
29
import { existsSync, mkdirSync } from 'fs'
30
import { getConnections } from './connections'
31
import { dirname, join, resolve } from 'path'
32
-import { newCustomHtmlState, watchLoadCustomHtml } from './customHtml'
32
+import { watchLoadCustomHtml } from './customHtml'
33
34
export const PATH = 'plugins'
35
export const DISABLING_POSTFIX = '-disabled'
@@ -115,10 +115,35 @@ export function getPluginConfigFields(id: string) {
115
return plugins[id]?.getData().config
116
}
117
118
+const serverCode = defineConfig('server_code', '', async (script, { k }) => {
119
+ const res: any = {}
120
+ try {
121
+ new Function('exports', script)(res) // parse
122
+ return await initPlugin(res)
123
+ }
124
+ catch (e: any) {
125
+ return console.error(k + ':', e.message || String(e))
126
+ }
127
+})
128
+
129
+async function initPlugin<T>(pl: any, more?: T) {
130
+ return Object.assign(pl, await pl.init?.({
131
+ const: Const, // legacy, deprecated in 0.48
132
+ Const,
133
+ require,
134
+ getConnections,
135
+ events,
136
+ log: console.log,
137
+ getHfsConfig: getConfig,
138
+ customApiCall,
139
+ ...more
140
+ }))
141
+}
142
+
143
export const pluginsMiddleware: Koa.Middleware = async (ctx, next) => {
144
const after: Dict<CallMeAfter> = {}
145
// run middleware plugins
121
- for (const [id,pl] of Object.entries(plugins))
146
+ for (const [id,pl] of Object.entries(plugins).concat([['.', await serverCode.compiled()]]))
147
try {
148
const res = await pl.middleware?.(ctx)
149
if (res === true)
@@ -325,30 +350,24 @@ function watchPlugin(id: string, path: string) {
350
setError(id, '')
351
const alreadyRunning = plugins[id]
352
console.log(alreadyRunning ? "reloading plugin" : "loading plugin", id)
328
- const { init, ...data } = await import(module)
329
- delete data.default
353
+ const pluginData = await import(module)
354
deleteModule(require.resolve(module)) // avoid caching at next import
331
- calculateBadApi(data)
332
- if (data.badApi)
333
- console.log("plugin", id, data.badApi)
355
+ calculateBadApi(pluginData)
356
+ if (pluginData.badApi)
357
+ console.log("plugin", id, pluginData.badApi)
358
359
await alreadyRunning?.unload(true)
360
console.debug("starting plugin", id)
361
const storageDir = resolve(module, '..', STORAGE_FOLDER) + (IS_WINDOWS ? '\\' : '/')
362
await mkdir(storageDir, { recursive: true })
339
- const res = await init?.call(null, {
363
+ await initPlugin(pluginData, {
364
srcDir: __dirname,
365
storageDir,
342
- Const,
343
- const: Const, // legacy, deprecated in 0.48
344
- require,
345
- getConnections,
346
- events,
366
log(...args: any[]) {
367
console.log('plugin', id+':', ...args)
368
},
369
getConfig: (cfgKey: string) =>
351
- pluginsConfig.get()?.[id]?.[cfgKey] ?? data.config?.[cfgKey]?.defaultValue,
370
+ pluginsConfig.get()?.[id]?.[cfgKey] ?? pluginData.config?.[cfgKey]?.defaultValue,
371
setConfig: (cfgKey: string, value: any) =>
372
setPluginConfig(id, { [cfgKey]: value }),
373
subscribeConfig(cfgKey: string, cb: Callback<any>) {
@@ -358,22 +377,14 @@ function watchPlugin(id: string, path: string) {
377
const now = this.getConfig(cfgKey)
378
if (same(now, last)) return
379
try { cb(last = now) }
361
- catch(e){
362
- console.log('plugin', id, String(e))
363
- }
380
+ catch(e){ this.log(String(e)) }
381
})
382
},
366
- getHfsConfig: getConfig,
367
- customApiCall(method: string, params?: any) {
368
- return mapPlugins(pl => pl.getData().customApi?.[method]?.(params))
369
- }
383
})
384
const folder = dirname(module)
372
- Object.assign(data, res, {
373
- customHtml: newCustomHtmlState()
374
- })
375
- const customHtmlWatcher = watchLoadCustomHtml(data.customHtml, folder)
376
- const plugin = plugins[id] = new Plugin(id, folder, data, customHtmlWatcher.unwatch)
385
+ const { state, unwatch } = watchLoadCustomHtml(folder)
386
+ pluginData.customHtml = state
387
+ const plugin = plugins[id] = new Plugin(id, folder, pluginData, unwatch)
388
if (alreadyRunning)
389
events.emit('pluginUpdated', Object.assign(_.pick(plugin, 'started'), getPluginInfo(id)))
390
else {
@@ -397,6 +408,10 @@ function watchPlugin(id: string, path: string) {
408
}
409
}
410
411
+function customApiCall(method: string, params?: any) {
412
+ return mapPlugins(pl => pl.getData().customApi?.[method]?.(params))
413
+}
414
+
415
function getError(id: string) {
416
return getPluginInfo(id).error
417
}