@samitouri / QOSami-HFS / commits / ecb980a0

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) {