admin/internet: "roots" moved here from "shared files"

Massimo Melina committed Apr 14, 2024 at 17:27 UTC 605deaff5ee3dbe2cb559c59d4bce1d9898be75d
9 files changed +59 -84
admin/src/FileForm.ts
+3 -3
@@ -324,7 +324,7 @@ function LinkField({ value, statusApi }: LinkFieldProps) {
324 !urls ? 'error' : // check data is ok
325 h(DisplayField, {
326 label: "Link",
327 - value: link || `outside of configured base address (${baseHost})`,
327 + value: link || `outside of configured main address (${baseHost})`,
328 error,
329 InputProps: link ? { inputComponent: RenderLink } : undefined,
330 end: h(Box, {},
@@ -376,14 +376,14 @@ export async function changeBaseUrl() {
376 const { base_url } = await apiCall('get_config', { only: ['base_url'] })
377 const urls: string[] = res.urls.https || res.urls.http
378 const { close } = newDialog({
379 - title: "Base address",
379 + title: "Main address",
380 Content() {
381 const [v, setV] = useState(base_url || '')
382 const proto = new URL(v || urls[0]).protocol + '//'
383 const host = urls.includes(v) ? '' : v.slice(proto.length)
384 const check = h(Check, { sx: { ml: 2 } })
385 return h(Box, { display: 'flex', flexDirection: 'column' },
386 - h(Box, { mb: 2 }, "Choose a base address for your links"),
386 + h(Box, { mb: 2 }, "Choose a main address for your links"),
387 h(MenuList, {},
388 h(MenuItem, {
389 selected: !v,
admin/src/InternetPage.ts
+28 -8
@@ -16,6 +16,8 @@ import _ from 'lodash'
16 import { SvgIconProps } from '@mui/material/SvgIcon/SvgIcon'
17 import { ConfigForm } from './ConfigForm'
18 import { DynamicDnsResult } from '../../src/ddns'
19 +import { ArrayField } from './ArrayField'
20 +import VfsPathField from './VfsPathField'
21
22 const COUNTRIES = ALL.filter(x => WITH_IP.includes(x.code))
23
@@ -44,8 +46,8 @@ export default function InternetPage() {
46 }, [verifyAgain.state])
47 return h(Flex, { vert: true, gap: '2em', maxWidth: '40em' },
48 h(Alert, { severity: 'info' }, "This page makes sure your site is working correctly on the Internet"),
47 - baseUrlBox(),
49 networkBox(),
50 + baseUrlBox(),
51 httpsBox(),
52 geoBox(),
53 ddnsBox(),
@@ -240,9 +242,10 @@ export default function InternetPage() {
242 const url = config.data?.base_url
243 const hostname = url && new URL(url).hostname
244 const domain = !isIP(hostname) && hostname
243 - return config.element || h(TitleCard, { icon: Public, title: "Address / Domain" },
245 + return config.element || h(TitleCard, { icon: Public, title: "Address" },
246 h(Flex, { flexWrap: 'wrap' },
245 - url || "Automatic, not configured",
247 + "Main address: ",
248 + url ? h('tt', {}, url) : "automatic, not configured",
249 h(Flex, {}, // keep buttons together when wrapping
250 h(Btn, {
251 size: 'small',
@@ -258,14 +261,31 @@ export default function InternetPage() {
261 }, "Check"),
262 ),
263 ),
261 - h(ConfigForm<{ force_base_url: boolean }>, {
262 - keys: ['force_base_url'],
264 + h(ConfigForm<{ roots: any, force_address: boolean }>, {
265 saveOnChange: true,
266 + onSave() {
267 + status.reload() // this config is affecting status data
268 + },
269 + keys: [CFG.roots, CFG.force_address],
270 form: {
271 fields: [
266 - { k: 'force_base_url', comp: BoolField, disabled: !url,
267 - label: "Accept requests only using domain (and localhost)",
268 - helperText: !url && "You must specify an address, for this option"
272 + {
273 + k: 'roots',
274 + label: false,
275 + 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.",
276 + comp: ArrayField,
277 + fields: [
278 + { k: 'host', label: "Domain/Host", helperText: "Wildcards supported: *.domain.com|other.com" },
279 + { k: 'root', label: "Home/Root", comp: VfsPathField, placeholder: "default", helperText: "Root path in VFS",
280 + $column: { renderCell({ value }: any) { return value || h('i', {}, 'default') } } },
281 + ],
282 + toField: x => Object.entries(x || {}).map(([host,root]) => ({ host, root })),
283 + fromField: x => Object.fromEntries(x.map((row: any) => [row.host, row.root || ''])),
284 + },
285 + {
286 + k: CFG.force_address,
287 + label: "Accept requests only using domains above (and localhost)",
288 + comp: BoolField,
289 }
290 ]
291 },
admin/src/VfsMenuBar.ts
+5 -45
@@ -4,12 +4,9 @@ import { createElement as h } from 'react'
4 import { Alert, Box, List, ListItem, ListItemIcon, ListItemText } from '@mui/material'
5 import { Microsoft, Storage } from '@mui/icons-material'
6 import { reloadVfs } from './VfsPage'
7 -import { CFG, newDialog, prefix } from './misc'
8 -import { Btn, Flex, IconBtn, reloadBtn, useBreakpoint } from './mui'
7 +import { prefix } from './misc'
8 +import { Btn, Flex, IconBtn, reloadBtn } from './mui'
9 import { apiCall, ApiObject, useApi } from './api'
10 -import { ConfigForm } from './ConfigForm'
11 -import { ArrayField } from './ArrayField'
12 -import { BoolField } from '@hfs/mui-grid-form'
10 import VfsPathField from './VfsPathField'
11 import { alertDialog, promptDialog } from './dialog'
12 import { formatDiskSpace } from './FilePicker'
@@ -17,13 +14,11 @@ import { getDiskSpaces } from '../../src/util-os'
14
15 export default function VfsMenuBar({ statusApi }: { statusApi: ApiObject }) {
16 return h(Flex, {
20 - mb: 2,
17 zIndex: 2,
18 backgroundColor: 'background.paper',
19 width: 'fit-content',
20 },
25 - h(Btn, { variant: 'outlined', onClick: roots }, "Roots"),
26 - useBreakpoint('sm') && reloadBtn(() => reloadVfs()),
21 + reloadBtn(() => reloadVfs()),
22 h(IconBtn, {
23 icon: Storage,
24 title: "Disk spaces",
@@ -41,47 +36,12 @@ export default function VfsMenuBar({ statusApi }: { statusApi: ApiObject }) {
36 }),
37 h(SystemIntegrationButton, statusApi.data)
38 )
44 -
45 - function roots() {
46 - const { close } = newDialog({
47 - title: "Roots for different domains",
48 - dialogProps: { maxWidth: 'sm' },
49 - Content: () => h(ConfigForm<{ roots: any, roots_mandatory: boolean }>, {
50 - onSave() {
51 - statusApi.reload() // this config is affecting status data
52 - close()
53 - },
54 - keys: [CFG.roots, CFG.roots_mandatory],
55 - form: {
56 - fields: [
57 - {
58 - k: 'roots',
59 - label: false,
60 - 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.",
61 - comp: ArrayField,
62 - fields: [
63 - { k: 'host', label: "Domain/Host", helperText: "Wildcards supported: *.domain.com|other.com" },
64 - { k: 'root', label: "Home/Root", comp: VfsPathField, placeholder: "default", helperText: "Root path in VFS" },
65 - ],
66 - toField: x => Object.entries(x || {}).map(([host,root]) => ({ host, root })),
67 - fromField: x => Object.fromEntries(x.map((row: any) => [row.host, row.root || ''])),
68 - },
69 - {
70 - k: 'roots_mandatory',
71 - label: "Accept requests only using domains above (and localhost)",
72 - comp: BoolField,
73 - }
74 - ]
75 - }
76 - })
77 - })
78 - }
39 }
40
41 function SystemIntegrationButton({ platform }: { platform: string | undefined }) {
42 const isWindows = platform === 'win32'
43 const { data: integrated, reload } = useApi(isWindows && 'windows_integrated')
84 - return !isWindows ? null : h(Btn, {
44 + return h(Btn, {
45 icon: Microsoft,
46 variant: 'outlined',
47 doneMessage: true,
@@ -91,7 +51,7 @@ function SystemIntegrationButton({ platform }: { platform: string | undefined })
51 const msg = h(Box, {}, "We are going to add a command in the right-click of Windows File Manager",
52 h('img', { src: 'win-shell.png', style: {
53 display: 'block',
94 - width: 'min(30em, 80vw)',
54 + width: 'min(30em, 100%)',
55 marginTop: '1em',
56 } }),
57 h(Alert, { severity: 'info' }, "It will also automatically copy the URL, ready to paste!"),
admin/src/VfsPage.ts
+4 -2
@@ -144,8 +144,10 @@ export default function VfsPage() {
144 ),
145 h(Grid, { container: true, rowSpacing: 1, columnSpacing: 2, top: 0 },
146 h(Grid, { item: true, xs: 12, [sideBreakpoint]: 6, lg: 6, xl: 5 },
147 - h(Typography, { variant: 'h6', mb: 1, }, "Virtual File System"),
148 - h(VfsMenuBar, { statusApi }),
147 + h(Flex, { mb: 1, flexWrap: 'wrap', gap: [0, 2] },
148 + h(Typography, { variant: 'h6' }, "Virtual File System"),
149 + h(VfsMenuBar, { statusApi }),
150 + ),
151 vfs && h(VfsTree, { id2node, statusApi }) ),
152 isSideBreakpoint && sideContent && h(Grid, { item: true, [sideBreakpoint]: true, maxWidth: '100%' },
153 h(Card, { sx: { overflow: 'initial' } }, // overflow is incompatible with stickyBar
config.md
+1 -2
@@ -83,10 +83,10 @@ Configuration can be done in several ways
83 - `session_duration` after how many seconds should the login session expire. Default is a day.
84 - `acme_domain` domain used for ACME certificate generation. Default is none.
85 - `acme_email` email used for ACME certificate generation. Default is none.
86 -- `force_base_url` disconnect any connection that's not using the domain used for ACME certificate generation. Default is none.
86 - `acme_renew` automatically renew acme certificate close to expiration. Default is false.
87 - `listen_interface` network interface to listen on, by specifying IP address. Default is any.
88 - `base_url` URL to be used for links generation. Default is automatic.
89 +- `force_address` disconnect any request not made with one of the hosts specified in `roots` or `base_url`. Default is false.
90 - `ignore_proxies` stop warning about detected proxies. Default is false.
91 - `descript_ion` enable reading and writing of comments in the old file format *DESCRIPT.ION*. Default is yes.
92 - `descript_ion_encoding` text encoding to be used for file *DESCRIPT.ION*. [List of supported values](https://github.com/ashtuchkin/iconv-lite/wiki/Supported-Encodings). Default is `utf8`.
@@ -99,7 +99,6 @@ Configuration can be done in several ways
99 music.domain.com: /music
100 image.domain.com: /image
101 ```
102 -- `roots_mandatory` disconnect any request not made with one of the hosts specified in `roots`. Default is false.
102 - `max_downloads` limit the number of concurrent downloads on the whole server. Default is unlimited.
103 - `max_downloads_per_ip` limit the number of concurrent downloads for the same IP address. Default is unlimited.
104 - `max_downloads_per_account` limit the number of concurrent downloads for each account. This is enforced only for connections that are logged in, and will override other similar settings. Default is unlimited.
src/cross.ts
+1 -1
@@ -25,7 +25,7 @@ export const SORT_BY_OPTIONS = ['name', 'extension', 'size', 'time']
25 export const THEME_OPTIONS = { auto: '', light: 'light', dark: 'dark' }
26 export const CFG = constMap(['geo_enable', 'geo_allow', 'geo_list', 'geo_allow_unknown', 'dynamic_dns_url',
27 'log', 'error_log', 'log_rotation', 'dont_log_net', 'log_gui', 'log_api', 'log_ua',
28 - 'max_downloads', 'max_downloads_per_ip', 'max_downloads_per_account', 'roots', 'roots_mandatory'])
28 + 'max_downloads', 'max_downloads_per_ip', 'max_downloads_per_account', 'roots', 'force_address'])
29 export const LIST = { add: '+', remove: '-', update: '=', props: 'props', ready: 'ready', error: 'e' }
30 export type Dict<T=any> = Record<string, T>
31 export type Falsy = false | null | undefined | '' | 0
src/index.ts
+1 -1
@@ -41,9 +41,9 @@ app.use(sessionMiddleware)
41 .use(gzipper)
42 .use(paramsDecoder) // must be done before plugins, so they can manipulate params
43 .use(headRequests)
44 + .use(rootsMiddleware)
45 .use(logMw)
46 .use(throttler)
46 - .use(rootsMiddleware)
47 .use(pluginsMiddleware)
48 .use(mount(API_URI, apiMiddleware({ ...frontEndApis, ...adminApis })))
49 .use(serveGuiAndSharedFiles)
src/middlewares.ts
+2 -5
@@ -7,11 +7,11 @@ import { DAY, dirTraversal, isLocalHost, splitAt, stream2string, tryJson } from
7 import { Readable } from 'stream'
8 import { applyBlock } from './block'
9 import { Account, accountCanLogin, getAccount } from './perm'
10 -import { Connection, disconnect, normalizeIp, socket2connection, updateConnectionForCtx } from './connections'
10 +import { Connection, normalizeIp, socket2connection, updateConnectionForCtx } from './connections'
11 import basicAuth from 'basic-auth'
12 import { invalidSessions, setLoggedIn, srpCheck } from './auth'
13 import { constants } from 'zlib'
14 -import { baseUrl, getHttpsWorkingPort } from './listen'
14 +import { getHttpsWorkingPort } from './listen'
15 import { defineConfig } from './config'
16 import session from 'koa-session'
17 import { app } from './index'
@@ -19,7 +19,6 @@ import events from './events'
19
20 const forceHttps = defineConfig('force_https', true)
21 const ignoreProxies = defineConfig('ignore_proxies', false)
22 -const forceBaseUrl = defineConfig('force_base_url', false)
22 export const sessionDuration = defineConfig('session_duration', Number(process.env.SESSION_DURATION) || DAY/1000,
23 v => v * 1000)
24
@@ -76,8 +75,6 @@ export const someSecurity: Koa.Middleware = async (ctx, next) => {
75 catch {
76 return ctx.status = HTTP_FOOL
77 }
79 - if (!ctx.state.skipFilters && forceBaseUrl.get() && baseUrl.compiled() && !isLocalHost(ctx) && ctx.host !== baseUrl.compiled())
80 - return disconnect(ctx, 'force-domain')
78 if (!ctx.secure && forceHttps.get() && getHttpsWorkingPort() && !isLocalHost(ctx)) {
79 const { URL } = ctx
80 URL.protocol = 'https'
src/roots.ts
+14 -17
@@ -1,23 +1,30 @@
1 -import { defineConfig } from './config'
1 +import { defineConfig, getConfig } 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 -import _ from 'lodash'
5 +import { baseUrl } from './listen'
6
7 export const roots = defineConfig(CFG.roots, {} as { [hostMask: string]: string }, map => {
8 - if (_.isArray(map)) { // legacy pre 0.51.0-alpha5, remove in 0.52
9 - roots.set(Object.fromEntries(map.map(x => [x.host, x.root])))
10 - return
11 - }
8 const list = Object.keys(map)
9 const matchers = list.map(hostMask => makeMatcher(hostMask))
10 const values = Object.values(map)
11 return (host: string) => values[matchers.findIndex(m => m(host))]
12 })
17 -const rootsMandatory = defineConfig(CFG.roots_mandatory, false)
13 +const forceAddress = defineConfig(CFG.force_address, false)
14 +forceAddress.sub((v, { version }) => { // convert from legacy configs
15 + if (version?.olderThan('0.53.0'))
16 + forceAddress.set(getConfig('force_base_url') || getConfig('roots_mandatory') || false)
17 +})
18
19 export const rootsMiddleware: Koa.Middleware = (ctx, next) =>
20 (() => {
21 + const root = roots.compiled()?.(ctx.host)
22 + if (!ctx.state.skipFilters && forceAddress.get())
23 + if (root === undefined && !isLocalHost(ctx) && ctx.host !== baseUrl.compiled()) {
24 + disconnect(ctx, forceAddress.key())
25 + return true // true will avoid calling next
26 + }
27 + if (!root || root === '/') return // not transformation is required
28 let params: undefined | typeof ctx.state.params | typeof ctx.query // undefined if we are not going to work on api parameters
29 if (ctx.path.startsWith(SPECIAL_URI)) { // special uris should be excluded...
30 if (!ctx.path.startsWith(API_URI)) return // ...unless it's an api
@@ -26,16 +33,6 @@ export const rootsMiddleware: Koa.Middleware = (ctx, next) =>
33 if (referer?.startsWith(ctx.state.revProxyPath + ADMIN_URI)) return // exclude apis for admin-panel
34 params = ctx.state.params || ctx.query // for api we'll translate params
35 }
29 - if (_.isEmpty(roots.get())) return
30 - const host2root = roots.compiled()
31 - if (!host2root) return
32 - const root = host2root(ctx.host)
33 - if (root === '' || root === '/') return
34 - if (root === undefined) {
35 - if (ctx.state.skipFilters || !rootsMandatory.get() || isLocalHost(ctx)) return
36 - disconnect(ctx, 'bad-domain')
37 - return true // true will avoid calling next
38 - }
36 if (!params) {
37 ctx.path = join(root, ctx.path)
38 return