@samitouri / QOSami-HFS / commits / 77fc44ca

fix: networked folders that were offline at startup are incorrectly displayed as files once they are back online #1115

Massimo Melina committed Nov 24, 2025 at 10:56 UTC 77fc44ca663f1f3dc13852df23e30b8164ff873e
5 files changed +44 -29
src/api.get_file_list.ts
+2 -2
@@ -115,10 +115,10 @@ export const get_file_list: ApiHandler = async ({ uri='/', offset, limit, c, onl
115 return name ? { n: name, url, target: node.target } : null
116 const isFolder = nodeIsFolder(node)
117 try {
118 - const st = source ? node.stats || await statWithTimeout(source).catch(e => {
118 + const st = source ? await (node.stats || statWithTimeout(source).catch(e => {
119 if (!isFolder || !node.children?.length) // folders with virtual children, keep them
120 throw e
121 - }) : undefined
121 + })) : undefined
122 // permissions of entries are sent as a difference with permissions of parent
123 const pl = node.can_list === WHO_NO_ONE ? 'l'
124 : !hasPermission(node, 'can_list', ctx) ? 'L'
src/api.vfs.ts
+5 -5
@@ -2,7 +2,7 @@
2
3 import {
4 getNodeName, isSameFilenameAs, nodeIsFolder, saveVfs, urlToNode, vfs, VfsNode, applyParentToChild,
5 - permsFromParent, nodeIsLink, VfsNodeStored, isRoot
5 + permsFromParent, VfsNodeStored, isRoot, nodeStats
6 } from './vfs'
7 import _ from 'lodash'
8 import { mkdir } from 'fs/promises'
@@ -39,8 +39,8 @@ const apis: ApiHandlers = {
39
40 async function recur(node=vfs): Promise<VfsNodeAdminSend> {
41 const { source } = node
42 - const stats = !source ? undefined : (node.stats || await statWithTimeout(source!).catch(() => undefined))
43 - const isDir = !nodeIsLink(node) && (!source || (stats?.isDirectory() ?? (source.endsWith('/') || node.children?.length! > 0)))
42 + const stats = await nodeStats(node)
43 + const isFolder = nodeIsFolder(node)
44 const copyStats: Pick<VfsNodeAdminSend, 'size' | 'birthtime' | 'mtime'> = stats ? _.pick(stats, ['size', 'birthtime', 'mtime'])
45 : { size: source ? -1 : undefined }
46 if (copyStats.mtime && (stats?.mtimeMs! - stats?.birthtimeMs!) < 1000)
@@ -56,10 +56,10 @@ const apis: ApiHandlers = {
56 inherited,
57 byMasks: _.isEmpty(byMasks) ? undefined : byMasks,
58 website: Boolean(node.children?.find(isSameFilenameAs('index.html')))
59 - || isDir && source && await statWithTimeout(join(source, 'index.html')).then(() => true, () => undefined)
59 + || isFolder && source && await statWithTimeout(join(source, 'index.html')).then(() => true, () => undefined)
60 || undefined,
61 name: getNodeName(node),
62 - type: isDir ? 'folder' : undefined,
62 + type: isFolder ? 'folder' : undefined,
63 children: node.children && await Promise.all(node.children.map(async child =>
64 recur(await applyParentToChild(child, node)) ))
65 }
src/outboundProxy.ts
+1 -1
@@ -13,7 +13,7 @@ const outboundProxy = defineConfig(CFG.outbound_proxy, '', v => {
13 const test = 'https://google.com'
14 console.debug("testing proxy using", test)
15 httpString(test, { noRedirect: true }).catch(e =>
16 - console.error(`proxy failed for ${test} : ${e?.errors?.[0] || e}`)) // `.errors` in case of AggregateError
16 + console.error(`proxy test failed on ${test} : ${e?.errors?.[0] || e}`)) // `.errors` in case of AggregateError
17 }
18 catch {
19 console.warn("invalid URL", v)
src/vfs.ts
+35 -20
@@ -44,7 +44,7 @@ export interface VfsNode extends VfsNodeStored { // include fields that are only
44 original?: VfsNode // if this is a temp node but reflecting an existing node
45 parent?: VfsNode // available when original is available
46 isFolder?: boolean // use nodeIsFolder() instead of relying on this field
47 - stats?: Stats
47 + stats?: Promise<Stats>
48 }
49
50 export function permsFromParent(parent: VfsNode, child: VfsNode) {
@@ -119,12 +119,9 @@ export async function urlToNode(url: string, ctx?: Koa.Context, parent: VfsNode=
119 if (rest || ret?.original)
120 return urlToNode(rest, ctx, ret, getRest)
121 if (ret.source)
122 - try {
123 - if (!showHiddenFiles.get() && await isHiddenFile(ret.source))
124 - throw 'hiddenFile'
125 - ret.isFolder = (await nodeStats(ret))!.isDirectory() // throws if it doesn't exist on disk
126 - }
127 - catch {
122 + if (!showHiddenFiles.get() && await isHiddenFile(ret.source))
123 + throw 'hiddenFile'
124 + else if (await setIsFolder(ret) === undefined) { // undefined = not found on disk
125 if (!getRest)
126 return
127 const rest = ret.source.slice(parent.source!.length) // parent has source, otherwise !ret.source || ret.original
@@ -134,11 +131,13 @@ export async function urlToNode(url: string, ctx?: Koa.Context, parent: VfsNode=
131 return ret
132 }
133
137 -export async function nodeStats(ret: VfsNode) {
138 - if (ret.stats)
139 - return ret.stats
140 - const stats = ret.source ? await statWithTimeout(ret.source) : undefined
141 - setHidden(ret, { stats })
134 +export async function nodeStats(node: VfsNode) {
135 + if (node.stats || !node.source)
136 + return node.stats
137 + const stats = statWithTimeout(node.source).catch(() => {
138 + setHidden(node, { stats: null }) // don't cache rejected promises
139 + })
140 + setHidden(node, { stats })
141 return stats
142 }
143
@@ -149,10 +148,10 @@ async function isHiddenFile(path: string) {
148
149 export async function getNodeByName(name: string, parent: VfsNode) {
150 // does the tree node have a child that goes by this name, otherwise attempt disk
152 - const child = parent.children?.find(isSameFilenameAs(name)) || childFromDisk()
151 + const child = parent.children?.find(isSameFilenameAs(name)) || await childFromDisk()
152 return child && applyParentToChild(child, parent, name)
153
155 - function childFromDisk() {
154 + async function childFromDisk() {
155 if (!parent.source) return
156 const ret: VfsNode = {}
157 let onDisk = name
@@ -167,10 +166,18 @@ export async function getNodeByName(name: string, parent: VfsNode) {
166 if (!isValidFileName(onDisk)) return
167 ret.source = join(parent.source, onDisk)
168 ret.original = undefined // this will overwrite the 'original' set in applyParentToChild, so we know this is not part of the vfs
169 + await setIsFolder(ret)
170 return ret
171 }
172 }
173
174 +async function setIsFolder(node: VfsNode) {
175 + if (!node.source) return
176 + const isFolder = /[\\/]$/.test(node.source) || await nodeStats(node).then(x => x?.isDirectory(), () => undefined)
177 + setHidden(node, { isFolder })
178 + return isFolder
179 +}
180 +
181 export let vfs: VfsNode = {}
182 defineConfig('vfs', vfs).sub(async x => {
183 await reviewVfs(x)
@@ -179,10 +186,8 @@ defineConfig('vfs', vfs).sub(async x => {
186
187 async function reviewVfs(data=vfs) {
188 await (async function recur(node) {
182 - if (node.source && !node.children?.length && node.isFolder === undefined) {
183 - const isFolder = /[\\/]$/.test(node.source) || await nodeStats(node).then(x => x?.isDirectory(), () => undefined)
184 - setHidden(node, { isFolder })
185 - }
189 + if (node.source && !node.children?.length && node.isFolder === undefined)
190 + await setIsFolder(node)
191 if (node.children)
192 await Promise.allSettled(node.children.map(recur))
193 })(data)
@@ -218,9 +223,19 @@ export function getNodeName(node: VfsNode) {
223 return base
224 }
225
226 +// this is sync
227 export function nodeIsFolder(node: VfsNode) {
228 return node.isFolder ?? node.original?.isFolder
223 - ?? (!nodeIsLink(node) && (node.children?.length! > 0 || !node.source))
229 + ?? (nodeIsLink(node) ? false : (node.children?.length! > 0 || !node.source || reconsider()))
230 +
231 + function reconsider() {
232 + // a networked source may be offline at startup, and become online later: recalculate in the background
233 + nodeStats(node).then(s => {
234 + if (s)
235 + setHidden(node.original || node, { isFolder: s.isDirectory() })
236 + }, () => {})
237 + return undefined
238 + }
239 }
240
241 export async function hasDefaultFile(node: VfsNode, ctx: Koa.Context) {
@@ -341,7 +356,7 @@ export async function* walkNode(parent: VfsNode, {
356 }
357 if (usingDescriptIon() && entry.name === DESCRIPT_ION)
358 return
344 - const {path} = entry
359 + const {path} = entry // this path is not the original deprecated property: we are overwriting/reusing it
360 const isFolder = entry.isDirectory()
361 let renamed = parent.rename?.[path]
362 if (renamed) {
src/zip.ts
+1 -1
@@ -55,7 +55,7 @@ export async function zipStreamFromFolder(node: VfsNode, ctx: Koa.Context) {
55 if (nodeIsFolder(el))
56 return { path: name + '/' }
57 if (!source) return
58 - const st = el.stats || await statWithTimeout(source)
58 + const st = await (el.stats || statWithTimeout(source))
59 if (!st || !st.isFile())
60 return
61 return {