nephele
Version:
Highly customizable and extensible WebDAV server for Node.js and Express.
1,539 lines (1,373 loc) • 44.7 kB
text/typescript
import zlib from 'node:zlib';
import { pipeline, Readable } from 'node:stream';
import path from 'node:path';
import type { Request } from 'express';
import * as xml2js from 'xml2js';
import contentType from 'content-type';
import { splitn } from '@sciactive/splitn';
import vary from 'vary';
import type {
AuthResponse,
Lock,
PluginEvent,
Resource,
User,
} from '../Interfaces/index.js';
import {
BadRequestError,
EncodingNotSupportedError,
MediaTypeNotSupportedError,
MethodNotSupportedError,
PreconditionFailedError,
ResourceNotFoundError,
ResourceNotModifiedError,
UnauthorizedError,
} from '../Errors/index.js';
import type { Options } from '../Options.js';
import { getAdapter, _getAdapter } from '../Options.js';
// The following regexes are used in parsing the If header.
// This regex matches a resource: </resource>
const matchResource = /^<.+?>\s*/;
// This regex matches a list of conditions: (<urn:uuid:some-uuid> ["etag"] ["etagwith(parens)"])
const matchList = /^\([^\)]+?(?:"[^"]+"[^\)]*?)*\)\s*/;
// This regex matches the Not keyword of a condition: Not "etag"
const matchNot = /^Not\s*/;
// This regex matches the no-lock condition: <DAV:no-lock>
const matchNolock = /^<DAV:no-lock>\s*/;
// This regex matches a token condition: <urn:uuid:some-uuid>
// Note that it will also match a no-lock condition, so check no-lock first.
const matchToken = /^<[^>]+>\s*/;
// This regex matches an etag condition: ["etag"]
const matchEtag = /^\[(?:W\/)?"[^"]+"\]\s*/;
type IfHeaderList = {
tokens: string[];
etags: string[];
nolock: boolean;
notTokens: string[];
notEtags: string[];
notNolock: boolean;
};
export class Method {
opts: Options;
DEV = process.env.NODE_ENV !== 'production';
xmlParser = new xml2js.Parser({
xmlns: true,
});
xmlBuilder = new xml2js.Builder({
xmldec: { version: '1.0', encoding: 'UTF-8' },
...(this.DEV
? {
renderOpts: {
pretty: true,
},
}
: {
renderOpts: {
indent: '',
newline: '',
pretty: false,
},
}),
});
constructor(opts: Options) {
this.opts = opts;
}
/**
* You can use this to run plugins for an event. If this returns true, the
* response has been ended by a plugin.
*/
async runPlugins(
request: Request,
response: AuthResponse,
event: PluginEvent,
data: any = {},
) {
let ended = false;
for (let plugin of response.locals.plugins) {
if (event in plugin) {
const fn = plugin[event];
if (fn) {
const result = await fn.bind(plugin)(request, response, data);
if (result === false) {
ended = true;
}
}
}
}
return ended;
}
/**
* You should reimplement this function in your class to handle the method.
*/
async run(request: Request, _response: AuthResponse) {
throw new MethodNotSupportedError(
`${request.method} is not supported on this server.`,
);
}
/**
* Check that the user is authorized to run the method.
*
* @param method This will be pulled from the request if not provided.
* @param url This will be pulled from the request if not provided.
*/
async checkAuthorization(
request: Request,
response: AuthResponse,
method?: string,
url?: URL,
) {
await this.runPlugins(request, response, 'beforeCheckAuthorization', {
method: this,
methodName: method,
url,
});
// If the adapter says it can handle the method, just handle the
// authorization and error handling for it.
if (
!(await response.locals.adapter.isAuthorized(
url ||
new URL(
request.originalUrl,
`${request.protocol}://${request.headers.host}`,
),
method || request.method,
response.locals.baseUrl,
response.locals.user,
))
) {
throw new UnauthorizedError('Unauthorized.');
}
await this.runPlugins(request, response, 'afterCheckAuthorization', {
method: this,
methodName: method,
url,
});
}
async getAdapter(
request: Request,
response: AuthResponse,
unencodedPath: string,
) {
const { adapter } = await getAdapter(
unencodedPath.replace(/\/?$/, () => '/'),
response.locals.adapterConfig,
{ request, response },
);
return adapter;
}
async getAdapterBaseUrl(response: AuthResponse, unencodedPath: string) {
const { baseUrl } = _getAdapter(
unencodedPath.replace(/\/?$/, () => '/'),
response.locals.adapterConfig,
);
return baseUrl;
}
async pathsHaveSameAdapter(
response: AuthResponse,
unencodedPathA: string,
unencodedPathB: string,
) {
return (
(await this.getAdapterBaseUrl(response, unencodedPathA)) ===
(await this.getAdapterBaseUrl(response, unencodedPathB))
);
}
/**
* Determine if a URL is for the root resource of an adapter.
*/
async isAdapterRoot(request: Request, response: AuthResponse, url: URL) {
if (
url.pathname.replace(/\/?$/, () => '/') ===
request.baseUrl.replace(/\/?$/, () => '/')
) {
return true;
}
const resourceAdapter = _getAdapter(
decodeURIComponent(
new URL(url.toString().replace(/\/?$/, () => '/')).pathname.substring(
request.baseUrl.length,
),
),
response.locals.adapterConfig,
).adapter;
const parentAdapter = _getAdapter(
decodeURIComponent(
path
.dirname(
new URL(
url.toString().replace(/\/?$/, () => '/'),
).pathname.substring(request.baseUrl.length),
)
.replace(/\/?$/, () => '/'),
),
response.locals.adapterConfig,
).adapter;
return resourceAdapter !== parentAdapter;
}
/**
* Return the collection of which the given resource is an internal member.
*
* Returns `undefined` if the resource is the root of the entire Nephele
* WebDAV server.
*
* Note that the resource returned from this function may exist on a different
* adapter than the resource given to it, and thus the resource returned may
* not include the resource given in `getInternalMembers`.
*/
async getParentResource(
request: Request,
response: AuthResponse,
resource: Resource,
) {
const url = await resource.getCanonicalUrl();
if (url.pathname === '/' || url.pathname === request.baseUrl) {
return undefined;
}
const parentPath = decodeURIComponent(path.dirname(url.pathname));
const { adapter: rawParentAdapter, baseUrl: parentBaseUrl } =
await getAdapter(
parentPath.replace(/\/?$/, () => '/'),
response.locals.adapterConfig,
{ request, response },
);
const splitPath = url.pathname.replace(/\/?$/, '').split('/');
const newPath = splitPath
.slice(0, -1)
.join('/')
.replace(/\/?$/, () => '/');
if (!newPath.startsWith(request.baseUrl.replace(/\/?$/, () => '/'))) {
// If the new path is outside of the server's basepath, return undefined.
return undefined;
}
const parentAdapter =
parentBaseUrl === response.locals.baseUrl.pathname
? response.locals.adapter
: rawParentAdapter;
return await parentAdapter.getResource(
new URL(newPath, `${request.protocol}://${request.headers.host}`),
new URL(
path.join(request.baseUrl || '/', parentBaseUrl),
`${request.protocol}://${request.headers.host}`,
),
);
}
async removeAndDeleteTimedOutLocks(locks: Lock[]) {
const currentLocks: Lock[] = [];
for (let lock of locks) {
if (lock.date.getTime() + lock.timeout <= new Date().getTime()) {
try {
await lock.delete();
} catch (e: any) {
// Ignore errors deleting timed out locks.
}
} else {
currentLocks.push(lock);
}
}
return currentLocks;
}
async getCurrentResourceLocks(resource: Resource) {
const locks = await resource.getLocks();
return await this.removeAndDeleteTimedOutLocks(locks);
}
async getCurrentResourceLocksByUser(resource: Resource, user: User) {
const locks = await resource.getLocksByUser(user);
return await this.removeAndDeleteTimedOutLocks(locks);
}
private async getLocksGeneral(
request: Request,
response: AuthResponse,
resource: Resource,
getLocks: (resource: Resource) => Promise<Lock[]>,
) {
const resourceLocks = await getLocks(resource);
const locks: {
all: Lock[];
resource: Lock[];
depthZero: Lock[];
depthInfinity: Lock[];
} = {
all: [...resourceLocks],
resource: resourceLocks,
depthZero: [],
depthInfinity: [],
};
let parent = await this.getParentResource(request, response, resource);
let firstLevelParent = true;
while (parent) {
const parentLocks = await getLocks(parent);
for (let lock of parentLocks) {
if (lock.depth === 'infinity') {
locks.depthInfinity.push(lock);
locks.all.push(lock);
} else if (firstLevelParent && lock.depth === '0') {
locks.depthZero.push(lock);
locks.all.push(lock);
}
}
parent = await this.getParentResource(request, response, parent);
firstLevelParent = false;
}
return locks;
}
async getLocks(request: Request, response: AuthResponse, resource: Resource) {
return await this.getLocksGeneral(
request,
response,
resource,
async (resource: Resource) =>
(await this.getCurrentResourceLocks(resource)).filter(
(lock) => !lock.provisional,
),
);
}
async getLocksByUser(
request: Request,
response: AuthResponse,
resource: Resource,
user: User,
) {
return await this.getLocksGeneral(
request,
response,
resource,
async (resource: Resource) =>
(await this.getCurrentResourceLocksByUser(resource, user)).filter(
(lock) => !lock.provisional,
),
);
}
async getProvisionalLocks(
request: Request,
response: AuthResponse,
resource: Resource,
) {
return await this.getLocksGeneral(
request,
response,
resource,
async (resource: Resource) =>
(await this.getCurrentResourceLocks(resource)).filter(
(lock) => lock.provisional,
),
);
}
/**
* Check if the user has permission to modify the resource, taking into
* account the set of locks they have submitted.
*
* Returns 0 if the user has no permissions to modify this resource or any
* resource this one may contain. (Directly locked or depth infinity locked.)
*
* Returns 1 if this resource is within a collection and the user has no
* permission to modify the mapping of the internal members of the collection,
* but it can modify the contents of members. This means the user cannot
* create, move, or delete the resource, but can change its contents. (Depth 0
* locked.)
*
* Returns 2 if the user has full permissions to modify this resource (either
* it is not locked or the user owns the lock and has provided it).
*
* Returns 3 if the user does not have full permission to modify this
* resource, but does have permission to lock it with a shared lock. This is
* only returned if `request.method === 'LOCK'`.
*
* @param request The request to check the lock permission for.
* @param resource The resource to check.
* @param user The user to check.
*/
async getLockPermission(
request: Request,
response: AuthResponse,
resource: Resource,
user: User,
): Promise<0 | 1 | 2 | 3> {
const locks = await this.getLocks(request, response, resource);
const lockTokens = this.getRequestLockTockens(request);
if (!locks.all.length) {
return 2;
}
const userLocks = await this.getLocksByUser(
request,
response,
resource,
user,
);
const lockTokenSet = new Set(lockTokens);
if (userLocks.all.find((userLock) => lockTokenSet.has(userLock.token))) {
// The user owns the lock and has submitted it.
return 2;
}
if (request.method === 'LOCK') {
let code: 0 | 3 = 0;
for (let lock of locks.resource) {
if (lock.scope === 'exclusive') {
return 0;
} else if (lock.scope === 'shared') {
code = 3;
}
}
for (let lock of locks.depthInfinity) {
if (lock.scope === 'exclusive') {
return 0;
} else if (lock.scope === 'shared') {
code = 3;
}
}
for (let lock of locks.depthZero) {
if (lock.scope === 'exclusive') {
return 1;
} else if (lock.scope === 'shared') {
code = 3;
}
}
return code;
} else {
if (locks.depthInfinity.length || locks.resource.length) {
return 0;
}
if (locks.depthZero.length) {
return 1;
}
return 0;
}
}
/**
* Extract the submitted lock tokens.
*
* Note that this is different than checking the conditional "If" header. That
* must be done separately from checking submitted lock tokens.
*/
getRequestLockTockens(request: Request) {
const lockTokens: string[] = [];
const ifHeader = request.get('If') || '';
const matches = ifHeader.match(
/<urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}>/g,
);
if (matches) {
for (let match of matches) {
lockTokens.push(match.slice(1, -1));
}
}
return lockTokens;
}
/**
* Parse and check the If header against existing resources.
*/
private async checkIfHeader(request: Request, response: AuthResponse) {
let ifHeader = request.get('If')?.trim().replace(/\n/g, ' ');
if (ifHeader == null) {
return;
}
if (ifHeader === '') {
throw new BadRequestError(
'The If header, if provided, must not be empty.',
);
}
const requestURL = this.getRequestUrl(request);
// Parse the If header into a usable object.
const parsedHeader: {
[resourceUri: string]: IfHeaderList[];
} = {};
let currentResource = requestURL.toString();
const startedWithResource = ifHeader.startsWith('<');
while (ifHeader.length) {
const resourceMatch = ifHeader.match(matchResource);
const listMatch = ifHeader.match(matchList);
if (resourceMatch) {
if (!startedWithResource) {
throw new BadRequestError(
'Tagged-lists and no-tag-lists must not be mixed in the If header.',
);
}
const resource = resourceMatch[0].trim();
currentResource = resource.slice(1, -1);
if (currentResource.match(/(?:^\/)\.\.?(?:$|\/)/)) {
throw new BadRequestError(
'Resource URIs in the If header must not contain dot segments.',
);
}
ifHeader = ifHeader.replace(matchResource, '');
} else if (listMatch) {
let list = listMatch[0].trim().slice(1, -1).trim();
const listObj: IfHeaderList = {
tokens: [],
etags: [],
nolock: false,
notTokens: [],
notEtags: [],
notNolock: false,
};
if (list === '') {
throw new BadRequestError(
'All lists in the If header must have at least one condition.',
);
}
while (list.length) {
const notMatch = list.match(matchNot);
if (notMatch) {
list = list.replace(matchNot, '');
}
const nolockMatch = list.match(matchNolock);
const tokenMatch = list.match(matchToken);
const etagMatch = list.match(matchEtag);
if (nolockMatch) {
if (notMatch) {
listObj.notNolock = true;
} else {
listObj.nolock = true;
}
list = list.replace(matchNolock, '');
} else if (tokenMatch) {
let token = tokenMatch[0].trim().slice(1, -1);
if (notMatch) {
listObj.notTokens.push(token);
} else {
listObj.tokens.push(token);
}
list = list.replace(matchToken, '');
} else if (etagMatch) {
let etag = etagMatch[0]
.trim()
.replace(/^\[(?:W\/)?"/, '')
.slice(0, -2);
if (notMatch) {
listObj.notEtags.push(etag);
} else {
listObj.etags.push(etag);
}
list = list.replace(matchEtag, '');
} else {
// Unparseable header.
throw new BadRequestError(
"The server doesn't recognize the submitted If header.",
);
}
}
if (!parsedHeader[currentResource]) {
parsedHeader[currentResource] = [];
}
parsedHeader[currentResource].push(listObj);
ifHeader = ifHeader.replace(matchList, '');
} else {
// Unparseable header.
throw new BadRequestError(
"The server doesn't recognize the submitted If header.",
);
}
}
if (Object.keys(parsedHeader).length === 0) {
throw new BadRequestError(
'The If header, if provided, must contain at least one list with a condition.',
);
}
// Now evaluate the parsed header and check for a single list that passes.
// The spec states that the entire header evaluates to true if a single list
// production evaluates to true.
for (let [resourceUri, lists] of Object.entries(parsedHeader)) {
const url = new URL(resourceUri, requestURL);
let etag = '';
let tokens: string[] = [];
const [needEtag, needTokens] = lists.reduce(
([needEtag, needTokens], list) => [
!!(needEtag || list.etags.length || list.notEtags.length),
!!(needTokens || list.tokens.length || list.notTokens.length),
],
[false, false],
);
if (needEtag || needTokens) {
try {
await this.checkAuthorization(request, response, 'GET', url);
const { adapter: newAdapter, baseUrl } = await getAdapter(
decodeURIComponent(url.pathname).replace(/\/?$/, () => '/'),
response.locals.adapterConfig,
{ request, response },
);
const adapter =
baseUrl === response.locals.baseUrl.pathname
? response.locals.adapter
: newAdapter;
const resource = await adapter.getResource(
url,
new URL(
`${request.protocol}://${request.headers.host}${path.join(
request.baseUrl || '/',
baseUrl,
)}`,
),
);
if (needEtag) {
etag = await resource.getEtag();
}
if (needTokens) {
tokens = (await this.getLocks(request, response, resource)).all.map(
(lock) => lock.token,
);
}
} catch (e: any) {
if (e instanceof UnauthorizedError) {
throw new PreconditionFailedError('If header check failed.');
}
if (!(e instanceof ResourceNotFoundError)) {
throw e;
}
}
}
listLoop: for (let list of lists) {
// For each list, all conditions in the list must evaluate to true for
// that list to evaluate to true.
if (list.nolock) {
// No resoure can be locked with <DAV:no-lock>, so this list evaluates
// to false.
continue;
}
for (let curEtag of list.etags) {
if (etag === '' || etag !== curEtag) {
continue listLoop;
}
}
for (let curEtag of list.notEtags) {
if (etag !== '' && etag === curEtag) {
continue listLoop;
}
}
for (let curToken of list.tokens) {
if (!tokens.includes(curToken)) {
continue listLoop;
}
}
for (let curToken of list.notTokens) {
if (tokens.includes(curToken)) {
continue listLoop;
}
}
// If we reached here, it either means all the checked conditions
// evaluated to true, or there was just a "Not <DAV:no-lock>" condition.
return;
}
}
throw new PreconditionFailedError('If header check failed.');
}
async checkConditionalHeaders(request: Request, response: AuthResponse) {
const requestURL = this.getRequestUrl(request);
let resource: Resource;
let newResource = false;
try {
resource = await response.locals.adapter.getResource(
requestURL,
response.locals.baseUrl,
);
} catch (e: any) {
if (e instanceof ResourceNotFoundError) {
resource = await response.locals.adapter.newResource(
requestURL,
response.locals.baseUrl,
);
newResource = true;
} else {
throw e;
}
}
const ifMatch = request.get('If-Match')?.trim();
const ifMatchEtags = (ifMatch || '').split(',').map((value) =>
value
.trim()
.replace(/^(?:W\/)?["']/, '')
.replace(/["']$/, ''),
);
const ifNoneMatch = request.get('If-None-Match')?.trim();
const ifNoneMatchEtags = (ifNoneMatch || '').split(',').map((value) =>
value
.trim()
.replace(/^(?:W\/)?["']/, '')
.replace(/["']$/, ''),
);
const ifUnmodifiedSince = request.get('If-Unmodified-Since')?.trim();
const ifModifiedSince = request.get('If-Modified-Since')?.trim();
let etag = '';
let lastModified = new Date(0);
if (!newResource) {
const properties = await resource.getProperties();
etag = await resource.getEtag();
const lastModifiedString = await properties.get('getlastmodified');
if (typeof lastModifiedString !== 'string') {
throw new Error('Last modified date property is not a string.');
}
lastModified = new Date(lastModifiedString);
}
// Check if header for etag. If it's a new resource, any etag should fail.
if (
ifMatch != null &&
((ifMatch === '*' && newResource) ||
(ifMatch !== '*' && (etag === '' || !ifMatchEtags.includes(etag))))
) {
throw new PreconditionFailedError('If-Match header check failed.');
}
// Check if header for modified date. If it's a new resource, any unmodified
// date in the past should fail.
if (
ifUnmodifiedSince != null &&
new Date(ifUnmodifiedSince) < lastModified
) {
throw new PreconditionFailedError(
'If-Unmodified-Since header check failed.',
);
}
let mustIgnoreIfModifiedSince = false;
if (ifNoneMatch != null) {
// Check the request header for the etag.
if (
(ifNoneMatch === '*' && !newResource) ||
(ifNoneMatch !== '*' && etag !== '' && ifNoneMatchEtags.includes(etag))
) {
if (request.method === 'GET' || request.method === 'HEAD') {
const cacheControl = this.getCacheControl(request);
if (!cacheControl['no-cache'] && cacheControl['max-age'] !== 0) {
throw new ResourceNotModifiedError(
newResource ? undefined : etag,
newResource ? undefined : lastModified,
);
}
} else {
throw new PreconditionFailedError(
'If-None-Match header check failed.',
);
}
} else {
mustIgnoreIfModifiedSince = true;
}
}
// Check the request header for the modified date.
// According to the spec, the server must ignore If-Modified-Since if none
// of the etags in If-None-Match match.
// According to the spec, If-Modified-Since can only be used with GET and
// HEAD.
if (
!mustIgnoreIfModifiedSince &&
(request.method === 'GET' || request.method === 'HEAD') &&
ifModifiedSince != null &&
new Date(ifModifiedSince) >= lastModified
) {
const cacheControl = this.getCacheControl(request);
if (!cacheControl['no-cache'] && cacheControl['max-age'] !== 0) {
throw new ResourceNotModifiedError(
newResource ? undefined : etag,
newResource ? undefined : lastModified,
);
}
}
// TODO: This seems to cause issues with existing clients.
// if (
// request.method === 'PUT' &&
// ifMatch == null &&
// ifUnmodifiedSince == null
// ) {
// // Require that PUT for an existing resource is conditional.
// // 428 Precondition Required
// throw new PreconditionRequiredError(
// 'Overwriting existing resource requires the use of a conditional header, If-Match or If-Unmodified-Since.'
// );
// }
await this.checkIfHeader(request, response);
}
getRequestUrl(request: Request) {
return new URL(
request.originalUrl,
`${request.protocol}://${request.headers.host}`,
);
}
getRequestedEncoding(request: Request, response: AuthResponse) {
const acceptEncoding =
request.get('Accept-Encoding') || 'identity, *;q=0.5';
const supported = ['gzip', 'deflate', 'br', 'identity'];
const encodings: [string, number][] = acceptEncoding
.split(',')
.map((value) => value.trim().split(';'))
.map((value) => [
value[0],
parseFloat(value[1]?.replace(/^q=/, '') || '1.0'),
]);
encodings.sort((a, b) => b[1] - a[1]);
let encoding = '';
while (![...supported, 'x-gzip', '*'].includes(encoding)) {
if (!encodings.length) {
throw new EncodingNotSupportedError(
'Requested content encoding is not supported.',
);
}
encoding = encodings.splice(0, 1)[0][0];
}
if (encoding === '*') {
// Pick the first encoding that's not listed in the header.
encoding =
supported.find(
(check) => encodings.find(([check2]) => check === check2) == null,
) || 'gzip';
}
response.locals.debug(`Requested encoding: ${encoding}.`);
return encoding as 'gzip' | 'x-gzip' | 'deflate' | 'br' | 'identity';
}
getCacheControl(request: Request) {
const cacheControlHeader = request.get('Cache-Control') || '*';
const cacheControl: { [k: string]: number | true } = {};
cacheControlHeader.split(',').forEach((directive) => {
if (
directive.startsWith('max-age=') ||
directive.startsWith('s-maxage=') ||
directive.startsWith('stale-while-revalidate=') ||
directive.startsWith('stale-if-error=') ||
directive.startsWith('max-stale=') ||
directive.startsWith('min-fresh=')
) {
const [name, value] = directive.split('=');
cacheControl[name] = parseInt(value);
} else {
cacheControl[directive] = true;
}
});
return cacheControl;
}
getRequestData(request: Request, response: AuthResponse) {
const url = this.getRequestUrl(request);
const encoding = this.getRequestedEncoding(request, response);
const cacheControl = this.getCacheControl(request);
return { url, encoding, cacheControl };
}
getRequestDestination(request: Request) {
const destinationHeader = request.get('Destination');
let destination: URL | undefined = undefined;
if (destinationHeader != null) {
if (destinationHeader.match(/(?:^\/)\.\.?(?:$|\/)/)) {
throw new BadRequestError(
'Destination header must not contain dot segments.',
);
}
try {
destination = new URL(
destinationHeader,
new URL(
request.originalUrl,
`${request.protocol}://${request.headers.host}`,
),
);
} catch (e: any) {
throw new BadRequestError('Destination header must be a valid URI.');
}
}
return destination;
}
async getBodyStream(request: Request, response: AuthResponse) {
if (request.get('Content-Length') === '0') {
return Readable.from(Buffer.from([]));
}
response.locals.debug('Getting body stream.');
let stream: Readable = request;
let encoding = request.get('Content-Encoding');
switch (encoding) {
case 'gzip':
case 'x-gzip':
stream = pipeline(request, zlib.createGunzip(), (e: any) => {
if (e) {
throw new Error('Compression pipeline failed: ' + e);
}
});
break;
case 'deflate':
stream = pipeline(request, zlib.createInflate(), (e: any) => {
if (e) {
throw new Error('Compression pipeline failed: ' + e);
}
});
break;
case 'br':
stream = pipeline(request, zlib.createBrotliDecompress(), (e: any) => {
if (e) {
throw new Error('Compression pipeline failed: ' + e);
}
});
break;
case 'identity':
break;
default:
if (encoding != null) {
throw new MediaTypeNotSupportedError(
'Provided content encoding is not supported.',
);
}
break;
}
return stream;
}
async sendBodyContent(
response: AuthResponse,
content: string,
encoding: 'gzip' | 'x-gzip' | 'deflate' | 'br' | 'identity',
) {
vary(response, 'Accept-Encoding');
// First, check cache-control.
const cacheControl = response.getHeader('Cache-Control');
const noTransform =
typeof cacheControl === 'string' &&
cacheControl.match(/(?:^|,)\s*?no-transform\s*?(?:,|$)/);
if (!this.opts.compression || encoding === 'identity' || noTransform) {
response.locals.debug(`Response encoding: identity`);
const unencodedContent = Buffer.from(content, 'utf-8');
response.set({
'Content-Length': unencodedContent.byteLength,
});
response.send(unencodedContent);
} else {
response.locals.debug(`Response encoding: ${encoding}`);
let transform: (content: Buffer) => Buffer = (content) => content;
switch (encoding) {
case 'gzip':
case 'x-gzip':
transform = (content) => zlib.gzipSync(content);
break;
case 'deflate':
transform = (content) => zlib.deflateSync(content);
break;
case 'br':
transform = (content) => zlib.brotliCompressSync(content);
break;
}
const unencodedContent = Buffer.from(content, 'utf-8');
const encodedContent = transform(unencodedContent);
response.set({
'Content-Encoding': encoding,
'Content-Length': encodedContent.byteLength,
});
response.send(encodedContent);
}
}
/**
* Get the body of the request as an XML object from xml2js.
*
* If you call this function, it means that anything other than XML in the
* body is an error.
*
* If the body is empty, it will return null.
*/
async getBodyXML(request: Request, response: AuthResponse) {
const stream = await this.getBodyStream(request, response);
const contentTypeHeader = request.get('Content-Type');
const contentLengthHeader = request.get('Content-Length');
const transferEncoding = request.get('Transfer-Encoding');
if (transferEncoding === 'chunked') {
// TODO: transfer-encoding chunked.
response.locals.debug('Request transfer encoding is chunked.');
}
if (contentTypeHeader == null && contentLengthHeader === '0') {
return null;
}
// Be nice to clients who don't send a Content-Type header.
const requestType = contentType.parse(
contentTypeHeader || 'application/xml',
);
if (
requestType.type !== 'text/xml' &&
requestType.type !== 'application/xml'
) {
throw new MediaTypeNotSupportedError(
'Provided content type is not supported.',
);
}
if (
![
'ascii',
'utf8',
'utf-8',
'utf16le',
'ucs2',
'ucs-2',
'base64',
'base64url',
'latin1',
'binary',
'hex',
].includes(requestType?.parameters?.charset || 'utf-8')
) {
throw new MediaTypeNotSupportedError(
'Provided content charset is not supported.',
);
}
const encoding: BufferEncoding = (requestType?.parameters?.charset ||
'utf-8') as BufferEncoding;
let xml = await new Promise<string>((resolve, reject) => {
const buffers: Buffer[] = [];
stream.on('data', (chunk: Buffer) => {
buffers.push(chunk);
});
stream.on('end', () => {
resolve(Buffer.concat(buffers).toString(encoding));
});
stream.on('error', (e: any) => {
reject(e);
});
});
if (xml.trim() === '') {
return null;
}
return xml;
}
/**
* Parse XML into a form that uses the DAV: namespace.
*
* Tags and attributes from other namespaces will have their namespace and the
* string '%%' prepended to their name.
*/
async parseXml(xml: string) {
let parsed = await this.xmlParser.parseStringPromise(xml);
let prefixes: { [k: string]: string } = {};
const rewriteAttributes = (
input: {
[k: string]: {
name: string;
value: string;
prefix: string;
local: string;
uri: string;
};
},
namespace: string,
): any => {
const output: { [k: string]: string } = {};
for (let name in input) {
if (
input[name].uri === 'http://www.w3.org/2000/xmlns/' ||
input[name].uri === 'http://www.w3.org/XML/1998/namespace'
) {
output[name] = input[name].value;
} else if (
input[name].uri === 'DAV:' ||
(input[name].uri === '' && namespace === 'DAV:')
) {
output[input[name].local] = input[name].value;
} else {
output[`${input[name].uri || namespace}%%${input[name].local}`] =
input[name].value;
}
}
return output;
};
const extractNamespaces = (input: {
[k: string]: {
name: string;
value: string;
prefix: string;
local: string;
uri: string;
};
}) => {
const output: { [k: string]: string } = {};
for (let name in input) {
if (
input[name].uri === 'http://www.w3.org/2000/xmlns/' &&
input[name].local !== '' &&
input[name].value !== 'DAV:'
) {
output[input[name].local] = input[name].value;
}
}
return output;
};
const recursivelyRewrite = (
input: any,
lang?: string,
element = '',
prefix: string = '',
namespaces: { [k: string]: string } = {},
includeLang = false,
): any => {
if (Array.isArray(input)) {
return input.map((value) =>
recursivelyRewrite(
value,
lang,
element,
prefix,
namespaces,
includeLang,
),
);
} else if (typeof input === 'object') {
const output: { [k: string]: any } = {};
// Remember the xml:lang attribute, as required by spec.
let curLang = lang;
let curNamespaces = { ...namespaces };
if ('$' in input) {
if ('xml:lang' in input.$) {
curLang = input.$['xml:lang'].value as string;
}
output.$ = rewriteAttributes(input.$, input.$ns.uri);
curNamespaces = {
...curNamespaces,
...extractNamespaces(input.$),
};
}
if (curLang != null && includeLang) {
output.$ = output.$ || {};
output.$['xml:lang'] = curLang;
}
if (element.includes('%%') && prefix !== '') {
const uri = element.split('%%', 1)[0];
if (prefix in curNamespaces && curNamespaces[prefix] === uri) {
output.$ = output.$ || {};
output.$[`xmlns:${prefix}`] = curNamespaces[prefix];
}
}
for (let name in input) {
if (name === '$ns' || name === '$') {
continue;
}
const ns = (Array.isArray(input[name])
? input[name][0].$ns
: input[name].$ns) || { local: name, uri: 'DAV:' };
let prefix = '';
if (name.includes(':')) {
prefix = name.split(':', 1)[0];
if (!(prefix in prefixes)) {
prefixes[prefix] = ns.uri;
}
}
const el = ns.uri === 'DAV:' ? ns.local : `${ns.uri}%%${ns.local}`;
output[el] = recursivelyRewrite(
input[name],
curLang,
el,
prefix,
curNamespaces,
element === 'prop',
);
}
return output;
} else {
return input;
}
};
const output = recursivelyRewrite(parsed);
return { output, prefixes };
}
/**
* Render XML that's in the form returned by `parseXml`.
*/
async renderXml(xml: any, prefixes: { [k: string]: string } = {}) {
let topLevelObject: { [k: string]: any } | undefined = undefined;
const prefixEntries = Object.entries(prefixes);
const davPrefix = (prefixEntries.find(
([_prefix, value]) => value === 'DAV:',
) || ['', 'DAV:'])[0];
const recursivelyRewrite = (
input: any,
namespacePrefixes: { [k: string]: string } = {},
element = '',
currentUri = 'DAV:',
addNamespace?: string,
): any => {
if (Array.isArray(input)) {
return input.map((value) =>
recursivelyRewrite(
value,
namespacePrefixes,
element,
currentUri,
addNamespace,
),
);
} else if (typeof input === 'object') {
const output: { [k: string]: any } =
element === ''
? {}
: {
$: {
...(addNamespace == null ? {} : { xmlns: addNamespace }),
},
};
const curNamespacePrefixes = { ...namespacePrefixes };
if ('$' in input) {
for (let attr in input.$) {
// Translate uri%%name attributes to prefix:name.
if (
attr.includes('%%') ||
(currentUri !== 'DAV:' && !attr.includes(':') && attr !== 'xmlns')
) {
const [uri, name] = attr.includes('%%')
? splitn(attr, '%%', 2)
: ['DAV:', attr];
if (currentUri === uri) {
output.$[name] = input.$[attr];
} else {
const xmlns = Object.entries(input.$).find(
([name, value]) => name.startsWith('xmlns:') && value === uri,
);
if (xmlns) {
const [_dec, prefix] = splitn(xmlns[0], ':', 2);
output.$[`${prefix}:${name}`] = input.$[attr];
} else {
const prefixEntry = Object.entries(curNamespacePrefixes).find(
([_prefix, value]) => value === uri,
);
output.$[
`${prefixEntry ? prefixEntry[0] + ':' : ''}${name}`
] = input.$[attr];
}
}
} else {
if (attr.startsWith('xmlns:')) {
// Remove excess namespace declarations.
if (curNamespacePrefixes[attr.substring(6)] === input.$[attr]) {
continue;
}
curNamespacePrefixes[attr.substring(6)] = input.$[attr];
}
output.$[attr] = input.$[attr];
}
}
}
const curNamespacePrefixEntries = Object.entries(curNamespacePrefixes);
for (let name in input) {
if (name === '$') {
continue;
}
let el = name;
let prefix = davPrefix;
let namespaceToAdd: string | undefined = undefined;
let uri = 'DAV:';
let local = el;
if (name.includes('%%')) {
[uri, local] = splitn(name, '%%', 2);
// Reset prefix because we're not in the DAV: namespace.
prefix = '';
// Look for a prefix in the current prefixes.
const curPrefixEntry = curNamespacePrefixEntries.find(
([_prefix, value]) => value === uri,
);
if (curPrefixEntry) {
prefix = curPrefixEntry[0];
}
// Look for a prefix in the children. It should override the current
// prefix.
const child = Array.isArray(input[name])
? input[name][0]
: input[name];
if (typeof child === 'object' && '$' in child) {
let foundPrefix = '';
for (let attr in child.$) {
if (attr.startsWith('xmlns:') && child.$[attr] === uri) {
foundPrefix = attr.substring(6);
break;
}
}
// Make sure every child has the same prefix.
if (foundPrefix) {
if (Array.isArray(input[name])) {
let prefixIsGood = true;
for (let child of input[name]) {
if (
typeof child !== 'object' ||
!('$' in child) ||
child.$[`xmlns:${foundPrefix}`] !== uri
) {
prefixIsGood = false;
break;
}
}
if (prefixIsGood) {
prefix = foundPrefix;
}
} else {
prefix = foundPrefix;
}
}
}
if (prefix) {
el = `${prefix}:${local}`;
} else {
// If we haven't found a prefix at all, we need to attach the
// namespace directly to the element.
namespaceToAdd = uri;
el = local;
}
}
let setTopLevel = false;
if (topLevelObject == null) {
setTopLevel = true;
}
output[el] = recursivelyRewrite(
input[name],
curNamespacePrefixes,
el,
uri,
namespaceToAdd,
);
if (setTopLevel) {
topLevelObject = output[el];
}
}
return output;
} else {
if (addNamespace != null) {
return {
$: { xmlns: addNamespace },
_: input,
};
}
return input;
}
};
const obj = recursivelyRewrite(xml, prefixes);
if (topLevelObject != null) {
const obj = topLevelObject as { [k: string]: any };
// Explicitly set the top level namespace to 'DAV:'.
obj.$.xmlns = 'DAV:';
for (let prefix in prefixes) {
obj.$[`xmlns:${prefix}`] = prefixes[prefix];
}
}
return this.xmlBuilder.buildObject(obj);
}
/**
* Format a list of locks into an object acceptable by xml2js.
*/
async formatLocks(locks: Lock[]) {
const xml = { activelock: [] as any[] };
if (locks != null) {
for (let lock of locks) {
const secondsLeft =
lock.timeout === Infinity
? Infinity
: (lock.date.getTime() + lock.timeout - new Date().getTime()) /
1000;
if (secondsLeft <= 0) {
continue;
}
xml.activelock.push({
locktype: {
write: {},
},
lockscope: {
[lock.scope]: {},
},
depth: {
_: `${lock.depth}`,
},
owner: lock.owner,
timeout:
secondsLeft === Infinity
? { _: 'Infinite' }
: { _: `Second-${secondsLeft}` },
locktoken: { href: { _: lock.token } },
lockroot: {
href: {
_: (await lock.resource.getCanonicalUrl()).pathname,
},
},
});
}
}
if (!xml.activelock.length) {
return {};
}
return xml;
}
}