plugins/network/sftp_client.js

const Client = require('ssh2-sftp-client');
const { Context } = require('../../core/context');
const fs = require('fs');
const { OperationResult } = require('../../core/operation_result');
const { Plugin } = require('../plugin');
const { StorageService } = require('../../core/storage_service');
const { Util } = require('../../core/util');


const defaultPutOptions = {
    flags: 'w',
    encoding: null,
    mode: 0o644,
    autoClose: true,
}

class SFTPClient extends Plugin {

    /**
     * Creates a new SFTPClient.
     *
     * @param {StorageService} storageService - A StorageService record that
     * includes information about how to connect to a remote SFTP service.
     * This record includes the host URL, default folder, and connection
     * credentials.
     */
    constructor(storageService) {
        super();
        this.storageService = storageService;
    }

    /**
     * Returns a {@link PluginDefinition} object describing this plugin.
     *
     * @returns {PluginDefinition}
     */
    static description() {
        return {
            id: 'aa7bb977-59b9-4f08-99a9-dfcc16632728',
            name: 'SFTPClient',
            description: 'Built-in DART SFTP network client',
            version: '0.1',
            readsFormats: [],
            writesFormats: [],
            implementsProtocols: ['sftp'],
            talksToRepository: [],
            setsUp: []
        };
    }

    /**
     * Uploads a file to the remote server. The name of the directory into
     * which the file will be uploaded is determined by the bucket property
     * of the {@link StorageService} passed in to this class'
     * constructor.
     *
     * @param {string} filepathOrBuffer - The path to the local file, or a
     * data buffer to be uploaded to the SFTP server.
     *
     * @param {string} keyname - This name to assign the file on the remote
     * server. This parameter is optional. If not specified, it defaults to
     * the basename of filepath. That is, /path/to/bagOfPhotos.tar would
     * default to bagOfPhotos.tar.
     *
     */
    upload(filepathOrBuffer, keyname) {
        let sftp = this;
        if (!filepathOrBuffer) {
            throw new Error('Param filepathOrBuffer is required for upload.');
        }
        if (filepathOrBuffer instanceof String && !keyname) {
            keyname = path.basename(filepathOrBuffer);
        }
        // Don't use path.join; force forward slash instead.
        let remoteFilepath =  keyname;
        if (this.storageService.bucket) {
            remoteFilepath = `${this.storageService.bucket}/${keyname}`;
        }
        let client = new Client();
        let result = this._initUploadResult(filepathOrBuffer, remoteFilepath);
        let connSettings = null;
        let succeeded = false;
        let errorWasHandled = false;
        try { connSettings = this._getConnSettings(); }
        catch (err) {
            result.finish(err);
            sftp.emit('error', result);
            return;
        }
        // Catch unexpected socket closure. Somehow, that does not
        // cause this client to throw an error, so we can't handle
        // it in the normal Promise.error() handler below.
        client.on('close', function(err) {
            if (errorWasHandled) {
                return;
            }
            if (err) {
                result.finish(err.message);
                sftp.emit('error', result);
            } else if (!succeeded) {
                result.finish(Context.y18n.__("Upload failed due to unknown error. The remote host may have terminated the connection."));
                sftp.emit('error', result);
            }
            // We should just emit finish immediately, but when
            // testing against local SFTP server, this closes
            // the upload stream before the server is ready,
            // causing ECONNRESET (even though the write has
            // completed on the server end).
            setTimeout(function() {
                sftp.emit('finish', result);
            }, 500);
        });
        // This handles authentication errors, which will occur before we
        // can even attempt an upload.
        client.on('error', function(err) {
            if (err.message.includes("authentication methods failed")) {
                errorWasHandled = true;
            }
            result.finish(err.message);
            sftp.emit('error', result);
        });
        client.connect(connSettings)
            .then(() => {
                // This library also has a fastPut method that comes
                // with a warning about potential file corruption.
                // Use put for initial release.
                Context.logger.info(`Uploading via sftp to ${remoteFilepath}`);
                return client.put(filepathOrBuffer, remoteFilepath); // defaultPutOptions);
            })
            .then((response) => {
                result.info = Context.y18n.__("Upload succeeded");
                result.finish();
                succeeded = true;
                client.end();
            })
            .catch(err => {
                errorWasHandled = true;
                result.finish(err.message);
                sftp.emit('error', result);
                // client.end() can fail if conn is not established
                if (client && client.sftp) {
                    try { client.end(); }
                    catch(ex) {}
                } else {
                    // AppVeyor seems to hit this condition frequently.
                    // If the connection failed and we can't call
                    // client.end(), we still need to emit the finish event.
                    sftp.emit('finish', result);
                }
            });
    }


