Files
smartserve/ts/protocols/webdav/webdav.handler.ts
2025-11-29 15:24:00 +00:00

660 lines
18 KiB
TypeScript

/**
* 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<string, IWebDAVLock> = 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<Response> {
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<Response> {
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<void> {
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<IWebDAVResource> {
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<Response> {
// 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<Response> {
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<Response> {
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<void> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<void>((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<Response> {
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;
}
}