UNPKG

nephele

Version:

Highly customizable and extensible WebDAV server for Node.js and Express.

384 lines (338 loc) 11.5 kB
import type { Request } from 'express'; import type { AuthResponse, Resource } from '../Interfaces/index.js'; import { BadRequestError, ForbiddenError, LockedError, MediaTypeNotSupportedError, NotAcceptableError, PreconditionFailedError, ResourceNotFoundError, UnauthorizedError, } from '../Errors/index.js'; import { catchErrors } from '../catchErrors.js'; import { MultiStatus, Status } from '../MultiStatus.js'; import { Method } from './Method.js'; import { DELETE } from './DELETE.js'; export class COPY extends Method { async run(request: Request, response: AuthResponse) { const { url, encoding } = this.getRequestData(request, response); if ( await this.runPlugins(request, response, 'beginCopy', { method: this, url, }) ) { return; } if (await this.isAdapterRoot(request, response, url)) { // Can't copy the root of an adapter. throw new ForbiddenError('This collection cannot be copied.'); } await this.checkAuthorization(request, response, 'COPY'); const contentType = request.accepts('application/xml', 'text/xml'); if (!contentType) { throw new NotAcceptableError('Requested content type is not supported.'); } let destination = this.getRequestDestination(request); const depth = request.get('Depth') || 'infinity'; const overwrite = request.get('Overwrite'); const resource = await response.locals.adapter.getResource( url, response.locals.baseUrl, ); if ((await resource.isCollection()) && !url.toString().endsWith('/')) { response.set({ 'Content-Location': `${url}/`, }); } if ( await this.runPlugins(request, response, 'preCopy', { method: this, resource, destination, depth, overwrite, }) ) { return; } if (!destination) { throw new BadRequestError('Destination header is required.'); } if ( url .toString() .startsWith(destination.toString().replace(/\/?$/, () => '/')) ) { // Technically, there's nothing in the spec that says you can't do this, // but logically, it's impossible, since the spec says to recursively // delete everything at the destination first. throw new BadRequestError("Can't copy a resource to its own ancestor."); } // Check that the adapter at the destination is the same as the adapter at // the source. Can't copy to another adapter. if ( !(await this.pathsHaveSameAdapter( response, decodeURIComponent(request.path), decodeURIComponent( destination.pathname.substring(request.baseUrl.length), ), )) ) { throw new ForbiddenError( 'This resource cannot be copied to the destination.', ); } if (await resource.isCollection()) { destination = new URL(destination.toString().replace(/\/?$/, () => '/')); } if (!['0', 'infinity'].includes(depth)) { throw new BadRequestError( 'Depth header must be one of "0", or "infinity".', ); } let stream = await this.getBodyStream(request, response); let providedBody = false; stream.on('data', (data: Buffer) => { if (data.toString().trim()) { providedBody = true; } }); await new Promise<void>((resolve, _reject) => { stream.on('end', () => { resolve(); }); }); if (providedBody) { response.locals.debug('Provided body to COPY.'); throw new MediaTypeNotSupportedError( "This server doesn't understand the body sent in the request.", ); } await this.checkConditionalHeaders(request, response); let destResource: Resource; let destExists = true; try { destResource = await response.locals.adapter.getResource( destination, response.locals.baseUrl, ); } catch (e: any) { if (e instanceof ResourceNotFoundError) { destResource = await response.locals.adapter.newResource( destination, response.locals.baseUrl, ); destExists = false; } else { throw e; } } if ( await this.runPlugins(request, response, 'beforeCopy', { method: this, resource, destination: destResource, exists: destExists, depth, overwrite, }) ) { return; } response.set({ 'Cache-Control': 'private, no-cache', Date: new Date().toUTCString(), }); const multiStatus = new MultiStatus(); const recursivelyCopy = async ( resource: Resource, destination: URL, topLevel = true, ) => { const run = catchErrors( async () => { // Can't copy to another adapter. if ( !(await this.pathsHaveSameAdapter( response, decodeURIComponent( (await resource.getCanonicalUrl()).pathname.substring( request.baseUrl.length, ), ), decodeURIComponent( destination.pathname.substring(request.baseUrl.length), ).replace(/\/?$/, () => '/'), )) ) { throw new ForbiddenError( 'This resource cannot be copied to the destination.', ); } let destinationResource: Resource; let destinationExists = true; try { destinationResource = await response.locals.adapter.getResource( destination, response.locals.baseUrl, ); } catch (e: any) { if (e instanceof ResourceNotFoundError) { destinationResource = await response.locals.adapter.newResource( destination, response.locals.baseUrl, ); destinationExists = false; } else { throw e; } } // Check overwrite header. if (overwrite === 'F' && destinationExists) { throw new PreconditionFailedError( 'A resource exists at the destination.', ); } // Check permissions to write to destination. const collection = await resource.isCollection(); if ( !(await response.locals.adapter.isAuthorized( destination, collection ? 'MKCOL' : 'PUT', response.locals.baseUrl, response.locals.user, )) ) { throw new UnauthorizedError( 'The user is not authorized to modify the destination resource.', ); } if (topLevel) { const lockPermission = await this.getLockPermission( request, response, destinationResource, response.locals.user, ); // Check that the resource wouldn't be added to a locked collection. if (lockPermission === 1) { throw new LockedError( 'The user does not have permission to add a new resource to the locked collection.', ); } if (lockPermission === 0) { throw new LockedError( 'The user does not have permission to modify the locked resource.', ); } } if (destinationExists) { if (topLevel && (await destinationResource.isCollection())) { // According to the spec, if the destination is an existing // collection, its contents mustn't be merged, but instead, // replaced by the source resource. In order to comply, we're just // going to delete all the destination's contents. const del = new DELETE(this.opts); const childrenDeleted = await del.recursivelyDelete( destinationResource, request, response, multiStatus, ); if (childrenDeleted) { await destinationResource.delete(response.locals.user); } } else { // If we get here, it either means the resource is locked, // undeletable, or contains something that's undeletable. const lockPermission = await this.getLockPermission( request, response, destinationResource, response.locals.user, ); if (lockPermission !== 2) { // It is locked, so we can't continue into this resource tree. // Technically, a depth "0" lock on a collection only locks the // internal members of the collection, not all members, but // merging members below its internal members would a. be very // complicated, and b. probably not result in what a user would // expect or want. return; } } } await resource.copy( destination, response.locals.baseUrl, response.locals.user, ); if (depth === 'infinity' && collection) { try { const children = await resource.getInternalMembers( response.locals.user, ); for (let child of children) { const name = await child.getCanonicalName(); const destinationUrl = new URL( destination.toString().replace(/\/?$/, () => '/') + encodeURIComponent(name) + ((await child.isCollection()) ? '/' : ''), `${destination.protocol}//${destination.host}`, ); await recursivelyCopy(child, destinationUrl, false); } } catch (e: any) { if (e instanceof UnauthorizedError) { // Silently fail to copy unlistable members. return; } throw e; } } return destinationExists; }, async (code, message, error) => { if (code === 500 && error) { response.locals.debug('Unknown Error: %o', error); } let status = new Status(destination, code); if (message) { status.description = message; } response.locals.errors.push(status); multiStatus.addStatus(status); }, ); return await run(); }; const existed = await recursivelyCopy(resource, destination); if (multiStatus.statuses.length === 0) { response.status(existed ? 204 : 201); // No Content : Created if (!existed) { response.set({ Location: destination.toString(), }); } response.end(); } else { const responseXml = await this.renderXml(multiStatus.render()); response.status(207); // Multi-Status response.set({ 'Content-Type': `${contentType}; charset=utf-8`, }); this.sendBodyContent(response, responseXml, encoding); } await this.runPlugins(request, response, 'afterCopy', { method: this, resource, destination: destResource, exists: destExists, depth, overwrite, }); } }