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 {