initial
This commit is contained in:
1
ts/protocols/index.ts
Normal file
1
ts/protocols/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './webdav/index.js';
|
||||
15
ts/protocols/webdav/index.ts
Normal file
15
ts/protocols/webdav/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export { WebDAVHandler } from './webdav.handler.js';
|
||||
export type {
|
||||
TWebDAVMethod,
|
||||
TWebDAVDepth,
|
||||
IWebDAVProperty,
|
||||
IWebDAVResource,
|
||||
IWebDAVLock,
|
||||
IWebDAVContext,
|
||||
} from './webdav.types.js';
|
||||
export {
|
||||
generateMultistatus,
|
||||
generateLockResponse,
|
||||
generateError,
|
||||
parsePropfindRequest,
|
||||
} from './webdav.xml.js';
|
||||
659
ts/protocols/webdav/webdav.handler.ts
Normal file
659
ts/protocols/webdav/webdav.handler.ts
Normal file
@@ -0,0 +1,659 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
74
ts/protocols/webdav/webdav.types.ts
Normal file
74
ts/protocols/webdav/webdav.types.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* WebDAV type definitions
|
||||
*/
|
||||
|
||||
export type TWebDAVMethod =
|
||||
| 'OPTIONS'
|
||||
| 'GET'
|
||||
| 'HEAD'
|
||||
| 'PUT'
|
||||
| 'DELETE'
|
||||
| 'PROPFIND'
|
||||
| 'PROPPATCH'
|
||||
| 'MKCOL'
|
||||
| 'COPY'
|
||||
| 'MOVE'
|
||||
| 'LOCK'
|
||||
| 'UNLOCK';
|
||||
|
||||
export type TWebDAVDepth = '0' | '1' | 'infinity';
|
||||
|
||||
export interface IWebDAVProperty {
|
||||
namespace: string;
|
||||
name: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface IWebDAVResource {
|
||||
href: string;
|
||||
isCollection: boolean;
|
||||
properties: IWebDAVProperty[];
|
||||
displayName?: string;
|
||||
contentType?: string;
|
||||
contentLength?: number;
|
||||
lastModified?: Date;
|
||||
creationDate?: Date;
|
||||
etag?: string;
|
||||
}
|
||||
|
||||
export interface IWebDAVLock {
|
||||
token: string;
|
||||
owner: string;
|
||||
depth: TWebDAVDepth;
|
||||
timeout: number;
|
||||
scope: 'exclusive' | 'shared';
|
||||
type: 'write';
|
||||
path: string;
|
||||
created: Date;
|
||||
}
|
||||
|
||||
export interface IWebDAVContext {
|
||||
method: TWebDAVMethod;
|
||||
depth: TWebDAVDepth;
|
||||
lockToken?: string;
|
||||
destination?: string;
|
||||
overwrite: boolean;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
// Standard WebDAV properties (DAV: namespace)
|
||||
export const DAV_NAMESPACE = 'DAV:';
|
||||
|
||||
export const DAV_PROPERTIES = {
|
||||
// Required properties
|
||||
creationdate: 'creationdate',
|
||||
displayname: 'displayname',
|
||||
getcontentlanguage: 'getcontentlanguage',
|
||||
getcontentlength: 'getcontentlength',
|
||||
getcontenttype: 'getcontenttype',
|
||||
getetag: 'getetag',
|
||||
getlastmodified: 'getlastmodified',
|
||||
lockdiscovery: 'lockdiscovery',
|
||||
resourcetype: 'resourcetype',
|
||||
supportedlock: 'supportedlock',
|
||||
} as const;
|
||||
184
ts/protocols/webdav/webdav.xml.ts
Normal file
184
ts/protocols/webdav/webdav.xml.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* WebDAV XML generation utilities
|
||||
*/
|
||||
|
||||
import type { IWebDAVResource, IWebDAVProperty, IWebDAVLock } from './webdav.types.js';
|
||||
import { DAV_NAMESPACE } from './webdav.types.js';
|
||||
|
||||
/**
|
||||
* Escape XML special characters
|
||||
*/
|
||||
function escapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for WebDAV (RFC 1123)
|
||||
*/
|
||||
function formatDate(date: Date): string {
|
||||
return date.toUTCString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for creationdate (ISO 8601)
|
||||
*/
|
||||
function formatCreationDate(date: Date): string {
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate multistatus response for PROPFIND
|
||||
*/
|
||||
export function generateMultistatus(resources: IWebDAVResource[]): string {
|
||||
const responses = resources.map(resource => generateResponse(resource)).join('\n');
|
||||
|
||||
return `<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:multistatus xmlns:D="${DAV_NAMESPACE}">
|
||||
${responses}
|
||||
</D:multistatus>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate single response element
|
||||
*/
|
||||
function generateResponse(resource: IWebDAVResource): string {
|
||||
const props: string[] = [];
|
||||
|
||||
// Resource type
|
||||
if (resource.isCollection) {
|
||||
props.push('<D:resourcetype><D:collection/></D:resourcetype>');
|
||||
} else {
|
||||
props.push('<D:resourcetype/>');
|
||||
}
|
||||
|
||||
// Display name
|
||||
if (resource.displayName) {
|
||||
props.push(`<D:displayname>${escapeXml(resource.displayName)}</D:displayname>`);
|
||||
}
|
||||
|
||||
// Content type
|
||||
if (resource.contentType) {
|
||||
props.push(`<D:getcontenttype>${escapeXml(resource.contentType)}</D:getcontenttype>`);
|
||||
}
|
||||
|
||||
// Content length
|
||||
if (resource.contentLength !== undefined) {
|
||||
props.push(`<D:getcontentlength>${resource.contentLength}</D:getcontentlength>`);
|
||||
}
|
||||
|
||||
// Last modified
|
||||
if (resource.lastModified) {
|
||||
props.push(`<D:getlastmodified>${formatDate(resource.lastModified)}</D:getlastmodified>`);
|
||||
}
|
||||
|
||||
// Creation date
|
||||
if (resource.creationDate) {
|
||||
props.push(`<D:creationdate>${formatCreationDate(resource.creationDate)}</D:creationdate>`);
|
||||
}
|
||||
|
||||
// ETag
|
||||
if (resource.etag) {
|
||||
props.push(`<D:getetag>"${escapeXml(resource.etag)}"</D:getetag>`);
|
||||
}
|
||||
|
||||
// Supported lock
|
||||
props.push(`<D:supportedlock>
|
||||
<D:lockentry>
|
||||
<D:lockscope><D:exclusive/></D:lockscope>
|
||||
<D:locktype><D:write/></D:locktype>
|
||||
</D:lockentry>
|
||||
</D:supportedlock>`);
|
||||
|
||||
// Custom properties
|
||||
for (const prop of resource.properties) {
|
||||
if (prop.value) {
|
||||
props.push(`<${prop.name} xmlns="${prop.namespace}">${escapeXml(prop.value)}</${prop.name}>`);
|
||||
} else {
|
||||
props.push(`<${prop.name} xmlns="${prop.namespace}"/>`);
|
||||
}
|
||||
}
|
||||
|
||||
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>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate lock discovery response
|
||||
*/
|
||||
export function generateLockDiscovery(locks: IWebDAVLock[]): string {
|
||||
if (locks.length === 0) {
|
||||
return '<D:lockdiscovery/>';
|
||||
}
|
||||
|
||||
const lockEntries = locks.map(lock => `
|
||||
<D:activelock>
|
||||
<D:locktype><D:write/></D:locktype>
|
||||
<D:lockscope><D:${lock.scope}/></D:lockscope>
|
||||
<D:depth>${lock.depth}</D:depth>
|
||||
<D:owner>${escapeXml(lock.owner)}</D:owner>
|
||||
<D:timeout>Second-${lock.timeout}</D:timeout>
|
||||
<D:locktoken><D:href>${escapeXml(lock.token)}</D:href></D:locktoken>
|
||||
</D:activelock>`).join('');
|
||||
|
||||
return `<D:lockdiscovery>${lockEntries}
|
||||
</D:lockdiscovery>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate lock response
|
||||
*/
|
||||
export function generateLockResponse(lock: IWebDAVLock): string {
|
||||
return `<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:prop xmlns:D="${DAV_NAMESPACE}">
|
||||
${generateLockDiscovery([lock])}
|
||||
</D:prop>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate error response
|
||||
*/
|
||||
export function generateError(status: number, message: string): string {
|
||||
return `<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:error xmlns:D="${DAV_NAMESPACE}">
|
||||
<D:${message.toLowerCase().replace(/\s+/g, '-')}/>
|
||||
</D:error>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse PROPFIND request body to extract requested properties
|
||||
*/
|
||||
export function parsePropfindRequest(body: string): { allprop: boolean; propnames: string[] } {
|
||||
// Simple XML parsing for PROPFIND
|
||||
if (!body || body.includes('<allprop') || body.includes('<D:allprop')) {
|
||||
return { allprop: true, propnames: [] };
|
||||
}
|
||||
|
||||
if (body.includes('<propname') || body.includes('<D:propname')) {
|
||||
return { allprop: false, propnames: [] };
|
||||
}
|
||||
|
||||
// Extract property names
|
||||
const propnames: string[] = [];
|
||||
const propMatch = body.match(/<(?:D:)?prop[^>]*>([\s\S]*?)<\/(?:D:)?prop>/i);
|
||||
if (propMatch) {
|
||||
const propContent = propMatch[1];
|
||||
const tagMatches = propContent.matchAll(/<(?:D:)?(\w+)[^>]*\/?>/gi);
|
||||
for (const match of tagMatches) {
|
||||
propnames.push(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return { allprop: propnames.length === 0, propnames };
|
||||
}
|
||||
Reference in New Issue
Block a user