hfs 3.1.2 → 3.1.4
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/admin/assets/{index-DI_SOdzQ.js → index-DTxjaflW.js} +1 -1
- package/admin/assets/{index-CpeyrW2d.js → index-Df6vYR7s.js} +4 -4
- package/admin/assets/{sha512-QTaxzUvZ.js → sha512-D936QW8l.js} +1 -1
- package/admin/index.html +1 -1
- package/frontend/assets/{index-legacy-DLISyaZ1.js → index-legacy-CQ3_LTGh.js} +1 -1
- package/frontend/assets/index-legacy-D6nPw49_.js +9 -0
- package/frontend/assets/{sha512-legacy-CHdxh7x9.js → sha512-legacy-DLqdsV8R.js} +1 -1
- package/frontend/index.html +1 -1
- package/npm-shrinkwrap.json +19 -19
- package/package.json +1 -1
- package/src/basicWeb.js +1 -1
- package/src/cross-const.js +2 -1
- package/src/first.js +15 -5
- package/src/serveGuiAndSharedFiles.js +2 -2
- package/src/update.js +2 -1
- package/src/upload.js +1 -1
- package/src/webdav.js +203 -60
- package/frontend/assets/index-legacy-BQCEUtxs.js +0 -9
package/src/webdav.js
CHANGED
|
@@ -10,11 +10,13 @@ const vfs_1 = require("./vfs");
|
|
|
10
10
|
const cross_1 = require("./cross");
|
|
11
11
|
const stream_1 = require("stream");
|
|
12
12
|
const promises_1 = require("fs/promises");
|
|
13
|
+
const http_1 = require("http");
|
|
13
14
|
const misc_1 = require("./misc");
|
|
14
15
|
const path_1 = require("path");
|
|
15
16
|
const frontEndApis_1 = require("./frontEndApis");
|
|
16
17
|
const node_crypto_1 = require("node:crypto");
|
|
17
18
|
const const_1 = require("./const");
|
|
19
|
+
const fswin_1 = __importDefault(require("fswin"));
|
|
18
20
|
const child_process_1 = require("child_process");
|
|
19
21
|
const auth_1 = require("./auth");
|
|
20
22
|
const config_1 = require("./config");
|
|
@@ -26,12 +28,26 @@ const webdavInitialAuth = (0, config_1.defineConfig)(cross_1.CFG.webdav_initial_
|
|
|
26
28
|
const webdavPrompted = (0, expiringCache_1.expiringCache)(cross_1.DAY);
|
|
27
29
|
const webdavDetectedAgents = (0, expiringCache_1.expiringCache)(cross_1.DAY);
|
|
28
30
|
const TOKEN_HEADER = 'lock-token';
|
|
29
|
-
const WEBDAV_METHODS = new Set(['PROPFIND', 'MKCOL', 'MOVE', 'LOCK', 'UNLOCK']);
|
|
31
|
+
const WEBDAV_METHODS = new Set(['PROPFIND', 'PROPPATCH', 'MKCOL', 'MOVE', 'LOCK', 'UNLOCK']);
|
|
30
32
|
const WEBDAV_HINT_HEADERS = ['depth', 'destination', 'overwrite', 'translate', 'if', TOKEN_HEADER, 'x-expected-entity-length'];
|
|
31
|
-
const KNOWN_UA = /webdav|miniredir|davclnt/i;
|
|
33
|
+
const KNOWN_UA = /webdav|miniredir|davclnt|microsoft office|ms-office/i;
|
|
32
34
|
const LOCK_DEFAULT_SECONDS = 3600;
|
|
33
35
|
const LOCK_MAX_SECONDS = cross_1.DAY / 1000;
|
|
34
36
|
const xmlParser = new fast_xml_parser_1.XMLParser({ ignoreAttributes: false, removeNSPrefix: true, trimValues: true });
|
|
37
|
+
const PROPPATCH_PROTECTED_LIVE_PROPS = new Set([
|
|
38
|
+
'creationdate', 'displayname', 'getcontentlanguage', 'getcontentlength', 'getcontenttype',
|
|
39
|
+
'getetag', 'getlastmodified', 'lockdiscovery', 'resourcetype', 'supportedlock',
|
|
40
|
+
]);
|
|
41
|
+
const PROPPATCH_UTIME_PROPS = new Set(['win32lastmodifiedtime', 'win32lastaccesstime']);
|
|
42
|
+
const WINDOWS_FILE_ATTRIBUTE_FLAGS = {
|
|
43
|
+
IS_READ_ONLY: 0x1,
|
|
44
|
+
IS_HIDDEN: 0x2,
|
|
45
|
+
IS_SYSTEM: 0x4,
|
|
46
|
+
IS_ARCHIVED: 0x20,
|
|
47
|
+
IS_TEMPORARY: 0x100,
|
|
48
|
+
IS_OFFLINE: 0x1000,
|
|
49
|
+
IS_NOT_CONTENT_INDEXED: 0x2000,
|
|
50
|
+
};
|
|
35
51
|
const canOverwrite = new Set();
|
|
36
52
|
const locks = new Map();
|
|
37
53
|
function releaseWebdavLock(path) {
|
|
@@ -53,7 +69,7 @@ async function isLocked(path, ctx) {
|
|
|
53
69
|
}
|
|
54
70
|
const ifHeader = ctx.get('If');
|
|
55
71
|
const tokenHeader = ctx.get(TOKEN_HEADER);
|
|
56
|
-
if (
|
|
72
|
+
if (isSameLockUsername(lock, ctx) && (hasToken(ifHeader, lock.token) || hasToken(tokenHeader, lock.token)))
|
|
57
73
|
return false;
|
|
58
74
|
ctx.status = cross_1.HTTP_LOCKED;
|
|
59
75
|
return true;
|
|
@@ -63,11 +79,11 @@ function hasToken(header, token) {
|
|
|
63
79
|
return false;
|
|
64
80
|
return header.includes(`<${token}>`) || header.split(/[,;\s]+/).includes(token);
|
|
65
81
|
}
|
|
66
|
-
function
|
|
82
|
+
function getWebdavUsername(ctx) {
|
|
67
83
|
return (0, auth_1.getCurrentUsername)(ctx) || '';
|
|
68
84
|
}
|
|
69
|
-
function
|
|
70
|
-
return lock.
|
|
85
|
+
function isSameLockUsername(lock, ctx) {
|
|
86
|
+
return lock.username === getWebdavUsername(ctx);
|
|
71
87
|
}
|
|
72
88
|
const webdav = async (ctx, next) => {
|
|
73
89
|
let { path } = ctx;
|
|
@@ -78,31 +94,57 @@ const webdav = async (ctx, next) => {
|
|
|
78
94
|
ctx.state.webdavDetected = true;
|
|
79
95
|
return ctx.status = cross_1.HTTP_FORBIDDEN;
|
|
80
96
|
}
|
|
81
|
-
|
|
97
|
+
// office starts document access with OPTIONS, then LOCK/GET; challenging OPTIONS keeps the whole exchange in the same WebDAV auth realm
|
|
98
|
+
const isCorsPreflight = ctx.method === 'OPTIONS' && ctx.get('Access-Control-Request-Method');
|
|
99
|
+
const isKnownWebdavAgent = KNOWN_UA.test(ua) || webdavDetectedAgents.has(webdavAgentKey(ctx, ua));
|
|
100
|
+
const isWebdavAuthRequest = !isCorsPreflight && (ctx.method === 'OPTIONS' || WEBDAV_METHODS.has(ctx.method) || WEBDAV_HINT_HEADERS.some(h => ctx.get(h))
|
|
101
|
+
|| ctx.method === 'GET' && isKnownWebdavAgent);
|
|
82
102
|
if (isWebdavAuthRequest)
|
|
83
103
|
ctx.state.webdavDetected = true;
|
|
84
104
|
if (isWebdavAuthRequest && ua && (0, auth_1.getCurrentUsername)(ctx))
|
|
85
105
|
webdavDetectedAgents.try(webdavAgentKey(ctx, ua), () => true);
|
|
86
|
-
if (
|
|
87
|
-
|
|
88
|
-
|
|
106
|
+
if (isCorsPreflight)
|
|
107
|
+
return next();
|
|
108
|
+
if (isWebdavAuthRequest && shouldChallengeWebdav())
|
|
109
|
+
return;
|
|
110
|
+
if (ctx.method === 'OPTIONS')
|
|
111
|
+
return handleOptions();
|
|
112
|
+
if (ctx.method === 'GET' && isWebdavAuthRequest)
|
|
113
|
+
return handleGet();
|
|
114
|
+
switch (ctx.method) {
|
|
115
|
+
case 'PUT': return handlePut();
|
|
116
|
+
case 'MKCOL': return handleMkcol();
|
|
117
|
+
case 'MOVE': return handleMove();
|
|
118
|
+
case 'DELETE': return handleDelete();
|
|
119
|
+
case 'UNLOCK': return handleUnlock();
|
|
120
|
+
case 'LOCK': return handleLock();
|
|
121
|
+
case 'PROPFIND': return handlePropfind();
|
|
122
|
+
case 'PROPPATCH': return handleProppatch();
|
|
123
|
+
}
|
|
124
|
+
return next();
|
|
125
|
+
async function handleOptions() {
|
|
89
126
|
setWebdavHeaders();
|
|
90
127
|
ctx.body = '';
|
|
91
|
-
return;
|
|
92
128
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
129
|
+
async function handleGet() {
|
|
130
|
+
const node = await (0, vfs_1.urlToNode)(path, ctx);
|
|
131
|
+
if (!node || (0, vfs_1.nodeIsFolder)(node))
|
|
132
|
+
return next();
|
|
133
|
+
// webdav file reads must not fall through to the browser frontend when auth rejects them
|
|
134
|
+
if ((0, vfs_1.statusCodeForMissingPerm)(node, 'can_read', ctx)) {
|
|
135
|
+
if (ctx.status === cross_1.HTTP_UNAUTHORIZED)
|
|
136
|
+
setWebdavHeaders(true);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
return next();
|
|
140
|
+
}
|
|
141
|
+
async function handlePut() {
|
|
96
142
|
if (await isLocked(path, ctx))
|
|
97
143
|
return;
|
|
98
144
|
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
|
|
99
145
|
// 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.
|
|
100
146
|
const x = ctx.get('x-expected-entity-length'); // field used by Finder's webdav on actual upload, after
|
|
101
|
-
if (
|
|
102
|
-
canOverwrite.add(overwriteGraceKey);
|
|
103
|
-
setTimeout(() => canOverwrite.delete(overwriteGraceKey), 10_000); // grace period
|
|
104
|
-
}
|
|
105
|
-
else if (canOverwrite.has(overwriteGraceKey)) {
|
|
147
|
+
if (isKnownWebdavAgent && canOverwrite.has(overwriteGraceKey)) {
|
|
106
148
|
canOverwrite.delete(overwriteGraceKey);
|
|
107
149
|
const node = await (0, vfs_1.urlToNode)(path, ctx);
|
|
108
150
|
if (node?.source)
|
|
@@ -110,11 +152,13 @@ const webdav = async (ctx, next) => {
|
|
|
110
152
|
}
|
|
111
153
|
if (x && ctx.length === undefined) // missing length can make PUT fail
|
|
112
154
|
ctx.req.headers['content-length'] = x;
|
|
113
|
-
if (
|
|
155
|
+
if (isKnownWebdavAgent)
|
|
114
156
|
ctx.query.existing ??= 'overwrite'; // with webdav this is our default
|
|
115
|
-
|
|
157
|
+
await next();
|
|
158
|
+
if (isKnownWebdavAgent && ctx.body?.uri === path) // the upload middleware reports the final uri that can be different from the initial request
|
|
159
|
+
allowWebdavOverwrite(overwriteGraceKey);
|
|
116
160
|
}
|
|
117
|
-
|
|
161
|
+
async function handleMkcol() {
|
|
118
162
|
setWebdavHeaders();
|
|
119
163
|
if (await isLocked(path, ctx))
|
|
120
164
|
return;
|
|
@@ -142,7 +186,7 @@ const webdav = async (ctx, next) => {
|
|
|
142
186
|
return ctx.status = cross_1.HTTP_SERVER_ERROR;
|
|
143
187
|
}
|
|
144
188
|
}
|
|
145
|
-
|
|
189
|
+
async function handleMove() {
|
|
146
190
|
setWebdavHeaders();
|
|
147
191
|
if (await isLocked(path, ctx))
|
|
148
192
|
return;
|
|
@@ -177,23 +221,22 @@ const webdav = async (ctx, next) => {
|
|
|
177
221
|
releaseWebdavLock(path); // successful MOVE leaves the old path invalid, therefore its lock must be dropped
|
|
178
222
|
return ctx.status = !err ? cross_1.HTTP_CREATED : typeof err === 'number' ? err : cross_1.HTTP_SERVER_ERROR;
|
|
179
223
|
}
|
|
180
|
-
|
|
224
|
+
async function handleDelete() {
|
|
181
225
|
setWebdavHeaders();
|
|
182
226
|
if (await isLocked(path, ctx))
|
|
183
227
|
return;
|
|
184
228
|
await next();
|
|
185
229
|
if (ctx.status === cross_1.HTTP_OK)
|
|
186
230
|
releaseWebdavLock(path); // webdav clients may forget UNLOCK; successful delete must clear any lock
|
|
187
|
-
return;
|
|
188
231
|
}
|
|
189
|
-
|
|
232
|
+
async function handleUnlock() {
|
|
190
233
|
setWebdavHeaders();
|
|
191
234
|
const x = ctx.get(TOKEN_HEADER).slice(1, -1);
|
|
192
235
|
const lock = locks.get(path);
|
|
193
236
|
if (x !== lock?.token)
|
|
194
237
|
return ctx.status = cross_1.HTTP_BAD_REQUEST;
|
|
195
|
-
// with force_webdav_login disabled a client may silently fall back to anonymous; keep lock ownership on the original
|
|
196
|
-
if (!
|
|
238
|
+
// with force_webdav_login disabled a client may silently fall back to anonymous; keep lock ownership on the original username
|
|
239
|
+
if (!isSameLockUsername(lock, ctx))
|
|
197
240
|
return ctx.status = cross_1.HTTP_PRECONDITION_FAILED;
|
|
198
241
|
releaseWebdavLock(path);
|
|
199
242
|
ctx.set(TOKEN_HEADER, x);
|
|
@@ -201,10 +244,10 @@ const webdav = async (ctx, next) => {
|
|
|
201
244
|
(0, vfs_1.urlToNode)(path, ctx).then(x => x?.source && dotClean((0, path_1.dirname)(x.source)));
|
|
202
245
|
return ctx.status = cross_1.HTTP_NO_CONTENT;
|
|
203
246
|
}
|
|
204
|
-
|
|
247
|
+
async function handleLock() {
|
|
205
248
|
setWebdavHeaders();
|
|
206
249
|
const body = ctx.length || ctx.get('content-length') || ctx.get('transfer-encoding') ? await (0, consumers_1.text)(ctx.req) : '';
|
|
207
|
-
const token = getProvidedLockToken(
|
|
250
|
+
const token = getProvidedLockToken();
|
|
208
251
|
let seconds = Number(ctx.get('timeout').split(',').find(x => /^Second-\d+$/i.test(x.trim()))?.trim().split('-', 2)[1]);
|
|
209
252
|
seconds = lodash_1.default.clamp(seconds || LOCK_DEFAULT_SECONDS, 1, LOCK_MAX_SECONDS);
|
|
210
253
|
if (!body) {
|
|
@@ -214,10 +257,10 @@ const webdav = async (ctx, next) => {
|
|
|
214
257
|
const lock = locks.get(path);
|
|
215
258
|
if (token !== lock?.token)
|
|
216
259
|
return ctx.status = cross_1.HTTP_PRECONDITION_FAILED;
|
|
217
|
-
// same-token refresh from another
|
|
218
|
-
if (!
|
|
260
|
+
// same-token refresh from another username would make abandoned locks effectively persistent
|
|
261
|
+
if (!isSameLockUsername(lock, ctx))
|
|
219
262
|
return ctx.status = cross_1.HTTP_PRECONDITION_FAILED;
|
|
220
|
-
// refresh lock
|
|
263
|
+
// refresh lock - keep the same token on refresh so clients can continue using the lock they already hold
|
|
221
264
|
clearTimeout(lock.timeout);
|
|
222
265
|
lock.timeout = setTimeout(() => releaseWebdavLock(path), seconds * 1000);
|
|
223
266
|
lock.seconds = seconds;
|
|
@@ -239,29 +282,11 @@ const webdav = async (ctx, next) => {
|
|
|
239
282
|
return ctx.status = cross_1.HTTP_LOCKED;
|
|
240
283
|
const newToken = 'urn:uuid:' + (0, node_crypto_1.randomUUID)();
|
|
241
284
|
const timeout = setTimeout(() => releaseWebdavLock(path), seconds * 1000);
|
|
242
|
-
locks.set(path, { token: newToken, timeout, seconds,
|
|
285
|
+
locks.set(path, { token: newToken, timeout, seconds, username: getWebdavUsername(ctx) });
|
|
243
286
|
ctx.set(TOKEN_HEADER, newToken);
|
|
244
287
|
ctx.body = renderLockResponse(newToken, seconds);
|
|
245
|
-
return;
|
|
246
|
-
function getProvidedLockToken(ctx) {
|
|
247
|
-
const direct = ctx.get(TOKEN_HEADER).replace(/[<>]/g, '');
|
|
248
|
-
if (direct)
|
|
249
|
-
return direct;
|
|
250
|
-
const ifHeader = ctx.get('If');
|
|
251
|
-
return /<([^>]+)>/.exec(ifHeader)?.[1] || '';
|
|
252
|
-
}
|
|
253
|
-
function renderLockResponse(token, seconds) {
|
|
254
|
-
return `<?xml version="1.0" encoding="utf-8"?><prop xmlns="DAV:"><lockdiscovery><activelock>
|
|
255
|
-
<locktype><write/></locktype>
|
|
256
|
-
<lockscope><exclusive/></lockscope>
|
|
257
|
-
<locktoken><href>${lodash_1.default.escape(token)}</href></locktoken>
|
|
258
|
-
<lockroot><href>${lodash_1.default.escape(path)}</href></lockroot>
|
|
259
|
-
<depth>0</depth>
|
|
260
|
-
<timeout>Second-${seconds}</timeout>
|
|
261
|
-
</activelock></lockdiscovery></prop>`;
|
|
262
|
-
}
|
|
263
288
|
}
|
|
264
|
-
|
|
289
|
+
async function handlePropfind() {
|
|
265
290
|
setWebdavHeaders();
|
|
266
291
|
const node = await (0, vfs_1.urlToNode)(path, ctx);
|
|
267
292
|
if (!node)
|
|
@@ -276,7 +301,7 @@ const webdav = async (ctx, next) => {
|
|
|
276
301
|
}
|
|
277
302
|
ctx.type = 'xml';
|
|
278
303
|
ctx.status = 207;
|
|
279
|
-
const outPath = (
|
|
304
|
+
const outPath = webdavHrefPath(path, node, ctx);
|
|
280
305
|
const res = ctx.body = new stream_1.PassThrough({ encoding: 'utf8' });
|
|
281
306
|
res.write(`<?xml version="1.0" encoding="utf-8" ?><multistatus xmlns="DAV:">`);
|
|
282
307
|
await sendEntry(node);
|
|
@@ -287,7 +312,6 @@ const webdav = async (ctx, next) => {
|
|
|
287
312
|
}
|
|
288
313
|
res.write(`</multistatus>`);
|
|
289
314
|
res.end();
|
|
290
|
-
return;
|
|
291
315
|
async function sendEntry(node, append = false) {
|
|
292
316
|
if ((0, vfs_1.nodeIsLink)(node))
|
|
293
317
|
return;
|
|
@@ -309,17 +333,36 @@ const webdav = async (ctx, next) => {
|
|
|
309
333
|
`);
|
|
310
334
|
}
|
|
311
335
|
}
|
|
312
|
-
|
|
336
|
+
async function handleProppatch() {
|
|
313
337
|
setWebdavHeaders();
|
|
314
|
-
|
|
338
|
+
if (await isLocked(path, ctx))
|
|
339
|
+
return;
|
|
340
|
+
const node = await (0, vfs_1.urlToNode)(path, ctx);
|
|
341
|
+
if (!node)
|
|
342
|
+
return next();
|
|
343
|
+
if ((0, vfs_1.statusCodeForMissingPerm)(node, 'can_see', ctx)) {
|
|
344
|
+
if (ctx.status === cross_1.HTTP_UNAUTHORIZED)
|
|
345
|
+
setWebdavHeaders(true);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const body = ctx.length || ctx.get('content-length') || ctx.get('transfer-encoding') ? await (0, consumers_1.text)(ctx.req) : '';
|
|
349
|
+
const props = (0, cross_1.try_)(() => parseProppatchProps(body)) || [];
|
|
350
|
+
if (!props.length)
|
|
351
|
+
return ctx.status = cross_1.HTTP_BAD_REQUEST;
|
|
352
|
+
const statuses = [];
|
|
353
|
+
for (const prop of props)
|
|
354
|
+
statuses.push({ prop: prop.name, status: await applyProppatchProp(prop, node, path, ctx) });
|
|
355
|
+
const outPath = webdavHrefPath(path, node, ctx);
|
|
356
|
+
ctx.type = 'xml';
|
|
357
|
+
ctx.status = 207;
|
|
358
|
+
ctx.body = renderProppatchResponse(outPath, statuses);
|
|
315
359
|
}
|
|
316
|
-
return next();
|
|
317
360
|
function setWebdavHeaders(authenticate = false) {
|
|
318
361
|
ctx.set('DAV', '1,2');
|
|
319
362
|
ctx.set('MS-Author-Via', 'DAV');
|
|
320
|
-
ctx.set('Allow', 'PROPFIND,OPTIONS,DELETE,MOVE,LOCK,UNLOCK,MKCOL,PUT');
|
|
363
|
+
ctx.set('Allow', 'PROPFIND,PROPPATCH,OPTIONS,DELETE,MOVE,LOCK,UNLOCK,MKCOL,PUT');
|
|
321
364
|
if (authenticate)
|
|
322
|
-
ctx.set('WWW-Authenticate',
|
|
365
|
+
ctx.set('WWW-Authenticate', cross_1.BASIC_AUTHENTICATE_HEADER);
|
|
323
366
|
}
|
|
324
367
|
function shouldChallengeWebdav() {
|
|
325
368
|
if ((0, auth_1.getCurrentUsername)(ctx))
|
|
@@ -342,6 +385,23 @@ const webdav = async (ctx, next) => {
|
|
|
342
385
|
return true;
|
|
343
386
|
}
|
|
344
387
|
}
|
|
388
|
+
function getProvidedLockToken() {
|
|
389
|
+
const direct = ctx.get(TOKEN_HEADER).replace(/[<>]/g, '');
|
|
390
|
+
if (direct)
|
|
391
|
+
return direct;
|
|
392
|
+
const ifHeader = ctx.get('If');
|
|
393
|
+
return /<([^>]+)>/.exec(ifHeader)?.[1] || '';
|
|
394
|
+
}
|
|
395
|
+
function renderLockResponse(token, seconds) {
|
|
396
|
+
return `<?xml version="1.0" encoding="utf-8"?><prop xmlns="DAV:"><lockdiscovery><activelock>
|
|
397
|
+
<locktype><write/></locktype>
|
|
398
|
+
<lockscope><exclusive/></lockscope>
|
|
399
|
+
<locktoken><href>${lodash_1.default.escape(token)}</href></locktoken>
|
|
400
|
+
<lockroot><href>${lodash_1.default.escape(path)}</href></lockroot>
|
|
401
|
+
<depth>0</depth>
|
|
402
|
+
<timeout>Second-${seconds}</timeout>
|
|
403
|
+
</activelock></lockdiscovery></prop>`;
|
|
404
|
+
}
|
|
345
405
|
};
|
|
346
406
|
exports.webdav = webdav;
|
|
347
407
|
function compileWebdavAgentRegex(v) {
|
|
@@ -351,6 +411,89 @@ function webdavAgentKey(ctx, ua) {
|
|
|
351
411
|
// tying detection to source IP avoids promoting one spoofed UA to global WebDAV behavior
|
|
352
412
|
return `${ctx.ip}|${ua}`;
|
|
353
413
|
}
|
|
414
|
+
function allowWebdavOverwrite(key) {
|
|
415
|
+
canOverwrite.add(key);
|
|
416
|
+
setTimeout(() => canOverwrite.delete(key), 10_000); // grace period
|
|
417
|
+
}
|
|
418
|
+
function webdavHrefPath(path, node, ctx) {
|
|
419
|
+
const href = path.slice(Math.max(0, (ctx.state.root?.length ?? 0) - 1));
|
|
420
|
+
// WebDAV clients use href shape to infer resource type, so file hrefs must not look like collections
|
|
421
|
+
return (0, vfs_1.nodeIsFolder)(node) ? (0, cross_1.enforceFinal)('/', href) : (0, cross_1.removeFinal)('/', href);
|
|
422
|
+
}
|
|
423
|
+
function parseProppatchProps(body) {
|
|
424
|
+
const doc = xmlParser.parse(body);
|
|
425
|
+
const update = getXmlChildren(doc, 'propertyupdate')[0];
|
|
426
|
+
if (!update)
|
|
427
|
+
return [];
|
|
428
|
+
const ret = [];
|
|
429
|
+
for (const opName of ['set', 'remove'])
|
|
430
|
+
for (const op of getXmlChildren(update, opName))
|
|
431
|
+
for (const prop of getXmlChildren(op, 'prop'))
|
|
432
|
+
for (const k of Object.keys(prop))
|
|
433
|
+
if (!k.startsWith('@_') && k !== '#text')
|
|
434
|
+
ret.push({ name: localXmlName(k), value: prop[k] });
|
|
435
|
+
return lodash_1.default.uniqBy(ret, 'name');
|
|
436
|
+
}
|
|
437
|
+
async function applyProppatchProp(prop, node, path, ctx) {
|
|
438
|
+
const k = prop.name.toLowerCase();
|
|
439
|
+
if (PROPPATCH_PROTECTED_LIVE_PROPS.has(k))
|
|
440
|
+
return cross_1.HTTP_FORBIDDEN;
|
|
441
|
+
if (node.source && (PROPPATCH_UTIME_PROPS.has(k) || const_1.IS_WINDOWS && k === 'win32fileattributes')) {
|
|
442
|
+
// WebDAV clients patch metadata right after upload; outside that short same-username grace, metadata writes are file modifications
|
|
443
|
+
const missingWritePerm = canOverwrite.has(path + (0, cross_1.prefix)('|', (0, auth_1.getCurrentUsername)(ctx))) ? 0
|
|
444
|
+
: (0, vfs_1.statusCodeForMissingPerm)(node, 'can_delete', ctx, false);
|
|
445
|
+
if (missingWritePerm)
|
|
446
|
+
return missingWritePerm;
|
|
447
|
+
}
|
|
448
|
+
if (node.source && PROPPATCH_UTIME_PROPS.has(k)) {
|
|
449
|
+
const date = new Date(String(prop.value));
|
|
450
|
+
if (isNaN(Number(date)))
|
|
451
|
+
return cross_1.HTTP_BAD_REQUEST;
|
|
452
|
+
const stats = await (0, vfs_1.nodeStats)(node);
|
|
453
|
+
const atime = k === 'win32lastaccesstime' ? date : stats?.atime ?? new Date();
|
|
454
|
+
const mtime = k === 'win32lastmodifiedtime' ? date : stats?.mtime ?? new Date();
|
|
455
|
+
// WebDAV clients often use dead properties for file times; apply the portable subset instead of only pretending success
|
|
456
|
+
await (0, promises_1.utimes)(node.source, atime, mtime);
|
|
457
|
+
}
|
|
458
|
+
if (node.source && const_1.IS_WINDOWS && k === 'win32fileattributes') {
|
|
459
|
+
const attributes = parseWindowsFileAttributes(prop.value);
|
|
460
|
+
if (attributes === undefined)
|
|
461
|
+
return cross_1.HTTP_BAD_REQUEST;
|
|
462
|
+
// fswin is already our Windows attribute bridge; this keeps PROPPATCH metadata aligned with the actual filesystem
|
|
463
|
+
const ok = await new Promise(resolve => fswin_1.default.setAttributes(node.source, lodash_1.default.mapValues(WINDOWS_FILE_ATTRIBUTE_FLAGS, flag => Boolean(attributes & flag)), ok => resolve(Boolean(ok))));
|
|
464
|
+
if (!ok)
|
|
465
|
+
return cross_1.HTTP_SERVER_ERROR;
|
|
466
|
+
}
|
|
467
|
+
// PROPPATCH is only persisted when HFS gets real dead-property storage; no-op success keeps Windows and macOS clients from aborting writes
|
|
468
|
+
return cross_1.HTTP_OK;
|
|
469
|
+
}
|
|
470
|
+
function parseWindowsFileAttributes(v) {
|
|
471
|
+
const s = String(v).trim();
|
|
472
|
+
if (!s)
|
|
473
|
+
return;
|
|
474
|
+
const n = Number(/^0x/i.test(s) || /^[0-9a-f]{8}$/i.test(s) ? '0x' + s.replace(/^0x/i, '') : s);
|
|
475
|
+
if (!Number.isInteger(n) || n < 0)
|
|
476
|
+
return;
|
|
477
|
+
return n;
|
|
478
|
+
}
|
|
479
|
+
function renderProppatchResponse(path, statuses) {
|
|
480
|
+
const byStatus = lodash_1.default.groupBy(statuses, 'status');
|
|
481
|
+
return `<?xml version="1.0" encoding="utf-8" ?><multistatus xmlns="DAV:"><response>
|
|
482
|
+
<href>${lodash_1.default.escape(path)}</href>
|
|
483
|
+
${lodash_1.default.map(byStatus, (items, status) => `<propstat>
|
|
484
|
+
<prop>${items.map(({ prop }) => `<${prop}/>`).join('')}</prop>
|
|
485
|
+
<status>HTTP/1.1 ${status} ${lodash_1.default.escape(cross_1.HTTP_MESSAGES[Number(status)] || http_1.STATUS_CODES[Number(status)] || '')}</status>
|
|
486
|
+
</propstat>`).join('')}
|
|
487
|
+
</response></multistatus>`;
|
|
488
|
+
}
|
|
489
|
+
function getXmlChildren(obj, name) {
|
|
490
|
+
if (!obj || typeof obj !== 'object')
|
|
491
|
+
return [];
|
|
492
|
+
return Object.entries(obj).flatMap(([k, v]) => localXmlName(k) === name ? (0, cross_1.wantArray)(v) : []);
|
|
493
|
+
}
|
|
494
|
+
function localXmlName(name) {
|
|
495
|
+
return name.split(':').at(-1) || name;
|
|
496
|
+
}
|
|
354
497
|
// Finder will upload special attributes as files with name ._* that can be merged using system utility "dot_clean"
|
|
355
498
|
const cleaners = {};
|
|
356
499
|
function dotClean(path) {
|