hfs 3.1.0-alpha3 → 3.1.0-beta4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,12 +1,14 @@
1
1
  {
2
2
  "name": "hfs",
3
- "version": "3.1.0-alpha3",
3
+ "version": "3.1.0-beta4",
4
4
  "description": "HTTP File Server",
5
- "keywords": ["file server", "http server"],
5
+ "keywords": [
6
+ "file server",
7
+ "http server"
8
+ ],
6
9
  "homepage": "https://rejetto.com/hfs",
7
10
  "license": "GPL-3.0",
8
11
  "author": "Massimo Melina <a@rejetto.com>",
9
- "workspaces": [ "admin", "frontend", "shared", "mui-grid-form" ],
10
12
  "scripts": {
11
13
  "watch-server": "cross-env DEV=1 nodemon --ignore tests/ --watch src -e ts,tsx --exec tsx src",
12
14
  "watch-server-proxied": "cross-env FRONTEND_PROXY=3005 ADMIN_PROXY=3006 npm run watch-server",
@@ -28,7 +30,7 @@
28
30
  "dist": "STASHED=; if ! git diff-index --quiet HEAD --; then git stash push -m 'dist' && STASHED=1; fi; CI=1 FORCE_COLOR=1 npm run dist-uncommitted || (EXIT_CODE=$?; [ -n \"$STASHED\" ] && git stash pop; exit $EXIT_CODE); [ -n \"$STASHED\" ] && git stash pop",
29
31
  "dist-uncommitted": "npm audit --omit=dev --audit-level=moderate && rm -rf dist && npm run build-server && npm run test-with-server && (npm run build-frontend & npm run build-admin) && npm run test-ui && npm run dist-bin",
30
32
  "dist-bin": "npm run dist-modules && npm run dist-bin-win && npm run dist-bin-linux && npm run dist-bin-linux-arm && npm run dist-bin-mac && npm run dist-bin-mac-arm",
31
- "dist-modules": "cp package*.json central.json README.md dist && cd dist && npm ci --omit=dev && npm shrinkwrap && cd .. && node scripts/prune_modules.js",
33
+ "dist-modules": "cp package.json central.json README.md dist && cd dist && npm pkg delete devDependencies workspaces && rm -rf node_modules && npm install --omit=dev && npm shrinkwrap && cd .. && node scripts/prune_modules.js",
32
34
  "dist-bin-win": "cd dist && pkg . --public -C gzip -t node20-win-x64 && npx resedit-cli --in hfs.exe --icon 1,../hfs.ico --out hfs.exe && zip hfs-windows-x64-$(jq -r .version ../package.json).zip hfs.exe -r plugins && cd ..",
33
35
  "dist-bin-mac-arm": "cd dist && pkg . --public -C gzip -t node20-macos-arm64 && zip hfs-mac-arm64-$(jq -r .version ../package.json).zip hfs -r plugins && cd ..",
34
36
  "dist-bin-mac": "cd dist && pkg . --public -C gzip -t node20-macos-x64 && zip hfs-mac-x64-$(jq -r .version ../package.json).zip hfs -r plugins && cd ..",
@@ -85,6 +87,7 @@
85
87
  "busboy": "^1.6.0",
86
88
  "crc-32": "^1.2.2",
87
89
  "fast-glob": "^3.3.3",
90
+ "fast-xml-parser": "^5.4.2",
88
91
  "find-process": "^2.0.0",
89
92
  "fs-x-attributes": "^1.0.2",
90
93
  "fswin": "^3.24.829",
@@ -108,27 +111,5 @@
108
111
  "valtio": "^1.13.2",
109
112
  "xxhashjs": "^0.2.2",
110
113
  "yaml": "^2.8.1"
111
- },
112
- "devDependencies": {
113
- "@playwright/test": "^1.55.1",
114
- "@types/busboy": "^1.5.4",
115
- "@types/koa": "^3.0.0",
116
- "@types/koa__router": "^12.0.4",
117
- "@types/koa-compress": "^4.0.6",
118
- "@types/koa-mount": "^4.0.5",
119
- "@types/koa-session": "^6.4.5",
120
- "@types/lodash": "^4.17.20",
121
- "@types/mime-types": "^3.0.1",
122
- "@types/minimist": "^1.2.5",
123
- "@types/node": "^20.17.30",
124
- "@types/node-forge": "^1.3.14",
125
- "@types/picomatch": "^4.0.2",
126
- "@yao-pkg/pkg": "6.14.1",
127
- "cross-env": "^10.0.0",
128
- "koa-better-http-proxy": "^0.2.10",
129
- "nm-prune": "^5.0.0",
130
- "nodemon": "^3.1.10",
131
- "tsx": "^4.20.5",
132
- "typescript": "^5.9.2"
133
114
  }
134
115
  }
