expose admin on frontend's same port
Massimo Melina committed
Mar 11, 2022 at 17:52 UTC
d4c779a838a7adf1cb23239ac22040707270b7f9
22 files changed
+153
-243
README.md
+2
-4
@@ -38,9 +38,9 @@ As you can see from the list above, we already have some goods that you can't fi
38
39
### Why you should still stay with HFS 2.x (so far)
40
41
-- easier to configure
41
- smaller
42
- more tested
43
+- easier to configure (not sure about this anymore)
44
45
# Installation
46
@@ -190,8 +190,6 @@ When not specified, default values will be used.
190
Supported entries are:
191
- `port` where to accept http connections. Default is 80.
192
- `vfs` the files and folders you want to expose. For details see the dedicated following section.
193
-- `admin_port` the port where to reach admin interface. Default is 63636.
194
-- `admin_network` the network address where to reach admin interface. Default is 127.0.0.1 .
193
- `log` path of the log file. Default is `access.log`.
194
- `log_rotation` frequency of log rotation. Accepted values are `daily`, `weekly`, `monthly`, or empty string to disable. Default is `weekly`.
195
- `error_log` path of the log file for errors. Default is `error.log`.
@@ -272,7 +270,7 @@ Other options you can define as properties of an account:
270
271
- `ignore_limits` to ignore speed limits. Default is `false`.
272
- `redirect` provide a URL if you want the user to be redirected upon login. Default is none.
275
-- `admin` set `true` if you want to give access to the Admin interface when it's configured to require login.
273
+- `admin` set `true` if you want to let this account log in to the Admin interface.
274
- `belongs` an array of usernames of other accounts from which to inherit their permissions.
275
276
## License
admin/package.json
+1
-1
@@ -1,7 +1,7 @@
1
{
2
"name": "@hfs/admin",
3
"private": true,
4
- "proxy": "http://localhost:63636",
4
+ "proxy": "http://localhost",
5
"scripts": {
6
"start": "react-scripts start",
7
"build": "cross-env GENERATE_SOURCEMAP=false BUILD_PATH='../dist/admin' react-scripts build",
admin/public/logo.svg
renamed
admin/src/AccountsPage.ts
+3
-6
@@ -1,7 +1,7 @@
1
// This file is part of HFS - Copyright 2021-2022, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3
import { isValidElement, createElement as h, useState, useEffect, Fragment } from "react"
4
-import { apiCall, useApi, useApiComp } from './api'
4
+import { apiCall, useApiComp } from './api'
5
import { Box, Button, Card, CardContent, Grid, List, ListItem, ListItemText, Typography } from '@mui/material'
6
import { Delete, Group, MilitaryTech, Person, PersonAdd, Refresh } from '@mui/icons-material'
7
import { BoolField, Form, MultiSelectField, SelectField, StringField } from './Form'
@@ -33,7 +33,6 @@ export default function AccountsPage() {
33
const [res, reload] = useApiComp('get_accounts')
34
const [sel, setSel] = useState<string[]>([])
35
const [add, setAdd] = useState(false)
36
- const [config] = useApi('get_config', { only: ['admin_login'] }) // load values here and pass to AccountForm, to avoid unnecessary reloads
36
const styles = useStyles()
37
useEffect(() => { // if accounts are reloaded, review the selection to remove elements that don't exist anymore
38
if (isValidElement(res) || !Array.isArray(res?.list)) return
@@ -108,7 +107,6 @@ export default function AccountsPage() {
107
h(CardContent, {},
108
account ? h(AccountForm, {
109
account,
111
- config,
110
groups: list.filter(x => !x.hasPassword).map( x => x.username ),
111
done(username) {
112
setAdd(false)
@@ -136,7 +134,7 @@ function hList(heading: string, list: any[]) {
134
)
135
}
136
139
-function AccountForm({ account, done, groups, config }: { account: Account, groups: string[], done: (username: string)=>void, config: any }) {
137
+function AccountForm({ account, done, groups }: { account: Account, groups: string[], done: (username: string)=>void }) {
138
const [values, setValues] = useState<Account & { password?: string, password2?: string }>(account)
139
const [belongsOptions, setBelongOptions] = useState<string[]>([])
140
useEffect(() => {
@@ -160,8 +158,7 @@ function AccountForm({ account, done, groups, config }: { account: Account, grou
158
{ k: 'ignore_limits', comp: BoolField,
159
helperText: values.ignore_limits ? "Speed limits don't apply to this account" : "Speed limits apply to this account" },
160
{ k: 'admin', comp: BoolField, fromField: (v:boolean) => v||null, label: "Permission to access Admin interface",
163
- helperText: "It's THIS interface you are using right now."
164
- + (config?.admin_login ? '' : " You are currently giving free access without login. You can require login in Configuration page."),
161
+ helperText: "It's THIS interface you are using right now.",
162
...account.adminActualAccess && { value: true, disabled: true, helperText: "This permission is inherited" },
163
},
164
{ k: 'redirect', comp: StringField, helperText: "If you want this account to be redirected to a specific folder/address at login time" },
admin/src/App.ts
+3
-4
@@ -1,11 +1,10 @@
1
// This file is part of HFS - Copyright 2021-2022, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3
import { createElement as h } from 'react'
4
-import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom'
4
+import { HashRouter, Routes, Route, useLocation } from 'react-router-dom'
5
import MainMenu, { getMenuLabel, mainMenu } from './MainMenu'
6
import { Box, ThemeProvider, Typography } from '@mui/material'
7
import { Dialogs } from './dialog'
8
-import logo from './logo.svg'
8
import { useMyTheme } from './theme'
9
import { LoginRequired } from './LoginRequired'
10
@@ -13,7 +12,7 @@ function App() {
12
return h(ThemeProvider, { theme: useMyTheme() },
13
h(ApplyTheme, {},
14
h(LoginRequired, {},
16
- h(BrowserRouter, {}, h(Routed)) ) ) )
15
+ h(HashRouter, {}, h(Routed)) ) ) )
16
}
17
18
function ApplyTheme(props:any) {
@@ -43,7 +42,7 @@ function Routed() {
42
position: 'relative',
43
display: 'flex',
44
flexDirection: 'column',
46
- background: 'url('+logo+') no-repeat center',
45
+ background: 'url(logo.svg) no-repeat center',
46
backgroundSize: 'contain',
47
}
48
},
admin/src/ConfigPage.ts
-12
@@ -57,18 +57,6 @@ export default function ConfigPage() {
57
{ k: 'https_port', comp: ServerPort, label: "HTTPS port", status: status?.https||true, suggestedPort: 443 },
58
config.https_port >= 0 && { k: 'cert', comp: StringField, label: "HTTPS certificate file" },
59
config.https_port >= 0 && { k: 'private_key', comp: StringField, label: "HTTPS private key file" },
60
- { k: 'admin_port', comp: ServerPort, label: "Admin port" },
61
- { k: 'admin_network', comp: SelectField, label: "Admin access",
62
- options:[
63
- { value: '127.0.0.1', label: "localhost only" },
64
- { value: '0.0.0.0', label: "any network" }
65
- ]
66
- },
67
- { k: 'admin_login', comp: BoolField, label: "Admin requires login",
68
- disabled: !status?.any_admin_account,
69
- helperText: (config.admin_network === '127.0.0.1' ? '' : "You should enable this because access is not restricted to localhost.")
70
- + (status?.any_admin_account ? '' : " Before this, you must go to Accounts and give Admin access to some account.")
71
- },
60
{ k: 'max_kbps', comp: NumberField, label: 'Max KB/s', helperText: "Limit output bandwidth" },
61
{ k: 'max_kbps_per_ip', comp: NumberField, label: "Max KB/s per-ip" },
62
{ k: 'log', xl: 4, comp: StringField, label: "Main log file" },
admin/src/HomePage.ts
+2
-2
@@ -14,7 +14,7 @@ export default function HomePage() {
14
const { username } = useSnapState()
15
const [status] = useApi<Dict<ServerStatus>>('get_status')
16
const [vfs] = useApi('get_vfs')
17
- const [cfg] = useApi('get_config', { only: ['https_port', 'cert', 'private_key', 'admin_network'] })
17
+ const [cfg] = useApi('get_config', { only: ['https_port', 'cert', 'private_key'] })
18
if (!status)
19
return spinner()
20
const { http, https } = status
@@ -47,7 +47,7 @@ export default function HomePage() {
47
!errors.length && h(Fragment, {}, ' - ', cfgLink("switch http or https on"))
48
)
49
),
50
- !username && cfg?.admin_network !== '127.0.0.1' && h(Alert, { severity: 'warning' }, "Admin interface is not limited to localhost - ", cfgLink("restrict access with login")),
50
+ !username && h(Alert, { severity: 'info' }, "You are accessing on localhost without an account - ", h(InLink, { to:'accounts' }, "give admin access to an account to be able to access from other computers")),
51
52
vfs?.root && !vfs.root.children?.length && !vfs.root.source &&
53
h(Alert, { severity: 'warning' }, "You have no files shared - ", fsLink("add some"))
admin/src/LoginRequired.ts
+3
-1
@@ -25,7 +25,7 @@ function LoginForm() {
25
setValues({ ...values, [k]: v })
26
},
27
fields: [
28
- { k: 'username' },
28
+ { k: 'username', autoComplete: 'username' },
29
{ k: 'password', type: 'password', autoComplete: 'current-password' },
30
]
31
}),
@@ -69,6 +69,8 @@ async function login(username: string, password: string) {
69
.catch(() => { throw WRONG })
70
await resStep2.step3(BigInt(res.proof))
71
.catch(() => { throw "Login aborted: server identity cannot be trusted" })
72
+ if (!res.adminUrl)
73
+ throw "This account has no Admin access"
74
75
// login was successful, update state
76
sessionRefresher({ username, exp:res.exp })
frontend/src/UserPanel.ts
+1
-7
@@ -6,7 +6,7 @@ import { alertDialog, closeDialog, newDialog, promptDialog } from './dialog'
6
import { createVerifierAndSalt, SRPParameters, SRPRoutines } from 'tssrp6a'
7
import { apiCall } from './api'
8
import { logout } from './login'
9
-import { MenuButton, MenuLink } from './menu'
9
+import { MenuButton } from './menu'
10
11
export default function showUserPanel() {
12
newDialog({ Content })
@@ -16,12 +16,6 @@ function Content() {
16
const snap = useSnapState()
17
return h('div', { id: 'user-panel' },
18
h('div', {}, "User: " + snap.username),
19
- snap.admin_port && h(MenuLink, {
20
- icon: 'admin',
21
- label: "Admin interface",
22
- href: 'http://' + window.location.hostname + ':' + snap.admin_port,
23
- target: 'admin',
24
- }),
19
h(MenuButton, {
20
icon: 'password',
21
label: "Change password",
frontend/src/login.ts
+2
-2
@@ -49,9 +49,9 @@ sessionRefresher(window.SESSION)
49
50
function sessionRefresher(response: any) {
51
if (!response) return
52
- const { exp, username, admin_port } = response
52
+ const { exp, username, adminUrl } = response
53
state.username = username
54
- state.admin_port = admin_port
54
+ state.adminUrl = adminUrl
55
if (!username || !exp) return
56
const delta = new Date(exp).getTime() - Date.now()
57
const t = Math.min(delta - 30_000, 600_000)
frontend/src/options.ts
+7
@@ -5,6 +5,7 @@ import { state, useSnapState } from './state'
5
import { createElement as h } from 'react'
6
import { Checkbox, FlexV, Select } from './components'
7
import { hIcon } from './misc'
8
+import { MenuLink } from './menu'
9
10
export function showOptions (){
11
const options = ['name','extension','size','time']
@@ -13,6 +14,12 @@ export function showOptions (){
14
function Content(){
15
const snap = useSnapState()
16
return h(FlexV, {},
17
+ snap.adminUrl && h(MenuLink, {
18
+ icon: 'admin',
19
+ label: "Admin interface",
20
+ href: snap.adminUrl,
21
+ target: 'admin',
22
+ }),
23
h('div', {}, 'Sort by'),
24
options.map(x => h('button',{
25
key: x,
frontend/src/state.ts
+1
-1
@@ -23,7 +23,7 @@ export const state = proxy<{
23
invertOrder: boolean,
24
foldersFirst: boolean,
25
theme: string,
26
- admin_port?: number,
26
+ adminUrl?: string,
27
}>({
28
iconsClass: '',
29
username: '',
server/src/adminApis.ts
+9
-8
@@ -10,8 +10,8 @@ import { Connection, getConnections } from './connections'
10
import { generatorAsCallback, onOffMap, pendingPromise } from './misc'
11
import _ from 'lodash'
12
import events from './events'
13
-import * as authApis from './api.auth'
14
-import { getAccounts } from './perm'
13
+import { getAccounts, getFromAccount } from './perm'
14
+import Koa from 'koa'
15
16
export const adminApis: ApiHandlers = {
17
@@ -36,7 +36,6 @@ export const adminApis: ApiHandlers = {
36
version: VERSION,
37
http: serverStatus(st.httpSrv, getConfig('port')),
38
https: serverStatus(st.httpsSrv, getConfig('https_port')),
39
- any_admin_account: _.some(getAccounts(), a => a.admin || false),
39
}
40
41
function serverStatus(h: typeof st.httpSrv, configuredPort?: number) {
@@ -91,12 +90,14 @@ export const adminApis: ApiHandlers = {
90
91
}
92
94
-// protect most apis...
93
for (const k in adminApis) {
94
const was = adminApis[k]
95
adminApis[k] = (params, ctx) =>
98
- getConfig('admin_login') && !ctx.state.accountIsAdmin ? new ApiError(401)
99
- : was(params, ctx)
96
+ ctxAdminAccess(ctx) ? was(params, ctx)
97
+ : new ApiError(401)
98
+}
99
+
100
+export function ctxAdminAccess(ctx: Koa.Context) {
101
+ return ctx.ip === '127.0.0.1'
102
+ || getFromAccount(ctx.state.account, a => a.admin)
103
}
101
-// exception made for auth apis
102
-Object.assign(adminApis, authApis)
server/src/api.accounts.ts
-8
@@ -37,15 +37,7 @@ const apis: ApiHandlers = {
37
changes.admin = undefined
38
else if (typeof admin !== 'boolean')
39
return new ApiError(400, "invalid admin")
40
- if (getConfig('admin_login') && admin === false && !anyOtherAccessibleAccountWithAdmin())
41
- return new ApiError(403, "you can't disable admin because this is the last account with such permission")
40
return setAccount(username, changes) ? {} : new ApiError(400)
43
-
44
- function anyOtherAccessibleAccountWithAdmin() {
45
- return _.some(getAccounts(), a => accountCanLogin(a)
46
- // with undefined we invite search to continue to its groups, because disabling admin on an account will still leave its inheritance on
47
- && Boolean(getFromAccount(a, a => a.username === username ? undefined : a.admin)))
48
- }
41
},
42
43
add_account({ username, ...rest }) {
server/src/api.auth.ts
+6
-11
@@ -4,11 +4,12 @@ import { getAccount, getCurrentUsername, getFromAccount } from './perm'
4
import { verifyPassword } from './crypt'
5
import { ApiError, ApiHandler } from './apiMiddleware'
6
import { SRPParameters, SRPRoutines, SRPServerSession, SRPServerSessionStep1 } from 'tssrp6a'
7
-import { SESSION_DURATION } from './const'
7
+import { ADMIN_URI, SESSION_DURATION } from './const'
8
import { randomId } from './misc'
9
import Koa from 'koa'
10
import { changeSrpHelper, changePasswordHelper } from './api.helpers'
11
-import { getListeningAdminPort } from './listen'
11
+import { ctxAdminAccess } from './adminApis'
12
+import { prepareState } from './middlewares'
13
14
const srp6aNimbusRoutines = new SRPRoutines(new SRPParameters())
15
const srpSession = new SRPServerSession(srp6aNimbusRoutines)
@@ -25,6 +26,7 @@ function loggedIn(ctx:Koa.Context, username: string | false) {
26
return
27
}
28
s.username = username
29
+ prepareState(ctx, async ()=>{}) // updating the state is necessary to send complete session data so that frontend shows admin button
30
ctx.cookies.set('csrf', randomId(), { signed:false, httpOnly: false })
31
}
32
@@ -62,7 +64,7 @@ export const loginSrp1: ApiHandler = async ({ username }, ctx) => {
64
return new ApiError(401)
65
if (!account.srp)
66
return new ApiError(406) // unacceptable
65
- if (ctx.state.admin && !getFromAccount(account, a => a.admin))
67
+ if (!getFromAccount(account, a => a.admin))
68
return new ApiError(403)
69
70
const [salt, verifier] = account.srp.split('|')
@@ -105,8 +107,8 @@ export const logout: ApiHandler = async ({}, ctx) => {
107
export const refresh_session: ApiHandler = async ({}, ctx) => {
108
return !ctx.session ? new ApiError(500) : {
109
username: getCurrentUsername(ctx),
110
+ adminUrl: ctxAdminAccess(ctx) ? ADMIN_URI : undefined,
111
...makeExp(),
109
- admin_port: ctx.state.admin ? undefined : getListeningAdminPort()
112
}
113
}
114
@@ -117,10 +119,3 @@ export const change_password: ApiHandler = async ({ newPassword }, ctx) => {
119
export const change_srp: ApiHandler = async ({ salt, verifier }, ctx) => {
120
return changeSrpHelper(ctx.state.account, salt, verifier)
121
}
120
-
121
-/*
122
-export const admin_port: ApiHandler = async ({}, ctx) => {
123
- return ctx.state.admin ? { admin_port: getListeningAdminPort() }
124
- : new ApiError(403)
125
-}
126
-*/
server/src/const.ts
+1
@@ -13,6 +13,7 @@ export const SESSION_DURATION = 30*60_000
13
14
export const SPECIAL_URI = '/~/'
15
export const FRONTEND_URI = SPECIAL_URI + 'frontend/'
16
+export const ADMIN_URI = SPECIAL_URI + 'admin/'
17
export const API_URI = SPECIAL_URI + 'api/'
18
export const PLUGINS_PUB_URI = SPECIAL_URI + 'plugins/'
19
server/src/index.ts
+7
-18
@@ -8,33 +8,22 @@ import { frontEndApis } from './frontEndApis'
8
import { log } from './log'
9
import { pluginsMiddleware } from './plugins'
10
import { throttler } from './throttler'
11
-import { headRequests, gzipper, sessions, frontendAndSharedFiles, someSecurity, prepareState } from './middlewares'
11
+import { headRequests, gzipper, sessions, serveGuiAndSharedFiles, someSecurity, prepareState } from './middlewares'
12
import './listen'
13
-import { serveAdminFiles } from './serveFrontend'
13
import { adminApis } from './adminApis'
14
15
const keys = ['hfs-keys-test']
17
-
18
-export const adminApp = new Koa({ keys })
19
-adminApp.use(someSecurity)
20
- .use(sessions(adminApp))
21
- .use(prepareState(true))
22
- .use(gzipper)
23
- .use(mount(API_URI, apiMiddleware(adminApis)))
24
- .use(serveAdminFiles)
25
- .on('error', errorHandler)
26
-
27
-export const frontendApp = new Koa({ keys })
28
-frontendApp.use(someSecurity)
29
- .use(sessions(frontendApp))
30
- .use(prepareState())
16
+export const app = new Koa({ keys })
17
+app.use(someSecurity)
18
+ .use(sessions(app))
19
+ .use(prepareState)
20
.use(headRequests)
21
.use(log())
22
.use(pluginsMiddleware())
23
.use(throttler())
24
.use(gzipper)
36
- .use(mount(API_URI, apiMiddleware(frontEndApis)))
37
- .use(frontendAndSharedFiles)
25
+ .use(mount(API_URI, apiMiddleware({ ...frontEndApis, ...adminApis })))
26
+ .use(serveGuiAndSharedFiles)
27
.on('error', errorHandler)
28
29
function errorHandler(err:Error & { code:string, path:string }) {
server/src/listen.ts
+6
-32
@@ -2,56 +2,34 @@
2
3
import * as http from 'http'
4
import { defineConfig, getConfig, subscribeConfig } from './config'
5
-import { adminApp, frontendApp } from './index'
5
+import { app } from './index'
6
import * as https from 'https'
7
import { watchLoad } from './watchLoad'
8
import { networkInterfaces } from 'os';
9
import { newConnection } from './connections'
10
import open from 'open'
11
-import { debounceAsync, prefix } from './misc'
12
-import { DEV } from './const'
11
+import { prefix } from './misc'
12
+import { ADMIN_URI, DEV } from './const'
13
import findProcess from 'find-process'
14
import _ from 'lodash'
15
16
interface ServerExtra { error?: string, busy?: string }
17
let httpSrv: http.Server & ServerExtra
18
let httpsSrv: http.Server & ServerExtra
19
-let adminSrv: http.Server & ServerExtra
19
20
subscribeConfig<number>({ k:'port', defaultValue: 80 }, async port => {
21
await stopServer(httpSrv)
23
- httpSrv = http.createServer(frontendApp.callback())
22
+ httpSrv = http.createServer(app.callback())
23
port = await startServer(httpSrv, { port, name:'http' })
24
if (!port) return
25
httpSrv.on('connection', newConnection)
26
printUrls(port, 'http')
28
-})
29
-
30
-const considerAdmin = debounceAsync(async () => {
31
- const port = getConfig('admin_port')
32
- const net = getConfig('admin_network')
33
- const ad = adminSrv?.address()
34
- if (ad && typeof ad !== 'string'
35
- && ad.port === port && ad.address === net) return
36
- await stopServer(adminSrv)
37
- adminSrv = http.createServer(adminApp.callback())
38
- const resultPort = await startServer(adminSrv, {
39
- port ,
40
- name: 'admin',
41
- net,
42
- })
43
- if (!resultPort)
44
- return
27
if (getConfig('open_browser_at_start'))
46
- open('http://localhost:' + resultPort).then()
47
- console.log('admin interface on http://localhost:' + resultPort)
28
+ open('http://localhost' + (port === 80 ? '' : ':' + port) + ADMIN_URI).then()
29
})
30
31
defineConfig('open_browser_at_start', { defaultValue: !DEV })
32
52
-subscribeConfig<string>({ k:'admin_network', defaultValue: '127.0.0.1' }, considerAdmin)
53
-subscribeConfig<number>({ k:'admin_port', defaultValue: 63636 }, considerAdmin)
54
-
33
const httpsNeeds = { cert:'', private_key:'' }
34
const httpsNeedsNames = { cert: 'certificate', private_key: 'private key' }
35
for (const k of Object.keys(httpsNeeds) as (keyof typeof httpsNeeds)[]) { // please be smarter typescript
@@ -78,7 +56,7 @@ async function considerHttps() {
56
await stopServer(httpsSrv)
57
let port = getConfig('https_port')
58
try {
81
- httpsSrv = https.createServer({ key: httpsNeeds.private_key, cert: httpsNeeds.cert }, frontendApp.callback())
59
+ httpsSrv = https.createServer({ key: httpsNeeds.private_key, cert: httpsNeeds.cert }, app.callback())
60
const missingKey = _.findKey(httpsNeeds, v => !v) as keyof typeof httpsNeeds
61
httpsSrv.error = port < 0 ? undefined
62
: missingKey && prefix(getConfig(missingKey) ? "cannot read file for " : "missing ", httpsNeedsNames[missingKey])
@@ -174,7 +152,3 @@ function printUrls(port: number, proto: string) {
152
}
153
}
154
}
177
-
178
-export function getListeningAdminPort() {
179
- return (adminSrv?.address() as any)?.port as number | undefined
180
-}
server/src/middlewares.ts
+15
-15
@@ -3,14 +3,14 @@
3
import compress from 'koa-compress'
4
import Koa from 'koa'
5
import session from 'koa-session'
6
-import { BUILD_TIMESTAMP, SESSION_DURATION } from './const'
6
+import { ADMIN_URI, BUILD_TIMESTAMP, SESSION_DURATION } from './const'
7
import Application from 'koa'
8
import { FRONTEND_URI } from './const'
9
import { cantReadStatusCode, hasPermission, urlToNode } from './vfs'
10
import { dirTraversal, isDirectory } from './misc'
11
import { zipStreamFromFolder } from './zip'
12
import { serveFileNode } from './serveFile'
13
-import { serveFrontend } from './serveFrontend'
13
+import { serveGuiFiles } from './serveGuiFiles'
14
import mount from 'koa-mount'
15
import { Readable } from 'stream'
16
import { getAccount, getCurrentUsername, getCurrentUsernameExpanded } from './perm'
@@ -49,15 +49,20 @@ export const sessions = (app: Application) => session({
49
maxAge: SESSION_DURATION,
50
}, app)
51
52
-// serve shared files and front-end files
53
-const serveFrontendPrefixed = mount(FRONTEND_URI.slice(0,-1), serveFrontend)
52
+const serveFrontendFiles = serveGuiFiles(process.env.FRONTEND_PROXY, FRONTEND_URI)
53
+const serveFrontendPrefixed = mount(FRONTEND_URI.slice(0,-1), serveFrontendFiles)
54
+const serveAdminPrefixed = mount(ADMIN_URI.slice(0,-1), serveGuiFiles(process.env.ADMIN_PROXY, ADMIN_URI))
55
55
-export const frontendAndSharedFiles: Koa.Middleware = async (ctx, next) => {
56
+export const serveGuiAndSharedFiles: Koa.Middleware = async (ctx, next) => {
57
const { path } = ctx
58
if (ctx.body)
59
return next()
60
if (path.startsWith(FRONTEND_URI))
61
return serveFrontendPrefixed(ctx,next)
62
+ if (path+'/' === ADMIN_URI)
63
+ return ctx.redirect(ADMIN_URI)
64
+ if (path.startsWith(ADMIN_URI))
65
+ return serveAdminPrefixed(ctx,next)
66
const node = await urlToNode(path, ctx)
67
if (!node)
68
return next()
@@ -77,7 +82,7 @@ export const frontendAndSharedFiles: Koa.Middleware = async (ctx, next) => {
82
: ctx.status = cantReadStatusCode(def)
83
}
84
ctx.set({ server:'HFS '+BUILD_TIMESTAMP })
80
- return serveFrontend(ctx, next)
85
+ return serveFrontendFiles(ctx, next)
86
}
87
if (source)
88
return serveFileNode(node)(ctx,next)
@@ -107,13 +112,8 @@ function applyBlock(socket: Socket) {
112
return socket.destroy()
113
}
114
110
-export function prepareState(admin=false): Koa.Middleware {
111
- return async (ctx, next) => {
112
- ctx.state.usernames = getCurrentUsernameExpanded(ctx) // accounts chained via .belongs for permissions check
113
- ctx.state.account = getAccount(getCurrentUsername(ctx))
114
- ctx.state.admin = admin
115
- if (admin)
116
- ctx.state.accountIsAdmin = ctx.state.usernames.some((u:string) => getAccount(u)?.admin)
117
- await next()
118
- }
115
+export const prepareState: Koa.Middleware = async (ctx, next) => {
116
+ ctx.state.usernames = getCurrentUsernameExpanded(ctx) // accounts chained via .belongs for permissions check
117
+ ctx.state.account = getAccount(getCurrentUsername(ctx))
118
+ await next()
119
}
server/src/serveFrontend.ts
deleted
-109
@@ -1,109 +0,0 @@
1
-// This file is part of HFS - Copyright 2020-2021, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
-
3
-import Koa from 'koa'
4
-import fs from 'fs/promises'
5
-import { FRONTEND_URI, METHOD_NOT_ALLOWED, NO_CONTENT, PLUGINS_PUB_URI } from './const'
6
-import { serveFile } from './serveFile'
7
-import { mapPlugins } from './plugins'
8
-import { refresh_session } from './api.auth'
9
-import { ApiError } from './apiMiddleware'
10
-import path from 'path'
11
-
12
-function serveProxyFrontend(port?: string) { // used for development
13
- if (!port)
14
- return
15
- console.debug('fronted: proxied')
16
- let proxy: Koa.Middleware
17
- import('koa-better-http-proxy').then(lib =>
18
- proxy = lib.default('localhost:'+port, {
19
- filter: ctx => ctx.method === 'GET' || (ctx.status = METHOD_NOT_ALLOWED) && false,
20
- proxyReqPathResolver: (ctx) => ctx.path.endsWith('/') ? '/' : ctx.path,
21
- userResDecorator(res, data, ctx) {
22
- return ctx.url.endsWith('/') ? treatIndex(ctx, data.toString('utf8'))
23
- : data
24
- }
25
- })
26
- )
27
- return function() { //@ts-ignore
28
- return proxy.apply(this,arguments)
29
- }
30
-}
31
-
32
-// in case of dev env we have our static files within the 'dist' folder'
33
-const DEV_STATIC = process.env.DEV ? '../dist/' : ''
34
-
35
-const serveStaticFrontend : Koa.Middleware = async (ctx, next) => {
36
- const isDir = ctx.path.endsWith('/')
37
- const fullPath = path.join(__dirname, '..', DEV_STATIC, 'frontend', isDir ? '/index.html' : ctx.path)
38
- if (ctx.method === 'OPTIONS') {
39
- ctx.status = NO_CONTENT
40
- ctx.set({ Allow: 'OPTIONS, GET' })
41
- return
42
- }
43
- if (ctx.method !== 'GET')
44
- return ctx.status = METHOD_NOT_ALLOWED
45
- if (!isDir) {
46
- const modifier = fullPath.includes('static/js') ? // webpack
47
- (s:string) => s.replace(/(return")(static\/)/g, '$1' + FRONTEND_URI.substring(1) + '$2')
48
- : undefined
49
- return serveFile(fullPath, 'auto', modifier)(ctx, next)
50
- }
51
- await serveIndex(ctx, fullPath)
52
- await next()
53
-}
54
-
55
-async function serveIndex(ctx: Koa.Context, fullPath: string) {
56
- // we don't cache the index as it's small and may prevent plugins change to apply
57
- ctx.body = await treatIndex(ctx, String(await fs.readFile(fullPath)))
58
- ctx.type = 'html'
59
- ctx.set('Cache-Control', 'no-store, no-cache, must-revalidate')
60
-}
61
-
62
-function serveProxyAdmin(port?: string) { // used for development
63
- if (!port)
64
- return
65
- console.debug('admin: proxied')
66
- let proxy: Koa.Middleware
67
- import('koa-better-http-proxy').then(lib =>
68
- proxy = lib.default('localhost:'+port, {
69
- userResDecorator(res, data, ctx) {
70
- return !ctx.path.includes('.') ? treatIndex(ctx, data.toString('utf8'))
71
- : data
72
- }
73
- }) )
74
- return function() { //@ts-ignore
75
- return proxy.apply(this,arguments)
76
- }
77
-}
78
-
79
-const serveStaticAdmin : Koa.Middleware = async (ctx, next) => {
80
- const index = !ctx.path.includes('.')
81
- const fullPath = path.join(__dirname, '..', DEV_STATIC, 'admin', index ? '/index.html' : ctx.path)
82
- return index ? await serveIndex(ctx, fullPath)
83
- : serveFile(fullPath, 'auto')(ctx, next)
84
-}
85
-
86
-async function treatIndex(ctx: Koa.Context, body: string) {
87
- const session = await refresh_session({}, ctx)
88
- ctx.set('etag', '')
89
- return body
90
- .replace(ctx.state.admin ? /^NEVER$/ : /((?:src|href) *= *['"])\/?(?![a-z]+:\/\/)/g, '$1' + FRONTEND_URI)
91
- .replace('_HFS_SESSION_', session instanceof ApiError ? 'null' : JSON.stringify(session))
92
- // replacing this text allow us to avoid injecting in frontends that don't support plugins. Don't use a <--comment--> or it will be removed by webpack
93
- .replace('_HFS_PLUGINS_', pluginsInjection)
94
-}
95
-
96
-function pluginsInjection() {
97
- const css = mapPlugins((plug,k) =>
98
- plug.frontend_css?.map(f => PLUGINS_PUB_URI + k + '/' + f)).flat().filter(Boolean)
99
- const js = mapPlugins((plug,k) =>
100
- plug.frontend_js?.map(f => PLUGINS_PUB_URI + k + '/' + f)).flat().filter(Boolean)
101
- return css.map(uri => `\n<link rel='stylesheet' type='text/css' href='${uri}'/>`).join('')
102
- + js.map(uri => `\n<script defer src='${uri}'></script>`).join('')
103
-}
104
-
105
-export const serveAdminFiles = serveProxyAdmin(process.env.ADMIN_PROXY)
106
- || serveStaticAdmin
107
-
108
-export const serveFrontend = serveProxyFrontend(process.env.FRONTEND_PROXY)
109
- || serveStaticFrontend
server/src/serveGuiFiles.ts
new
+83
@@ -0,0 +1,83 @@
1
+// This file is part of HFS - Copyright 2021-2022, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
+
3
+import Koa from 'koa'
4
+import fs from 'fs/promises'
5
+import { METHOD_NOT_ALLOWED, NO_CONTENT, PLUGINS_PUB_URI } from './const'
6
+import { serveFile } from './serveFile'
7
+import { mapPlugins } from './plugins'
8
+import { refresh_session } from './api.auth'
9
+import { ApiError } from './apiMiddleware'
10
+import path from 'path'
11
+
12
+// in case of dev env we have our static files within the 'dist' folder'
13
+const DEV_STATIC = process.env.DEV ? '../dist/' : ''
14
+
15
+function serveStatic(uri: string): Koa.Middleware {
16
+ const folder = uri.slice(2,-1) // we know folder is very similar to uri
17
+ return async (ctx, next) => {
18
+ const isDir = ctx.path.endsWith('/')
19
+ const fullPath = path.join(__dirname, '..', DEV_STATIC, folder, isDir? '/index.html': ctx.path)
20
+ if(ctx.method === 'OPTIONS') {
21
+ ctx.status = NO_CONTENT
22
+ ctx.set({ Allow: 'OPTIONS, GET' })
23
+ return
24
+ }
25
+ if (ctx.method !== 'GET')
26
+ return ctx.status = METHOD_NOT_ALLOWED
27
+ if (!isDir)
28
+ return serveFile(fullPath, 'auto', getModifier(ctx.path, uri))(ctx, next)
29
+ // we don't cache the index as it's small and may prevent plugins change to apply
30
+ ctx.body = await treatIndex(ctx, String(await fs.readFile(fullPath)), uri)
31
+ ctx.type = 'html'
32
+ ctx.set('Cache-Control', 'no-store, no-cache, must-revalidate')
33
+ }
34
+}
35
+
36
+function getModifier(path: string, uri: string) {
37
+ return path.startsWith('/static/js') ? // webpack
38
+ (s: string) => s.replace(/(")(static\/)/g, '$1' + uri.substring(1) + '$2')
39
+ : undefined
40
+}
41
+
42
+async function treatIndex(ctx: Koa.Context, body: string, filesUri: string) {
43
+ const session = await refresh_session({}, ctx)
44
+ ctx.set('etag', '')
45
+ return body
46
+ .replace(/((?:src|href) *= *['"])\/?(?![a-z]+:\/\/)/g, '$1' + filesUri)
47
+ .replace('_HFS_SESSION_', session instanceof ApiError ? 'null' : JSON.stringify(session))
48
+ // replacing this text allow us to avoid injecting in frontends that don't support plugins. Don't use a <--comment--> or it will be removed by webpack
49
+ .replace('_HFS_PLUGINS_', pluginsInjection)
50
+}
51
+
52
+function serveProxied(port: string | undefined, uri: string) { // used for development
53
+ if (!port)
54
+ return
55
+ console.debug('proxied on port', port)
56
+ let proxy: Koa.Middleware
57
+ import('koa-better-http-proxy').then(lib =>
58
+ proxy = lib.default('localhost:'+port, {
59
+ proxyReqPathResolver: (ctx) => ctx.path.endsWith('/') ? '/' : ctx.path,
60
+ userResDecorator(res, data, ctx) {
61
+ if (!ctx.path.includes('.'))
62
+ return treatIndex(ctx, String(data), uri)
63
+ const mod = getModifier(ctx.path, uri)
64
+ return mod ? mod(String(data)) : data
65
+ }
66
+ }) )
67
+ return function() { //@ts-ignore
68
+ return proxy.apply(this,arguments)
69
+ }
70
+}
71
+
72
+function pluginsInjection() {
73
+ const css = mapPlugins((plug,k) =>
74
+ plug.frontend_css?.map(f => PLUGINS_PUB_URI + k + '/' + f)).flat().filter(Boolean)
75
+ const js = mapPlugins((plug,k) =>
76
+ plug.frontend_js?.map(f => PLUGINS_PUB_URI + k + '/' + f)).flat().filter(Boolean)
77
+ return css.map(uri => `\n<link rel='stylesheet' type='text/css' href='${uri}'/>`).join('')
78
+ + js.map(uri => `\n<script defer src='${uri}'></script>`).join('')
79
+}
80
+
81
+export function serveGuiFiles(proxyPort:string | undefined, uri:string) {
82
+ return serveProxied(proxyPort, uri) || serveStatic(uri)
83
+}
todo.md
+1
-2
@@ -1,6 +1,4 @@
1
# To do
2
-- expose admin on frontend's same port
3
-- admin: improve masks editor
2
- if specified config is a folder, check for file config.yaml inside
3
- merge accounts in config
4
- frontend: ok button to inputDialogs
@@ -18,6 +16,7 @@
16
- config.proxies:number (will enable koa.proxy:true + maxIpsCount, default 0)
17
- log filter option
18
- log filter plugin
19
+- admin: improve masks editor
20
- publish to npm (so people can "npm install hfs")
21
- frontend search supporting masks
22
- remove seconds from time