support for rev-proxy with a path #140
Massimo Melina committed
Mar 10, 2023 at 17:27 UTC
d250a8a2c91a69c2068a44e12245bcaba196030f
14 files changed
+90
-36
README.md
+25
@@ -42,6 +42,7 @@ This is a full rewrite of [the Delphi version](https://github.com/rejetto/hfs2).
42
- multi-language front-end
43
- virtual hosting (plug-in)
44
- anti-brute-force (plug-in)
45
+- reverse-proxy support
46
47
## Installation
48
@@ -230,6 +231,30 @@ For each account entries, this is the list of properties you can have:
231
- `admin` set `true` if you want to let this account log in to the Admin-panel. Default is `false`.
232
- `belongs` an array of usernames of other accounts from which to inherit their permissions. Default is none.
233
234
+## Reverse proxy
235
+
236
+HFS can work behind a reverse proxy. Configuration depends on what software you use, but this is an example using nginx
237
+exposing HFS under the path `/files/` instead of just `/`. Adjust it to suit your needs.
238
+
239
+```
240
+location /files/ {
241
+ proxy_http_version 1.1;
242
+ keepalive_timeout 30;
243
+ proxy_buffering off;
244
+ proxy_redirect off;
245
+ proxy_max_temp_file_size 0;
246
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # forward IP address
247
+ proxy_set_header X-forwarded-prefix "/files"; # the location without final slash
248
+ client_max_body_size 0; # disable max size for uploads
249
+ proxy_pass http://127.0.0.1:80/;
250
+}
251
+```
252
+
253
+This is enough, but you may want to configure generated links accordingly:
254
+- go to Admin-panel > Shared files > Home > Link
255
+- click the change button (right side of the field)
256
+- enter the final URL of your proxy including the location
257
+
258
## License
259
260
[GPLv3](https://github.com/rejetto/hfs/blob/master/LICENSE.txt)
admin/src/FileForm.ts
+12
-3
@@ -202,12 +202,13 @@ function LinkField({ value, urls, }: LinkFieldProps) {
202
)
203
204
function edit() {
205
- const proto = new URL(urls[0]).protocol + '//'
205
+ const startingProto = new URL(base || urls[0]).protocol + '//'
206
newDialog({
207
title: "Change link",
208
onClose: reload,
209
Content() {
210
const [v, setV] = useState(base)
211
+ const [proto, setProto] = useState(startingProto)
212
return h(Box, { display: 'flex', flexDirection: 'column' },
213
h(Box, { mb: 2 }, "You can choose a different base address for your links"),
214
h(MenuList, {},
@@ -222,13 +223,21 @@ function LinkField({ value, urls, }: LinkFieldProps) {
223
helperText: md("You can type any address but *you* are responsible to make the address work.\nThis functionality is just to help you copy the link in case you have a domain or a complex network configuration."),
224
value: !v || urls.includes(v) ? '' : v.slice(proto.length),
225
onChange: v => set(prefix(proto, v)),
225
- onTyping: v => /^[-\w.[\]:]*$/.test(v),
226
- start: proto,
226
+ start: h(SelectField as Field<string>, {
227
+ value: proto,
228
+ onChange: setProto,
229
+ options: ['http://','https://'],
230
+ size: 'small',
231
+ variant: 'standard',
232
+ sx: { '& .MuiSelect-select': { pt: '1px', pb: 0 } },
233
+ }),
234
sx: { mt: 2 }
235
}),
236
)
237
238
async function set(u: string) {
239
+ if (u.endsWith('/'))
240
+ u = u.slice(0, -1)
241
await apiCall('set_config', { values: { base_url: u } })
242
setV(u)
243
}
admin/src/api.ts
+1
-1
@@ -22,7 +22,7 @@ export function useApiEx<T=any>(...args: Parameters<typeof useApi>) {
22
return { data, error, reload, loading, element }
23
}
24
25
-const PREFIX = '/~/api/'
25
+const PREFIX = (window as any).HFS?.prefixUrl + '/~/api/'
26
27
const timeoutByApi: Dict = {
28
loginSrp1: 90, // support antibrute
frontend/src/Breadcrumbs.ts
+7
-6
@@ -2,20 +2,21 @@
2
3
import { Link, useLocation } from 'react-router-dom'
4
import { createElement as h, Fragment, ReactElement } from 'react'
5
-import { hIcon } from './misc'
5
+import { getPrefixUrl, hIcon } from './misc'
6
import { state } from './state'
7
import { reloadList } from './useFetchList'
8
import { useI18N } from './i18n'
9
10
export function Breadcrumbs() {
11
- const currentPath = useLocation().pathname.slice(1,-1)
12
- let prev = ''
13
- const parent = currentPath.split('/').slice(0,-1).join('/')+'/'
14
- const breadcrumbs = currentPath ? currentPath.split('/').map(x => [prev = prev + x + '/', decodeURIComponent(x)]) : []
11
+ const base = getPrefixUrl() + '/'
12
+ const currentPath = useLocation().pathname.slice(base.length,-1)
13
+ const parent = base + currentPath.slice(0, currentPath.lastIndexOf('/') + 1)
14
+ let prev = base
15
+ const breadcrumbs = currentPath ? currentPath.split('/').map(x => [prev += x + '/', decodeURIComponent(x)]) : []
16
const {t} = useI18N()
17
return h(Fragment, {},
18
h(Breadcrumb, { label: hIcon('parent', { alt: t`parent folder` }), path: parent }),
18
- h(Breadcrumb, { current: !currentPath, label: hIcon('home', { alt: t`home` }) }),
19
+ h(Breadcrumb, { label: hIcon('home', { alt: t`home` }), path: base, current: !currentPath }),
20
breadcrumbs.map(([path,label]) =>
21
h(Breadcrumb, {
22
key: path,
frontend/src/BrowseFiles.ts
+2
-2
@@ -12,7 +12,7 @@ import {
12
useState
13
} from 'react'
14
import { domOn, formatBytes, hError, hIcon, isMobile } from './misc'
15
-import { Checkbox, CustomCode, Html, Spinner } from './components'
15
+import { Checkbox, CustomCode, Spinner } from './components'
16
import { Head } from './Head'
17
import { state, useSnapState } from './state'
18
import { alertDialog } from './dialog'
@@ -182,7 +182,7 @@ const PAGE_SEPARATOR_CLASS = 'page-separator'
182
183
const Entry = memo((entry: DirEntry & { midnight: Date, separator?: string }) => {
184
let { n: relativePath, isFolder, separator } = entry
185
- const base = usePath()
185
+ const base = useLocation().pathname
186
const { showFilter, selected } = useSnapState()
187
const href = fixUrl(relativePath)
188
const containerDir = isFolder ? '' : relativePath.substring(0, relativePath.lastIndexOf('/')+1)
frontend/src/api.ts
+3
-3
@@ -1,9 +1,9 @@
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 { useEffect, useRef, useState } from 'react';
4
-import { Dict, Falsy, getCookie, working } from './misc'
4
+import { Dict, Falsy, getCookie, getPrefixUrl, working } from './misc'
5
6
-const PREFIX = '/~/api/'
6
+const PREFIX = getPrefixUrl() + '/~/api/'
7
8
interface ApiCallOptions { noModal?:true }
9
export function apiCall(cmd: string, params?: Dict, options: ApiCallOptions={}) {
@@ -12,7 +12,7 @@ export function apiCall(cmd: string, params?: Dict, options: ApiCallOptions={})
12
if (csrf)
13
params = { csrf, ...params }
14
const controller = new AbortController()
15
- return Object.assign(fetch(PREFIX+cmd, {
15
+ return Object.assign(fetch(PREFIX + cmd, {
16
method: 'POST',
17
headers: { 'content-type': 'application/json' },
18
signal: controller.signal,
frontend/src/login.ts
+2
-2
@@ -3,7 +3,7 @@
3
import { apiCall } from './api'
4
import { state } from './state'
5
import { alertDialog, newDialog } from './dialog'
6
-import { hIcon, srpSequence, working } from './misc'
6
+import { getPrefixUrl, hIcon, srpSequence, working } from './misc'
7
import { useNavigate } from 'react-router-dom'
8
import { createElement as h, useEffect, useRef } from 'react'
9
import { t, tComponent, useI18N } from './i18n'
@@ -109,7 +109,7 @@ export async function loginDialog(navigate: ReturnType<typeof useNavigate>) {
109
const res = await login(usr, pwd)
110
closeDialog()
111
if (res?.redirect)
112
- navigate(res.redirect)
112
+ navigate(getPrefixUrl() + res.redirect)
113
} catch (err: any) {
114
await alertDialog(err)
115
usrRef.current?.focus()
frontend/src/misc.ts
+10
-1
@@ -66,4 +66,13 @@ Object.assign((window as any).HFS ||= {}, {
66
output.push(res)
67
})
68
}
69
-})
\ No newline at end of file
69
+})
70
+
71
+export function getHFS() {
72
+ return (window as any).HFS
73
+}
74
+
75
+export function getPrefixUrl() {
76
+ return getHFS().prefixUrl
77
+}
78
+
plugins/vhosting/plugin.js
+1
-1
@@ -27,7 +27,7 @@ exports.init = api => {
27
if (!ctx.path.startsWith(api.const.API_URI) || ctx.params.path === undefined) return
28
let { referer } = ctx.headers
29
referer &&= new URL(referer).pathname
30
- if (referer?.startsWith(api.const.ADMIN_URI)) return
30
+ if (referer?.startsWith(ctx.state.revProxyPath + api.const.ADMIN_URI)) return
31
toModify = ctx.params
32
}
33
const hosts = api.getConfig('hosts')
src/api.auth.ts
+1
-1
@@ -128,7 +128,7 @@ export const logout: ApiHandler = async ({}, ctx) => {
128
export const refresh_session: ApiHandler = async ({}, ctx) => {
129
return !ctx.session ? new ApiError(HTTP_SERVER_ERROR) : {
130
username: getCurrentUsername(ctx),
131
- adminUrl: ctxAdminAccess(ctx) ? ADMIN_URI : undefined,
131
+ adminUrl: ctxAdminAccess(ctx) ? ctx.state.revProxyPath + ADMIN_URI : undefined,
132
...makeExp(),
133
}
134
}
src/apiMiddleware.ts
+5
-3
@@ -3,7 +3,7 @@
3
import Koa from 'koa'
4
import createSSE from './sse'
5
import { Readable } from 'stream'
6
-import { asyncGeneratorToReadable, onOff } from './misc'
6
+import { asyncGeneratorToReadable, onOff, removeStarting } from './misc'
7
import events from './events'
8
import { HTTP_BAD_REQUEST, HTTP_NOT_FOUND, HTTP_UNAUTHORIZED } from './const'
9
import _ from 'lodash'
@@ -30,8 +30,10 @@ export function apiMiddleware(apis: ApiHandlers) : Koa.Middleware {
30
// we don't rely on SameSite cookie option because it's https-only
31
let res
32
try {
33
- res = csrf && csrf !== ctx.params.csrf ? new ApiError(HTTP_UNAUTHORIZED, 'csrf')
34
- : await apiFun(ctx.params || {}, ctx)
33
+ if (params.path)
34
+ params.path = removeStarting(ctx.state.revProxyPath, params.path)
35
+ res = csrf && csrf !== params.csrf ? new ApiError(HTTP_UNAUTHORIZED, 'csrf')
36
+ : await apiFun(params || {}, ctx)
37
}
38
catch(e) {
39
res = e
src/middlewares.ts
+3
-2
@@ -84,7 +84,7 @@ export const serveGuiAndSharedFiles: Koa.Middleware = async (ctx, next) => {
84
if (path.startsWith(FRONTEND_URI))
85
return serveFrontendPrefixed(ctx,next)
86
if (path+'/' === ADMIN_URI)
87
- return ctx.redirect(ADMIN_URI)
87
+ return ctx.redirect(ctx.state.revProxyPath + ADMIN_URI)
88
if (path.startsWith(ADMIN_URI))
89
return serveAdminPrefixed(ctx,next)
90
if (ctx.method === 'PUT') { // curl -T file url/
@@ -119,7 +119,7 @@ export const serveGuiAndSharedFiles: Koa.Middleware = async (ctx, next) => {
119
const canRead = hasPermission(node, 'can_read', ctx)
120
const isFolder = await nodeIsDirectory(node)
121
if (isFolder && !path.endsWith('/'))
122
- return ctx.redirect(ctx.originalUrl + '/')
122
+ return ctx.redirect(ctx.state.revProxyPath + ctx.originalUrl + '/')
123
if (canRead && !isFolder)
124
return node.source ? serveFileNode(node)(ctx,next)
125
: next()
@@ -175,6 +175,7 @@ export const prepareState: Koa.Middleware = async (ctx, next) => {
175
// calculate these once and for all
176
ctx.state.account = await getHttpAccount(ctx) ?? getAccount(ctx.session?.username, false)
177
const conn = ctx.state.connection = socket2connection(ctx.socket)
178
+ ctx.state.revProxyPath = ctx.get('x-forwarded-prefix')
179
await next()
180
if (conn)
181
updateConnection(conn, { ctx })
src/misc.ts
+4
@@ -20,6 +20,10 @@ export function enforceFinal(sub:string, s:string) {
20
return s.endsWith(sub) ? s : s+sub
21
}
22
23
+export function removeStarting(sub: string, s: string) {
24
+ return s.startsWith(sub) ? s.slice(sub.length) : s
25
+}
26
+
27
export function prefix(pre:string, v:string|number|undefined, post:string='') {
28
return v ? pre+v+post : ''
29
}
src/serveGuiFiles.ts
+14
-11
@@ -21,6 +21,7 @@ import { favicon, title } from './adminApis'
21
import { subscribe } from 'valtio'
22
import { customHtmlState, getSection } from './customHtml'
23
import _ from 'lodash'
24
+import { getWholeConfig } from './config'
25
26
// in case of dev env we have our static files within the 'dist' folder'
27
const DEV_STATIC = process.env.DEV ? 'dist/' : ''
@@ -42,14 +43,14 @@ function serveStatic(uri: string): Koa.Middleware {
43
const content = await getOrSet(cache, ctx.path, async () => {
44
const data = await fs.readFile(fullPath).catch(() => null)
45
return serveApp || !data ? data
45
- : adjustBundlerLinks(ctx.path, uri, data)
46
+ : adjustBundlerLinks(ctx, uri, data)
47
})
48
if (content === null)
49
return ctx.status = HTTP_NOT_FOUND
50
if (!serveApp)
51
return serveFile(fullPath, 'auto', content)(ctx, next)
52
// we don't cache the index as it's small and may prevent plugins change to apply
52
- ctx.body = await treatIndex(ctx, String(content), uri)
53
+ ctx.body = await treatIndex(ctx, uri, String(content))
54
ctx.type = 'html'
55
ctx.set('Cache-Control', 'no-store, no-cache, must-revalidate')
56
}
@@ -59,22 +60,23 @@ function shouldServeApp(ctx: Koa.Context) {
60
return ctx.state.serveApp ||= ctx.path.endsWith('/')
61
}
62
62
-function adjustBundlerLinks(path: string, uri: string, data: string | Buffer) {
63
- const ext = extname(path)
63
+function adjustBundlerLinks(ctx: Koa.Context, uri: string, data: string | Buffer) {
64
+ const ext = extname(ctx.path)
65
return ext && !ext.match(/\.(css|html|js|ts|scss)/) ? data
65
- : String(data).replace(/((?:import | from )['"])\//g, `$1${uri}`)
66
+ : String(data).replace(/((?:import | from )['"])\//g, `$1${ctx.state.revProxyPath}${uri}`)
67
}
68
68
-async function treatIndex(ctx: Koa.Context, body: string, filesUri: string) {
69
+async function treatIndex(ctx: Koa.Context, filesUri: string, body: string) {
70
const session = await refresh_session({}, ctx)
71
ctx.set('etag', '')
72
73
const isFrontend = filesUri === FRONTEND_URI
74
75
+ const pub = ctx.state.revProxyPath + PLUGINS_PUB_URI
76
const css = mapPlugins((plug,k) =>
75
- (isFrontend ? plug.frontend_css : null)?.map(f => PLUGINS_PUB_URI + k + '/' + f)).flat().filter(Boolean)
77
+ (isFrontend ? plug.frontend_css : null)?.map(f => pub + k + '/' + f)).flat().filter(Boolean)
78
const js = mapPlugins((plug,k) =>
77
- (isFrontend ? plug.frontend_js : null)?.map(f => PLUGINS_PUB_URI + k + '/' + f)).flat().filter(Boolean)
79
+ (isFrontend ? plug.frontend_js : null)?.map(f => pub + k + '/' + f)).flat().filter(Boolean)
80
81
// expose plugins' configs that are declared with 'frontend' attribute
82
const plugins = Object.fromEntries(onlyTruthy(mapPlugins((pl,name) => {
@@ -86,7 +88,7 @@ async function treatIndex(ctx: Koa.Context, body: string, filesUri: string) {
88
return !_.isEmpty(configs) && [name, configs]
89
})))
90
let ret = body
89
- .replace(/((?:src|href) *= *['"])\/?(?![a-z]+:\/\/)/g, '$1' + filesUri)
91
+ .replace(/((?:src|href) *= *['"])\/?(?![a-z]+:\/\/)/g, '$1' + ctx.state.revProxyPath + filesUri)
92
.replace('</head>', () => `
93
${!isFrontend ? '' : `
94
<title>${title.get()}</title>
@@ -98,6 +100,7 @@ async function treatIndex(ctx: Koa.Context, body: string, filesUri: string) {
100
API_VERSION,
101
session: session instanceof ApiError ? null : session,
102
plugins,
103
+ prefixUrl: ctx.state.revProxyPath,
104
customHtml: _.omit(Object.fromEntries(customHtmlState.sections),
105
['top','bottom']), // excluding sections we apply in this phase
106
}, null, 4)}
@@ -134,8 +137,8 @@ function serveProxied(port: string | undefined, uri: string) { // used for devel
137
proxyReqPathResolver: (ctx) =>
138
shouldServeApp(ctx) ? '/' : ctx.path,
139
userResDecorator(res, data, ctx) {
137
- return shouldServeApp(ctx) ? treatIndex(ctx, String(data), uri)
138
- : adjustBundlerLinks(ctx.path, uri, data)
140
+ return shouldServeApp(ctx) ? treatIndex(ctx, uri, String(data))
141
+ : adjustBundlerLinks(ctx, uri, data)
142
}
143
}) )
144
return function() { //@ts-ignore