UNPKG

nephele

Version:

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

557 lines (470 loc) 15.6 kB
import type { Request } from 'express'; import { v4 as uuid } from 'uuid'; import type { AuthResponse, Resource } from '../Interfaces/index.js'; import { BadRequestError, LockedError, NotAcceptableError, ResourceNotFoundError, ServiceUnavailableError, UnauthorizedError, } from '../Errors/index.js'; import { catchErrors } from '../catchErrors.js'; import { MultiStatus, Status } from '../MultiStatus.js'; import { Method } from './Method.js'; export class LOCK extends Method { async run(request: Request, response: AuthResponse) { const { url, encoding } = this.getRequestData(request, response); if ( await this.runPlugins(request, response, 'beginLock', { method: this, url, }) ) { return; } await this.checkAuthorization(request, response, 'LOCK'); const depth: '0' | 'infinity' = (request.get('Depth') || 'infinity') as | '0' | 'infinity'; const timeoutHeader = request.get('Timeout') || 'Infinite'; const contentType = request.accepts('application/xml', 'text/xml'); if (!contentType) { throw new NotAcceptableError('Requested content type is not supported.'); } let resource: Resource; let newResource = false; try { resource = await response.locals.adapter.getResource( url, response.locals.baseUrl, ); } catch (e: any) { if (e instanceof ResourceNotFoundError) { resource = await response.locals.adapter.newResource( url, response.locals.baseUrl, ); newResource = true; } else { throw e; } } if ((await resource.isCollection()) && !url.toString().endsWith('/')) { response.set({ 'Content-Location': `${url}/`, }); } if ( await this.runPlugins(request, response, 'preLock', { method: this, resource, }) ) { return; } const timeoutRequests = timeoutHeader.split(/,\s+/); let timeout = Infinity; for (let curTreq of timeoutRequests) { let tReqSec: number; if (curTreq === 'Infinite') { tReqSec = Infinity; } else if (curTreq.startsWith('Second-')) { tReqSec = parseInt(curTreq.substring('Second-'.length)); } else { tReqSec = NaN; } if (isNaN(tReqSec)) { throw new BadRequestError( 'Timeout header must contain only valid timeouts.', ); } if ( tReqSec * 1000 <= this.opts.maxLockTimeout && tReqSec * 1000 >= this.opts.minLockTimeout ) { timeout = tReqSec * 1000; break; } } if (timeout > this.opts.maxLockTimeout) { timeout = this.opts.maxLockTimeout; } else if (timeout < this.opts.minLockTimeout) { timeout = this.opts.minLockTimeout; } const xmlBody = await this.getBodyXML(request, response); if (xmlBody == null) { // If the body is empty, it means the user is trying to refresh a lock. if ( await this.runPlugins(request, response, 'preLockRefresh', { method: this, resource, }) ) { return; } const lockTokens = this.getRequestLockTockens(request); if (lockTokens.length !== 1) { throw new BadRequestError( 'LOCK method for refreshing a lock must include exactly one lock token in the If header.', ); } const token = lockTokens[0]; const locks = await this.getLocksByUser( request, response, resource, response.locals.user, ); const lock = locks.resource.find((lock) => lock.token === token) || locks.depthInfinity.find((lock) => lock.token === token); if (lock == null) { const multiStatus = new MultiStatus(); let status = new Status(url, 412); // Precondition Failed status.setBody({ error: [{ 'lock-token-matches-request-uri': {} }] }); response.locals.errors.push(status); multiStatus.addStatus(status); 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); return; } await this.checkConditionalHeaders(request, response); if ( await this.runPlugins(request, response, 'beforeLockRefresh', { method: this, resource, lock, }) ) { return; } lock.date = new Date(); lock.timeout = timeout; await lock.save(); // Lock returned only in response body. Why? The spec says so. const currentLocks = await this.getLocks(request, response, resource); const responseObj = { prop: { lockdiscovery: await this.formatLocks(currentLocks.all), }, }; const responseXml = await this.renderXml(responseObj); response.status(200); // OK response.set({ 'Content-Type': `${contentType}; charset=utf-8`, }); this.sendBodyContent(response, responseXml, encoding); await this.runPlugins(request, response, 'afterLock', { method: this, resource, lock, }); return; } // Check depth header after empty body check, because it must be ignored on // LOCK refresh requests. if (!['0', 'infinity'].includes(depth)) { throw new BadRequestError( 'Depth header, if present must be one of "0", or "infinity".', ); } const { output: xml, prefixes } = await this.parseXml(xmlBody); if (xml == null) { throw new BadRequestError( 'The given body was not understood by the server.', ); } if (!('lockinfo' in xml)) { throw new BadRequestError('LOCK methods requires a lockinfo element.'); } if (!('lockscope' in xml.lockinfo) || !xml.lockinfo.lockscope.length) { throw new BadRequestError('LOCK methods requires a lockscope element.'); } const lockscopeXml = xml.lockinfo.lockscope[0]; if (!('locktype' in xml.lockinfo) || !xml.lockinfo.locktype.length) { throw new BadRequestError('LOCK methods requires a locktype element.'); } const locktypeXml = xml.lockinfo.locktype[0]; if ( !('owner' in xml.lockinfo) || !xml.lockinfo.owner.length || Object.keys(xml.lockinfo.owner[0]).length === 0 ) { throw new BadRequestError('LOCK method requires a filled owner element.'); } const owner = xml.lockinfo.owner[0]; if (!('write' in locktypeXml)) { throw new BadRequestError('This server only supports write locks.'); } if (!('exclusive' in lockscopeXml) && !('shared' in lockscopeXml)) { throw new BadRequestError( 'This server only supports exclusive and shared locks.', ); } const scope: 'exclusive' | 'shared' = 'exclusive' in lockscopeXml ? 'exclusive' : 'shared'; const checkForLockAbove = async () => { const lockPermission = await this.getLockPermission( request, response, resource, response.locals.user, ); // Check that the resource wouldn't be added to a locked collection. if (newResource && lockPermission === 1) { throw new LockedError( 'The user does not have permission to create an empty resource in the locked collection.', ); } if (lockPermission === 0) { throw new LockedError( `The user does not have permission to ${ newResource ? 'create' : 'lock' } the locked resource.`, ); } if (lockPermission === 3 && scope === 'exclusive') { throw new LockedError( `The user does not have permission to ${ newResource ? 'create' : 'lock' } the locked resource with an exclusive lock.`, ); } }; await new Promise<void>(async (resolve, reject) => { let attempt = 0; const runLockAndProvisionalCheck = async () => { await checkForLockAbove(); // Check for provisional locks that are blocking this one. const provisionalLocks = await this.getProvisionalLocks( request, response, resource, ); if (provisionalLocks.all.length) { if (attempt >= 120) { // Give up after a while. (Max ~60 seconds.) throw new ServiceUnavailableError( 'The server is waiting for another lock operation to complete.', ); } // A provisional lock exists, so wait for between 100 and 500 ms to // try again. await new Promise((resolve) => setTimeout(resolve, 100 + Math.random() * 400), ); attempt++; await runLockAndProvisionalCheck(); } }; try { await runLockAndProvisionalCheck(); resolve(); } catch (e: any) { reject(e); } }); await this.checkConditionalHeaders(request, response); if ( await this.runPlugins(request, response, 'beforeLockProvisional', { method: this, resource, }) ) { return; } const multiStatus = new MultiStatus(); response.set({ 'Cache-Control': 'private, no-cache', Date: new Date().toUTCString(), }); // Create a provisional lock. const lock = await resource.createLockForUser(response.locals.user); lock.token = `urn:uuid:${uuid()}`; lock.date = new Date(); // Timeout the provisional lock after five minutes. lock.timeout = 1000 * 60 * 5; lock.scope = scope; lock.depth = depth; lock.owner = owner; lock.provisional = true; await lock.save(); // Now that we have a provisional lock, check upward again. try { await checkForLockAbove(); } catch (e: any) { await lock.delete(); throw e; } const checkForPermissionAndLocksBelow = async ( resource: Resource, firstLevel = true, ) => { // If the resource is the root of another adapter, we need its copy of the // resource in order to continue looking for locks below. const resourceUrl = await resource.getCanonicalUrl(); if ( !firstLevel && (await this.isAdapterRoot(request, response, resourceUrl)) ) { const absoluteUrl = new URL( resourceUrl.toString().replace(/\/?$/, () => '/'), ); const adapter = await this.getAdapter( request, response, decodeURIComponent( resourceUrl.pathname.substring(request.baseUrl.length), ), ); resource = await adapter.getResource(absoluteUrl, absoluteUrl); } // Check permissions to lock the resource. if ( // Use the resource's adapter and baseUrl, because this could be on // another adapter than the request. !(await resource.adapter.isAuthorized( await resource.getCanonicalUrl(), 'LOCK', resource.baseUrl, response.locals.user, )) ) { throw new UnauthorizedError( 'The user is not authorized to lock the resource.', ); } const locks = await this.getCurrentResourceLocks(resource); if ( locks.length && (locks.length !== 1 || locks[0].token !== lock.token) ) { if (lock.scope === 'exclusive') { throw new LockedError('Cannot create a conflicting lock.'); } for (let checkLock of locks) { if (checkLock.token === lock.token) { continue; } if (checkLock.scope === 'exclusive') { throw new LockedError('Cannot create a conflicting lock.'); } } } if ( (await resource.isCollection()) && ((lock.depth === '0' && firstLevel) || lock.depth === 'infinity') ) { const children = await resource.getInternalMembers( response.locals.user, ); for (let child of children) { const run = catchErrors( async () => { if (!multiStatus.statuses.length) { await checkForPermissionAndLocksBelow(child, false); } }, async (code, message, error) => { if (code === 500 && error) { response.locals.debug('Unknown Error: %o', error); } const url = await child.getCanonicalUrl(); let status = new Status(url, code); if (message) { status.description = message; } if (error instanceof LockedError) { status.setBody({ error: [{ 'no-conflicting-lock': {} }] }); } response.locals.errors.push(status); multiStatus.addStatus(status); }, ); await run(); } } }; // And check below for any conflicting lock. const run = catchErrors( async () => { await checkForPermissionAndLocksBelow(resource); }, async (code, message, error) => { if (code === 500 && error) { response.locals.debug('Unknown Error: %o', error); } const url = await resource.getCanonicalUrl(); let status = new Status(url, code); if (message) { status.description = message; } if (error instanceof LockedError) { status.setBody({ error: [{ 'no-conflicting-lock': {} }] }); } multiStatus.addStatus(status); }, ); await run(); if (multiStatus.statuses.length) { await lock.delete(); let status = new Status(url, 424); // Failed Dependency multiStatus.addStatus(status); const responseXml = await this.renderXml(multiStatus.render(), prefixes); response.status(207); // Multi-Status response.set({ 'Content-Type': `${contentType}; charset=utf-8`, }); this.sendBodyContent(response, responseXml, encoding); return; } // There's no conflicting lock below this one, so continue on. // Create an empty resource if it's new. if (newResource) { try { await resource.create(response.locals.user); } catch (e: any) { await lock.delete(); throw e; } } if ( await this.runPlugins(request, response, 'beforeLock', { method: this, resource, lock, }) ) { return; } // Set provisional lock to real lock. lock.date = new Date(); lock.timeout = timeout; lock.provisional = false; await lock.save(); // Lock returned in Lock-Token header and response body. const currentLocks = await this.getLocks(request, response, resource); const responseObj = { prop: { lockdiscovery: await this.formatLocks(currentLocks.all), }, }; const responseXml = await this.renderXml(responseObj, prefixes); response.status(newResource ? 201 : 200); // Created or OK response.set({ 'Lock-Token': `<${lock.token}>`, 'Content-Type': `${contentType}; charset=utf-8`, }); this.sendBodyContent(response, responseXml, encoding); await this.runPlugins(request, response, 'afterLock', { method: this, resource, lock, }); } }