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 }