node-smb-server
Version:
A Pure JavaScript SMB Server Implementation
553 lines (514 loc) • 21.8 kB
JavaScript
/*
* 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.
*/
;
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;