660 lines
18 KiB
TypeScript
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;
|
||
|
|
}
|
||
|
|
}
|