UNPKG

node-smb-server

Version:

A Pure JavaScript SMB Server Implementation

553 lines (514 loc) 21.8 kB
/* * Copyright 2016 Adobe Systems Incorporated. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ 'use strict'; var Path = require('path'); var EventEmitter = require('events').EventEmitter; var util = require('util'); var log = require('winston').loggers.get('spi'); var rqlog = require('winston').loggers.get('rq'); var Datastore = require('nedb'); var utils = require('../../utils'); var consts = require('./common.js'); /** * Creates a new RequestQueue instance. * @constructor */ function RequestQueue(options) { if (!(this instanceof RequestQueue)) { return new RequestQueue(options); } EventEmitter.call(this); this.db = new Datastore({filename: Path.join(options.path, consts.REQUEST_DB), autoload: true}); } util.inherits(RequestQueue, EventEmitter); /** * Retrieves all requests that have been queued for a given parent directory. * @param {String} path The full to the directory to check. * @param {Function} callback Function to call with results of get operation. * @param {String|Error} callback.err Error that occurred (will be undefined on success) * @param {Object} callback.requestLookup object whose keys are names and values are methods */ RequestQueue.prototype.getRequests = function (path, callback) { rqlog.debug('RequestQueue.getRequests %s', path); var self = this; self.db.find({path: path}, function (err, docs) { if (err) { log.warn('unexpected error while attempting to query request queue: ' + err); callback(err); } else { log.debug('getCreateRequests: query for path %s returned %s records', path, docs.length); var requestLookup = {}; for (var i = 0; i < docs.length; i++) { requestLookup[docs[i].name] = docs[i].method; } callback(undefined, requestLookup); } }); }; /** * Retrieves all requests that have been queued. * @param {Function} callback Function to call with results of get operation. * @param {String|Error} callback.err Error that occurred (will be falsy on success). * @param {Array} callback.requests Array of objects representing the raw queued requests. */ RequestQueue.prototype.getAllRequests = function (callback) { rqlog.debug('RequestQueue.getActiveRequests'); this.db.find({}).sort({timestamp: -1}).exec(callback); }; /** * Retrieves a value indicating whether or not a given item exists in the queue. * @param {String} path The path of the item to check. * @param {String} name The name of the item to check. * @param {Function} callback Will be invoked with information about the existence of the item. * @param {String|Error} callback.err Will be truthy if the operation failed. * @param {Boolean} callback.exists Will be true if the item exists in the queue, otherwise will be false. */ RequestQueue.prototype.exists = function (path, name, callback) { rqlog.debug('RequestQueue.exists [%s] [%s]', path, name); var self = this; self.db.findOne({$and: [{path: path}, {name: name}]}, function (err, doc) { if (err) { callback(err); } else { callback(null, (doc != null)); } }); }; /** * Removes all requests that exceeded the maximum number of retries. * @param {Number} maxRetries The number of retries that a request should exceed before being purged. * @param {Function} callback Will be invoked when requests have been purged. * @param {String|Error} callback.err Will be truthy if the purge encountered errors. * @param {Array} callback.paths An array of file paths whose requests were purged. */ RequestQueue.prototype.purgeFailedRequests = function (maxRetries, callback) { rqlog.debug('RequestQueue.purgeFailedRequests [%d]', maxRetries); var self = this; self.db.find({retries: {$gte: maxRetries}}, function (err, docs) { var paths = []; var purgeRequest = function (index) { if (index < docs.length) { paths.push(Path.join(docs[index].path, docs[index].name)); self.db.remove({_id: docs[index]._id}, {}, function (err) { if (err) { callback(err); } else { purgeRequest(index + 1); } }); } else { callback(null, paths); } }; purgeRequest(0); }); }; /** * Increments the number of retry counts for a given request by 1. * @param {String} path The path of the request to be updated. * @param {String} name The name of the file whose request should be updated. * @param {Number} delay * @param {Function} callback Will be called after the update. * @param {String|Error} callback.err Will be truthy if there were errors during the update. */ RequestQueue.prototype.incrementRetryCount = function (path, name, delay, callback) { rqlog.debug('RequestQueue.incrementRetryCount [%s] [%s] [%d]', path, name, delay); var newTime = new Date().getTime() + delay; this.db.update({$and: [{path: path}, {name: name}]}, { $inc: {retries: 1}, $set: {timestamp: newTime} }, function (err, numAffected) { if (err) { callback(err); } else if (numAffected != 1) { callback('unexpected number of requests had retry count updated: ' + numAffected); } else { callback(); } }); }; /** * Removes the request for a given local path from the queue. * @param {String} path The path of the request to be removed. * @param {String} name The name of the file whose request should be removed. * @param {Function} callback Function that will be called when removal is complete. * @param {String|Error} callback.err If non-null, indicates that an error occurred. */ RequestQueue.prototype.removeRequest = function (path, name, callback) { rqlog.debug('RequestQueue.removeRequest [%s] [%s]', path, name); var self = this; this.db.remove({path: path, name: name}, {}, function (err, numRemoved) { if (err) { callback(err); } else if (numRemoved != 1) { callback('unexpected number of requests removed ' + numRemoved); } else { self.emit('itemupdated', Path.join(path, name)); log.debug('emitting queuechanged event due to removed request'); self.emit('queuechanged'); callback(); } }); }; /** * Completes the request for a given local path by removing it from the queue. * @param {String} path The path of the request to be completed. * @param {String} name The name of the file whose request should be completed. * @param {Function} callback Function that will be called when finished. * @param {String|Error} callback.err If non-null, indicates that an error occurred. */ RequestQueue.prototype.completeRequest = function (path, name, callback) { rqlog.debug('RequestQueue.completeRequest [%s] [%s]', path, name); var self = this; log.debug('completing request at path [%s] with name [%s]', path, name); this.db.remove({path: path, name: name}, {}, function (err, numRemoved) { if (err) { callback(err); } else if (numRemoved != 1) { callback('unexpected number of requests completed ' + numRemoved); } else { callback(); } }); }; /** * Retrieves the next request that is older than the given expiration. * @param {Number} expiration The next request older than this number of ticks will be retrieved. * @param {Number} maxRetries Requests that have attempted to process this many times will be excluded. * @param {Function} callback Will be invoked when the request is retrieved. * @param {String|Error} callback.err Will be truthy if there were errors retrieving the request. * @param {Object} callback.request The retrieved request, or falsy if there were none. */ RequestQueue.prototype.getProcessRequest = function (expiration, maxRetries, callback) { rqlog.debug('RequestQueue.getProcessRequest [%d] [%d]', expiration, maxRetries); var self = this; var expired = Date.now() - expiration; log.debug('getProcessRequest: retrieving requests that are ready to be processed'); self.db.find({$and: [{timestamp: {$lte: expired}}, {retries: {$lt: maxRetries}}]}).sort({timestamp: 1}).limit(1).exec(function (err, docs) { if (err) { callback(err); } else { if (docs.length) { log.debug('getProcessRequest: found a request for path [%s] name [%s] to process', docs[0].path, docs[0].name); callback(null, docs[0]); } else { log.debug('getProcessRequest: no requests ready to process'); callback(); } } }); }; /** * Gets the filter for retrieving all child records of a given path. * @param {String} path The path whose filter should be created. * @returns {Object} The path's filter object. */ RequestQueue.prototype.getFindPathFilter = function (path) { var subPath = path; if (subPath != '/') { subPath += '\\/'; } var subReg = new RegExp('^' + subPath, 'g'); return {$or: [{path: path}, {path: subReg}]}; }; /** * Given a current path, an old path, and a new path, retrieves the new full path for an item. * @param {String} currPath The items current path. * @param {String} oldPath The old portion of the path to be updated. * @param {String} newPath The new path to update the old path with. * @returns {String} The item's new path. */ RequestQueue.prototype.getNewPath = function (currPath, oldPath, newPath) { var docPath = currPath; docPath = docPath.substr(oldPath.length); docPath = newPath + docPath; return docPath; }; /** * Updates the records with a matching path to have a different path value. * @param {String} oldPath The path whose records should be updated. * @param {String} newPath The new value to set for matching records. * @param {Function} callback Will be invoked upon completion. * @param {String|Error} callback.err Will be truthy if there was an error while updating. */ RequestQueue.prototype.updatePath = function (oldPath, newPath, callback) { rqlog.debug('RequestQueue.updatePath [%s] [%s]', oldPath, newPath); var self = this; self.db.find(self.getFindPathFilter(oldPath), function (err, docs) { if (err) { callback(err); } else { var newTimestamp = new Date().getTime(); var updateDoc = function (index) { if (index < docs.length) { self.db.update({_id: docs[index]._id}, { $set: { path: self.getNewPath(docs[index].path, oldPath, newPath), timestamp: newTimestamp } }, function (err, numAffected) { if (err) { callback(err); } else { updateDoc(index + 1); } }); } else { self.emit('pathupdated', oldPath); if (docs.length) { log.debug('emitting queuechanged event due to updated path'); self.emit('queuechanged'); } callback(); } }; updateDoc(0); } }); }; /** * Removes all records whose path matches a given value. * @param {String} path The path whose records should be removed. * @param {Function} callback Will be invoked upon completion. * @param {String|Error} callback.err Will be truthy if there was an error while updating. */ RequestQueue.prototype.removePath = function (path, callback) { rqlog.debug('RequestQueue.removePath [%s]', path); var self = this; log.debug('removing all requests for path %s', path); this.db.remove(self.getFindPathFilter(path), {multi: true}, function (err, numAffected) { if (err) { callback(err); } else { log.debug('removed %d requests for path %s', numAffected, path); self.emit('pathupdated', path); if (numAffected) { log.debug('emitting queuechanged event due to removed path'); self.emit('queuechanged'); } callback(); } }); }; /** * Copies all records with a given path and assigns them a new path. * @param {String} oldPath The path whose records should be copied. * @param {String} newPath The path that copied records should receive. * @param {Function} callback Will be invoked upon completion. * @param {String|Error} callback.err Will be truthy if there was an error while updating. */ RequestQueue.prototype.copyPath = function (oldPath, newPath, callback) { rqlog.debug('RequestQueue.copyPath [%s] [%s]', oldPath, newPath); var self = this; self.db.find(self.getFindPathFilter(oldPath), function (err, docs) { if (err) { callback(err); } else { var doInsert = function (insertIndex) { if (insertIndex < docs.length) { var doc = docs[insertIndex]; self.queueRequest({ method: doc.method, path: self.getNewPath(doc.path, oldPath, newPath) + '/' + doc.name, localPrefix: doc.localPrefix, remotePrefix: doc.remotePrefix }, function (err) { if (err) { callback(err); } else { doInsert(insertIndex + 1); } }); } else { callback(); } }; doInsert(0); } }); }; /** * Queues a request for processing. * @param {Object} options Options for the queue request. * @param {String} options.method The HTTP request being queued * @param {String} options.path The path to the file to be queued. This path should be the portion of the file's * path that is common between its local location and remote location. * @param {String} options.localPrefix Path prefix for the local location of the file. Concatenating this value * with path should yield the full path to the local file. * @param {String} options.remotePrefix URL prefix for the remote target of the request. Concatenating this value * with path should yield the full URL to the file. * @param {String} options.destPath Optional destination path for move and copy requests. Should be the portion of * the file's path that is common between its local location and remote location. * @param {Function} callback Callback function to call once the request has been queued. * @param {String|Error} callback.err Any error messages that occurred. */ RequestQueue.prototype.queueRequest = function (options, callback) { var reqMethod = options.method; var fullPath = options.path; var path = utils.getParentPath(fullPath); var name = utils.getPathName(fullPath); var localPrefix = options.localPrefix; var remotePrefix = options.remotePrefix; var destPath = null; var destName = null; var moveTarget = 'PUT'; if (options.destPath) { destPath = utils.getParentPath(options.destPath); destName = utils.getPathName(options.destPath); if (options.replace) { moveTarget = 'POST'; } } rqlog.debug('RequestQueue.queueRequest [%s] [%s]', reqMethod, fullPath); log.debug('queueRequest: %s: queuing %s method', fullPath, reqMethod); var self = this; if (fullPath.match(/\/\./g)) { // protect against file names that start with a period. These can cause serious issues when used with the // assets api, especially when deleting. callback('%s: paths with names beginning with a period are forbidden from being queued for requests', fullPath); } else { var remove = function (removeDoc, removeCallback) { log.debug('queueRequest: %s: removing previously queued %s request', fullPath, removeDoc.method); self.db.remove({_id: removeDoc._id}, {}, function (err, numRemoved) { if (err) { log.warn('queueRequest: %s: encountered error while attempting removal', fullPath, err); removeCallback(err); } else { log.debug('queueRequest: %s: successfully removed previously queued request', fullPath); removeCallback(); } }); }; var insert = function (insertReqMethod, insertPath, insertName, insertDestPath, insertDestName, insertCallback) { log.debug('queueRequest: %s: preparing to insert ' + insertReqMethod + ' request', fullPath); var record = { method: insertReqMethod, timestamp: Date.now(), retries: 0, path: insertPath, name: insertName, localPrefix: localPrefix, remotePrefix: remotePrefix }; if (insertDestPath) { record['destPath'] = insertDestPath; record['destName'] = insertDestName; } self.db.insert(record, function (err, newDoc) { if (err) { insertCallback(err); } else { insertCallback(); } }); }; var processMethod = function (methodToProcess, processPath, processName, processDestPath, processDestName, processCallback) { self.db.findOne({$and: [{path: processPath, name: processName}]}, function (err, doc) { if (err) { log.warn('queueRequest: %s: unexpected error while retrieving existing requests', fullPath, err); processCallback(err); } else { log.debug('queueRequest: %s: finished querying for cached file %s', fullPath, processPath); if (doc !== null) { log.debug('queueRequest: %s: already queued for %s', fullPath, doc.method); var update = (doc.method == 'PUT' || doc.method == 'POST'); // the file has already been queued. Run through a series of test to determine what should happen if (methodToProcess == 'DELETE') { // the file is being deleted. any previously queued actions should be removed. log.debug('queueRequest: %s: queuing for delete. removing previously queued %s', fullPath, doc.method); // only queue the deletion if the file isn't newly added remove(doc, function (err) { if (err) { processCallback(err); } else if (doc.method != 'PUT') { insert(methodToProcess, processPath, processName, processDestPath, processDestName, function (err) { if (err) { processCallback(err); } else { processCallback(null, update, processPath, processName); } }); } else { processCallback(null, update, processPath, processName); } }); } else if (doc.method == 'PUT' || doc.method == 'POST') { // update timestamp log.debug('queueRequest: %s: updating timestamp of existing record', fullPath); self.db.update({_id: doc._id}, {$set: {timestamp: new Date().getTime()}}, {}, function (err) { if (err) { processCallback(err); } else { processCallback(null, update, processPath, processName); } }); } else if (doc.method == 'DELETE') { // file is being re-created log.debug('queueRequest: %s: %s previously queued. changing to POST %s', fullPath, doc.method, processPath); // change to update instead remove(doc, function (err) { if (err) { processCallback(err); } else { insert('POST', processPath, processName, processDestPath, processDestName, function (err) { if (err) { processCallback(err); } else { processCallback(null, update, processPath, processName); } }); } }); } else { log.warn('queueRequest: %s: unhandled method: ' + doc.method, fullPath); processCallback(null, update, processPath, processName); } } else { log.debug('queueRequest: %s: queuing originally submitted %s to %s', fullPath, methodToProcess, processPath); insert(methodToProcess, processPath, processName, processDestPath, processDestName, processCallback); } } }); }; var handleResult = function (err, sendUpdate, resultPath, resultName) { if (err) { callback(err); } else { if (sendUpdate) { self.emit('itemupdated', Path.join(resultPath, resultName)); } log.debug('queueRequest: %s: emitting queuechanged event', fullPath); self.emit('queuechanged'); callback(); } }; if (reqMethod == 'COPY') { log.debug('queueRequest: %s: queueing for COPY. processing PUT for destination', fullPath, reqMethod); processMethod('PUT', destPath, destName, null, null, handleResult); } else if (reqMethod == 'MOVE') { log.debug('queueRequest: %s: queueing for MOVE. processing DELETE for source', fullPath, reqMethod); processMethod('DELETE', path, name, null, null, function (err) { if (err) { callback(err); } else { log.debug('queueRequest: %s: queueing for MOVE. processing %w for destination', fullPath, reqMethod, moveTarget); processMethod(moveTarget, destPath, destName, null, null, handleResult); } }); } else { processMethod(reqMethod, path, name, destPath, destName, handleResult); } } }; // export this class module.exports = RequestQueue;