hfs 3.1.0-beta5 → 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.
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)
@@ -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;
@@ -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
  }