feat(webdav): add RFC 4918 Class 2 support (#398)

* feat(webdav): add RFC 4918 class 2 support

Agent-Profile: https://agent-kanban.dev/agents/1dc839c09b5ee5e5

* test(webdav): cover RFC failure paths

Agent-Profile: https://agent-kanban.dev/agents/1dc839c09b5ee5e5

* fix(webdav): harden RFC lock and state semantics

Agent-Profile: https://agent-kanban.dev/agents/1dc839c09b5ee5e5

* fix(webdav): cover rejected RFC edge cases

Agent-Profile: https://agent-kanban.dev/agents/1dc839c09b5ee5e5

* test(webdav): cover precondition rejection paths

Agent-Profile: https://agent-kanban.dev/agents/1dc839c09b5ee5e5

* fix(webdav): close RFC lock compliance gaps

Agent-Profile: https://agent-kanban.dev/agents/1dc839c09b5ee5e5

* fix(webdav): close lock refresh scope gaps

Agent-Profile: https://agent-kanban.dev/agents/1dc839c09b5ee5e5
This commit is contained in:
Jasper Van
2026-05-12 11:38:29 -04:00
committed by GitHub
parent 9557f4da7c
commit e41ea3f016
9 changed files with 5164 additions and 47 deletions
+27
View File
@@ -0,0 +1,27 @@
CREATE TABLE `webdav_dead_properties` (
`id` text PRIMARY KEY NOT NULL,
`org_id` text NOT NULL,
`resource_path` text NOT NULL,
`namespace` text NOT NULL,
`name` text NOT NULL,
`value` text NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `webdav_dead_properties_resource_prop_uniq` ON `webdav_dead_properties` (`org_id`,`resource_path`,`namespace`,`name`);--> statement-breakpoint
CREATE INDEX `webdav_dead_properties_resource_idx` ON `webdav_dead_properties` (`org_id`,`resource_path`);--> statement-breakpoint
CREATE TABLE `webdav_locks` (
`id` text PRIMARY KEY NOT NULL,
`token` text NOT NULL,
`org_id` text NOT NULL,
`resource_path` text NOT NULL,
`owner` text DEFAULT '' NOT NULL,
`depth` text DEFAULT 'infinity' NOT NULL,
`expires_at` integer NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `webdav_locks_token_unique` ON `webdav_locks` (`token`);--> statement-breakpoint
CREATE INDEX `webdav_locks_resource_idx` ON `webdav_locks` (`org_id`,`resource_path`);--> statement-breakpoint
CREATE INDEX `webdav_locks_expires_idx` ON `webdav_locks` (`expires_at`);
File diff suppressed because it is too large Load Diff
+7
View File
@@ -218,6 +218,13 @@
"when": 1778475474332,
"tag": "0031_talented_skullbuster",
"breakpoints": true
},
{
"idx": 32,
"version": "6",
"when": 1778557854518,
"tag": "0032_webdav-class2-state",
"breakpoints": true
}
]
}
+36
View File
@@ -19,6 +19,42 @@ export const matters = sqliteTable('matters', {
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
})
export const webdavDeadProperties = sqliteTable(
'webdav_dead_properties',
{
id: text('id').primaryKey(),
orgId: text('org_id').notNull(),
resourcePath: text('resource_path').notNull(),
namespace: text('namespace').notNull(),
name: text('name').notNull(),
value: text('value').notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
},
(t) => [
uniqueIndex('webdav_dead_properties_resource_prop_uniq').on(t.orgId, t.resourcePath, t.namespace, t.name),
index('webdav_dead_properties_resource_idx').on(t.orgId, t.resourcePath),
],
)
export const webdavLocks = sqliteTable(
'webdav_locks',
{
id: text('id').primaryKey(),
token: text('token').notNull().unique(),
orgId: text('org_id').notNull(),
resourcePath: text('resource_path').notNull(),
owner: text('owner').notNull().default(''),
depth: text('depth').notNull().default('infinity'),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
},
(t) => [
index('webdav_locks_resource_idx').on(t.orgId, t.resourcePath),
index('webdav_locks_expires_idx').on(t.expiresAt),
],
)
export const storages = sqliteTable('storages', {
id: text('id').primaryKey(),
title: text('title').notNull(),
+883 -4
View File
@@ -214,6 +214,176 @@ describe('WebDAV API', () => {
expect(hiddenRes.status).toBe(404)
})
it('PROPFIND supports prop, propname, allprop include, explicit depths, and rejects infinity', 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'] })
await folder(db, workspace.id, { id: 'docs', name: 'Docs' })
await file(db, workspace.id, { id: 'readme', name: 'readme.txt', parent: 'Docs' })
const prop = await app.request(`/dav/${workspace.slug}/Docs`, {
method: 'PROPFIND',
headers: basicHeaders(account.email, key, { Depth: '0', 'Content-Type': 'application/xml' }),
body: `<?xml version="1.0"?>
<D:propfind xmlns:D="DAV:">
<D:prop><D:displayname/><D:quota-used-bytes/></D:prop>
</D:propfind>`,
})
expect(prop.status).toBe(207)
const propXml = await prop.text()
expect(propXml).toContain('<D:displayname>Docs</D:displayname>')
expect(propXml).toContain('HTTP/1.1 404 Not Found')
expect(propXml).not.toContain('readme.txt')
const propname = await app.request(`/dav/${workspace.slug}/Docs`, {
method: 'PROPFIND',
headers: basicHeaders(account.email, key, { Depth: '1', 'Content-Type': 'application/xml' }),
body: '<D:propfind xmlns:D="DAV:"><D:propname/></D:propfind>',
})
expect(propname.status).toBe(207)
const propnameXml = await propname.text()
expect(propnameXml).toContain('<D:displayname/>')
expect(propnameXml).toContain(`/dav/${workspace.slug}/Docs/readme.txt`)
const defaultNamespace = await app.request(`/dav/${workspace.slug}/Docs`, {
method: 'PROPFIND',
headers: basicHeaders(account.email, key, { Depth: '0', 'Content-Type': 'application/xml' }),
body: `<?xml version="1.0"?>
<propfind xmlns="DAV:">
<!-- default namespace property names are valid RFC 4918 XML -->
<prop><displayname/></prop>
</propfind>`,
})
expect(defaultNamespace.status).toBe(207)
expect(await defaultNamespace.text()).toContain('<D:displayname>Docs</D:displayname>')
const allprop = await app.request(`/dav/${workspace.slug}/Docs`, {
method: 'PROPFIND',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: '<D:propfind xmlns:D="DAV:"><D:allprop/><D:include><D:displayname/><D:quota-used-bytes/></D:include></D:propfind>',
})
expect(allprop.status).toBe(207)
expect(await allprop.text()).toContain('HTTP/1.1 404 Not Found')
const invalidRequestType = await app.request(`/dav/${workspace.slug}/Docs`, {
method: 'PROPFIND',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: '<propfind xmlns="DAV:"><prop/><allprop/></propfind>',
})
expect(invalidRequestType.status).toBe(400)
const infinity = await app.request(`/dav/${workspace.slug}/Docs`, {
method: 'PROPFIND',
headers: basicHeaders(account.email, key, { Depth: 'infinity' }),
})
expect(infinity.status).toBe(403)
expect(await infinity.text()).toContain('propfind-finite-depth')
})
it('PROPPATCH stores and removes dead properties visible to later PROPFIND', 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: 'dead-props', name: 'dead-props.txt' })
const set = await app.request(`/dav/${workspace.slug}/dead-props.txt`, {
method: 'PROPPATCH',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: `<D:propertyupdate xmlns:D="DAV:" xmlns:Z="urn:zpan:test">
<D:set><D:prop><Z:color>blue</Z:color></D:prop></D:set>
</D:propertyupdate>`,
})
expect(set.status).toBe(207)
expect(await set.text()).toContain('HTTP/1.1 200 OK')
const find = await app.request(`/dav/${workspace.slug}/dead-props.txt`, {
method: 'PROPFIND',
headers: basicHeaders(account.email, key, { Depth: '0', 'Content-Type': 'application/xml' }),
body: '<D:propfind xmlns:D="DAV:" xmlns:Z="urn:zpan:test"><D:prop><Z:color/></D:prop></D:propfind>',
})
expect(find.status).toBe(207)
expect(await find.text()).toContain('blue</Z:color>')
const defaultDeadPropertyNamespace = await app.request(`/dav/${workspace.slug}/dead-props.txt`, {
method: 'PROPPATCH',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: `<D:propertyupdate xmlns:D="DAV:" xmlns:Z="urn:zpan:test">
<D:set><D:prop><Z:finish xmlns:Z="urn:zpan:test">matte</Z:finish></D:prop></D:set>
<D:set><D:prop><color xmlns="urn:zpan:test">red</color></D:prop></D:set>
<D:set><D:prop xmlns="urn:zpan:test"><pattern>striped</pattern></D:prop></D:set>
</D:propertyupdate>`,
})
expect(defaultDeadPropertyNamespace.status).toBe(207)
const defaultDeadPropertyFind = await app.request(`/dav/${workspace.slug}/dead-props.txt`, {
method: 'PROPFIND',
headers: basicHeaders(account.email, key, { Depth: '0', 'Content-Type': 'application/xml' }),
body: '<D:propfind xmlns:D="DAV:" xmlns="urn:zpan:test" xmlns:Z="urn:zpan:test"><D:prop><Z:finish/><color/><pattern/></D:prop></D:propfind>',
})
const defaultDeadPropertyXml = await defaultDeadPropertyFind.text()
expect(defaultDeadPropertyXml).toContain('<Z:finish xmlns:Z="urn:zpan:test">matte</Z:finish>')
expect(defaultDeadPropertyXml).toContain('<color xmlns="urn:zpan:test">red</color>')
expect(defaultDeadPropertyXml).toContain('<pattern xmlns="urn:zpan:test">striped</pattern>')
const invalid = await app.request(`/dav/${workspace.slug}/dead-props.txt`, {
method: 'PROPPATCH',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: '<D:propertyupdate xmlns:D="DAV:"><D:set><D:prop><D:getetag>bad</D:getetag></D:prop></D:set></D:propertyupdate>',
})
expect(invalid.status).toBe(403)
const badInstruction = await app.request(`/dav/${workspace.slug}/dead-props.txt`, {
method: 'PROPPATCH',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: '<D:propertyupdate xmlns:D="DAV:"><D:bad/></D:propertyupdate>',
})
expect(badInstruction.status).toBe(403)
const missingProp = await app.request(`/dav/${workspace.slug}/dead-props.txt`, {
method: 'PROPPATCH',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: '<D:propertyupdate xmlns:D="DAV:"><D:set/></D:propertyupdate>',
})
expect(missingProp.status).toBe(403)
const atomicFailure = await app.request(`/dav/${workspace.slug}/dead-props.txt`, {
method: 'PROPPATCH',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: `<propertyupdate xmlns="DAV:" xmlns:Z="urn:zpan:test">
<set><prop><Z:shape>circle</Z:shape></prop></set>
<set><prop><getetag>bad</getetag></prop></set>
</propertyupdate>`,
})
expect(atomicFailure.status).toBe(403)
const afterAtomicFailure = await app.request(`/dav/${workspace.slug}/dead-props.txt`, {
method: 'PROPFIND',
headers: basicHeaders(account.email, key, { Depth: '0', 'Content-Type': 'application/xml' }),
body: '<propfind xmlns="DAV:" xmlns:Z="urn:zpan:test"><prop><Z:shape/></prop></propfind>',
})
expect(await afterAtomicFailure.text()).toContain('HTTP/1.1 404 Not Found')
const remove = await app.request(`/dav/${workspace.slug}/dead-props.txt`, {
method: 'PROPPATCH',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: '<D:propertyupdate xmlns:D="DAV:" xmlns:Z="urn:zpan:test"><D:remove><D:prop><Z:color/></D:prop></D:remove></D:propertyupdate>',
})
expect(remove.status).toBe(207)
const removed = await app.request(`/dav/${workspace.slug}/dead-props.txt`, {
method: 'PROPFIND',
headers: basicHeaders(account.email, key, { Depth: '0', 'Content-Type': 'application/xml' }),
body: '<D:propfind xmlns:D="DAV:" xmlns:Z="urn:zpan:test"><D:prop><Z:color/></D:prop></D:propfind>',
})
expect(await removed.text()).toContain('HTTP/1.1 404 Not Found')
})
it('GET returns file bytes directly and HEAD returns coherent file headers', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
@@ -356,8 +526,9 @@ describe('WebDAV API', () => {
const res = await app.request('/dav/', { method: 'OPTIONS', headers: basicHeaders(account.email, key) })
expect(res.status).toBe(204)
expect(res.headers.get('DAV')).toBe('1')
expect(res.headers.get('DAV')).toBe('1, 2')
expect(res.headers.get('Allow')).toContain('PROPFIND')
expect(res.headers.get('Allow')).toContain('LOCK')
})
it('rejects API keys when verification throws', async () => {
@@ -496,6 +667,13 @@ describe('WebDAV API', () => {
headers: basicHeaders(account.email, key),
})
expect(fileParent.status).toBe(405)
const unsupportedBody = await app.request(`/dav/${workspace.slug}/BodyCollection`, {
method: 'MKCOL',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: '<D:mkcol xmlns:D="DAV:"/>',
})
expect(unsupportedBody.status).toBe(415)
})
it('MOVE, COPY, and DELETE stay within org scope; DELETE trashes instead of purging', async () => {
@@ -590,6 +768,703 @@ describe('WebDAV API', () => {
}),
})
expect(existing.status).toBe(412)
const root = await app.request(`/dav/${workspace.slug}/source.txt`, {
method: 'COPY',
headers: basicHeaders(account.email, key, { Destination: `http://localhost/dav/${workspace.slug}/` }),
})
expect(root.status).toBe(405)
})
it('COPY recursively copies collections and rejects copying into own descendant', 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: ['write'] })
await folder(db, workspace.id, { id: 'src-folder', name: 'Source' })
await folder(db, workspace.id, { id: 'nested-folder', name: 'Nested', parent: 'Source' })
await file(db, workspace.id, { id: 'nested-file', name: 'note.txt', parent: 'Source/Nested', size: 12 })
const copied = await app.request(`/dav/${workspace.slug}/Source`, {
method: 'COPY',
headers: basicHeaders(account.email, key, {
Destination: `http://localhost/dav/${workspace.slug}/Copied`,
Depth: 'infinity',
}),
})
expect(copied.status).toBe(201)
const rows = await db.all<{ name: string; parent: string }>(
sql`SELECT name, parent FROM matters WHERE org_id = ${workspace.id} AND status = 'active' AND parent LIKE 'Copied%' ORDER BY parent, name`,
)
expect(rows).toContainEqual({ name: 'Nested', parent: 'Copied' })
expect(rows).toContainEqual({ name: 'note.txt', parent: 'Copied/Nested' })
const descendant = await app.request(`/dav/${workspace.slug}/Source`, {
method: 'COPY',
headers: basicHeaders(account.email, key, {
Destination: `http://localhost/dav/${workspace.slug}/Source/Child`,
}),
})
expect(descendant.status).toBe(403)
const badDepth = await app.request(`/dav/${workspace.slug}/Source`, {
method: 'COPY',
headers: basicHeaders(account.email, key, {
Destination: `http://localhost/dav/${workspace.slug}/BadDepth`,
Depth: '1',
}),
})
expect(badDepth.status).toBe(400)
await folder(db, workspace.id, { id: 'existing-copy-root', name: 'ExistingCopy' })
const replacedCollection = await app.request(`/dav/${workspace.slug}/Source`, {
method: 'COPY',
headers: basicHeaders(account.email, key, {
Destination: `http://localhost/dav/${workspace.slug}/ExistingCopy`,
Depth: '0',
}),
})
expect(replacedCollection.status).toBe(201)
})
it('COPY enforces destination locks and rolls back collection copy quota on storage failure', 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: ['write'] })
await folder(db, workspace.id, { id: 'locked-target', name: 'LockedTarget' })
await file(db, workspace.id, { id: 'copy-locked-source', name: 'locked-source.txt', size: 12 })
const locked = await app.request(`/dav/${workspace.slug}/LockedTarget`, {
method: 'LOCK',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: '<lockinfo xmlns="DAV:"><lockscope><exclusive/></lockscope><locktype><write/></locktype><owner>tester</owner></lockinfo>',
})
expect(locked.status).toBe(200)
const blocked = await app.request(`/dav/${workspace.slug}/locked-source.txt`, {
method: 'COPY',
headers: basicHeaders(account.email, key, {
Destination: `http://localhost/dav/${workspace.slug}/LockedTarget/locked-source.txt`,
}),
})
expect(blocked.status).toBe(423)
await folder(db, workspace.id, { id: 'rollback-source', name: 'RollbackSource' })
await file(db, workspace.id, { id: 'rollback-file', name: 'data.bin', parent: 'RollbackSource', size: 12 })
vi.mocked(S3Service.prototype.copyObject).mockRejectedValueOnce(new Error('copy failed'))
const failed = await app.request(`/dav/${workspace.slug}/RollbackSource`, {
method: 'COPY',
headers: basicHeaders(account.email, key, {
Destination: `http://localhost/dav/${workspace.slug}/RollbackCopy`,
Depth: 'infinity',
}),
})
expect(failed.status).toBe(500)
const storageRows = await db.all<{ used: number }>(sql`SELECT used FROM storages WHERE id = ${storage.id}`)
expect(storageRows[0]?.used).toBe(0)
const partialRows = await db.all<{ name: string }>(
sql`SELECT name FROM matters WHERE org_id = ${workspace.id} AND status = 'active' AND (name = 'RollbackCopy' OR parent LIKE 'RollbackCopy%')`,
)
expect(partialRows).toEqual([])
})
it('MOVE keeps collection descendant paths consistent and rejects descendant moves', 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: ['write'] })
await folder(db, workspace.id, { id: 'move-folder', name: 'MoveMe' })
await folder(db, workspace.id, { id: 'move-child', name: 'Child', parent: 'MoveMe' })
await file(db, workspace.id, { id: 'move-file', name: 'note.txt', parent: 'MoveMe/Child' })
const moved = await app.request(`/dav/${workspace.slug}/MoveMe`, {
method: 'MOVE',
headers: basicHeaders(account.email, key, { Destination: `http://localhost/dav/${workspace.slug}/Moved` }),
})
expect(moved.status).toBe(201)
const rows = await db.all<{ id: string; parent: string }>(
sql`SELECT id, parent FROM matters WHERE id IN ('move-child', 'move-file') ORDER BY id`,
)
expect(rows).toEqual([
{ id: 'move-child', parent: 'Moved' },
{ id: 'move-file', parent: 'Moved/Child' },
])
const descendant = await app.request(`/dav/${workspace.slug}/Moved`, {
method: 'MOVE',
headers: basicHeaders(account.email, key, {
Destination: `http://localhost/dav/${workspace.slug}/Moved/Child/Sub`,
}),
})
expect(descendant.status).toBe(403)
})
it('write methods enforce WebDAV If and lock preconditions before mutations', 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: ['write'] })
await file(db, workspace.id, { id: 'guarded-file', name: 'guarded.txt' })
await file(db, workspace.id, { id: 'move-guarded-file', name: 'move-guarded.txt' })
await file(db, workspace.id, { id: 'copy-guarded-file', name: 'copy-guarded.txt' })
await file(db, workspace.id, { id: 'delete-guarded-file', name: 'delete-guarded.txt' })
const proppatchIfFailed = await app.request(`/dav/${workspace.slug}/guarded.txt`, {
method: 'PROPPATCH',
headers: basicHeaders(account.email, key, { If: '(["stale"])', 'Content-Type': 'application/xml' }),
body: '<propertyupdate xmlns="DAV:" xmlns:Z="urn:zpan:test"><set><prop><Z:color>blue</Z:color></prop></set></propertyupdate>',
})
expect(proppatchIfFailed.status).toBe(412)
const mkcolIfFailed = await app.request(`/dav/${workspace.slug}/BlockedByIf`, {
method: 'MKCOL',
headers: basicHeaders(account.email, key, { If: '(["stale"])' }),
})
expect(mkcolIfFailed.status).toBe(412)
const deleteIfFailed = await app.request(`/dav/${workspace.slug}/delete-guarded.txt`, {
method: 'DELETE',
headers: basicHeaders(account.email, key, { If: '(["stale"])' }),
})
expect(deleteIfFailed.status).toBe(412)
const moveIfFailed = await app.request(`/dav/${workspace.slug}/move-guarded.txt`, {
method: 'MOVE',
headers: basicHeaders(account.email, key, {
Destination: `http://localhost/dav/${workspace.slug}/moved-guarded.txt`,
If: '(["stale"])',
}),
})
expect(moveIfFailed.status).toBe(412)
const moveToSelf = await app.request(`/dav/${workspace.slug}/move-guarded.txt`, {
method: 'MOVE',
headers: basicHeaders(account.email, key, {
Destination: `http://localhost/dav/${workspace.slug}/move-guarded.txt`,
}),
})
expect(moveToSelf.status).toBe(204)
const copyIfFailed = await app.request(`/dav/${workspace.slug}/copy-guarded.txt`, {
method: 'COPY',
headers: basicHeaders(account.email, key, {
Destination: `http://localhost/dav/${workspace.slug}/copied-guarded.txt`,
If: '(["stale"])',
}),
})
expect(copyIfFailed.status).toBe(412)
const lock = await app.request(`/dav/${workspace.slug}/guarded.txt`, {
method: 'LOCK',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: '<lockinfo xmlns="DAV:"><lockscope><exclusive/></lockscope><locktype><write/></locktype></lockinfo>',
})
expect(lock.status).toBe(200)
const deleteLocked = await app.request(`/dav/${workspace.slug}/guarded.txt`, {
method: 'DELETE',
headers: basicHeaders(account.email, key),
})
expect(deleteLocked.status).toBe(423)
})
it('DELETE on collections removes descendants from WebDAV listings', 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 folder(db, workspace.id, { id: 'delete-folder', name: 'DeleteMe' })
await file(db, workspace.id, { id: 'delete-file', name: 'gone.txt', parent: 'DeleteMe' })
const del = await app.request(`/dav/${workspace.slug}/DeleteMe`, {
method: 'DELETE',
headers: basicHeaders(account.email, key),
})
expect(del.status).toBe(204)
const listing = await app.request(`/dav/${workspace.slug}/DeleteMe`, {
method: 'PROPFIND',
headers: basicHeaders(account.email, key),
})
expect(listing.status).toBe(404)
const rows = await db.all<{ status: string }>(
sql`SELECT status FROM matters WHERE id IN ('delete-folder', 'delete-file') ORDER BY id`,
)
expect(rows).toEqual([{ status: 'trashed' }, { status: 'trashed' }])
})
it('moves, copies, and deletes WebDAV dead properties and locks with namespace changes', 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: 'state-file', name: 'state.txt' })
const patch = await app.request(`/dav/${workspace.slug}/state.txt`, {
method: 'PROPPATCH',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: '<propertyupdate xmlns="DAV:" xmlns:Z="urn:zpan:test"><set><prop><Z:color>green</Z:color></prop></set></propertyupdate>',
})
expect(patch.status).toBe(207)
const lock = await app.request(`/dav/${workspace.slug}/state.txt`, {
method: 'LOCK',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: '<lockinfo xmlns="DAV:"><lockscope><exclusive/></lockscope><locktype><write/></locktype><owner>tester</owner></lockinfo>',
})
expect(lock.status).toBe(200)
const token = lock.headers.get('Lock-Token') ?? ''
const moved = await app.request(`/dav/${workspace.slug}/state.txt`, {
method: 'MOVE',
headers: basicHeaders(account.email, key, {
Destination: `http://localhost/dav/${workspace.slug}/moved-state.txt`,
'Lock-Token': token,
}),
})
expect(moved.status).toBe(201)
const movedProps = await app.request(`/dav/${workspace.slug}/moved-state.txt`, {
method: 'PROPFIND',
headers: basicHeaders(account.email, key, { Depth: '0', 'Content-Type': 'application/xml' }),
body: '<propfind xmlns="DAV:" xmlns:Z="urn:zpan:test"><prop><Z:color/><lockdiscovery/></prop></propfind>',
})
const movedXml = await movedProps.text()
expect(movedXml).toContain('green</Z:color>')
expect(movedXml).toContain(token.slice(1, -1))
const copied = await app.request(`/dav/${workspace.slug}/moved-state.txt`, {
method: 'COPY',
headers: basicHeaders(account.email, key, {
Destination: `http://localhost/dav/${workspace.slug}/copied-state.txt`,
'Lock-Token': token,
}),
})
expect(copied.status).toBe(201)
const copiedProps = await app.request(`/dav/${workspace.slug}/copied-state.txt`, {
method: 'PROPFIND',
headers: basicHeaders(account.email, key, { Depth: '0', 'Content-Type': 'application/xml' }),
body: '<propfind xmlns="DAV:" xmlns:Z="urn:zpan:test"><prop><Z:color/><lockdiscovery/></prop></propfind>',
})
const copiedXml = await copiedProps.text()
expect(copiedXml).toContain('green</Z:color>')
expect(copiedXml).not.toContain(token.slice(1, -1))
const del = await app.request(`/dav/${workspace.slug}/moved-state.txt`, {
method: 'DELETE',
headers: basicHeaders(account.email, key, { 'Lock-Token': token }),
})
expect(del.status).toBe(204)
const recreate = await app.request(`/dav/${workspace.slug}/moved-state.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, { 'Content-Type': 'text/plain' }),
body: 'new',
})
expect(recreate.status).toBe(201)
const stale = await app.request(`/dav/${workspace.slug}/moved-state.txt`, {
method: 'PROPFIND',
headers: basicHeaders(account.email, key, { Depth: '0', 'Content-Type': 'application/xml' }),
body: '<propfind xmlns="DAV:" xmlns:Z="urn:zpan:test"><prop><Z:color/><lockdiscovery/></prop></propfind>',
})
const staleXml = await stale.text()
expect(staleXml).toContain('HTTP/1.1 404 Not Found')
expect(staleXml).not.toContain(token.slice(1, -1))
})
it('If header evaluates ETag matches, misses, and Not conditions', 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: 'if-file', name: 'if.txt', size: 12 })
const head = await app.request(`/dav/${workspace.slug}/if.txt`, {
method: 'HEAD',
headers: basicHeaders(account.email, key),
})
const firstEtag = head.headers.get('ETag') ?? ''
const taggedMatch = await app.request(`/dav/${workspace.slug}/if.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, {
If: `<http://localhost/dav/${workspace.slug}/if.txt> ([${firstEtag}])`,
'Content-Type': 'text/plain',
}),
body: 'tagged',
})
expect(taggedMatch.status).toBe(204)
const updatedHead = await app.request(`/dav/${workspace.slug}/if.txt`, {
method: 'HEAD',
headers: basicHeaders(account.email, key),
})
const etag = updatedHead.headers.get('ETag') ?? ''
const matched = await app.request(`/dav/${workspace.slug}/if.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, { If: `([${etag}])`, 'Content-Type': 'text/plain' }),
body: 'matched',
})
expect(matched.status).toBe(204)
const missed = await app.request(`/dav/${workspace.slug}/if.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, { If: '(["stale"])', 'Content-Type': 'text/plain' }),
body: 'missed',
})
expect(missed.status).toBe(412)
const randomLockToken = await app.request(`/dav/${workspace.slug}/if.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, {
If: '(<opaquelocktoken:random>)',
'Content-Type': 'text/plain',
}),
body: 'missed',
})
expect(randomLockToken.status).toBe(412)
const taggedExternalResource = await app.request(`/dav/${workspace.slug}/if.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, {
If: '<https://example.com/dav/elsewhere> (<opaquelocktoken:random>)',
'Content-Type': 'text/plain',
}),
body: 'missed',
})
expect(taggedExternalResource.status).toBe(412)
const tokenTaggedResource = await app.request(`/dav/${workspace.slug}/if.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, {
If: '<opaquelocktoken:random> (["stale"])',
'Content-Type': 'text/plain',
}),
body: 'missed',
})
expect(tokenTaggedResource.status).toBe(412)
const emptyStateList = await app.request(`/dav/${workspace.slug}/if.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, { If: '()', 'Content-Type': 'text/plain' }),
body: 'missed',
})
expect(emptyStateList.status).toBe(412)
const malformedTaggedUrl = await app.request(`/dav/${workspace.slug}/if.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, {
If: '<http://[::1> (["stale"])',
'Content-Type': 'text/plain',
}),
body: 'missed',
})
expect(malformedTaggedUrl.status).toBe(412)
const invalidSyntax = await app.request(`/dav/${workspace.slug}/if.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, { If: 'Not a state list', 'Content-Type': 'text/plain' }),
body: 'missed',
})
expect(invalidSyntax.status).toBe(412)
const notMatched = await app.request(`/dav/${workspace.slug}/if.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, { If: '(Not ["stale"])', 'Content-Type': 'text/plain' }),
body: 'not matched',
})
expect(notMatched.status).toBe(204)
})
it('LOCK and UNLOCK expose Class 2 state and enforce write tokens', 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: 'lock-file', name: 'locked.txt', size: 12 })
await file(db, workspace.id, { id: 'other-lock-file', name: 'other-locked.txt', size: 12 })
const locked = await app.request(`/dav/${workspace.slug}/locked.txt`, {
method: 'LOCK',
headers: basicHeaders(account.email, key, {
Depth: '0',
Timeout: 'Second-600',
'Content-Type': 'application/xml',
}),
body: '<D:lockinfo xmlns:D="DAV:"><D:lockscope><D:exclusive/></D:lockscope><D:locktype><D:write/></D:locktype><D:owner>tester</D:owner></D:lockinfo>',
})
expect(locked.status).toBe(200)
const token = locked.headers.get('Lock-Token') ?? ''
expect(token).toMatch(/^<opaquelocktoken:/)
expect(await locked.text()).toContain('lockdiscovery')
const discovery = await app.request(`/dav/${workspace.slug}/locked.txt`, {
method: 'PROPFIND',
headers: basicHeaders(account.email, key, { Depth: '0' }),
})
const discoveryXml = await discovery.text()
expect(discoveryXml).toContain('supportedlock')
expect(discoveryXml).toContain(token.slice(1, -1))
const rejected = await app.request(`/dav/${workspace.slug}/locked.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, { 'Content-Type': 'text/plain' }),
body: 'blocked',
})
expect(rejected.status).toBe(423)
const accepted = await app.request(`/dav/${workspace.slug}/locked.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, { 'Lock-Token': token, 'Content-Type': 'text/plain' }),
body: 'allowed',
})
expect(accepted.status).toBe(204)
const acceptedWithIfToken = await app.request(`/dav/${workspace.slug}/locked.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, { If: `(${token})`, 'Content-Type': 'text/plain' }),
body: 'allowed by if',
})
expect(acceptedWithIfToken.status).toBe(204)
const refreshed = await app.request(`/dav/${workspace.slug}/locked.txt`, {
method: 'LOCK',
headers: basicHeaders(account.email, key, { If: `(${token})`, Timeout: 'Second-1200' }),
})
expect(refreshed.status).toBe(200)
expect(refreshed.headers.get('Lock-Token')).toBeNull()
const refreshWithBody = await app.request(`/dav/${workspace.slug}/locked.txt`, {
method: 'LOCK',
headers: basicHeaders(account.email, key, { If: `(${token})`, 'Content-Type': 'application/xml' }),
body: '<lockinfo xmlns="DAV:"><lockscope><exclusive/></lockscope><locktype><write/></locktype></lockinfo>',
})
expect(refreshWithBody.status).toBe(400)
const refreshWithMultipleTokens = await app.request(`/dav/${workspace.slug}/locked.txt`, {
method: 'LOCK',
headers: basicHeaders(account.email, key, {
If: `(${token})(<opaquelocktoken:extra>)`,
Timeout: 'Second-1200',
}),
})
expect(refreshWithMultipleTokens.status).toBe(400)
const conflictingLock = await app.request(`/dav/${workspace.slug}/locked.txt`, {
method: 'LOCK',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: '<lockinfo xmlns="DAV:"><lockscope><exclusive/></lockscope><locktype><write/></locktype></lockinfo>',
})
expect(conflictingLock.status).toBe(423)
const wrongResourceRefresh = await app.request(`/dav/${workspace.slug}/other-locked.txt`, {
method: 'LOCK',
headers: basicHeaders(account.email, key, { If: `(${token})`, Timeout: 'Second-1200' }),
})
expect(wrongResourceRefresh.status).toBe(412)
const badRefresh = await app.request(`/dav/${workspace.slug}/locked.txt`, {
method: 'LOCK',
headers: basicHeaders(account.email, key, { If: '(<opaquelocktoken:missing>)' }),
})
expect(badRefresh.status).toBe(412)
const shared = await app.request(`/dav/${workspace.slug}/other-locked.txt`, {
method: 'LOCK',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: '<lockinfo xmlns="DAV:"><lockscope><shared/></lockscope><locktype><write/></locktype></lockinfo>',
})
expect(shared.status).toBe(422)
const malformedLock = await app.request(`/dav/${workspace.slug}/other-locked.txt`, {
method: 'LOCK',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: '<lockinfo xmlns="DAV:"><lockscope><exclusive/></lockscope></lockinfo>',
})
expect(malformedLock.status).toBe(422)
const unsupportedDepth = await app.request(`/dav/${workspace.slug}/other-locked.txt`, {
method: 'LOCK',
headers: basicHeaders(account.email, key, { Depth: '1', 'Content-Type': 'application/xml' }),
body: '<lockinfo xmlns="DAV:"><lockscope><exclusive/></lockscope><locktype><write/></locktype></lockinfo>',
})
expect(unsupportedDepth.status).toBe(400)
const missingLockTarget = await app.request(`/dav/${workspace.slug}/missing-lock-target.txt`, {
method: 'LOCK',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: '<lockinfo xmlns="DAV:"><lockscope><exclusive/></lockscope><locktype><write/></locktype></lockinfo>',
})
expect(missingLockTarget.status).toBe(201)
const createdToken = missingLockTarget.headers.get('Lock-Token') ?? ''
expect(createdToken).toMatch(/^<opaquelocktoken:/)
expect(await missingLockTarget.text()).toContain('lockdiscovery')
const createdHead = await app.request(`/dav/${workspace.slug}/missing-lock-target.txt`, {
method: 'HEAD',
headers: basicHeaders(account.email, key),
})
expect(createdHead.status).toBe(200)
expect(createdHead.headers.get('Content-Length')).toBe('0')
const missingLockParent = await app.request(`/dav/${workspace.slug}/Missing/missing-lock-target.txt`, {
method: 'LOCK',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: '<lockinfo xmlns="DAV:"><lockscope><exclusive/></lockscope><locktype><write/></locktype></lockinfo>',
})
expect(missingLockParent.status).toBe(409)
const missingUnlockToken = await app.request(`/dav/${workspace.slug}/locked.txt`, {
method: 'UNLOCK',
headers: basicHeaders(account.email, key),
})
expect(missingUnlockToken.status).toBe(400)
const missingUnlockTarget = await app.request(`/dav/${workspace.slug}/absent-unlock-target.txt`, {
method: 'UNLOCK',
headers: basicHeaders(account.email, key, { 'Lock-Token': token }),
})
expect(missingUnlockTarget.status).toBe(404)
const invalidUnlock = await app.request(`/dav/${workspace.slug}/locked.txt`, {
method: 'UNLOCK',
headers: basicHeaders(account.email, key, { 'Lock-Token': '<opaquelocktoken:bad>' }),
})
expect(invalidUnlock.status).toBe(409)
const unlocked = await app.request(`/dav/${workspace.slug}/locked.txt`, {
method: 'UNLOCK',
headers: basicHeaders(account.email, key, { 'Lock-Token': token }),
})
expect(unlocked.status).toBe(204)
const afterUnlock = await app.request(`/dav/${workspace.slug}/locked.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, { 'Content-Type': 'text/plain' }),
body: 'after',
})
expect(afterUnlock.status).toBe(204)
})
it('LOCK refresh accepts descendant URLs inside a depth-infinity lock scope only', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await seedStorage(db)
const workspace = await org(db)
const account = await userAccount(db)
const secondWorkspace = await teamWorkspace(db, {
id: 'refresh-other-workspace',
slug: 'refresh-other-workspace',
userId: account.id,
name: 'Refresh Other Workspace',
})
const key = await apiKey(auth, account.id, { webdav: ['write'] })
await folder(db, workspace.id, { id: 'refresh-folder', name: 'RefreshScope' })
await file(db, workspace.id, { id: 'refresh-child', name: 'child.txt', parent: 'RefreshScope' })
await file(db, workspace.id, { id: 'refresh-outside', name: 'outside.txt' })
await folder(db, secondWorkspace.id, { id: 'refresh-other-folder', name: 'RefreshScope' })
await file(db, secondWorkspace.id, {
id: 'refresh-other-child',
name: 'child.txt',
parent: 'RefreshScope',
})
const locked = await app.request(`/dav/${workspace.slug}/RefreshScope`, {
method: 'LOCK',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: '<lockinfo xmlns="DAV:"><lockscope><exclusive/></lockscope><locktype><write/></locktype><owner>tester</owner></lockinfo>',
})
expect(locked.status).toBe(200)
const token = locked.headers.get('Lock-Token') ?? ''
const descendantRefresh = await app.request(`/dav/${workspace.slug}/RefreshScope/child.txt`, {
method: 'LOCK',
headers: basicHeaders(account.email, key, { If: `(${token})`, Timeout: 'Second-1200' }),
})
expect(descendantRefresh.status).toBe(200)
expect(descendantRefresh.headers.get('Lock-Token')).toBeNull()
expect(await descendantRefresh.text()).toContain(token.slice(1, -1))
const outsideRefresh = await app.request(`/dav/${workspace.slug}/outside.txt`, {
method: 'LOCK',
headers: basicHeaders(account.email, key, { If: `(${token})`, Timeout: 'Second-1200' }),
})
expect(outsideRefresh.status).toBe(412)
const otherWorkspaceRefresh = await app.request(`/dav/${secondWorkspace.slug}/RefreshScope/child.txt`, {
method: 'LOCK',
headers: basicHeaders(account.email, key, { If: `(${token})`, Timeout: 'Second-1200' }),
})
expect(otherWorkspaceRefresh.status).toBe(412)
const outsideUnlock = await app.request(`/dav/${workspace.slug}/outside.txt`, {
method: 'UNLOCK',
headers: basicHeaders(account.email, key, { 'Lock-Token': token }),
})
expect(outsideUnlock.status).toBe(409)
const descendantUnlock = await app.request(`/dav/${workspace.slug}/RefreshScope/child.txt`, {
method: 'UNLOCK',
headers: basicHeaders(account.email, key, { 'Lock-Token': token }),
})
expect(descendantUnlock.status).toBe(204)
const afterDescendantUnlock = await app.request(`/dav/${workspace.slug}/RefreshScope/child.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, { 'Content-Type': 'text/plain' }),
body: 'after unlock',
})
expect(afterDescendantUnlock.status).toBe(204)
})
it('PROPFIND lockdiscovery includes inherited depth-infinity locks', 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 folder(db, workspace.id, { id: 'discovery-folder', name: 'DiscoveryScope' })
await file(db, workspace.id, { id: 'discovery-child', name: 'child.txt', parent: 'DiscoveryScope' })
const locked = await app.request(`/dav/${workspace.slug}/DiscoveryScope`, {
method: 'LOCK',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: '<lockinfo xmlns="DAV:"><lockscope><exclusive/></lockscope><locktype><write/></locktype><owner>tester</owner></lockinfo>',
})
expect(locked.status).toBe(200)
const token = locked.headers.get('Lock-Token') ?? ''
const childProps = await app.request(`/dav/${workspace.slug}/DiscoveryScope/child.txt`, {
method: 'PROPFIND',
headers: basicHeaders(account.email, key, { Depth: '0', 'Content-Type': 'application/xml' }),
body: '<propfind xmlns="DAV:"><prop><lockdiscovery/></prop></propfind>',
})
expect(childProps.status).toBe(207)
const xml = await childProps.text()
expect(xml).toContain(token.slice(1, -1))
expect(xml).toContain('<D:depth>infinity</D:depth>')
})
it('returns WebDAV path errors for missing GET and DELETE targets', async () => {
@@ -647,7 +1522,7 @@ describe('WebDAV API', () => {
])
})
it('COPY honors Overwrite header for existing destinations and rejects collection copy explicitly', async () => {
it('COPY honors Overwrite header for existing destinations and copies collection roots', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await seedStorage(db)
@@ -681,10 +1556,14 @@ describe('WebDAV API', () => {
method: 'COPY',
headers: basicHeaders(account.email, key, {
Destination: `http://localhost/dav/${workspace.slug}/Copied%20Folder`,
Depth: '0',
}),
})
expect(collection.status).toBe(403)
expect(await collection.text()).toContain('Collection COPY is not supported')
expect(collection.status).toBe(201)
const folders = await db.all<{ name: string; parent: string }>(
sql`SELECT name, parent FROM matters WHERE org_id = ${workspace.id} AND name = 'Copied Folder'`,
)
expect(folders[0]).toEqual({ name: 'Copied Folder', parent: '' })
})
it('COPY rolls back quota reservation when storage copy fails', async () => {
+489 -16
View File
@@ -1,4 +1,4 @@
import { and, eq } from 'drizzle-orm'
import { and, eq, like, or } from 'drizzle-orm'
import type { Context } from 'hono'
import { Hono } from 'hono'
import { DirType, ObjectStatus } from '../../shared/constants'
@@ -28,11 +28,37 @@ import {
WebDavPathError,
type WebDavTarget,
} from '../services/webdav-path'
import { davEtag, matterEntry, mountRootEntry, multistatus, workspaceEntry } from '../services/webdav-xml'
import {
activeLocks,
applyDeadPropertyUpdate,
conflictingLocks,
copyDeadProperties,
createLock,
deleteWebDavState,
listDeadProperties,
moveWebDavState,
refreshLock,
removeLock,
} from '../services/webdav-state'
import {
type DavEntry,
davEtag,
errorXml,
lockDiscoveryXml,
matterEntry,
mountRootEntry,
multistatus,
parseLockInfoXml,
parsePropfindXml,
parseProppatchXml,
proppatchMultistatus,
workspaceEntry,
xmlResponse,
} from '../services/webdav-xml'
const s3 = new S3Service()
const READ_METHODS = new Set(['OPTIONS', 'PROPFIND', 'GET', 'HEAD'])
const WRITE_METHODS = new Set(['PUT', 'DELETE', 'MKCOL', 'MOVE', 'COPY'])
const WRITE_METHODS = new Set(['PUT', 'DELETE', 'MKCOL', 'MOVE', 'COPY', 'PROPPATCH', 'LOCK', 'UNLOCK'])
const WEBDAV_RESOURCE = 'webdav'
const WEBDAV_CONFIG_ID = 'webdav'
const WEBDAV_REALM = 'Basic realm="ZPan WebDAV"'
@@ -226,8 +252,157 @@ function bytesBody(bytes: Uint8Array): ArrayBuffer {
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
}
function resourcePath(target: WebDavTarget): string {
if (!target.workspace) return ''
return target.matter
? joinMatterPath(target.matter.parent, target.matter.name)
: joinMatterPath(target.parent, target.name)
}
function targetHref(target: WebDavTarget): string {
if (target.mountRoot) return '/dav/'
const workspace = requireWorkspace(target)
if (!target.matter) return `/dav/${encodeURIComponent(workspace.slug)}/`
const path = joinMatterPath(target.matter.parent, target.matter.name)
const href = `/dav/${encodeURIComponent(workspace.slug)}/${path.split('/').map(encodeURIComponent).join('/')}`
return target.matter.dirtype === DirType.FILE ? href : `${href}/`
}
function parseTimeout(header: string | undefined): number {
if (!header) return 3600
const second = header
.split(',')
.map((value) => value.trim())
.find((value) => /^Second-\d+$/i.test(value))
if (!second) return 3600
return Math.min(Number(second.slice('Second-'.length)), 604800)
}
function lockTokenHeader(c: DavContext): string | null {
const header = c.req.header('Lock-Token')
return header?.replace(/^<|>$/g, '') ?? null
}
function submittedLockTokens(c: DavContext): Set<string> {
const tokens = new Set<string>()
const direct = lockTokenHeader(c)
if (direct) tokens.add(direct)
const ifHeader = c.req.header('If')
if (!ifHeader) return tokens
for (const match of ifHeader.matchAll(/<([^>]+)>/g)) {
if (match[1].startsWith('opaquelocktoken:')) tokens.add(match[1])
}
return tokens
}
function lockRefreshToken(c: DavContext): string | Response | null {
const ifHeader = c.req.header('If')
if (!ifHeader) return null
const tokens = [...ifHeader.matchAll(/<([^>]+)>/g)]
.map((match) => match[1])
.filter((token) => token.startsWith('opaquelocktoken:'))
if (tokens.length === 0) return null
if (tokens.length !== 1) return xmlResponse(errorXml('lock-token-submitted'), 400)
return tokens[0]
}
async function lockPrecondition(c: DavContext, target: WebDavTarget): Promise<Response | null> {
const workspace = requireWorkspace(target)
const locks = await activeLocks(c.get('platform').db, workspace.id, resourcePath(target))
if (locks.length === 0) return null
const tokens = submittedLockTokens(c)
if (locks.every((lock) => tokens.has(lock.token))) return null
return xmlResponse(errorXml('lock-token-submitted', 'A matching lock token is required.'), 423)
}
async function ifHeaderPrecondition(c: DavContext, auth: DavAuth, target: WebDavTarget): Promise<Response | null> {
const header = c.req.header('If')
if (!header) return null
if (await evaluateIfHeader(c, auth, header, target)) return null
return xmlResponse(errorXml('condition-failed', 'If header conditions did not match.'), 412)
}
async function evaluateIfHeader(
c: DavContext,
auth: DavAuth,
header: string,
fallback: WebDavTarget,
): Promise<boolean> {
const clauses = [...header.matchAll(/(?:<([^>]+)>\s*)?(\([^)]*\))/g)]
if (clauses.length === 0) return false
for (const clause of clauses) {
const target = clause[1] ? await ifTaggedTarget(c, auth, clause[1]) : fallback
if (!target) continue
const workspace = target.workspace
const etag = target.matter ? matterEtag(target.matter) : null
const locks = workspace ? await activeLocks(c.get('platform').db, workspace.id, resourcePath(target)) : []
const lockTokens = new Set(locks.map((lock) => lock.token))
const list = clause[2]
const conditions = [...list.matchAll(/(Not\s+)?(?:\[([^\]]+)\]|<([^>]+)>)/gi)]
if (conditions.length === 0) continue
if (
conditions.every((condition) => {
const negated = Boolean(condition[1])
const value = condition[2] ?? condition[3]
const matched = value.startsWith('opaquelocktoken:') ? lockTokens.has(value) : etag === value
return negated ? !matched : matched
})
) {
return true
}
}
return false
}
async function ifTaggedTarget(c: DavContext, auth: DavAuth, tag: string): Promise<WebDavTarget | null> {
if (tag.startsWith('opaquelocktoken:')) return null
try {
const url = new URL(tag, c.req.url)
if (url.origin !== new URL(c.req.url).origin) return null
return await resolveWebDavPath(c.get('platform').db, auth.userId, url.pathname)
} catch {
return null
}
}
async function davEntry(c: DavContext, target: WebDavTarget): Promise<DavEntry> {
const db = c.get('platform').db
if (target.mountRoot) return mountRootEntry()
const workspace = requireWorkspace(target)
const path = resourcePath(target)
const [deadProperties, locks] = await Promise.all([
listDeadProperties(db, workspace.id, path),
activeLocks(db, workspace.id, path),
])
return target.matter
? matterEntry(workspace, target.matter, deadProperties, locks)
: workspaceEntry(workspace, deadProperties, locks)
}
async function listDescendants(db: Env['Variables']['platform']['db'], orgId: string, rootPath: string) {
return db
.select()
.from(matters)
.where(
and(eq(matters.orgId, orgId), eq(matters.status, ObjectStatus.ACTIVE), like(matters.parent, `${rootPath}/%`)),
)
}
async function restoreActiveMatterRows(
db: Env['Variables']['platform']['db'],
rows: NonNullable<WebDavTarget['matter']>[],
): Promise<void> {
const now = new Date()
for (const row of rows) {
await db
.update(matters)
.set({ status: ObjectStatus.ACTIVE, trashedAt: null, updatedAt: now })
.where(and(eq(matters.id, row.id), eq(matters.orgId, row.orgId)))
}
}
const app = new Hono<Env>().on(
['OPTIONS', 'PROPFIND', 'GET', 'HEAD', 'PUT', 'DELETE', 'MKCOL', 'MOVE', 'COPY'],
['OPTIONS', 'PROPFIND', 'PROPPATCH', 'GET', 'HEAD', 'PUT', 'DELETE', 'MKCOL', 'MOVE', 'COPY', 'LOCK', 'UNLOCK'],
'/*',
async (c) => {
const auth = await requireWebDavApiKey(c)
@@ -237,10 +412,15 @@ const app = new Hono<Env>().on(
case 'OPTIONS':
return new Response(null, {
status: 204,
headers: { Allow: 'OPTIONS, PROPFIND, GET, HEAD, PUT, DELETE, MKCOL, MOVE, COPY', DAV: '1' },
headers: {
Allow: 'OPTIONS, PROPFIND, PROPPATCH, GET, HEAD, PUT, DELETE, MKCOL, MOVE, COPY, LOCK, UNLOCK',
DAV: '1, 2',
},
})
case 'PROPFIND':
return propfind(c, auth)
case 'PROPPATCH':
return proppatch(c, auth)
case 'GET':
case 'HEAD':
return readFile(c, auth)
@@ -254,6 +434,10 @@ const app = new Hono<Env>().on(
return moveMatter(c, auth)
case 'COPY':
return copyMatterRoute(c, auth)
case 'LOCK':
return lockMatter(c, auth)
case 'UNLOCK':
return unlockMatter(c, auth)
default:
return c.text('Method Not Allowed', 405)
}
@@ -265,27 +449,77 @@ async function propfind(c: DavContext, auth: DavAuth): Promise<Response> {
try {
const target = await resolveWebDavPath(db, auth.userId, davPath(c))
const depth = c.req.header('Depth') ?? '1'
const entries = []
if (depth !== '0' && depth !== '1') {
return xmlResponse(errorXml('propfind-finite-depth', 'Depth infinity is not supported for PROPFIND.'), 403)
}
const request = parsePropfindXml(await c.req.text())
const entries: DavEntry[] = []
if (target.mountRoot) {
entries.push(mountRootEntry())
if (depth !== '0') entries.push(...(await listUserWorkspaces(db, auth.userId)).map(workspaceEntry))
if (depth !== '0') {
for (const workspace of await listUserWorkspaces(db, auth.userId)) {
const workspaceTarget = { workspace, mountRoot: false, parent: '', name: '', matter: null }
entries.push(await davEntry(c, workspaceTarget))
}
}
} else if (!target.matter) {
if (target.name) throw new WebDavPathError('Not found', 404)
const workspace = requireWorkspace(target)
entries.push(workspaceEntry(workspace))
if (depth !== '0')
entries.push(...(await listChildren(db, workspace.id, '')).map((m) => matterEntry(workspace, m)))
entries.push(await davEntry(c, target))
if (depth !== '0') {
for (const matter of await listChildren(db, workspace.id, '')) {
entries.push(
await davEntry(c, { workspace, mountRoot: false, parent: matter.parent, name: matter.name, matter }),
)
}
}
} else {
const workspace = requireWorkspace(target)
entries.push(matterEntry(workspace, target.matter))
entries.push(await davEntry(c, target))
if (depth !== '0' && target.matter.dirtype !== DirType.FILE) {
const parent = joinMatterPath(target.matter.parent, target.matter.name)
entries.push(...(await listChildren(db, workspace.id, parent)).map((m) => matterEntry(workspace, m)))
for (const matter of await listChildren(db, workspace.id, parent)) {
entries.push(
await davEntry(c, { workspace, mountRoot: false, parent: matter.parent, name: matter.name, matter }),
)
}
}
}
return c.body(multistatus(entries), 207, { 'Content-Type': 'application/xml; charset=utf-8' })
return xmlResponse(multistatus(entries, request), 207)
} catch (e) {
if (e instanceof Error && (e.message.includes('XML') || e.message.includes('PROPFIND'))) {
return xmlResponse(errorXml('valid-xml', e.message), 400)
}
return davError(c, e)
}
}
async function proppatch(c: DavContext, auth: DavAuth): Promise<Response> {
const db = c.get('platform').db
try {
const target = await resolveExistingWebDavPath(db, auth.userId, davPath(c))
const workspace = requireWorkspace(target)
const locked = await lockPrecondition(c, target)
if (locked) return locked
const ifFailed = await ifHeaderPrecondition(c, auth, target)
if (ifFailed) return ifFailed
const operations = parseProppatchXml(await c.req.text())
await applyDeadPropertyUpdate(db, workspace.id, resourcePath(target), operations)
await db
.update(matters)
.set({ updatedAt: new Date() })
.where(and(eq(matters.id, target.matter!.id), eq(matters.orgId, workspace.id)))
const properties = operations.map((operation) => operation.property)
return xmlResponse(proppatchMultistatus(targetHref(target), properties), 207)
} catch (e) {
if (
e instanceof Error &&
(e.message.includes('XML') || e.message.includes('PROPPATCH') || e.message.includes('Protected'))
) {
return xmlResponse(errorXml('cannot-modify-protected-property', e.message), 403)
}
return davError(c, e)
}
}
@@ -333,6 +567,10 @@ async function putFile(c: DavContext, auth: DavAuth): Promise<Response> {
if (!target.name) return c.text('Cannot PUT a collection root', 405)
if (target.matter && target.matter.dirtype !== DirType.FILE)
return c.text('Cannot replace collection with file', 409)
const locked = await lockPrecondition(c, target)
if (locked) return locked
const ifFailed = await ifHeaderPrecondition(c, auth, target)
if (ifFailed) return ifFailed
const precondition = target.matter ? preconditionResponse(c, target.matter) : missingPreconditionResponse(c)
if (precondition) return precondition
await ensureParentCollection(db, auth.userId, workspace.slug, target.parent)
@@ -398,6 +636,14 @@ async function makeCollection(c: DavContext, auth: DavAuth): Promise<Response> {
const workspace = requireWorkspace(target)
if (!target.name) return c.text('Cannot create collection root', 405)
if (target.matter) return c.text('Already exists', 405)
const body = await c.req.text()
if (body.length > 0) {
return xmlResponse(errorXml('unsupported-media-type', 'MKCOL request bodies are not supported.'), 415)
}
const locked = await lockPrecondition(c, target)
if (locked) return locked
const ifFailed = await ifHeaderPrecondition(c, auth, target)
if (ifFailed) return ifFailed
await ensureParentCollection(db, auth.userId, workspace.slug, target.parent)
const storage = (await selectStorage(db, 'private')) as unknown as S3Storage
await createMatter(db, {
@@ -425,6 +671,11 @@ async function deleteMatter(c: DavContext, auth: DavAuth): Promise<Response> {
const workspace = requireWorkspace(target)
const matter = target.matter
if (!matter) throw new WebDavPathError('Not found', 404)
const locked = await lockPrecondition(c, target)
if (locked) return locked
const ifFailed = await ifHeaderPrecondition(c, auth, target)
if (ifFailed) return ifFailed
await deleteWebDavState(db, workspace.id, resourcePath(target))
await trashMatter(db, workspace.id, matter.id, auth.userId)
return new Response(null, { status: 204 })
} catch (e) {
@@ -438,6 +689,10 @@ async function moveMatter(c: DavContext, auth: DavAuth): Promise<Response> {
const source = await resolveExistingWebDavPath(db, auth.userId, davPath(c))
const sourceWorkspace = requireWorkspace(source)
if (!source.matter) throw new WebDavPathError('Not found', 404)
const locked = await lockPrecondition(c, source)
if (locked) return locked
const ifFailed = await ifHeaderPrecondition(c, auth, source)
if (ifFailed) return ifFailed
const precondition = preconditionResponse(c, source.matter)
if (precondition) return precondition
const destination = destinationPath(c)
@@ -446,12 +701,26 @@ async function moveMatter(c: DavContext, auth: DavAuth): Promise<Response> {
const targetWorkspace = requireWorkspace(target)
if (sourceWorkspace.id !== targetWorkspace.id) return c.text('Cross-workspace MOVE is not supported', 403)
if (!target.name) return c.text('Cannot move to collection root', 405)
if (source.matter.dirtype !== DirType.FILE) {
const oldPath = joinMatterPath(source.matter.parent, source.matter.name)
const newPath = joinMatterPath(target.parent, target.name)
if (newPath === oldPath || newPath.startsWith(`${oldPath}/`)) {
return xmlResponse(errorXml('forbidden', 'Cannot move a collection into itself or its descendant.'), 403)
}
}
const targetLocked = await lockPrecondition(c, target)
if (targetLocked) return targetLocked
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)
}
await ensureParentCollection(db, auth.userId, targetWorkspace.slug, target.parent)
if (target.matter) await trashMatter(db, targetWorkspace.id, target.matter.id, auth.userId)
const oldPath = resourcePath(source)
const newPath = joinMatterPath(target.parent, target.name)
if (target.matter) {
await deleteWebDavState(db, targetWorkspace.id, resourcePath(target))
await trashMatter(db, targetWorkspace.id, target.matter.id, auth.userId)
}
await updateMatter(
db,
source.matter.id,
@@ -459,6 +728,7 @@ async function moveMatter(c: DavContext, auth: DavAuth): Promise<Response> {
{ name: target.name, parent: target.parent },
auth.userId,
)
await moveWebDavState(db, sourceWorkspace.id, oldPath, newPath)
return new Response(null, { status: 201 })
} catch (e) {
return davError(c, e)
@@ -471,18 +741,30 @@ async function copyMatterRoute(c: DavContext, auth: DavAuth): Promise<Response>
const source = await resolveExistingWebDavPath(db, auth.userId, davPath(c))
const sourceWorkspace = requireWorkspace(source)
if (!source.matter) throw new WebDavPathError('Not found', 404)
const ifFailed = await ifHeaderPrecondition(c, auth, source)
if (ifFailed) return ifFailed
const precondition = preconditionResponse(c, source.matter)
if (precondition) return precondition
if (source.matter.dirtype !== DirType.FILE) return c.text('Collection COPY is not supported', 403)
const destination = destinationPath(c)
if (destination instanceof Response) return destination
const target = await resolveWebDavPath(db, auth.userId, destination)
const targetWorkspace = requireWorkspace(target)
if (sourceWorkspace.id !== targetWorkspace.id) return c.text('Cross-workspace COPY is not supported', 403)
if (!target.name) return c.text('Cannot copy to collection root', 405)
const oldPath = joinMatterPath(source.matter.parent, source.matter.name)
const newPath = joinMatterPath(target.parent, target.name)
if (source.matter.dirtype !== DirType.FILE && (newPath === oldPath || newPath.startsWith(`${oldPath}/`))) {
return xmlResponse(errorXml('forbidden', 'Cannot copy a collection into itself or its descendant.'), 403)
}
const targetLocked = await lockPrecondition(c, target)
if (targetLocked) return targetLocked
if (target.matter && !overwriteAllowed(c)) return c.text('Already exists', 412)
await ensureParentCollection(db, auth.userId, targetWorkspace.slug, target.parent)
if (source.matter.dirtype !== DirType.FILE) {
return copyCollection(c, auth, source, target)
}
let newObject = ''
let reservedUsage: { storageId: string; bytes: number } | null = null
try {
@@ -499,11 +781,15 @@ async function copyMatterRoute(c: DavContext, auth: DavAuth): Promise<Response>
await s3.copyObject(storage, source.matter.object, storage, newObject)
}
if (target.matter) await trashMatter(db, targetWorkspace.id, target.matter.id, auth.userId)
if (target.matter) {
await deleteWebDavState(db, targetWorkspace.id, resourcePath(target))
await trashMatter(db, targetWorkspace.id, target.matter.id, auth.userId)
}
const copy = await copyMatter(db, { ...source.matter, name: target.name }, target.parent, newObject, {
onConflict: 'fail',
userId: auth.userId,
})
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)
} catch (e) {
@@ -522,6 +808,193 @@ async function copyMatterRoute(c: DavContext, auth: DavAuth): Promise<Response>
}
}
async function copyCollection(
c: DavContext,
auth: DavAuth,
source: WebDavTarget,
target: WebDavTarget,
): Promise<Response> {
const db = c.get('platform').db
const sourceWorkspace = requireWorkspace(source)
const targetWorkspace = requireWorkspace(target)
if (!source.matter) throw new WebDavPathError('Not found', 404)
const depth = c.req.header('Depth') ?? 'infinity'
if (depth !== '0' && depth !== 'infinity') return xmlResponse(errorXml('bad-depth'), 400)
const sourceRoot = joinMatterPath(source.matter.parent, source.matter.name)
const targetRoot = joinMatterPath(target.parent, target.name)
const children = await listChildren(db, sourceWorkspace.id, sourceRoot)
const descendants = await listDescendants(db, sourceWorkspace.id, sourceRoot)
const ordered =
depth === 'infinity' ? [...children, ...descendants].sort((a, b) => a.parent.length - b.parent.length) : []
const reservedUsage: Array<{ storageId: string; bytes: number }> = []
const copiedObjects: Array<{ storage: S3Storage; key: string }> = []
const preparedCopies: Array<{ item: (typeof ordered)[number]; targetParent: string; objectKey: string }> = []
const createdIds: string[] = []
const targetRows =
target.matter && target.matter.dirtype !== DirType.FILE
? [
target.matter,
...(await listChildren(db, targetWorkspace.id, resourcePath(target))),
...(await listDescendants(db, targetWorkspace.id, resourcePath(target))),
]
: target.matter
? [target.matter]
: []
try {
for (const item of ordered) {
const targetParent =
item.parent === sourceRoot ? targetRoot : `${targetRoot}${item.parent.slice(sourceRoot.length)}`
let objectKey = ''
if (item.dirtype === DirType.FILE && item.object) {
const storage = (await getStorage(db, item.storageId)) as unknown as S3Storage | null
if (!storage) return c.text('Storage not found', 404)
const bytes = item.size ?? 0
if (bytes > 0) {
const allowed = await incrementUsageIfAllowed(db, targetWorkspace.id, storage.id, bytes)
if (!allowed) return c.text('Quota exceeded', 422)
reservedUsage.push({ storageId: storage.id, bytes })
}
objectKey = buildObjectKey({ uid: auth.userId, orgId: targetWorkspace.id, rawExt: fileExt(item.name) })
await s3.copyObject(storage, item.object, storage, objectKey)
copiedObjects.push({ storage, key: objectKey })
}
preparedCopies.push({ item, targetParent, objectKey })
}
if (target.matter) {
await deleteWebDavState(db, targetWorkspace.id, resourcePath(target))
await trashMatter(db, targetWorkspace.id, target.matter.id, auth.userId)
}
const rootCopy = await copyMatter(db, { ...source.matter, name: target.name }, target.parent, '', {
onConflict: 'fail',
userId: auth.userId,
})
createdIds.push(rootCopy.id)
await copyDeadProperties(db, sourceWorkspace.id, sourceRoot, joinMatterPath(rootCopy.parent, rootCopy.name))
for (const prepared of preparedCopies) {
const copy = await copyMatter(db, prepared.item, prepared.targetParent, prepared.objectKey, {
onConflict: 'fail',
userId: auth.userId,
})
createdIds.push(copy.id)
await copyDeadProperties(
db,
sourceWorkspace.id,
joinMatterPath(prepared.item.parent, prepared.item.name),
joinMatterPath(copy.parent, copy.name),
)
}
c.header(
'Location',
matterLocation(c.req.url, targetWorkspace.slug, joinMatterPath(rootCopy.parent, rootCopy.name)),
)
return c.body(null, 201)
} catch (e) {
if (createdIds.length > 0) {
await db
.update(matters)
.set({ status: ObjectStatus.TRASHED, trashedAt: Date.now(), updatedAt: new Date() })
.where(and(eq(matters.orgId, targetWorkspace.id), or(...createdIds.map((id) => eq(matters.id, id)))))
await deleteWebDavState(db, targetWorkspace.id, targetRoot)
}
if (targetRows.length > 0) await restoreActiveMatterRows(db, targetRows)
await Promise.all(copiedObjects.map((object) => s3.deleteObject(object.storage, object.key)))
const byStorage = new Map<string, number>()
let total = 0
for (const item of reservedUsage) {
byStorage.set(item.storageId, (byStorage.get(item.storageId) ?? 0) + item.bytes)
total += item.bytes
}
if (total > 0) await decrementUsage(db, targetWorkspace.id, byStorage, total)
throw e
}
}
async function lockMatter(c: DavContext, auth: DavAuth): Promise<Response> {
const db = c.get('platform').db
try {
const target = await resolveWebDavPath(db, auth.userId, davPath(c))
const workspace = requireWorkspace(target)
const body = await c.req.text()
const existingToken = lockRefreshToken(c)
if (existingToken instanceof Response) return existingToken
if (existingToken) {
if (body.length > 0) return xmlResponse(errorXml('lock-token-submitted'), 400)
const refreshed = await refreshLock(
db,
workspace.id,
resourcePath(target),
existingToken,
parseTimeout(c.req.header('Timeout')),
)
if (!refreshed) return xmlResponse(errorXml('lock-token-submitted'), 412)
return xmlResponse(lockDiscoveryXml(refreshed), 200)
}
const depth = c.req.header('Depth') ?? 'infinity'
if (depth !== '0' && depth !== 'infinity') return xmlResponse(errorXml('bad-depth'), 400)
const path = resourcePath(target)
const conflicts = await conflictingLocks(db, workspace.id, path)
if (conflicts.length > 0) return xmlResponse(errorXml('no-conflicting-lock'), 423)
let lockInfo: { owner: string }
try {
lockInfo = parseLockInfoXml(body)
} catch (e) {
return xmlResponse(errorXml('supported-lock', e instanceof Error ? e.message : 'Unsupported lock request.'), 422)
}
const created = !target.matter && Boolean(target.name)
if (created) {
await ensureParentCollection(db, auth.userId, workspace.slug, target.parent)
const storage = (await selectStorage(db, 'private')) as unknown as S3Storage
const objectKey = buildObjectKey({ uid: auth.userId, orgId: workspace.id, rawExt: fileExt(target.name) })
await s3.putObject(storage, objectKey, new Uint8Array(), 'application/octet-stream')
target.matter = await createMatter(db, {
orgId: workspace.id,
userId: auth.userId,
name: target.name,
type: 'application/octet-stream',
size: 0,
dirtype: DirType.FILE,
parent: target.parent,
object: objectKey,
storageId: storage.id,
status: ObjectStatus.ACTIVE,
})
}
const lock = await createLock(db, {
orgId: workspace.id,
resourcePath: path,
owner: lockInfo.owner,
depth,
timeoutSeconds: parseTimeout(c.req.header('Timeout')),
})
return xmlResponse(lockDiscoveryXml(lock), created ? 201 : 200, { 'Lock-Token': `<${lock.token}>` })
} catch (e) {
return davError(c, e)
}
}
async function unlockMatter(c: DavContext, auth: DavAuth): Promise<Response> {
const db = c.get('platform').db
try {
const target = await resolveExistingWebDavPath(db, auth.userId, davPath(c))
const workspace = requireWorkspace(target)
const token = lockTokenHeader(c)
if (!token) return xmlResponse(errorXml('lock-token-submitted'), 400)
const removed = await removeLock(db, workspace.id, resourcePath(target), token)
if (!removed) return xmlResponse(errorXml('lock-token-matches-request-uri'), 409)
return new Response(null, { status: 204 })
} catch (e) {
return davError(c, e)
}
}
function matterLocation(requestUrl: string, slug: string, path: string): string {
const url = new URL(requestUrl)
url.pathname = `/dav/${encodeURIComponent(slug)}/${path.split('/').map(encodeURIComponent).join('/')}`
+318
View File
@@ -0,0 +1,318 @@
import { and, eq, or, sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { webdavDeadProperties, webdavLocks } from '../db/schema'
import type { Database } from '../platform/interface'
import { type AtomicQuery, executeWriteTransaction } from './db-transaction'
export interface DavPropertyName {
namespace: string
name: string
}
export interface DavDeadProperty extends DavPropertyName {
value: string
}
export interface DavLock {
id: string
token: string
orgId: string
resourcePath: string
owner: string
depth: string
expiresAt: Date
createdAt: Date
updatedAt: Date
}
export async function listDeadProperties(
db: Database,
orgId: string,
resourcePath: string,
): Promise<DavDeadProperty[]> {
const rows = await db
.select({
namespace: webdavDeadProperties.namespace,
name: webdavDeadProperties.name,
value: webdavDeadProperties.value,
})
.from(webdavDeadProperties)
.where(and(eq(webdavDeadProperties.orgId, orgId), eq(webdavDeadProperties.resourcePath, resourcePath)))
return rows
}
export async function applyDeadPropertyUpdate(
db: Database,
orgId: string,
resourcePath: string,
operations: Array<{ action: 'set'; property: DavDeadProperty } | { action: 'remove'; property: DavPropertyName }>,
): Promise<void> {
const now = new Date()
const queries: AtomicQuery[] = []
for (const operation of operations) {
if (operation.action === 'remove') {
queries.push(
db
.delete(webdavDeadProperties)
.where(
and(
eq(webdavDeadProperties.orgId, orgId),
eq(webdavDeadProperties.resourcePath, resourcePath),
eq(webdavDeadProperties.namespace, operation.property.namespace),
eq(webdavDeadProperties.name, operation.property.name),
),
),
)
continue
}
const property = operation.property
queries.push(
db
.insert(webdavDeadProperties)
.values({
id: nanoid(),
orgId,
resourcePath,
namespace: property.namespace,
name: property.name,
value: property.value,
updatedAt: now,
})
.onConflictDoUpdate({
target: [
webdavDeadProperties.orgId,
webdavDeadProperties.resourcePath,
webdavDeadProperties.namespace,
webdavDeadProperties.name,
],
set: { value: property.value, updatedAt: now },
}),
)
}
await executeWriteTransaction(db, queries)
}
export async function deleteWebDavState(db: Database, orgId: string, resourcePath: string): Promise<void> {
await executeWriteTransaction(db, [
db
.delete(webdavDeadProperties)
.where(
and(
eq(webdavDeadProperties.orgId, orgId),
or(
eq(webdavDeadProperties.resourcePath, resourcePath),
sql`${webdavDeadProperties.resourcePath} LIKE ${`${resourcePath}/%`}`,
),
),
),
db
.delete(webdavLocks)
.where(
and(
eq(webdavLocks.orgId, orgId),
or(eq(webdavLocks.resourcePath, resourcePath), sql`${webdavLocks.resourcePath} LIKE ${`${resourcePath}/%`}`),
),
),
])
}
export async function moveWebDavState(db: Database, orgId: string, oldPath: string, newPath: string): Promise<void> {
const now = new Date()
await executeWriteTransaction(db, [
db
.update(webdavDeadProperties)
.set({
resourcePath: sql`CASE WHEN ${webdavDeadProperties.resourcePath} = ${oldPath} THEN ${newPath} ELSE ${newPath} || SUBSTR(${webdavDeadProperties.resourcePath}, ${oldPath.length + 1}) END`,
updatedAt: now,
})
.where(
and(
eq(webdavDeadProperties.orgId, orgId),
or(
eq(webdavDeadProperties.resourcePath, oldPath),
sql`${webdavDeadProperties.resourcePath} LIKE ${`${oldPath}/%`}`,
),
),
),
db
.update(webdavLocks)
.set({
resourcePath: sql`CASE WHEN ${webdavLocks.resourcePath} = ${oldPath} THEN ${newPath} ELSE ${newPath} || SUBSTR(${webdavLocks.resourcePath}, ${oldPath.length + 1}) END`,
updatedAt: now,
})
.where(
and(
eq(webdavLocks.orgId, orgId),
or(eq(webdavLocks.resourcePath, oldPath), sql`${webdavLocks.resourcePath} LIKE ${`${oldPath}/%`}`),
),
),
])
}
export async function copyDeadProperties(
db: Database,
orgId: string,
sourcePath: string,
targetPath: string,
): Promise<void> {
const rows = await db
.select()
.from(webdavDeadProperties)
.where(and(eq(webdavDeadProperties.orgId, orgId), eq(webdavDeadProperties.resourcePath, sourcePath)))
if (rows.length === 0) return
const now = new Date()
await executeWriteTransaction(
db,
rows.map((row) =>
db
.insert(webdavDeadProperties)
.values({
id: nanoid(),
orgId,
resourcePath: targetPath,
namespace: row.namespace,
name: row.name,
value: row.value,
updatedAt: now,
})
.onConflictDoUpdate({
target: [
webdavDeadProperties.orgId,
webdavDeadProperties.resourcePath,
webdavDeadProperties.namespace,
webdavDeadProperties.name,
],
set: { value: row.value, updatedAt: now },
}),
),
)
}
export async function activeLocks(db: Database, orgId: string, resourcePath: string): Promise<DavLock[]> {
await purgeExpiredLocks(db)
const now = Date.now()
return db
.select()
.from(webdavLocks)
.where(
and(
eq(webdavLocks.orgId, orgId),
sql`${webdavLocks.expiresAt} > ${now}`,
or(
eq(webdavLocks.resourcePath, resourcePath),
sql`${webdavLocks.resourcePath} = '' AND ${webdavLocks.depth} = 'infinity'`,
sql`${resourcePath} LIKE ${webdavLocks.resourcePath} || '/%' AND ${webdavLocks.depth} = 'infinity'`,
),
),
)
}
export async function conflictingLocks(db: Database, orgId: string, resourcePath: string): Promise<DavLock[]> {
await purgeExpiredLocks(db)
const now = Date.now()
return db
.select()
.from(webdavLocks)
.where(
and(
eq(webdavLocks.orgId, orgId),
sql`${webdavLocks.expiresAt} > ${now}`,
or(
eq(webdavLocks.resourcePath, resourcePath),
sql`${webdavLocks.resourcePath} = '' AND ${webdavLocks.depth} = 'infinity'`,
sql`${resourcePath} LIKE ${webdavLocks.resourcePath} || '/%' AND ${webdavLocks.depth} = 'infinity'`,
sql`${webdavLocks.resourcePath} LIKE ${resourcePath} || '/%'`,
),
),
)
}
export async function directLocks(db: Database, orgId: string, resourcePath: string): Promise<DavLock[]> {
await purgeExpiredLocks(db)
const now = Date.now()
return db
.select()
.from(webdavLocks)
.where(
and(
eq(webdavLocks.orgId, orgId),
eq(webdavLocks.resourcePath, resourcePath),
sql`${webdavLocks.expiresAt} > ${now}`,
),
)
}
export async function createLock(
db: Database,
input: { orgId: string; resourcePath: string; owner: string; depth: string; timeoutSeconds: number },
): Promise<DavLock> {
const now = new Date()
const lock: DavLock = {
id: nanoid(),
token: `opaquelocktoken:${crypto.randomUUID()}`,
orgId: input.orgId,
resourcePath: input.resourcePath,
owner: input.owner,
depth: input.depth,
expiresAt: new Date(now.getTime() + input.timeoutSeconds * 1000),
createdAt: now,
updatedAt: now,
}
await db.insert(webdavLocks).values(lock)
return lock
}
export async function refreshLock(
db: Database,
orgId: string,
resourcePath: string,
token: string,
timeoutSeconds: number,
): Promise<DavLock | null> {
await purgeExpiredLocks(db)
const now = new Date()
const expiresAt = new Date(now.getTime() + timeoutSeconds * 1000)
const rows = await db
.update(webdavLocks)
.set({ expiresAt, updatedAt: now })
.where(
and(
eq(webdavLocks.orgId, orgId),
eq(webdavLocks.token, token),
sql`${webdavLocks.expiresAt} > ${now.getTime()}`,
or(
eq(webdavLocks.resourcePath, resourcePath),
sql`${webdavLocks.resourcePath} = '' AND ${webdavLocks.depth} = 'infinity'`,
sql`${resourcePath} LIKE ${webdavLocks.resourcePath} || '/%' AND ${webdavLocks.depth} = 'infinity'`,
),
),
)
.returning()
return rows[0] ?? null
}
export async function removeLock(db: Database, orgId: string, resourcePath: string, token: string): Promise<boolean> {
await purgeExpiredLocks(db)
const rows = await db
.delete(webdavLocks)
.where(
and(
eq(webdavLocks.orgId, orgId),
eq(webdavLocks.token, token),
sql`${webdavLocks.expiresAt} > ${Date.now()}`,
or(
eq(webdavLocks.resourcePath, resourcePath),
sql`${webdavLocks.resourcePath} = '' AND ${webdavLocks.depth} = 'infinity'`,
sql`${resourcePath} LIKE ${webdavLocks.resourcePath} || '/%' AND ${webdavLocks.depth} = 'infinity'`,
),
),
)
.returning({ id: webdavLocks.id })
return rows.length > 0
}
async function purgeExpiredLocks(db: Database): Promise<void> {
await db.delete(webdavLocks).where(sql`${webdavLocks.expiresAt} <= ${Date.now()}`)
}
+351 -27
View File
@@ -2,8 +2,11 @@ import { DirType } from '../../shared/constants'
import type { Matter } from './matter'
import type { WebDavWorkspace } from './webdav-path'
import { matterHref, workspaceHref } from './webdav-path'
import type { DavDeadProperty, DavLock, DavPropertyName } from './webdav-state'
interface DavEntry {
export const DAV_NAMESPACE = 'DAV:'
export interface DavEntry {
href: string
displayName: string
collection: boolean
@@ -12,37 +15,66 @@ interface DavEntry {
createdAt: Date
updatedAt: Date
etag: string
deadProperties: DavDeadProperty[]
locks: DavLock[]
}
export function workspaceEntry(workspace: WebDavWorkspace): DavEntry {
const now = new Date()
export interface PropfindRequest {
mode: 'allprop' | 'propname' | 'prop'
properties: DavPropertyName[]
include: DavPropertyName[]
}
export type ProppatchOperation =
| { action: 'set'; property: DavDeadProperty }
| { action: 'remove'; property: DavPropertyName }
export interface LockInfoRequest {
owner: string
}
export function workspaceEntry(
workspace: WebDavWorkspace,
deadProperties: DavDeadProperty[],
locks: DavLock[],
): DavEntry {
const stableDate = new Date(0)
return {
href: workspaceHref(workspace),
displayName: workspace.name,
collection: true,
contentType: 'httpd/unix-directory',
contentLength: 0,
createdAt: now,
updatedAt: now,
etag: davEtag(workspace.id, 0, now),
createdAt: stableDate,
updatedAt: stableDate,
etag: davEtag(workspace.id, 0, stableDate),
deadProperties,
locks,
}
}
export function mountRootEntry(): DavEntry {
const now = new Date()
const stableDate = new Date(0)
return {
href: '/dav/',
displayName: 'dav',
collection: true,
contentType: 'httpd/unix-directory',
contentLength: 0,
createdAt: now,
updatedAt: now,
etag: davEtag('mount-root', 0, now),
createdAt: stableDate,
updatedAt: stableDate,
etag: davEtag('mount-root', 0, stableDate),
deadProperties: [],
locks: [],
}
}
export function matterEntry(workspace: WebDavWorkspace, matter: Matter): DavEntry {
export function matterEntry(
workspace: WebDavWorkspace,
matter: Matter,
deadProperties: DavDeadProperty[],
locks: DavLock[],
): DavEntry {
const collection = matter.dirtype !== DirType.FILE
return {
href: collection ? `${matterHref(workspace, matter)}/` : matterHref(workspace, matter),
@@ -53,37 +85,329 @@ export function matterEntry(workspace: WebDavWorkspace, matter: Matter): DavEntr
createdAt: matter.createdAt,
updatedAt: matter.updatedAt,
etag: davEtag(matter.id, matter.size ?? 0, matter.updatedAt),
deadProperties,
locks,
}
}
export function multistatus(entries: DavEntry[]): string {
return `<?xml version="1.0" encoding="utf-8"?>\n<D:multistatus xmlns:D="DAV:">\n${entries.map(response).join('\n')}\n</D:multistatus>`
export function multistatus(entries: DavEntry[], request: PropfindRequest): string {
return xmlDocument(
`<D:multistatus xmlns:D="DAV:">\n${entries.map((entry) => response(entry, request)).join('\n')}\n</D:multistatus>`,
)
}
function response(entry: DavEntry): string {
return ` <D:response>
<D:href>${escapeXml(entry.href)}</D:href>
export function proppatchMultistatus(href: string, properties: DavPropertyName[], status = 'HTTP/1.1 200 OK'): string {
return xmlDocument(`<D:multistatus xmlns:D="DAV:">
<D:response>
<D:href>${escapeXml(href)}</D:href>
<D:propstat>
<D:prop>
<D:displayname>${escapeXml(entry.displayName)}</D:displayname>
<D:creationdate>${entry.createdAt.toISOString()}</D:creationdate>
<D:getetag>${escapeXml(entry.etag)}</D:getetag>
<D:resourcetype>${entry.collection ? '<D:collection/>' : ''}</D:resourcetype>
<D:getcontentlength>${entry.contentLength}</D:getcontentlength>
<D:getcontenttype>${escapeXml(entry.contentType)}</D:getcontenttype>
<D:getlastmodified>${entry.updatedAt.toUTCString()}</D:getlastmodified>
<D:supportedlock/>
<D:lockdiscovery/>
${properties.map((property) => ` ${emptyPropertyXml(property)}`).join('\n')}
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
<D:status>${status}</D:status>
</D:propstat>
</D:response>`
</D:response>
</D:multistatus>`)
}
export function errorXml(precondition: string, message?: string): string {
const description = message ? `\n <D:responsedescription>${escapeXml(message)}</D:responsedescription>` : ''
return xmlDocument(`<D:error xmlns:D="DAV:">
<D:${precondition}/>${description}
</D:error>`)
}
export function lockDiscoveryXml(lock: DavLock): string {
return xmlDocument(`<D:prop xmlns:D="DAV:">
<D:lockdiscovery>
${activeLockXml(lock)}
</D:lockdiscovery>
</D:prop>`)
}
export function parsePropfindXml(body: string): PropfindRequest {
if (!body.trim()) return { mode: 'allprop', properties: [], include: [] }
const root = parseXmlElement(body)
requireElement(root, DAV_NAMESPACE, 'propfind')
const children = elementChildren(root)
const prop = children.find((child) => isElement(child, DAV_NAMESPACE, 'prop'))
const propname = children.find((child) => isElement(child, DAV_NAMESPACE, 'propname'))
const allprop = children.find((child) => isElement(child, DAV_NAMESPACE, 'allprop'))
const include = children.find((child) => isElement(child, DAV_NAMESPACE, 'include'))
const selected = [prop, propname, allprop].filter(Boolean)
if (selected.length !== 1) throw new Error('PROPFIND must contain exactly one request type')
if (prop) return { mode: 'prop', properties: propertyNames(prop), include: [] }
if (propname) return { mode: 'propname', properties: [], include: [] }
return { mode: 'allprop', properties: [], include: include ? propertyNames(include) : [] }
}
export function parseProppatchXml(body: string): ProppatchOperation[] {
const root = parseXmlElement(body)
requireElement(root, DAV_NAMESPACE, 'propertyupdate')
const operations: ProppatchOperation[] = []
for (const instruction of elementChildren(root)) {
if (!isElement(instruction, DAV_NAMESPACE, 'set') && !isElement(instruction, DAV_NAMESPACE, 'remove')) {
throw new Error('PROPPATCH instructions must be set or remove')
}
const prop = elementChildren(instruction).find((child) => isElement(child, DAV_NAMESPACE, 'prop'))
if (!prop) throw new Error('PROPPATCH instruction missing prop')
for (const property of elementChildren(prop)) {
if (property.namespace === DAV_NAMESPACE) throw new Error('Protected DAV properties cannot be patched')
if (isElement(instruction, DAV_NAMESPACE, 'set')) {
operations.push({
action: 'set',
property: { ...toPropertyName(property), value: propertyXmlWithNamespace(property) },
})
} else {
operations.push({ action: 'remove', property: toPropertyName(property) })
}
}
}
if (operations.length === 0) throw new Error('PROPPATCH must change at least one property')
return operations
}
export function parseLockInfoXml(body: string): LockInfoRequest {
const root = parseXmlElement(body)
requireElement(root, DAV_NAMESPACE, 'lockinfo')
const lockscope = elementChildren(root).find((child) => isElement(child, DAV_NAMESPACE, 'lockscope'))
const locktype = elementChildren(root).find((child) => isElement(child, DAV_NAMESPACE, 'locktype'))
if (!lockscope || !locktype) throw new Error('LOCK request missing lockscope or locktype')
const exclusive = elementChildren(lockscope).some((child) => isElement(child, DAV_NAMESPACE, 'exclusive'))
const shared = elementChildren(lockscope).some((child) => isElement(child, DAV_NAMESPACE, 'shared'))
const write = elementChildren(locktype).some((child) => isElement(child, DAV_NAMESPACE, 'write'))
if (!exclusive || shared || !write) throw new Error('Only exclusive write locks are supported')
const owner = elementChildren(root).find((child) => isElement(child, DAV_NAMESPACE, 'owner'))?.innerXml ?? ''
return { owner }
}
export function davEtag(id: string, size: number, updatedAt: Date): string {
return `"${id}-${size}-${updatedAt.getTime()}"`
}
export function xmlResponse(body: string, status: number, headers?: Record<string, string>): Response {
return new Response(body, {
status,
headers: { 'Content-Type': 'application/xml; charset=utf-8', ...headers },
})
}
function response(entry: DavEntry, request: PropfindRequest): string {
const properties = requestedProperties(entry, request)
const found = properties.filter((property) => propertyXml(entry, property))
const missing = properties.filter((property) => !propertyXml(entry, property))
const propstats = [
found.length > 0 ? propstat(entry, found, 'HTTP/1.1 200 OK', request.mode === 'propname') : '',
missing.length > 0 ? propstat(entry, missing, 'HTTP/1.1 404 Not Found', true) : '',
]
.filter(Boolean)
.join('\n')
return ` <D:response>
<D:href>${escapeXml(entry.href)}</D:href>
${propstats}
</D:response>`
}
function propstat(entry: DavEntry, properties: DavPropertyName[], status: string, namesOnly: boolean): string {
return ` <D:propstat>
<D:prop>
${properties.map((property) => ` ${namesOnly ? emptyPropertyXml(property) : propertyXml(entry, property)}`).join('\n')}
</D:prop>
<D:status>${status}</D:status>
</D:propstat>`
}
function requestedProperties(entry: DavEntry, request: PropfindRequest): DavPropertyName[] {
if (request.mode === 'prop') return request.properties
const all = [...livePropertyNames(), ...entry.deadProperties.map(({ namespace, name }) => ({ namespace, name }))]
if (request.mode === 'propname') return uniqueProperties(all)
return uniqueProperties([...all, ...request.include])
}
function livePropertyNames(): DavPropertyName[] {
return [
{ namespace: DAV_NAMESPACE, name: 'displayname' },
{ namespace: DAV_NAMESPACE, name: 'creationdate' },
{ namespace: DAV_NAMESPACE, name: 'getetag' },
{ namespace: DAV_NAMESPACE, name: 'resourcetype' },
{ namespace: DAV_NAMESPACE, name: 'getcontentlength' },
{ namespace: DAV_NAMESPACE, name: 'getcontenttype' },
{ namespace: DAV_NAMESPACE, name: 'getlastmodified' },
{ namespace: DAV_NAMESPACE, name: 'supportedlock' },
{ namespace: DAV_NAMESPACE, name: 'lockdiscovery' },
]
}
function propertyXml(entry: DavEntry, property: DavPropertyName): string {
if (property.namespace !== DAV_NAMESPACE) {
return entry.deadProperties.find((dead) => sameProperty(dead, property))?.value ?? ''
}
switch (property.name) {
case 'displayname':
return `<D:displayname>${escapeXml(entry.displayName)}</D:displayname>`
case 'creationdate':
return `<D:creationdate>${entry.createdAt.toISOString()}</D:creationdate>`
case 'getetag':
return `<D:getetag>${escapeXml(entry.etag)}</D:getetag>`
case 'resourcetype':
return `<D:resourcetype>${entry.collection ? '<D:collection/>' : ''}</D:resourcetype>`
case 'getcontentlength':
return `<D:getcontentlength>${entry.contentLength}</D:getcontentlength>`
case 'getcontenttype':
return `<D:getcontenttype>${escapeXml(entry.contentType)}</D:getcontenttype>`
case 'getlastmodified':
return `<D:getlastmodified>${entry.updatedAt.toUTCString()}</D:getlastmodified>`
case 'supportedlock':
return `<D:supportedlock>
<D:lockentry><D:lockscope><D:exclusive/></D:lockscope><D:locktype><D:write/></D:locktype></D:lockentry>
</D:supportedlock>`
case 'lockdiscovery':
return `<D:lockdiscovery>
${entry.locks.map(activeLockXml).join('\n')}
</D:lockdiscovery>`
default:
return ''
}
}
function activeLockXml(lock: DavLock): string {
return ` <D:activelock>
<D:locktype><D:write/></D:locktype>
<D:lockscope><D:exclusive/></D:lockscope>
<D:depth>${escapeXml(lock.depth)}</D:depth>
<D:owner>${lock.owner}</D:owner>
<D:timeout>Second-${Math.max(0, Math.ceil((lock.expiresAt.getTime() - Date.now()) / 1000))}</D:timeout>
<D:locktoken><D:href>${escapeXml(lock.token)}</D:href></D:locktoken>
</D:activelock>`
}
function emptyPropertyXml(property: DavPropertyName): string {
return property.namespace === DAV_NAMESPACE
? `<D:${property.name}/>`
: `<Z:${property.name} xmlns:Z="${escapeXml(property.namespace)}"/>`
}
function uniqueProperties(properties: DavPropertyName[]): DavPropertyName[] {
const seen = new Set<string>()
return properties.filter((property) => {
const key = `${property.namespace}\n${property.name}`
if (seen.has(key)) return false
seen.add(key)
return true
})
}
function sameProperty(a: DavPropertyName, b: DavPropertyName): boolean {
return a.namespace === b.namespace && a.name === b.name
}
interface XmlElement {
namespace: string
name: string
prefix: string
raw: string
innerXml: string
children: XmlElement[]
}
function parseXmlElement(xml: string): XmlElement {
const source = xml.replace(/<\?xml[^>]*>/i, '').trim()
const root: XmlElement = { namespace: '', name: '', prefix: '', raw: '', innerXml: '', children: [] }
const stack: Array<XmlElement & { start: number; bodyStart: number; namespaces: Map<string, string> }> = [
{ ...root, start: 0, bodyStart: 0, namespaces: new Map([['D', DAV_NAMESPACE]]) },
]
const tag =
/<!--[\s\S]*?-->|<\?[\s\S]*?\?>|<\/\s*([A-Za-z_][\w.-]*:)?([A-Za-z_][\w.-]*)\s*>|<\s*([A-Za-z_][\w.-]*:)?([A-Za-z_][\w.-]*)([^>]*?)>/g
let match = tag.exec(source)
while (match) {
if (match[0].startsWith('<!--') || match[0].startsWith('<?')) {
match = tag.exec(source)
continue
}
if (match[2]) {
const current = stack.pop()
if (!current || stack.length === 0) throw new Error('Invalid XML')
const closePrefix = match[1]?.slice(0, -1) ?? ''
if (current.prefix !== closePrefix || current.name !== match[2]) throw new Error('Invalid XML')
current.raw = source.slice(current.start, match.index + match[0].length)
current.innerXml = source.slice(current.bodyStart, match.index)
stack.at(-1)?.children.push(current)
match = tag.exec(source)
continue
}
const prefix = match[3]?.slice(0, -1) ?? ''
const name = match[4]
const rawAttributes = match[5] ?? ''
const selfClosing = /\/\s*$/.test(rawAttributes)
const namespaces = new Map(stack.at(-1)?.namespaces)
for (const ns of rawAttributes.matchAll(/\s+xmlns(?::([A-Za-z_][\w.-]*))?=["']([^"']+)["']/g)) {
namespaces.set(ns[1] ?? '', ns[2])
}
const namespace = namespaces.get(prefix) ?? (prefix || namespaces.has('') ? '' : DAV_NAMESPACE)
const element = {
namespace,
name,
prefix,
raw: selfClosing ? match[0] : '',
innerXml: '',
children: [],
start: match.index,
bodyStart: match.index + match[0].length,
namespaces,
}
if (selfClosing) {
stack.at(-1)?.children.push(element)
} else {
stack.push(element)
}
match = tag.exec(source)
}
if (stack.length !== 1 || stack[0].children.length !== 1) throw new Error('Invalid XML')
return stack[0].children[0]
}
function requireElement(element: XmlElement, namespace: string, name: string): void {
if (!isElement(element, namespace, name)) throw new Error(`Expected ${name}`)
}
function isElement(element: XmlElement, namespace: string, name: string): boolean {
return element.namespace === namespace && element.name === name
}
function elementChildren(element: XmlElement): XmlElement[] {
return element.children
}
function propertyNames(element: XmlElement): DavPropertyName[] {
return elementChildren(element).map(toPropertyName)
}
function toPropertyName(element: XmlElement): DavPropertyName {
return { namespace: element.namespace, name: element.name }
}
function propertyXmlWithNamespace(element: XmlElement): string {
if (element.namespace === DAV_NAMESPACE) return element.raw
const insertion = element.prefix
? ` xmlns:${element.prefix}="${escapeXml(element.namespace)}"`
: ` xmlns="${escapeXml(element.namespace)}"`
if (element.prefix) {
if (element.raw.includes(`xmlns:${element.prefix}=`)) return element.raw
return element.raw.replace(/(<[^\s>/]+)(\s|>|\/>)/, `$1${insertion}$2`)
}
if (element.raw.includes('xmlns=')) return element.raw
return element.raw.replace(/(<[^\s>/]+)(\s|>|\/>)/, `$1${insertion}$2`)
}
function xmlDocument(body: string): string {
return `<?xml version="1.0" encoding="utf-8"?>\n${body}`
}
function escapeXml(value: string): string {
return value
.replaceAll('&', '&amp;')
+26
View File
@@ -111,6 +111,32 @@ const APP_SCHEMA_SQL = `
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS webdav_dead_properties (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL,
resource_path TEXT NOT NULL,
namespace TEXT NOT NULL,
name TEXT NOT NULL,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS webdav_dead_properties_resource_prop_uniq
ON webdav_dead_properties(org_id, resource_path, namespace, name);
CREATE INDEX IF NOT EXISTS webdav_dead_properties_resource_idx
ON webdav_dead_properties(org_id, resource_path);
CREATE TABLE IF NOT EXISTS webdav_locks (
id TEXT PRIMARY KEY,
token TEXT NOT NULL UNIQUE,
org_id TEXT NOT NULL,
resource_path TEXT NOT NULL,
owner TEXT NOT NULL DEFAULT '',
depth TEXT NOT NULL DEFAULT 'infinity',
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS webdav_locks_resource_idx ON webdav_locks(org_id, resource_path);
CREATE INDEX IF NOT EXISTS webdav_locks_expires_idx ON webdav_locks(expires_at);
CREATE TABLE IF NOT EXISTS storages (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,