node-smb-server
Version:
A Pure JavaScript SMB Server Implementation
570 lines (519 loc) • 18.8 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.
*/
'use strict';
var Util = require('util');
var fs = require('fs');
var logger = require('winston').loggers.get('spi');
var async = require('async');
var File = require('../../spi/file');
var ntstatus = require('../../ntstatus');
var SMBError = require('../../smberror');
var JCR = require('./constants');
var utils = require('../../utils');
var webutils = require('../../webutils');
/**
* Creates an instance of File.
*
* @constructor
* @private
* @this {JCRFile}
* @param {String} filePath normalized file path
* @param {Object} content JCR file content representation
* @param {Number} fileLength file length
* @param {JCRTree} tree tree object
*/
var JCRFile = function (filePath, content, fileLength, tree) {
logger.debug('[%s] file.open %s', tree.share.config.backend, filePath);
if (!(this instanceof JCRFile)) {
return new JCRFile(filePath, content, fileLength, tree);
}
this.content = content;
this.tree = tree;
this.fileLength = fileLength;
// object holding path/fd of local copy of remote resource
this.localFile = null;
// needs flushing?
this.dirty = false;
File.call(this, filePath, tree);
};
// the JCRFile prototype inherits from File
Util.inherits(JCRFile, File);
/**
* Async factory method
*
* @param {String} filePath normalized file path
* @param {JCRTree} tree tree object
* @param {Object} [content=null] file meta data (null if unknown)
* @param {Number} [fileLength=-1] file length (-1 if unknown)
* @param {Function} cb callback called with the result
* @param {SMBError} cb.error error (non-null if an error occurred)
* @param {JCRFile} cb.file JCRFile instance
*/
JCRFile.createInstance = function (filePath, tree, content, fileLength, cb) {
content = typeof content === 'object' ? content : null;
fileLength = typeof fileLength === 'number' ? fileLength : -1;
cb = arguments[arguments.length - 1];
if (typeof cb !== 'function') {
logger.error(new Error('JCRFile.createInstance: called without callback'));
cb = function () {};
}
function getContent(callback) {
if (content) {
callback(null, content);
} else {
tree.share.getContent(filePath, false, function (err, content) {
if (content) {
callback(err, content);
} else {
callback(err || 'not found: ' + filePath);
}
});
}
}
function getFileLength(content, callback) {
if (fileLength > -1) {
callback(null, content, fileLength);
} else if (!tree.share.isFilePrimaryType(content[JCR.JCR_PRIMARYTYPE])) {
// folder has length 0
callback(null, content, 0);
} else if (typeof content[JCR.JCR_CONTENT][JCR.JCR_DATA_LENGTH] === 'number') {
callback(null, content, content[JCR.JCR_CONTENT][JCR.JCR_DATA_LENGTH]);
} else {
// last resort: send a separate request for file length
tree.fetchFileLength(filePath, function (err, length) {
if (err) {
callback(err);
} else {
callback(null, content, length);
}
});
}
}
async.seq(getContent, getFileLength)(function (err, metaData, length) {
if (err) {
cb(SMBError.fromSystemError(err, 'unable to create file instance due to unexpected error ' + filePath));
} else {
cb(null, new JCRFile(filePath, metaData, length, tree));
}
});
};
/**
* Returns path and fd of local file holding a copy of the remote resource's content.
*
* @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 {Object} cb.localFile object holding path/fd of local copy of remote resource
* @param {String} cb.localFile.path path of local file holding a copy of the remote resource's content.
* @param {Number} cb.localFile.fd file handle to opened local file
*/
JCRFile.prototype.ensureGotLocalCopy = function (cb) {
if (this.localFile) {
cb(null, this.localFile);
return;
}
var self = this;
this.tree.share.getLocalFile(this.filePath, this.lastModified(), function (err, localFilePath) {
if (err) {
cb(err);
} else {
fs.open(localFilePath, 'r+', function (err, fd) {
if (err) {
logger.error('failed to open local file %s (%s)', localFilePath, self.filePath, err);
cb(err);
} else {
self.localFile = {
path: localFilePath,
fd: fd
};
cb(null, self.localFile);
}
});
}
});
};
/**
* Uploads the local tmp file to the server if there are pending changes.
*
* @param {Function} cb callback called on completion.
* @param {String|Error} cb.error error (non-null if an error occurred)
*/
JCRFile.prototype.syncLocalChanges = function (cb) {
if (!this.localFile || !this.dirty) {
// no local changes, we're done
cb();
return;
}
logger.debug('[%s] file.syncLocalChanges %s', this.tree.share.config.backend, this.filePath);
// deferred write (spool local tmp file to server)
var self = this;
var url = this.tree.share.buildResourceUrl(this.filePath);
var options = this.tree.share.applyRequestDefaults({
url: url,
method: 'PUT',
headers: {
'Content-Type': utils.lookupMimeType(this.filePath)
}
});
this.ensureGotLocalCopy(function (err, localFile) {
if (err) {
cb(SMBError.fromSystemError(err, 'unable to get local copy due to unexpected error ' + self.filePath));
return;
}
fs.createReadStream(null, { fd: localFile.fd, autoClose: false }).pipe(
webutils.submitRequest(options, function (err, resp, body) {
if (err) {
logger.error('failed to spool %s to %s', self.localFile.path, self.filePath, err);
cb(SMBError.fromSystemError(err, 'unable to submit request due to unexpected error ' + self.filePath));
} else if (resp.statusCode !== 200 && resp.statusCode !== 204) {
logger.error('failed to spool %s to %s - %s %s [%d]', localFile.path, self.filePath, this.method, this.href, resp.statusCode, body);
cb(new SMBError(ntstatus.STATUS_UNSUCCESSFUL, 'unable to sync local changes due to unexpecte status code ' + resp.statusCode + ' ' + self.filePath));
} else {
// succeeded
fs.fstat(localFile.fd, function (err, stats) {
if (err) {
cb(err);
} else {
// invalidate content cache
self.tree.share.invalidateContentCache(self.filePath, false);
// update length and lastModified
self.fileLength = stats.size;
self.setLastModified(stats.mtime.getTime());
self.dirty = false;
// touch cache entry
self.tree.share.touchLocalFile(self.filePath, self.lastModified(), function (err) {
cb(err);
});
}
});
}
})
);
});
};
/**
* Updates the content representation of the fileLength and lastModified attributes.
*
* @param {Object} content content object to patch
* @param {Number} fileLength
* @param {Number} lastModified (ms)
*/
JCRFile.prototype.patchContent = function (content, fileLength, lastModified) {
// update fileLength content representation
this.content[JCR.JCR_CONTENT][JCR.JCR_DATA_LENGTH] = fileLength;
// update lastModified content representation
this.content[JCR.JCR_CONTENT][JCR.JCR_LASTMODIFIED] = new Date(lastModified).toISOString();
};
//---------------------------------------------------------------------< File >
/**
* Return a flag indicating whether this is a file.
*
* @return {Boolean} <code>true</code> if this is a file;
* <code>false</code> otherwise
*/
JCRFile.prototype.isFile = function () {
return this.tree.share.isFilePrimaryType(this.content[JCR.JCR_PRIMARYTYPE]);
};
/**
* Return a flag indicating whether this is a directory.
*
* @return {Boolean} <code>true</code> if this is a directory;
* <code>false</code> otherwise
*/
JCRFile.prototype.isDirectory = function () {
return this.tree.share.isDirectoryPrimaryType(this.content[JCR.JCR_PRIMARYTYPE]);
};
/**
* Return a flag indicating whether this file is read-only.
*
* @return {Boolean} <code>true</code> if this file is read-only;
* <code>false</code> otherwise
*/
JCRFile.prototype.isReadOnly = function () {
return this.content[JCR.JCR_ISCHECKEDOUT] === false;
};
/**
* Return the file size.
*
* @return {Number} file size, in bytes
*/
JCRFile.prototype.size = function () {
return this.fileLength;
};
/**
* Return the number of bytes that are allocated to the file.
*
* @return {Number} allocation size, in bytes
*/
JCRFile.prototype.allocationSize = function () {
return this.isFile() ? this.size() : 0;
};
/**
* Return the time of last modification, in milliseconds since
* Jan 1, 1970, 00:00:00.0.
*
* @return {Number} time of last modification
*/
JCRFile.prototype.lastModified = function () {
if (this.isFile() && this.content[JCR.JCR_CONTENT][JCR.JCR_LASTMODIFIED]) {
return new Date(this.content[JCR.JCR_CONTENT][JCR.JCR_LASTMODIFIED]).getTime();
} else {
return this.created();
}
};
/**
* Sets the time of last modification, in milliseconds since
* Jan 1, 1970, 00:00:00.0.
*
* @param {Number} ms
* @return {Number} time of last modification
*/
JCRFile.prototype.setLastModified = function (ms) {
if (this.isFile()) {
// update lastModified (transient)
this.content[JCR.JCR_CONTENT][JCR.JCR_LASTMODIFIED] = new Date(ms).toISOString();
}
};
/**
* Return the time when file status was last changed, in milliseconds since
* Jan 1, 1970, 00:00:00.0.
*
* @return {Number} when file status was last changed
*/
JCRFile.prototype.lastChanged = function () {
// todo correct?
return this.created();
};
/**
* Return the create time, in milliseconds since Jan 1, 1970, 00:00:00.0.
* Jan 1, 1970, 00:00:00.0.
*
* @return {Number} time created
*/
JCRFile.prototype.created = function () {
return new Date(this.content[JCR.JCR_CREATED]).getTime();
};
/**
* Return the time of last access, in milliseconds since Jan 1, 1970, 00:00:00.0.
* Jan 1, 1970, 00:00:00.0.
*
* @return {Number} time of last access
*/
JCRFile.prototype.lastAccessed = function () {
// todo correct?
return this.lastModified();
};
/**
* Read bytes at a certain position inside the file.
*
* @param {Buffer} buffer the buffer that the data will be written to
* @param {Number} offset the offset in the buffer to start writing at
* @param {Number} length the number of bytes to read
* @param {Number} position offset where to begin reading from in the file
* @param {Function} cb callback called with the bytes actually read
* @param {SMBError} cb.error error (non-null if an error occurred)
* @param {Number} cb.bytesRead number of bytes actually read
* @param {Buffer} cb.buffer buffer holding the bytes actually read
*/
JCRFile.prototype.read = function (buffer, offset, length, position, cb) {
logger.debug('[%s] file.read %s offset=%d, length=%d, position=%d', this.tree.share.config.backend, this.filePath, offset, length, position);
var self = this;
function getLocalFile(callback) {
self.ensureGotLocalCopy(callback);
}
function read(localFile, callback) {
fs.read(localFile.fd, buffer, offset, length, position, callback);
}
async.waterfall([ getLocalFile, read ], function (err, bytesRead, buffer) {
if (err) {
err = SMBError.fromSystemError(err, 'unable to read file due to unexpected error ' + self.filePath);
}
cb(err, bytesRead, buffer);
});
};
/**
* Write bytes at a certain position inside the file.
*
* @param {Buffer} data buffer to write
* @param {Number} position position inside file
* @param {Function} cb callback called on completion
* @param {SMBError} cb.error error (non-null if an error occurred)
*/
JCRFile.prototype.write = function (data, position, cb) {
logger.debug('[%s] file.write %s data.length=%d, position=%d', this.tree.share.config.backend, this.filePath, data.length, position);
var self = this;
function getLocalFile(callback) {
self.ensureGotLocalCopy(callback);
}
function write(localFile, callback) {
fs.write(localFile.fd, data, 0, data.length, position, function (err) {
callback(err, localFile);
});
}
function updateStats(localFile, callback) {
fs.fstat(localFile.fd, function (err, stats) {
if (err) {
callback(err);
} else {
// update length and lastModified
self.fileLength = stats.size;
self.setLastModified(stats.mtime.getTime());
self.dirty = true;
// touch cache entry
self.tree.share.touchLocalFile(self.filePath, self.lastModified(), function (err) {
callback(err);
});
}
});
}
async.waterfall([ getLocalFile, write, updateStats ], function (err) {
if (err) {
err = SMBError.fromSystemError(err, 'unable to write file due to unexpected error ' + self.filePath);
}
cb(err);
});
};
/**
* Sets the file length.
*
* @param {Number} length file length
* @param {Function} cb callback called on completion
* @param {SMBError} cb.error error (non-null if an error occurred)
*/
JCRFile.prototype.setLength = function (length, cb) {
logger.debug('[%s] file.setLength %s length=%d', this.tree.share.config.backend, this.filePath, length);
// todo avoid spooling the entire file if length is 0, just create an empty local tmp file
var self = this;
this.ensureGotLocalCopy(function (err, localFile) {
if (err) {
cb(SMBError.fromSystemError(err, 'unable to set file length due to unexpected error ' + self.filePath));
return;
}
fs.ftruncate(localFile.fd, length, function (err) {
if (err) {
cb(SMBError.fromSystemError(err, 'unable to truncate file due to unexpected error ' + self.filePath));
} else {
// update length and lastModified
self.fileLength = length;
var lastModified = Date.now();
self.setLastModified(lastModified);
self.dirty = true;
// InDesign repeatedly sets (via TRANS2_SET_FILE_INFORMATION) & checks (via TRANS2_FIND_FIRST2) file length during saving.
// since the file is just truncated locally (i.e. not yet flushed/closed) and the length is returned through a backend request
// conflicting (i.e non-expected) length values lead to InDesign crashing.
// a conservative solution would be syncing (i.e. uploading) the file on setLength but is very inefficient, especially for large files.
// @FIXME workaround to avoid InDesign file corruption: refresh and patch content cache entry with new length
self.tree.share.getContent(self.filePath, false, function (err, content) {
if (content) {
self.patchContent(content, length, lastModified);
content.fetched = Date.now(); // touch cache entry
}
cb(err);
});
}
});
});
};
/**
* Delete this file or directory. If this file denotes a directory, it must
* be empty in order to be deleted.
*
* @param {Function} cb callback called on completion
* @param {SMBError} cb.error error (non-null if an error occurred)
*/
JCRFile.prototype.delete = function (cb) {
logger.debug('[%s] file.delete %s', this.tree.share.config.backend, this.filePath);
var url = this.tree.share.buildResourceUrl(this.filePath);
var options = this.tree.share.applyRequestDefaults({
url: url,
method: 'DELETE'
});
var self = this;
webutils.submitRequest(options, function (err, resp, body) {
if (err) {
logger.error('failed to delete %s', self.filePath, err);
cb(SMBError.fromSystemError(err, 'unable to delete file due to unexpected error ' + self.filePath));
} else if (resp.statusCode !== 204) {
logger.error('failed to delete %s - %s %s [%d]', self.filePath, this.method, this.href, resp.statusCode, body);
cb(new SMBError(ntstatus.STATUS_UNSUCCESSFUL, 'unable to delete file due to unexpected ' + resp.statusCode + ' status code ' + self.filePath));
} else {
// succeeded
// invalidate cache
self.tree.share.invalidateContentCache(self.filePath, self.isDirectory());
self.dirty = false;
if (self.localFile) {
fs.close(self.localFile.fd, function (ignored) {
self.localFile = null;
self.tree.share.discardLocalFile(self.filePath, function (ignored) {
cb();
});
});
} else {
self.tree.share.discardLocalFile(self.filePath, function (ignored) {
cb();
});
}
}
});
};
/**
* Flush the contents of the file to disk.
*
* @param {Function} cb callback called on completion
* @param {SMBError} cb.error error (non-null if an error occurred)
*/
JCRFile.prototype.flush = function (cb) {
logger.debug('[%s] file.flush %s', this.tree.share.config.backend, this.filePath);
if (!this.localFile) {
// we're done
cb();
return;
}
fs.fsync(this.localFile.fd, function (err) {
if (err) {
logger.warn('failed to flush local file %s (%s)', self.localFile.path, self.filePath, err);
}
cb();
});
};
/**
* Close this file, releasing any resources.
*
* @param {Function} cb callback called on completion
* @param {SMBError} cb.error error (non-null if an error occurred)
*/
JCRFile.prototype.close = function (cb) {
logger.debug('[%s] file.close %s', this.tree.share.config.backend, this.filePath);
if (!this.localFile) {
// we're done
cb();
return;
}
var self = this;
function sync(callback) {
if (self.dirty) {
self.syncLocalChanges(callback);
} else {
callback();
}
}
function cleanup(callback) {
fs.close(self.localFile.fd, function (err) {
if (err) {
logger.warn('failed to close local file %s (%s)', self.localFile.path, self.filePath, err);
}
self.localFile = null;
callback();
});
}
async.series([ sync, cleanup ], cb);
};
module.exports = JCRFile;