Add folder permissions UI and WebDAV protocol support

Permissions UI:
- FolderPermissionsDialog component with public/private toggle,
  role/user permission management, and access level badges
- Integrated into file manager toolbar (visible for folder admins)
- Backend returns accessLevel in folder detail endpoint

WebDAV server:
- Full WebDAV protocol at /webdav/ with Basic Auth (existing credentials)
- PROPFIND, GET, PUT, DELETE, MKCOL, COPY, MOVE, LOCK/UNLOCK support
- Permission-checked against existing folder access model
- In-memory lock stubs for Windows client compatibility
- 22 API integration tests covering all operations

Also fixes canAccess to check folder creator (was missing).
This commit is contained in:
Ryan Moon
2026-03-29 17:38:57 -05:00
parent cbbf2713a1
commit 51ca2ca683
14 changed files with 1757 additions and 7 deletions

View File

@@ -0,0 +1,104 @@
/**
* WebDAV XML response builders.
* Generates DAV-compliant XML without external dependencies.
*/
export interface DavResource {
href: string
isCollection: boolean
displayName: string
contentType?: string
contentLength?: number
lastModified?: Date
createdAt?: Date
etag?: string
}
function escapeXml(str: string): string {
return str
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function formatRfc1123(date: Date): string {
return date.toUTCString()
}
function formatIso8601(date: Date): string {
return date.toISOString()
}
function buildResourceResponse(resource: DavResource): string {
const props: string[] = []
if (resource.isCollection) {
props.push('<D:resourcetype><D:collection/></D:resourcetype>')
} else {
props.push('<D:resourcetype/>')
}
props.push(`<D:displayname>${escapeXml(resource.displayName)}</D:displayname>`)
if (resource.contentType && !resource.isCollection) {
props.push(`<D:getcontenttype>${escapeXml(resource.contentType)}</D:getcontenttype>`)
}
if (resource.contentLength != null && !resource.isCollection) {
props.push(`<D:getcontentlength>${resource.contentLength}</D:getcontentlength>`)
}
if (resource.lastModified) {
props.push(`<D:getlastmodified>${formatRfc1123(resource.lastModified)}</D:getlastmodified>`)
}
if (resource.createdAt) {
props.push(`<D:creationdate>${formatIso8601(resource.createdAt)}</D:creationdate>`)
}
if (resource.etag) {
props.push(`<D:getetag>"${escapeXml(resource.etag)}"</D:getetag>`)
}
return `<D:response>
<D:href>${escapeXml(resource.href)}</D:href>
<D:propstat>
<D:prop>
${props.join('\n')}
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>`
}
export function buildMultistatus(resources: DavResource[]): string {
const responses = resources.map(buildResourceResponse).join('\n')
return `<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:">
${responses}
</D:multistatus>`
}
export function buildLockResponse(lockToken: string, owner: string, timeout: number): string {
return `<?xml version="1.0" encoding="utf-8"?>
<D:prop xmlns:D="DAV:">
<D:lockdiscovery>
<D:activelock>
<D:locktype><D:write/></D:locktype>
<D:lockscope><D:exclusive/></D:lockscope>
<D:depth>infinity</D:depth>
<D:owner><D:href>${escapeXml(owner)}</D:href></D:owner>
<D:timeout>Second-${timeout}</D:timeout>
<D:locktoken><D:href>${escapeXml(lockToken)}</D:href></D:locktoken>
</D:activelock>
</D:lockdiscovery>
</D:prop>`
}
export function buildErrorResponse(statusCode: number, message: string): string {
return `<?xml version="1.0" encoding="utf-8"?>
<D:error xmlns:D="DAV:">
<D:message>${escapeXml(message)}</D:message>
</D:error>`
}