node-smb-server
Version:
A Pure JavaScript SMB Server Implementation
549 lines (492 loc) • 18.2 kB
JavaScript
/*
* Copyright 2015 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.
*/
;
// enable DNS caching, which will improve performance of HTTP requests because there will not need to be a DNS lookup
// for every request. node.js does not provide DNS caching capabilities by default.
require('dnscache')({
'enable': true,
'ttl': 300,
'cachesize': 1000
});
var fs = require('fs');
var Path = require('path');
var Util = require('util');
var _ = require('lodash');
var async = require('async');
var logger = require('winston').loggers.get('spi');
var tmp = require('temp').track(); // cleanup on exit
var Share = require('../../spi/share');
var FSShare = require('../fs/share');
var JCRTree = require('./tree');
var JCR = require('./constants');
var utils = require('../../utils');
var webutils = require('../../webutils');
var ntstatus = require('../../ntstatus');
var SMBError = require('../../smberror');
var mkdirp = require('mkdirp');
/**
* Creates an instance of JCRShare.
*
* @constructor
* @this {JCRShare}
* @param {String} name share name
* @param {Object} config configuration hash
*/
var JCRShare = function (name, config) {
if (!(this instanceof JCRShare)) {
return new JCRShare(name, config);
}
config = config || {};
Share.call(this, name, config);
this.host = config.host;
this.port = config.port || 80;
this.auth = config.auth;
this.path = config.path;
this.protocol = config.protocol || 'http:';
this.maxSockets = config.maxSockets || 32;
// path prefix for .<depth>.json requests
//this.jsonServletPath = ''; // Sling Default Get Servlet
this.jsonServletPath = '/crx/server/crx.default/jcr%3aroot'; // DAVEX
this.description = config.description || '';
// TTL before all caches will be cleared
this.allCacheClear = Date.now();
this.allCacheTTL = typeof config.allCacheTTL === 'number' ? config.allCacheTTL : 1800000; // default: 30m
// TTL in ms for content cache entries
this.contentCacheTTL = typeof config.contentCacheTTL === 'number' ? config.contentCacheTTL : 30000; // default: 30s
// TTL in ms for cached binaries
this.binCacheTTL = typeof config.binCacheTTL === 'number' ? config.binCacheTTL : 300000; // default: 5m
this.cachedFolderListings = {};
this.cachedFileEntries = {};
this.cachedBinaries = {};
this.lastLongDownload = 0;
};
// the JCRShare prototype inherits from Share
Util.inherits(JCRShare, Share);
JCRShare.prototype.isFilePrimaryType = function (primaryType) {
return [ JCR.NT_FILE ].indexOf(primaryType) > -1;
};
JCRShare.prototype.isDirectoryPrimaryType = function (primaryType) {
return [ JCR.NT_FOLDER, JCR.SLING_FOLDER, JCR.SLING_ORDEREDFOLDER ].indexOf(primaryType) > -1;
};
JCRShare.prototype.parseContentChildEntries = function (content, iterator) {
var self = this;
_.forOwn(content, function(entry, nm) {
if (typeof entry === 'object' && entry[JCR.JCR_PRIMARYTYPE]
&& (self.isFilePrimaryType(entry[JCR.JCR_PRIMARYTYPE])
|| self.isDirectoryPrimaryType(entry[JCR.JCR_PRIMARYTYPE]))) {
iterator(nm, entry);
}
});
};
JCRShare.prototype.buildContentUrl = function (path, depth) {
return this.protocol + '//' + this.host + ':' + this.port + this.jsonServletPath + encodeURI(utils.normalizeSMBFileName(Path.join(this.path, path))) + '.' + depth + '.json';
};
JCRShare.prototype.buildResourceUrl = function (path) {
return this.protocol + '//' + this.host + ':' + this.port + encodeURI(utils.normalizeSMBFileName(Path.join(this.path, path)));
};
JCRShare.prototype.getContent = function (path, deep, cb) {
// clear all caches periodically to ensure that memory consumption remains in check
if (Date.now() - this.allCacheClear >= this.allCacheTTL) {
logger.debug('clearing all content caches');
this.cachedFolderListings = {};
this.cachedFileEntries = {};
this.cachedBinaries = {};
this.allCacheClear = Date.now();
}
var cache = deep ? this.cachedFolderListings : this.cachedFileEntries;
var result = cache[path];
if (result) {
if (Date.now() - result.fetched <= this.contentCacheTTL) {
//logger.debug('returning cached content %s', path);
cb(null, result);
return;
} else {
delete cache[path];
}
}
var self = this;
logger.debug('fetching content %s, deep=%s', path, deep);
this.fetchContent(path, deep, function (err, content) {
if (err) {
cb(err);
return;
}
if (content) {
// cached root never expires
content.fetched = path === Path.sep && !deep ? Number.MAX_SAFE_INTEGER : Date.now();
cache[path] = content;
//logger.debug('cached content %s', path);
if (deep) {
// populate self.cachedFileEntries with child entries
self.parseContentChildEntries(content, function (childName, childContent) {
childContent.fetched = Date.now();
var childPath = Path.join(path, childName);
self.cachedFileEntries[childPath] = childContent;
//logger.debug('cached content %s', childPath);
});
}
} else {
// content not found: invalidate stale cache entries
self.invalidateContentCache(path, deep);
}
cb(null, content);
});
};
JCRShare.prototype.invalidateContentCache = function (path, deep) {
if (this.cachedFileEntries[path]) {
// file/directory entry
//logger.debug('invalidating cached entry %s', path);
delete this.cachedFileEntries[path];
// invalidate parent folder listing as well
var parentPath = utils.getParentPath(path);
//logger.debug('invalidating cached directory listing %s', parentPath);
delete this.cachedFolderListings[parentPath];
}
if (this.cachedFolderListings[path]) {
// directory listing
//logger.debug('invalidating cached directory listing %s', path);
delete this.cachedFolderListings[path];
// make sure child entries get invalidated as well
deep = true;
}
var pathPrefix = path + Path.sep;
function iterate(content, p, cache) {
if (p.indexOf(pathPrefix) === 0) {
//logger.debug('invalidating cached content %s', path);
delete cache[p];
}
}
if (deep) {
_.forOwn(this.cachedFileEntries, iterate);
_.forOwn(this.cachedFolderListings, iterate);
}
};
JCRShare.prototype.fetchContent = function (path, deep, cb) {
if (path === Path.sep) {
path = '';
}
var depth = deep ? 2 : 1;
var url = this.buildContentUrl(path, depth);
var opts = this.applyRequestDefaults(null, url);
webutils.submitRequest(opts, function (err, resp, body) {
if (err) {
cb(err);
} else if (resp.statusCode === 200) {
// succeeded
try {
cb(null, JSON.parse(body));
} catch (parseError) {
cb(parseError);
}
} else if (resp.statusCode === 404) {
// not found, return null
cb(null, null);
} else {
// failed
cb(this.method + ' ' + this.href + ' [' + resp.statusCode + '] ' + body || '');
}
});
};
/**
* Returns the path of local file holding a copy of the remote resource's content.
*
* @param {String} path path of remote resource
* @param {Number} lastModified remote resource's last modification time stamp (used to detect stale cache entries)
* @param {Function} cb callback called with the path of the local file holding a copy of the remote resource's content.
* @param {String|Error} cb.error error (non-null if an error occurred)
* @param {String} cb.localFilePath path of local file holding a copy of the remote resource's content.
*/
JCRShare.prototype.getLocalFile = function (path, lastModified, cb) {
var self = this;
function checkCache(path, callback) {
var result = self.cachedBinaries[path];
if (result) {
if (Date.now() - result.fetched <= self.binCacheTTL && lastModified <= result.lastModified) {
// valid cache entry, verify
fs.stat(result.localFilePath, function (err, stats) {
if (err) {
logger.warn('detected corrupt cache entry %s: local copy %s cannot be found', path, result.localFilePath, err);
delete self.cachedBinaries[path];
result = null;
}
callback(null, result);
});
return;
} else {
// evict expired cache entry
delete self.cachedBinaries[path];
fs.unlink(result.localFilePath, function (ignored) {});
result = null;
// fall through
}
}
callback(null, result);
}
function cacheResource(path, callback) {
logger.debug('fetching resource %s', path);
self.fetchResource(path, function (err, localFilePath) {
if (err) {
cb(err);
return;
}
// create cache entry
if (localFilePath) {
self.cachedBinaries[path] = {
localFilePath: localFilePath,
lastModified: lastModified,
fetched: Date.now()
};
//logger.debug('cached resource %s', path);
}
callback(null, localFilePath);
});
}
// check cache
checkCache(path, function (err, result) {
if (err) {
cb(err);
} else if (result) {
// found valid cache entry. we're done
cb(null, result.localFilePath);
} else {
// fetch resource and cache it
cacheResource(path, cb);
}
});
};
/**
* Touches the cache entry of a remote resource, i.e. extends the entry's TTL and updates the lastModified timestamp.
* The cached local file itself won't be touched or modified.
*
* @param {String} path path of remote resource
* @param {Number} lastModified remote resource's last modification time stamp (used to detect stale cache entries)
* @param {Function} cb callback called with the path of the local file holding a copy of the remote resource's content.
* @param {String|Error} cb.error error (non-null if an error occurred)
*/
JCRShare.prototype.touchLocalFile = function (path, lastModified, cb) {
var result = this.cachedBinaries[path];
if (result) {
fs.stat(result.localFilePath, function (err, stats) {
if (!err) {
result.lastModified = lastModified;
result.fetched = Date.now();
cb();
} else {
logger.warn('detected corrupt cache entry %s: local copy %s cannot be found', err);
delete self.cachedBinaries[path];
cb(err);
}
});
}
};
/**
* Removes the cache entry and discards the local file holding a copy of the remote resource's content.
*
* @param {String} path path of remote resource
* @param {Function} cb callback called on completion
* @param {String|Error} cb.error error (non-null if an error occurred)
*/
JCRShare.prototype.discardLocalFile = function (path, cb) {
var result = this.cachedBinaries[path];
if (result) {
delete this.cachedBinaries[path];
fs.unlink(result.localFilePath, cb);
} else {
cb();
}
};
/**
* Fetches the specified remote resource and returns the path of the local file holding a copy of the remote resource's content.
*
* @param {String} path path of remote resource
* @param {Function} cb callback called with the path of the local file holding a copy of the remote resource's content.
* @param {String|Error} cb.error error (non-null if an error occurred)
* @param {String} cb.localFilePath path of local file holding a copy of the remote resource's content.
*/
JCRShare.prototype.fetchResource = function (path, cb) {
// spool remote resource to local tmp file
var stream = this.createResourceStream(path);
var tmpFilePath = stream.path;
var self = this;
var failed = false;
stream.on('finish', function () {
if (failed) {
fs.unlink(tmpFilePath, function (ignored) {
cb('failed to spool ' + path + ' to ' + tmpFilePath);
});
} else {
fs.stat(tmpFilePath, function (err, stats) {
if (err) {
cb(err);
} else {
logger.debug('[%s] spooled %s to %s (%d bytes)', self.config.backend, path, tmpFilePath, stats.size);
cb(null, tmpFilePath);
}
});
}
});
stream.on('error', function (err) {
fs.unlink(tmpFilePath, function (ignored) {
cb(err);
});
});
var url = this.buildResourceUrl(path);
var options = this.applyRequestDefaults(null, url);
self.emit('downloadstart', {path: path});
webutils.submitRequest(options)
.on('response', function (resp) {
if (resp.statusCode !== 200) {
logger.error('failed to spool %s to %s - %s %s [%d]', path, tmpFilePath, this.method, this.href, resp.statusCode);
self.emit('downloaderr', {path: path, err: 'unexpected status code: ' + resp.statusCode});
failed = true;
}
var totalSize = 0;
if (resp.headers) {
// content length is not always provided
if (resp.headers['content-length']) {
totalSize = resp.headers['content-length'];
}
}
webutils.monitorTransferProgress(resp, path, tmpFilePath, totalSize, function (progress) {
logger.debug('received download progress for %s', path, progress);
// only send long download event once every 30 seconds
var now = new Date().getTime();
if (progress.elapsed > 3000 && (now - self.lastLongDownload > 30000)) {
self.lastLongDownload = now;
logger.info('long download of %s detected, sending event', path);
self.emit('longdownload', {path: path});
}
self.emit('downloadprogress', progress);
});
})
.on('end', function () {
self.emit('downloadend', {path: path});
})
.on('error', function (err) {
self.emit('downloaderr', {path: path, err: err});
fs.unlink(tmpFilePath, function (ignored) {
cb(err);
});
})
.pipe(stream);
};
JCRShare.prototype.createResourceStream = function (path) {
return tmp.createWriteStream({
suffix: '-' + utils.getPathName(path)
});
};
JCRShare.prototype.createTreeInstance = function (content, tempFilesTree) {
return new JCRTree(this, content, tempFilesTree);
};
JCRShare.prototype.applyRequestDefaults = function (opts, url) {
var def = {};
if (url) {
def.url = url;
}
if (this.auth) {
def.auth = this.auth;
}
// limit/throttle # of concurrent backend requests
def.pool = { maxSockets: this.maxSockets };
if (this.config.strictSSL !== undefined) {
def.strictSSL = this.config.strictSSL;
}
return _.defaultsDeep(def, opts, this.config.options);
};
//--------------------------------------------------------------------< Share >
/**
* Return a flag indicating whether this is a named pipe share.
*
* @return {Boolean} <code>true</code> if this is a named pipe share;
* <code>false</code> otherwise, i.e. if it is a disk share.
*/
JCRShare.prototype.isNamedPipe = function () {
return false;
};
/**
*
* @param {Session} session
* @param {Buffer|String} shareLevelPassword optional share-level password (may be null)
* @param {Function} cb callback called with the connect tree
* @param {SMBError} cb.error error (non-null if an error occurred)
* @param {JCRTree} cb.tree connected tree
*/
JCRShare.prototype.connect = function (session, shareLevelPassword, cb) {
var self = this;
this.purgeCacheTimer = setInterval(function () {
var now = Date.now();
function iterate(content, path, cache) {
if (now - content.fetched > self.contentCacheTTL) {
delete cache[path];
}
}
_.forOwn(self.cachedFileEntries, iterate);
_.forOwn(self.cachedFolderListings, iterate);
}, this.contentCacheTTL);
function getContent(done) {
self.getContent(Path.sep, false, done);
}
function createTempDir(content, done) {
if (!content) {
done('not found');
return;
}
if (!self.config.tmpPath) {
tmp.mkdir('NodeSMBServerTmpFiles_', function (err, dirPath) {
if (!err) {
logger.debug('created local tmp directory for temporary system files: %s', dirPath);
}
done(err, content, dirPath);
});
} else {
mkdirp(self.config.tmpPath, function (err) {
if (!err) {
logger.debug('created local tmp directory for temporary system files: %s', self.config.tmpPath);
}
done(err, content, self.config.tmpPath);
});
}
}
function prepopulateTempDir(content, tempDir, done) {
fs.closeSync(fs.openSync(Path.join(tempDir, '.metadata_never_index'), 'w'));
fs.closeSync(fs.openSync(Path.join(tempDir, '.metadata_never_index_unless_rootfs'), 'w'));
//fs.closeSync(fs.openSync(Path.join(tempDir, '.com.apple.smb.streams.off'), 'w'));
done(null, content, tempDir);
}
function connectTempTree(content, tempDir, done) {
var tmpShare = new FSShare('tmpFiles', {
backend: 'fs',
description: 'shadow share for local temporary system files',
path: tempDir
});
tmpShare.connect(session, null, function (error, tmpTree) {
done(error, content, tmpTree);
});
}
function connectJCRTree(content, tempTree, done) {
done(null, self.createTreeInstance(content, tempTree));
}
async.waterfall([ getContent, createTempDir, prepopulateTempDir, connectTempTree, connectJCRTree ], function (err, tree) {
if (err) {
var msg = 'invalid share configuration: ' + JSON.stringify({ host: self.config.host, port: self.config.port, path: self.config.path });
logger.error(msg, err);
cb(SMBError.fromSystemError(err, msg));
} else {
cb(null, tree);
}
});
};
JCRShare.prototype.disconnect = function (cb) {
clearInterval(this.purgeCacheTimer);
tmp.cleanup(cb);
};
module.exports = JCRShare;