hfs 3.1.0-beta4 → 3.1.0-beta6

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.
Files changed (52) hide show
  1. package/README.md +7 -11
  2. package/admin/assets/{index-BL84dr-c.js → index-14Q6fQhw.js} +1 -1
  3. package/admin/assets/{index-aI7IEKyS.js → index-VnuTY7OR.js} +98 -98
  4. package/admin/assets/{sha512-BUo7IPyY.js → sha512-DDIK-e99.js} +1 -1
  5. package/admin/index.html +1 -1
  6. package/frontend/assets/{index-legacy-CVyhaUXB.js → index-legacy-C8oPp-Hh.js} +1 -1
  7. package/frontend/assets/index-legacy-l9a4YlnQ.js +9 -0
  8. package/frontend/assets/{sha512-legacy-CXmh1M3g.js → sha512-legacy-DOR1zo9J.js} +1 -1
  9. package/frontend/index.html +1 -1
  10. package/npm-shrinkwrap.json +28 -28
  11. package/package.json +1 -1
  12. package/src/cross-const.js +2 -1
  13. package/src/cross.js +1 -0
  14. package/src/github.js +2 -1
  15. package/src/index.js +6 -5
  16. package/src/langs/hfs-lang-ar.json +1 -1
  17. package/src/langs/hfs-lang-bg.json +1 -1
  18. package/src/langs/hfs-lang-de.json +1 -1
  19. package/src/langs/hfs-lang-el.json +1 -1
  20. package/src/langs/hfs-lang-es.json +1 -1
  21. package/src/langs/hfs-lang-fi.json +1 -1
  22. package/src/langs/hfs-lang-fr.json +1 -1
  23. package/src/langs/hfs-lang-hu.json +1 -1
  24. package/src/langs/hfs-lang-it.json +1 -1
  25. package/src/langs/hfs-lang-ja.json +1 -1
  26. package/src/langs/hfs-lang-ko.json +1 -1
  27. package/src/langs/hfs-lang-lt.json +1 -1
  28. package/src/langs/hfs-lang-ms.json +1 -1
  29. package/src/langs/hfs-lang-nl.json +1 -1
  30. package/src/langs/hfs-lang-pt-br.json +1 -1
  31. package/src/langs/hfs-lang-ro.json +1 -1
  32. package/src/langs/hfs-lang-ru.json +1 -1
  33. package/src/langs/hfs-lang-sr.json +1 -1
  34. package/src/langs/hfs-lang-th.json +1 -1
  35. package/src/langs/hfs-lang-tr.json +1 -1
  36. package/src/langs/hfs-lang-uk.json +1 -1
  37. package/src/langs/hfs-lang-vi.json +1 -1
  38. package/src/langs/hfs-lang-zh-tw.json +1 -1
  39. package/src/langs/hfs-lang-zh.json +1 -1
  40. package/src/listen.js +3 -0
  41. package/src/log.js +12 -10
  42. package/src/makeQ.js +10 -4
  43. package/src/multipartUpload.js +7 -1
  44. package/src/serveGuiAndSharedFiles.js +10 -8
  45. package/src/serveGuiFiles.js +2 -1
  46. package/src/throttler.js +2 -1
  47. package/src/update.js +5 -2
  48. package/src/upload.js +4 -3
  49. package/src/util-os.js +26 -2
  50. package/src/vfs.js +5 -13
  51. package/src/webdav.js +87 -47
  52. package/frontend/assets/index-legacy-f8_Sicwh.js +0 -9
package/src/log.js CHANGED
@@ -101,7 +101,7 @@ const logUA = (0, config_1.defineConfig)(misc_1.CFG.log_ua, false);
101
101
  const logSpam = (0, config_1.defineConfig)(misc_1.CFG.log_spam, false);
102
102
  const debounce = lodash_1.default.debounce(cb => cb(), 1000); // with this technique, i'll be able to debounce some code respecting the references in its closure
