@samitouri / QOSami-HFS / commits / 6c536f04

webdav: improved LOCK and removed fake PROPPATCH support

Massimo Melina committed Mar 8, 2026 at 13:44 UTC 6c536f0479abdc6b66f9396c2d47dafffa552c8f
3 files changed +115 -61
package.json
+1
@@ -85,6 +85,7 @@
85 "busboy": "^1.6.0",
86 "crc-32": "^1.2.2",
87 "fast-glob": "^3.3.3",
88 + "fast-xml-parser": "^5.4.2",
89 "find-process": "^2.0.0",
90 "fs-x-attributes": "^1.0.2",
91 "fswin": "^3.24.829",
src/webdav.ts
+70 -41
@@ -1,11 +1,12 @@
1 import Koa from 'koa'
2 +import { text as stream2string } from 'node:stream/consumers'
3 import {
4 getNodeName, nodeIsFolder, nodeIsLink, nodeStats, statusCodeForMissingPerm, urlToNode, vfs, VfsNode, walkNode
5 } from './vfs'
6 import {
6 - HTTP_BAD_REQUEST, HTTP_CREATED, HTTP_METHOD_NOT_ALLOWED, HTTP_NO_CONTENT, HTTP_NOT_FOUND, HTTP_SERVER_ERROR,
7 - enforceFinal, pathEncode, prefix, getOrSet, Dict, Timeout, HTTP_UNAUTHORIZED, CFG, HTTP_LOCKED, HTTP_FORBIDDEN, DAY,
8 - join as crossJoin
7 + HTTP_BAD_REQUEST, HTTP_CONFLICT, HTTP_CREATED, HTTP_METHOD_NOT_ALLOWED, HTTP_NO_CONTENT, HTTP_NOT_FOUND,
8 + HTTP_PRECONDITION_FAILED, HTTP_SERVER_ERROR, HTTP_UNAUTHORIZED, HTTP_LOCKED, HTTP_FORBIDDEN,
9 + DAY, CFG, enforceFinal, pathEncode, prefix, getOrSet, Dict, Timeout, join as crossJoin, try_
10 } from './cross'
11 import { PassThrough } from 'stream'
12 import { mkdir, rm } from 'fs/promises'
@@ -18,6 +19,8 @@ import { exec } from 'child_process'
19 import { getCurrentUsername } from './auth'
20 import { defineConfig } from './config'
21 import { expiringCache } from './expiringCache'
22 +import { XMLParser } from 'fast-xml-parser'
23 +import _ from 'lodash'
24
25 const forceWebdavLogin = defineConfig<boolean|string, null|RegExp>(CFG.force_webdav_login, true, compileWebdavAgentRegex)
26 const webdavInitialAuth = defineConfig<boolean|string, null|RegExp>(CFG.webdav_initial_auth, 'WebDAVFS', compileWebdavAgentRegex)
@@ -25,12 +28,15 @@ const webdavPrompted = expiringCache<boolean>(DAY)
28 const webdavDetectedAgents = new Set<string>()
29
30 const TOKEN_HEADER = 'lock-token'
28 -const WEBDAV_METHODS = new Set(['PROPFIND', 'PROPPATCH', 'MKCOL', 'MOVE', 'LOCK', 'UNLOCK'])
31 +const WEBDAV_METHODS = new Set(['PROPFIND', 'MKCOL', 'MOVE', 'LOCK', 'UNLOCK'])
32 const WEBDAV_HINT_HEADERS = ['depth', 'destination', 'overwrite', 'translate', 'if', TOKEN_HEADER, 'x-expected-entity-length']
33 const KNOWN_UA = /webdav|miniredir|davclnt/i
34 +const LOCK_DEFAULT_SECONDS = 3600
35 +const LOCK_MAX_SECONDS = DAY / 1000
36 +const xmlParser = new XMLParser({ ignoreAttributes: false, removeNSPrefix: true, trimValues: true })
37
38 const canOverwrite = new Set()
33 -const locks = new Map<string, { token: string, timeout: NodeJS.Timeout }>()
39 +const locks = new Map<string, { token: string, timeout: NodeJS.Timeout, seconds: number }>()
40
41 function isLocked(path: string, ctx: Koa.Context) {
42 const lock = locks.get(path)
@@ -57,7 +63,7 @@ export async function handledWebdav(ctx: Koa.Context) {
63 webdavDetectedAgents.add(ua)
64 }
65
60 - if (ctx.path.includes('/._') && ua?.startsWith('WebDAVFS')) {// too much spam from Finder for these files that can contain metas
66 + if (path.includes('/._') && ua?.startsWith('WebDAVFS')) {// too much spam from Finder for these files that can contain metas
67 ctx.state.dontLog = true
68 return ctx.status = HTTP_FORBIDDEN
69 }
@@ -160,22 +166,65 @@ export async function handledWebdav(ctx: Koa.Context) {
166 }
167 if (ctx.method === 'LOCK') {
168 setWebdavHeaders()
169 + const body = ctx.length || ctx.get('content-length') || ctx.get('transfer-encoding') ? await stream2string(ctx.req) : ''
170 + const token = getProvidedLockToken(ctx)
171 + let seconds = Number(ctx.get('timeout').split(',').find(x => /^Second-\d+$/i.test(x.trim()))?.trim().split('-', 2)[1])
172 + seconds = _.clamp(seconds || LOCK_DEFAULT_SECONDS, 1, LOCK_MAX_SECONDS)
173 +
174 + if (!body) {
175 + // Finder and similar clients refresh an existing lock by sending LOCK without a body
176 + if (!token)
177 + return ctx.status = HTTP_BAD_REQUEST
178 + const lock = locks.get(path)
179 + if (token !== lock?.token)
180 + return ctx.status = HTTP_PRECONDITION_FAILED
181 + // refresh lock – keep the same token on refresh so clients can continue using the lock they already hold
182 + clearTimeout(lock.timeout)
183 + lock.timeout = setTimeout(() => locks.delete(path), seconds * 1000)
184 + lock.seconds = seconds
185 + locks.set(path, lock)
186 +
187 + ctx.set(TOKEN_HEADER, lock.token)
188 + ctx.body = renderLockResponse(lock.token, lock.seconds)
189 + return true
190 + }
191 + const lockinfo = try_(() => xmlParser.parse(body).lockinfo)
192 + const scope = _.keys(lockinfo?.lockscope)[0]
193 + const type = _.keys(lockinfo?.locktype)[0]
194 + if (!scope || !type)
195 + return ctx.status = HTTP_BAD_REQUEST
196 + if (ctx.get('depth') && ctx.get('depth') !== '0')
197 + return ctx.status = HTTP_CONFLICT
198 + if (scope !== 'exclusive' || type !== 'write')
199 + return ctx.status = HTTP_CONFLICT
200 if (locks.has(path))
164 - return ctx.status = 423
165 - const token = 'urn:uuid:' + randomUUID()
166 - ctx.set(TOKEN_HEADER, token)
167 - const seconds = 3600
201 + return ctx.status = HTTP_LOCKED
202 + const newToken = 'urn:uuid:' + randomUUID()
203 const timeout = setTimeout(() => locks.delete(path), seconds * 1000)
169 - locks.set(path, { token, timeout })
170 - ctx.body = `<?xml version="1.0" encoding="utf-8"?><prop xmlns="DAV:"><lockdiscovery><activelock>
171 - <locktype><write/></locktype>
172 - <lockscope><exclusive/></lockscope>
173 - <locktoken><href>${token}</href></locktoken>
174 - <lockroot><href>${path}</href></lockroot>
175 - <depth>0</depth>
176 - <timeout>Second-${seconds}</timeout>
177 - </activelock></lockdiscovery></prop>`
204 + locks.set(path, { token: newToken, timeout, seconds })
205 + ctx.set(TOKEN_HEADER, newToken)
206 + ctx.body = renderLockResponse(newToken, seconds)
207 return true
208 +
209 + function getProvidedLockToken(ctx: Koa.Context) {
210 + const direct = ctx.get(TOKEN_HEADER).replace(/[<>]/g, '')
211 + if (direct)
212 + return direct
213 + const ifHeader = ctx.get('If')
214 + return /<([^>]+)>/.exec(ifHeader)?.[1] || ''
215 + }
216 +
217 + function renderLockResponse(token: string, seconds: number) {
218 + return `<?xml version="1.0" encoding="utf-8"?><prop xmlns="DAV:"><lockdiscovery><activelock>
219 + <locktype><write/></locktype>
220 + <lockscope><exclusive/></lockscope>
221 + <locktoken><href>${token}</href></locktoken>
222 + <lockroot><href>${path}</href></lockroot>
223 + <depth>0</depth>
224 + <timeout>Second-${seconds}</timeout>
225 + </activelock></lockdiscovery></prop>`
226 + }
227 +
228 }
229 if (ctx.method === 'PROPFIND') {
230 setWebdavHeaders()
@@ -226,33 +275,13 @@ export async function handledWebdav(ctx: Koa.Context) {
275 }
276 if (ctx.method === 'PROPPATCH') {
277 setWebdavHeaders()
229 - if (isLocked(path, ctx)) return true
230 - const node = await urlToNode(path, ctx)
231 - if (!node) return
232 - if (statusCodeForMissingPerm(node, 'can_see', ctx)) {
233 - if (ctx.status === 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
278 + return ctx.status = HTTP_METHOD_NOT_ALLOWED
279 }
280
281 function setWebdavHeaders(authenticate=false) {
282 ctx.set('DAV', '1,2')
283 ctx.set('MS-Author-Via', 'DAV')
255 - ctx.set('Allow', 'PROPFIND,PROPPATCH,OPTIONS,DELETE,MOVE,LOCK,UNLOCK,MKCOL,PUT')
284 + ctx.set('Allow', 'PROPFIND,OPTIONS,DELETE,MOVE,LOCK,UNLOCK,MKCOL,PUT')
285 if (authenticate)
286 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
287 }
tests/test.ts
+44 -20
@@ -41,14 +41,17 @@ const BIG_CONTENT = _.repeat(randomId(10), 300_000) // 3MB, big enough to satura
41 const throttle = BIG_CONTENT.length /1000 /0.8 // KB, finish in 0.8s, quick but still overlapping downloads
42 const SAMPLE_FILE_PATH = resolve(__dirname, 'page/gpl.png')
43 const WEBDAV_UA = 'Microsoft-WebDAV-MiniRedir/10.0.22000'
44 -const WEBDAV_PROPPATCH_BODY = `<?xml version="1.0" encoding="utf-8"?>
45 -<propertyupdate xmlns="DAV:">
46 - <set>
47 - <prop>
48 - <displayname>patched</displayname>
49 - </prop>
50 - </set>
51 -</propertyupdate>`
44 +const TOKEN_HEADER = 'lock-token'
45 +const WEBDAV_LOCK_BODY = `<?xml version="1.0" encoding="utf-8"?>
46 +<lockinfo xmlns="DAV:">
47 + <lockscope><exclusive/></lockscope>
48 + <locktype><write/></locktype>
49 +</lockinfo>`
50 +const WEBDAV_SHARED_LOCK_BODY = `<?xml version="1.0" encoding="utf-8"?>
51 +<lockinfo xmlns="DAV:">
52 + <lockscope><shared/></lockscope>
53 + <locktype><write/></locktype>
54 +</lockinfo>`
55 let defaultBaseUrl = BASE_URL
56
57 const execP = (cmd: string) => promisify(exec)(cmd).then(x => x.stdout)
@@ -294,27 +297,35 @@ describe('webdav', () => {
297 await rmAny(destPath)
298 }
299 })
297 - test('webdav.proppatch file', async () => {
298 - const name = `wd-proppatch-${randomId(6)}.txt`
300 + test('webdav.lock refresh keeps token', async () => {
301 + const name = `wd-lock-${randomId(6)}.txt`
302 const uri = `${UPLOAD_ROOT}${UPLOAD_DIR}/${name}`
303 let destPath = ''
304 + let token = ''
305 try {
306 destPath = await webdavUpload(uri, x => x?.uri === uri, 'test')()
303 - await webdavProppatch(uri)()
307 + await webdavLock(uri, (_data, res) => token = res.headers?.[TOKEN_HEADER] || '')()
308 + if (!token)
309 + throw "missing lock token"
310 + await webdavLock(uri, (_data, res) =>
311 + res.statusCode === 200 && res.headers?.[TOKEN_HEADER] === token, '', { If: `(<${token}>)` })()
312 }
313 finally {
314 + if (token)
315 + await webdavUnlock(uri, token)().catch(() => {})
316 await rmAny(destPath)
317 }
318 })
309 - test('webdav.proppatch folder', async () => {
310 - const folder = `wd-proppatch-dir-${randomId(6)}`
311 - const uri = `${UPLOAD_ROOT}${folder}/`
319 + test('webdav.lock rejects shared lock', async () => {
320 + const name = `wd-lock-shared-${randomId(6)}.txt`
321 + const uri = `${UPLOAD_ROOT}${UPLOAD_DIR}/${name}`
322 + let destPath = ''
323 try {
313 - await reqApi('create_folder', { uri: UPLOAD_ROOT, name: folder }, 200, { auth, jar })()
314 - await webdavProppatch(uri)()
324 + destPath = await webdavUpload(uri, x => x?.uri === uri, 'test')()
325 + await webdavLock(uri, 409, WEBDAV_SHARED_LOCK_BODY)()
326 }
327 finally {
317 - await req(uri, 200, { method: 'delete', auth, jar })().catch(() => {})
328 + await rmAny(destPath)
329 }
330 })
331
@@ -331,20 +342,33 @@ describe('webdav', () => {
342 })().then(res => uploadUriToPath(res?.uri || uri))
343 }
344
334 - function webdavProppatch(uri: string, tester: Tester=207, body=WEBDAV_PROPPATCH_BODY, userAgent=WEBDAV_UA) {
345 + function webdavLock(uri: string, tester: Tester=200, body=WEBDAV_LOCK_BODY, headers?: Record<string, string>) {
346 return req(uri, tester, {
336 - method: 'PROPPATCH',
347 + method: 'LOCK',
348 auth,
349 jar,
350 headers: {
351 'content-type': 'text/xml',
352 'content-length': Buffer.byteLength(body),
342 - 'user-agent': userAgent,
353 + 'user-agent': WEBDAV_UA,
354 + ...headers,
355 },
356 body,
357 })
358 }
359
360 + function webdavUnlock(uri: string, token: string, tester: Tester=204) {
361 + return req(uri, tester, {
362 + method: 'UNLOCK',
363 + auth,
364 + jar,
365 + headers: {
366 + 'user-agent': WEBDAV_UA,
367 + [TOKEN_HEADER]: `<${token}>`,
368 + },
369 + })
370 + }
371 +
372 })
373
374 // do this before login, or max_dl_accounts config will override max_dl