dx: sync nodeIsDirectory, and vfsNode.parent always present
Massimo Melina committed
Sep 5, 2025 at 11:00 UTC
09c5f4cf2d76a30c29713fed70c44938e6aec604
19 files changed
+141
-149
admin/src/HomePage.ts
+1
-1
@@ -43,7 +43,7 @@ export default function HomePage() {
43
const goSecure = !http?.listening && https?.listening ? 's' : ''
44
const srv = goSecure ? https : (http?.listening && http)
45
const href = srv && `http${goSecure}://`+window.location.hostname + (srv.port === (goSecure ? 443 : 80) ? '' : ':'+srv.port)
46
- const serverErrors = objSameKeys(_.pick(status, ['http', 'https']), v =>
46
+ const serverErrors = objSameKeys({ http, https }, v =>
47
v.busy ? [`port ${v.configuredPort} already used by ${v.busy}${SOLUTION_SEP}choose a `, cfgLink('different port'), ` or stop ${v.busy}`]
48
: v.error )
49
const errors = serverErrors && onlyTruthy(Object.entries(serverErrors).map(([k,v]) =>
src/acme.ts
+1
-1
@@ -42,7 +42,7 @@ repeat(MINUTE, async stop => {
42
})
43
44
async function generateSSLCert(domain: string, email?: string, altNames?: string[]) {
45
- // will answer challenge through our koa app (if on port 80) or must we spawn a dedicated server?
45
+ // will answer the challenge through our koa app (if on port 80) or must we spawn a dedicated server?
46
const nat = await getNatInfo()
47
const { http } = await getServerStatus()
48
const tempSrv = nat.externalPort === 80 || http.listening && http.port === 80 ? undefined
src/adminApis.ts
+1
-1
@@ -51,7 +51,7 @@ export const adminApis = {
51
52
async set_config({ values }) {
53
apiAssertTypes({ object: { values } })
54
- setConfig(values)
54
+ await setConfig(values)
55
if (values.port === 0 || values.https_port === 0)
56
return await waitFor(async () => {
57
const st = await getServerStatus()
src/api.cert.ts
+1
-1
@@ -33,7 +33,7 @@ export default {
33
const configs = { cert: fileName + '.cer', private_key: fileName + '.key' }
34
await writeFile(configs.private_key, ret.private_key)
35
await writeFile(configs.cert, ret.cert)
36
- setConfig(configs)
36
+ await setConfig(configs)
37
return configs
38
}
39
src/api.get_file_list.ts
+4
-4
@@ -1,7 +1,7 @@
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 {
4
- applyParentToChild, getNodeName, hasDefaultFile, hasPermission, masksCouldGivePermission, nodeIsDirectory,
4
+ applyParentToChild, getNodeName, hasDefaultFile, hasPermission, masksCouldGivePermission, nodeIsFolder,
5
statusCodeForMissingPerm, urlToNode, VfsNode, walkNode
6
} from './vfs'
7
import { ApiError, ApiHandler } from './apiMiddleware'
@@ -36,7 +36,7 @@ export const get_file_list: ApiHandler = async ({ uri='/', offset, limit, c, onl
36
if (!node)
37
return fail(HTTP_NOT_FOUND)
38
admin &&= ctxAdminAccess(ctx) // validate 'admin' flag
39
- if (await hasDefaultFile(node, ctx) || !await nodeIsDirectory(node)) // in case of files without permission, we are provided with the frontend, and the location is the file itself
39
+ if (await hasDefaultFile(node, ctx) || !nodeIsFolder(node)) // in case of files without permission, we are provided with the frontend, and the location is the file itself
40
// so, we first check if you have a permission problem, to tell frontend to show login, otherwise we fall back to method_not_allowed, as it's proper for files.
41
return fail(!admin && statusCodeForMissingPerm(node, 'can_read', ctx) ? undefined : HTTP_METHOD_NOT_ALLOWED)
42
if (!admin && statusCodeForMissingPerm(node, 'can_list', ctx))
@@ -78,7 +78,7 @@ export const get_file_list: ApiHandler = async ({ uri='/', offset, limit, c, onl
78
for await (const sub of walker) {
79
let name = getNodeName(sub)
80
name = basename(name) || name // on windows, basename('C:') === ''
81
- if (filterName && !filterName(name) || fileMask && !await nodeIsDirectory(sub) && !fileMask(name)
81
+ if (filterName && !filterName(name) || fileMask && !nodeIsFolder(sub) && !fileMask(name)
82
|| filterComment && !filterComment(await getCommentFor(sub.source) || ''))
83
continue
84
const entry = await nodeToDirEntry(ctx, sub)
@@ -114,7 +114,7 @@ export const get_file_list: ApiHandler = async ({ uri='/', offset, limit, c, onl
114
const name = getNodeName(node)
115
if (url)
116
return name ? { n: name, url, target: node.target } : null
117
- const isFolder = await nodeIsDirectory(node)
117
+ const isFolder = nodeIsFolder(node)
118
try {
119
const st = source ? node.stats || await stat(source).catch(e => {
120
if (!isFolder || !node.children?.length) // folders with virtual children, keep them
src/api.vfs.ts
+10
-9
@@ -1,7 +1,7 @@
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 {
4
- getNodeName, isSameFilenameAs, nodeIsDirectory, saveVfs, urlToNode, vfs, VfsNode, applyParentToChild,
4
+ getNodeName, isSameFilenameAs, nodeIsFolder, saveVfs, urlToNode, vfs, VfsNode, applyParentToChild,
5
permsFromParent, nodeIsLink, VfsNodeStored, isRoot
6
} from './vfs'
7
import _ from 'lodash'
@@ -87,7 +87,7 @@ const apis: ApiHandlers = {
87
if (_.isEmpty(oldParent!.children))
88
delete oldParent!.children
89
;(parentNode.children ||= []).push(fromNode)
90
- saveVfs()
90
+ await saveVfs()
91
return {}
92
},
93
@@ -106,7 +106,8 @@ const apis: ApiHandlers = {
106
delete props.masks
107
Object.assign(n, pickProps(props, ALLOWED_KEYS))
108
simplifyName(n)
109
- saveVfs()
109
+ n.isFolder = undefined // reset field, it will be set by saveVfs
110
+ await saveVfs()
111
return n
112
},
113
@@ -118,12 +119,12 @@ const apis: ApiHandlers = {
119
const parentNode = parent ? await urlToNodeOriginal(parent) : vfs
120
if (!parentNode)
121
return new ApiError(HTTP_NOT_FOUND, 'parent not found')
121
- if (!await nodeIsDirectory(parentNode))
122
+ if (!nodeIsFolder(parentNode))
123
return new ApiError(HTTP_NOT_ACCEPTABLE, 'parent not a folder')
124
if (isWindowsDrive(source))
125
source += '\\' // slash must be included, otherwise it will refer to the cwd of that drive
125
- const isDir = source && await isDirectory(source)
126
- if (source && isDir === undefined)
126
+ const isFolder = source && await isDirectory(source)
127
+ if (source && isFolder === undefined)
128
return new ApiError(HTTP_NOT_FOUND, 'source not found')
129
const child = { source, name, ...pickProps(rest, ALLOWED_KEYS) }
130
name = getNodeName(child) // could be not given as input
@@ -135,11 +136,11 @@ const apis: ApiHandlers = {
136
child.name = name
137
simplifyName(child)
138
;(parentNode.children ||= []).unshift(child)
138
- saveVfs()
139
+ await saveVfs()
140
const link = rest.url ? undefined : await getBaseUrlOrDefault()
141
+ (parent ? enforceStarting('/', enforceFinal('/', parent)) : '/')
142
+ encodeURIComponent(getNodeName(child))
142
- + (isDir ? '/' : '')
143
+ + (isFolder ? '/' : '')
144
return { name, link }
145
},
146
@@ -164,7 +165,7 @@ const apis: ApiHandlers = {
165
return HTTP_SERVER_ERROR
166
const idx = children.indexOf(node)
167
children.splice(idx, 1)
167
- saveVfs()
168
+ await saveVfs()
169
return 0 // error code 0 is OK
170
}))
171
}
src/basicWeb.ts
+2
-2
@@ -2,7 +2,7 @@ import { getCurrentUsername, setLoggedIn } from './auth'
2
import { HTTP_UNAUTHORIZED } from './cross-const'
3
import Koa from 'koa'
4
import { defineConfig } from './config'
5
-import { getNodeName, hasDefaultFile, nodeIsDirectory, VfsNode, walkNode } from './vfs'
5
+import { getNodeName, hasDefaultFile, nodeIsFolder, VfsNode, walkNode } from './vfs'
6
import { asyncGeneratorToReadable, Dict, filterMapGenerator, pathEncode } from './misc'
7
import _ from 'lodash'
8
import { title } from './adminApis'
@@ -34,7 +34,7 @@ export function basicWeb(ctx: Koa.Context, node: VfsNode) {
34
const force = forced ? '?get=basic' : ''
35
const walker = walkNode(node, { ctx, depth: 0 })
36
const stream = asyncGeneratorToReadable(filterMapGenerator(walker, async el => {
37
- const isFolder = await nodeIsDirectory(el)
37
+ const isFolder = nodeIsFolder(el)
38
const name = getNodeName(el) + (isFolder ? '/' : '')
39
return `<li>${a(pathEncode(name) + (isFolder && !await hasDefaultFile(el, ctx) ? force : ''), name)}\n`
40
}))
src/commands.ts
+2
-2
@@ -80,13 +80,13 @@ const commands = {
80
},
81
config: {
82
params: '<key> <value>',
83
- cb(key: string, value: string) {
83
+ async cb(key: string, value: string) {
84
if (!configKeyExists(key))
85
throw "specified key doesn't exist"
86
let v: any = value
87
try { v = JSON.parse(v) }
88
catch {}
89
- setConfig({ [key]: v })
89
+ await setConfig({ [key]: v })
90
}
91
},
92
'get-config': {
src/config.ts
+39
-23
@@ -57,12 +57,11 @@ const CONFIG_CHANGE_EVENT_PREFIX = 'config.'
57
export const currentVersion = new Version(VERSION)
58
const configVersion = defineConfig('version', VERSION, v => new Version(v))
59
60
-type Subscriber<T,R=void> = (v:T, more: { was?: T, version?: Version, defaultValue: T, k: string }) => R
60
+type Subscriber<T,R=void> = (v:T, more: { was?: T, version?: Version, defaultValue: T, k: string, object: object, onlyCompileChanged?: true }) => R
61
export function defineConfig<T, CT=unknown>(k: string, defaultValue: T, compiler?: Subscriber<T,CT>) {
62
configProps[k] = { defaultValue }
63
type Updater = (currentValue:T) => T
64
- let compiled = compiler?.(defaultValue, { k, version: currentVersion, defaultValue })
65
- const ret = { // consider a Class
64
+ const object = { // consider a Class
65
key() {
66
return k
67
},
@@ -71,11 +70,11 @@ export function defineConfig<T, CT=unknown>(k: string, defaultValue: T, compiler
70
},
71
sub(cb: Subscriber<T>) {
72
if (started) // initial event already passed, we'll make the first call
74
- cb(getConfig(k), { k, was: defaultValue, defaultValue, version: configVersion.compiled() })
75
- return events.on(CONFIG_CHANGE_EVENT_PREFIX + k, (v, was, version) => {
73
+ cb(getConfig(k), { k, was: defaultValue, defaultValue, version: configVersion.compiled(), object })
74
+ return events.on(CONFIG_CHANGE_EVENT_PREFIX + k, (v, was, version, onlyCompileChanged) => {
75
if (stack.includes(cb)) return // avoid infinite loop in case a subscriber changes the value
76
stack.push(cb)
78
- try { return cb(v, { k, was, version, defaultValue }) }
77
+ try { cb(v, { k, was, version, defaultValue, object, onlyCompileChanged }) }
78
finally { stack.pop() }
79
}, { warnAfter: 1000 }) // e.g. each plugin watch enable_plugins
80
},
@@ -86,11 +85,19 @@ export function defineConfig<T, CT=unknown>(k: string, defaultValue: T, compiler
85
setConfig1(k, v)
86
},
87
compiled: () => (compiler ? compiled : throw_("missing compiler")) as CT,
88
+ setCompiled(v: CT) {
89
+ compiled = v
90
+ const was = getConfig(k)
91
+ return events.emitAsync(CONFIG_CHANGE_EVENT_PREFIX + k, was, was, VERSION, true)
92
+ }
93
}
94
+ let compiled = compiler?.(defaultValue, { k, version: currentVersion, defaultValue, object })
95
if (compiler)
91
- ret.sub((...args) =>
92
- compiled = compiler(...args) )
93
- return ret
96
+ object.sub((v, more) => {
97
+ if (!more.onlyCompileChanged)
98
+ compiled = compiler(v, more)
99
+ })
100
+ return object
101
}
102
103
export function configKeyExists(k: string) {
@@ -114,7 +121,7 @@ export function getWholeConfig({ omit, only }: { omit?:string[], only?:string[]
121
}
122
123
// pass a value to `save` to force saving decision, or leave undefined for auto. Passing false will also reset previously loaded configs.
117
-export function setConfig(newCfg: Record<string,unknown>, save?: boolean) {
124
+export async function setConfig(newCfg: Record<string,unknown>, save?: boolean) {
125
const version = _.isString(newCfg.version) ? new Version(newCfg.version) : undefined
126
const considerEnvs = !process.env['HFS_ENV_BOOTSTRAP'] || !started && _.isEmpty(newCfg)
127
// first time we consider also CLI args
@@ -125,26 +132,24 @@ export function setConfig(newCfg: Record<string,unknown>, save?: boolean) {
132
saveConfigAsap() // don't set `save` argument, as it would interfere below at check `save===false`
133
Object.assign(newCfg, argCfg)
134
}
128
- for (const k in newCfg)
129
- apply(k, newCfg[k])
135
+ await Promise.allSettled(Object.keys(newCfg).map(k =>
136
+ apply(k, newCfg[k])))
137
if (save) {
138
saveConfigAsap()
139
return
140
}
141
if (started) {
142
if (save === false) // false is used when loading whole config, and in such case we should not leave previous values untreated. Also, we need this only after we already `started`.
136
- for (const k of Object.keys(state))
137
- if (!newCfg.hasOwnProperty(k))
138
- apply(k, undefined)
143
+ await Promise.allSettled(Object.keys(state).map(k =>
144
+ newCfg.hasOwnProperty(k) || apply(k, undefined)))
145
return
146
}
147
// first time we emit also for the default values
142
- for (const k of Object.keys(configProps))
143
- if (!newCfg.hasOwnProperty(k))
144
- apply(k, newCfg[k], true)
148
+ await Promise.allSettled(Object.keys(configProps).map(k =>
149
+ newCfg.hasOwnProperty(k) || apply(k, undefined, true)))
150
started = true
151
events.emit('configReady', startedWithoutConfig)
147
- if (version !== VERSION) // be sure to save version
152
+ if (version?.valueOf() !== VERSION) // be sure to save version
153
saveConfigAsap()
154
155
function apply(k: string, newV: any, isDefault=false) {
@@ -152,7 +157,7 @@ export function setConfig(newCfg: Record<string,unknown>, save?: boolean) {
157
}
158
}
159
155
-function setConfig1(k: string, newV: unknown, saveChanges=true, valueVersion?: Version) {
160
+async function setConfig1(k: string, newV: unknown, saveChanges=true, valueVersion?: Version) {
161
if (_.isPlainObject(newV))
162
newV = _.pickBy(newV as any, x => x !== undefined)
163
const def = configProps[k]?.defaultValue
@@ -161,7 +166,7 @@ function setConfig1(k: string, newV: unknown, saveChanges=true, valueVersion?: V
166
if (started && same(newV, state[k])) return // no change
167
const was = getConfig(k) // include cloned default, if necessary
168
state[k] = newV
164
- events.emit(CONFIG_CHANGE_EVENT_PREFIX + k, getConfig(k), was, valueVersion)
169
+ await events.emitAsync(CONFIG_CHANGE_EVENT_PREFIX + k, getConfig(k), was, valueVersion, false)
170
if (saveChanges)
171
saveConfigAsap()
172
@@ -192,7 +197,7 @@ let startedWithoutConfig = false
197
console.log("config", filePath)
198
export const configFile = watchLoad(filePath, text => {
199
startedWithoutConfig = !text
195
- try { setConfig(yaml.parse(text, { uniqueKeys: false }) || {}, false) }
200
+ try { return setConfig(yaml.parse(text, { uniqueKeys: false }) || {}, false) }
201
catch(e: any) { console.error("Error in", filePath, ':', e.message || String(e)) }
202
}, {
203
failedOnFirstAttempt(){
@@ -203,8 +208,19 @@ export const configFile = watchLoad(filePath, text => {
208
}
209
})
210
211
+export function subMultipleConfigs(cb: () => any, configs: Array<ReturnType<typeof defineConfig>>) {
212
+ // we depend on multiple configs, so wait for all of them to be ready
213
+ const unsub_s = configReady.then(() =>
214
+ configs.map(x => x.sub(cb)) )
215
+ return async () => {
216
+ for (const x of await unsub_s)
217
+ x()
218
+ }
219
+}
220
+
221
export const showHelp = argv.help
207
-events.on('configReady', () => {
222
+export const configReady = events.once('configReady') // the boolean value means startedWithoutConfig
223
+configReady.then(() => {
224
if (!showHelp) return
225
console.log(`HELP
226
You can pass any configuration in the form: --name value
src/const.ts
+1
-1
@@ -28,7 +28,7 @@ export const HFS_STARTED = new Date()
28
const PKG_PATH = join(__dirname, '..', 'package.json')
29
export const BUILD_TIMESTAMP = fs.statSync(PKG_PATH).mtime.toISOString()
30
const pkg = JSON.parse(fs.readFileSync(PKG_PATH,'utf8'))
31
-export const VERSION = pkg.version
31
+export const VERSION = pkg.version as string
32
export const RUNNING_BETA = VERSION.includes('-')
33
export const IS_WINDOWS = process.platform === 'win32'
34
export const IS_MAC = process.platform === 'darwin'
src/events.ts
+3
-2
@@ -32,10 +32,11 @@ export class BetterEventEmitter {
32
}
33
}
34
}
35
- // call me when listeners for event have changed
35
+ // call me when listeners for the event have changed
36
onListeners(event: string, listener: Listener) {
37
return this.on(event + LISTENERS_SUFFIX, listener)
38
}
39
+ // returns the unsubscriber function, which is also a PromiseLike with the array of arguments received by the listener
40
once(event: string, listener?: Listener) {
41
let off: () => unknown
42
const pro = new Promise<any[]>(resolve => {
@@ -45,7 +46,7 @@ export class BetterEventEmitter {
46
return listener?.(...args)
47
})
48
})
48
- return Object.assign(off!, { then: pro.then.bind(pro) } satisfies PromiseLike<any> as Promise<any>)
49
+ return Object.assign(off!, { then: pro.then.bind(pro) } satisfies PromiseLike<any> as typeof pro)
50
}
51
multi(map: { [eventName: string]: Listener }) {
52
const cbs = Object.entries(map).map(([name, cb]) => this.on(name.split(' '), cb))
src/frontEndApis.ts
+2
-2
@@ -11,7 +11,7 @@ import {
11
HTTP_NOT_FOUND, HTTP_SERVER_ERROR, HTTP_UNAUTHORIZED
12
} from './const'
13
import {
14
- hasPermission, isRoot, nodeIsDirectory, nodeStats, statusCodeForMissingPerm, urlToNode, VfsNode, walkNode
14
+ hasPermission, isRoot, nodeIsFolder, nodeStats, statusCodeForMissingPerm, urlToNode, VfsNode, walkNode
15
} from './vfs'
16
import fs from 'fs'
17
import { mkdir, rename, copyFile, unlink } from 'fs/promises'
@@ -167,7 +167,7 @@ export const frontEndApis: ApiHandlers = {
167
const folder = await urlToNode(uri, ctx)
168
if (!folder)
169
throw new ApiError(HTTP_NOT_FOUND)
170
- if (!await nodeIsDirectory(folder))
170
+ if (!nodeIsFolder(folder))
171
throw new ApiError(HTTP_METHOD_NOT_ALLOWED)
172
if (statusCodeForMissingPerm(folder, 'can_list', ctx))
173
return new ApiError(ctx.status)
src/icons.ts
+2
-2
@@ -3,13 +3,13 @@ import { basename, extname, join } from 'path'
3
import { watchDir } from './util-files'
4
import { debounceAsync } from './debounceAsync'
5
import { readdir } from 'fs/promises'
6
-import events from './events'
6
+import { configReady } from './config'
7
8
export const ICONS_FOLDER = 'icons'
9
10
export type CustomizedIcons = undefined | Dict<string>
11
export let customizedIcons: CustomizedIcons
12
-events.once('configReady', () => { // wait for cwd to be defined
12
+configReady.then(() => { // wait for cwd to be defined
13
watchIconsFolder('.', v => customizedIcons = v)
14
})
15
export function watchIconsFolder(parentFolder: string, cb: Callback<CustomizedIcons>) {
src/listen.ts
+33
-45
@@ -1,7 +1,7 @@
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 * as http from 'http'
4
-import { defineConfig } from './config'
4
+import { defineConfig, subMultipleConfigs } from './config'
5
import { app } from './index'
6
import * as https from 'https'
7
import { watchLoad } from './watchLoad'
@@ -64,10 +64,9 @@ const considerHttp = debounceAsync(async () => {
64
openAdmin()
65
})
66
67
-export const portCfg = defineConfig<number>('port', 80)
67
+export const portCfg = defineConfig('port', 80)
68
const listenInterface = defineConfig('listen_interface', '')
69
-portCfg.sub(considerHttp)
70
-listenInterface.sub(considerHttp)
69
+subMultipleConfigs(considerHttp, [portCfg, listenInterface])
70
71
export function openAdmin() {
72
for (const srv of [httpSrv, httpsSrv]) {
@@ -90,8 +89,9 @@ export function openAdmin() {
89
}
90
91
export function getCertObject() {
93
- if (!httpsOptions.cert) return
94
- const all = new X509Certificate(httpsOptions.cert)
92
+ const c = cert.compiled()
93
+ if (!c) return
94
+ const all = new X509Certificate(c)
95
const some = _.pick(all, ['subject', 'issuer', 'validFrom', 'validTo'])
96
const ret = objSameKeys(some, v => v?.includes('=') ? Object.fromEntries(v.split('\n').map(x => x.split('='))) : v)
97
return Object.assign(ret, { altNames: all.subjectAltName?.replace(/DNS:/g, '').split(/, */) })
@@ -108,22 +108,22 @@ const considerHttps = debounceAsync(async () => {
108
httpsSrv = Object.assign(
109
https.createServer(port === PORT_DISABLED ? {} : {
110
...commonServerOptions,
111
- key: httpsOptions.private_key,
112
- cert: httpsOptions.cert,
111
+ key: privateKey.compiled(),
112
+ cert: cert.compiled(),
113
...moreOptions,
114
}, app.callback()),
115
{ name: 'https' },
116
commonServerAssign
117
)
118
if (port >= 0) {
119
- const cert = getCertObject()
120
- if (cert) {
121
- const cn = cert.subject?.CN
119
+ const certObj = getCertObject()
120
+ if (certObj) {
121
+ const cn = certObj.subject?.CN
122
if (cn)
123
- console.log("certificate loaded for", cert.altNames?.join(' + ') || cn)
123
+ console.log("certificate loaded for", certObj.altNames?.join(' + ') || cn)
124
const now = new Date()
125
- const from = new Date(cert.validFrom)
126
- const to = new Date(cert.validTo)
125
+ const from = new Date(certObj.validFrom)
126
+ const to = new Date(certObj.validTo)
127
updateError() // error will change at from and to dates of the certificate
128
const cancelTo = runAt(to.getTime(), updateError)
129
const cancelFrom = runAt(from.getTime(), updateError)
@@ -137,12 +137,11 @@ const considerHttps = debounceAsync(async () => {
137
}
138
}
139
const namesForOutput: any = { cert: 'certificate', private_key: 'private key' }
140
- const missing = httpsNeeds.find(x => !x.get())?.key()
141
- if (missing)
142
- return httpsSrv.error = "missing " + namesForOutput[missing]
143
- const cantRead = httpsNeeds.find(x => !httpsOptions[x.key() as HttpsKeys])?.key()
144
- if (cantRead)
145
- return httpsSrv.error = "cannot read " + namesForOutput[cantRead]
140
+ for (const x of httpsNeeds)
141
+ if (!x.get())
142
+ return httpsSrv.error = "missing " + namesForOutput[x.key()]
143
+ else if (!x.compiled())
144
+ return httpsSrv.error = "cannot read " + namesForOutput[x.key()]
145
}
146
}
147
catch(e: any) {
@@ -153,7 +152,7 @@ const considerHttps = debounceAsync(async () => {
152
}
153
httpsSrv.on('connection', newConnection) // this event is emitted as soon as the tcp layer is connected
154
httpsSrv.on('secureConnection', (socket: TLSSocket) => { // emitted when the TLS layer is connected
156
- for (const c of getConnections()) // TLSSocket shares same ip:port, so we can find its matching Connection
155
+ for (const c of getConnections()) // TLSSocket shares the same ip:port, so we can find its matching Connection
156
if (socket.remoteAddress === c.socket.remoteAddress
157
&& socket.remotePort === c.socket.remotePort)
158
return c.socket.emit('secure', socket) // let know Connection about the secure socket
@@ -166,32 +165,21 @@ const considerHttps = debounceAsync(async () => {
165
defaultBaseUrl.port = getCurrentPort(httpsSrv) ?? 0
166
}, { wait: 200 }) // give time to have key and cert ready
167
169
-export const cert = defineConfig('cert', '')
170
-export const privateKey = defineConfig('private_key', '')
168
+export const cert = defineConfig('cert', '' as string, load)
169
+export const privateKey = defineConfig('private_key', '' as string, load)
170
const httpsNeeds = [cert, privateKey]
172
-const httpsOptions = { cert: '', private_key: '' }
173
-type HttpsKeys = keyof typeof httpsOptions
174
-for (const cfg of httpsNeeds) {
175
- let unwatch: ReturnType<typeof watchLoad>['unwatch']
176
- cfg.sub(async v => {
177
- unwatch?.()
178
- const k = cfg.key() as HttpsKeys
179
- httpsOptions[k] = v
180
- if (!v || v.includes('\n'))
181
- return considerHttps()
182
- // v is a path
183
- httpsOptions[k] = ''
184
- unwatch = watchLoad(v, async data => {
185
- httpsOptions[k] = data
186
- await considerHttps()
187
- }, { immediateFirst: true }).unwatch
188
- await considerHttps()
189
- })
171
+
172
+function load(v: string, { object }: any) {
173
+ object.watcher?.unwatch()
174
+ if (!v || v.includes('\n'))
175
+ return v
176
+ // v is a path, we'll watch the file for changes
177
+ object.watcher = watchLoad(v, x => object.setCompiled(x), { immediateFirst: true })
178
+ return ''
179
}
180
181
export const httpsPortCfg = defineConfig('https_port', PORT_DISABLED)
193
-httpsPortCfg.sub(considerHttps)
194
-listenInterface.sub(considerHttps)
182
+subMultipleConfigs(considerHttps, [httpsPortCfg, listenInterface, ...httpsNeeds])
183
184
const genericInterfaceNames = {
185
'0.0.0.0': "any IPv4",
@@ -210,7 +198,7 @@ export function startServer(srv: typeof httpSrv, { port, host }: StartServer) {
198
try {
199
if (port === PORT_DISABLED)
200
return resolve(0)
213
- if (!host && !await testIpV4()) // !host means ipV4+6, and if v4 port alone is busy we won't be notified of the failure, so we'll first test it on its own
201
+ if (!host && !await testIpV4()) // !host means ipV4+6, and if v4 port alone is busy, we won't be notified of the failure, so we'll first test it on its own
202
throw srv.error
203
// from a few tests, this seems enough to support the expect-100 http/1.1 mechanism, at least with curl -T, not used by chrome|firefox anyway
204
srv.on('checkContinue', (req, res) => srv.emit('request', req, res))
@@ -256,7 +244,7 @@ export function startServer(srv: typeof httpSrv, { port, host }: StartServer) {
244
srv.busy = findProcess('port', port).then(
245
res => res?.map(x => prefix("Service", x.name === 'svchost.exe' && x.cmd.split(x.name)[1]?.trim()) || x.name).join(' + '),
246
() => '')
259
- if (code === 'EACCES' && port < 1024 && !srv.busy) // on Windows, when port is used by a service, we get EACCESS
247
+ if (code === 'EACCES' && port < 1024 && !srv.busy) // on Windows, when port is used by a service, we get EACCES
248
srv.error = `lacking permission on port ${port}, try with permission (${IS_WINDOWS ? 'administrator' : 'sudo'}) or port > 1024`
249
if (code === 'EADDRINUSE' || srv.busy)
250
srv.error = `port ${port} busy: ${await srv.busy || "unknown process"}`
src/outboundProxy.ts
+2
-3
@@ -1,8 +1,7 @@
1
-import { defineConfig } from './config'
1
+import { configReady, defineConfig } from './config'
2
import { parse } from 'node:url'
3
import { httpStream, httpString } from './util-http'
4
import { reg } from './util-os'
5
-import events from './events'
5
import { IS_WINDOWS } from './const'
6
import { CFG, prefix } from './cross'
7
@@ -21,7 +20,7 @@ const outboundProxy = defineConfig(CFG.outbound_proxy, '', v => {
20
}
21
})
22
24
-events.once('configReady', async startedWithoutConfig => {
23
+configReady.then(async ([startedWithoutConfig]) => {
24
if (!IS_WINDOWS || !startedWithoutConfig) return
25
// try to read Windows system setting for proxy
26
const out = await reg('query', 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings')
src/plugins.ts
+6
-10
@@ -13,7 +13,7 @@ import {
13
PendingPromise, pendingPromise, Promisable, same, tryJson, wait, waitFor, wantArray, watchDir, objFromKeys, patchKey
14
} from './misc'
15
import * as misc from './misc'
16
-import { defineConfig, getConfig } from './config'
16
+import { defineConfig, getConfig, subMultipleConfigs } from './config'
17
import { DirEntry } from './api.get_file_list'
18
import { VfsNode } from './vfs'
19
import { serveFile } from './serveFile'
@@ -436,17 +436,14 @@ function watchPlugin(id: string, path: string) {
436
console.debug('plugin watch', id)
437
const module = resolve(path)
438
let starting: PendingPromise | undefined
439
- const unsub = enablePlugins.sub(() => getPluginInfo(id) && considerStart()) // only after it has been loaded
440
- const unsub2 = suspendPlugins.sub(() => getPluginInfo(id) && considerStart())
441
- function considerStart() {
439
+ const unsub = subMultipleConfigs(() => {
440
+ if (!getPluginInfo(id)) return // not loaded yet
441
const should = isPluginEnabled(id, true)
442
if (should === isPluginRunning(id)) return
444
- if (should) {
445
- start()
446
- return true
447
- }
443
+ if (should)
444
+ return start()
445
stop()
449
- }
446
+ }, [enablePlugins, suspendPlugins])
447
const { unwatch } = watchLoad(module, async source => {
448
const notRunning = availablePlugins[id]
449
if (!source)
@@ -461,7 +458,6 @@ function watchPlugin(id: string, path: string) {
458
return () => {
459
console.debug('plugin unwatch', id)
460
unsub()
464
- unsub2()
461
unwatch()
462
return onUninstalled()
463
}
src/serveGuiAndSharedFiles.ts
+3
-3
@@ -1,6 +1,6 @@
1
import Koa from 'koa'
2
import { basename, dirname, join } from 'path'
3
-import { getNodeName, nodeIsDirectory, statusCodeForMissingPerm, urlToNode, vfs, VfsNode, walkNode } from './vfs'
3
+import { getNodeName, nodeIsFolder, statusCodeForMissingPerm, urlToNode, vfs, VfsNode, walkNode } from './vfs'
4
import { sendErrorPage } from './errorPages'
5
import events from './events'
6
import {
@@ -139,7 +139,7 @@ export const serveGuiAndSharedFiles: Koa.Middleware = async (ctx, next) => {
139
}
140
if (get === 'icon')
141
return serveFile(ctx, node.icon || '|') // pipe to cause not-found
142
- if (!await nodeIsDirectory(node))
142
+ if (!nodeIsFolder(node))
143
return node.url ? ctx.redirect(node.url)
144
: !node.source ? sendErrorPage(ctx, HTTP_METHOD_NOT_ALLOWED) // !dir && !source is not supported at this moment
145
: !statusCodeForMissingPerm(node, 'can_read', ctx) ? serveFileNode(ctx, node) // all good
@@ -178,7 +178,7 @@ async function sendFolderList(node: VfsNode, ctx: Koa.Context) {
178
}
179
const walker = walkNode(node, { ctx, depth: depth === '*' ? Infinity : Number(depth), parallelizeRecursion: false })
180
ctx.body = asyncGeneratorToReadable(filterMapGenerator(walker, async el => {
181
- const isFolder = await nodeIsDirectory(el)
181
+ const isFolder = nodeIsFolder(el)
182
return !folders && isFolder ? undefined
183
: prepend + pathEncode(getNodeName(el)) + (isFolder ? '/' : '') + '\n'
184
}))
src/vfs.ts
+26
-35
@@ -42,7 +42,7 @@ export interface VfsNode extends VfsNodeStored { // include fields that are only
42
isTemp?: true // this node doesn't belong to the tree and was created by necessity
43
original?: VfsNode // if this is a temp node but reflecting an existing node
44
parent?: VfsNode // available when original is available
45
- isFolder?: boolean
45
+ isFolder?: boolean // use nodeIsFolder() instead of relying on this field
46
stats?: Stats
47
}
48
@@ -65,7 +65,9 @@ export function permsFromParent(parent: VfsNode, child: VfsNode) {
65
return _.isEmpty(ret) ? undefined : ret
66
}
67
68
-function inheritFromParent(parent: VfsNode, child: VfsNode) {
68
+function inheritFromParent(child: VfsNode) {
69
+ const { parent } = child
70
+ if (!parent) return
71
Object.assign(child, permsFromParent(parent, child))
72
if (typeof parent.mime === 'object' && typeof child.mime === 'object')
73
_.defaults(child.mime, parent.mime)
@@ -90,14 +92,14 @@ export async function applyParentToChild(child: VfsNode | undefined, parent: Vfs
92
const ret: VfsNode = {
93
original: child, // this can be overridden by passing an 'original' in `child`
94
...child,
93
- isFolder: child?.isFolder ?? (child?.children?.length! > 0 || undefined), // isFolder is hidden in original node, so we must read it to copy it
95
+ isFolder: child?.isFolder ?? (child?.children?.length! > 0 || undefined), // isFolder is hidden in original node, so we must copy it explicitly
96
isTemp: true,
97
parent,
98
}
99
name ||= child ? getNodeName(child) : ''
100
inheritMasks(ret, parent, name)
101
await parentMaskApplier(parent)(ret, name)
100
- inheritFromParent(parent, ret)
102
+ inheritFromParent(ret)
103
return ret
104
}
105
@@ -127,7 +129,7 @@ export async function urlToNode(url: string, ctx?: Koa.Context, parent: VfsNode=
129
const rest = ret.source.slice(parent.source!.length) // parent has source, otherwise !ret.source || ret.original
130
getRest(removeStarting('/', rest))
131
return parent
130
- }
132
+ }
133
return ret
134
}
135
@@ -169,21 +171,17 @@ export async function getNodeByName(name: string, parent: VfsNode) {
171
}
172
173
export let vfs: VfsNode = {}
172
-defineConfig<VfsNode>('vfs', {}).sub(data =>
173
- vfs = (function recur(node) {
174
- const {masks} = node
175
- _.each(masks, (v: any, mask) => { // legacy pre-0.56: convert from property to key suffix
176
- if (v.maskOnly) {
177
- masks![`${mask}|${v.maskOnly}|`] = v = _.omit(v, 'maskOnly')
178
- delete masks![mask]
179
- }
180
- recur(v)
181
- })
174
+defineConfig('vfs', vfs).sub(async data => {
175
+ await (async function recur(node) {
176
+ if (node.source && !node.children?.length && node.isFolder === undefined) {
177
+ const isFolder = /[\\/]$/.test(node.source) || (await nodeStats(node))?.isDirectory()
178
+ setHidden(node, { isFolder })
179
+ }
180
if (node.children)
183
- for (const c of node.children)
184
- recur(c)
185
- return node
186
- })(data) )
181
+ await Promise.allSettled(node.children.map(recur))
182
+ })(data)
183
+ vfs = data
184
+})
185
186
export function saveVfs() {
187
return setConfig({ vfs: _.cloneDeep(vfs) }, true)
@@ -213,20 +211,13 @@ export function getNodeName(node: VfsNode) {
211
return base
212
}
213
216
-export async function nodeIsDirectory(node: VfsNode) {
217
- if (node.isFolder !== undefined)
218
- return node.isFolder
219
- if (nodeIsLink(node))
220
- return false
221
- if (node.children?.length || !node.source)
222
- return true
223
- const isFolder = await nodeStats(node).then(x => x!.isDirectory(), () => false)
224
- setHidden(node, { isFolder }) // don't make it to the storage (a node.isTemp doesn't need it to be hidden)
225
- return isFolder
214
+export function nodeIsFolder(node: VfsNode) {
215
+ return node.isFolder ?? node.original?.isFolder
216
+ ?? (!nodeIsLink(node) && (node.children?.length! > 0 || !node.source))
217
}
218
219
export async function hasDefaultFile(node: VfsNode, ctx: Koa.Context) {
229
- return node.default && await nodeIsDirectory(node) && await urlToNode(node.default, ctx, node) || undefined
220
+ return node.default && nodeIsFolder(node) && await urlToNode(node.default, ctx, node) || undefined
221
}
222
223
export function nodeIsLink(node: VfsNode) {
@@ -314,12 +305,12 @@ export async function* walkNode(parent: VfsNode, {
305
const nodeName = getNodeName(child)
306
const name = prefixPath + nodeName
307
taken?.add(normalizeFilename(name))
317
- const item = { ...child, original: child, name }
308
+ const item = { ...child, original: child, name, parent }
309
if (await cantSee(item)) continue
310
if (item.source && !item.children?.length) // real items must be accessible, unless there's more to it
311
try { await fs.access(item.source) }
312
catch { continue }
322
- const isFolder = await nodeIsDirectory(child)
313
+ const isFolder = nodeIsFolder(child)
314
if (onlyFiles ? !isFolder : (!onlyFolders || isFolder))
315
stream.push(item)
316
if (!depth || !isFolder || cantRecur(item)) continue
@@ -355,7 +346,7 @@ export async function* walkNode(parent: VfsNode, {
346
if (taken?.has(normalizeFilename(name))) // taken by vfs node above
347
return false // false just in case it's a folder
348
358
- const item: VfsNode = { name, isFolder, source: join(source, path) }
349
+ const item: VfsNode = { name, isFolder, source: join(source, path), parent }
350
if (await cantSee(item)) // can't see: don't produce and don't recur
351
return false
352
if (onlyFiles ? !isFolder : (!onlyFolders || isFolder))
@@ -382,7 +373,7 @@ export async function* walkNode(parent: VfsNode, {
373
// item will be changed, so be sure to pass a temp node
374
async function cantSee(item: VfsNode) {
375
await maskApplier(item)
385
- inheritFromParent(parent, item)
376
+ inheritFromParent(item)
377
if (ctx && !hasPermission(item, 'can_see', ctx)) return true
378
item.isTemp = true
379
}
@@ -419,7 +410,7 @@ export function parentMaskApplier(parent: VfsNode) {
410
let isFolder: boolean | undefined = undefined
411
for (const { matcher, mods, mustBeFolder } of matchers) {
412
if (mustBeFolder !== undefined) {
422
- isFolder ??= await nodeIsDirectory(item)
413
+ isFolder ??= nodeIsFolder(item)
414
if (mustBeFolder !== isFolder) continue
415
}
416
if (!matcher(virtualBasename)) continue
src/zip.ts
+2
-2
@@ -1,6 +1,6 @@
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 { getNodeName, hasPermission, nodeIsDirectory, nodeIsLink, urlToNode, VfsNode, walkNode, statusCodeForMissingPerm } from './vfs'
3
+import { getNodeName, hasPermission, nodeIsFolder, nodeIsLink, urlToNode, VfsNode, walkNode, statusCodeForMissingPerm } from './vfs'
4
import Koa from 'koa'
5
import { filterMapGenerator, isWindowsDrive, safeDecodeURIComponent, wantArray } from './misc'
6
import { QuickZipStream } from './QuickZipStream'
@@ -30,7 +30,7 @@ export async function zipStreamFromFolder(node: VfsNode, ctx: Koa.Context) {
30
const subNode = await urlToNode(uri, ctx, node)
31
if (!subNode)
32
continue
33
- if (await nodeIsDirectory(subNode)) { // a directory needs to walked
33
+ if (nodeIsFolder(subNode)) { // a directory needs to walked
34
if (hasPermission(subNode, 'can_list', ctx) && hasPermission(subNode, 'can_archive', ctx)) {
35
yield subNode // it could be empty
36
yield* walkNode(subNode, { ctx, prefixPath: decodeURI(uri) + '/', requiredPerm: 'can_archive' })