103
103
  const logMw = async (ctx, next) => {
104
- const now = new Date(); // request start
104
+ const reqStart = new Date(); // request start
105
105
  const userAtStart = (0, auth_1.getCurrentUsername)(ctx);
106
106
  // do it now so it's available for returning plugins
107
107
  ctx.state.completed = Promise.race([(0, events_1.once)(ctx.res, 'finish'), (0, events_1.once)(ctx.res, 'close')]);
@@ -130,13 +130,14 @@ const logMw = async (ctx, next) => {
130
130
  let { stream, last, path } = logger;
131
131
  if (!stream)
132
132
  return;
133
- logger.last = now;
133
+ logger.last = reqStart;
134
134
  if (rotate && last) { // rotation enabled and a file exists?
135
- const passed = Number(now) - Number(last)
135
+ const passed = Number(reqStart) - Number(last)
136
136
  - 3600_000; // be pessimistic and count a possible DST change
137
- if (rotate === 'm' && (passed >= 31 * misc_1.DAY || now.getMonth() !== last.getMonth())
138
- || rotate === 'd' && (passed >= misc_1.DAY || now.getDate() !== last.getDate()) // checking passed will solve the case when the day of the month is the same but a month has passed
139
- || rotate === 'w' && (passed >= 7 * misc_1.DAY || now.getDay() < last.getDay())) {
137
+ const t = reqStart;
138
+ if (rotate === 'm' && (passed >= 31 * misc_1.DAY || t.getMonth() !== last.getMonth())
139
+ || rotate === 'd' && (passed >= misc_1.DAY || t.getDate() !== last.getDate()) // checking passed will solve the case when the day of the month is the same but a month has passed
140
+ || rotate === 'w' && (passed >= 7 * misc_1.DAY || t.getDay() < last.getDay())) {
140
141
  stream.end();
141
142
  const suffix = '-' + last.getFullYear() + '-' + doubleDigit(last.getMonth() + 1) + '-' + doubleDigit(last.getDate());
142
143
  const newPath = (0, misc_1.strinsert)(path, path.length - (0, path_1.extname)(path).length, suffix);
@@ -152,12 +153,13 @@ const logMw = async (ctx, next) => {
152
153
  }
153
154
  }
154
155
  const format = '%s - %s [%s] "%s %s HTTP/%s" %d %s %s\n'; // Apache's Common Log Format
155
- const a = now.toString().split(' '); // like nginx, our default log contains the time of log writing
156
- const date = a[2] + '/' + a[1] + '/' + a[3] + ':' + a[4] + ' ' + a[5]?.slice(3);
156
+ // keep reqStart for duration/rotation, but log time should reflect when the line is actually written
157
+ const a = new Date().toString().split(' '); // like nginx, our default log contains the time of log writing
158
+ const date = `${a[2]}/${a[1]}/${a[3]}:${a[4]} ${a[5]?.slice(3)}`;
157
159
  const user = (0, auth_1.getCurrentUsername)(ctx) || userAtStart;
158
160
  const length = ctx.state.length ?? ctx.length;
159
161
  const uri = ctx.originalUrl;
160
- const duration = (Date.now() - Number(now)) / 1000;
162
+ const duration = (Date.now() - Number(reqStart)) / 1000;
161
163
  ctx.logExtra(ctx.vfsNode && {
162
164
  speed: Math.round(length / duration),
163
165
  ...ctx.state.includesLastByte && ctx.res.finished && { dl: 1 }
@@ -172,7 +174,7 @@ const logMw = async (ctx, next) => {
172
174
  ctx.logExtra({ ua: ctx.get('user-agent') || undefined });
173
175
  const extra = ctx.state.logExtra;
174
176
  if (events_2.default.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.
175
- events_2.default.emit(logger.name, { ctx, length, user, ts: now, uri, extra });
177
+ events_2.default.emit(logger.name, { ctx, length, user, ts: reqStart, uri, extra });
176
178
  debounce(() => // once in a while we check if the file is still good (not deleted, etc), or we'll reopen it
177
179
  (0, util_files_1.statWithTimeout)(logger.path).catch(() => logger.reopen())); // async = smoother but we may lose some entries
178
180
  stream.write(util.format(format, ctx.ip, user || '-', date, ctx.method, uri, ctx.req.httpVersion, ctx.status, length?.toString() ?? '-', lodash_1.default.isEmpty(extra) ? '' : JSON.stringify(JSON.stringify(extra))));
package/src/makeQ.js CHANGED
@@ -1,27 +1,33 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.makeQ = makeQ;
4
- function makeQ(parallelization = 1) {
4
+ const asap = globalThis.setImmediate || setTimeout;
5
+ function makeQ(parallelization = 1, max = Infinity) {
5
6
  const running = new Set();
6
7
  const queued = [];
7
8
  return {
8
9
  add(toAdd) {
10
+ if (queued.length >= max + parallelization - running.size) // we may have some free slots that will be used at the next tick
11
+ return false;
9
12
  queued.push(toAdd);
10
- setTimeout(startNextIfPossible); // avoid nesting/stacking of jobs
13
+ asap(startNextIfPossible); // avoid calling now, as it would cause nesting/stacking of jobs
14
+ return true;
11
15
  },
12
16
  isWorking() { return running.size > 0; },
13
17
  isFree() { return running.size < parallelization; },
18
+ setMax(newMax) { max = newMax; },
19
+ queueSize() { return queued.length; },
14
20
  };
15
21
  function startNextIfPossible() {
16
22
  while (running.size < parallelization) {
17
- const job = queued.pop();
23
+ const job = queued.shift();
18
24
  if (!job)
19
25
  break; // finished
20
26
  const working = job(); // start the job
21
27
  if (!working)
22
28
  continue; // it was canceled
23
29
  running.add(working);
24
- working.then(() => {
30
+ working.finally(() => {
25
31
  running.delete(working);
26
32
  startNextIfPossible();
27
33
  });
@@ -11,6 +11,7 @@ const path_1 = require("path");
11
11
  const upload_1 = require("./upload");
12
12
  const cross_const_1 = require("./cross-const");
13
13
  const first_1 = require("./first");
14
+ const cross_1 = require("./cross");
14
15
  async function handleMultipartUpload(ctx, node) {
15
16
  if (ctx.request.type !== 'multipart/form-data')
16
17
  return ctx.status = cross_const_1.HTTP_BAD_REQUEST;
@@ -18,7 +19,12 @@ async function handleMultipartUpload(ctx, node) {
18
19
  const locks = [];
19
20
  const fileJobs = [];
20
21
  const errors = [];
21
- const bb = (0, busboy_1.default)({ headers: ctx.req.headers, preservePath: true });
22
+ const bb = (0, cross_1.try_)(() => (0, busboy_1.default)({ headers: ctx.req.headers, preservePath: true }), e => {
23
+ ctx.body = String(e); // busboy validates multipart headers at construction time, so malformed requests must stop here as 400
24
+ ctx.status = cross_const_1.HTTP_BAD_REQUEST;
25
+ });
26
+ if (!bb)
27
+ return;
22
28
  bb.on('field', (name) => {
23
29
  if (name === 'upload')
24
30
  errors.push('empty filename');
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.serveGuiAndSharedFiles = void 0;
6
+ exports.serveSharedFiles = exports.guiFilesMiddleware = void 0;
7
7
  const path_1 = require("path");
8
8
  const vfs_1 = require("./vfs");
9
9
  const errorPages_1 = require("./errorPages");
@@ -28,12 +28,11 @@ const comments_1 = require("./comments");
28
28
  const basicWeb_1 = require("./basicWeb");
29
29
  const icons_1 = require("./icons");
30
30
  const plugins_1 = require("./plugins");
31
- const webdav_1 = require("./webdav");
32
31
  const serveFrontendFiles = (0, serveGuiFiles_1.serveGuiFiles)(process.env.FRONTEND_PROXY, cross_const_1.FRONTEND_URI);
33
32
  const serveFrontendPrefixed = (0, koa_mount_1.default)(cross_const_1.FRONTEND_URI.slice(0, -1), serveFrontendFiles);
34
33
  const serveAdminFiles = (0, serveGuiFiles_1.serveGuiFiles)(process.env.ADMIN_PROXY, cross_const_1.ADMIN_URI);
35
34
  const serveAdminPrefixed = (0, koa_mount_1.default)(cross_const_1.ADMIN_URI.slice(0, -1), serveAdminFiles);
36
- const serveGuiAndSharedFiles = async (ctx, next) => {
35
+ const guiFilesMiddleware = async (ctx, next) => {
37
36
  const { path } = ctx;
38
37
  // dynamic import on frontend|admin (used for non-https login) while developing (vite4) is not producing a relative path
39
38
  if (const_1.DEV && path.startsWith('/node_modules/')) {
@@ -59,15 +58,18 @@ const serveGuiAndSharedFiles = async (ctx, next) => {
59
58
  ctx.state.considerAsGui = true;
60
59
  return (0, serveFile_1.serveFile)(ctx, (0, path_1.join)(plugin?.folder || '', icons_1.ICONS_FOLDER, file), const_1.MIME_AUTO);
61
60
  }
62
- if (await (0, webdav_1.handledWebdav)(ctx))
63
- return;
61
+ return next();
62
+ };
63
+ exports.guiFilesMiddleware = guiFilesMiddleware;
64
+ const serveSharedFiles = async (ctx, next) => {
65
+ const { path } = ctx;
64
66
  const { get } = ctx.query;
65
67
  const getUploadTempHash = get === cross_const_1.UPLOAD_TEMP_HASH;
66
68
  if (ctx.method === 'PUT' || getUploadTempHash) { // PUT is what you get with `curl -T file url/`
67
69
  const decPath = (0, misc_1.safeDecodeURIComponent)(path, '');
68
70
  const fn = (0, path_1.basename)(decPath);
69
71
  const folderUri = (0, misc_1.pathEncode)((0, path_1.dirname)(decPath)); // re-encode to get readable urls
70
- const folder = await (0, vfs_1.urlToNode)(folderUri, ctx, vfs_1.vfs, true);
72
+ const folder = await (0, vfs_1.urlToNode)(folderUri, ctx, vfs_1.vfs, true); // we don't require the folder to already exist, but to be mapped on disk AND to have proper permissions
71
73
  if (!folder)
72
74
  return (0, errorPages_1.sendErrorPage)(ctx, cross_const_1.HTTP_NOT_FOUND);
73
75
  ctx.state.uploadPath = decPath;
@@ -149,7 +151,7 @@ const serveGuiAndSharedFiles = async (ctx, next) => {
149
151
  : get === 'list' ? sendFolderList(node, ctx)
150
152
  : ((0, basicWeb_1.basicWeb)(ctx, node) || serveFrontendFiles(ctx, next));
151
153
  };
152
- exports.serveGuiAndSharedFiles = serveGuiAndSharedFiles;
154
+ exports.serveSharedFiles = serveSharedFiles;
153
155
  async function sendFolderList(node, ctx) {
154
156
  if ((await events_1.default.emitAsync('getList', { node, ctx }))?.isDefaultPrevented())
155
157
  return;
@@ -161,7 +163,7 @@ async function sendFolderList(node, ctx) {
161
163
  || URL.protocol + '//' + URL.host + ctx.state.revProxyPath;
162
164
  prepend = base + (0, misc_1.pathEncode)(decodeURI(ctx.path)); // redo the encoding our way, keeping unicode chars unchanged
163
165
  }
164
- const walker = (0, vfs_1.walkNode)(node, { ctx, depth: depth === '*' ? Infinity : Number(depth), parallelizeRecursion: false });
166
+ const walker = (0, vfs_1.walkNode)(node, { ctx, depth: depth === '*' ? Infinity : Number(depth), parallelizeRecursion: false }); // parallelization produces out-of-order results, and we don't want it like that here
165
167
  ctx.body = (0, misc_1.asyncGeneratorToReadable)((0, misc_1.filterMapGenerator)(walker, async (el) => {
166
168
  const isFolder = (0, vfs_1.nodeIsFolder)(el);
167
169
  return !folders && isFolder ? undefined
@@ -90,6 +90,7 @@ async function treatIndex(ctx, filesUri, body) {
90
90
  VERSION: const_1.VERSION,
91
91
  API_VERSION: const_1.API_VERSION,
92
92
  SPECIAL_URI: const_1.SPECIAL_URI, PLUGINS_PUB_URI: const_1.PLUGINS_PUB_URI, FRONTEND_URI: const_1.FRONTEND_URI,
93
+ pathSeparator: path_1.sep,
93
94
  session: session instanceof apiMiddleware_1.ApiError ? null : session,
94
95
  plugins,
95
96
  loadScripts: Object.fromEntries((0, plugins_1.mapPlugins)((p, id) => [id, p.frontend_js?.map(f => f.includes('//') ? f : pub + id + '/' + f)])),
@@ -174,7 +175,7 @@ function serveProxied(port, uri) {
174
175
  return function (ctx, next) {
175
176
  if (!exports.logGui.get())
176
177
  ctx.state.dontLog = true;
177
- return proxy(ctx, next);
178
+ return proxy(ctx, async () => { });
178
179
  };
179
180
  }
180
181
  function serveGuiFiles(proxyPort, uri) {
package/src/throttler.js CHANGED
@@ -57,7 +57,8 @@ const throttler = async (ctx, next) => {
57
57
  if (outSpeed || !closed)
58
58
  conn[SymTimeout] = setTimeout(update, DELAY);
59
59
  }, DELAY, { leading: true, maxWait: DELAY });
60
- ts.on('sent', (n) => {
60
+ ts.on('sent', async (n) => {
61
+ await exports.totalSent.ready();
61
62
  exports.totalSent.set(x => x + n);
62
63
  update();
63
64
  });
package/src/update.js CHANGED
@@ -134,13 +134,16 @@ async function update(tagOrUrl = '') {
134
134
  }
135
135
  if (url) {
136
136
  console.log("downloading", url);
137
+ const temp = LOCAL_UPDATE + '-temp';
138
+ await (0, promises_1.rm)(temp, { force: true });
137
139
  try {
138
- await (0, promises_1.writeFile)(LOCAL_UPDATE, await (0, misc_1.httpStream)(url));
140
+ await (0, promises_1.writeFile)(temp, await (0, misc_1.httpStream)(url));
139
141
  }
140
142
  catch (e) {
141
- await (0, promises_1.rm)(LOCAL_UPDATE).catch(() => { }); // no leftovers
143
+ await (0, promises_1.rm)(temp).catch(() => { }); // no leftovers
142
144
  throw "Download failed for " + url + (0, misc_1.prefix)(' – ', e?.message);
143
145
  }
146
+ await (0, promises_1.rename)(temp, LOCAL_UPDATE);
144
147
  console.debug("download finished");
145
148
  }
146
149
  const bin = process.execPath;
package/src/upload.js CHANGED
@@ -66,12 +66,13 @@ function uploadWriter(base, baseUri, filename, ctx) {
66
66
  // enforce minAvailableMb
67
67
  const min = exports.minAvailableMb.get() * (1 << 20);
68
68
  const { simulate } = ctx.query; // `simulate` is used to get the same error but with an empty body, so that the request is processed quickly
69
- const contentLength = Number(simulate || ctx.headers["content-length"]);
70
- const isPartial = ctx.query.partial !== undefined; // while the presence of "partial" conveys the upload is split...
69
+ const contentLength = Number(simulate ?? ctx.headers["content-length"]
70
+ ?? ctx.headers['x-expected-entity-length']); // some webdav clients send this; it's not equivalent to content-length, but can get the job done in some cases
71
+ const isPartial = ctx.query.partial !== undefined; // while the presence of "partial" conveys that the upload is split...
71
72
  const stillToWrite = Math.max(contentLength, Number(ctx.query.partial) || 0); // ...the number is used to tell how much space we need (fullSize - offset)
72
73
  if (isNaN(stillToWrite)) {
73
74
  if (min)
74
- return fail(const_1.HTTP_BAD_REQUEST, 'content-length mandatory');
75
+ return fail(const_1.HTTP_LENGTH_REQUIRED);
75
76
  }
76
77
  else
77
78
  try {
package/src/util-os.js CHANGED
@@ -84,15 +84,39 @@ async function getWindowsServicePids() {
84
84
  const no = parsed?.[1]?.[2];
85
85
  return Object.fromEntries(parsed.slice(2).filter(x => x[2] !== no).map(x => [x[1], x[2]]));
86
86
  }
87
- exports.RUNNING_AS_SERVICE = const_1.IS_WINDOWS && getWindowsServicePids().then(x => {
88
- const ret = x[node_process_1.pid] || x[node_process_1.ppid];
87
+ exports.RUNNING_AS_SERVICE = detectRunningAsService().then(ret => {
89
88
  if (ret)
90
89
  console.log("running as service", ret);
91
90
  return ret;
92
91
  }, e => {
93
92
  console.log("couldn't determine if we are running as a service");
94
93
  console.debug(e);
94
+ return false;
95
95
  });
96
+ async function detectRunningAsService() {
97
+ if (const_1.IS_WINDOWS)
98
+ return getWindowsServicePids().then(x => x[node_process_1.pid] || x[node_process_1.ppid]);
99
+ if (process.platform === 'linux') {
100
+ // interactive shells live in session-*.scope; only a leaf *.service means this process is actually inside a service unit
101
+ const systemdFromCgroup = await (0, promises_1.readFile)('/proc/self/cgroup', 'utf8').then(cgroup => cgroup.split('\n')
102
+ // cgroup v1 uses single-colon fields while v2 uses double-colon; taking the last colon-separated field covers both
103
+ .map(line => line.split(':').pop()?.split('/').pop() || '')
104
+ .find(unit => unit.endsWith('.service')));
105
+ if (systemdFromCgroup)
106
+ return systemdFromCgroup;
107
+ // keep env markers as a last fallback only for non-interactive runs, to avoid false positives in normal terminal sessions
108
+ if (!process.stdin.isTTY && !process.stdout.isTTY && (process.env.INVOCATION_ID || process.env.JOURNAL_STREAM || process.env.NOTIFY_SOCKET || process.env.SYSTEMD_EXEC_PID))
109
+ return process.env.INVOCATION_ID || process.env.SYSTEMD_EXEC_PID || 'systemd';
110
+ return false;
111
+ }
112
+ // launchd services expose LAUNCH_JOB_NAME and run detached from interactive ttys
113
+ if (!const_1.IS_MAC || process.stdin.isTTY || process.stdout.isTTY)
114
+ return false;
115
+ const launchdService = process.env.LAUNCH_JOB_NAME || false;
116
+ if (launchdService)
117
+ return launchdService;
118
+ return false;
119
+ }
96
120
  function reg(...pars) {
97
121
  return (0, util_1.promisify)(child_process_1.execFile)('reg', pars).then(x => x.stdout);
98
122
  }
package/src/vfs.js CHANGED
@@ -92,7 +92,8 @@ async function applyParentToChild(child, parent, name) {
92
92
  inheritFromParent(ret);
93
93
  return ret;
94
94
  }
95
- async function urlToNode(url, ctx, parent = exports.vfs, resolveMissing) {
95
+ async function urlToNode(url, ctx, parent = exports.vfs, allowMissing // true means missing path segments still resolve to temporary nodes with a computed source path
96
+ ) {
96
97
  let initialSlashes = 0;
97
98
  while (url[initialSlashes] === '/')
98
99
  initialSlashes++;
@@ -105,25 +106,16 @@ async function urlToNode(url, ctx, parent = exports.vfs, resolveMissing) {
105
106
  return;
106
107
  const hasTrailingSlash = url.endsWith('/');
107
108
  const rest = nextSlash < 0 ? '' : url.slice(nextSlash + 1, hasTrailingSlash ? -1 : undefined);
108
- const allowMissing = resolveMissing === true;
109
109
  const assumeFolder = allowMissing && (rest > '' || hasTrailingSlash);
110
110
  const ret = await getNodeByName(name, parent, assumeFolder);
111
111
  if (!ret)
112
112
  return;
113
113
  if (rest || ret?.original)
114
- return urlToNode(rest, ctx, ret, resolveMissing);
114
+ return urlToNode(rest, ctx, ret, allowMissing);
115
115
  if (ret.source)
116
- if (!showHiddenFiles.get() && await isHiddenFile(ret.source))
116
+ if (!showHiddenFiles.get() && await isHiddenFile(ret.source)
117
+ || !allowMissing && await setIsFolder(ret) === undefined) // undefined = not found on disk
117
118
  return;
118
- else if (await setIsFolder(ret) === undefined) { // undefined = not found on disk
119
- if (!resolveMissing)
120
- return;
121
- if (allowMissing)
122
- return ret;
123
- const rest = ret.source.slice(parent.source.length); // we know parent has .source, otherwise !ret.source || ret.original
124
- resolveMissing((0, misc_1.removeStarting)('/', rest));
125
- return parent;
126
- }
127
119
  return ret;
128
120
  }
129
121
  async function nodeStats(node) {
package/src/webdav.js CHANGED
@@ -3,7 +3,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.handledWebdav = handledWebdav;
6
+ exports.webdav = void 0;
7
+ exports.releaseWebdavLock = releaseWebdavLock;
7
8
  const consumers_1 = require("node:stream/consumers");
8
9
  const vfs_1 = require("./vfs");
9
10
  const cross_1 = require("./cross");
@@ -33,13 +34,26 @@ const LOCK_MAX_SECONDS = cross_1.DAY / 1000;
33
34
  const xmlParser = new fast_xml_parser_1.XMLParser({ ignoreAttributes: false, removeNSPrefix: true, trimValues: true });
34
35
  const canOverwrite = new Set();
35
36
  const locks = new Map();
36
- function isLocked(path, ctx) {
37
+ function releaseWebdavLock(path) {
37
38
  const lock = locks.get(path);
38
39
  if (!lock)
39
40
  return false;
41
+ clearTimeout(lock.timeout);
42
+ locks.delete(path);
43
+ return true;
44
+ }
45
+ async function isLocked(path, ctx) {
46
+ const lock = locks.get(path);
47
+ if (!lock)
48
+ return false;
49
+ // if the resource is gone, keeping the lock only creates fake 423 responses
50
+ if (!await (0, vfs_1.urlToNode)(path, ctx)) {
51
+ releaseWebdavLock(path);
52
+ return false;
53
+ }
40
54
  const ifHeader = ctx.get('If');
41
55
  const tokenHeader = ctx.get(TOKEN_HEADER);
42
- if (hasToken(ifHeader, lock.token) || hasToken(tokenHeader, lock.token))
56
+ if (isSameLockPrincipal(lock, ctx) && (hasToken(ifHeader, lock.token) || hasToken(tokenHeader, lock.token)))
43
57
  return false;
44
58
  ctx.status = cross_1.HTTP_LOCKED;
45
59
  return true;
@@ -49,7 +63,13 @@ function hasToken(header, token) {
49
63
  return false;
50
64
  return header.includes(`<${token}>`) || header.split(/[,;\s]+/).includes(token);
51
65
  }
52
- async function handledWebdav(ctx) {
66
+ function getWebdavPrincipal(ctx) {
67
+ return (0, auth_1.getCurrentUsername)(ctx) || '';
68
+ }
69
+ function isSameLockPrincipal(lock, ctx) {
70
+ return lock.principal === getWebdavPrincipal(ctx);
71
+ }
72
+ const webdav = async (ctx, next) => {
53
73
  let { path } = ctx;
54
74
  path = path.replace(/^\/+/, '/'); // double-slash is causing empty listing in filezilla-pro
55
75
  const ua = ctx.get('user-agent');
@@ -61,25 +81,26 @@ async function handledWebdav(ctx) {
61
81
  if (isWebdavAuthRequest && ua && (0, auth_1.getCurrentUsername)(ctx))
62
82
  webdavDetectedAgents.try(webdavAgentKey(ctx, ua), () => true);
63
83
  if (ctx.method === 'OPTIONS') {
64
- if (ctx.get('Access-Control-Request-Method'))
65
- return; // it's a preflight cors request, not webdav
84
+ if (ctx.get('Access-Control-Request-Method')) // it's a preflight cors request, not webdav
85
+ return next();
66
86
  setWebdavHeaders();
67
87
  ctx.body = '';
68
- return true;
88
+ return;
69
89
  }
70
90
  if (isWebdavAuthRequest && shouldChallengeWebdav())
71
- return true;
91
+ return;
72
92
  if (ctx.method === 'PUT') {
73
- if (isLocked(path, ctx))
74
- return true;
93
+ if (await isLocked(path, ctx))
94
+ return;
95
+ const overwriteGraceKey = path + (0, cross_1.prefix)('|', (0, auth_1.getCurrentUsername)(ctx)); // bind temporary overwrite grace to the authenticated user so accounts cannot reuse each other's grace window
75
96
  // Finder first creates an empty file (a test?) then wants to overwrite it, which requires deletion permission, but the user may not have it, causing a renamed upload. To solve, so we give it special permission for a few seconds.
76
97
  const x = ctx.get('x-expected-entity-length'); // field used by Finder's webdav on actual upload, after
77
98
  if (!x && !ctx.length) {
78
- canOverwrite.add(path);
79
- setTimeout(() => canOverwrite.delete(path), 10_000); // grace period
99
+ canOverwrite.add(overwriteGraceKey);
100
+ setTimeout(() => canOverwrite.delete(overwriteGraceKey), 10_000); // grace period
80
101
  }
81
- else if (canOverwrite.has(path)) {
82
- canOverwrite.delete(path);
102
+ else if (canOverwrite.has(overwriteGraceKey)) {
103
+ canOverwrite.delete(overwriteGraceKey);
83
104
  const node = await (0, vfs_1.urlToNode)(path, ctx);
84
105
  if (node?.source)
85
106
  await (0, promises_1.rm)(node.source).catch(() => { });
@@ -88,25 +109,27 @@ async function handledWebdav(ctx) {
88
109
  ctx.req.headers['content-length'] = x;
89
110
  if (KNOWN_UA.test(ua) || webdavDetectedAgents.has(webdavAgentKey(ctx, ua)))
90
111
  ctx.query.existing ??= 'overwrite'; // with webdav this is our default
91
- return; // default handling
112
+ return next();
92
113
  }
93
114
  if (ctx.method === 'MKCOL') {
94
115
  setWebdavHeaders();
95
- if (isLocked(path, ctx))
96
- return true;
116
+ if (await isLocked(path, ctx))
117
+ return;
97
118
  const node = await (0, vfs_1.urlToNode)(path, ctx);
98
- if (node)
99
- return ctx.status = cross_1.HTTP_METHOD_NOT_ALLOWED;
100
- let name = '';
101
- const parentNode = await (0, vfs_1.urlToNode)(path, ctx, vfs_1.vfs, v => name = v);
102
- if (!parentNode)
103
- return ctx.status = cross_1.HTTP_NOT_FOUND;
119
+ if (node) {
120
+ ctx.status = cross_1.HTTP_METHOD_NOT_ALLOWED;
121
+ return;
122
+ }
123
+ const parentNode = await (0, vfs_1.urlToNode)((0, path_1.dirname)(path), ctx);
124
+ if (!parentNode) // this is a bit incoherent with the way we handle PUT, which doesn't stop in this case, but it's by RFC 4918 section 9.3
125
+ return ctx.status = cross_1.HTTP_CONFLICT;
126
+ const name = (0, cross_1.safeDecodeURIComponent)((0, path_1.basename)(path), '');
104
127
  if (!(0, misc_1.isValidFileName)(name))
105
128
  return ctx.status = cross_1.HTTP_BAD_REQUEST;
106
129
  if ((0, vfs_1.statusCodeForMissingPerm)(parentNode, 'can_upload', ctx)) {
107
130
  if (ctx.status === cross_1.HTTP_UNAUTHORIZED)
108
131
  setWebdavHeaders(true);
109
- return true;
132
+ return;
110
133
  }
111
134
  try {
112
135
  await (0, promises_1.mkdir)((0, path_1.join)(parentNode.source, name));
@@ -118,21 +141,26 @@ async function handledWebdav(ctx) {
118
141
  }
119
142
  if (ctx.method === 'MOVE') {
120
143
  setWebdavHeaders();
121
- if (isLocked(path, ctx))
122
- return true;
144
+ if (await isLocked(path, ctx))
145
+ return;
123
146
  const node = await (0, vfs_1.urlToNode)(path, ctx);
124
147
  if (!node)
125
- return;
148
+ return next();
126
149
  let dest = ctx.get('destination');
127
150
  const i = dest.indexOf('//');
128
151
  if (i >= 0)
129
152
  dest = dest.slice(dest.indexOf('/', i + 2));
130
153
  dest = (0, cross_1.join)(ctx.state.root || '', dest); // on Windows, we must use / as the delimiter to be able to compare with `path` below
131
- if (isLocked(dest, ctx))
132
- return true;
154
+ if (await isLocked(dest, ctx))
155
+ return;
133
156
  if ((0, path_1.dirname)(path) === (0, path_1.dirname)(dest)) // rename case. `path` is is encoded, so we test before decoding `dest`
134
157
  try {
135
- await (0, frontEndApis_1.requestedRename)(node, (0, path_1.basename)(decodeURI(dest)), ctx);
158
+ // decode the single path segment so reserved chars like %2C become their real name on rename
159
+ const newName = (0, cross_1.safeDecodeURIComponent)((0, path_1.basename)(dest), '');
160
+ if (!newName)
161
+ return ctx.status = cross_1.HTTP_BAD_REQUEST;
162
+ await (0, frontEndApis_1.requestedRename)(node, newName, ctx);
163
+ releaseWebdavLock(path); // RFC 4918 says MOVE must not carry locks to destination, so clear source lock on success
136
164
  return ctx.status = cross_1.HTTP_CREATED;
137
165
  }
138
166
  catch (e) {
@@ -142,13 +170,18 @@ async function handledWebdav(ctx) {
142
170
  if (moveRes instanceof Error)
143
171
  return ctx.status = moveRes.status || cross_1.HTTP_SERVER_ERROR;
144
172
  const err = moveRes?.errors?.[0];
173
+ if (!err)
174
+ releaseWebdavLock(path); // successful MOVE leaves the old path invalid, therefore its lock must be dropped
145
175
  return ctx.status = !err ? cross_1.HTTP_CREATED : typeof err === 'number' ? err : cross_1.HTTP_SERVER_ERROR;
146
176
  }
147
177
  if (ctx.method === 'DELETE') {
148
178
  setWebdavHeaders();
149
- if (isLocked(path, ctx))
150
- return true;
151
- return; // allow default handling in serveGuiAndSharedFiles.ts
179
+ if (await isLocked(path, ctx))
180
+ return;
181
+ await next();
182
+ if (ctx.status === cross_1.HTTP_OK)
183
+ releaseWebdavLock(path); // webdav clients may forget UNLOCK; successful delete must clear any lock
184
+ return;
152
185
  }
153
186
  if (ctx.method === 'UNLOCK') {
154
187
  setWebdavHeaders();
@@ -156,8 +189,10 @@ async function handledWebdav(ctx) {
156
189
  const lock = locks.get(path);
157
190
  if (x !== lock?.token)
158
191
  return ctx.status = cross_1.HTTP_BAD_REQUEST;
159
- clearTimeout(lock.timeout);
160
- locks.delete(path);
192
+ // with force_webdav_login disabled a client may silently fall back to anonymous; keep lock ownership on the original principal
193
+ if (!isSameLockPrincipal(lock, ctx))
194
+ return ctx.status = cross_1.HTTP_PRECONDITION_FAILED;
195
+ releaseWebdavLock(path);
161
196
  ctx.set(TOKEN_HEADER, x);
162
197
  if (const_1.IS_MAC)
163
198
  (0, vfs_1.urlToNode)(path, ctx).then(x => x?.source && dotClean((0, path_1.dirname)(x.source)));
@@ -176,14 +211,17 @@ async function handledWebdav(ctx) {
176
211
  const lock = locks.get(path);
177
212
  if (token !== lock?.token)
178
213
  return ctx.status = cross_1.HTTP_PRECONDITION_FAILED;
214
+ // same-token refresh from another principal would make abandoned locks effectively persistent
215
+ if (!isSameLockPrincipal(lock, ctx))
216
+ return ctx.status = cross_1.HTTP_PRECONDITION_FAILED;
179
217
  // refresh lock – keep the same token on refresh so clients can continue using the lock they already hold
180
218
  clearTimeout(lock.timeout);
181
- lock.timeout = setTimeout(() => locks.delete(path), seconds * 1000);
219
+ lock.timeout = setTimeout(() => releaseWebdavLock(path), seconds * 1000);
182
220
  lock.seconds = seconds;
183
221
  locks.set(path, lock);
184
222
  ctx.set(TOKEN_HEADER, lock.token);
185
223
  ctx.body = renderLockResponse(lock.token, lock.seconds);
186
- return true;
224
+ return;
187
225
  }
188
226
  const lockinfo = (0, cross_1.try_)(() => xmlParser.parse(body).lockinfo);
189
227
  const scope = lodash_1.default.keys(lockinfo?.lockscope)[0];
@@ -197,11 +235,11 @@ async function handledWebdav(ctx) {
197
235
  if (locks.has(path))
198
236
  return ctx.status = cross_1.HTTP_LOCKED;
199
237
  const newToken = 'urn:uuid:' + (0, node_crypto_1.randomUUID)();
200
- const timeout = setTimeout(() => locks.delete(path), seconds * 1000);
201
- locks.set(path, { token: newToken, timeout, seconds });
238
+ const timeout = setTimeout(() => releaseWebdavLock(path), seconds * 1000);
239
+ locks.set(path, { token: newToken, timeout, seconds, principal: getWebdavPrincipal(ctx) });
202
240
  ctx.set(TOKEN_HEADER, newToken);
203
241
  ctx.body = renderLockResponse(newToken, seconds);
204
- return true;
242
+ return;
205
243
  function getProvidedLockToken(ctx) {
206
244
  const direct = ctx.get(TOKEN_HEADER).replace(/[<>]/g, '');
207
245
  if (direct)
@@ -213,8 +251,8 @@ async function handledWebdav(ctx) {
213
251
  return `<?xml version="1.0" encoding="utf-8"?><prop xmlns="DAV:"><lockdiscovery><activelock>
214
252
  <locktype><write/></locktype>
215
253
  <lockscope><exclusive/></lockscope>
216
- <locktoken><href>${token}</href></locktoken>
217
- <lockroot><href>${path}</href></lockroot>
254
+ <locktoken><href>${lodash_1.default.escape(token)}</href></locktoken>
255
+ <lockroot><href>${lodash_1.default.escape(path)}</href></lockroot>
218
256
  <depth>0</depth>
219
257
  <timeout>Second-${seconds}</timeout>
220
258
  </activelock></lockdiscovery></prop>`;
@@ -224,14 +262,14 @@ async function handledWebdav(ctx) {
224
262
  setWebdavHeaders();
225
263
  const node = await (0, vfs_1.urlToNode)(path, ctx);
226
264
  if (!node)
227
- return;
265
+ return next();
228
266
  let depth = Number(ctx.get('depth'));
229
267
  depth = isNaN(depth) ? Infinity : depth;
230
268
  const isList = depth !== 0;
231
269
  if ((0, vfs_1.statusCodeForMissingPerm)(node, isList ? 'can_list' : 'can_see', ctx)) {
232
270
  if (ctx.status === cross_1.HTTP_UNAUTHORIZED)
233
271
  setWebdavHeaders(true);
234
- return true;
272
+ return;
235
273
  }
236
274
  ctx.type = 'xml';
237
275
  ctx.status = 207;
@@ -246,7 +284,7 @@ async function handledWebdav(ctx) {
246
284
  }
247
285
  res.write(`</multistatus>`);
248
286
  res.end();
249
- return true;
287
+ return;
250
288
  async function sendEntry(node, append = false) {
251
289
  if ((0, vfs_1.nodeIsLink)(node))
252
290
  return;
@@ -254,7 +292,7 @@ async function handledWebdav(ctx) {
254
292
  const isDir = await (0, vfs_1.nodeIsFolder)(node);
255
293
  const st = await (0, vfs_1.nodeStats)(node);
256
294
  res.write(`<response>
257
- <href>${outPath + (append ? (0, cross_1.pathEncode)(name, true) + (isDir ? '/' : '') : '')}</href>
295
+ <href>${lodash_1.default.escape(outPath + (append ? (0, cross_1.pathEncode)(name, true) + (isDir ? '/' : '') : ''))}</href>
258
296
  <propstat>
259
297
  <status>HTTP/1.1 200 OK</status>
260
298
  <prop>
@@ -272,6 +310,7 @@ async function handledWebdav(ctx) {
272
310
  setWebdavHeaders();
273
311
  return ctx.status = cross_1.HTTP_METHOD_NOT_ALLOWED;
274
312
  }
313
+ return next();
275
314
  function setWebdavHeaders(authenticate = false) {
276
315
  ctx.set('DAV', '1,2');
277
316
  ctx.set('MS-Author-Via', 'DAV');
@@ -300,7 +339,8 @@ async function handledWebdav(ctx) {
300
339
  return true;
301
340
  }
302
341
  }
303
- }
342
+ };
343
+ exports.webdav = webdav;
304
344
  function compileWebdavAgentRegex(v) {
305
345
  return !v ? null : v === true ? /.*/ : new RegExp(v.trim(), 'i');
306
346
  }