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' })