/** * WebDAV protocol handler * Implements RFC 4918 for network drive mounting */ import * as plugins from '../../plugins.js'; import type { IWebDAVConfig, IRequestContext } from '../../core/smartserve.interfaces.js'; import type { TWebDAVMethod, TWebDAVDepth, IWebDAVResource, IWebDAVLock, IWebDAVContext, } from './webdav.types.js'; import { generateMultistatus, generateLockResponse, generateError, parsePropfindRequest, } from './webdav.xml.js'; import { getMimeType } from '../../utils/utils.mime.js'; import { generateETag } from '../../utils/utils.etag.js'; /** * WebDAV handler for serving files with WebDAV protocol */ export class WebDAVHandler { private config: IWebDAVConfig; private locks: Map = new Map(); constructor(config: IWebDAVConfig) { this.config = { locking: true, ...config, }; } /** * Check if request is a WebDAV request */ isWebDAVRequest(request: Request): boolean { const method = request.method.toUpperCase(); const webdavMethods = ['PROPFIND', 'PROPPATCH', 'MKCOL', 'COPY', 'MOVE', 'LOCK', 'UNLOCK']; return webdavMethods.includes(method); } /** * Handle WebDAV request */ async handle(request: Request): Promise { const method = request.method.toUpperCase() as TWebDAVMethod; const url = new URL(request.url); const path = decodeURIComponent(url.pathname); // Security check if (path.includes('..')) { return new Response('Forbidden', { status: 403 }); } // Parse WebDAV context const context = this.parseContext(request); // Authentication if (this.config.auth) { const mockCtx = { headers: request.headers } as IRequestContext; const authenticated = await this.config.auth(mockCtx); if (!authenticated) { return new Response('Unauthorized', { status: 401, headers: { 'WWW-Authenticate': 'Basic realm="WebDAV"' }, }); } } try { switch (method) { case 'OPTIONS': return this.handleOptions(); case 'PROPFIND': return await this.handlePropfind(request, path, context); case 'PROPPATCH': return await this.handleProppatch(request, path); case 'MKCOL': return await this.handleMkcol(path); case 'COPY': return await this.handleCopy(path, context); case 'MOVE': return await this.handleMove(path, context); case 'LOCK': return await this.handleLock(request, path, context); case 'UNLOCK': return await this.handleUnlock(path, context); case 'GET': case 'HEAD': return await this.handleGet(request, path, method === 'HEAD'); case 'PUT': return await this.handlePut(request, path, context); case 'DELETE': return await this.handleDelete(path, context); default: return new Response('Method Not Allowed', { status: 405 }); } } catch (error: any) { console.error('WebDAV error:', error); if (error.code === 'ENOENT') { return new Response('Not Found', { status: 404 }); } return new Response('Internal Server Error', { status: 500 }); } } /** * Parse WebDAV-specific headers into context */ private parseContext(request: Request): IWebDAVContext { const depth = (request.headers.get('Depth') ?? '1') as TWebDAVDepth; const destination = request.headers.get('Destination') ?? undefined; const overwrite = request.headers.get('Overwrite') !== 'F'; const lockToken = request.headers.get('Lock-Token')?.replace(/[<>]/g, ''); const timeout = this.parseTimeout(request.headers.get('Timeout')); return { method: request.method.toUpperCase() as TWebDAVMethod, depth: depth === 'infinity' ? 'infinity' : depth === '0' ? '0' : '1', destination, overwrite, lockToken, timeout, }; } /** * Parse timeout header */ private parseTimeout(header: string | null): number { if (!header) return 3600; // 1 hour default const match = header.match(/Second-(\d+)/i); if (match) return parseInt(match[1], 10); if (header.toLowerCase() === 'infinite') return 0; return 3600; } /** * Resolve file path */ private resolvePath(path: string): string { return plugins.path.join(this.config.root, path); } /** * Handle OPTIONS request */ private handleOptions(): Response { return new Response(null, { status: 200, headers: { 'Allow': 'OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK', 'DAV': '1, 2', 'MS-Author-Via': 'DAV', }, }); } /** * Handle PROPFIND request */ private async handlePropfind( request: Request, path: string, context: IWebDAVContext ): Promise { const filePath = this.resolvePath(path); // Parse request body const body = await request.text(); const { allprop } = parsePropfindRequest(body); try { const stat = await plugins.fs.promises.stat(filePath); const resources: IWebDAVResource[] = []; // Add the requested resource resources.push(await this.statToResource(path, stat)); // If directory and depth > 0, list children if (stat.isDirectory() && context.depth !== '0') { const entries = await plugins.fs.promises.readdir(filePath, { withFileTypes: true }); for (const entry of entries) { const childPath = plugins.path.join(path, entry.name); const childFilePath = plugins.path.join(filePath, entry.name); const childStat = await plugins.fs.promises.stat(childFilePath); resources.push(await this.statToResource(childPath, childStat)); // Handle infinite depth (recursive) if (context.depth === 'infinity' && entry.isDirectory()) { await this.collectResources(childPath, childFilePath, resources); } } } const xml = generateMultistatus(resources); return new Response(xml, { status: 207, // Multi-Status headers: { 'Content-Type': 'application/xml; charset=utf-8', }, }); } catch (error: any) { if (error.code === 'ENOENT') { return new Response('Not Found', { status: 404 }); } throw error; } } /** * Recursively collect resources for infinite depth */ private async collectResources( basePath: string, filePath: string, resources: IWebDAVResource[] ): Promise { const entries = await plugins.fs.promises.readdir(filePath, { withFileTypes: true }); for (const entry of entries) { const childPath = plugins.path.join(basePath, entry.name); const childFilePath = plugins.path.join(filePath, entry.name); const childStat = await plugins.fs.promises.stat(childFilePath); resources.push(await this.statToResource(childPath, childStat)); if (entry.isDirectory()) { await this.collectResources(childPath, childFilePath, resources); } } } /** * Convert fs.Stats to WebDAV resource */ private async statToResource(path: string, stat: plugins.fs.Stats): Promise { const isCollection = stat.isDirectory(); const displayName = plugins.path.basename(path) || '/'; return { href: encodeURI(path), isCollection, displayName, contentType: isCollection ? 'httpd/unix-directory' : getMimeType(path), contentLength: isCollection ? undefined : stat.size, lastModified: stat.mtime, creationDate: stat.birthtime, etag: generateETag(stat), properties: [], }; } /** * Handle PROPPATCH request (property modification) */ private async handleProppatch(request: Request, path: string): Promise { // For now, we don't support modifying properties // Return 403 Forbidden for property modification attempts const filePath = this.resolvePath(path); try { await plugins.fs.promises.access(filePath); return new Response(generateError(403, 'cannot-modify-protected-property'), { status: 403, headers: { 'Content-Type': 'application/xml; charset=utf-8' }, }); } catch { return new Response('Not Found', { status: 404 }); } } /** * Handle MKCOL request (create directory) */ private async handleMkcol(path: string): Promise { const filePath = this.resolvePath(path); try { // Check if parent exists const parent = plugins.path.dirname(filePath); await plugins.fs.promises.access(parent); // Check if already exists try { await plugins.fs.promises.access(filePath); return new Response('Method Not Allowed', { status: 405 }); // Already exists } catch { // Good, doesn't exist } await plugins.fs.promises.mkdir(filePath); return new Response(null, { status: 201 }); // Created } catch (error: any) { if (error.code === 'ENOENT') { return new Response('Conflict', { status: 409 }); // Parent doesn't exist } throw error; } } /** * Handle COPY request */ private async handleCopy(path: string, context: IWebDAVContext): Promise { if (!context.destination) { return new Response('Bad Request', { status: 400 }); } const sourcePath = this.resolvePath(path); const destUrl = new URL(context.destination); const destPath = this.resolvePath(decodeURIComponent(destUrl.pathname)); // Check if destination exists let destExists = false; try { await plugins.fs.promises.access(destPath); destExists = true; if (!context.overwrite) { return new Response('Precondition Failed', { status: 412 }); } } catch { // Destination doesn't exist, that's fine } try { const stat = await plugins.fs.promises.stat(sourcePath); if (stat.isDirectory()) { await this.copyDirectory(sourcePath, destPath); } else { await plugins.fs.promises.copyFile(sourcePath, destPath); } return new Response(null, { status: destExists ? 204 : 201 }); } catch (error: any) { if (error.code === 'ENOENT') { return new Response('Not Found', { status: 404 }); } throw error; } } /** * Recursively copy directory */ private async copyDirectory(src: string, dest: string): Promise { await plugins.fs.promises.mkdir(dest, { recursive: true }); const entries = await plugins.fs.promises.readdir(src, { withFileTypes: true }); for (const entry of entries) { const srcPath = plugins.path.join(src, entry.name); const destPath = plugins.path.join(dest, entry.name); if (entry.isDirectory()) { await this.copyDirectory(srcPath, destPath); } else { await plugins.fs.promises.copyFile(srcPath, destPath); } } } /** * Handle MOVE request */ private async handleMove(path: string, context: IWebDAVContext): Promise { if (!context.destination) { return new Response('Bad Request', { status: 400 }); } const sourcePath = this.resolvePath(path); const destUrl = new URL(context.destination); const destPath = this.resolvePath(decodeURIComponent(destUrl.pathname)); // Check lock if (this.config.locking && this.isLocked(path) && !this.hasValidLock(path, context.lockToken)) { return new Response('Locked', { status: 423 }); } // Check if destination exists let destExists = false; try { await plugins.fs.promises.access(destPath); destExists = true; if (!context.overwrite) { return new Response('Precondition Failed', { status: 412 }); } // Remove existing destination await plugins.fs.promises.rm(destPath, { recursive: true, force: true }); } catch { // Destination doesn't exist, that's fine } try { await plugins.fs.promises.rename(sourcePath, destPath); // Move lock if exists const lock = this.locks.get(path); if (lock) { this.locks.delete(path); lock.path = decodeURIComponent(destUrl.pathname); this.locks.set(lock.path, lock); } return new Response(null, { status: destExists ? 204 : 201 }); } catch (error: any) { if (error.code === 'ENOENT') { return new Response('Not Found', { status: 404 }); } throw error; } } /** * Handle LOCK request */ private async handleLock( request: Request, path: string, context: IWebDAVContext ): Promise { if (!this.config.locking) { return new Response('Method Not Allowed', { status: 405 }); } const filePath = this.resolvePath(path); // Check if resource exists try { await plugins.fs.promises.access(filePath); } catch { return new Response('Not Found', { status: 404 }); } // Check if already locked by someone else if (this.isLocked(path) && !this.hasValidLock(path, context.lockToken)) { return new Response('Locked', { status: 423 }); } // Create lock const lock: IWebDAVLock = { token: `opaquelocktoken:${crypto.randomUUID()}`, owner: 'anonymous', // Could parse from request body depth: context.depth, timeout: context.timeout ?? 3600, scope: 'exclusive', type: 'write', path, created: new Date(), }; this.locks.set(path, lock); // Set timeout to remove lock if (lock.timeout > 0) { setTimeout(() => { this.locks.delete(path); }, lock.timeout * 1000); } const xml = generateLockResponse(lock); return new Response(xml, { status: 200, headers: { 'Content-Type': 'application/xml; charset=utf-8', 'Lock-Token': `<${lock.token}>`, }, }); } /** * Handle UNLOCK request */ private async handleUnlock(path: string, context: IWebDAVContext): Promise { if (!this.config.locking) { return new Response('Method Not Allowed', { status: 405 }); } if (!context.lockToken) { return new Response('Bad Request', { status: 400 }); } const lock = this.locks.get(path); if (!lock) { return new Response('Conflict', { status: 409 }); } if (lock.token !== context.lockToken) { return new Response('Forbidden', { status: 403 }); } this.locks.delete(path); return new Response(null, { status: 204 }); } /** * Handle GET/HEAD request */ private async handleGet(request: Request, path: string, headOnly: boolean): Promise { const filePath = this.resolvePath(path); try { const stat = await plugins.fs.promises.stat(filePath); if (stat.isDirectory()) { // Return directory listing as HTML (fallback) return new Response('This is a WebDAV directory', { status: 200, headers: { 'Content-Type': 'text/plain' }, }); } const headers = new Headers({ 'Content-Type': getMimeType(filePath), 'Content-Length': stat.size.toString(), 'Last-Modified': stat.mtime.toUTCString(), 'ETag': `"${generateETag(stat)}"`, }); if (headOnly) { return new Response(null, { status: 200, headers }); } const stream = plugins.fs.createReadStream(filePath); const webStream = new ReadableStream({ start(controller) { stream.on('data', (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk))); stream.on('end', () => controller.close()); stream.on('error', (err) => controller.error(err)); }, cancel() { stream.destroy(); }, }); return new Response(webStream, { status: 200, headers }); } catch (error: any) { if (error.code === 'ENOENT') { return new Response('Not Found', { status: 404 }); } throw error; } } /** * Handle PUT request (file upload) */ private async handlePut( request: Request, path: string, context: IWebDAVContext ): Promise { const filePath = this.resolvePath(path); // Check lock if (this.config.locking && this.isLocked(path) && !this.hasValidLock(path, context.lockToken)) { return new Response('Locked', { status: 423 }); } // Check if file exists let exists = false; try { await plugins.fs.promises.access(filePath); exists = true; } catch { // File doesn't exist, will create } // Ensure parent directory exists const parent = plugins.path.dirname(filePath); try { await plugins.fs.promises.mkdir(parent, { recursive: true }); } catch { // Parent might already exist } // Write file const body = request.body; if (body) { const reader = body.getReader(); const writeStream = plugins.fs.createWriteStream(filePath); try { while (true) { const { done, value } = await reader.read(); if (done) break; writeStream.write(value); } writeStream.end(); await new Promise((resolve, reject) => { writeStream.on('finish', resolve); writeStream.on('error', reject); }); } catch (error) { writeStream.destroy(); throw error; } } else { // Empty file await plugins.fs.promises.writeFile(filePath, ''); } return new Response(null, { status: exists ? 204 : 201 }); } /** * Handle DELETE request */ private async handleDelete(path: string, context: IWebDAVContext): Promise { const filePath = this.resolvePath(path); // Check lock if (this.config.locking && this.isLocked(path) && !this.hasValidLock(path, context.lockToken)) { return new Response('Locked', { status: 423 }); } try { const stat = await plugins.fs.promises.stat(filePath); if (stat.isDirectory()) { await plugins.fs.promises.rm(filePath, { recursive: true }); } else { await plugins.fs.promises.unlink(filePath); } // Remove lock if exists this.locks.delete(path); return new Response(null, { status: 204 }); } catch (error: any) { if (error.code === 'ENOENT') { return new Response('Not Found', { status: 404 }); } throw error; } } /** * Check if a path is locked */ private isLocked(path: string): boolean { return this.locks.has(path); } /** * Check if token matches lock */ private hasValidLock(path: string, token?: string): boolean { if (!token) return false; const lock = this.locks.get(path); return lock?.token === token; } }