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,542 @@
import type { FastifyPluginAsync, FastifyRequest, FastifyReply } from 'fastify'
import { webdavBasicAuth } from '../../plugins/webdav-auth.js'
import { WebDavService } from '../../services/webdav.service.js'
import { StoragePermissionService, StorageFolderService, StorageFileService } from '../../services/storage.service.js'
import { buildMultistatus, buildLockResponse, type DavResource } from '../../utils/webdav-xml.js'
import { randomUUID } from 'crypto'
// In-memory lock store for WebDAV LOCK/UNLOCK
const locks = new Map<string, { token: string; owner: string; expires: number }>()
const DAV_METHODS = 'OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK'
const WEBDAV_PREFIX = '/webdav'
export const webdavRoutes: FastifyPluginAsync = async (app) => {
// Parse all request bodies as raw buffers for WebDAV
app.addContentTypeParser('*', function (_request, payload, done) {
const chunks: Buffer[] = []
payload.on('data', (chunk: Buffer) => chunks.push(chunk))
payload.on('end', () => done(null, Buffer.concat(chunks)))
payload.on('error', done)
})
const auth = webdavBasicAuth(app)
// Helper: normalize request path relative to /webdav
function getResourcePath(request: FastifyRequest): string {
const url = request.url.split('?')[0]
let path = url.startsWith(WEBDAV_PREFIX) ? url.slice(WEBDAV_PREFIX.length) : url
// Remove trailing slash for consistency (except root)
if (path.length > 1 && path.endsWith('/')) path = path.slice(0, -1)
return path || '/'
}
// --- OPTIONS ---
app.route({
method: 'OPTIONS',
url: '/*',
handler: async (_request, reply) => {
reply
.header('Allow', DAV_METHODS)
.header('DAV', '1, 2')
.header('MS-Author-Via', 'DAV')
.status(200)
.send('')
},
})
// Also handle OPTIONS on root /webdav
app.route({
method: 'OPTIONS',
url: '/',
handler: async (_request, reply) => {
reply
.header('Allow', DAV_METHODS)
.header('DAV', '1, 2')
.header('MS-Author-Via', 'DAV')
.status(200)
.send('')
},
})
// --- PROPFIND ---
app.route({
method: 'PROPFIND' as any,
url: '/*',
preHandler: auth,
handler: async (request, reply) => {
const resourcePath = getResourcePath(request)
const depth = (request.headers['depth'] as string) ?? '1'
const resolved = await WebDavService.resolvePath(app.db, resourcePath)
if (resolved.type === null) {
return reply.status(404).send('Not Found')
}
const resources: DavResource[] = []
const basePath = `${WEBDAV_PREFIX}${resourcePath === '/' ? '/' : resourcePath + '/'}`
if (resolved.type === 'root') {
// Root collection
resources.push({
href: `${WEBDAV_PREFIX}/`,
isCollection: true,
displayName: 'Files',
})
if (depth !== '0') {
const children = await WebDavService.listChildren(app.db, null, `${WEBDAV_PREFIX}/`)
resources.push(...children)
}
} else if (resolved.type === 'folder' && resolved.folder) {
// Check access
const hasAccess = await StoragePermissionService.canAccess(app.db, resolved.folder.id, request.user.id)
if (!hasAccess) return reply.status(403).send('Access Denied')
resources.push({
href: basePath.slice(0, -1) + '/',
isCollection: true,
displayName: resolved.folder.name,
lastModified: resolved.folder.updatedAt ? new Date(resolved.folder.updatedAt) : undefined,
createdAt: resolved.folder.createdAt ? new Date(resolved.folder.createdAt) : undefined,
})
if (depth !== '0') {
const children = await WebDavService.listChildren(app.db, resolved.folder.id, basePath)
resources.push(...children)
}
} else if (resolved.type === 'file' && resolved.file && resolved.folder) {
const hasAccess = await StoragePermissionService.canAccess(app.db, resolved.folder.id, request.user.id)
if (!hasAccess) return reply.status(403).send('Access Denied')
resources.push({
href: `${WEBDAV_PREFIX}${resourcePath}`,
isCollection: false,
displayName: resolved.file.filename,
contentType: resolved.file.contentType,
contentLength: resolved.file.sizeBytes,
lastModified: resolved.file.createdAt ? new Date(resolved.file.createdAt) : undefined,
createdAt: resolved.file.createdAt ? new Date(resolved.file.createdAt) : undefined,
etag: resolved.file.id,
})
}
const xml = buildMultistatus(resources)
return reply
.header('Content-Type', 'application/xml; charset=utf-8')
.status(207)
.send(xml)
},
})
// PROPFIND on root
app.route({
method: 'PROPFIND' as any,
url: '/',
preHandler: auth,
handler: async (request, reply) => {
const depth = (request.headers['depth'] as string) ?? '1'
const resources: DavResource[] = [{
href: `${WEBDAV_PREFIX}/`,
isCollection: true,
displayName: 'Files',
}]
if (depth !== '0') {
const children = await WebDavService.listChildren(app.db, null, `${WEBDAV_PREFIX}/`)
resources.push(...children)
}
const xml = buildMultistatus(resources)
return reply
.header('Content-Type', 'application/xml; charset=utf-8')
.status(207)
.send(xml)
},
})
// --- GET ---
app.get('/*', { preHandler: auth }, async (request, reply) => {
const resourcePath = getResourcePath(request)
const resolved = await WebDavService.resolvePath(app.db, resourcePath)
if (resolved.type === 'file' && resolved.file && resolved.folder) {
const hasAccess = await StoragePermissionService.canAccess(app.db, resolved.folder.id, request.user.id)
if (!hasAccess) return reply.status(403).send('Access Denied')
const data = await app.storage.get(resolved.file.path)
return reply
.header('Content-Type', resolved.file.contentType)
.header('Content-Length', resolved.file.sizeBytes)
.header('ETag', `"${resolved.file.id}"`)
.send(data)
}
if (resolved.type === 'folder' || resolved.type === 'root') {
// Return a simple HTML listing for browsers
return reply.status(200).header('Content-Type', 'text/plain').send('This is a WebDAV collection. Use a WebDAV client to browse.')
}
return reply.status(404).send('Not Found')
})
// HEAD is auto-generated by Fastify for GET routes
// --- PUT (upload/overwrite file) ---
app.put('/*', { preHandler: auth }, async (request, reply) => {
const resourcePath = getResourcePath(request)
const { parentPath, name } = WebDavService.parseParentAndName(resourcePath)
if (!name) return reply.status(400).send('Invalid path')
// Resolve parent to find the folder
const parentResolved = await WebDavService.resolvePath(app.db, parentPath)
if (parentResolved.type !== 'folder' || !parentResolved.folder) {
return reply.status(409).send('Parent folder not found')
}
// Check edit permission
const accessLevel = await StoragePermissionService.getAccessLevel(app.db, parentResolved.folder.id, request.user.id)
if (!accessLevel || accessLevel === 'view') {
return reply.status(403).send('No edit access')
}
// Body is a Buffer from our content type parser
const data = Buffer.isBuffer(request.body) ? request.body : Buffer.from(String(request.body ?? ''))
// Guess content type from extension
const contentType = guessContentType(name)
// Check if file already exists (overwrite)
const existingResolved = await WebDavService.resolvePath(app.db, resourcePath)
if (existingResolved.type === 'file' && existingResolved.file) {
await StorageFileService.delete(app.db, app.storage, existingResolved.file.id)
}
const file = await StorageFileService.upload(app.db, app.storage, {
folderId: parentResolved.folder.id,
data,
filename: name,
contentType,
uploadedBy: request.user.id,
})
return reply.status(existingResolved.type === 'file' ? 204 : 201).header('ETag', `"${file.id}"`).send('')
})
// --- DELETE ---
app.delete('/*', { preHandler: auth }, async (request, reply) => {
const resourcePath = getResourcePath(request)
const resolved = await WebDavService.resolvePath(app.db, resourcePath)
if (resolved.type === 'file' && resolved.file && resolved.folder) {
const accessLevel = await StoragePermissionService.getAccessLevel(app.db, resolved.folder.id, request.user.id)
if (!accessLevel || accessLevel === 'view') return reply.status(403).send('No edit access')
await StorageFileService.delete(app.db, app.storage, resolved.file.id)
return reply.status(204).send('')
}
if (resolved.type === 'folder' && resolved.folder) {
const accessLevel = await StoragePermissionService.getAccessLevel(app.db, resolved.folder.id, request.user.id)
if (accessLevel !== 'admin') return reply.status(403).send('Admin access required')
await StorageFolderService.delete(app.db, resolved.folder.id)
return reply.status(204).send('')
}
return reply.status(404).send('Not Found')
})
// --- MKCOL (create folder) ---
app.route({
method: 'MKCOL' as any,
url: '/*',
preHandler: auth,
handler: async (request, reply) => {
const resourcePath = getResourcePath(request)
const { parentPath, name } = WebDavService.parseParentAndName(resourcePath)
if (!name) return reply.status(400).send('Invalid path')
// Check if resource already exists
const existing = await WebDavService.resolvePath(app.db, resourcePath)
if (existing.type !== null) return reply.status(405).send('Resource already exists')
// Resolve parent
let parentFolderId: string | undefined
if (parentPath !== '/') {
const parentResolved = await WebDavService.resolvePath(app.db, parentPath)
if (parentResolved.type !== 'folder' || !parentResolved.folder) {
return reply.status(409).send('Parent folder not found')
}
// Check edit permission on parent
const accessLevel = await StoragePermissionService.getAccessLevel(app.db, parentResolved.folder.id, request.user.id)
if (!accessLevel || accessLevel === 'view') {
return reply.status(403).send('No edit access to parent folder')
}
parentFolderId = parentResolved.folder.id
}
await StorageFolderService.create(app.db, {
name,
parentId: parentFolderId,
isPublic: false,
createdBy: request.user.id,
})
return reply.status(201).send('')
},
})
// --- COPY ---
app.route({
method: 'COPY' as any,
url: '/*',
preHandler: auth,
handler: async (request, reply) => {
const resourcePath = getResourcePath(request)
const destinationHeader = request.headers['destination'] as string
if (!destinationHeader) return reply.status(400).send('Destination header required')
const destUrl = new URL(destinationHeader, `http://${request.headers.host}`)
let destPath = destUrl.pathname
if (destPath.startsWith(WEBDAV_PREFIX)) destPath = destPath.slice(WEBDAV_PREFIX.length)
if (destPath.length > 1 && destPath.endsWith('/')) destPath = destPath.slice(0, -1)
destPath = destPath || '/'
const resolved = await WebDavService.resolvePath(app.db, resourcePath)
if (resolved.type !== 'file' || !resolved.file || !resolved.folder) {
return reply.status(404).send('Source not found (only file copy supported)')
}
// Check read access on source
const srcAccess = await StoragePermissionService.canAccess(app.db, resolved.folder.id, request.user.id)
if (!srcAccess) return reply.status(403).send('No access to source')
// Resolve destination parent
const { parentPath, name: destName } = WebDavService.parseParentAndName(destPath)
const destParent = await WebDavService.resolvePath(app.db, parentPath)
if (destParent.type !== 'folder' || !destParent.folder) {
return reply.status(409).send('Destination parent not found')
}
// Check edit access on destination
const destAccess = await StoragePermissionService.getAccessLevel(app.db, destParent.folder.id, request.user.id)
if (!destAccess || destAccess === 'view') return reply.status(403).send('No edit access to destination')
// Copy: read source file data and upload to destination
const fileData = await app.storage.get(resolved.file.path)
const overwrite = (request.headers['overwrite'] as string)?.toUpperCase() !== 'F'
// Check if destination exists
const existingDest = await WebDavService.resolvePath(app.db, destPath)
if (existingDest.type === 'file' && existingDest.file) {
if (!overwrite) return reply.status(412).send('Destination exists')
await StorageFileService.delete(app.db, app.storage, existingDest.file.id)
}
await StorageFileService.upload(app.db, app.storage, {
folderId: destParent.folder.id,
data: fileData,
filename: destName || resolved.file.filename,
contentType: resolved.file.contentType,
uploadedBy: request.user.id,
})
return reply.status(existingDest.type === 'file' ? 204 : 201).send('')
},
})
// --- MOVE ---
app.route({
method: 'MOVE' as any,
url: '/*',
preHandler: auth,
handler: async (request, reply) => {
const resourcePath = getResourcePath(request)
const destinationHeader = request.headers['destination'] as string
if (!destinationHeader) return reply.status(400).send('Destination header required')
const destUrl = new URL(destinationHeader, `http://${request.headers.host}`)
let destPath = destUrl.pathname
if (destPath.startsWith(WEBDAV_PREFIX)) destPath = destPath.slice(WEBDAV_PREFIX.length)
if (destPath.length > 1 && destPath.endsWith('/')) destPath = destPath.slice(0, -1)
destPath = destPath || '/'
const resolved = await WebDavService.resolvePath(app.db, resourcePath)
if (resolved.type === 'file' && resolved.file && resolved.folder) {
// Check edit access on source folder
const srcAccess = await StoragePermissionService.getAccessLevel(app.db, resolved.folder.id, request.user.id)
if (!srcAccess || srcAccess === 'view') return reply.status(403).send('No edit access to source')
// Resolve destination
const { parentPath, name: destName } = WebDavService.parseParentAndName(destPath)
const destParent = await WebDavService.resolvePath(app.db, parentPath)
if (destParent.type !== 'folder' || !destParent.folder) {
return reply.status(409).send('Destination parent not found')
}
const destAccess = await StoragePermissionService.getAccessLevel(app.db, destParent.folder.id, request.user.id)
if (!destAccess || destAccess === 'view') return reply.status(403).send('No edit access to destination')
// Move: copy data then delete source
const overwrite = (request.headers['overwrite'] as string)?.toUpperCase() !== 'F'
const existingDest = await WebDavService.resolvePath(app.db, destPath)
if (existingDest.type === 'file' && existingDest.file) {
if (!overwrite) return reply.status(412).send('Destination exists')
await StorageFileService.delete(app.db, app.storage, existingDest.file.id)
}
// Read source, upload to dest, delete source
const fileData = await app.storage.get(resolved.file.path)
await StorageFileService.upload(app.db, app.storage, {
folderId: destParent.folder.id,
data: fileData,
filename: destName || resolved.file.filename,
contentType: resolved.file.contentType,
uploadedBy: request.user.id,
})
await StorageFileService.delete(app.db, app.storage, resolved.file.id)
return reply.status(existingDest.type === 'file' ? 204 : 201).send('')
}
if (resolved.type === 'folder' && resolved.folder) {
// Folder move — update parentId and recalculate path
const srcAccess = await StoragePermissionService.getAccessLevel(app.db, resolved.folder.id, request.user.id)
if (srcAccess !== 'admin') return reply.status(403).send('Admin access required to move folder')
const { parentPath, name: destName } = WebDavService.parseParentAndName(destPath)
// If just renaming (same parent), update name
const destParent = await WebDavService.resolvePath(app.db, parentPath)
if (destParent.type === 'folder' || destParent.type === 'root') {
const newParentId = destParent.type === 'folder' ? destParent.folder?.id : undefined
if (newParentId) {
const destAccess = await StoragePermissionService.getAccessLevel(app.db, newParentId, request.user.id)
if (!destAccess || destAccess === 'view') return reply.status(403).send('No edit access to destination')
}
await StorageFolderService.update(app.db, resolved.folder.id, { name: destName })
// TODO: If parent changed, also update parentId + materialized path
return reply.status(201).send('')
}
return reply.status(409).send('Destination parent not found')
}
return reply.status(404).send('Not Found')
},
})
// --- LOCK ---
app.route({
method: 'LOCK' as any,
url: '/*',
preHandler: auth,
handler: async (request, reply) => {
const resourcePath = getResourcePath(request)
const timeout = 300 // 5 minutes
const token = `opaquelocktoken:${randomUUID()}`
// Clean expired locks
const now = Date.now()
for (const [path, lock] of locks) {
if (lock.expires < now) locks.delete(path)
}
locks.set(resourcePath, {
token,
owner: request.user.id,
expires: now + timeout * 1000,
})
const xml = buildLockResponse(token, request.user.id, timeout)
return reply
.header('Content-Type', 'application/xml; charset=utf-8')
.header('Lock-Token', `<${token}>`)
.status(200)
.send(xml)
},
})
// LOCK on root
app.route({
method: 'LOCK' as any,
url: '/',
preHandler: auth,
handler: async (request, reply) => {
const token = `opaquelocktoken:${randomUUID()}`
const xml = buildLockResponse(token, request.user.id, 300)
locks.set('/', { token, owner: request.user.id, expires: Date.now() + 300000 })
return reply
.header('Content-Type', 'application/xml; charset=utf-8')
.header('Lock-Token', `<${token}>`)
.status(200)
.send(xml)
},
})
// --- UNLOCK ---
app.route({
method: 'UNLOCK' as any,
url: '/*',
preHandler: auth,
handler: async (request, reply) => {
const resourcePath = getResourcePath(request)
locks.delete(resourcePath)
return reply.status(204).send('')
},
})
app.route({
method: 'UNLOCK' as any,
url: '/',
preHandler: auth,
handler: async (_request, reply) => {
locks.delete('/')
return reply.status(204).send('')
},
})
// --- PROPPATCH (stub — accept but do nothing) ---
app.route({
method: 'PROPPATCH' as any,
url: '/*',
preHandler: auth,
handler: async (request, reply) => {
const resourcePath = getResourcePath(request)
const resolved = await WebDavService.resolvePath(app.db, resourcePath)
if (resolved.type === null) return reply.status(404).send('Not Found')
// Return a minimal success response
const xml = buildMultistatus([{
href: `${WEBDAV_PREFIX}${resourcePath}`,
isCollection: resolved.type === 'folder' || resolved.type === 'root',
displayName: resolved.type === 'file' ? resolved.file!.filename : resolved.type === 'folder' ? resolved.folder!.name : 'Files',
}])
return reply
.header('Content-Type', 'application/xml; charset=utf-8')
.status(207)
.send(xml)
},
})
}
function guessContentType(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase()
const map: Record<string, string> = {
pdf: 'application/pdf',
jpg: 'image/jpeg', jpeg: 'image/jpeg',
png: 'image/png', webp: 'image/webp', gif: 'image/gif',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
csv: 'text/csv', txt: 'text/plain',
mp4: 'video/mp4', mp3: 'audio/mpeg',
zip: 'application/zip',
json: 'application/json',
xml: 'application/xml',
html: 'text/html', htm: 'text/html',
}
return map[ext ?? ''] ?? 'application/octet-stream'
}