nephele
Version:
Highly customizable and extensible WebDAV server for Node.js and Express.
384 lines (338 loc) • 11.5 kB
text/typescript
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,
});
}
}