webdav
Massimo Melina committed
Oct 6, 2024 at 17:34 UTC
ecb980a0a3418d07e9c19fae60d09695556a0c60
13 files changed
+579
-78
admin/src/LogsPage.ts
+4
-1
@@ -363,6 +363,9 @@ const BROWSER_ICONS = {
363
Safari: UW + '5/52/Safari_browser_logo.svg',
364
Edge: UW + '9/98/Microsoft_Edge_logo_%282019%29.svg',
365
Opera: UW + '4/49/Opera_2015_icon.svg',
366
+ Finder: UW + 'thumb/b/b9/Finder_Icon_macOS_Tahoe.png/100px-Finder_Icon_macOS_Tahoe.png',
367
+ Cyberduck: UW + 'archive/4/48/20091115091336%21Cyberduck_icon.png',
368
+ ForkLift: UW + '../en/9/96/ForkLift_3_File_Manager_and_File_Transfer_Client_Logo.png',
369
}
370
const OS_ICONS = {
371
android: UW + 'd/d7/Android_robot.svg',
@@ -371,7 +374,7 @@ const OS_ICONS = {
374
apple: UW + '7/74/Apple_logo_dark_grey.svg', // grey works for both themes
375
}
376
const OSS = {
374
- apple: /Mac OS|iPhone OS/,
377
+ apple: /Mac OS|iPhone OS|Darwin/,
378
win: /Windows NT/,
379
android: /Android/,
380
linux: /Linux/,
admin/src/OptionsPage.ts
+29
-2
@@ -1,7 +1,7 @@
1
// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3
import { Box, Button, Divider, FormHelperText } from '@mui/material';
4
-import { createElement as h, useEffect, useRef } from 'react';
4
+import { createElement as h, useEffect, useId, useRef, useState } from 'react'
5
import { apiCall, useApiEx } from './api'
6
import { state, useSnapState } from './state'
7
import { Link as RouterLink } from 'react-router-dom'
@@ -253,6 +253,14 @@ export default function OptionsPage() {
253
fromField: x => Object.fromEntries(x.map((row: any) => [row.k, row.v || 'auto'])),
254
},
255
256
+ { k: CFG.force_webdav_login, comp: WebdavAgentAuthField, sm: true, label: "WebDAV force login",
257
+ fallbackRE: 'Microsoft-WebDAV', // ms-webdav won't send credentials even with the initial_auth – it must be forced, so we offer it as preset regex if you don't like the *always* value
258
+ helperText: "Force login for clients that mishandle mixed anonymous/protected access",
259
+ },
260
+ values[CFG.force_webdav_login] !== true && { k: CFG.webdav_initial_auth, comp: WebdavAgentAuthField, sm: 6, label: "WebDAV initial auth",
261
+ helperText: "Force login only once. Used only when previous option does not match",
262
+ },
263
+
264
{ k: 'server_code', comp: TextEditorField, lang: 'js', xs: 12,
265
helperText: md(`This code works similarly to [a plugin](${REPO_URL}blob/main/dev-plugins.md) (with some limitations)`)
266
},
@@ -397,6 +405,25 @@ function AllowedReferer({ label, value, onChange, error }: FieldProps<string>) {
405
)
406
}
407
408
+function WebdavAgentAuthField({ label, value, onChange, error, helperText, fallbackRE='.*' }: FieldProps<boolean | string>) {
409
+ const [lastRegex, setLastRegex] = useState('')
410
+ const isRE = typeof value === 'string'
411
+ useEffect(() => setLastRegex(isRE ? value : fallbackRE), [value])
412
+ const helperId = useId()
413
+ return h(Box, {},
414
+ h(Box, { display: 'flex' },
415
+ h(SelectField as Field<boolean | string>, {
416
+ label, value, onChange, error,
417
+ 'aria-describedby': helperId,
418
+ options: { "Off": false, "Always": true, "RegEx": lastRegex },
419
+ sx: isRE ? { maxWidth: '9em' } : undefined,
420
+ }),
421
+ isRE && h(StringField, { label: "User-Agent regex", value, onChange, error }),
422
+ ),
423
+ h(FormHelperText, { id: helperId }, helperText),
424
+ )
425
+}
426
+
427
export async function suggestMakingCert() {
428
return new Promise(resolve => {
429
const { close } = newDialog({
@@ -424,4 +451,4 @@ export async function suggestMakingCert() {
451
close()
452
}
453
})
427
-}
\ No newline at end of file
454
+}
config.md
+2
@@ -89,6 +89,8 @@ Configuration can be done in several ways
89
Default is true. Affects the frontend only, but you can get the same effect using the `?existing=rename` in the url.
90
- `keep_session_alive` keeps you logged in while the page is left open and the computer is on. Default is true.
91
- `session_duration` after how many seconds should the login session expire. Default is a day.
92
+- `force_webdav_login` force WebDAV clients to authenticate. Accepts: `false` (disabled), `true` (all user-agents), or a case-insensitive regex string (only matching user-agents). Default is true.
93
+- `webdav_initial_auth` one-time login prompt for matching WebDAV user-agents (used only when `force_webdav_login` does not match). Accepts: `false` (disabled), `true` (all user-agents), or a case-insensitive regex string. Default is `WebDAVFS`.
94
- `acme_domain` domain used for ACME certificate generation. Default is none.
95
- `acme_renew` automatically renew acme certificate close to expiration. Default is false.
96
- `listen_interface` network interface to listen on, by specifying IP address. Default is any.
src/api.vfs.ts
+1
-1
@@ -112,7 +112,7 @@ export default {
112
async add_vfs({ parent, source, name, ...rest }) {
113
if (!source && !name)
114
return new ApiError(HTTP_BAD_REQUEST, 'name or source required')
115
- if (!isValidFileName(name))
115
+ if (name && !isValidFileName(name))
116
return new ApiError(HTTP_BAD_REQUEST, 'bad name')
117
const parentNode = parent ? await urlToNodeOriginal(parent) : vfs
118
if (!parentNode)
src/cross-const.ts
+2
@@ -16,6 +16,7 @@ export const HIDE_IN_TESTS = 'hideInTests' // elements that have variable size,
16
export const EMBEDDED_LANGUAGE = 'en' // frontend includes this language in the code, and not need to import the translation json
17
18
export const HTTP_OK = 200
19
+export const HTTP_CREATED = 201
20
export const HTTP_NO_CONTENT = 204
21
export const HTTP_PARTIAL_CONTENT = 206
22
export const HTTP_MOVED_PERMANENTLY = 301
@@ -32,6 +33,7 @@ export const HTTP_PRECONDITION_FAILED = 412
33
export const HTTP_PAYLOAD_TOO_LARGE = 413
34
export const HTTP_RANGE_NOT_SATISFIABLE = 416
35
export const HTTP_FOOL = 418
36
+export const HTTP_LOCKED = 423
37
export const HTTP_FAILED_DEPENDENCY = 424
38
export const HTTP_TOO_MANY_REQUESTS = 429
39
export const HTTP_SERVER_ERROR = 500
src/cross.ts
+11
-5
@@ -33,7 +33,7 @@ export const CFG = constMap(['geo_enable', 'geo_allow', 'geo_list', 'geo_allow_u
33
'log', 'error_log', 'log_rotation', 'dont_log_net', 'log_gui', 'log_api', 'log_ua', 'log_spam', 'track_ips',
34
'max_downloads', 'max_downloads_per_ip', 'max_downloads_per_account', 'roots', 'force_address', 'split_uploads',
35
'force_lang', 'suspend_plugins', 'base_url', 'size_1024', 'disable_custom_html', 'comments_storage',
36
- 'outbound_proxy'])
36
+ 'force_webdav_login', 'webdav_initial_auth', 'outbound_proxy'])
37
export const LIST = { add: '+', remove: '-', update: '=', props: 'props', ready: 'ready', error: 'e' }
38
export type Dict<T=any> = Record<string, T>
39
export type Falsy = false | null | undefined | '' | 0
@@ -307,7 +307,10 @@ export async function waitFor<T>(cb: ()=> Promisable<T>, { interval=200, timeout
307
}
308
}
309
310
-export function getOrSet<T>(o: Record<string,T>, k:string, creator:()=>T): T {
310
+export function getOrSet<T>(o: Record<string,T> | Map<string, T>, k:string, creator:()=>T): T {
311
+ if (o instanceof Map)
312
+ return o.get(k)
313
+ || with_(creator(), x => o.set(k, x) && x)
314
return k in o ? o[k]!
315
: (o[k] = creator())
316
}
@@ -458,8 +461,8 @@ export async function promiseBestEffort<T>(promises: Promise<T>[]) {
461
}
462
463
// encode paths leaving / separator unencoded (not like encodeURIComponent), but still encode #
461
-export function pathEncode(s: string) {
462
- return s.replace(/[:&#'"% ?\\]/g, escape) // escape() is not utf8, but we are encoding only ascii chars
464
+export function pathEncode(s: string, all=false) {
465
+ return all ? encodeURI(s).replace(/#/g, escape) : s.replace(/[:&#'"% ?\\]/g, escape) // escape() is not utf8, but we are encoding only ascii chars
466
}
467
export function pathDecode(s: string) { return decodeURI(s).replace(/%23/g, '#') }
468
@@ -589,4 +592,7 @@ const BROWSERS = {
592
PS: /playstation/i,
593
Xbox: /xbox/i,
594
UC: /UCBrowser/i,
592
-}
\ No newline at end of file
595
+ Finder: /WebDAVFS.+Darwin|WebDAVLib/,
596
+ Cyberduck: /^Cyberduck/,
597
+ ForkLift: /^ForkLift/,
598
+}
src/frontEndApis.ts
+79
-60
@@ -64,7 +64,7 @@ export const frontEndApis: ApiHandlers = {
64
apiAssertTypes({ string: { uri, name } })
65
try { ctx.logExtra(null, { name, target: pathDecode(uri) }) }
66
catch { return new ApiError(HTTP_BAD_REQUEST) }
67
- if (!isValidFileName(name))
67
+ if (!name || !isValidFileName(name))
68
return new ApiError(HTTP_BAD_REQUEST, 'bad name')
69
const parentNode = await urlToNode(uri, ctx)
70
if (!parentNode)
@@ -88,71 +88,22 @@ export const frontEndApis: ApiHandlers = {
88
catch { return new ApiError(HTTP_BAD_REQUEST) }
89
const node = await urlToNode(uri, ctx)
90
if (!node)
91
- return new ApiError(HTTP_NOT_FOUND)
91
+ throw new ApiError(HTTP_NOT_FOUND)
92
if (isRoot(node) || !isValidFileName(dest))
93
return new ApiError(HTTP_FORBIDDEN)
94
- if (statusCodeForMissingPerm(node, 'can_delete', ctx))
95
- return new ApiError(ctx.status)
96
- if (!node.source)
97
- return new ApiError(HTTP_FAILED_DEPENDENCY)
98
- const destNode = await urlToNode(pathEncode(dest), ctx, node.parent)
99
- if (destNode && statusCodeForMissingPerm(destNode, 'can_delete', ctx)) // if destination exists, you need delete permission
100
- return new ApiError(ctx.status)
101
- try {
102
- const destSource = join(dirname(node.source), dest)
103
- await rename(node.source, destSource)
104
- getCommentFor(node.source).then(c => {
105
- if (!c) return
106
- void setCommentFor(node.source!, '')
107
- void setCommentFor(destSource, c)
108
- })
109
- return {}
110
- }
111
- catch (e: any) {
112
- return new ApiError(HTTP_SERVER_ERROR, e)
113
- }
94
+ await requestedRename(node, dest, ctx)
95
+ return {}
96
},
97
116
- async move_files({ uri_from, uri_to }, ctx, override) {
117
- apiAssertTypes({ array: { uri_from }, string: { uri_to } })
118
- try { ctx.logExtra(null, { target: uri_from.map(pathDecode), destination: pathDecode(uri_to) }) }
119
- catch { return new ApiError(HTTP_BAD_REQUEST) }
120
- const destNode = await urlToNode(uri_to, ctx)
121
- const err = !destNode ? HTTP_NOT_FOUND
122
- : !nodeIsFolder(destNode) ? HTTP_METHOD_NOT_ALLOWED
123
- : statusCodeForMissingPerm(destNode, 'can_upload', ctx)
124
- if (err)
125
- return new ApiError(err)
126
- return {
127
- errors: await Promise.all(uri_from.map(async (from1: any) => {
128
- if (typeof from1 !== 'string') return HTTP_BAD_REQUEST
129
- const srcNode = await urlToNode(from1, ctx)
130
- const src = srcNode?.source
131
- if (!src) return HTTP_NOT_FOUND
132
- const destName = basename(src)
133
- const destChild = await urlToNode(destName, ctx, destNode!)
134
- if (destChild && statusCodeForMissingPerm(destChild, 'can_delete', ctx))
135
- return ctx.status
136
- const dest = join(destNode!.source!, destName)
137
- if (_.isFunction(override))
138
- return override?.(srcNode, dest)
139
- return statusCodeForMissingPerm(srcNode, 'can_delete', ctx)
140
- || rename(src, dest).catch(async e => {
141
- if (e.code !== 'EXDEV') throw e // exdev = different drive
142
- await copyFile(src, dest)
143
- await unlink(src)
144
- }).catch(e => e.code || String(e))
145
- }))
146
- }
98
+ async move_files({ uri_from, uri_to }, ctx) {
99
+ return moveFiles(uri_from, uri_to, ctx)
100
},
101
149
- async copy_files(params, ctx) {
150
- return frontEndApis.move_files!(params, ctx, // same parameters
151
- (srcNode: VfsNode, dest: string) => // but override behavior
152
- statusCodeForMissingPerm(srcNode, 'can_read', ctx)
153
- // .source is checked by move_files
154
- || copyFile(srcNode.source!, dest, fs.constants.COPYFILE_EXCL | fs.constants.COPYFILE_FICLONE)
155
- .catch(e => e.code || String(e))
102
+ async copy_files({ uri_from, uri_to }, ctx) {
103
+ return moveFiles(uri_from, uri_to, ctx, (srcNode: VfsNode, dest: string) => // override behavior
104
+ statusCodeForMissingPerm(srcNode, 'can_read', ctx)
105
+ || copyFile(srcNode.source!, dest, fs.constants.COPYFILE_EXCL | fs.constants.COPYFILE_FICLONE) // .source is checked by moveFiles
106
+ .catch(e => e.code || String(e))
107
)
108
},
109
@@ -203,3 +154,71 @@ export function notifyClient(channel: string | Koa.Context, name: string, data:
154
}
155
156
const NOTIFICATION_PREFIX = 'notificationChannel:'
157
+
158
+export async function moveFiles(uri_from: any, uri_to: any, ctx: Koa.Context, override?: Function) {
159
+ apiAssertTypes({ array: { uri_from }, string: { uri_to } })
160
+ try { ctx.logExtra(null, { target: uri_from.map(pathDecode), destination: pathDecode(uri_to) }) }
161
+ catch { return new ApiError(HTTP_BAD_REQUEST) }
162
+ const destNode = await urlToNode(uri_to, ctx)
163
+ const err = !destNode ? HTTP_NOT_FOUND
164
+ : !nodeIsFolder(destNode) ? HTTP_METHOD_NOT_ALLOWED
165
+ : statusCodeForMissingPerm(destNode, 'can_upload', ctx)
166
+ if (err)
167
+ return new ApiError(err)
168
+ return {
169
+ errors: await Promise.all(uri_from.map(async (from1: any) => {
170
+ if (typeof from1 !== 'string') return HTTP_BAD_REQUEST
171
+ const srcNode = await urlToNode(from1, ctx)
172
+ const src = srcNode?.source
173
+ if (!src) return HTTP_NOT_FOUND
174
+ const destName = basename(src)
175
+ const destChild = await urlToNode(destName, ctx, destNode!)
176
+ if (destChild && statusCodeForMissingPerm(destChild, 'can_delete', ctx))
177
+ return ctx.status
178
+ const dest = join(destNode!.source!, destName)
179
+ if (_.isFunction(override))
180
+ return override?.(srcNode, dest)
181
+ return statusCodeForMissingPerm(srcNode, 'can_delete', ctx)
182
+ || rename(src, dest).catch(async e => {
183
+ if (e.code !== 'EXDEV') throw e // exdev = different drive
184
+ await copyFile(src, dest)
185
+ await unlink(src)
186
+ }).catch(e => e.code || String(e))
187
+ }))
188
+ }
189
+}
190
+
191
+export async function requestedRename(node: VfsNode | undefined, newName: string, ctx: Koa.Context) {
192
+ if (!node)
193
+ throw new ApiError(HTTP_NOT_FOUND)
194
+ if (statusCodeForMissingPerm(node, 'can_delete', ctx))
195
+ return new ApiError(ctx.status)
196
+ try {
197
+ if (node.name) // virtual name = virtual rename
198
+ node.name = newName
199
+ else {
200
+ if (!node.source)
201
+ throw new ApiError(HTTP_FAILED_DEPENDENCY)
202
+ const destNode = await urlToNode(pathEncode(newName), ctx, node.parent)
203
+ if (destNode && statusCodeForMissingPerm(destNode, 'can_delete', ctx)) // if destination exists, you need delete permission
204
+ return new ApiError(ctx.status)
205
+ try {
206
+ const destSource = join(dirname(node.source), newName)
207
+ await rename(node.source, destSource)
208
+ getCommentFor(node.source).then(c => {
209
+ if (!c) return
210
+ void setCommentFor(node.source!, '')
211
+ void setCommentFor(destSource, c)
212
+ })
213
+ return {}
214
+ }
215
+ catch (e: any) {
216
+ return new ApiError(HTTP_SERVER_ERROR, e)
217
+ }
218
+ }
219
+ return
220
+ }
221
+ catch (e: any) {
222
+ throw new ApiError(HTTP_SERVER_ERROR, e)
223
+ }
224
+}
src/log.ts
+5
-3
@@ -77,10 +77,12 @@ export const logMw: Koa.Middleware = async (ctx, next) => {
77
// do it now so it's available for returning plugins
78
ctx.state.completed = Promise.race([ once(ctx.res, 'finish'), once(ctx.res, 'close') ])
79
await next()
80
- console.debug(ctx.status, ctx.method, ctx.originalUrl, ctx.isAborted() ? '(aborted)' : '')
80
+ if (!ctx.state.dontLog) // with Finder's webdav spam, it's best to not report even in console
81
+ console.debug(ctx.status, ctx.method, ctx.originalUrl, ctx.isAborted() ? '(aborted)' : '')
82
if (!logSpam.get()
82
- && (ctx.querystring.includes('{.exec|')
83
- || ctx.status === HTTP_NOT_FOUND && /wlwmanifest.xml$|robots.txt$|\.(php)$|cgi/.test(ctx.path))) {
83
+ && (ctx.querystring.includes('{.exec|') // v2's bug
84
+ // requests generated by security scanners, other bots, and macos' finder
85
+ || ctx.status === HTTP_NOT_FOUND && /wlwmanifest.xml$|\.(php)$|cgi|robots.txt$/.test(ctx.path))) {
86
events.emit('spam', ctx)
87
return
88
}
src/serveGuiAndSharedFiles.ts
+2
@@ -29,6 +29,7 @@ import { setCommentFor } from './comments'
29
import { basicWeb, detectBasicAgent } from './basicWeb'
30
import { customizedIcons, ICONS_FOLDER } from './icons'
31
import { getPluginInfo } from './plugins'
32
+import { handledWebdav } from './webdav'
33
34
const serveFrontendFiles = serveGuiFiles(process.env.FRONTEND_PROXY, FRONTEND_URI)
35
const serveFrontendPrefixed = mount(FRONTEND_URI.slice(0,-1), serveFrontendFiles)
@@ -59,6 +60,7 @@ export const serveGuiAndSharedFiles: Koa.Middleware = async (ctx, next) => {
60
ctx.state.considerAsGui = true
61
return serveFile(ctx, join(plugin?.folder || '', ICONS_FOLDER, file), MIME_AUTO)
62
}
63
+ if (await handledWebdav(ctx)) return
64
const { get } = ctx.query
65
const getUploadTempHash = get === UPLOAD_TEMP_HASH
66
if (ctx.method === 'PUT' || getUploadTempHash) { // PUT is what you get with `curl -T file url/`
src/upload.ts
+3
-2
@@ -60,7 +60,7 @@ const diskSpaceCache = expiringCache<ReturnType<typeof getDiskSpaceSync>>(3_000)
60
const uploadingFiles = new Map<string, { ctx: Koa.Context, size: number, got: number }>()
61
// initially sync for formidable; still sync to avoid async races and PUT piping gaps
62
export function uploadWriter(base: VfsNode, baseUri: string, filename: string, ctx: Koa.Context) {
63
- if (!filename || !isValidFileName(filename))
63
+ if (!filename || !isValidFileName(filename) || !filename)
64
return fail(HTTP_FOOL)
65
if (statusCodeForMissingPerm(base, 'can_upload', ctx))
66
return fail()
@@ -288,7 +288,8 @@ export function uploadWriter(base: VfsNode, baseUri: string, filename: string, c
288
if (msg)
289
ctx.body = msg
290
if (status >= 400 // with other codes Chrome will report ERR_CONNECTION_RESET
291
- && !ctx.get('x-hfs-wait')) // you can disable the following behavior
291
+ && !ctx.get('x-hfs-wait') // you can disable the following behavior
292
+ && !ctx.req.complete) // if request body is already complete, forcing a disconnect can interfere with follow-up requests on reused sockets.
293
setTimeout(() => disconnect(ctx), 200) // don't wait, if the upload is still in progress
294
}
295
}
src/util-files.ts
+1
-1
@@ -156,7 +156,7 @@ export async function createSafeWriteStream(path: string, options?: Parameters<t
156
}
157
158
export function isValidFileName(name: string, acceptUnreadable=false) {
159
- return name !== '.' && !(IS_WINDOWS ? /[/:"*?<>|\\]/ : /\//).test(name) && !hasDirTraversal(name)
159
+ return name && name !== '.' && !(IS_WINDOWS ? /[/:"*?<>|\\]/ : /\//).test(name) && !hasDirTraversal(name)
160
&& (acceptUnreadable || !/[\u0000-\u001F\u007F]/.test(name))
161
}
162
src/webdav.ts
new
+299
@@ -0,0 +1,299 @@
1
+import Koa from 'koa'
2
+import {
3
+ getNodeName, nodeIsFolder, nodeIsLink, nodeStats, statusCodeForMissingPerm, urlToNode, vfs, VfsNode, walkNode
4
+} from './vfs'
5
+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
+} from './cross'
9
+import { PassThrough } from 'stream'
10
+import { mkdir, rm } from 'fs/promises'
11
+import { isValidFileName } from './misc'
12
+import { basename, dirname, join } from 'path'
13
+import { moveFiles, requestedRename } from './frontEndApis'
14
+import { randomUUID } from 'node:crypto'
15
+import { IS_MAC } from './const'
16
+import { exec } from 'child_process'
17
+import { getCurrentUsername } from './auth'
18
+import { defineConfig } from './config'
19
+import { expiringCache } from './expiringCache'
20
+
21
+const forceWebdavLogin = defineConfig<boolean|string, null|RegExp>(CFG.force_webdav_login, true, compileWebdavAgentRegex)
22
+const webdavInitialAuth = defineConfig<boolean|string, null|RegExp>(CFG.webdav_initial_auth, 'WebDAVFS', compileWebdavAgentRegex)
23
+const webdavPrompted = expiringCache<boolean>(DAY)
24
+const webdavDetectedAgents = new Set<string>()
25
+
26
+const TOKEN_HEADER = 'lock-token'
27
+const WEBDAV_METHODS = new Set(['PROPFIND', 'PROPPATCH', 'MKCOL', 'MOVE', 'LOCK', 'UNLOCK'])
28
+const WEBDAV_HINT_HEADERS = ['depth', 'destination', 'overwrite', 'translate', 'if', TOKEN_HEADER, 'x-expected-entity-length']
29
+const KNOWN_UA = /webdav|miniredir|davclnt/i
30
+
31
+const canOverwrite = new Set()
32
+const locks = new Map<string, { token: string, timeout: NodeJS.Timeout }>()
33
+
34
+function isLocked(path: string, ctx: Koa.Context) {
35
+ const lock = locks.get(path)
36
+ if (!lock) return false
37
+ const ifHeader = ctx.get('If')
38
+ const tokenHeader = ctx.get(TOKEN_HEADER)
39
+ if (hasToken(ifHeader, lock.token) || hasToken(tokenHeader, lock.token))
40
+ return false
41
+ ctx.status = HTTP_LOCKED
42
+ return true
43
+}
44
+
45
+function hasToken(header: string, token: string) {
46
+ if (!header) return false
47
+ return header.includes(`<${token}>`) || header.split(/[,;\s]+/).includes(token)
48
+}
49
+
50
+export async function handledWebdav(ctx: Koa.Context) {
51
+ const {path} = ctx
52
+ const isWebdavAuthRequest = WEBDAV_METHODS.has(ctx.method) || WEBDAV_HINT_HEADERS.some(h => !!ctx.get(h))
53
+ const ua = ctx.get('user-agent')
54
+ if (isWebdavAuthRequest && getCurrentUsername(ctx)) {
55
+ if (ua)
56
+ webdavDetectedAgents.add(ua)
57
+ }
58
+
59
+ if (ctx.path.includes('/._') && ua?.startsWith('WebDAVFS')) {// too much spam from Finder for these files that can contain metas
60
+ ctx.state.dontLog = true
61
+ return ctx.status = HTTP_FORBIDDEN
62
+ }
63
+ if (ctx.method === 'OPTIONS') {
64
+ if (ctx.get('Access-Control-Request-Method')) return // it's a preflight cors request, not webdav
65
+ setWebdavHeaders()
66
+ ctx.body = ''
67
+ return true
68
+ }
69
+ if (isWebdavAuthRequest && shouldChallengeWebdav())
70
+ return true
71
+ if (ctx.method === 'PUT') {
72
+ if (isLocked(path, ctx)) return true
73
+ // 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.
74
+ const x = ctx.get('x-expected-entity-length') // field used by Finder's webdav on actual upload, after
75
+ if (!x && !ctx.length) {
76
+ canOverwrite.add(path)
77
+ setTimeout(() => canOverwrite.delete(path), 10_000) // grace period
78
+ }
79
+ else if (canOverwrite.has(path)) {
80
+ canOverwrite.delete(path)
81
+ const node = await urlToNode(path, ctx)
82
+ if (node?.source)
83
+ await rm(node.source).catch(() => {})
84
+ }
85
+ if (x && ctx.length === undefined) // missing length can make PUT fail
86
+ ctx.req.headers['content-length'] = x
87
+
88
+ if (KNOWN_UA.test(ua) || webdavDetectedAgents.has(ua))
89
+ ctx.query.existing ??= 'overwrite' // with webdav this is our default
90
+ return // default handling
91
+ }
92
+ if (ctx.method === 'MKCOL') {
93
+ setWebdavHeaders()
94
+ if (isLocked(path, ctx)) return true
95
+ const node = await urlToNode(path, ctx)
96
+ if (node)
97
+ return ctx.status = HTTP_METHOD_NOT_ALLOWED
98
+ let name = ''
99
+ const parentNode = await urlToNode(path, ctx, vfs, v => name = v)
100
+ if (!parentNode)
101
+ return ctx.status = HTTP_NOT_FOUND
102
+ if (!isValidFileName(name))
103
+ return ctx.status = HTTP_BAD_REQUEST
104
+ if (statusCodeForMissingPerm(parentNode, 'can_upload', ctx)) {
105
+ if (ctx.status === HTTP_UNAUTHORIZED)
106
+ setWebdavHeaders(true)
107
+ return true
108
+ }
109
+ try {
110
+ await mkdir(join(parentNode.source!, name))
111
+ return ctx.status = HTTP_CREATED
112
+ }
113
+ catch(e:any) {
114
+ return ctx.status = HTTP_SERVER_ERROR
115
+ }
116
+ }
117
+ if (ctx.method === 'MOVE') {
118
+ setWebdavHeaders()
119
+ if (isLocked(path, ctx)) return true
120
+ const node = await urlToNode(path, ctx)
121
+ if (!node) return
122
+ let dest = ctx.get('destination')
123
+ const i = dest.indexOf('//')
124
+ if (i >= 0)
125
+ dest = dest.slice(dest.indexOf('/', i + 2))
126
+ if (isLocked(dest, ctx)) return true
127
+ if (dirname(path) === dirname(dest)) // rename. `path` is is encoded, so we test before decoding `dest`
128
+ try {
129
+ await requestedRename(node, basename(decodeURI(dest)), ctx)
130
+ return ctx.status = HTTP_CREATED
131
+ }
132
+ catch(e:any) {
133
+ return ctx.status = e.status || HTTP_SERVER_ERROR
134
+ }
135
+ const moveRes = await moveFiles([path], dirname(dest), ctx)
136
+ if (moveRes instanceof Error)
137
+ return ctx.status = (moveRes as any).status || HTTP_SERVER_ERROR
138
+ const err = moveRes?.errors?.[0]
139
+ return ctx.status = !err ? HTTP_CREATED : typeof err === 'number' ? err : HTTP_SERVER_ERROR
140
+ }
141
+ if (ctx.method === 'DELETE') {
142
+ setWebdavHeaders()
143
+ if (isLocked(path, ctx)) return true
144
+ return // allow default handling in serveGuiAndSharedFiles.ts
145
+ }
146
+ if (ctx.method === 'UNLOCK') {
147
+ setWebdavHeaders()
148
+ const x = ctx.get(TOKEN_HEADER).slice(1,-1)
149
+ const lock = locks.get(path)
150
+ if (x !== lock?.token)
151
+ return ctx.status = HTTP_BAD_REQUEST
152
+ clearTimeout(lock.timeout)
153
+ locks.delete(path)
154
+ ctx.set(TOKEN_HEADER, x)
155
+ if (IS_MAC)
156
+ urlToNode(path, ctx).then(x => x?.source && dotClean(dirname(x.source)))
157
+ return ctx.status = HTTP_NO_CONTENT
158
+ }
159
+ if (ctx.method === 'LOCK') {
160
+ setWebdavHeaders()
161
+ if (locks.has(path))
162
+ return ctx.status = 423
163
+ const token = 'urn:uuid:' + randomUUID()
164
+ ctx.set(TOKEN_HEADER, token)
165
+ const seconds = 3600
166
+ const timeout = setTimeout(() => locks.delete(path), seconds * 1000)
167
+ locks.set(path, { token, timeout })
168
+ ctx.body = `<?xml version="1.0" encoding="utf-8"?><prop xmlns="DAV:"><lockdiscovery><activelock>
169
+ <locktype><write/></locktype>
170
+ <lockscope><exclusive/></lockscope>
171
+ <locktoken><href>${token}</href></locktoken>
172
+ <lockroot><href>${path}</href></lockroot>
173
+ <depth>0</depth>
174
+ <timeout>Second-${seconds}</timeout>
175
+ </activelock></lockdiscovery></prop>`
176
+ return true
177
+ }
178
+ if (ctx.method === 'PROPFIND') {
179
+ setWebdavHeaders()
180
+ const node = await urlToNode(path, ctx)
181
+ if (!node) return
182
+ let depth = Number(ctx.get('depth'))
183
+ depth = isNaN(depth) ? Infinity : depth
184
+ const isList = depth !== 0
185
+ if (statusCodeForMissingPerm(node, isList ? 'can_list' : 'can_see', ctx)) {
186
+ if (ctx.status === HTTP_UNAUTHORIZED)
187
+ setWebdavHeaders(true)
188
+ return true
189
+ }
190
+ ctx.type = 'xml'
191
+ ctx.status = 207
192
+ const pathSlash = enforceFinal('/', path)
193
+ const res = ctx.body = new PassThrough({ encoding: 'utf8' })
194
+ res.write(`<?xml version="1.0" encoding="utf-8" ?><multistatus xmlns="DAV:">`)
195
+ await sendEntry(node)
196
+ if (isList) {
197
+ depth = Math.max(0, depth - 1)
198
+ for await (const n of walkNode(node, { ctx, depth }))
199
+ await sendEntry(n, true)
200
+ }
201
+ res.write(`</multistatus>`)
202
+ res.end()
203
+ return true
204
+
205
+ async function sendEntry(node: VfsNode, append=false) {
206
+ if (nodeIsLink(node)) return
207
+ const name = getNodeName(node)
208
+ const isDir = await nodeIsFolder(node)
209
+ const st = await nodeStats(node)
210
+ res.write(`<response>
211
+ <href>${pathSlash + (append ? pathEncode(name, true) + (isDir ? '/' : '') : '')}</href>
212
+ <propstat>
213
+ <status>HTTP/1.1 200 OK</status>
214
+ <prop>
215
+ ${prefix('<getlastmodified>', (st?.mtime as any)?.toGMTString(), '</getlastmodified>')}
216
+ ${prefix('<creationdate>', (st?.birthtime || st?.ctime)?.toISOString().replace(/\..*/, '-00:00'), '</creationdate>')}
217
+ ${isDir ? '<resourcetype><collection/></resourcetype>'
218
+ : `<resourcetype/><getcontentlength>${st?.size}</getcontentlength>`}
219
+ </prop>
220
+ </propstat>
221
+ </response>
222
+ `)
223
+ }
224
+ }
225
+ if (ctx.method === 'PROPPATCH') {
226
+ setWebdavHeaders()
227
+ if (isLocked(path, ctx)) return true
228
+ const node = await urlToNode(path, ctx)
229
+ if (!node) return
230
+ if (statusCodeForMissingPerm(node, 'can_see', ctx)) {
231
+ if (ctx.status === HTTP_UNAUTHORIZED)
232
+ setWebdavHeaders(true)
233
+ return true
234
+ }
235
+ ctx.type = 'xml'
236
+ ctx.status = 207
237
+ ctx.body = `<?xml version="1.0" encoding="utf-8"?>
238
+ <multistatus xmlns="DAV:">
239
+ <response>
240
+ <href>${path}</href>
241
+ <propstat>
242
+ <status>HTTP/1.1 200 OK</status>
243
+ <prop/>
244
+ </propstat>
245
+ </response>
246
+ </multistatus>`
247
+ return true
248
+ }
249
+
250
+ function setWebdavHeaders(authenticate=false) {
251
+ ctx.set('DAV', '1,2')
252
+ ctx.set('MS-Author-Via', 'DAV')
253
+ ctx.set('Allow', 'PROPFIND,PROPPATCH,OPTIONS,DELETE,MOVE,LOCK,UNLOCK,MKCOL,PUT')
254
+ if (authenticate)
255
+ 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
256
+ }
257
+
258
+ function shouldChallengeWebdav() {
259
+ if (getCurrentUsername(ctx))
260
+ return false
261
+ if (forceWebdavLogin.compiled()?.test(ua))
262
+ return challengeWebdav()
263
+ if (!webdavInitialAuth.compiled()?.test(ua))
264
+ return false
265
+ if (ctx.get('authorization'))
266
+ return challengeWebdav()
267
+ const key = `${ctx.ip}|${ctx.host}|${ua || ''}`
268
+ if (webdavPrompted.has(key))
269
+ return false
270
+ webdavPrompted.try(key, () => true)
271
+ return challengeWebdav()
272
+
273
+ function challengeWebdav() {
274
+ setWebdavHeaders(true)
275
+ ctx.status = HTTP_UNAUTHORIZED
276
+ ctx.body = ''
277
+ return true
278
+ }
279
+ }
280
+
281
+}
282
+
283
+function compileWebdavAgentRegex(v: boolean|string) {
284
+ return !v ? null : v === true ? /.*/ : new RegExp(v.trim(), 'i')
285
+}
286
+
287
+// Finder will upload special attributes as files with name ._* that can be merged using system utility "dot_clean"
288
+const cleaners: Dict<Timeout> = {}
289
+function dotClean(path: string) {
290
+ getOrSet(cleaners, path, () => setTimeout(() => {
291
+ try { exec('dot_clean .', { cwd: path }, (err, out) => done(err || out)) }
292
+ catch (e) { done(e) }
293
+
294
+ function done(log: any) {
295
+ console.debug('dot_clean', path, log)
296
+ delete cleaners[path]
297
+ }
298
+ }, 10_000))
299
+}
tests/test.ts
+141
-3
@@ -39,6 +39,15 @@ const UPLOAD_DEST = UPLOAD_ROOT + UPLOAD_RELATIVE
39
const BIG_CONTENT = _.repeat(randomId(10), 300_000) // 3MB, big enough to saturate buffers
40
const throttle = BIG_CONTENT.length /1000 /0.8 // KB, finish in 0.8s, quick but still overlapping downloads
41
const SAMPLE_FILE_PATH = resolve(__dirname, 'page/gpl.png')
42
+const WEBDAV_UA = 'Microsoft-WebDAV-MiniRedir/10.0.22000'
43
+const WEBDAV_PROPPATCH_BODY = `<?xml version="1.0" encoding="utf-8"?>
44
+<propertyupdate xmlns="DAV:">
45
+ <set>
46
+ <prop>
47
+ <displayname>patched</displayname>
48
+ </prop>
49
+ </set>
50
+</propertyupdate>`
51
let defaultBaseUrl = BASE_URL
52
53
const execP = (cmd: string) => promisify(exec)(cmd).then(x => x.stdout)
@@ -230,6 +239,112 @@ describe('basics', () => {
239
})
240
})
241
242
+describe('webdav', () => {
243
+ const jar = {}
244
+ after(() => rmAny(resolve(__dirname, UPLOAD_DIR)))
245
+ test('webdav force login.scope propfind', req('/f1/', 401, { method: 'PROPFIND', headers: { depth: '0' }, jar }))
246
+ test('webdav.put detects client after propfind', async () => {
247
+ const name = `wd-detected-${randomId(6)}.txt`
248
+ const ua = `hfs-test-detected-${randomId(6)}`
249
+ const uri = `${UPLOAD_ROOT}${UPLOAD_DIR}/${name}`
250
+ let destPath = ''
251
+ try {
252
+ destPath = await webdavUpload(uri, x => x?.uri === uri, 'dest', ua)()
253
+ await req(uri, 207, { method: 'PROPFIND', auth, jar, headers: { depth: '0', 'user-agent': ua } })()
254
+ const secondPath = await webdavUpload(uri, x => x?.uri === uri, 'source', ua)()
255
+ if (secondPath !== destPath)
256
+ throw "destination changed unexpectedly"
257
+ if (readFileSync(destPath, 'utf8') !== 'source')
258
+ throw "destination wasn't overwritten"
259
+ }
260
+ finally {
261
+ await rmAny(destPath)
262
+ }
263
+ })
264
+ test('webdav.put default-overwrite with can_delete', async () => {
265
+ const name = `wd-overwrite-${randomId(6)}.txt`
266
+ const uri = `${UPLOAD_ROOT}${UPLOAD_DIR}/${name}`
267
+ let destPath = ''
268
+ try {
269
+ destPath = await webdavUpload(uri, x => x?.uri === uri, 'dest')()
270
+ const secondPath = await webdavUpload(uri, x => x?.uri === uri, 'source')()
271
+ if (secondPath !== destPath)
272
+ throw "destination changed unexpectedly"
273
+ if (readFileSync(destPath, 'utf8') !== 'source')
274
+ throw "destination wasn't overwritten"
275
+ }
276
+ finally {
277
+ await rmAny(destPath)
278
+ }
279
+ })
280
+ test('webdav.put overwrite forbidden without can_delete', async () => {
281
+ const name = `wd-nodelete-${randomId(6)}.txt`
282
+ const uri = `${CANT_OVERWRITE_URI}${name}`
283
+ const dir = await ensureCantOverwriteDir()
284
+ const destPath = resolve(dir, name)
285
+ await writeFile(destPath, 'dest')
286
+ try {
287
+ await webdavUpload(uri, 403, 'source')()
288
+ if (readFileSync(destPath, 'utf8') !== 'dest')
289
+ throw "destination changed"
290
+ }
291
+ finally {
292
+ await rmAny(destPath)
293
+ }
294
+ })
295
+ test('webdav.proppatch file', async () => {
296
+ const name = `wd-proppatch-${randomId(6)}.txt`
297
+ const uri = `${UPLOAD_ROOT}${UPLOAD_DIR}/${name}`
298
+ let destPath = ''
299
+ try {
300
+ destPath = await webdavUpload(uri, x => x?.uri === uri, 'test')()
301
+ await webdavProppatch(uri)()
302
+ }
303
+ finally {
304
+ await rmAny(destPath)
305
+ }
306
+ })
307
+ test('webdav.proppatch folder', async () => {
308
+ const folder = `wd-proppatch-dir-${randomId(6)}`
309
+ const uri = `${UPLOAD_ROOT}${folder}/`
310
+ try {
311
+ await reqApi('create_folder', { uri: UPLOAD_ROOT, name: folder }, 200, { auth, jar })()
312
+ await webdavProppatch(uri)()
313
+ }
314
+ finally {
315
+ await req(uri, 200, { method: 'delete', auth, jar })().catch(() => {})
316
+ }
317
+ })
318
+
319
+ function webdavUpload(uri: string, tester: Tester, body: string, userAgent=WEBDAV_UA) {
320
+ return () => req(uri, tester, {
321
+ method: 'PUT',
322
+ auth,
323
+ jar,
324
+ headers: {
325
+ 'content-length': Buffer.byteLength(body),
326
+ 'user-agent': userAgent,
327
+ },
328
+ body,
329
+ })().then(res => uploadUriToPath(res?.uri || uri))
330
+ }
331
+
332
+ function webdavProppatch(uri: string, tester: Tester=207, body=WEBDAV_PROPPATCH_BODY, userAgent=WEBDAV_UA) {
333
+ return req(uri, tester, {
334
+ method: 'PROPPATCH',
335
+ auth,
336
+ jar,
337
+ headers: {
338
+ 'content-type': 'text/xml',
339
+ 'content-length': Buffer.byteLength(body),
340
+ 'user-agent': userAgent,
341
+ },
342
+ body,
343
+ })
344
+ }
345
+
346
+})
347
+
348
// do this before login, or max_dl_accounts config will override max_dl
349
describe('limits', () => {
350
const fn = ROOT + 'big'
@@ -286,7 +401,7 @@ describe('after-login', () => {
401
before(() => login(username))
402
const trickyChars = '%strange#'
403
test('create_folder', reqApi('create_folder', { uri: UPLOAD_ROOT, name: UPLOAD_DIR }, 200))
289
- test('create_folder.empty name', reqApi('create_folder', { uri: UPLOAD_ROOT, name: '' }, 409))
404
+ test('create_folder.empty name', reqApi('create_folder', { uri: UPLOAD_ROOT, name: '' }, 400))
405
test('create_folder.tricky chars', async () => {
406
await reqApi('create_folder', { uri: UPLOAD_ROOT, name: trickyChars }, 200)()
407
const dest = resolve(__dirname, trickyChars)
@@ -321,6 +436,18 @@ describe('after-login', () => {
436
await req(`${UPLOAD_ROOT}${rel}?get=${UPLOAD_TEMP_HASH}`, 401, { jar: {} })()
437
})
438
test('upload.temp hash missing', req(`${UPLOAD_ROOT}${UPLOAD_DIR}/missing.png?get=${UPLOAD_TEMP_HASH}`, 404))
439
+ test('upload.numbered', async () => {
440
+ const uri = `${UPLOAD_ROOT}${UPLOAD_DIR}/put-plain-${randomId(6)}.txt`
441
+ const res: any = {}
442
+ try {
443
+ res.first = await reqUpload(uri, x => x?.uri === uri, 'some')()
444
+ res.second = await reqUpload(uri, x => x?.uri !== uri, 'more')() // this will be numbered to not overwrite
445
+ }
446
+ finally {
447
+ await rmAny(uploadUriToPath(res.first?.uri))
448
+ await rmAny(uploadUriToPath(res.second?.uri))
449
+ }
450
+ })
451
test('file_details.admin', reqApi('get_file_details', { uris: [UPLOAD_DEST] }, res => {
452
const u = res?.details?.[0]?.upload
453
throwIf(!u?.ip ? 'ip' : u?.username !== username ? 'username' : '')
@@ -552,6 +679,13 @@ describe('admin', () => {
679
await reqApi('del_vfs', { uris: ['/'+name] }, data => data?.errors?.[0] === 0, { auth })() // remove
680
}
681
})
682
+ test('add_vfs source without name', async () => {
683
+ const res = await reqApi('add_vfs', { source: '.' }, 200, { auth })()
684
+ const name = res?.name
685
+ if (typeof name !== 'string' || !name)
686
+ throw "missing name"
687
+ await reqApi('del_vfs', { uris: ['/' + name] }, data => [0, 404].includes(data?.errors?.[0]), { auth })().catch(() => {})
688
+ })
689
test('set_vfs.rename and props', async () => {
690
const name = `set-vfs-${randomId(6)}`
691
const renamed = `${name}-renamed`
@@ -648,7 +782,7 @@ function reqUpload(dest: string, tester: Tester, body?: string | Readable, size?
782
tester = {
783
status,
784
cb(data) {
651
- const fn = ROOT + decodeURI(data.uri).replace(UPLOAD_ROOT, '')
785
+ const fn = uploadUriToPath(data.uri)
786
const stats = try_(() => statSync(fn))
787
if (!stats)
788
throw "uploaded file not found: " + fn
@@ -664,6 +798,10 @@ function reqUpload(dest: string, tester: Tester, body?: string | Readable, size?
798
})
799
}
800
801
+function uploadUriToPath(uri: string) {
802
+ return ROOT + decodeURI(uri).replace(UPLOAD_ROOT, '')
803
+}
804
+
805
async function testMaxDl(uri: string, good: number, bad: number) {
806
// make good+bad requests, and check results
807
await Promise.all(_.range(good + bad).map(i => req(uri + '?' + i, (_data, res) => {
@@ -805,7 +943,7 @@ function isInList(res:any, name:string) {
943
}
944
945
function rmAny(path: string) {
808
- return rm(path, { recursive: true, force: true }).catch(() => {})
946
+ return path && rm(path, { recursive: true, force: true }).catch(() => {})
947
}
948
949
function throwIf(msg: any) {