fix(webdav): stabilize propfind directory listings

This commit is contained in:
saltbo
2026-06-05 12:39:56 -04:00
parent 121d447c31
commit 3c9e1bf1ca
4 changed files with 117 additions and 26 deletions
+5 -1
View File
@@ -172,6 +172,7 @@ describe('WebDAV API', () => {
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' })
await file(db, workspace.id, { id: 'special', name: 'Miss Americana & The Heartbreak Prince.txt', parent: 'Docs' })
const root = await app.request('/dav/', { method: 'PROPFIND', headers: basicHeaders(account.email, key) })
expect(root.status).toBe(207)
@@ -192,6 +193,8 @@ describe('WebDAV API', () => {
const xml = await docs.text()
expect(xml).toContain(`/dav/${workspace.slug}/Docs/`)
expect(xml).toContain(`/dav/${workspace.slug}/Docs/readme.txt`)
expect(xml).toContain(`/dav/${workspace.slug}/Docs/Miss%20Americana%20%26%20The%20Heartbreak%20Prince.txt`)
expect(xml).toContain('<D:displayname>Miss Americana &amp; The Heartbreak Prince.txt</D:displayname>')
const doubledMountSlash = await app.request(`/dav//${workspace.slug}/Docs`, {
method: 'PROPFIND',
@@ -1732,7 +1735,7 @@ describe('WebDAV API', () => {
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>',
body: '<lockinfo xmlns="DAV:"><lockscope><exclusive/></lockscope><locktype><write/></locktype><owner>tester &amp; maintainer</owner></lockinfo>',
})
expect(locked.status).toBe(200)
const token = locked.headers.get('Lock-Token') ?? ''
@@ -1746,6 +1749,7 @@ describe('WebDAV API', () => {
const xml = await childProps.text()
expect(xml).toContain(token.slice(1, -1))
expect(xml).toContain('<D:depth>infinity</D:depth>')
expect(xml).toContain('<D:owner>tester &amp; maintainer</D:owner>')
})
it('returns WebDAV path errors for missing GET and DELETE targets', async () => {
+47 -23
View File
@@ -32,12 +32,13 @@ import {
} from '../services/webdav-path'
import {
activeLocks,
activeLocksForResources,
applyDeadPropertyUpdate,
conflictingLocks,
copyDeadProperties,
createLock,
deleteWebDavState,
listDeadProperties,
listDeadPropertiesForResources,
moveWebDavState,
refreshLock,
removeLock,
@@ -560,18 +561,44 @@ async function ifTaggedTarget(c: DavContext, auth: DavAuth, tag: string): Promis
}
}
async function davEntry(c: DavContext, target: WebDavTarget): Promise<DavEntry> {
async function davEntries(c: DavContext, targets: WebDavTarget[]): Promise<DavEntry[]> {
const entries: DavEntry[] = []
const byWorkspace = new Map<string, { workspace: NonNullable<WebDavTarget['workspace']>; targets: WebDavTarget[] }>()
for (const target of targets) {
if (target.mountRoot) {
entries.push(mountRootEntry())
continue
}
const workspace = requireWorkspace(target)
const group = byWorkspace.get(workspace.id)
if (group) {
group.targets.push(target)
} else {
byWorkspace.set(workspace.id, { workspace, targets: [target] })
}
}
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)
for (const { workspace, targets: workspaceTargets } of byWorkspace.values()) {
const paths = workspaceTargets.map(resourcePath)
const [deadPropertiesByPath, locksByPath] = await Promise.all([
listDeadPropertiesForResources(db, workspace.id, paths),
activeLocksForResources(db, workspace.id, paths),
])
for (const target of workspaceTargets) {
const path = resourcePath(target)
const deadProperties = deadPropertiesByPath.get(path) ?? []
const locks = locksByPath.get(path) ?? []
entries.push(
target.matter
? matterEntry(workspace, target.matter, deadProperties, locks)
: workspaceEntry(workspace, deadProperties, locks),
)
}
}
return entries
}
async function listDescendants(db: Env['Variables']['platform']['db'], orgId: string, rootPath: string) {
@@ -648,40 +675,37 @@ async function propfind(c: DavContext, auth: DavAuth): Promise<Response> {
return xmlResponse(errorXml('propfind-finite-depth', 'Depth infinity is not supported for PROPFIND.'), 403)
}
const request = parsePropfindXml(await c.req.text())
const entries: DavEntry[] = []
const targets: WebDavTarget[] = []
if (target.mountRoot) {
entries.push(mountRootEntry())
targets.push(target)
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))
targets.push(workspaceTarget)
}
}
} else if (!target.matter) {
if (target.name) throw new WebDavPathError('Not found', 404)
const workspace = requireWorkspace(target)
entries.push(await davEntry(c, target))
targets.push(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 }),
)
targets.push({ workspace, mountRoot: false, parent: matter.parent, name: matter.name, matter })
}
}
} else {
const workspace = requireWorkspace(target)
entries.push(await davEntry(c, target))
targets.push(target)
if (depth !== '0' && target.matter.dirtype !== DirType.FILE) {
const parent = joinMatterPath(target.matter.parent, target.matter.name)
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 }),
)
targets.push({ workspace, mountRoot: false, parent: matter.parent, name: matter.name, matter })
}
}
}
const entries = await davEntries(c, targets)
return xmlResponse(multistatus(entries, request), 207)
} catch (e) {
if (e instanceof Error && (e.message.includes('XML') || e.message.includes('PROPFIND'))) {
+51 -1
View File
@@ -1,4 +1,4 @@
import { and, eq, or, sql } from 'drizzle-orm'
import { and, eq, inArray, or, sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { webdavDeadProperties, webdavLocks } from '../db/schema'
import type { Database } from '../platform/interface'
@@ -41,6 +41,31 @@ export async function listDeadProperties(
return rows
}
export async function listDeadPropertiesForResources(
db: Database,
orgId: string,
resourcePaths: string[],
): Promise<Map<string, DavDeadProperty[]>> {
const uniquePaths = [...new Set(resourcePaths)]
const result = new Map(uniquePaths.map((path) => [path, [] as DavDeadProperty[]]))
if (uniquePaths.length === 0) return result
const rows = await db
.select({
resourcePath: webdavDeadProperties.resourcePath,
namespace: webdavDeadProperties.namespace,
name: webdavDeadProperties.name,
value: webdavDeadProperties.value,
})
.from(webdavDeadProperties)
.where(and(eq(webdavDeadProperties.orgId, orgId), inArray(webdavDeadProperties.resourcePath, uniquePaths)))
for (const row of rows) {
result.get(row.resourcePath)?.push({ namespace: row.namespace, name: row.name, value: row.value })
}
return result
}
export async function applyDeadPropertyUpdate(
db: Database,
orgId: string,
@@ -200,6 +225,31 @@ export async function activeLocks(db: Database, orgId: string, resourcePath: str
return rows.filter((lock) => lockAppliesToResource(lock, resourcePath))
}
export async function activeLocksForResources(
db: Database,
orgId: string,
resourcePaths: string[],
): Promise<Map<string, DavLock[]>> {
const uniquePaths = [...new Set(resourcePaths)]
const result = new Map(uniquePaths.map((path) => [path, [] as DavLock[]]))
if (uniquePaths.length === 0) return result
await purgeExpiredLocks(db)
const now = Date.now()
const rows = await db
.select()
.from(webdavLocks)
.where(and(eq(webdavLocks.orgId, orgId), sql`${webdavLocks.expiresAt} > ${now}`))
for (const path of uniquePaths) {
result.set(
path,
rows.filter((lock) => lockAppliesToResource(lock, path)),
)
}
return result
}
export async function conflictingLocks(db: Database, orgId: string, resourcePath: string): Promise<DavLock[]> {
await purgeExpiredLocks(db)
const now = Date.now()
+14 -1
View File
@@ -278,12 +278,25 @@ function activeLockXml(lock: DavLock): string {
<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:owner>${ownerXml(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 ownerXml(owner: string): string {
return /<\s*[A-Za-z_][\w.-]*(?::[A-Za-z_][\w.-]*)?[\s>/]/.test(owner) ? owner : escapeXml(unescapeXml(owner))
}
function unescapeXml(value: string): string {
return value
.replaceAll('&quot;', '"')
.replaceAll('&apos;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&')
}
function emptyPropertyXml(property: DavPropertyName): string {
return property.namespace === DAV_NAMESPACE
? `<D:${property.name}/>`