fix: admin/shared: vfs not loading in case of slow (or offline) networked sources
Massimo Melina committed
Nov 1, 2025 at 13:17 UTC
b603e024393f704afb699ab274ab974f418ec8fd
15 files changed
+49
-42
config.md
+2
-1
@@ -126,7 +126,8 @@ Configuration can be done in several ways
126
Setting one will trigger a test request to google.com. You can skip this with env HFS_SKIP_PROXY_TEST=1 .
127
- `auto_basic` automatically detect (based on user-agent) when the basic web interface should be served, to support legacy browsers. Default is true. No UI.
128
You can disable it setting it to `false`, or recognize additional user-agents by setting a regular expression.
129
-- `authorization_header` support Authentication HTTP header. Default is true. No UI.
129
+- `file_timeout` number of seconds to wait before giving up when accessing a file. Default is 3. No UI.
130
+- `authorization_header` enable support for the HTTP `Authorization` header. Default is true. No UI.
131
- `cache_control_disk_files` number of seconds after which the browser should bypass the cache and check the server for an updated version of the file. Default is 5. No UI.
132
- `disable_custom_html` disable the content of `custom_html`. Default is false.
133
- `split_uploads` The size in megabytes of the chunks the upload will be split into. Default is none.
shared/api.ts
+1
@@ -14,6 +14,7 @@ const timeoutByApi: Dict = {
14
login: 90,
15
get_status: 20, // can be lengthy on slow machines because of the find-process-on-busy-port feature
16
check_update: 20,
17
+ get_vfs: 20, // multiple sources may be slow
18
}
19
20
interface ApiCallOptions {
src/api.get_file_list.ts
+4
-5
@@ -5,9 +5,8 @@ import {
5
statusCodeForMissingPerm, urlToNode, VfsNode, walkNode
6
} from './vfs'
7
import { ApiError, ApiHandler } from './apiMiddleware'
8
-import { stat } from 'fs/promises'
8
import { mapPlugins } from './plugins'
10
-import { asyncGeneratorToArray, pattern2filter, WHO_NO_ONE } from './misc'
9
+import { asyncGeneratorToArray, pattern2filter, statWithTimeout, WHO_NO_ONE } from './misc'
10
import { HTTP_METHOD_NOT_ALLOWED, HTTP_NOT_FOUND } from './const'
11
import Koa from 'koa'
12
import { getCommentFor, areCommentsEnabled } from './comments'
@@ -36,7 +35,7 @@ export const get_file_list: ApiHandler = async ({ uri='/', offset, limit, c, onl
35
if (!node)
36
return fail(HTTP_NOT_FOUND)
37
admin &&= ctxAdminAccess(ctx) // validate 'admin' flag
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
38
+ if (await hasDefaultFile(node, ctx) || !nodeIsFolder(node)) // for files without permission, the frontend is sent, and the location is the file itself
39
// 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.
40
return fail(!admin && statusCodeForMissingPerm(node, 'can_read', ctx) ? undefined : HTTP_METHOD_NOT_ALLOWED)
41
if (!admin && statusCodeForMissingPerm(node, 'can_list', ctx))
@@ -116,7 +115,7 @@ 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 {
119
- const st = source ? node.stats || await stat(source).catch(e => {
118
+ const st = source ? node.stats || await statWithTimeout(source).catch(e => {
119
if (!isFolder || !node.children?.length) // folders with virtual children, keep them
120
throw e
121
}) : undefined
@@ -128,7 +127,7 @@ export const get_file_list: ApiHandler = async ({ uri='/', offset, limit, c, onl
127
const pr = node.can_read === WHO_NO_ONE && !(isFolder && filesInsideCould()) ? 'r'
128
: !hasPermission(node, 'can_read', ctx) ? 'R'
129
: ''
131
- // for delete, the diff is based on can_delete_children instead of can_delete, because it will produce fewer data
130
+ // for delete, the diff is based on can_delete_children instead of can_delete, because it will produce less data
131
const pd = Boolean(can_delete_children) === hasPermission(node, 'can_delete', ctx) ? '' : can_delete_children ? 'd' : 'D'
132
const pa = Boolean(can_archive) === hasPermission(node, 'can_archive', ctx) ? '' : can_archive ? 'a' : 'A'
133
const pu = !isFolder || Boolean(can_upload) === hasPermission(node, 'can_upload', ctx) ? '' : can_upload ? 'u' : 'U'
src/api.log.ts
+2
-3
@@ -2,10 +2,9 @@ import { ApiHandlers } from './apiMiddleware'
2
import _ from 'lodash'
3
import { consoleLog } from './consoleLog'
4
import { HTTP_BAD_REQUEST, HTTP_NOT_ACCEPTABLE, HTTP_NOT_FOUND, wait } from './cross'
5
-import { apiAssertTypes } from './misc'
5
+import { apiAssertTypes, statWithTimeout } from './misc'
6
import events from './events'
7
import { getRotatedFiles, loggers } from './log'
8
-import { stat } from 'fs/promises'
8
import { SendListReadable } from './SendList'
9
import { forceDownload, serveFile } from './serveFile'
10
import { ips } from './ips'
@@ -13,7 +12,7 @@ import { disconnectionsLog } from './connections'
12
13
export default {
14
async get_log_info() {
16
- const current = Object.fromEntries(await Promise.all(loggers.map(async x => [x.name, await stat(x.path).then(s => s.size, () => 0)])))
15
+ const current = Object.fromEntries(await Promise.all(loggers.map(async x => [x.name, await statWithTimeout(x.path).then(s => s.size, () => 0)])))
16
return { current, rotated: await getRotatedFiles() }
17
},
18
src/api.vfs.ts
+5
-5
@@ -5,12 +5,12 @@ import {
5
permsFromParent, nodeIsLink, VfsNodeStored, isRoot
6
} from './vfs'
7
import _ from 'lodash'
8
-import { mkdir, stat } from 'fs/promises'
8
+import { mkdir } from 'fs/promises'
9
import { ApiError, ApiHandlers } from './apiMiddleware'
10
import { dirname, extname, join, resolve } from 'path'
11
import {
12
enforceFinal, enforceStarting, isDirectory, isValidFileName, isWindowsDrive, makeMatcher, PERM_KEYS,
13
- VfsNodeAdminSend, wait
13
+ statWithTimeout, VfsNodeAdminSend
14
} from './misc'
15
import {
16
IS_WINDOWS, HTTP_BAD_REQUEST, HTTP_NOT_FOUND, HTTP_SERVER_ERROR, HTTP_CONFLICT, HTTP_NOT_ACCEPTABLE,
@@ -39,7 +39,7 @@ const apis: ApiHandlers = {
39
40
async function recur(node=vfs): Promise<VfsNodeAdminSend> {
41
const { source } = node
42
- const stats = !source ? undefined : (node.stats || await stat(source!).catch(() => undefined))
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)))
44
const copyStats: Pick<VfsNodeAdminSend, 'size' | 'birthtime' | 'mtime'> = stats ? _.pick(stats, ['size', 'birthtime', 'mtime'])
45
: { size: source ? -1 : undefined }
@@ -56,7 +56,7 @@ const apis: ApiHandlers = {
56
inherited,
57
byMasks: _.isEmpty(byMasks) ? undefined : byMasks,
58
website: Boolean(node.children?.find(isSameFilenameAs('index.html')))
59
- || isDir && source && await stat(join(source, 'index.html')).then(() => true, () => undefined)
59
+ || isDir && source && await statWithTimeout(join(source, 'index.html')).then(() => true, () => undefined)
60
|| undefined,
61
name: getNodeName(node),
62
type: isDir ? 'folder' : undefined,
@@ -214,7 +214,7 @@ const apis: ApiHandlers = {
214
if (!files || fileMask && !matching(name))
215
return
216
try {
217
- const stats = entry.stats || await stat(join(path, name))
217
+ const stats = entry.stats || await statWithTimeout(join(path, name))
218
list.add({
219
n: name,
220
s: stats.size,
src/config.ts
+3
-2
@@ -9,9 +9,10 @@ import { debounceAsync } from './debounceAsync'
9
import { statSync } from 'fs'
10
import { join, resolve } from 'path'
11
import events from './events'
12
-import { copyFile, stat } from 'fs/promises'
12
+import { copyFile } from 'fs/promises'
13
import { produce, setAutoFreeze } from 'immer'
14
import { argv } from './argv'
15
+import { statWithTimeout } from './util-files'
16
17
setAutoFreeze(false) // we still want to mess with objects later (eg: account.belongs)
18
@@ -182,7 +183,7 @@ const saveDebounced = debounceAsync(async () => {
183
// keep backup
184
const bak = filePath + '.bak'
185
const aWeekAgo = Date.now() - DAY * 7
185
- if (await stat(bak).then(x => aWeekAgo > x.mtimeMs, () => true))
186
+ if (await statWithTimeout(bak).then(x => aWeekAgo > x.mtimeMs, () => true))
187
await copyFile(filePath, bak).catch(() => {}) // ignore errors
188
189
await configFile.save(stringify({ ...state, version: VERSION }))
src/fileAttr.ts
+3
-2
@@ -4,7 +4,8 @@ import { promisify } from 'util'
4
import { access } from 'fs/promises'
5
import { try_, tryJson } from './cross'
6
import { onProcessExit } from './first'
7
-import { utimes, stat } from 'node:fs/promises'
7
+import { utimes } from 'node:fs/promises'
8
+import { statWithTimeout } from './util-files'
9
import { IS_WINDOWS } from './const'
10
11
const fsx = try_(() => {
@@ -21,7 +22,7 @@ const FILE_ATTR_PREFIX = 'user.hfs.' // user. prefix to be linux compatible
22
23
/* @param v must be JSON-able or undefined */
24
export async function storeFileAttr(path: string, k: string, v: any) {
24
- const s = await stat(path).catch(() => null)
25
+ const s = await statWithTimeout(path).catch(() => null)
26
// since we don't have fsx.remove, we simulate it with an empty string
27
if (s && await fsx?.set(path, FILE_ATTR_PREFIX + k, v === undefined ? '' : JSON.stringify(v)).then(() => 1, () => 0)) {
28
if (IS_WINDOWS) utimes(path, s.atime, s.mtime) // restore timestamps, necessary only on Windows
src/geo.ts
+4
-4
@@ -1,6 +1,6 @@
1
import { defineConfig } from './config'
2
-import { CFG, DAY, httpStream, isIpLan, isLocalHost, unzip } from './misc'
3
-import { stat, rename, unlink } from 'node:fs/promises'
2
+import { CFG, DAY, httpStream, isIpLan, isLocalHost, statWithTimeout, unzip } from './misc'
3
+import { rename, unlink } from 'node:fs/promises'
4
import { IP2Location } from 'ip2location-nodejs'
5
import _ from 'lodash'
6
import { Middleware } from 'koa'
@@ -40,7 +40,7 @@ async function checkFiles() {
40
const URL = `https://download.ip2location.com/lite/${ZIP_FILE}.ZIP`
41
const LOCAL_FILE = 'geo_ip.bin'
42
const TEMP = LOCAL_FILE + '.downloading'
43
- const { mtime=0 } = await stat(LOCAL_FILE).catch(() => ({ mtime: 0 }))
43
+ const { mtime=0 } = await statWithTimeout(LOCAL_FILE).catch(() => ({ mtime: 0 }))
44
const name = 'geo-ip db'
45
const now = Date.now()
46
if (+mtime < now - 31 * DAY) // month-old or non-existing
@@ -48,7 +48,7 @@ async function checkFiles() {
48
const req = await httpStream(URL)
49
console.log(`downloading ${name}`)
50
await unzip(req, path => path.toUpperCase().endsWith(ZIP_FILE) && TEMP)
51
- await stat(TEMP) // check existence
51
+ await statWithTimeout(TEMP) // check existence
52
if (isOpen())
53
ip2location.close()
54
await unlink(LOCAL_FILE).catch(() => {})
src/log.ts
+3
-4
@@ -6,9 +6,8 @@ import { Writable } from 'stream'
6
import { defineConfig } from './config'
7
import { createWriteStream, renameSync, statSync } from 'fs'
8
import * as util from 'util'
9
-import { stat } from 'fs/promises'
9
import _ from 'lodash'
11
-import { createFileWithPath, prepareFolder } from './util-files'
10
+import { createFileWithPath, prepareFolder, statWithTimeout } from './util-files'
11
import { getCurrentUsername } from './auth'
12
import { DAY, makeNetMatcher, tryJson, Dict, Falsy, CFG, strinsert, repeat, formatTimestamp, HTTP_NOT_FOUND } from './misc'
13
import { extname } from 'path'
@@ -33,7 +32,7 @@ class Logger {
32
if (!path)
33
return this.stream = undefined
34
try {
36
- const stats = await stat(path)
35
+ const stats = await statWithTimeout(path)
36
this.last = stats.mtime
37
}
38
catch {
@@ -139,7 +138,7 @@ export const logMw: Koa.Middleware = async (ctx, next) => {
138
if (events.anyListener(logger.name)) // small optimization: this event can happen often, while most times there's no listener, and the parameters object is constructed pointlessly. A benchmark measured it 20% faster (just the line), while maybe it was not necessary.
139
events.emit(logger.name, { ctx, length, user, ts: now, uri, extra })
140
debounce(() => // once in a while we check if the file is still good (not deleted, etc), or we'll reopen it
142
- stat(logger.path).catch(() => logger.reopen())) // async = smoother but we may lose some entries
141
+ statWithTimeout(logger.path).catch(() => logger.reopen())) // async = smoother but we may lose some entries
142
stream!.write(util.format( format,
143
ctx.ip,
144
user || '-',
src/serveGuiFiles.ts
+2
-3
@@ -1,7 +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 Koa from 'koa'
4
-import fs from 'fs/promises'
4
import {
5
API_VERSION, MIME_AUTO, FRONTEND_URI, HTTP_METHOD_NOT_ALLOWED, HTTP_NO_CONTENT, HTTP_NOT_FOUND,
6
PLUGINS_PUB_URI, VERSION, SPECIAL_URI, ICONS_URI, DEV
@@ -13,7 +12,7 @@ import { ApiError } from './apiMiddleware'
12
import { join, extname } from 'path'
13
import {
14
CFG, debounceAsync, formatBytes, FRONTEND_OPTIONS, isPrimitive, newObj, objSameKeys, onlyTruthy, parseFileContent,
16
- enforceStarting
15
+ enforceStarting, statWithTimeout
16
} from './misc'
17
import { favicon, title } from './adminApis'
18
import { customHtml, getAllSections, getSection } from './customHtml'
@@ -68,7 +67,7 @@ function adjustBundlerLinks(ctx: Koa.Context, uri: string, data: string | Buffer
67
68
const getFaviconTimestamp = debounceAsync(async () => {
69
const f = favicon.get()
71
- return !f ? 0 : fs.stat(f).then(x => x?.mtimeMs || 0, () => 0)
70
+ return !f ? 0 : statWithTimeout(f).then(x => x?.mtimeMs || 0, () => 0)
71
}, { retain: 5_000 })
72
73
async function treatIndex(ctx: Koa.Context, filesUri: string, body: string) {
src/update.ts
+3
-3
@@ -4,10 +4,10 @@ import { apiGithubPaginated, getProjectInfo, getRepoInfo } from './github'
4
import { ARGS_FILE, HFS_REPO, IS_BINARY, IS_WINDOWS, PREVIOUS_TAG, RUNNING_BETA } from './const'
5
import { dirname, join } from 'path'
6
import { spawn, spawnSync } from 'child_process'
7
-import { DAY, exists, debounceAsync, unzip, prefix, xlate, HOUR, httpWithBody } from './misc'
7
+import { DAY, exists, debounceAsync, unzip, prefix, xlate, HOUR, httpWithBody, statWithTimeout } from './misc'
8
import { createReadStream, existsSync, renameSync, unlinkSync, writeFileSync } from 'fs'
9
import { pluginsWatcher } from './plugins'
10
-import { chmod, rename, stat, writeFile } from 'fs/promises'
10
+import { chmod, rename, writeFile } from 'fs/promises'
11
import open from 'open'
12
import { currentVersion, defineConfig, versionToScalar } from './config'
13
import { cmdEscape, RUNNING_AS_SERVICE } from './util-os'
@@ -151,7 +151,7 @@ export async function update(tagOrUrl: string='') {
151
join(binPath, path === binFile ? newBinFile : path))
152
const newBin = join(binPath, newBinFile)
153
if (!IS_WINDOWS) {
154
- const { mode } = await stat(bin)
154
+ const { mode } = await statWithTimeout(bin)
155
await chmod(newBin, mode).catch(console.error)
156
}
157
await rename(INSTALLED_FN, PREVIOUS_FN).catch(e => e?.code !== 'ENOENT' && console.warn(String(e)))
src/util-files.ts
+10
-3
@@ -1,7 +1,8 @@
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 { access, mkdir, readFile, stat } from 'fs/promises'
4
-import { Promisable, try_, wait, isWindowsDrive } from './cross'
4
+import { Promisable, try_, wait, isWindowsDrive, haveTimeout } from './cross'
5
+import { defineConfig } from './config'
6
import { createWriteStream, mkdirSync, watch, ftruncate } from 'fs'
7
import { basename, dirname } from 'path'
8
import glob from 'fast-glob'
@@ -11,8 +12,14 @@ import { Readable } from 'stream'
12
// @ts-ignore
13
import unzipper from 'unzip-stream'
14
15
+const fileTimeout = defineConfig('file_timeout', 3, x => x * 1000)
16
+
17
+export function statWithTimeout(path: string) {
18
+ return haveTimeout(fileTimeout.compiled(), stat(path))
19
+}
20
+
21
export async function isDirectory(path: string) {
15
- try { return (await stat(path)).isDirectory() }
22
+ try { return (await statWithTimeout(path)).isDirectory() }
23
catch {}
24
}
25
@@ -148,7 +155,7 @@ export function exists(path: string) {
155
// parse a file, caching unless timestamp has changed
156
export const parseFileCache = new Map<string, { ts: Date, parsed: unknown }>()
157
export async function parseFile<T>(path: string, parse: (path: string) => T) {
151
- const { mtime: ts } = await stat(path)
158
+ const { mtime: ts } = await statWithTimeout(path)
159
const cached = parseFileCache.get(path)
160
if (cached && Number(ts) === Number(cached.ts))
161
return cached.parsed as T
src/vfs.ts
+2
-1
@@ -5,6 +5,7 @@ import { basename, dirname, join, resolve } from 'path'
5
import {
6
makeMatcher, setHidden, onlyTruthy, isValidFileName, throw_, VfsPerms, Who, debounceAsync,
7
isWhoObject, WHO_ANY_ACCOUNT, defaultPerms, PERM_KEYS, removeStarting, HTTP_SERVER_ERROR, try_, matches,
8
+ statWithTimeout,
9
} from './misc'
10
import Koa from 'koa'
11
import _ from 'lodash'
@@ -136,7 +137,7 @@ export async function urlToNode(url: string, ctx?: Koa.Context, parent: VfsNode=
137
export async function nodeStats(ret: VfsNode) {
138
if (ret.stats)
139
return ret.stats
139
- const stats = ret.source ? await fs.stat(ret.source) : undefined
140
+ const stats = ret.source ? await statWithTimeout(ret.source) : undefined
141
setHidden(ret, { stats })
142
return stats
143
}
src/walkDir.ts
+3
-3
@@ -1,5 +1,5 @@
1
import { makeQ } from './makeQ'
2
-import { stat, opendir } from 'fs/promises'
2
+import { opendir } from 'fs/promises'
3
import { IS_WINDOWS } from './const'
4
import { join } from 'path'
5
import { pendingPromise, Promisable } from './cross'
@@ -8,7 +8,7 @@ import events from './events'
8
import _ from 'lodash'
9
import { Context } from 'koa'
10
import fswin from 'fswin'
11
-import { isDirectory } from './util-files'
11
+import { isDirectory, statWithTimeout } from './util-files'
12
13
export interface DirStreamEntry extends Dirent {
14
closingBranch?: Promise<string>
@@ -70,7 +70,7 @@ export function walkDir(path: string, { depth = 0, hidden = true, parallelizeRec
70
if (stopped) break
71
if (!hidden && entry.name[0] === '.' && !IS_WINDOWS)
72
continue
73
- const stats = entry.isSymbolicLink?.() && await stat(join(base, entry.name)).catch(() => null)
73
+ const stats = entry.isSymbolicLink?.() && await statWithTimeout(join(base, entry.name)).catch(() => null)
74
if (stats === null) continue
75
if (stats)
76
entry = new DirentFromStats(entry.name, stats)
src/zip.ts
+2
-3
@@ -2,10 +2,9 @@
2
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'
5
+import { filterMapGenerator, isWindowsDrive, safeDecodeURIComponent, statWithTimeout, wantArray } from './misc'
6
import { QuickZipStream } from './QuickZipStream'
7
import { createReadStream } from 'fs'
8
-import fs from 'fs/promises'
8
import { defineConfig } from './config'
9
import { basename, dirname } from 'path'
10
import { applyRange, forceDownload, monitorAsDownload } from './serveFile'
@@ -56,7 +55,7 @@ export async function zipStreamFromFolder(node: VfsNode, ctx: Koa.Context) {
55
if (nodeIsFolder(el))
56
return { path: name + '/' }
57
if (!source) return
59
- const st = el.stats || await fs.stat(source)
58
+ const st = el.stats || await statWithTimeout(source)
59
if (!st || !st.isFile())
60
return
61
return {