nicer yaml format for roots

Massimo Melina committed Dec 22, 2023 at 22:19 UTC bc2d67f6bb152e7153d0134db714555a5dc5dc5f
7 files changed +35 -14
admin/src/FileForm.ts
+1 -1
@@ -273,7 +273,7 @@ function LinkField({ value, statusApi }: LinkFieldProps) {
273 const { data, reload, error } = statusApi
274 const urls: string[] = data?.urls.https || data?.urls.http
275 const baseHost = data?.baseUrl && new URL(data.baseUrl).hostname
276 - const root = useMemo(() => baseHost && data.roots?.find((row: any) => matches(baseHost, row.host))?.root,
276 + const root = useMemo(() => baseHost && _.find(data.roots, (root, host) => matches(baseHost, host)),
277 [data])
278 if (root)
279 value &&= value.indexOf(root) === 1 ? value.slice(root.length) : undefined
admin/src/VfsMenuBar.ts
+3 -1
@@ -82,7 +82,9 @@ export default function VfsMenuBar({ statusApi }: { statusApi: ApiObject }) {
82 fields: [
83 { k: 'host', label: "Domain/Host", helperText: "Wildcards supported: domain.*|other.*" },
84 { k: 'root', label: "Home/Root", comp: VfsPathField, placeholder: "default", helperText: "Root path in VFS" },
85 - ]
85 + ],
86 + toField: x => Object.entries(x || {}).map(([host,root]) => ({ host, root })),
87 + fromField: x => Object.fromEntries(x.map((row: any) => [row.host, row.root])),
88 },
89 {
90 k: 'roots_mandatory',
admin/src/VfsTree.ts
+3 -1
@@ -12,6 +12,7 @@ import { onlyTruthy, Who, with_ } from './misc'
12 import { iconTooltip } from './mui'
13 import { apiCall, ApiObject } from './api'
14 import { alertDialog, confirmDialog } from './dialog'
15 +import _ from 'lodash'
16
17 export const FolderIcon = Folder
18 export const FileIcon = InsertDriveFileOutlined
@@ -96,7 +97,8 @@ export default function VfsTree({ id2node, statusApi }:{ id2node: Map<string, Vf
97 node.default && iconTooltip(Web, "Act as website"),
98 node.masks && iconTooltip(TheaterComedy, "Masks"),
99 node.size === -1 && iconTooltip(HighlightOff, "Source not found"),
99 - with_(statusApi.data?.roots?.find((row: any) => row.root === id.slice(1)), row => row && iconTooltip(Home, `home for ${row.host}`))
100 + with_(_.findKey(statusApi.data?.roots, root => root === id.slice(1)), host =>
101 + host && iconTooltip(Home, `home for ${host}`))
102 ),
103 ),
104 isRoot ? "Home" : (() => { // special rendering if the whole source is not too long, and the name was not customized
config.md
+7
@@ -90,6 +90,13 @@ Configuration can be done in several ways
90 - `server_code` javascript code that works similarly to [a plugin](dev-plugins.md).
91 - `tiles_size` starting value for frontend's tiles size. Default is 0.
92 - `update_to_beta` includes beta versions searching for updates. Default is false.
93 +- `roots` maps hosts (or mask of hosts) to a root different from the home folder. Default is none. E.g.
94 + ```
95 + roots:
96 + music.domain.com: /music
97 + image.domain.com: /image
98 + ```
99 +- `roots_mandatory` disconnect any request not made with one of the hosts specified in `roots`. Default is false.
100 - `max_downloads` limit the number of concurrent downloads on the whole server. Default is unlimited.
101 - `max_downloads_per_ip` limit the number of concurrent downloads for the same IP address. Default is unlimited.
102 - `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/roots.ts
+16 -8
@@ -2,10 +2,17 @@ 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 +import _ from 'lodash'
6
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))]
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 + }
12 + const list = Object.keys(map)
13 + const matchers = list.map(hostMask => makeMatcher(hostMask))
14 + const values = Object.values(map)
15 + return (host: string) => values[matchers.findIndex(m => m(host))]
16 })
17 const rootsMandatory = defineConfig(CFG.roots_mandatory, false)
18
@@ -19,15 +26,16 @@ export const rootsMiddleware: Koa.Middleware = (ctx, next) =>
26 if (referer?.startsWith(ctx.state.revProxyPath + ADMIN_URI)) return // exclude apis for admin-panel
27 params = ctx.params || ctx.query // for api we'll translate params
28 }
22 - if (!roots.get()?.length) return
23 - const row = roots.compiled()(ctx.host)
24 - if (!row) {
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 (!rootsMandatory.get() || isLocalHost(ctx)) return
36 disconnect(ctx)
37 return true // true will avoid calling next
38 }
29 - const { root='' } = row
30 - if (!root || root === '/') return
39 if (!params) {
40 ctx.path = join(root, ctx.path)
41 return
tests/config.yaml
+2
@@ -131,3 +131,5 @@ accounts:
131 version: 0.51.0-alpha4
132 max_downloads_per_account: 2
133 max_downloads: 1
134 +roots:
135 + 127.0.0.1:*: f1/
tests/test.ts
+3 -3
@@ -38,6 +38,7 @@ describe('basics', () => {
38 it('bad range', req('/f1/f2/alfa.txt', 416, {
39 headers: { Range: 'bytes=7-' }
40 }))
41 + it('roots', req('/f2/alfa.txt', 200, { baseUrl: BASE_URL.replace('localhost', '127.0.0.1') })) // host 127.0.0.1 is rooted in /f1
42 it('website', req('/f1/page/', { re:/This is a test/, mime:'text/html' }))
43 it('traversal', req('/f1/page/.%2e/.%2e/README.md', 418))
44 it('custom mime from above', req('/tests/page/index.html', { status: 200, mime:'text/plain' }))
@@ -184,9 +185,9 @@ type Tester = number
185
186 const jar = {}
187
187 -function req(url: string, test:Tester, requestOptions: XRequestOptions & { throttle?: number }={}) {
188 +function req(url: string, test:Tester, { baseUrl, throttle, ...requestOptions }: XRequestOptions & { throttle?: number, baseUrl?: string }={}) {
189 // passing 'path' keeps it as it is, avoiding internal resolving
189 - return () => httpStream(BASE_URL + url, { path: url, jar, ...requestOptions }).catch(e => {
190 + return () => httpStream((baseUrl || BASE_URL) + url, { path: url, jar, ...requestOptions }).catch(e => {
191 if (e.code === "ECONNREFUSED")
192 throw e
193 return e.cause
@@ -198,7 +199,6 @@ function req(url: string, test:Tester, requestOptions: XRequestOptions & { throt
199 test = { re:test }
200 if (typeof test === 'number')
201 test = { status: test }
201 - const { throttle } = requestOptions
202 const stream = throttle ? res.pipe(new ThrottledStream(new ThrottleGroup(throttle))) : res
203 const data = await stream2string(stream)
204 const obj = tryJson(data)