package/src/commands.js CHANGED
@@ -20,6 +20,7 @@ const cross_1 = require("./cross");
20
20
  const api_monitor_1 = __importDefault(require("./api.monitor"));
21
21
  const argv_1 = require("./argv");
22
22
  const listen_2 = require("./listen");
23
+ let debugEnabled = argv_1.argv.debug || process.env.HFS_DEBUG;
23
24
  if (!argv_1.argv.updating && !config_1.showHelp) {
24
25
  try {
25
26
  // Not sure if the try is necessary for when stdin is unavailable, but someone reported a problem using nohup https://github.com/rejetto/hfs/issues/74 and I've found this example try-catching https://github.com/DefinitelyTyped/DefinitelyTyped/blob/dda83a906914489e09ca28afea12948529015d4a/types/node/readline.d.ts#L489
@@ -29,7 +30,7 @@ if (!argv_1.argv.updating && !config_1.showHelp) {
29
30
  .on('SIGINT', () => process.emit('SIGINT')); // readline swallows the first ctrl+c unless we forward it to process-level handlers
30
31
  let isClean = true;
31
32
  const showPrompt = tty && lodash_1.default.debounce(() => {
32
- if (first_1.quitting || !tty)
33
+ if (first_1.quitting || !tty || !isClean)
33
34
  return;
34
35
  prompter.prompt(true);
35
36
  isClean = false;
@@ -43,12 +44,14 @@ if (!argv_1.argv.updating && !config_1.showHelp) {
43
44
  }
44
45
  showPrompt?.();
45
46
  for (const k of ['log', 'warn', 'error', 'debug']) {
46
- const v = console[k];
47
+ const original = console[k];
47
48
  console[k] = (...args) => {
49
+ if (k === 'debug' && !debugEnabled)
50
+ return;
48
51
  if (!first_1.quitting && tty)
49
52
  clean();
50
53
  try {
51
- v(...args);
54
+ original(...args);
52
55
  }
53
56
  finally {
54
57
  showPrompt?.();
@@ -58,6 +61,8 @@ if (!argv_1.argv.updating && !config_1.showHelp) {
58
61
  }
59
62
  catch {
60
63
  console.log("console commands not available");
64
+ const original = console.debug;
65
+ console.debug = (...args) => debugEnabled && original(...args);
61
66
  }
62
67
  }
63
68
  async function parseCommandLine(line) {
@@ -156,6 +161,13 @@ const commands = {
156
161
  console.log(const_1.VERSION, 'build', const_1.BUILD_TIMESTAMP);
157
162
  }
158
163
  },
164
+ debug: {
165
+ params: '',
166
+ cb() {
167
+ debugEnabled = !debugEnabled;
168
+ console.log(`debug messages ${debugEnabled ? "on" : "off"}`);
169
+ }
170
+ },
159
171
  'start-plugin': {
160
172
  params: '<name>',
161
173
  cb: plugins_1.startPlugin,
package/src/const.js CHANGED
@@ -36,16 +36,34 @@ var __importStar = (this && this.__importStar) || (function () {
36
36
  var __exportStar = (this && this.__exportStar) || function(m, exports) {
37
37
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
38
38
  };
39
+ var __importDefault = (this && this.__importDefault) || function (mod) {
40
+ return (mod && mod.__esModule) ? mod : { "default": mod };
41
+ };
39
42
  Object.defineProperty(exports, "__esModule", { value: true });
40
- exports.CONFIG_FILE = exports.MIME_AUTO = exports.APP_PATH = exports.IS_BINARY = exports.IS_MAC = exports.IS_WINDOWS = exports.RUNNING_BETA = exports.VERSION = exports.BUILD_TIMESTAMP = exports.HFS_STARTED = exports.ORIGINAL_CWD = exports.DEV = exports.COMPATIBLE_API_VERSION = exports.API_VERSION = void 0;
43
+ exports.CONFIG_FILE = exports.MIME_AUTO = exports.APP_PATH = exports.IS_BINARY = exports.IS_MAC = exports.IS_WINDOWS = exports.RUNNING_BETA = exports.VERSION = exports.BUILD_TIMESTAMP = exports.HFS_STARTED = exports.ORIGINAL_CWD = exports.DEV = exports.ARGS_FILE = exports.COMPATIBLE_API_VERSION = exports.API_VERSION = void 0;
44
+ const minimist_1 = __importDefault(require("minimist"));
41
45
  const fs = __importStar(require("fs"));
42
46
  const os_1 = require("os");
47
+ const lodash_1 = __importDefault(require("lodash"));
43
48
  const path_1 = require("path");
44
49
  const cross_1 = require("./cross");
45
50
  const argv_1 = require("./argv");
46
51
  __exportStar(require("./cross-const"), exports);
47
52
  exports.API_VERSION = 13;
48
53
  exports.COMPATIBLE_API_VERSION = 1; // the day we break with the past, we'll update this
54
+ // you can add arguments with this file, currently used for the update process on mac/linux.
55
+ // we are using homedir because it's the only stable path both the old and new process can agree on (open() doesn't preserve cwd)
56
+ exports.ARGS_FILE = (0, path_1.join)((0, os_1.homedir)(), 'hfs-args');
57
+ try {
58
+ const s = fs.readFileSync(exports.ARGS_FILE, 'utf-8');
59
+ console.log('additional arguments', s);
60
+ lodash_1.default.defaults(argv_1.argv, (0, minimist_1.default)(JSON.parse(s)));
61
+ fs.unlinkSync(exports.ARGS_FILE);
62
+ }
63
+ catch (e) {
64
+ if (e?.code !== 'ENOENT')
65
+ console.error(exports.ARGS_FILE, String(e));
66
+ }
49
67
  exports.DEV = process.env.DEV ? 'DEV' : '';
50
68
  exports.ORIGINAL_CWD = process.cwd();
51
69
  exports.HFS_STARTED = new Date();
@@ -65,14 +83,11 @@ if (exports.DEV) {
65
83
  console.clear();
66
84
  process.env.DEBUG = 'acme-client';
67
85
  }
68
- else if (!argv_1.argv.debug && !process.env.HFS_DEBUG)
69
- console.debug = () => { };
70
86
  console.log(`HFS ~ HTTP File Server`);
71
87
  console.log(`© Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt`);
72
88
  console.log('started', (0, cross_1.formatTimestamp)(exports.HFS_STARTED), exports.DEV);
73
89
  console.log('version', exports.VERSION || '-');
74
90
  console.log('build', exports.BUILD_TIMESTAMP || '-');
75
- console.debug('arguments', argv_1.argv);
76
91
  // still considering whether to use ".hfs" with Windows users, who may be less accustomed to it
77
92
  const dir = argv_1.argv.cwd || useHomeDir() && (0, path_1.join)((0, os_1.homedir)(), '.hfs');
78
93
  if (dir) {
@@ -141,12 +141,17 @@ exports.frontEndApis = {
141
141
  return new apiMiddleware_1.ApiError(ctx.status);
142
142
  let bytes = 0;
143
143
  let files = 0;
144
- for await (const n of (0, vfs_1.walkNode)(folder, { ctx, onlyFiles: true, depth: Infinity })) {
145
- bytes += await (0, vfs_1.nodeStats)(n).then(x => x?.size || 0, () => 0);
146
- files++;
147
- partialFolderSize[id] = { bytes, files };
144
+ let folders = 0;
145
+ for await (const n of (0, vfs_1.walkNode)(folder, { ctx, depth: Infinity })) {
146
+ if (n.isFolder)
147
+ folders++;
148
+ else {
149
+ bytes += await (0, vfs_1.nodeStats)(n).then(x => x?.size || 0, () => 0);
150
+ files++;
151
+ }
152
+ partialFolderSize[id] = { bytes, files, folders };
148
153
  }
149
- return (0, misc_1.popKey)(partialFolderSize, id) || { bytes, files };
154
+ return (0, misc_1.popKey)(partialFolderSize, id) || { bytes, files, folders };
150
155
  },
151
156
  };
152
157
  function notifyClient(channel, name, data) {
package/src/plugins.js CHANGED
@@ -204,18 +204,16 @@ async function initPlugin(pl, morePassedToInit) {
204
204
  events_1.default.emit('pluginInitialized', pl);
205
205
  return pl;
206
206
  }
207
- const already = new Set();
208
- function warnOnce(msg) {
209
- if (already.has(msg))
210
- return;
211
- already.add(msg);
212
- console.log('Warning: ' + msg);
213
- }
214
207
  const pluginsMiddleware = async (ctx, next) => {
215
208
  const after = {};
216
209
  // run middleware plugins
217
210
  let lastStatus = ctx.status;
218
211
  let lastBody = ctx.body;
212
+ const res = await events_1.default.emitAsync('request', { ctx });
213
+ if (ctx.isAborted() || res?.isDefaultPrevented())
214
+ return;
215
+ if (res?.length)
216
+ after['/event'] = () => res.forEach(misc_1.callable);
219
217
  await Promise.all(mapPlugins(async (pl, id) => {
220
218
  try {
221
219
  const res = await pl.middleware?.(ctx);
package/src/roots.js CHANGED
@@ -12,7 +12,7 @@ const lodash_1 = __importDefault(require("lodash"));
12
12
  exports.roots = (0, config_1.defineConfig)(misc_1.CFG.roots, {}, map => {
13
13
  const list = Object.keys(map);
14
14
  const matchers = list.map(hostMask => (0, misc_1.makeMatcher)(hostMask));
15
- const values = Object.values(map).map(x => (0, misc_1.enforceFinal)('/', (0, misc_1.enforceStarting)('/', x)));
15
+ const values = Object.values(map).map(x => (0, misc_1.enforceFinal)('/', (0, misc_1.enforceStarting)('/', x.replace(/\/{2,}/g, '/'))));
16
16
  return (host) => values[matchers.findIndex(m => m(host))];
17
17
  });
18
18
  const forceAddress = (0, config_1.defineConfig)(misc_1.CFG.force_address, false);
package/src/update.js CHANGED
@@ -19,6 +19,7 @@ const misc_1 = require("./misc");
19
19
  const fs_1 = require("fs");
20
20
  const plugins_1 = require("./plugins");
21
21
  const promises_1 = require("fs/promises");
22
+ const open_1 = __importDefault(require("open"));
22
23
  const config_1 = require("./config");
23
24
  const util_os_1 = require("./util-os");
24
25
  const first_1 = require("./first");
@@ -190,11 +191,24 @@ if (argv_1.argv.updating) { // we were launched with a temporary name, restore o
190
191
  // have to relaunch with the new name, or otherwise the next update will fail with EBUSY on hfs.exe
191
192
  console.log(`renamed binary file to "${argv_1.argv.updating}" and now restarting`);
192
193
  // if you change anything, be sure to test launching both double-clicking and in a terminal
193
- if (const_1.IS_WINDOWS) // windows-only; this method on Mac works only once, and without the console
194
+ if (const_1.IS_WINDOWS) // windows-only; this method on mac+linux works only once, and without the console
194
195
  (0, first_1.onProcessExit)(() => (0, child_process_1.spawn)((0, util_os_1.cmdEscape)(dest), ['--updated', '--cwd .'], { detached: true, shell: true, stdio: [0, 1, 2] })); // launch+sync here would cause the old process to stay open, locking ports
195
- else if (process.stdin.isTTY && process.stdout.isTTY) // keep interactive terminal users attached to the restarted process
196
- (0, child_process_1.spawnSync)(dest, ['--updated', '--cwd', process.cwd()], { stdio: [0, 1, 2] });
197
- else
198
- (0, child_process_1.spawn)(dest, ['--updated', '--cwd', process.cwd()], { detached: true, stdio: 'ignore' }).unref();
196
+ else if (const_1.IS_MAC) {
197
+ // open() is the only consistent way that I could find working on macos preserving console input/output over relaunching,
198
+ // and it doesn't let us pass cli arguments, so we pass them through a temp file consumed at the next startup.
199
+ // For the record, on mac you can: write "./hfs arg1 arg2" to /tmp/tmp.sh with 0o700, and then spawn "open -a Terminal /tmp/tmp.sh"
200
+ try {
201
+ (0, fs_1.writeFileSync)(const_1.ARGS_FILE, JSON.stringify(['--updated', '--cwd', process.cwd()]));
202
+ }
203
+ catch { }
204
+ console.log('open-ing');
205
+ void (0, open_1.default)(dest);
206
+ }
207
+ else { // linux and other *nix
208
+ if (process.stdin.isTTY && process.stdout.isTTY) // in interactive terminals, block this bridge process on the restarted hfs so the terminal session stays attached
209
+ (0, child_process_1.spawnSync)(dest, ['--updated', '--cwd', process.cwd()], { stdio: [0, 1, 2] });
210
+ else
211
+ (0, child_process_1.spawn)(dest, ['--updated', '--cwd', process.cwd()], { detached: true, stdio: 'ignore' }).unref();
212
+ }
199
213
  process.exit();
200
214
  }
package/src/vfs.js CHANGED
@@ -324,6 +324,7 @@ async function* walkNode(parent, { ctx, depth = Infinity, prefixPath = '', requi
324
324
  && !hasPermission(parent, requiredPerm, ctx)
325
325
  && !masksCouldGivePermission(parent.masks, requiredPerm))
326
326
  return;
327
+ const pathMaskApplier = parentMaskApplier(parent, true);
327
328
  try {
328
329
  await (0, walkDir_1.walkDir)(source, { depth, ctx, hidden: showHiddenFiles.get(), parallelizeRecursion }, async (entry) => {
329
330
  if (ctx?.isAborted()) {
@@ -344,6 +345,8 @@ async function* walkNode(parent, { ctx, depth = Infinity, prefixPath = '', requi
344
345
  if (taken?.has(normalizeFilename(name))) // taken by vfs node above
345
346
  return false; // false just in case it's a folder
346
347
  const item = { name, isFolder, source: (0, path_1.join)(source, path), parent };
348
+ // masks containing '/' must be matched against the relative path while keeping walkDir recursion enabled
349
+ await pathMaskApplier(item, renamed || path);
347
350
  if (await cantSee(item)) // can't see: don't produce and don't recur
348
351
  return false;
349
352
  if (onlyFiles ? !isFolder : (!onlyFolders || isFolder))
@@ -359,7 +362,7 @@ async function* walkNode(parent, { ctx, depth = Infinity, prefixPath = '', requi
359
362
  finally {
360
363
  await childrenWorking;
361
364
  for (const [item, name] of visitLater)
362
- for await (const x of walkNode(item, { depth: depth - 1, prefixPath: name + '/', ctx, requiredPerm, onlyFolders, parallelizeRecursion }))
365
+ for await (const x of walkNode(item, { depth: depth - 1, prefixPath: name + '/', ctx, requiredPerm, onlyFolders, onlyFiles, parallelizeRecursion }))
363
366
  stream.push(x);
364
367
  stream.push(null);
365
368
  }
@@ -383,26 +386,40 @@ async function* walkNode(parent, { ctx, depth = Infinity, prefixPath = '', requi
383
386
  function masksCouldGivePermission(masks, perm) {
384
387
  return masks !== undefined && Object.values(masks).some(props => props[perm] || masksCouldGivePermission(props.masks, perm));
385
388
  }
386
- function parentMaskApplier(parent) {
389
+ function parentMaskApplier(parent, pathBased = false) {
387
390
  // rules are met in the parent.masks object from nearest to farthest, but since we finally apply with _.defaults, the nearest has precedence in the final result
388
- const matchers = (0, misc_1.onlyTruthy)(lodash_1.default.map(parent.masks, (mods, k) => {
391
+ const matchers = (0, misc_1.onlyTruthy)(lodash_1.default.map(parent.masks, (mods, mask) => {
389
392
  if (!mods)
390
393
  return;
391
394
  const mustBeFolder = (() => {
392
- if (k.at(-1) !== '|')
395
+ if (mask.at(-1) !== '|')
393
396
  return; // parse special flag syntax as suffix |FLAG| inside the key. This allows specifying different flags with the same mask using separate keys. To avoid syntax conflicts with the rest of the file-mask, we look for an ending pipe, as it has no practical use. Ending-pipe was preferred over starting-pipe to leave the rest of the logic (inheritMasks) untouched.
394
- const i = k.lastIndexOf('|', k.length - 2);
397
+ const i = mask.lastIndexOf('|', mask.length - 2);
395
398
  if (i < 0)
396
399
  return;
397
- const type = k.slice(i + 1, -1);
398
- k = k.slice(0, i); // remove
400
+ const type = mask.slice(i + 1, -1);
401
+ mask = mask.slice(0, i); // remove
399
402
  return type === 'folders';
400
403
  })();
401
- const m = /^(!?)\*\*\//.exec(k); // ** globstar matches also zero subfolders, so this mask must be applied here too
402
- k = m ? m[1] + k.slice(m[0].length) : !k.includes('/') ? k : '';
403
- return k && { mods, matcher: (0, misc_1.makeMatcher)(k), mustBeFolder };
404
+ if (pathBased) {
405
+ if (!mask.includes('/'))
406
+ return;
407
+ // avoid evaluating twice masks like **/*.png because parentMaskApplier already handles them by basename
408
+ const m = /^(!?)\*\*\//.exec(mask);
409
+ // this keeps the fast basename path as source-of-truth for patterns that collapse to a filename after **/
410
+ if (m && !mask.slice(m[0].length).includes('/'))
411
+ return;
412
+ }
413
+ else {
414
+ const m = /^(!?)\*\*\//.exec(mask); // ** globstar matches also zero subfolders, so this mask must be applied here too
415
+ mask = m ? m[1] + mask.slice(m[0].length) : !mask.includes('/') ? mask : '';
416
+ if (!mask)
417
+ return;
418
+ }
419
+ return mask && { matcher: (0, misc_1.makeMatcher)(mask), mods, mustBeFolder };
404
420
  }));
405
- return async (item, virtualBasename = (0, path_1.basename)(getNodeName(item))) => {
421
+ return async (item, virtualName = (pathBased ? lodash_1.default.identity : path_1.basename)(getNodeName(item))) => {
422
+ // depth traversal passes full relative paths, while node traversal still matches only basenames
406
423
  let isFolder = undefined;
407
424
  for (const { matcher, mods, mustBeFolder } of matchers) {
408
425
  if (mustBeFolder !== undefined) {
@@ -410,13 +427,14 @@ function parentMaskApplier(parent) {
410
427
  if (mustBeFolder !== isFolder)
411
428
  continue;
412
429
  }
413
- if (!matcher(virtualBasename))
430
+ if (!matcher(virtualName))
414
431
  continue;
415
432
  item.masks &&= lodash_1.default.merge(lodash_1.default.cloneDeep(mods.masks), item.masks); // item.masks must take precedence
416
433
  lodash_1.default.defaults(item, mods);
417
434
  }
418
435
  };
419
436
  }
437
+ // propagates masks, don't apply
420
438
  function inheritMasks(item, parent, virtualBasename = getNodeName(item)) {
421
439
  const { masks } = parent;
422
440
  if (!masks)
package/src/webdav.js CHANGED
@@ -1,6 +1,10 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.handledWebdav = handledWebdav;
7
+ const consumers_1 = require("node:stream/consumers");
4
8
  const vfs_1 = require("./vfs");
5
9
  const cross_1 = require("./cross");
6
10
  const stream_1 = require("stream");
@@ -14,14 +18,19 @@ const child_process_1 = require("child_process");
14
18
  const auth_1 = require("./auth");
15
19
  const config_1 = require("./config");
16
20
  const expiringCache_1 = require("./expiringCache");
21
+ const fast_xml_parser_1 = require("fast-xml-parser");
22
+ const lodash_1 = __importDefault(require("lodash"));
17
23
  const forceWebdavLogin = (0, config_1.defineConfig)(cross_1.CFG.force_webdav_login, true, compileWebdavAgentRegex);
18
24
  const webdavInitialAuth = (0, config_1.defineConfig)(cross_1.CFG.webdav_initial_auth, 'WebDAVFS', compileWebdavAgentRegex);
19
25
  const webdavPrompted = (0, expiringCache_1.expiringCache)(cross_1.DAY);
20
- const webdavDetectedAgents = new Set();
26
+ const webdavDetectedAgents = (0, expiringCache_1.expiringCache)(cross_1.DAY);
21
27
  const TOKEN_HEADER = 'lock-token';
22
- const WEBDAV_METHODS = new Set(['PROPFIND', 'PROPPATCH', 'MKCOL', 'MOVE', 'LOCK', 'UNLOCK']);
28
+ const WEBDAV_METHODS = new Set(['PROPFIND', 'MKCOL', 'MOVE', 'LOCK', 'UNLOCK']);
23
29
  const WEBDAV_HINT_HEADERS = ['depth', 'destination', 'overwrite', 'translate', 'if', TOKEN_HEADER, 'x-expected-entity-length'];
24
30
  const KNOWN_UA = /webdav|miniredir|davclnt/i;
31
+ const LOCK_DEFAULT_SECONDS = 3600;
32
+ const LOCK_MAX_SECONDS = cross_1.DAY / 1000;
33
+ const xmlParser = new fast_xml_parser_1.XMLParser({ ignoreAttributes: false, removeNSPrefix: true, trimValues: true });
25
34
  const canOverwrite = new Set();
26
35
  const locks = new Map();
27
36
  function isLocked(path, ctx) {
@@ -41,17 +50,16 @@ function hasToken(header, token) {
41
50
  return header.includes(`<${token}>`) || header.split(/[,;\s]+/).includes(token);
42
51
  }
43
52
  async function handledWebdav(ctx) {
44
- const { path } = ctx;
45
- const isWebdavAuthRequest = WEBDAV_METHODS.has(ctx.method) || WEBDAV_HINT_HEADERS.some(h => !!ctx.get(h));
53
+ let { path } = ctx;
54
+ path = path.replace(/^\/+/, '/'); // double-slash is causing empty listing in filezilla-pro
46
55
  const ua = ctx.get('user-agent');
47
- if (isWebdavAuthRequest && (0, auth_1.getCurrentUsername)(ctx)) {
48
- if (ua)
49
- webdavDetectedAgents.add(ua);
50
- }
51
- if (ctx.path.includes('/._') && ua?.startsWith('WebDAVFS')) { // too much spam from Finder for these files that can contain metas
56
+ if (path.includes('/._') && ua?.startsWith('WebDAVFS')) { // too much spam from Finder for these files that can contain metas
52
57
  ctx.state.dontLog = true;
53
58
  return ctx.status = cross_1.HTTP_FORBIDDEN;
54
59
  }
60
+ const isWebdavAuthRequest = WEBDAV_METHODS.has(ctx.method) || WEBDAV_HINT_HEADERS.some(h => ctx.get(h));
61
+ if (isWebdavAuthRequest && ua && (0, auth_1.getCurrentUsername)(ctx))
62
+ webdavDetectedAgents.try(webdavAgentKey(ctx, ua), () => true);
55
63
  if (ctx.method === 'OPTIONS') {
56
64
  if (ctx.get('Access-Control-Request-Method'))
57
65
  return; // it's a preflight cors request, not webdav
@@ -78,7 +86,7 @@ async function handledWebdav(ctx) {
78
86
  }
79
87
  if (x && ctx.length === undefined) // missing length can make PUT fail
80
88
  ctx.req.headers['content-length'] = x;
81
- if (KNOWN_UA.test(ua) || webdavDetectedAgents.has(ua))
89
+ if (KNOWN_UA.test(ua) || webdavDetectedAgents.has(webdavAgentKey(ctx, ua)))
82
90
  ctx.query.existing ??= 'overwrite'; // with webdav this is our default
83
91
  return; // default handling
84
92
  }
@@ -157,22 +165,60 @@ async function handledWebdav(ctx) {
157
165
  }
158
166
  if (ctx.method === 'LOCK') {
159
167
  setWebdavHeaders();
168
+ const body = ctx.length || ctx.get('content-length') || ctx.get('transfer-encoding') ? await (0, consumers_1.text)(ctx.req) : '';
169
+ const token = getProvidedLockToken(ctx);
170
+ let seconds = Number(ctx.get('timeout').split(',').find(x => /^Second-\d+$/i.test(x.trim()))?.trim().split('-', 2)[1]);
171
+ seconds = lodash_1.default.clamp(seconds || LOCK_DEFAULT_SECONDS, 1, LOCK_MAX_SECONDS);
172
+ if (!body) {
173
+ // Finder and similar clients refresh an existing lock by sending LOCK without a body
174
+ if (!token)
175
+ return ctx.status = cross_1.HTTP_BAD_REQUEST;
176
+ const lock = locks.get(path);
177
+ if (token !== lock?.token)
178
+ return ctx.status = cross_1.HTTP_PRECONDITION_FAILED;
179
+ // refresh lock – keep the same token on refresh so clients can continue using the lock they already hold
180
+ clearTimeout(lock.timeout);
181
+ lock.timeout = setTimeout(() => locks.delete(path), seconds * 1000);
182
+ lock.seconds = seconds;
183
+ locks.set(path, lock);
184
+ ctx.set(TOKEN_HEADER, lock.token);
185
+ ctx.body = renderLockResponse(lock.token, lock.seconds);
186
+ return true;
187
+ }
188
+ const lockinfo = (0, cross_1.try_)(() => xmlParser.parse(body).lockinfo);
189
+ const scope = lodash_1.default.keys(lockinfo?.lockscope)[0];
190
+ const type = lodash_1.default.keys(lockinfo?.locktype)[0];
191
+ if (!scope || !type)
192
+ return ctx.status = cross_1.HTTP_BAD_REQUEST;
193
+ if (ctx.get('depth') && ctx.get('depth') !== '0')
194
+ return ctx.status = cross_1.HTTP_CONFLICT;
195
+ if (scope !== 'exclusive' || type !== 'write')
196
+ return ctx.status = cross_1.HTTP_CONFLICT;
160
197
  if (locks.has(path))
161
- return ctx.status = 423;
162
- const token = 'urn:uuid:' + (0, node_crypto_1.randomUUID)();
163
- ctx.set(TOKEN_HEADER, token);
164
- const seconds = 3600;
198
+ return ctx.status = cross_1.HTTP_LOCKED;
199
+ const newToken = 'urn:uuid:' + (0, node_crypto_1.randomUUID)();
165
200
  const timeout = setTimeout(() => locks.delete(path), seconds * 1000);
166
- locks.set(path, { token, timeout });
167
- ctx.body = `<?xml version="1.0" encoding="utf-8"?><prop xmlns="DAV:"><lockdiscovery><activelock>
168
- <locktype><write/></locktype>
169
- <lockscope><exclusive/></lockscope>
170
- <locktoken><href>${token}</href></locktoken>
171
- <lockroot><href>${path}</href></lockroot>
172
- <depth>0</depth>
173
- <timeout>Second-${seconds}</timeout>
174
- </activelock></lockdiscovery></prop>`;
201
+ locks.set(path, { token: newToken, timeout, seconds });
202
+ ctx.set(TOKEN_HEADER, newToken);
203
+ ctx.body = renderLockResponse(newToken, seconds);
175
204
  return true;
205
+ function getProvidedLockToken(ctx) {
206
+ const direct = ctx.get(TOKEN_HEADER).replace(/[<>]/g, '');
207
+ if (direct)
208
+ return direct;
209
+ const ifHeader = ctx.get('If');
210
+ return /<([^>]+)>/.exec(ifHeader)?.[1] || '';
211
+ }
212
+ function renderLockResponse(token, seconds) {
213
+ return `<?xml version="1.0" encoding="utf-8"?><prop xmlns="DAV:"><lockdiscovery><activelock>
214
+ <locktype><write/></locktype>
215
+ <lockscope><exclusive/></lockscope>
216
+ <locktoken><href>${token}</href></locktoken>
217
+ <lockroot><href>${path}</href></lockroot>
218
+ <depth>0</depth>
219
+ <timeout>Second-${seconds}</timeout>
220
+ </activelock></lockdiscovery></prop>`;
221
+ }
176
222
  }
177
223
  if (ctx.method === 'PROPFIND') {
178
224
  setWebdavHeaders();
@@ -189,7 +235,7 @@ async function handledWebdav(ctx) {
189
235
  }
190
236
  ctx.type = 'xml';
191
237
  ctx.status = 207;
192
- const pathSlash = (0, cross_1.enforceFinal)('/', path);
238
+ const outPath = (0, cross_1.enforceFinal)('/', path.slice(Math.max(0, (ctx.state.root?.length ?? 0) - 1)), true);
193
239
  const res = ctx.body = new stream_1.PassThrough({ encoding: 'utf8' });
194
240
  res.write(`<?xml version="1.0" encoding="utf-8" ?><multistatus xmlns="DAV:">`);
195
241
  await sendEntry(node);
@@ -208,7 +254,7 @@ async function handledWebdav(ctx) {
208
254
  const isDir = await (0, vfs_1.nodeIsFolder)(node);
209
255
  const st = await (0, vfs_1.nodeStats)(node);
210
256
  res.write(`<response>
211
- <href>${pathSlash + (append ? (0, cross_1.pathEncode)(name, true) + (isDir ? '/' : '') : '')}</href>
257
+ <href>${outPath + (append ? (0, cross_1.pathEncode)(name, true) + (isDir ? '/' : '') : '')}</href>
212
258
  <propstat>
213
259
  <status>HTTP/1.1 200 OK</status>
214
260
  <prop>
@@ -224,34 +270,12 @@ async function handledWebdav(ctx) {
224
270
  }
225
271
  if (ctx.method === 'PROPPATCH') {
226
272
  setWebdavHeaders();
227
- if (isLocked(path, ctx))
228
- return true;
229
- const node = await (0, vfs_1.urlToNode)(path, ctx);
230
- if (!node)
231
- return;
232
- if ((0, vfs_1.statusCodeForMissingPerm)(node, 'can_see', ctx)) {
233
- if (ctx.status === cross_1.HTTP_UNAUTHORIZED)
234
- setWebdavHeaders(true);
235
- return true;
236
- }
237
- ctx.type = 'xml';
238
- ctx.status = 207;
239
- ctx.body = `<?xml version="1.0" encoding="utf-8"?>
240
- <multistatus xmlns="DAV:">
241
- <response>
242
- <href>${path}</href>
243
- <propstat>
244
- <status>HTTP/1.1 200 OK</status>
245
- <prop/>
246
- </propstat>
247
- </response>
248
- </multistatus>`;
249
- return true;
273
+ return ctx.status = cross_1.HTTP_METHOD_NOT_ALLOWED;
250
274
  }
251
275
  function setWebdavHeaders(authenticate = false) {
252
276
  ctx.set('DAV', '1,2');
253
277
  ctx.set('MS-Author-Via', 'DAV');
254
- ctx.set('Allow', 'PROPFIND,PROPPATCH,OPTIONS,DELETE,MOVE,LOCK,UNLOCK,MKCOL,PUT');
278
+ ctx.set('Allow', 'PROPFIND,OPTIONS,DELETE,MOVE,LOCK,UNLOCK,MKCOL,PUT');
255
279
  if (authenticate)
256
280
  ctx.set('WWW-Authenticate', `Basic realm="HFS WebDAV"`); // keep a dedicated realm for WebDAV so Windows credential cache is isolated from other basic-auth flows
257
281
  }
@@ -280,6 +304,10 @@ async function handledWebdav(ctx) {
280
304
  function compileWebdavAgentRegex(v) {
281
305
  return !v ? null : v === true ? /.*/ : new RegExp(v.trim(), 'i');
282
306
  }
307
+ function webdavAgentKey(ctx, ua) {
308
+ // tying detection to source IP avoids promoting one spoofed UA to global WebDAV behavior
309
+ return `${ctx.ip}|${ua}`;
310
+ }
283
311
  // Finder will upload special attributes as files with name ._* that can be merged using system utility "dot_clean"
284
312
  const cleaners = {};
285
313
  function dotClean(path) {