UNPKG

nephele

Version:

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

1,539 lines (1,373 loc) 44.7 kB
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; } }