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