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 }