mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
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:
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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('/')}`
|
||||
|
||||
@@ -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
@@ -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('&', '&')
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user