UNPKG

node-smb-server

Version:

A Pure JavaScript SMB Server Implementation

549 lines (492 loc) 18.2 kB
/* * 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. */ 'use strict'; // 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;