admin/fs: roots button (replacing vhosting plugin)
Massimo Melina committed
Dec 7, 2023 at 22:39 UTC
78c96b4739c74ea38fe66fdfe40b2023692face5
21 files changed
+263
-201
admin/src/ConfigForm.ts
new
+52
@@ -0,0 +1,52 @@
1
+import { Form, FormProps } from '@hfs/mui-grid-form'
2
+import { apiCall, useApiEx } from './api'
3
+import { createElement as h, useEffect, useState } from 'react'
4
+import _ from 'lodash'
5
+import { IconBtn, modifiedSx } from './mui'
6
+import { RestartAlt } from '@mui/icons-material'
7
+import { Callback } from '../../src/cross'
8
+
9
+type FormRest<T> = Omit<FormProps<T>, 'values' | 'set' | 'save'> & Partial<Pick<FormProps<T>, 'save'>>
10
+export function ConfigForm<T=any>({ keys, form, saveOnChange, onSave, ...rest }: Partial<FormRest<T>> & {
11
+ keys: (keyof T)[],
12
+ form: FormRest<T> | ((values: T) => FormRest<T>),
13
+ onSave?: Callback,
14
+ saveOnChange?: boolean
15
+}) {
16
+ const config = useApiEx('get_config', { only: keys })
17
+ const [values, setValues] = useState<any>(config.data)
18
+ useEffect(() => setValues((v: any) => config.data || v), [config.data])
19
+ const modified = values && !_.isEqual(values, config.data)
20
+ useEffect(() => {
21
+ if (modified && saveOnChange) save()
22
+ }, [modified])
23
+ if (!values)
24
+ return config.element
25
+ const formProps = _.isFunction(form) ? form(values) : form
26
+ return h(Form, {
27
+ values,
28
+ set(v, k) {
29
+ setValues((was: any) => ({ ...was, [k]: v }))
30
+ },
31
+ save: saveOnChange ? false : {
32
+ onClick: save,
33
+ sx: modifiedSx(modified),
34
+ },
35
+ ...Array.isArray(formProps) ? { fields: formProps } : formProps,
36
+ ...rest,
37
+ barSx: { gap: 1, ...rest.barSx },
38
+ addToBar: [
39
+ h(IconBtn, {
40
+ icon: RestartAlt,
41
+ disabled: !modified,
42
+ title: "Reset",
43
+ onClick(){ setValues(config.data) }
44
+ }),
45
+ ...rest.addToBar||[],
46
+ ],
47
+ })
48
+
49
+ function save() {
50
+ return apiCall('set_config', { values }).then(onSave).then(config.reload)
51
+ }
52
+}
\ No newline at end of file
admin/src/FileForm.ts
+12
-34
@@ -3,40 +3,12 @@
3
import { state, useSnapState } from './state'
4
import { createElement as h, ReactElement, ReactNode, useEffect, useMemo, useState } from 'react'
5
import { Alert, Box, Collapse, FormHelperText, Link, MenuItem, MenuList } from '@mui/material'
6
-import {
7
- BoolField,
8
- DisplayField,
9
- Field,
10
- FieldProps,
11
- Form,
12
- MultiSelectField,
13
- SelectField,
14
- StringField
6
+import { BoolField, DisplayField, Field, FieldProps, Form, MultiSelectField, SelectField, StringField
7
} from '@hfs/mui-grid-form'
8
import { apiCall, UseApi, useApiEx } from './api'
17
-import {
18
- _log,
19
- basename,
20
- Btn,
21
- defaultPerms,
22
- formatBytes,
23
- formatTimestamp,
24
- IconBtn,
25
- isEqualLax,
26
- isWhoObject,
27
- LinkBtn,
28
- modifiedSx,
29
- newDialog,
30
- objSameKeys,
31
- onlyTruthy,
32
- prefix,
33
- useBreakpoint,
34
- VfsPerms,
35
- wantArray,
36
- Who,
37
- WhoObject,
38
- wikiLink
39
-} from './misc'
9
+import { basename, Btn, defaultPerms, formatBytes, formatTimestamp, IconBtn, isEqualLax, isWhoObject,
10
+ LinkBtn, modifiedSx, newDialog, objSameKeys, onlyTruthy, prefix, useBreakpoint, VfsPerms, wantArray,
11
+ Who, WhoObject, wikiLink, matches } from './misc'
12
import { reloadVfs, VfsNode } from './VfsPage'
13
import md from './md'
14
import _ from 'lodash'
@@ -303,17 +275,23 @@ interface LinkFieldProps extends FieldProps<string> {
275
function LinkField({ value, statusApi }: LinkFieldProps) {
276
const { data, reload, error } = statusApi
277
const urls: string[] = data?.urls.https || data?.urls.http
306
- const link = (data?.baseUrl || '') + value
278
+ const baseHost = data?.baseUrl && new URL(data.baseUrl).hostname
279
+ const root = useMemo(() => baseHost && data.roots?.find((row: any) => matches(baseHost, row.host))?.root,
280
+ [data])
281
+ if (root)
282
+ value &&= value.indexOf(root) === 1 ? value.slice(root.length) : undefined
283
+ const link = prefix(data?.baseUrl || '', value)
284
return h(Box, { display: 'flex' },
285
!urls ? 'error' : // check data is ok
286
h(DisplayField, {
287
label: "Link",
311
- value: link,
288
+ value: link || `outside of configured base address (${baseHost})`,
289
error,
290
end: h(Box, {},
291
h(IconBtn, {
292
icon: ContentCopy,
293
title: "Copy",
294
+ disabled: !link,
295
onClick: () => navigator.clipboard.writeText(link)
296
}),
297
h(IconBtn, { icon: Edit, title: "Change", onClick() { changeBaseUrl().then(reload) } }),
admin/src/InternetPage.ts
+3
-46
@@ -4,9 +4,9 @@ import { CardMembership, HomeWorkTwoTone, Lock, Public, PublicTwoTone, RouterTwo
4
SvgIconComponent } from '@mui/icons-material'
5
import { apiCall, useApiEx } from './api'
6
import { closeDialog, DAY, formatTimestamp, wait, wantArray, with_ } from '@hfs/shared'
7
-import { PORT_DISABLED, Flex, LinkBtn, isIP, Btn, modifiedSx, IconBtn, CFG } from './misc'
7
+import { PORT_DISABLED, Flex, LinkBtn, isIP, Btn, CFG } from './misc'
8
import { alertDialog, confirmDialog, promptDialog, toast, waitDialog } from './dialog'
9
-import { BoolField, Form, FormProps, MultiSelectField, NumberField, SelectField } from '@hfs/mui-grid-form'
9
+import { BoolField, Form, MultiSelectField, NumberField, SelectField } from '@hfs/mui-grid-form'
10
import md from './md'
11
import { suggestMakingCert } from './OptionsPage'
12
import { changeBaseUrl } from './FileForm'
@@ -14,6 +14,7 @@ import { getNatInfo } from '../../src/nat'
14
import { ALL, WITH_IP } from './countries'
15
import _ from 'lodash'
16
import { SvgIconProps } from '@mui/material/SvgIcon/SvgIcon'
17
+import { ConfigForm } from './ConfigForm'
18
19
const COUNTRIES = ALL.filter(x => WITH_IP.includes(x.code))
20
@@ -355,47 +356,3 @@ function TitleCard({ title, icon, color, children }: { title: ReactNode, icon?:
356
children
357
)))
358
}
358
-
359
-type FormRest<T> = Omit<FormProps<T>, 'values' | 'set' | 'save'> & Partial<Pick<FormProps<T>, 'save'>>
360
-function ConfigForm<T=any>({ keys, form, saveOnChange, ...rest }: Partial<FormRest<T>> & {
361
- keys: (keyof T)[],
362
- form: FormRest<T> | ((values: T) => FormRest<T>),
363
- saveOnChange?: boolean
364
-}) {
365
- const config = useApiEx('get_config', { only: keys })
366
- const [values, setValues] = useState<any>(config.data)
367
- useEffect(() => setValues((v: any) => config.data || v), [config.data])
368
- const modified = values && !_.isEqual(values, config.data)
369
- useEffect(() => {
370
- if (modified && saveOnChange) save()
371
- }, [modified])
372
- if (!values)
373
- return config.element
374
- const formProps = _.isFunction(form) ? form(values) : form
375
- return h(Form, {
376
- values,
377
- set(v, k) {
378
- setValues((was: any) => ({ ...was, [k]: v }))
379
- },
380
- save: saveOnChange ? false : {
381
- onClick: save,
382
- sx: modifiedSx(modified),
383
- },
384
- ...Array.isArray(formProps) ? { fields: formProps } : formProps,
385
- ...rest,
386
- barSx: { gap: 1, ...rest.barSx },
387
- addToBar: [
388
- h(IconBtn, {
389
- icon: RestartAlt,
390
- disabled: !modified,
391
- title: "Reset",
392
- onClick(){ setValues(config.data) }
393
- }),
394
- ...rest.addToBar||[],
395
- ],
396
- })
397
-
398
- function save() {
399
- return apiCall('set_config', { values }).then(config.reload)
400
- }
401
-}
\ No newline at end of file
admin/src/VfsMenuBar.ts
+50
-15
@@ -6,22 +6,23 @@ import { Add, Microsoft } from '@mui/icons-material'
6
import { reloadVfs } from './VfsPage'
7
import addFiles, { addLink, addVirtual } from './addFiles'
8
import MenuButton from './MenuButton'
9
-import { Btn, reloadBtn } from './misc'
10
-import { apiCall, useApi } from './api'
9
+import { Btn, CFG, Flex, newDialog, reloadBtn } from './misc'
10
+import { apiCall, ApiObject, useApi } from './api'
11
+import { ConfigForm } from './ConfigForm'
12
+import { ArrayField } from './ArrayField'
13
+import { BoolField } from '@hfs/mui-grid-form'
14
+import VfsPathField from './VfsPathField'
15
12
-export default function VfsMenuBar({ status }: any) {
13
- const { data: integrated, reload } = useApi(status?.platform === 'win32' && 'windows_integrated')
14
- return h(Box, {
15
- display: 'flex',
16
- gap: 2,
16
+export default function VfsMenuBar({ statusApi }: { statusApi: ApiObject }) {
17
+ const isWindows = statusApi.data?.platform === 'win32'
18
+ const { data: integrated, reload } = useApi(isWindows && 'windows_integrated')
19
+ return h(Flex, {
20
mb: 2,
18
- sx: {
19
- position: 'sticky',
20
- top: 0,
21
- zIndex: 2,
22
- backgroundColor: 'background.paper',
23
- width: 'fit-content',
24
- },
21
+ position: 'sticky',
22
+ top: 0,
23
+ zIndex: 2,
24
+ backgroundColor: 'background.paper',
25
+ width: 'fit-content',
26
},
27
h(MenuButton, {
28
variant: 'contained',
@@ -32,8 +33,9 @@ export default function VfsMenuBar({ status }: any) {
33
{ children: "web-link", onClick: addLink },
34
]
35
}, "Add"),
36
+ h(Btn, { variant: 'outlined', onClick: roots }, "Roots"),
37
reloadBtn(() => reloadVfs()),
36
- status?.platform === 'win32' && h(Btn, {
38
+ isWindows && h(Btn, {
39
icon: Microsoft,
40
variant: 'outlined',
41
doneMessage: true,
@@ -55,4 +57,37 @@ export default function VfsMenuBar({ status }: any) {
57
})
58
}),
59
)
60
+
61
+ function roots() {
62
+ const { close } = newDialog({
63
+ dialogProps: { maxWidth: 'sm' },
64
+ Content: () => h(ConfigForm<{ roots: any, roots_mandatory: boolean }>, {
65
+ onSave() {
66
+ statusApi.reload() // this config is affecting status data
67
+ close()
68
+ },
69
+ keys: [CFG.roots, CFG.roots_mandatory],
70
+ form: {
71
+ fields: [
72
+ {
73
+ k: 'roots',
74
+ label: "Roots for different domains",
75
+ helperText: "You can decide different home-folders (in the VFS) for different domains, a bit like virtual hosts. If none is matched, the default home will be used.",
76
+ comp: ArrayField,
77
+ fields: [
78
+ { k: 'host', label: "Domain/Host", helperText: "Wildcards supported: domain.*|other.*" },
79
+ { k: 'root', label: "Home/Root", comp: VfsPathField, placeholder: "default", helperText: "Root path in VFS" },
80
+ ]
81
+ },
82
+ {
83
+ k: 'roots_mandatory',
84
+ label: "Block requests that are not using any of the domains above",
85
+ helperText: "localhost connections are not included",
86
+ comp: BoolField,
87
+ }
88
+ ]
89
+ }
90
+ })
91
+ })
92
+ }
93
}
admin/src/VfsPage.ts
+8
-7
@@ -122,17 +122,18 @@ export default function VfsPage() {
122
severity: 'info',
123
children: [
124
"Your shared files can be browsed from ",
125
- reactJoin(" or ", urls.slice(0,3).map(href => h(Link, { href }, href)))
125
+ reactJoin(" or ", urls.slice(0,3).map(href => h(Link, { href, target: 'frontend' }, href)))
126
]
127
}
128
return h(Grid, { container:true, rowSpacing: 1, columnSpacing: 2 },
129
- alert && h(Grid, { item: true, mb: 2, xs: 12 }, h(Alert, alert)),
130
- h(Grid, { item:true, [sideBreakpoint]: 6, lg: 5, xl: 4 },
131
- h(Typography, { variant: 'h6', mb:1, }, "Virtual File System"),
129
+ h(Grid, { item: true, mb: 2, xs: 12 },
130
h(Alert, { severity: 'info' }, "If you rename or delete here, it's virtual, and only affects what is presented to the users"),
133
- h(VfsMenuBar, { status }),
134
- vfs && h(VfsTree, { id2node })),
135
- isSideBreakpoint && sideContent && h(Grid, { item:true, [sideBreakpoint]: true, maxWidth:'100%' },
131
+ alert && h(Alert, alert) ),
132
+ h(Grid, { item: true, [sideBreakpoint]: 7, lg: 6, xl: 5 },
133
+ h(Typography, { variant: 'h6', mb:1, }, "Virtual File System"),
134
+ h(VfsMenuBar, { statusApi }),
135
+ vfs && h(VfsTree, { id2node, statusApi }) ),
136
+ isSideBreakpoint && sideContent && h(Grid, { item: true, [sideBreakpoint]: true, maxWidth:'100%' },
137
h(Card, { sx: { overflow: 'initial' } }, // overflow is incompatible with stickyBar
138
h(CardContent, {}, sideContent) ))
139
)
admin/src/VfsPathField.ts
new
+36
@@ -0,0 +1,36 @@
1
+import { dirname } from './misc'
2
+import { useApiList } from './api'
3
+import { createElement as h, useMemo } from 'react'
4
+import { Autocomplete, TextField } from '@mui/material'
5
+
6
+export default function VfsPathField({ value='', onChange, helperText, setApi, autocompleteProps, ...props }: any) {
7
+ const uri = dirname(value)
8
+ const { list, loading } = useApiList('get_file_list', { uri, admin: true, onlyFolders: true })
9
+ const options = useMemo(() => [uri + '/'].concat(list.map(x => value + x.n)), [list, uri])
10
+ return h(Autocomplete, {
11
+ value,
12
+ options,
13
+ isOptionEqualToValue: (o,v) => o === v || o === v + '/',
14
+ loading,
15
+ disableClearable: true,
16
+ renderInput: params => h(TextField, {
17
+ helperText,
18
+ placeholder: "home",
19
+ onChange(event) {
20
+ const v = event.target.value
21
+ if (v.endsWith('/') || !v)
22
+ onChange(v, { was: value, event })
23
+ },
24
+ onBlur(event) {
25
+ const v = event.target.value + '/'
26
+ if (options.includes(v))
27
+ onChange(v, { was: value, event })
28
+ },
29
+ ...params,
30
+ ...props,
31
+ InputLabelProps: { shrink: true, ...params.InputLabelProps, ...props.InputLabelProps },
32
+ }),
33
+ onChange: (event, sel) => onChange(sel, { was: value, event }),
34
+ ...autocompleteProps,
35
+ })
36
+}
\ No newline at end of file
admin/src/VfsTree.ts
+5
-4
@@ -8,14 +8,14 @@ import { ChevronRight, ExpandMore, TheaterComedy, Folder, Home, Link, InsertDriv
8
} from '@mui/icons-material'
9
import { Box } from '@mui/material'
10
import { reloadVfs, VfsNode } from './VfsPage'
11
-import { iconTooltip, onlyTruthy, Who } from './misc'
12
-import { apiCall } from './api'
11
+import { iconTooltip, onlyTruthy, Who, with_ } from './misc'
12
+import { apiCall, ApiObject } from './api'
13
import { alertDialog, confirmDialog } from './dialog'
14
15
export const FolderIcon = Folder
16
export const FileIcon = InsertDriveFileOutlined
17
18
-export default function VfsTree({ id2node }:{ id2node: Map<string, VfsNode> }) {
18
+export default function VfsTree({ id2node, statusApi }:{ id2node: Map<string, VfsNode>, statusApi: ApiObject }) {
19
const { vfs, selectedFiles } = useSnapState()
20
const [selected, setSelected] = useState<string[]>(selectedFiles.map(x => x.id)) // try to restore selection after reload
21
const [expanded, setExpanded] = useState(Array.from(id2node.keys()))
@@ -94,7 +94,8 @@ export default function VfsTree({ id2node }:{ id2node: Map<string, VfsNode> }) {
94
isRestricted(node.can_read) && iconTooltip(Lock, "Restrictions on who can download"),
95
node.default && iconTooltip(Web, "Act as website"),
96
node.masks && iconTooltip(TheaterComedy, "Masks"),
97
- node.size === -1 && iconTooltip(HighlightOff, "Source not found")
97
+ node.size === -1 && iconTooltip(HighlightOff, "Source not found"),
98
+ with_(statusApi.data?.roots?.find((row: any) => row.root === id.slice(1)), row => row && iconTooltip(Home, `home for ${row.host}`))
99
),
100
),
101
isRoot ? "Home" : (() => { // special rendering if the whole source is not too long, and the name was not customized
admin/src/api.ts
+1
@@ -22,6 +22,7 @@ setDefaultApiCallOptions({
22
23
const ERRORS = { timeout: "Operation timeout" }
24
// expand useApi with things that cannot be shared with Frontend
25
+export type ApiObject<T=any> = ReturnType<typeof useApiEx<T>>
26
export function useApiEx<T=any>(...args: Parameters<typeof useApi>) {
27
const res = useApi<T>(...args)
28
return {
mui-grid-form/SelectField.ts
+2
-1
@@ -28,7 +28,7 @@ export function SelectField<T>(props: FieldProps<T> & CommonSelectProps<T>) {
28
}
29
30
export function MultiSelectField<T>(props: FieldProps<T[]> & CommonSelectProps<T>) {
31
- const { value, onChange, setApi, options, sx, clearable, clearValue, placeholder, ...rest } = props
31
+ const { value, onChange, setApi, options, sx, clearable, clearValue, placeholder, autocompleteProps, ...rest } = props
32
const { select, InputProps, ...common } = commonSelectProps({ clearValue: [], ...props, clearable: false })
33
const normalizedOptions = useMemo(() => normalizeOptions(options), [options])
34
const valueAsOptions = useMemo(() => !Array.isArray(value) ? []
@@ -43,6 +43,7 @@ export function MultiSelectField<T>(props: FieldProps<T[]> & CommonSelectProps<T
43
getOptionLabel: x => x.label,
44
renderOption: (props, x) => h('span', props, x.label),
45
...common,
46
+ ...autocompleteProps,
47
value: valueAsOptions,
48
renderInput: params => h(TextField, {
49
...rest,
package.json
+2
-1
@@ -82,6 +82,7 @@
82
"minimist": "^1.2.6",
83
"nat-upnp-ts": "^2.0.1",
84
"open": "^8.4.0",
85
+ "picomatch": "^3.0.1",
86
"tssrp6a": "^3.0.0",
87
"unzip-stream": "^0.3.1",
88
"valtio": "^1.10.3",
@@ -97,11 +98,11 @@
98
"@types/koa-mount": "^4.0.1",
99
"@types/koa-session": "^5.10.4",
100
"@types/lodash": "^4.14.178",
100
- "@types/micromatch": "^4.0.2",
101
"@types/mime-types": "^2.1.1",
102
"@types/minimist": "^1.2.2",
103
"@types/mocha": "^9.0.0",
104
"@types/node": "^18.17.14",
105
+ "@types/picomatch": "^2.3.3",
106
"@types/tough-cookie": "^4.0.2",
107
"@types/unzipper": "^0.10.5",
108
"cross-env": "^7.0.3",
plugins/vhosting/plugin.js
deleted
-62
@@ -1,62 +0,0 @@
1
-exports.description = "If you want to have different home folders, based on domain"
2
-exports.version = 3.2
3
-exports.apiRequired = 2 // 2 is for the config 'array'
4
-
5
-exports.config = {
6
- hosts: {
7
- label: '',
8
- type: 'array',
9
- fields: {
10
- host: { label: "Domain", helperText: "Wildcards supported: domain.*|other.*" },
11
- root: { helperText: "Root path in VFS" },
12
- }
13
- },
14
- mandatory: {
15
- label: "Block requests that are not using any of the domains above",
16
- type: 'boolean',
17
- }
18
-}
19
-
20
-exports.configDialog = {
21
- sx: { maxWidth: '35em' },
22
-}
23
-
24
-exports.init = api => {
25
- const { matches } = api.require('./misc')
26
- return {
27
- middleware(ctx) {
28
- let params // undefined if we are not going to work on api parameters
29
- if (ctx.path.startsWith(api.Const.SPECIAL_URI)) { // special uris should be excluded...
30
- if (!ctx.path.startsWith(api.Const.API_URI)) return // ...unless it's an api
31
- let { referer } = ctx.headers
32
- referer &&= new URL(referer).pathname
33
- if (referer?.startsWith(ctx.state.revProxyPath + api.Const.ADMIN_URI)) return // exclude apis for admin-panel
34
- params = ctx.params || ctx.query // for api we'll translate params
35
- }
36
-
37
- const hosts = api.getConfig('hosts')
38
- if (!hosts?.length) return
39
- const row = hosts?.find(x => matches(ctx.host, x.host))
40
- if (!row) {
41
- if (api.getConfig('mandatory')) {
42
- ctx.socket.destroy()
43
- return true
44
- }
45
- return
46
- }
47
- let { root='' } = row
48
- if (!root || root === '/') return
49
- if (params === undefined) {
50
- ctx.path = join(root, ctx.path)
51
- return
52
- }
53
- for (const [k,v] of Object.entries(params))
54
- if (k.startsWith('uri'))
55
- params[k] = Array.isArray(v) ? v.map(x => join(root, x)) : join(root, v)
56
- }
57
- }
58
-}
59
-
60
-function join(a, b) {
61
- return a + (b && b[0] !== '/' ? '/' : '') + b
62
-}
\ No newline at end of file
src/adminApis.ts
+2
@@ -37,6 +37,7 @@ import { consoleLog } from './consoleLog'
37
import { resolve } from 'path'
38
import { getErrorSections } from './errorPages'
39
import { ip2country } from './geo'
40
+import { roots } from './roots'
41
42
export const adminApis: ApiHandlers = {
43
@@ -114,6 +115,7 @@ export const adminApis: ApiHandlers = {
115
urls: await getUrls(),
116
ips: await getIps(false),
117
baseUrl: getBaseUrlOrDefault(),
118
+ roots: roots.get(),
119
updatePossible: !updateSupported() ? false : await localUpdateAvailable() ? 'local' : true,
120
proxyDetected: getProxyDetected(),
121
frpDetected: localhostAdmin.get() && !getProxyDetected()
src/api.file_list.ts
+8
-6
@@ -11,15 +11,17 @@ import Koa from 'koa'
11
import { descriptIon, DESCRIPT_ION, getCommentFor, areCommentsEnabled } from './comments'
12
import { basename } from 'path'
13
import { getConnection, updateConnection } from './connections'
14
+import { ctxAdminAccess } from './adminApis'
15
16
export interface DirEntry { n:string, s?:number, m?:Date, c?:Date, p?: string, comment?: string, web?: boolean, url?: string }
17
17
-export const get_file_list: ApiHandler = async ({ uri, offset, limit, search, c }, ctx) => {
18
+export const get_file_list: ApiHandler = async ({ uri, offset, limit, search, c, onlyFolders, admin }, ctx) => {
19
const node = await urlToNode(uri || '/', ctx)
20
const list = ctx.get('accept') === 'text/event-stream' ? new SendListReadable() : undefined
21
if (!node)
22
return fail(HTTP_NOT_FOUND)
22
- if (statusCodeForMissingPerm(node,'can_list',ctx))
23
+ admin &&= ctxAdminAccess(ctx) // validate 'admin' flag
24
+ if (!admin && statusCodeForMissingPerm(node, 'can_list', ctx))
25
return fail()
26
if (dirTraversal(search))
27
return fail(HTTP_FOOL)
@@ -28,12 +30,12 @@ export const get_file_list: ApiHandler = async ({ uri, offset, limit, search, c
30
offset = Number(offset)
31
limit = Number(limit)
32
const filter = pattern2filter(search)
31
- const walker = walkNode(node, ctx, search ? Infinity : 0)
33
+ const walker = walkNode(node, { ctx: admin ? undefined : ctx, onlyFolders, depth: search ? Infinity : 0 })
34
const onDirEntryHandlers = mapPlugins(plug => plug.onDirEntry)
33
- const can_upload = hasPermission(node, 'can_upload', ctx)
35
+ const can_upload = admin || hasPermission(node, 'can_upload', ctx)
36
const fakeChild = applyParentToChild({}, node) // we want to know if we want to delete children
35
- const can_delete = hasPermission(fakeChild, 'can_delete', ctx)
36
- const can_archive = hasPermission(fakeChild, 'can_archive', ctx)
37
+ const can_delete = admin || hasPermission(fakeChild, 'can_delete', ctx)
38
+ const can_archive = admin || hasPermission(fakeChild, 'can_archive', ctx)
39
const can_comment = can_upload && areCommentsEnabled()
40
const props = { can_archive, can_upload, can_delete, accept: node.accept, can_comment }
41
if (!list)
src/cross.ts
+12
-1
@@ -2,6 +2,7 @@
2
// all content here is shared between client and server
3
import _ from 'lodash'
4
import { VfsNodeStored } from './vfs'
5
+import picomatch from 'picomatch/lib/picomatch' // point directly to the browser-compatible source
6
export * from './cross-const'
7
8
export const REPO_URL = 'https://github.com/rejetto/hfs/'
@@ -21,7 +22,8 @@ export const FRONTEND_OPTIONS = {
22
}
23
export const SORT_BY_OPTIONS = ['name', 'extension', 'size', 'time']
24
export const THEME_OPTIONS = { auto: '', light: 'light', dark: 'dark' }
24
-export const CFG = constMap(['geo_enable', 'geo_allow', 'geo_list', 'geo_allow_unknown'])
25
+export const CFG = constMap(['geo_enable', 'geo_allow', 'geo_list', 'geo_allow_unknown',
26
+ 'roots', 'roots_mandatory'])
27
export const LIST = { add: '+', remove: '-', update: '=', props: 'props', ready: 'ready', error: 'e' }
28
export type Dict<T=any> = Record<string, T>
29
export type Falsy = false | null | undefined | '' | 0
@@ -395,6 +397,15 @@ export function runAt(ts: number, cb: Callback) {
397
}
398
}
399
400
+export function makeMatcher(mask: string, emptyMaskReturns=false) {
401
+ return mask ? picomatch(mask.replace(/^(!)?/, '$1(') + ')', { nocase: true}) // adding () will allow us to use the pipe at root level
402
+ : () => emptyMaskReturns
403
+}
404
+
405
+export function matches(s: string, mask: string, emptyMaskReturns=false) {
406
+ return makeMatcher(mask, emptyMaskReturns)(s) // adding () will allow us to use the pipe at root level
407
+}
408
+
409
export function shortenAgent(agent: string) {
410
return _.findKey(BROWSERS, re => re.test(agent))
411
|| /^[^/(]+ ?/.exec(agent)?.[0]
src/index.ts
+2
@@ -23,6 +23,7 @@ import { selfCheckMiddleware } from './selfCheck'
23
import { acmeMiddleware } from './acme'
24
import './geo'
25
import { geoFilter } from './geo'
26
+import { rootsMiddleware } from './roots'
27
import events from './events'
28
29
ok(_.intersection(Object.keys(frontEndApis), Object.keys(adminApis)).length === 0) // they share same endpoints, don't clash
@@ -43,6 +44,7 @@ app.use(sessionMiddleware)
44
.use(headRequests)
45
.use(logMw)
46
.use(throttler)
47
+ .use(rootsMiddleware)
48
.use(mount(API_URI, apiMiddleware({ ...frontEndApis, ...adminApis })))
49
.use(serveGuiAndSharedFiles)
50
.on('error', errorHandler)
src/middlewares.ts
+6
-1
@@ -161,7 +161,7 @@ async function sendFolderList(node: VfsNode, ctx: Koa.Context) {
161
|| URL.protocol + '//' + URL.host + ctx.state.revProxyPath
162
prepend = base + ctx.originalUrl.split('?')[0]! as string
163
}
164
- const walker = walkNode(node, ctx, depth === '*' ? Infinity : Number(depth))
164
+ const walker = walkNode(node, { ctx, depth: depth === '*' ? Infinity : Number(depth) })
165
ctx.body = asyncGeneratorToReadable(filterMapGenerator(walker, async el => {
166
const isFolder = await nodeIsDirectory(el)
167
return !folders && isFolder ? undefined
@@ -246,6 +246,11 @@ export const prepareState: Koa.Middleware = async (ctx, next) => {
246
}
247
}
248
249
+declare module "koa" {
250
+ interface BaseContext {
251
+ params: Record<string, any>
252
+ }
253
+}
254
export const paramsDecoder: Koa.Middleware = async (ctx, next) => {
255
ctx.params = ctx.method === 'POST' && ctx.originalUrl.startsWith(API_URI)
256
&& (tryJson(await stream2string(ctx.req)) || {})
src/misc.ts
+1
-11
@@ -11,11 +11,10 @@ export * from './util-files'
11
export * from './cross'
12
export * from './debounceAsync'
13
import { Readable } from 'stream'
14
-import { matcher } from 'micromatch'
14
import { SocketAddress, BlockList } from 'node:net'
15
import { ApiError } from './apiMiddleware'
16
import { HTTP_BAD_REQUEST } from './const'
18
-import { ipLocalHost } from './cross'
17
+import { ipLocalHost, makeMatcher } from './cross'
18
import { isIPv6 } from 'net'
19
20
type ProcessExitHandler = (signal:string) => any
@@ -93,15 +92,6 @@ function parseAddress(s: string) {
92
return new SocketAddress({ address: s, family: isIPv6(s) ? 'ipv6' : 'ipv4' })
93
}
94
96
-export function makeMatcher(mask: string, emptyMaskReturns=false) {
97
- return mask ? matcher(mask.replace(/^(!)?/, '$1(') + ')', { nocase: true}) // adding () will allow us to use the pipe at root level
98
- : () => emptyMaskReturns
99
-}
100
-
101
-export function matches(s: string, mask: string, emptyMaskReturns=false) {
102
- return makeMatcher(mask, emptyMaskReturns)(s) // adding () will allow us to use the pipe at root level
103
-}
104
-
95
export function same(a: any, b: any) {
96
try {
97
assert.deepStrictEqual(a, b)
src/roots.ts
new
+42
@@ -0,0 +1,42 @@
1
+import { defineConfig } from './config'
2
+import { ADMIN_URI, API_URI, CFG, isLocalHost, makeMatcher, SPECIAL_URI } from './misc'
3
+import Koa from 'koa'
4
+import { disconnect } from './connections'
5
+
6
+export const roots = defineConfig(CFG.roots, [] as { host: string, root: string }[], list => {
7
+ const matchers = list.map((row: any) => typeof row?.host === 'string' ? makeMatcher(row.host) : () => false)
8
+ return (host: string) => list[matchers.findIndex(m => m(host))]
9
+})
10
+const rootsMandatory = defineConfig(CFG.roots_mandatory, false)
11
+
12
+export const rootsMiddleware: Koa.Middleware = (ctx, next) =>
13
+ (() => {
14
+ let params: undefined | typeof ctx.params | typeof ctx.query // undefined if we are not going to work on api parameters
15
+ if (ctx.path.startsWith(SPECIAL_URI)) { // special uris should be excluded...
16
+ if (!ctx.path.startsWith(API_URI)) return // ...unless it's an api
17
+ let { referer } = ctx.headers
18
+ referer &&= new URL(referer).pathname
19
+ if (referer?.startsWith(ctx.state.revProxyPath + ADMIN_URI)) return // exclude apis for admin-panel
20
+ params = ctx.params || ctx.query // for api we'll translate params
21
+ }
22
+ if (!roots.get()?.length) return
23
+ const row = roots.compiled()(ctx.host)
24
+ if (!row) {
25
+ if (!rootsMandatory.get() || isLocalHost(ctx)) return
26
+ disconnect(ctx)
27
+ return true // true will avoid calling next
28
+ }
29
+ const { root='' } = row
30
+ if (!root || root === '/') return
31
+ if (!params) {
32
+ ctx.path = join(root, ctx.path)
33
+ return
34
+ }
35
+ for (const [k,v] of Object.entries(params))
36
+ if (k.startsWith('uri'))
37
+ params[k] = Array.isArray(v) ? v.map(x => join(root, x)) : join(root, v)
38
+ })() || next()
39
+
40
+function join(a: string, b: any) {
41
+ return a + (b && b[0] !== '/' ? '/' : '') + b
42
+}
\ No newline at end of file
src/util-files.ts
+7
-6
@@ -74,14 +74,15 @@ export function adjustStaticPathForGlob(path: string) {
74
return glob.escapePath(path.replace(/\\/g, '/'))
75
}
76
77
-export async function* dirStream(path: string, deep=0) {
77
+export async function* dirStream(path: string, { depth=0, onlyFiles=false, onlyFolders = false }={}) {
78
if (!await isDirectory(path))
79
throw Error('ENOTDIR')
80
- const dirStream = glob.stream(deep ? '**/*' : '*', {
80
+ const dirStream = glob.stream(depth ? '**/*' : '*', {
81
cwd: path,
82
dot: true,
83
- deep: deep + 1,
84
- onlyFiles: false,
83
+ deep: depth + 1,
84
+ onlyFiles,
85
+ onlyDirectories: onlyFolders,
86
suppressErrors: true,
87
objectMode: true,
88
})
@@ -98,10 +99,10 @@ export async function* dirStream(path: string, deep=0) {
99
async function getItemsToSkip(path: string) {
100
if (!IS_WINDOWS) return
101
const winPath = path.replace(/\//g, '\\')
101
- const out = await runCmd('dir', ['/ah', '/b', deep ? '/s' : '/c', winPath]) // cannot pass '', so we pass /c as a noop parameter
102
+ const out = await runCmd('dir', ['/ah', '/b', depth ? '/s' : '/c', winPath]) // cannot pass '', so we pass /c as a noop parameter
103
.catch(()=>'') // error in case of no matching file
104
return out.split('\r\n').slice(0,-1).map(x =>
104
- !deep ? x : x.slice(winPath.length + 1).replace(/\\/g, '/'))
105
+ !depth ? x : x.slice(winPath.length + 1).replace(/\\/g, '/'))
106
}
107
}
108
src/vfs.ts
+10
-4
@@ -215,14 +215,20 @@ export function statusCodeForMissingPerm(node: VfsNode, perm: keyof VfsPerms, ct
215
}
216
217
// it's responsibility of the caller to verify you have list permission on parent, as callers have different needs.
218
-// Too many parameters: consider object, but benchmark against degraded recursion on huge folders.
219
-export async function* walkNode(parent:VfsNode, ctx?: Koa.Context, depth:number=0, prefixPath:string='', requiredPerm?: keyof VfsPerms): AsyncIterableIterator<VfsNode> {
218
+export async function* walkNode(parent: VfsNode, {
219
+ ctx,
220
+ depth = Infinity,
221
+ prefixPath = '',
222
+ requiredPerm,
223
+ onlyFolders = false
224
+}: { ctx?: Koa.Context,depth?: number, prefixPath?: string, requiredPerm?: undefined | keyof VfsPerms, onlyFolders?: boolean } = {}): AsyncIterableIterator<VfsNode> {
225
const { children, source } = parent
226
const took = prefixPath ? undefined : new Set()
227
const maskApplier = parentMaskApplier(parent)
228
const parentsCache = new Map() // we use this only if depth > 0
229
if (children)
230
for (const child of children) {
231
+ if (onlyFolders && !await nodeIsDirectory(child)) continue
232
const nodeName = getNodeName(child)
233
const name = prefixPath + nodeName
234
took?.add(name)
@@ -236,7 +242,7 @@ export async function* walkNode(parent:VfsNode, ctx?: Koa.Context, depth:number=
242
parentsCache.set(name, item)
243
inheritMasks(item, parent, nodeName)
244
if (!ctx || hasPermission(item, 'can_list', ctx)) // check perm before recursion
239
- yield* walkNode(item, ctx, depth - 1, name + '/', requiredPerm)
245
+ yield* walkNode(item, { ctx, depth: depth - 1, prefixPath: name + '/', requiredPerm, onlyFolders })
246
}
247
if (!source)
248
return
@@ -249,7 +255,7 @@ export async function* walkNode(parent:VfsNode, ctx?: Koa.Context, depth:number=
255
let lastDir = prefixPath.slice(0, -1) || '.'
256
parentsCache.set(lastDir, parent)
257
// it's important to keep using dirStream in deep-mode, as it is manyfold faster (it parallelizes)
252
- for await (const [path, isFolder] of dirStream(source, depth)) {
258
+ for await (const [path, isFolder] of dirStream(source, { depth, onlyFolders })) {
259
if (ctx?.req.aborted)
260
return
261
const name = prefixPath + (parent.rename?.[path] || path)
src/zip.ts
+2
-2
@@ -20,7 +20,7 @@ export async function zipStreamFromFolder(node: VfsNode, ctx: Koa.Context) {
20
const name = list?.length === 1 ? decodeURIComponent(basename(list[0]!)) : getNodeName(node)
21
ctx.attachment((isWindowsDrive(name) ? name[0] : (name || 'archive')) + '.zip')
22
const filter = pattern2filter(String(ctx.query.search||''))
23
- const walker = !list ? walkNode(node, ctx, Infinity, '', 'can_archive')
23
+ const walker = !list ? walkNode(node, { ctx, requiredPerm: 'can_archive' })
24
: (async function*(): AsyncIterableIterator<VfsNode> {
25
for await (const uri of list) {
26
const subNode = await urlToNode(uri, ctx, node)
@@ -29,7 +29,7 @@ export async function zipStreamFromFolder(node: VfsNode, ctx: Koa.Context) {
29
if (await nodeIsDirectory(subNode)) { // a directory needs to walked
30
if (hasPermission(subNode, 'can_list',ctx)) {
31
yield subNode // it could be empty
32
- yield* walkNode(subNode, ctx, Infinity, decodeURI(uri) + '/', 'can_archive')
32
+ yield* walkNode(subNode, { ctx, prefixPath: decodeURI(uri) + '/', requiredPerm: 'can_archive' })
33
}
34
continue
35
}