mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-01 15:49:00 +08:00
fix(webdav): honor date conditions and overwrite status
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user