better code: webdav as middleware
Massimo Melina committed
Mar 20, 2026 at 11:15 UTC
9e18f2847f4e3509853b56848a7dfa145e8ba036
4 files changed
+42
-31
src/index.ts
+5
-2
@@ -11,7 +11,8 @@ import { logMw } from './log'
11
import { pluginsMiddleware } from './plugins'
12
import { throttler } from './throttler'
13
import { headRequests, gzipper, someSecurity, prepareState, paramsDecoder, sessionMiddleware } from './middlewares'
14
-import { serveGuiAndSharedFiles } from './serveGuiAndSharedFiles'
14
+import { serveSharedFiles, guiFilesMiddleware } from './serveGuiAndSharedFiles'
15
+import { webdav } from './webdav'
16
import './listen'
17
import './commands'
18
import { adminApis } from './adminApis'
@@ -54,7 +55,9 @@ app.use(sessionMiddleware)
55
.use(throttler)
56
.use(pluginsMiddleware)
57
.use(mount(API_URI, apiMiddleware({ ...frontEndApis, ...adminApis })))
57
- .use(serveGuiAndSharedFiles)
58
+ .use(guiFilesMiddleware)
59
+ .use(webdav)
60
+ .use(serveSharedFiles)
61
.on('error', errorHandler)
62
events.emit('app', app)
63
src/serveGuiAndSharedFiles.ts
+7
-6
@@ -29,14 +29,13 @@ 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, releaseWebdavLock } from './webdav'
32
33
const serveFrontendFiles = serveGuiFiles(process.env.FRONTEND_PROXY, FRONTEND_URI)
34
const serveFrontendPrefixed = mount(FRONTEND_URI.slice(0,-1), serveFrontendFiles)
35
const serveAdminFiles = serveGuiFiles(process.env.ADMIN_PROXY, ADMIN_URI)
36
const serveAdminPrefixed = mount(ADMIN_URI.slice(0,-1), serveAdminFiles)
37
39
-export const serveGuiAndSharedFiles: Koa.Middleware = async (ctx, next) => {
38
+export const guiFilesMiddleware: Koa.Middleware = async (ctx, next) => {
39
const { path } = ctx
40
// dynamic import on frontend|admin (used for non-https login) while developing (vite4) is not producing a relative path
41
if (DEV && path.startsWith('/node_modules/')) {
@@ -45,7 +44,7 @@ export const serveGuiAndSharedFiles: Koa.Middleware = async (ctx, next) => {
44
: serveFrontendFiles(ctx, next)
45
}
46
if (path.startsWith(FRONTEND_URI))
48
- return serveFrontendPrefixed(ctx,next)
47
+ return serveFrontendPrefixed(ctx, next)
48
if (path.length === ADMIN_URI.length - 1 && ADMIN_URI.startsWith(path))
49
return ctx.redirect(ctx.state.revProxyPath + ADMIN_URI)
50
if (path.startsWith(ADMIN_URI))
@@ -60,7 +59,11 @@ export const serveGuiAndSharedFiles: Koa.Middleware = async (ctx, next) => {
59
ctx.state.considerAsGui = true
60
return serveFile(ctx, join(plugin?.folder || '', ICONS_FOLDER, file), MIME_AUTO)
61
}
63
- if (await handledWebdav(ctx)) return
62
+ return next()
63
+}
64
+
65
+export const serveSharedFiles: Koa.Middleware = async (ctx, next) => {
66
+ const { path } = ctx
67
const { get } = ctx.query
68
const getUploadTempHash = get === UPLOAD_TEMP_HASH
69
if (ctx.method === 'PUT' || getUploadTempHash) { // PUT is what you get with `curl -T file url/`
@@ -108,8 +111,6 @@ export const serveGuiAndSharedFiles: Koa.Middleware = async (ctx, next) => {
111
if ((await events.emitAsync('deleting', { node, ctx }))?.isDefaultPrevented())
112
return ctx.status = HTTP_FAILED_DEPENDENCY
113
await rm(source, { recursive: true })
111
- // webdav clients may forget UNLOCK on failures; successful delete must clear any lock tied to this path
112
- releaseWebdavLock(ctx.path)
114
void setCommentFor(source, '') // necessary only to clean a possible descript.ion or kvstorage
115
return ctx.status = HTTP_OK
116
} catch (e: any) {
src/serveGuiFiles.ts
+1
-1
@@ -195,7 +195,7 @@ function serveProxied(port: string | undefined, uri: string) { // used for devel
195
return function (ctx, next) {
196
if (!logGui.get())
197
ctx.state.dontLog = true
198
- return proxy(ctx, next)
198
+ return proxy(ctx, async () => {})
199
} as Koa.Middleware
200
}
201
src/webdav.ts
+29
-22
@@ -5,7 +5,7 @@ import {
5
} from './vfs'
6
import {
7
HTTP_BAD_REQUEST, HTTP_CONFLICT, HTTP_CREATED, HTTP_METHOD_NOT_ALLOWED, HTTP_NO_CONTENT, HTTP_NOT_FOUND,
8
- HTTP_PRECONDITION_FAILED, HTTP_SERVER_ERROR, HTTP_UNAUTHORIZED, HTTP_LOCKED, HTTP_FORBIDDEN,
8
+ HTTP_OK, HTTP_PRECONDITION_FAILED, HTTP_SERVER_ERROR, HTTP_UNAUTHORIZED, HTTP_LOCKED, HTTP_FORBIDDEN,
9
DAY, CFG, enforceFinal, pathEncode, prefix, getOrSet, Dict, Timeout, join as crossJoin, try_, safeDecodeURIComponent
10
} from './cross'
11
import { PassThrough } from 'stream'
@@ -75,7 +75,7 @@ function isSameLockPrincipal(lock: { principal: string }, ctx: Koa.Context) {
75
return lock.principal === getWebdavPrincipal(ctx)
76
}
77
78
-export async function handledWebdav(ctx: Koa.Context) {
78
+export const webdav: Koa.Middleware = async (ctx, next) => {
79
let {path} = ctx
80
path = path.replace(/^\/+/, '/') // double-slash is causing empty listing in filezilla-pro
81
@@ -89,15 +89,16 @@ export async function handledWebdav(ctx: Koa.Context) {
89
webdavDetectedAgents.try(webdavAgentKey(ctx, ua), () => true)
90
91
if (ctx.method === 'OPTIONS') {
92
- if (ctx.get('Access-Control-Request-Method')) return // it's a preflight cors request, not webdav
92
+ if (ctx.get('Access-Control-Request-Method')) // it's a preflight cors request, not webdav
93
+ return next()
94
setWebdavHeaders()
95
ctx.body = ''
95
- return true
96
+ return
97
}
98
if (isWebdavAuthRequest && shouldChallengeWebdav())
98
- return true
99
+ return
100
if (ctx.method === 'PUT') {
100
- if (await isLocked(path, ctx)) return true
101
+ if (await isLocked(path, ctx)) return
102
const overwriteGraceKey = path + prefix('|', getCurrentUsername(ctx)) // bind temporary overwrite grace to the authenticated user so accounts cannot reuse each other's grace window
103
// 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.
104
const x = ctx.get('x-expected-entity-length') // field used by Finder's webdav on actual upload, after
@@ -116,14 +117,16 @@ export async function handledWebdav(ctx: Koa.Context) {
117
118
if (KNOWN_UA.test(ua) || webdavDetectedAgents.has(webdavAgentKey(ctx, ua)))
119
ctx.query.existing ??= 'overwrite' // with webdav this is our default
119
- return // default handling
120
+ return next()
121
}
122
if (ctx.method === 'MKCOL') {
123
setWebdavHeaders()
123
- if (await isLocked(path, ctx)) return true
124
+ if (await isLocked(path, ctx)) return
125
const node = await urlToNode(path, ctx)
125
- if (node)
126
- return ctx.status = HTTP_METHOD_NOT_ALLOWED
126
+ if (node) {
127
+ ctx.status = HTTP_METHOD_NOT_ALLOWED
128
+ return
129
+ }
130
let name = ''
131
const parentNode = await urlToNode(path, ctx, vfs, v => name = v)
132
if (!parentNode)
@@ -133,7 +136,7 @@ export async function handledWebdav(ctx: Koa.Context) {
136
if (statusCodeForMissingPerm(parentNode, 'can_upload', ctx)) {
137
if (ctx.status === HTTP_UNAUTHORIZED)
138
setWebdavHeaders(true)
136
- return true
139
+ return
140
}
141
try {
142
await mkdir(join(parentNode.source!, name))
@@ -145,15 +148,15 @@ export async function handledWebdav(ctx: Koa.Context) {
148
}
149
if (ctx.method === 'MOVE') {
150
setWebdavHeaders()
148
- if (await isLocked(path, ctx)) return true
151
+ if (await isLocked(path, ctx)) return
152
const node = await urlToNode(path, ctx)
150
- if (!node) return
153
+ if (!node) return next()
154
let dest = ctx.get('destination')
155
const i = dest.indexOf('//')
156
if (i >= 0)
157
dest = dest.slice(dest.indexOf('/', i + 2))
158
dest = crossJoin(ctx.state.root || '', dest) // on Windows, we must use / as the delimiter to be able to compare with `path` below
156
- if (await isLocked(dest, ctx)) return true
159
+ if (await isLocked(dest, ctx)) return
160
if (dirname(path) === dirname(dest)) // rename case. `path` is is encoded, so we test before decoding `dest`
161
try {
162
// decode the single path segment so reserved chars like %2C become their real name on rename
@@ -172,13 +175,16 @@ export async function handledWebdav(ctx: Koa.Context) {
175
return ctx.status = (moveRes as any).status || HTTP_SERVER_ERROR
176
const err = moveRes?.errors?.[0]
177
if (!err)
175
- releaseWebdavLock(path) // successful move leaves old path invalid, therefore its lock must be dropped
178
+ releaseWebdavLock(path) // successful MOVE leaves the old path invalid, therefore its lock must be dropped
179
return ctx.status = !err ? HTTP_CREATED : typeof err === 'number' ? err : HTTP_SERVER_ERROR
180
}
181
if (ctx.method === 'DELETE') {
182
setWebdavHeaders()
180
- if (await isLocked(path, ctx)) return true
181
- return // allow default handling in serveGuiAndSharedFiles.ts
183
+ if (await isLocked(path, ctx)) return
184
+ await next()
185
+ if (ctx.status === HTTP_OK)
186
+ releaseWebdavLock(path) // webdav clients may forget UNLOCK; successful delete must clear any lock
187
+ return
188
}
189
if (ctx.method === 'UNLOCK') {
190
setWebdavHeaders()
@@ -220,7 +226,7 @@ export async function handledWebdav(ctx: Koa.Context) {
226
227
ctx.set(TOKEN_HEADER, lock.token)
228
ctx.body = renderLockResponse(lock.token, lock.seconds)
223
- return true
229
+ return
230
}
231
const lockinfo = try_(() => xmlParser.parse(body).lockinfo)
232
const scope = _.keys(lockinfo?.lockscope)[0]
@@ -238,7 +244,7 @@ export async function handledWebdav(ctx: Koa.Context) {
244
locks.set(path, { token: newToken, timeout, seconds, principal: getWebdavPrincipal(ctx) })
245
ctx.set(TOKEN_HEADER, newToken)
246
ctx.body = renderLockResponse(newToken, seconds)
241
- return true
247
+ return
248
249
function getProvidedLockToken(ctx: Koa.Context) {
250
const direct = ctx.get(TOKEN_HEADER).replace(/[<>]/g, '')
@@ -263,14 +269,14 @@ export async function handledWebdav(ctx: Koa.Context) {
269
if (ctx.method === 'PROPFIND') {
270
setWebdavHeaders()
271
const node = await urlToNode(path, ctx)
266
- if (!node) return
272
+ if (!node) return next()
273
let depth = Number(ctx.get('depth'))
274
depth = isNaN(depth) ? Infinity : depth
275
const isList = depth !== 0
276
if (statusCodeForMissingPerm(node, isList ? 'can_list' : 'can_see', ctx)) {
277
if (ctx.status === HTTP_UNAUTHORIZED)
278
setWebdavHeaders(true)
273
- return true
279
+ return
280
}
281
ctx.type = 'xml'
282
ctx.status = 207
@@ -285,7 +291,7 @@ export async function handledWebdav(ctx: Koa.Context) {
291
}
292
res.write(`</multistatus>`)
293
res.end()
288
- return true
294
+ return
295
296
async function sendEntry(node: VfsNode, append=false) {
297
if (nodeIsLink(node)) return
@@ -311,6 +317,7 @@ export async function handledWebdav(ctx: Koa.Context) {
317
setWebdavHeaders()
318
return ctx.status = HTTP_METHOD_NOT_ALLOWED
319
}
320
+ return next()
321
322
function setWebdavHeaders(authenticate=false) {
323
ctx.set('DAV', '1,2')