fix(webdav): honor date conditions and overwrite status

This commit is contained in:
saltbo
2026-05-12 21:36:48 -04:00
parent 81a1aef015
commit c3596a63cd
2 changed files with 93 additions and 12 deletions
+58 -3
View File
@@ -605,6 +605,52 @@ describe('WebDAV API', () => {
expect(updated.headers.get('ETag')).not.toBe(etag)
})
it('honors HTTP date preconditions', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await seedStorage(db)
const workspace = await org(db)
const account = await userAccount(db)
const key = await apiKey(auth, account.id, { webdav: ['read', 'write'] })
await file(db, workspace.id, { id: 'date-precondition', name: 'date.txt', size: 12 })
const head = await app.request(`/dav/${workspace.slug}/date.txt`, {
method: 'HEAD',
headers: basicHeaders(account.email, key),
})
const lastModified = head.headers.get('Last-Modified') ?? ''
const stale = new Date(Date.parse(lastModified) - 1000).toUTCString()
const fresh = new Date(Date.parse(lastModified) + 1000).toUTCString()
const notModified = await app.request(`/dav/${workspace.slug}/date.txt`, {
method: 'GET',
headers: basicHeaders(account.email, key, { 'If-Modified-Since': fresh }),
})
expect(notModified.status).toBe(304)
const staleWrite = await app.request(`/dav/${workspace.slug}/date.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, {
'If-Unmodified-Since': stale,
'Content-Type': 'text/plain',
'Content-Length': '7',
}),
body: 'changed',
})
expect(staleWrite.status).toBe(412)
const freshWrite = await app.request(`/dav/${workspace.slug}/date.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, {
'If-Unmodified-Since': fresh,
'Content-Type': 'text/plain',
'Content-Length': '7',
}),
body: 'changed',
})
expect(freshWrite.status).toBe(204)
})
it('OPTIONS advertises DAV methods', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
@@ -941,7 +987,7 @@ describe('WebDAV API', () => {
Depth: '0',
}),
})
expect(replacedCollection.status).toBe(201)
expect(replacedCollection.status).toBe(204)
})
it('COPY enforces destination locks and rolls back collection copy quota on storage failure', async () => {
@@ -1644,7 +1690,7 @@ describe('WebDAV API', () => {
Destination: `http://localhost/dav/${workspace.slug}/move-target.txt`,
}),
})
expect(replaced.status).toBe(201)
expect(replaced.status).toBe(204)
const rows = await db.all<{ id: string; name: string; status: string }>(
sql`SELECT id, name, status FROM matters WHERE id IN ('move-source', 'move-target') ORDER BY id`,
)
@@ -1680,7 +1726,7 @@ describe('WebDAV API', () => {
Destination: `http://localhost/dav/${workspace.slug}/copy-target.txt`,
}),
})
expect(replaced.status).toBe(201)
expect(replaced.status).toBe(204)
const rows = await db.all<{ status: string }>(sql`SELECT status FROM matters WHERE id = 'copy-target'`)
expect(rows[0]?.status).toBe('trashed')
@@ -1696,6 +1742,15 @@ describe('WebDAV API', () => {
sql`SELECT name, parent FROM matters WHERE org_id = ${workspace.id} AND name = 'Copied Folder'`,
)
expect(folders[0]).toEqual({ name: 'Copied Folder', parent: '' })
const collectionReplacement = await app.request(`/dav/${workspace.slug}/Copy%20Folder`, {
method: 'COPY',
headers: basicHeaders(account.email, key, {
Destination: `http://localhost/dav/${workspace.slug}/Copied%20Folder`,
Depth: '0',
}),
})
expect(collectionReplacement.status).toBe(204)
})
it('COPY rolls back quota reservation when storage copy fails', async () => {
+35 -9
View File
@@ -218,17 +218,40 @@ function preconditionResponse(c: DavContext, matter: NonNullable<WebDavTarget['m
const ifMatch = c.req.header('If-Match')
if (ifMatch && !etagMatches(ifMatch, etag)) return new Response(null, { status: 412 })
const ifNoneMatch = c.req.header('If-None-Match')
if (!ifNoneMatch || !etagMatches(ifNoneMatch, etag)) return null
const ifUnmodifiedSince = ifMatch ? null : parseHttpDate(c.req.header('If-Unmodified-Since'))
if (ifUnmodifiedSince && matter.updatedAt.getTime() > ifUnmodifiedSince.getTime()) {
return new Response(null, { status: 412 })
}
if (c.req.method.toUpperCase() === 'GET' || c.req.method.toUpperCase() === 'HEAD') {
const ifNoneMatch = c.req.header('If-None-Match')
if (ifNoneMatch) {
if (!etagMatches(ifNoneMatch, etag)) return null
if (c.req.method.toUpperCase() === 'GET' || c.req.method.toUpperCase() === 'HEAD') {
return new Response(null, { status: 304, headers: validatorHeaders(matter) })
}
return new Response(null, { status: 412 })
}
const ifModifiedSince =
c.req.method.toUpperCase() === 'GET' || c.req.method.toUpperCase() === 'HEAD'
? parseHttpDate(c.req.header('If-Modified-Since'))
: null
if (ifModifiedSince && matter.updatedAt.getTime() <= ifModifiedSince.getTime()) {
return new Response(null, { status: 304, headers: validatorHeaders(matter) })
}
return new Response(null, { status: 412 })
return null
}
function parseHttpDate(header: string | undefined): Date | null {
if (!header) return null
const timestamp = Date.parse(header)
if (!Number.isFinite(timestamp)) return null
return new Date(timestamp)
}
function missingPreconditionResponse(c: DavContext): Response | null {
if (c.req.header('If-Match')) return new Response(null, { status: 412 })
if (c.req.header('If-Match') || c.req.header('If-Unmodified-Since')) return new Response(null, { status: 412 })
return null
}
@@ -762,6 +785,7 @@ async function moveMatter(c: DavContext, auth: DavAuth): Promise<Response> {
}
const targetLocked = await lockPrecondition(c, target)
if (targetLocked) return targetLocked
const replacingTarget = Boolean(target.matter)
if (target.matter) {
if (target.matter.id === source.matter.id) return new Response(null, { status: 204 })
if (!overwriteAllowed(c)) return c.text('Already exists', 412)
@@ -781,7 +805,7 @@ async function moveMatter(c: DavContext, auth: DavAuth): Promise<Response> {
auth.userId,
)
await moveWebDavState(db, sourceWorkspace.id, oldPath, newPath)
return new Response(null, { status: 201 })
return new Response(null, { status: replacingTarget ? 204 : 201 })
} catch (e) {
return davError(c, e)
}
@@ -811,10 +835,11 @@ async function copyMatterRoute(c: DavContext, auth: DavAuth): Promise<Response>
const targetLocked = await lockPrecondition(c, target)
if (targetLocked) return targetLocked
if (target.matter && !overwriteAllowed(c)) return c.text('Already exists', 412)
const replacingTarget = Boolean(target.matter)
await ensureParentCollection(db, auth.userId, targetWorkspace.slug, target.parent)
if (source.matter.dirtype !== DirType.FILE) {
return copyCollection(c, auth, source, target)
return copyCollection(c, auth, source, target, replacingTarget)
}
let newObject = ''
@@ -843,7 +868,7 @@ async function copyMatterRoute(c: DavContext, auth: DavAuth): Promise<Response>
})
await copyDeadProperties(db, sourceWorkspace.id, resourcePath(source), joinMatterPath(copy.parent, copy.name))
c.header('Location', matterLocation(c.req.url, targetWorkspace.slug, joinMatterPath(copy.parent, copy.name)))
return c.body(null, 201)
return c.body(null, replacingTarget ? 204 : 201)
} catch (e) {
if (reservedUsage) {
await decrementUsage(
@@ -865,6 +890,7 @@ async function copyCollection(
auth: DavAuth,
source: WebDavTarget,
target: WebDavTarget,
replacingTarget: boolean,
): Promise<Response> {
const db = c.get('platform').db
const sourceWorkspace = requireWorkspace(source)
@@ -946,7 +972,7 @@ async function copyCollection(
'Location',
matterLocation(c.req.url, targetWorkspace.slug, joinMatterPath(rootCopy.parent, rootCopy.name)),
)
return c.body(null, 201)
return c.body(null, replacingTarget ? 204 : 201)
} catch (e) {
if (createdIds.length > 0) {
await db