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