mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-01 15:49:00 +08:00
fix(webdav): stabilize propfind directory listings
This commit is contained in:
@@ -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 & 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 & 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 & maintainer</D:owner>')
|
||||
})
|
||||
|
||||
it('returns WebDAV path errors for missing GET and DELETE targets', async () => {
|
||||
|
||||
+47
-23
@@ -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'))) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('&', '&')
|
||||
}
|
||||
|
||||
function emptyPropertyXml(property: DavPropertyName): string {
|
||||
return property.namespace === DAV_NAMESPACE
|
||||
? `<D:${property.name}/>`
|
||||
|
||||
Reference in New Issue
Block a user