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