admin/lang

Massimo Melina committed Feb 20, 2023 at 10:58 UTC f1138a962c7c6a4c2da73ac3c3fc34750d4e8707
8 files changed +192 -16
README.md
+10
@@ -42,6 +42,7 @@ You won't find all previous features here (yet), but still we got:
42 - log file
43 - speed throttler
44 - admin web interface
45 +- multi-language front-end
46 - virtual hosting (plug-in)
47 - anti-brute-force (plug-in)
48
@@ -81,6 +82,15 @@ If you want to run HFS as a service
82 - run `npm -g update hfs`
83 - run the service installation again
84
85 +## Internationalization
86 +
87 +It is possible to show the Front-end in other languages.
88 +In the Languages section of the Admin-panel you'll be able to install lang files.
89 +You can find some of these files at https://github.com/rejetto/hfs/tree/main/langs
90 +To download a file: open it, right-click on the "Raw" button, Save.
91 +
92 +Files must be named `hfs-lang-CODE.json` (lowercase), where `CODE` is the ISO code for your language (e.g. pt-br for Brazilian).
93 +
94 ## Plug-ins
95
96 To install a plugin you just copy its folder inside `plugins` folder.
admin/src/LangPage.ts new
+76
@@ -0,0 +1,76 @@
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 } from 'react';
4 +import { apiCall, useApiList } from './api'
5 +import { DataGrid } from '@mui/x-data-grid'
6 +import { Alert, Box, Button } from '@mui/material'
7 +import { Delete, Upload } from '@mui/icons-material'
8 +import { IconBtn, readFile, selectFiles } from './misc'
9 +import _ from 'lodash'
10 +import { toast } from './dialog'
11 +
12 +export default function LangPage() {
13 + const { list, error, connecting, reload } = useApiList('get_langs', undefined, { addId: true })
14 + if (error)
15 + return error
16 + return h(Fragment, {},
17 + h(Alert, { severity: 'info' }, "Translation is limited to Front-end, it doesn't apply to Admin-panel"),
18 + h(Alert, { severity: 'info' }, "The front-end will automatically apply translation based on the language of the browser. You can force loading of a specific language by appending ?lang=CODE to the URL."),
19 + h(Box, { mb: 1 },
20 + h(Button, { variant: 'contained', startIcon: h(Upload), onClick: add }, "Add"),
21 + ),
22 + h(DataGrid, {
23 + loading: connecting,
24 + rows: list as any,
25 + hideFooter: true,
26 + sx: { maxWidth: '40em' },
27 + columns: [
28 + {
29 + field: 'code',
30 + width: 80,
31 + },
32 + {
33 + field: 'version',
34 + width: 80,
35 + },
36 + {
37 + field: 'author',
38 + flex: 1,
39 + },
40 + {
41 + field: "actions",
42 + width: 80,
43 + align: 'center',
44 + hideSortIcons: true,
45 + disableColumnMenu: true,
46 + renderCell({ row }) {
47 + return h('div', {},
48 + h(IconBtn, {
49 + icon: Delete,
50 + title: "Delete",
51 + confirm: "Delete?",
52 + async onClick() {
53 + await apiCall('del_lang', _.pick(row, 'code'))
54 + reload()
55 + toast("Deleted")
56 + }
57 + }),
58 + )
59 + }
60 + }
61 + ]
62 + })
63 + )
64 +
65 + function add() {
66 + selectFiles(async list => {
67 + if (!list) return
68 + const langs: any = {}
69 + for (const f of list)
70 + langs[f.name] = await readFile(f)
71 + await apiCall('add_langs', { langs })
72 + reload()
73 + toast("Loaded")
74 + })
75 + }
76 +}
admin/src/MainMenu.ts
+3
@@ -11,6 +11,7 @@ import {
11 Monitor,
12 Public,
13 Settings,
14 + Translate,
15 SvgIconComponent
16 } from '@mui/icons-material'
17 import _ from 'lodash'
@@ -21,6 +22,7 @@ import VfsPage from './VfsPage';
22 import AccountsPage from './AccountsPage';
23 import HomePage from './HomePage'
24 import LogoutPage from './LogoutPage';
25 +import LangPage from './LangPage'
26 import LogsPage from './LogsPage';
27 import PluginsPage from './PluginsPage';
28 import { useApi } from './api'
@@ -40,6 +42,7 @@ export const mainMenu: MenuEntry[] = [
42 { path: 'configuration', icon: Settings, comp: ConfigPage },
43 { path: 'monitoring', icon: Monitor, comp: MonitorPage },
44 { path: 'logs', icon: History, comp: LogsPage },
45 + { path: 'language', icon: Translate, comp: LangPage },
46 { path: 'plugins', icon: Extension, comp: PluginsPage },
47 { path: 'logout', icon: Logout, comp: LogoutPage }
48 ]
admin/src/api.ts
+8 -3
@@ -1,6 +1,6 @@
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, useCallback, useEffect, useMemo, useRef } from 'react'
3 +import { createElement as h, useCallback, useEffect, useMemo, useRef, useState } from 'react'
4 import { Dict, err2msg, Falsy, getCookie, IconBtn, spinner, useStateMounted, wantArray } from './misc'
5 import { Alert } from '@mui/material'
6 import _ from 'lodash'
@@ -165,6 +165,7 @@ export function useApiList<T=any>(cmd:string|Falsy, params: Dict={}, { addId=fal
165 const [connecting, setConnecting] = useStateMounted(true)
166 const [loading, setLoading] = useStateMounted(false)
167 const [initializing, setInitializing] = useStateMounted(true)
168 + const [reloader, setReloader] = useState(0)
169 const idRef = useRef(0)
170 useEffect(() => {
171 if (!cmd) return
@@ -257,8 +258,12 @@ export function useApiList<T=any>(cmd:string|Falsy, params: Dict={}, { addId=fal
258 setLoading(false)
259 apply.flush()
260 }
260 - }, [cmd, JSON.stringify(params)]) //eslint-disable-line
261 - return { list, props, loading, error, initializing, connecting, setList, updateList }
261 + }, [reloader, cmd, JSON.stringify(params)]) //eslint-disable-line
262 + return { list, props, loading, error, initializing, connecting, setList, updateList, reload }
263 +
264 + function reload() {
265 + setReloader(x => x + 1)
266 + }
267
268 function updateList(cb: (toModify: Draft<typeof list>) => void) {
269 setList(produce(list, x => {
frontend/src/upload.ts
+5 -13
@@ -2,7 +2,7 @@
2
3 import { createElement as h, useMemo, useState } from 'react'
4 import { Flex, FlexV } from './components'
5 -import { closeDialog, DialogCloser, formatBytes, hIcon, newDialog, prefix } from './misc'
5 +import { closeDialog, DialogCloser, formatBytes, hIcon, newDialog, prefix, selectFiles } from './misc'
6 import _ from 'lodash'
7 import { proxy, ref, subscribe, useSnapshot } from 'valtio'
8 import { alertDialog, confirmDialog, promptDialog } from './dialog'
@@ -86,8 +86,8 @@ export function showUpload() {
86 h(FlexV, { position: 'sticky', top: -4, background: 'var(--bg)' },
87 !can_upload ? t('no_upload_here', "No upload permission for the current folder")
88 : h(Flex, { justifyContent: 'center', flexWrap: 'wrap', },
89 - h('button', { onClick: () => selectFiles() }, t`Pick files`),
90 - h('button', { onClick: () => selectFiles(true) }, t`Pick folder`),
89 + h('button', { onClick: () => pickFiles() }, t`Pick files`),
90 + h('button', { onClick: () => pickFiles(true) }, t`Pick folder`),
91 files.length > 0 && h('button', {
92 onClick() {
93 enqueue(files)
@@ -134,16 +134,8 @@ export function showUpload() {
134 )
135 )
136
137 - function selectFiles(folder=false) {
138 - const el = Object.assign(document.createElement('input'), {
139 - type: 'file',
140 - name: 'file',
141 - multiple: true,
142 - webkitdirectory: folder,
143 - })
144 - el.addEventListener('change', () =>
145 - setFiles([ ...files, ...el.files ||[] ] ))
146 - el.click()
137 + function pickFiles(folder=false) {
138 + selectFiles(list => setFiles([ ...files, ...list ||[] ] ), { folder })
139 }
140 }
141
shared/index.ts
+24
@@ -99,4 +99,28 @@ export function findFirst<I=any, O=any>(a: I[], cb:(v:I)=>O): any {
99 if (ret !== undefined)
100 return ret
101 }
102 +}
103 +
104 +export function selectFiles(cb: (list: FileList | null)=>void, { multiple=true, folder=false }={}) {
105 + const el = Object.assign(document.createElement('input'), {
106 + type: 'file',
107 + name: 'file',
108 + multiple: multiple,
109 + webkitdirectory: folder,
110 + })
111 + el.addEventListener('change', () =>
112 + cb(el.files))
113 + el.click()
114 +}
115 +
116 +export function readFile(f: File) {
117 + return new Promise((resolve, reject) => {
118 + const reader = new FileReader()
119 + reader.addEventListener('load', (event) => {
120 + if (!event.target)
121 + return reject()
122 + resolve(event.target.result)
123 + })
124 + reader.readAsText(f)
125 + })
126 }
\ No newline at end of file
src/adminApis.ts
+2
@@ -19,6 +19,7 @@ import vfsApis from './api.vfs'
19 import accountsApis from './api.accounts'
20 import pluginsApis from './api.plugins'
21 import monitorApis from './api.monitor'
22 +import langApis from './api.lang'
23 import { getConnections } from './connections'
24 import { debounceAsync, isLocalHost, onOff, wait } from './misc'
25 import _ from 'lodash'
@@ -39,6 +40,7 @@ export const adminApis: ApiHandlers = {
40 ...accountsApis,
41 ...pluginsApis,
42 ...monitorApis,
43 + ...langApis,
44
45 async set_config({ values: v }) {
46 if (v) {
src/api.lang.ts new
+64
@@ -0,0 +1,64 @@
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 { ApiError, ApiHandlers, SendListReadable } from './apiMiddleware'
4 +import _ from 'lodash'
5 +import glob from 'fast-glob'
6 +import { readFile, rm, writeFile } from 'fs/promises'
7 +import { dirTraversal, isValidFileName } from './util-files'
8 +import { HTTP_BAD_REQUEST, HTTP_SERVER_ERROR } from './const'
9 +
10 +const PREFIX = 'hfs-lang-'
11 +const SUFFIX = '.json'
12 +
13 +const apis: ApiHandlers = {
14 +
15 + get_langs() {
16 + return new SendListReadable({
17 + doAtStart: async list => {
18 + for await (let name of glob.stream(code2file('*'))) {
19 + name = String(name)
20 + const code = name.slice(PREFIX.length, -SUFFIX.length)
21 + try {
22 + const data = JSON.parse(await readFile(name, 'utf8'))
23 + list.add({ code, ..._.omit(data, 'translate') })
24 + }
25 + catch {}
26 + }
27 + list.close()
28 + }
29 + })
30 + },
31 +
32 + async del_lang({ code }) {
33 + validateCode(code)
34 + try {
35 + await rm(code2file(code))
36 + return {}
37 + }
38 + catch (e: any) {
39 + return new ApiError(e.code || HTTP_SERVER_ERROR, e)
40 + }
41 + },
42 +
43 + async add_langs({ langs }) {
44 + for (let [code, content] of Object.entries(langs)) {
45 + if (code.endsWith(SUFFIX)) // filename, actually
46 + code = code.slice(PREFIX.length, -SUFFIX.length)
47 + validateCode(code)
48 + await writeFile(code2file(code), String(content), 'utf8')
49 + }
50 + return {}
51 + }
52 +
53 +}
54 +
55 +export default apis
56 +
57 +function code2file(code: string) {
58 + return PREFIX + code.toLowerCase() + SUFFIX
59 +}
60 +
61 +function validateCode(code: string) {
62 + if (!isValidFileName(code) || dirTraversal(code))
63 + throw new ApiError(HTTP_BAD_REQUEST, 'bad code')
64 +}
\ No newline at end of file