UNPKG

jsdav-ext

Version:

jsDAV allows you to easily add WebDAV support to a NodeJS application. jsDAV is meant to cover the entire standard, and attempts to allow integration using an easy to understand API.

843 lines (762 loc) 33 kB
/* * @package jsDAV * @subpackage DAV * @copyright Copyright(c) 2011 Ajax.org B.V. <info AT ajax DOT org> * @author Mike de Boer <info AT mikedeboer DOT nl> * @license http://github.com/mikedeboer/jsDAV/blob/master/LICENSE MIT License */ "use strict"; var jsDAV_ServerPlugin = require("./../plugin"); var jsDAV_Property_SupportedLock = require("./../property/supportedLock"); var jsDAV_Property_LockDiscovery = require("./../property/lockDiscovery"); var jsDAV_iFile = require("./../interfaces/iFile"); var jsDAV_iLockable = require("./../interfaces/iLockable"); var jsDAV_Locks_LockInfo = require("./locks/lockinfo"); var Async = require("asyncjs"); var Exc = require("./../../shared/exceptions"); var Util = require("./../../shared/util"); var Xml = require("./../../shared/xml"); var jsDAV_Locks_Plugin = module.exports = jsDAV_ServerPlugin.extend({ /** * Plugin name * * @var String */ name: "locks", initialize: function(handler) { this.handler = handler; //this.locksBackend = locksBackend; this.locksBackend = handler.server.options.locksBackend || null; handler.addEventListener("unknownMethod", this.unknownMethod.bind(this)); handler.addEventListener("beforeMethod", this.beforeMethod.bind(this)); handler.addEventListener("afterGetProperties", this.afterGetProperties.bind(this)); handler.addEventListener("afterDelete", this.afterDelete.bind(this)); handler.addEventListener("afterMove", this.afterMove.bind(this)); }, /** * This method intercepts all MOVE methods to delete all remaining locks on the original resource. * See RFC4918 section 7.7 for more details. * * @param e * @param {String} destinationUri destination URI */ afterMove: function(e, destinationUri) { var uri = this.handler.getRequestUri(); this.removeLockFromRoot(uri, function(err) { e.next(err); }); }, /** * This method intercepts all DELETE methods to delete all remaining locks rooted on this resource. * See RFC4918 section 9.6 for more details. * * @param e * @param uri {String} deleted object * @return bool */ afterDelete: function(e, uri) { this.removeLockFromRoot(uri, function(err) { e.next(err); }); }, /** * Deletes a lock with its root on the given uri from the lock backend (if there is one). * * @param uri URI of the locks root */ removeLockFromRoot: function(uri, cbdeletelock) { if (!this.locksBackend) cbdeletelock(); // Not sure if returnChildLocks should be true here var self = this; this.getLocks(uri, false, function(err, locks) { if (err) { cbdeletelock(err); } else if(locks) { var callbackCalled = false; Async.list(locks).each(function(lockInfo, next) { if(lockInfo.uri == uri) { if (self.locksBackend) return self.locksBackend.unlock(uri, lockInfo, function(err){ callbackCalled = true; cbdeletelock(err); }); next(Async.STOP); } else { next(); } }).end(function(err) { if(!callbackCalled) { cbdeletelock(err); } }); } else { cbdeletelock(); } }); }, /** * This method is called by the Server if the user used an HTTP method * the server didn"t recognize. * * This plugin intercepts the LOCK and UNLOCK methods. * * @param {String} method * @return bool */ unknownMethod: function(e, method) { if (!this.locksBackend) return e.next(); if (method == "LOCK") this.httpLock(e); else if (method == "UNLOCK") this.httpUnlock(e); else e.next(); }, /** * This method is called after most properties have been found * it allows us to add in any Lock-related properties * * @param {String} path * @param {Array} properties * @return bool */ afterGetProperties: function(e, path, newProperties) { if (!this.locksBackend) return e.next(); var self = this; Async.list(Object.keys(newProperties["404"])).each(function(propName, next) { var val = false; switch (propName) { case "{DAV:}supportedlock" : if (self.locksBackend) { val = true; afterGetNode(); } else { self.handler.getNodeForPath(path, function(err, node) { if (err) return next((err instanceof Exc.FileNotFound) ? null : err); if (node.hasFeature(jsDAV_iLockable)) val = true; afterGetNode(); }); } break; case "{DAV:}lockdiscovery" : self.getLocks(path, false, function(err, locks) { if (err) return next(err); newProperties["200"][propName] = jsDAV_Property_LockDiscovery.new(locks); delete newProperties["404"][propName]; next(); }); break; default: next(); break; } function afterGetNode() { newProperties["200"][propName] = jsDAV_Property_SupportedLock.new(val); delete newProperties["404"][propName]; next(); } }) .end(function(err) { e.next(err); }); }, /** * This method is called before the logic for any HTTP method is * handled. * * This plugin uses that feature to intercept access to locked resources. * * @param {String} method * @return bool */ beforeMethod: function(e, method) { if (!this.locksBackend) return e.next(); var req = this.handler.httpRequest; switch (method) { case "DELETE" : case "MKCOL" : case "PROPPATCH" : case "PUT" : this.validateLock(null, false, function(err, isValid, lastLock) { e.next(err ? err : !isValid ? new Exc.Locked(lastLock) : null); }); break; case "MOVE" : this.validateLock([ this.handler.getRequestUri(), this.handler.calculateUri(req.headers["destination"]) ], false, function(err, isValid, lastLock) { e.next(err ? err : !isValid ? new Exc.Locked(lastLock) : null); }); break; case "COPY" : this.validateLock(this.handler.calculateUri(req.headers["destination"]), false, function(err, isValid, lastLock) { e.next(err ? err : !isValid ? new Exc.Locked(lastLock) : null); }); break; default: e.next(); break; } }, /** * Use this method to tell the server this plugin defines additional * HTTP methods. * * This method is passed a uri. It should only return HTTP methods that are * available for the specified uri. * * @param {String} uri * @return array */ getHTTPMethods: function(uri, node) { if (this.locksBackend || (node && node.hasFeature(jsDAV_iLockable))) return ["LOCK", "UNLOCK"]; return []; }, /** * Returns a list of features for the HTTP OPTIONS Dav: header. * * In this case this is only the number 2. The 2 in the Dav: header * indicates the server supports locks. * * @return array */ getFeatures: function() { return ["2"]; }, /** * Returns all lock information on a particular uri * * This function should return an array with jsDAV_Locks_LockInfo objects. * If there are no locks on a file, return an empty array. * * Additionally there is also the possibility of locks on parent nodes, so * we'll need to traverse every part of the tree. * * @param {String} uri * @return array */ getLocks: function(uri, returnChildLocks, cbgetlocks) { var lockList = []; var uriLocks = []; var currentPath = ""; var self = this; Async.list(uri.split("/")) .delay(0, 10) .each(function(uriPart, next) { if (currentPath) currentPath += "/"; currentPath += uriPart; self.handler.getNodeForPath(currentPath, function(err, node) { if (err) return next((err instanceof Exc.FileNotFound) ? null : err); if (node.hasFeature(jsDAV_iLockable)) { node.getLocks(function(err, locks) { if (err) return next(err); uriLocks = locks; next(); }); } else { next(); } }); }) .end(function(err) { if (err) return cbgetlocks(err); for (var i = 0, l = uriLocks.length; i < l; ++i) {// as uriLock) { var uriLock = uriLocks[i]; // Unless we're on the leaf of the uri-tree we should ingore locks with depth 0 if (uri == currentPath || uriLock.depth !== 0) { uriLock.uri = currentPath; lockList.push(uriLock); } } if (self.locksBackend) { self.locksBackend.getLocks(uri, returnChildLocks, function(err, locks) { cbgetlocks(err, lockList.concat(locks)); }); } else cbgetlocks(null, lockList); }); }, /** * Locks an uri * * The WebDAV lock request can be operated to either create a new lock on a * file, or to refresh an existing lock. * If a new lock is created, a full XML body should be supplied, containing * information about the lock such as the type of lock (shared or exclusive) * and the owner of the lock. * * If a lock is to be refreshed, no body should be supplied and there should * be a valid If header containing the lock. * * Additionally, a lock can be requested for a non-existant file. In this * case we're obligated to create an empty file as per RFC4918:S7.3 * * @return void */ httpLock: function(e) { var timeout; var uri = this.handler.getRequestUri(); var self = this; this.validateLock(uri, false, function(err, isValid, lastLock) { if (err) return e.next(err); if (!isValid) { // If the existing lock was an exclusive lock, we need to fail if (!lastLock || lastLock.scope == jsDAV_Locks_LockInfo.EXCLUSIVE) return e.next(new Exc.ConflictingLock(lastLock)); } self.handler.getRequestBody("utf8", null, false, function(err, body) { if (err) return e.next(err); var lockInfo; if (body) { // This is a new lock request lockInfo = self.parseLockRequest(body); lockInfo.depth = self.handler.getHTTPDepth(); lockInfo.uri = uri; if (lastLock && lockInfo.scope != jsDAV_Locks_LockInfo.SHARED) return e.next(new Exc.ConflictingLock(lastLock)); } else if (lastLock) { // This must have been a lock refresh lockInfo = lastLock; // The resource could have been locked through another uri. if (uri != lockInfo.uri) uri = lockInfo.uri; } else { // There was neither a lock refresh nor a new lock request return e.next(new Exc.BadRequest("An xml body is required for lock requests")); } try { timeout = self.getTimeoutHeader(); } catch (ex) { return e.next(ex); } lockInfo.timeout = timeout; var newFile = false; // If we got this far.. we should go check if this node actually exists. // If this is not the case, we need to create it first self.handler.getNodeForPath(uri, function(err, node) { if (err) { if (err instanceof Exc.FileNotFound) { // It didn't, lets create it self.handler.createFile(uri, new Buffer(0), "utf8", function(err) { if (err) return e.next(err); newFile = true; afterNode(); }); } else return e.next(err); } else afterNode(); function afterNode() { // We need to call the beforeWriteContent event for RFC3744 self.handler.dispatchEvent("beforeWriteContent", [uri, node]); self.lockNode(uri, lockInfo, function(err) { if (err) return e.next(err); self.handler.httpResponse.writeHead(newFile ? 201 : 200, { "Content-Type": "application/xml; charset=utf-8", "Lock-Token": "<opaquelocktoken:" + lockInfo.token + ">" }); self.handler.httpResponse.end(self.generateLockResponse(lockInfo)); e.stop(); }); } }); }); }); }, /** * Unlocks a uri * * This WebDAV method allows you to remove a lock from a node. The client * should provide a valid locktoken through the Lock-token http header. * The server should return 204 (No content) on success * * @return void */ httpUnlock: function(e) { var uri = this.handler.getRequestUri(); var lockToken = this.handler.httpRequest.headers["lock-token"]; var self = this; // If the locktoken header is not supplied, we need to throw a bad request exception if (!lockToken) return e.next(new Exc.BadRequest("No lock token was supplied")); this.getLocks(uri, false, function(err, locks) { if (err) return e.next(err); var lock, found; var i = 0; var l = locks.length; for (; i < l; ++i) {//locks as lock) { lock = locks[i]; if ("<opaquelocktoken:" + lock.token + ">" == lockToken) { found = lock; break; } } if (found) { self.unlockNode(uri, lock, function(err) { if (err) return e.next(err); self.handler.httpResponse.writeHead(204, {"Content-Length": "0"}); self.handler.httpResponse.end(); e.stop(); }); } else { // If we got here, it means the locktoken was invalid e.next(new Exc.LockTokenMatchesRequestUri()); } }); }, /** * Locks a uri * * All the locking information is supplied in the lockInfo object. The object * has a suggested timeout, but this can be safely ignored. * It is important that if the existing timeout is ignored, the property is * overwritten, as this needs to be sent back to the client. * * @param {String} uri * @param jsDAV_Locks_LockInfo lockInfo * @return void */ lockNode: function(uri, lockInfo, cblock) { var self = this; this.handler.dispatchEvent("beforeLock", [uri], function(stop, updatedLock) { if (stop === true) return cblock(null, lockInfo); // event handler might have updated the lock! if (updatedLock) lockInfo = updatedLock; self.handler.getNodeForPath(uri, function(err, node) { // In case the node didn't exist, this could be a lock-null request if (err && !(err instanceof Exc.FileNotFound)) return cblock(err); if (node && node.hasFeature(jsDAV_iLockable)) return node.lock(lockInfo, cblock); if (self.locksBackend) return self.locksBackend.lock(uri, lockInfo, cblock); cblock(new Exc.MethodNotAllowed("Locking support is not " + "enabled for this resource. No Locking backend was found so if you " + "didn't expect this error, please check your configuration.")); }); }); }, /** * Unlocks a uri * * This method removes a lock from a uri. It is assumed all the supplied * information is correct and verified. * * @param {String} uri * @param jsDAV_Locks_LockInfo lockInfo * @return void */ unlockNode: function(uri, lockInfo, cbunlock) { var self = this; this.handler.dispatchEvent("beforeUnlock", [uri], function(stop, updatedLock) { if (stop === true) return cbunlock(null, lockInfo); // event handler might have updated the lock! if (updatedLock) lockInfo = updatedLock; self.handler.getNodeForPath(uri, function(err, node) { // In case the node didn't exist, this could be a lock-null request if (err && !(err instanceof Exc.FileNotFound)) return cbunlock(err); if (node && node.hasFeature(jsDAV_iLockable)) return node.unlock(cbunlock, lockInfo); if (self.locksBackend) return self.locksBackend.unlock(uri, lockInfo, cbunlock); cbunlock(); }); }); }, /** * Returns the contents of the HTTP Timeout header. * * The method formats the header into an integer. * * @return int */ getTimeoutHeader: function() { var header = this.handler.httpRequest.headers["timeout"]; if (header) { if (header.toLowerCase().indexOf("second-") === 0) header = parseInt(header.substr(7), 10); else if (header.toLowerCase() == "infinite") header = jsDAV_Locks_LockInfo.TIMEOUT_INFINITE; else throw new Exc.BadRequest("Invalid HTTP timeout header"); } else { header = 0; } return header; }, /** * Generates the response for successfull LOCK requests * * @param jsDAV_Locks_LockInfo lockInfo * @return string */ generateLockResponse: function(lockInfo) { var lockObj = jsDAV_Property_LockDiscovery.new([lockInfo], true); var xml = '<?xml version="1.0" encoding="utf-8"?><d:prop'; // Adding in default namespaces var prefix, namespace; for (namespace in Xml.xmlNamespaces) { prefix = Xml.xmlNamespaces[namespace]; xml += ' xmlns:' + prefix + '="' + namespace + '"'; } return xml + "><d:lockdiscovery>" + lockObj.serialize(this.handler, "") + "</d:lockdiscovery></d:prop>"; }, /** * validateLock should be called when a write operation is about to happen. * It will check if the requested url is locked, and see if the correct lock * tokens are passed. * * @param {mixed} urls List of relevant urls. Can be an array, a string or * nothing at all for the current request uri * @param {mixed} lastLock This variable will be populated with the last checked * lock object (jsDAV_Locks_LockInfo) * @return bool */ validateLock: function(urls, checkChildLocks, cbvalidate) { var lastLock = null; urls = urls || null; if (urls === null) { try { urls = [this.handler.getRequestUri()]; } catch (ex) { return cbvalidate(ex); } } else if (typeof urls == "string") { urls = [urls]; } else if (!Array.isArray(urls)) { return cbvalidate(new Exc.jsDAV_Exception("The urls parameter should either be null, a string or an array"), false, null); } var ret, locks; var stopped = false; var cbCalled = false; var conditions = this.getIfConditions(); var self = this; // We're going to loop through the urls and make sure all lock conditions // are satisfied Async.list(urls) .delay(0, 10) .each(function(url, next) { self.getLocks(url, false, function(err, aLocks) { if (err) return next(err); locks = [].concat(aLocks); // If there were no conditions, but there were locks, we fail if (!conditions.length && locks.length) { ret = false; cbvalidate(null, ret, locks[0]); cbCalled = true; stopped = true; return next(Async.STOP); } // If there were no locks or conditions, we go to the next url if (!locks.length && !conditions.length) return next(); Async.list(conditions) .delay(0, 10) .each(function(condition, next2) { var conditionUri; try { conditionUri = condition.uri ? self.handler.calculateUri(condition.uri) : ""; } catch (ex) { return next2(ex); } // If the condition has a url, and it isn't part of the affected // url at all, check the next condition if (conditionUri && url.indexOf(conditionUri) !== 0) return next2(); // The tokens array contains arrays with 2 elements. 0=true/false // for normal/not condition, 1=locktoken // At least 1 condition has to be satisfied var tokensStopped = false; Async.list(condition.tokens).each(function(conditionToken, next3) { var etagValid = true; var lockValid = true; // condition can contain an etag if ("etag" in conditionToken && conditionToken.etag !== null) { var uri; try { uri = conditionUri ? conditionUri : self.handler.getRequestUri(); } catch (ex) { return next3(ex); } self.handler.getNodeForPath(uri, function(err, node) { if (err) return next3((err instanceof Exc.FileNotFound) ? null : err); if (!node.hasFeature(jsDAV_iFile)) return afterEtag(); node.getETag(function(err, etag) { if (err) return next3(err); etagValid = etag === null || etag == conditionToken.etag; afterEtag(); }); }); } else afterEtag(); function afterEtag() { // condition can contain a lock token if ("token" in conditionToken && conditionToken.token !== null) { lockValid = false; // Match all the locks for (var lock, lockToken, lockIndex = 0, l = locks.length; lockIndex < l; ++lockIndex) { lock = locks[lockIndex]; lockToken = "opaquelocktoken:" + lock.token; // Checking NOT if (conditionToken.not && lockToken != conditionToken.token) { // Condition valid, onto the next lockValid = true; break; } if (!conditionToken.not && lockToken == conditionToken.token) { lastLock = lock; // Condition valid and lock matched locks.splice(lockIndex, 1); lockValid = true; break; } } } // If, after checking both etags and locks they are stil valid, // we can continue with the next condition. if (etagValid && lockValid) { tokensStopped = true; next3(Async.STOP); } else next3(); } }) .end(function(err) { if (err) return next2(err); if (!tokensStopped) { // No conditions matched, so we fail return next2(new Exc.PreconditionFailed("The tokens " + "provided in the if header did not match", "If")); } stopped = true; next2(Async.STOP); }); }) .end(next); }); }) .end(function(err) { if (cbCalled) { // we must not call callback twice return } if (err && !stopped) return cbvalidate(err, false, lastLock); // Conditions were met, we'll also need to check if all the locks are gone if (locks.length) { // There's still locks, we fail return cbvalidate(err, false, locks[0]); } // We got here, this means every condition was satisfied cbvalidate(null, true, lastLock); }); }, /** * This method is created to extract information from the WebDAV HTTP "If:" * header. * * The If header can be quite complex, and has a bunch of features. We're * using a regex to extract all relevant information. * The function will return an array, containg structs with the following keys * * * uri - the uri the condition applies to. This can be an empty string * for "every relevant url" * * tokens - The lock token. another 2 dimensional array containg 2 elements * (0 = true/false.. If this is a negative condition its set to * false, 1 = the actual token) * * etag - an etag, if supplied * * @return void */ getIfConditions: function() { var header = this.handler.httpRequest.headers["if"]; if (!header) return []; var conditions = []; header.replace(/(?:<(.*?)>\s)?\((Not\s)?(?:<([^>]*)>)?(?:\s?)(?:\[([^\]]*)\])?\)/gi, function(m, uri, not, token, etag) { var token = { not: !!not, token: token, etag: etag ? etag : null }; var condition = { uri : uri, tokens : [token] }; if (!condition.uri && conditions.length) conditions[conditions.length - 1].tokens.push(token); else conditions.push(condition); }); return conditions; }, /** * Parses a webdav lock xml body, and returns a new jsDAV_Locks_LockInfo object * * @param {String} body * @return jsDAV_Locks_LockInfo */ parseLockRequest: function(xml) { var lockInfo = jsDAV_Locks_LockInfo.new(); var m = xml.match(/<(?:d\:)?owner>([\w\W\n\r\t\s]*)<\/(?:d\:)?owner>/i); lockInfo.owner = m && m[1] ? Util.trim(m[1]) : null; var id = Util.createHash(Date.now() + "somethingrandom"); var lockToken = "44445502-" + id.substr(0, 4) + "-" + id.substr(4, 4) + "-" + id.substr(8, 4) + "-" + id.substr(12, 12); lockInfo.token = lockToken; lockInfo.scope = /<(?:d\:)?exclusive/.test(xml.toLowerCase()) ? jsDAV_Locks_LockInfo.EXCLUSIVE : jsDAV_Locks_LockInfo.SHARED; return lockInfo; } });