    /**
     * Downloads a file from the remote server. The name of the default
     * directory on the remote server is determined by the bucket property
     * of the {@link StorageService} passed in to this class'
     * constructor.
     *
     * @param {string} filepath - The local path to which we should save the
     * downloaded file.
     *
     * @param {string} keyname - This name of the file to download from
     * the remote server.
     *
     */
    download(filepath, keyname) {
        throw 'SFTPClient.download() is not yet implemented.';
    }

    /**
     * Lists files on a remote SFTP server. NOT YET IMPLEMENTED.
     *
     */
    list() {
        throw 'SFTPClient.list() is not yet implemented.';
    }


    _getConnSettings() {
        if (!this.storageService) {
            throw Context.y18n.__("SFTP client cannot establish a connection without a StorageService object");
        }
        let connSettings = {
            host: this.storageService.host,
            port: this.storageService.port || 22,
            username: this.storageService.login,
            //debug: console.log,
        }
        if (!Util.isEmpty(this.storageService.loginExtra)) {
            Context.logger.info(Context.y18n.__("Using private key at %s for SFTP connection", this.storageService.loginExtra));
            connSettings.privateKey = this._loadPrivateKey();
        } else if (!Util.isEmpty(this.storageService.password)) {
            Context.logger.info(Context.y18n.__("Using password for SFTP connection"));
            connSettings.password = this.storageService.password
        } else {
            let msg = Context.y18n.__("Storage service %s has no password or key file to connect to remote server", this.storageService.name);
            Context.logger.error(msg);
            throw msg;
        }
        return connSettings
    }

    /**
     * Loads a private key to be used in establishing an SFTP connection.
     *
     */
    _loadPrivateKey() {
        Context.logger.info(Context.y18n.__("Checking %s for RSA key for SFTP connection", this.storageService.loginExtra));
        if(!fs.existsSync(this.storageService.loginExtra)) {
            throw Context.y18n.__("Private key file %s is missing for storage service %s", this.storageService.loginExtra, this.storageService.name);
        }
        if(!Util.canRead(this.storageService.loginExtra)) {
            throw Context.y18n.__("You do not have permission to read the private key file %s for storage service %s", this.storageService.loginExtra, this.storageService.name);
        }
        let pk = '';
        try {
            pk = fs.readFileSync(this.storageService.loginExtra);
        } catch (ex) {
            throw Context.y18n.__("Error reading private key file %s for storage service %s: %s", this.storageService.loginExtra, this.storageService.name, ex.toString());
        }
        return pk.toString();
    }


    _initUploadResult(filepathOrBuffer, remoteFilepath) {
        let result = new OperationResult('upload', 'SFTPClient');
        result.start();
        if (typeof filepathOrBuffer == 'string') {
            let stats = fs.statSync(filepathOrBuffer);
            result.filepath = filepathOrBuffer;
            result.filesize = stats.size;
            result.fileMtime = stats.mtime;
        } else if (Buffer.isBuffer(filepathOrBuffer)) {
            // This is used for tests only
            result.filepathOrBuffer = "In-memory buffer";
            result.filesize = filepathOrBuffer.length;
        } else {
            throw Context.y18n.__("Must upload filepath or buffer");
        }
        result.remoteURL = this._buildUrl(remoteFilepath);
        return result;
    }

    // Build the remote URL. Don't use path.join because on
    // Windows, that gives us backslashes, and we want forward
    // slashes for SFTP.
    _buildUrl(remoteFilepath) {
        let bucket = this.storageService.bucket;
        let url = `sftp://${this.storageService.host}`;
        if (this.storageService.port) {
            url += `:${this.storageService.port}`;
        }
        if (!remoteFilepath.startsWith('/') && !bucket.startsWith('/')) {
            url += '/';
        }
        if (bucket.trim() != "" && !bucket.endsWith('/')) {
            bucket += '/';
        }
        // Replace multiple slashes with single slash.
        return url + `${remoteFilepath}`.replace(/\/+/g, '/');
    }

    /**
     * @event SFTPClient#start
     * @type {string} A message indicating that the upload or download is starting.
     *
     * @event SFTPClient#warning
     * @type {string} A warning message describing why the SFTPClient is retrying
     * an upload or download operation.
     *
     * @event SFTPClient#error
     * @type {OperationResult} Contains information about what went wrong during
     * an upload or download operation.
     *
     * @event SFTPClient#finish
     * @type {OperationResult} Contains information about the outcome of
     * an upload or download operation.
     */
}

module.exports = SFTPClient;