main
ts 562 lines 24 KB
Raw
1 import Koa from 'koa'
2 import { text as stream2string } from 'node:stream/consumers'
3 import {
4 getNodeName, nodeIsFolder, nodeIsLink, nodeStats, statusCodeForMissingPerm, urlToNode, VfsNode, walkNode
5 } from './vfs'
6 import {
7 HTTP_BAD_REQUEST, HTTP_CONFLICT, HTTP_CREATED, HTTP_METHOD_NOT_ALLOWED, HTTP_NO_CONTENT, HTTP_OK,
8 HTTP_PRECONDITION_FAILED, HTTP_SERVER_ERROR, HTTP_UNAUTHORIZED, HTTP_LOCKED, HTTP_FORBIDDEN, HTTP_MESSAGES,
9 DAY, CFG, enforceFinal, removeFinal, pathEncode, prefix, getOrSet, Dict, Timeout, join as crossJoin, try_,
10 safeDecodeURIComponent, wantArray, BASIC_AUTHENTICATE_HEADER,
11 } from './cross'
12 import { PassThrough } from 'stream'
13 import { mkdir, rm, utimes } from 'fs/promises'
14 import { STATUS_CODES } from 'http'
15 import { isValidFileName } from './misc'
16 import { basename, dirname, join } from 'path'
17 import { moveFiles, requestedRename } from './frontEndApis'
18 import { randomUUID } from 'node:crypto'
19 import { IS_MAC, IS_WINDOWS } from './const'
20 import fswin from 'fswin'
21 import { exec } from 'child_process'
22 import { getCurrentUsername } from './auth'
23 import { defineConfig } from './config'
24 import { expiringCache } from './expiringCache'
25 import { XMLParser } from 'fast-xml-parser'
26 import _ from 'lodash'
27 import { deleteStoredFileAttrs } from './fileAttr'
28
29 const forceWebdavLogin = defineConfig<boolean|string, null|RegExp>(CFG.force_webdav_login, true, compileWebdavAgentRegex)
30 const webdavInitialAuth = defineConfig<boolean|string, null|RegExp>(CFG.webdav_initial_auth, 'WebDAVFS', compileWebdavAgentRegex)
31 const webdavPrompted = expiringCache<boolean>(DAY)
32 const webdavDetectedAgents = expiringCache<boolean>(DAY)
33
34 const TOKEN_HEADER = 'lock-token'
35 const WEBDAV_METHODS = new Set(['PROPFIND', 'PROPPATCH', 'MKCOL', 'MOVE', 'LOCK', 'UNLOCK'])
36 const WEBDAV_HINT_HEADERS = ['depth', 'destination', 'overwrite', 'translate', 'if', TOKEN_HEADER, 'x-expected-entity-length']
37 const KNOWN_UA = /webdav|miniredir|davclnt|microsoft office|ms-office/i
38 const LOCK_DEFAULT_SECONDS = 3600
39 const LOCK_MAX_SECONDS = DAY / 1000
40 const xmlParser = new XMLParser({ ignoreAttributes: false, removeNSPrefix: true, trimValues: true })
41 const PROPPATCH_PROTECTED_LIVE_PROPS = new Set([
42 'creationdate', 'displayname', 'getcontentlanguage', 'getcontentlength', 'getcontenttype',
43 'getetag', 'getlastmodified', 'lockdiscovery', 'resourcetype', 'supportedlock',
44 ])
45 const PROPPATCH_UTIME_PROPS = new Set(['win32lastmodifiedtime', 'win32lastaccesstime'])
46 const WINDOWS_FILE_ATTRIBUTE_FLAGS = {
47 IS_READ_ONLY: 0x1,
48 IS_HIDDEN: 0x2,
49 IS_SYSTEM: 0x4,
50 IS_ARCHIVED: 0x20,
51 IS_TEMPORARY: 0x100,
52 IS_OFFLINE: 0x1000,
53 IS_NOT_CONTENT_INDEXED: 0x2000,
54 } as const
55
56 const canOverwrite = new Set<string>()
57 const locks = new Map<string, { token: string, timeout: NodeJS.Timeout, seconds: number, username: string }>()
58
59 export function releaseWebdavLock(path: string) {
60 const lock = locks.get(path)
61 if (!lock) return false
62 clearTimeout(lock.timeout)
63 locks.delete(path)
64 return true
65 }
66
67 async function isLocked(path: string, ctx: Koa.Context) {
68 const lock = locks.get(path)
69 if (!lock) return false
70 // if the resource is gone, keeping the lock only creates fake 423 responses
71 if (!await urlToNode(path, ctx)) {
72 releaseWebdavLock(path)
73 return false
74 }
75 const ifHeader = ctx.get('If')
76 const tokenHeader = ctx.get(TOKEN_HEADER)
77 if (isSameLockUsername(lock, ctx) && (hasToken(ifHeader, lock.token) || hasToken(tokenHeader, lock.token)))
78 return false
79 ctx.status = HTTP_LOCKED
80 return true
81 }
82
83 function hasToken(header: string, token: string) {
84 if (!header) return false
85 return header.includes(`<${token}>`) || header.split(/[,;\s]+/).includes(token)
86 }
87
88 function getWebdavUsername(ctx: Koa.Context) {
89 return getCurrentUsername(ctx) || ''
90 }
91
92 function isSameLockUsername(lock: { username: string }, ctx: Koa.Context) {
93 return lock.username === getWebdavUsername(ctx)
94 }
95
96 export const webdav: Koa.Middleware = async (ctx, next) => {
97 let {path} = ctx
98 path = path.replace(/^\/+/, '/') // double-slash is causing empty listing in filezilla-pro
99
100 const ua = ctx.get('user-agent')
101 if (path.includes('/._') && ua?.startsWith('WebDAVFS')) {// too much spam from Finder for these files that can contain metas
102 ctx.state.dontLog = true
103 ctx.state.webdavDetected = true
104 return ctx.status = HTTP_FORBIDDEN
105 }
106 // office starts document access with OPTIONS, then LOCK/GET; challenging OPTIONS keeps the whole exchange in the same WebDAV auth realm
107 const isCorsPreflight = ctx.method === 'OPTIONS' && ctx.get('Access-Control-Request-Method')
108 const isKnownWebdavAgent = KNOWN_UA.test(ua) || webdavDetectedAgents.has(webdavAgentKey(ctx, ua))
109 const isWebdavAuthRequest = !isCorsPreflight && (ctx.method === 'OPTIONS' || WEBDAV_METHODS.has(ctx.method) || WEBDAV_HINT_HEADERS.some(h => ctx.get(h))
110 || ctx.method === 'GET' && isKnownWebdavAgent
111 )
112 if (isWebdavAuthRequest)
113 ctx.state.webdavDetected = true
114 if (isWebdavAuthRequest && ua && getCurrentUsername(ctx))
115 webdavDetectedAgents.try(webdavAgentKey(ctx, ua), () => true)
116
117 if (isCorsPreflight)
118 return next()
119 if (isWebdavAuthRequest && shouldChallengeWebdav())
120 return
121 if (ctx.method === 'OPTIONS')
122 return handleOptions()
123 if (ctx.method === 'GET' && isWebdavAuthRequest)
124 return handleGet()
125 switch (ctx.method) {
126 case 'PUT': return handlePut()
127 case 'MKCOL': return handleMkcol()
128 case 'MOVE': return handleMove()
129 case 'DELETE': return handleDelete()
130 case 'UNLOCK': return handleUnlock()
131 case 'LOCK': return handleLock()
132 case 'PROPFIND': return handlePropfind()
133 case 'PROPPATCH': return handleProppatch()
134 }
135 return next()
136
137 async function handleOptions() {
138 setWebdavHeaders()
139 ctx.body = ''
140 }
141
142 async function handleGet() {
143 const node = await urlToNode(path, ctx)
144 if (!node || nodeIsFolder(node))
145 return next()
146 // webdav file reads must not fall through to the browser frontend when auth rejects them
147 if (statusCodeForMissingPerm(node, 'can_read', ctx)) {
148 if (ctx.status === HTTP_UNAUTHORIZED)
149 setWebdavHeaders(true)
150 return
151 }
152 return next()
153 }
154
155 async function handlePut() {
156 if (await isLocked(path, ctx)) return
157 const overwriteGraceKey = path + prefix('|', getCurrentUsername(ctx)) // bind temporary overwrite grace to the authenticated user so accounts cannot reuse each other's grace window
158 // 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.
159 const x = ctx.get('x-expected-entity-length') // field used by Finder's webdav on actual upload, after
160 if (isKnownWebdavAgent && canOverwrite.has(overwriteGraceKey)) {
161 canOverwrite.delete(overwriteGraceKey)
162 const node = await urlToNode(path, ctx)
163 if (node?.source)
164 await rm(node.source)
165 .then(() => deleteStoredFileAttrs(node.source!))
166 .catch(() => {})
167 }
168 if (x && ctx.length === undefined) // missing length can make PUT fail
169 ctx.req.headers['content-length'] = x
170
171 if (isKnownWebdavAgent)
172 ctx.query.existing ??= 'overwrite' // with webdav this is our default
173 await next()
174 if (isKnownWebdavAgent && ctx.body?.uri === path) // the upload middleware reports the final uri that can be different from the initial request
175 allowWebdavOverwrite(overwriteGraceKey)
176 }
177
178 async function handleMkcol() {
179 setWebdavHeaders()
180 if (await isLocked(path, ctx)) return
181 const node = await urlToNode(path, ctx)
182 if (node) {
183 ctx.status = HTTP_METHOD_NOT_ALLOWED
184 return
185 }
186 const parentNode = await urlToNode(dirname(path), ctx)
187 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
188 return ctx.status = HTTP_CONFLICT
189 const name = safeDecodeURIComponent(basename(path), '')
190 if (!isValidFileName(name))
191 return ctx.status = HTTP_BAD_REQUEST
192 if (statusCodeForMissingPerm(parentNode, 'can_upload', ctx)) {
193 if (ctx.status === HTTP_UNAUTHORIZED)
194 setWebdavHeaders(true)
195 return
196 }
197 try {
198 await mkdir(join(parentNode.source!, name))
199 return ctx.status = HTTP_CREATED
200 }
201 catch(e:any) {
202 return ctx.status = HTTP_SERVER_ERROR
203 }
204 }
205
206 async function handleMove() {
207 setWebdavHeaders()
208 if (await isLocked(path, ctx)) return
209 const node = await urlToNode(path, ctx)
210 if (!node) return next()
211 let dest = ctx.get('destination')
212 const i = dest.indexOf('//')
213 if (i >= 0)
214 dest = dest.slice(dest.indexOf('/', i + 2))
215 dest = crossJoin(ctx.state.root || '', dest) // on Windows, we must use / as the delimiter to be able to compare with `path` below
216 if (await isLocked(dest, ctx)) return
217 if (dirname(path) === dirname(dest)) // rename case. `path` is is encoded, so we test before decoding `dest`
218 try {
219 // decode the single path segment so reserved chars like %2C become their real name on rename
220 await requestedRename(node, safeDecodeURIComponent(basename(dest), ''), ctx)
221 releaseWebdavLock(path) // RFC 4918 says MOVE must not carry locks to destination, so clear source lock on success
222 return ctx.status = HTTP_CREATED
223 }
224 catch(e:any) {
225 return ctx.status = e.status || HTTP_SERVER_ERROR
226 }
227 const moveRes = await moveFiles([path], dirname(dest), ctx)
228 if (moveRes instanceof Error)
229 return ctx.status = (moveRes as any).status || HTTP_SERVER_ERROR
230 const err = moveRes?.errors?.[0]
231 if (!err)
232 releaseWebdavLock(path) // successful MOVE leaves the old path invalid, therefore its lock must be dropped
233 return ctx.status = !err ? HTTP_CREATED : typeof err === 'number' ? err : HTTP_SERVER_ERROR
234 }
235
236 async function handleDelete() {
237 setWebdavHeaders()
238 if (await isLocked(path, ctx)) return
239 await next()
240 if (ctx.status === HTTP_OK)
241 releaseWebdavLock(path) // webdav clients may forget UNLOCK; successful delete must clear any lock
242 }
243
244 async function handleUnlock() {
245 setWebdavHeaders()
246 const x = ctx.get(TOKEN_HEADER).slice(1,-1)
247 const lock = locks.get(path)
248 if (x !== lock?.token)
249 return ctx.status = HTTP_BAD_REQUEST
250 // with force_webdav_login disabled a client may silently fall back to anonymous; keep lock ownership on the original username
251 if (!isSameLockUsername(lock, ctx))
252 return ctx.status = HTTP_PRECONDITION_FAILED
253 releaseWebdavLock(path)
254 ctx.set(TOKEN_HEADER, x)
255 if (IS_MAC)
256 urlToNode(path, ctx).then(x => x?.source && dotClean(dirname(x.source)))
257 return ctx.status = HTTP_NO_CONTENT
258 }
259
260 async function handleLock() {
261 setWebdavHeaders()
262 // a lock reserves a future write, so authorize it against the existing resource or its parent
263 const node = await urlToNode(path, ctx)
264 const permissionNode = node || await urlToNode(dirname(path), ctx)
265 if (!permissionNode)
266 return ctx.status = HTTP_CONFLICT
267 const missingWritePerm = node && canOverwrite.has(path + prefix('|', getCurrentUsername(ctx))) ? 0
268 : statusCodeForMissingPerm(permissionNode, node ? 'can_delete' : 'can_upload', ctx)
269 if (missingWritePerm) {
270 if (ctx.status === HTTP_UNAUTHORIZED)
271 setWebdavHeaders(true)
272 return
273 }
274 const body = ctx.length || ctx.get('content-length') || ctx.get('transfer-encoding') ? await stream2string(ctx.req) : ''
275 const token = getProvidedLockToken()
276 let seconds = Number(ctx.get('timeout').split(',').find(x => /^Second-\d+$/i.test(x.trim()))?.trim().split('-', 2)[1])
277 seconds = _.clamp(seconds || LOCK_DEFAULT_SECONDS, 1, LOCK_MAX_SECONDS)
278
279 if (!body) {
280 // Finder and similar clients refresh an existing lock by sending LOCK without a body
281 if (!token)
282 return ctx.status = HTTP_BAD_REQUEST
283 const lock = locks.get(path)
284 if (token !== lock?.token)
285 return ctx.status = HTTP_PRECONDITION_FAILED
286 // same-token refresh from another username would make abandoned locks effectively persistent
287 if (!isSameLockUsername(lock, ctx))
288 return ctx.status = HTTP_PRECONDITION_FAILED
289 // refresh lock - keep the same token on refresh so clients can continue using the lock they already hold
290 clearTimeout(lock.timeout)
291 lock.timeout = setTimeout(() => releaseWebdavLock(path), seconds * 1000)
292 lock.seconds = seconds
293 locks.set(path, lock)
294
295 ctx.set(TOKEN_HEADER, lock.token)
296 ctx.body = renderLockResponse(lock.token, lock.seconds)
297 return
298 }
299 const lockinfo = try_(() => xmlParser.parse(body).lockinfo)
300 const scope = _.keys(lockinfo?.lockscope)[0]
301 const type = _.keys(lockinfo?.locktype)[0]
302 if (!scope || !type)
303 return ctx.status = HTTP_BAD_REQUEST
304 if (ctx.get('depth') && ctx.get('depth') !== '0')
305 return ctx.status = HTTP_CONFLICT
306 if (scope !== 'exclusive' || type !== 'write')
307 return ctx.status = HTTP_CONFLICT
308 if (locks.has(path))
309 return ctx.status = HTTP_LOCKED
310 const newToken = 'urn:uuid:' + randomUUID()
311 const timeout = setTimeout(() => releaseWebdavLock(path), seconds * 1000)
312 locks.set(path, { token: newToken, timeout, seconds, username: getWebdavUsername(ctx) })
313 ctx.set(TOKEN_HEADER, newToken)
314 ctx.body = renderLockResponse(newToken, seconds)
315 }
316
317 async function handlePropfind() {
318 setWebdavHeaders()
319 const node = await urlToNode(path, ctx)
320 if (!node) return next()
321 let depth = Number(ctx.get('depth'))
322 depth = isNaN(depth) ? Infinity : depth
323 const isList = depth !== 0
324 if (statusCodeForMissingPerm(node, isList ? 'can_list' : 'can_see', ctx)) {
325 if (ctx.status === HTTP_UNAUTHORIZED)
326 setWebdavHeaders(true)
327 return
328 }
329 ctx.type = 'xml'
330 ctx.status = 207
331 const outPath = webdavHrefPath(path, node, ctx)
332 const res = ctx.body = new PassThrough({ encoding: 'utf8' })
333 res.write(`<?xml version="1.0" encoding="utf-8" ?><multistatus xmlns="DAV:">`)
334 await sendEntry(node)
335 if (isList) {
336 depth = Math.max(0, depth - 1)
337 for await (const n of walkNode(node, { ctx, depth }))
338 await sendEntry(n, true)
339 }
340 res.write(`</multistatus>`)
341 res.end()
342
343 async function sendEntry(node: VfsNode, append=false) {
344 if (nodeIsLink(node)) return
345 const name = getNodeName(node)
346 const isDir = await nodeIsFolder(node)
347 const st = await nodeStats(node)
348 res.write(`<response>
349 <href>${_.escape(outPath + (append ? pathEncode(name, true) + (isDir ? '/' : '') : ''))}</href>
350 <propstat>
351 <prop>
352 ${prefix('<getlastmodified>', (st?.mtime as any)?.toGMTString(), '</getlastmodified>')}
353 ${prefix('<creationdate>', (st?.birthtime || st?.ctime)?.toISOString().replace(/\..*/, '-00:00'), '</creationdate>')}
354 ${isDir ? '<resourcetype><collection/></resourcetype>'
355 : `<resourcetype/><getcontentlength>${st?.size}</getcontentlength>`}
356 </prop>
357 <status>HTTP/1.1 200 OK</status>
358 </propstat>
359 </response>
360 `)
361 }
362 }
363
364 async function handleProppatch() {
365 setWebdavHeaders()
366 if (await isLocked(path, ctx)) return
367 const node = await urlToNode(path, ctx)
368 if (!node) return next()
369 if (statusCodeForMissingPerm(node, 'can_see', ctx)) {
370 if (ctx.status === HTTP_UNAUTHORIZED)
371 setWebdavHeaders(true)
372 return
373 }
374 const body = ctx.length || ctx.get('content-length') || ctx.get('transfer-encoding') ? await stream2string(ctx.req) : ''
375 const props = try_(() => parseProppatchProps(body)) || []
376 if (!props.length)
377 return ctx.status = HTTP_BAD_REQUEST
378 const statuses = []
379 for (const prop of props)
380 statuses.push({ prop: prop.name, status: await applyProppatchProp(prop, node, path, ctx) })
381 const outPath = webdavHrefPath(path, node, ctx)
382 ctx.type = 'xml'
383 ctx.status = 207
384 ctx.body = renderProppatchResponse(outPath, statuses)
385 }
386
387 function setWebdavHeaders(authenticate=false) {
388 ctx.set('DAV', '1,2')
389 ctx.set('MS-Author-Via', 'DAV')
390 ctx.set('Allow', 'PROPFIND,PROPPATCH,OPTIONS,DELETE,MOVE,LOCK,UNLOCK,MKCOL,PUT')
391 if (authenticate)
392 ctx.set('WWW-Authenticate', BASIC_AUTHENTICATE_HEADER)
393 }
394
395 function shouldChallengeWebdav() {
396 if (getCurrentUsername(ctx))
397 return false
398 if (forceWebdavLogin.compiled()?.test(ua))
399 return challengeWebdav()
400 if (!webdavInitialAuth.compiled()?.test(ua))
401 return false
402 if (ctx.get('authorization'))
403 return challengeWebdav()
404 const key = `${ctx.ip}|${ctx.host}|${ua || ''}`
405 if (webdavPrompted.has(key))
406 return false
407 webdavPrompted.try(key, () => true)
408 return challengeWebdav()
409
410 function challengeWebdav() {
411 setWebdavHeaders(true)
412 ctx.status = HTTP_UNAUTHORIZED
413 ctx.body = ''
414 return true
415 }
416 }
417
418 function getProvidedLockToken() {
419 const direct = ctx.get(TOKEN_HEADER).replace(/[<>]/g, '')
420 if (direct)
421 return direct
422 const ifHeader = ctx.get('If')
423 return /<([^>]+)>/.exec(ifHeader)?.[1] || ''
424 }
425
426 function renderLockResponse(token: string, seconds: number) {
427 return `<?xml version="1.0" encoding="utf-8"?><prop xmlns="DAV:"><lockdiscovery><activelock>
428 <locktype><write/></locktype>
429 <lockscope><exclusive/></lockscope>
430 <locktoken><href>${_.escape(token)}</href></locktoken>
431 <lockroot><href>${_.escape(path)}</href></lockroot>
432 <depth>0</depth>
433 <timeout>Second-${seconds}</timeout>
434 </activelock></lockdiscovery></prop>`
435 }
436 }
437
438 function compileWebdavAgentRegex(v: boolean|string) {
439 return !v ? null : v === true ? /.*/ : new RegExp(v.trim(), 'i')
440 }
441
442 function webdavAgentKey(ctx: Koa.Context, ua: string) {
443 // tying detection to source IP avoids promoting one spoofed UA to global WebDAV behavior
444 return `${ctx.ip}|${ua}`
445 }
446
447 function allowWebdavOverwrite(key: string) {
448 canOverwrite.add(key)
449 setTimeout(() => canOverwrite.delete(key), 10_000) // grace period
450 }
451
452 function webdavHrefPath(path: string, node: VfsNode, ctx: Koa.Context) {
453 const href = path.slice(Math.max(0, (ctx.state.root?.length ?? 0) - 1))
454 // WebDAV clients use href shape to infer resource type, so file hrefs must not look like collections
455 return nodeIsFolder(node) ? enforceFinal('/', href) : removeFinal('/', href)
456 }
457
458 interface ProppatchProp {
459 name: string
460 value: unknown
461 }
462
463 function parseProppatchProps(body: string) {
464 const doc = xmlParser.parse(body)
465 const update = getXmlChildren(doc, 'propertyupdate')[0]
466 if (!update)
467 return []
468 const ret: ProppatchProp[] = []
469 for (const opName of ['set', 'remove'])
470 for (const op of getXmlChildren(update, opName))
471 for (const prop of getXmlChildren(op, 'prop'))
472 for (const k of Object.keys(prop))
473 if (!k.startsWith('@_') && k !== '#text')
474 ret.push({ name: localXmlName(k), value: prop[k] })
475 return _.uniqBy(ret, 'name')
476 }
477
478 async function applyProppatchProp(prop: ProppatchProp, node: VfsNode, path: string, ctx: Koa.Context) {
479 const k = prop.name.toLowerCase()
480 if (PROPPATCH_PROTECTED_LIVE_PROPS.has(k))
481 return HTTP_FORBIDDEN
482 if (node.source && (PROPPATCH_UTIME_PROPS.has(k) || IS_WINDOWS && k === 'win32fileattributes')) {
483 // WebDAV clients patch metadata right after upload; outside that short same-username grace, metadata writes are file modifications
484 const missingWritePerm = canOverwrite.has(path + prefix('|', getCurrentUsername(ctx))) ? 0
485 : statusCodeForMissingPerm(node, 'can_delete', ctx, false)
486 if (missingWritePerm)
487 return missingWritePerm
488 }
489 if (node.source && PROPPATCH_UTIME_PROPS.has(k)) {
490 const date = new Date(String(prop.value))
491 if (isNaN(Number(date)))
492 return HTTP_BAD_REQUEST
493 const stats = await nodeStats(node)
494 const atime = k === 'win32lastaccesstime' ? date : stats?.atime ?? new Date()
495 const mtime = k === 'win32lastmodifiedtime' ? date : stats?.mtime ?? new Date()
496 // WebDAV clients often use dead properties for file times; apply the portable subset instead of only pretending success
497 await utimes(node.source, atime, mtime)
498 }
499 if (node.source && IS_WINDOWS && k === 'win32fileattributes') {
500 const attributes = parseWindowsFileAttributes(prop.value)
501 if (attributes === undefined)
502 return HTTP_BAD_REQUEST
503 // fswin is already our Windows attribute bridge; this keeps PROPPATCH metadata aligned with the actual filesystem
504 const ok = await new Promise<boolean>(resolve =>
505 fswin.setAttributes(node.source!, _.mapValues(WINDOWS_FILE_ATTRIBUTE_FLAGS, flag => Boolean(attributes & flag)), ok => resolve(Boolean(ok))) )
506 if (!ok)
507 return HTTP_SERVER_ERROR
508 }
509 // PROPPATCH is only persisted when HFS gets real dead-property storage; no-op success keeps Windows and macOS clients from aborting writes
510 return HTTP_OK
511 }
512
513 function parseWindowsFileAttributes(v: unknown) {
514 const s = String(v).trim()
515 if (!s)
516 return
517 const n = Number(/^0x/i.test(s) || /^[0-9a-f]{8}$/i.test(s) ? '0x' + s.replace(/^0x/i, '') : s)
518 if (!Number.isInteger(n) || n < 0)
519 return
520 return n
521 }
522
523 function renderProppatchResponse(path: string, statuses: { prop: string, status: number }[]) {
524 const byStatus = _.groupBy(statuses, 'status')
525 return `<?xml version="1.0" encoding="utf-8" ?><multistatus xmlns="DAV:"><response>
526 <href>${_.escape(path)}</href>
527 ${_.map(byStatus, (items, status) => `<propstat>
528 <prop>${items.map(({ prop }) => `<${prop}/>`).join('')}</prop>
529 <status>HTTP/1.1 ${status} ${_.escape(HTTP_MESSAGES[Number(status)] || STATUS_CODES[Number(status)] || '')}</status>
530 </propstat>`).join('')}
531 </response></multistatus>`
532 }
533
534 function getXmlChildren(obj: unknown, name: string) {
535 if (!obj || typeof obj !== 'object')
536 return []
537 return Object.entries(obj).flatMap(([k, v]) => localXmlName(k) === name ? wantArray(v) : [])
538 }
539
540 function localXmlName(name: string) {
541 return name.split(':').at(-1) || name
542 }
543
544 // Finder will upload special attributes as files with name ._* that can be merged using system utility "dot_clean"
545 const cleaners: Dict<Timeout> = {}
546 function dotClean(path: string) {
547 getOrSet(cleaners, path, () => setTimeout(() => {
548 try { exec('dot_clean .', { cwd: path }, (err, out) => done(err || out)) }
549 catch (e) { done(e) }
550
551 function done(log: any) {
552 console.debug('dot_clean', path, log)
553 delete cleaners[path]
554 }
555 }, 10_000))
556 }
557
558 declare module "koa" {
559 interface DefaultState {
560 webdavDetected?: boolean
561 }
562 }