unifile-sftp.js

'use strict';

const Mime = require('mime');
const Promise = require('bluebird');
const {PassThrough} = require('stream');
const SFTPClient = require('sftp-promises');
const {Client} = require('ssh2');

const Tools = require('unifile-common-tools');

const NAME = 'sftp';

function parseError(err) {
	let msg = null;
	switch (err.code) {
		case 2:
			msg = 'This path does not exist';
			break;
		case 4:
			msg = 'An error occured: ' + err;
			break;
		default:
			throw err;
	}
	const error = new Error(msg);
	error.code = err.code;
	throw error;
}

function protectPath(path) {
	return path.length ? path : '/';
}

/**
 * Service connector for {@link https://en.wikipedia.org/wiki/SSH_File_Transfer_Protocol|SFTP}
 */
class SftpConnector {
	/**
   * @constructor
   * @param {Object} config - Configuration object.
   * @param {string} config.redirectUri - URI redirecting to an authantification form.
   * @param {boolean} [config.showHiddenFiles=false] - Flag to show hidden files.
   * @param {ConnectorStaticInfos} [config.infos] - Connector infos to override
   */
	constructor(config) {
		if(!config || !config.redirectUri)
			throw new Error('You should at least set a redirectUri for this connector');

		this.redirectUri = config.redirectUri;
		this.showHiddenFile = config.showHiddenFile || false;
		this.infos = Tools.mergeInfos(config.infos || {}, {
			name: NAME,
			displayName: 'SFTP',
			icon: '',
			description: 'Edit files on a SSH server.'
		});
		this.name = this.infos.name;
	}

	getInfos(session) {
		return Object.assign({
			isLoggedIn: 'token' in session,
			isOAuth: false,
			username: session.username
		}, this.infos);
	}

	// Auth methods are useless here

	getAuthorizeURL(session) {
		return Promise.resolve(this.redirectUri);
	}

	setAccessToken(session, token) {
		session.token = token;
		return Promise.resolve(token);
	}

	clearAccessToken(session) {
		Tools.clearSession(session);
		return Promise.resolve();
	}

	login(session, loginInfos) {
		try {
			const auth = Tools.parseBasicAuth(loginInfos);
			session.host = auth.host;
			session.port = auth.port;
			session.user = auth.user;
			// Duplicate because SFTP wait for `username` but we want to keep compatibility
			session.username = auth.user;
			session.password = auth.password;
		} catch (e) {
			return Promise.reject(e);
		}
		// Check credentials by stating root
		return this.stat(session, '/')
		.catch((err) => {
			throw new Error('Cannot access server. Please check your credentials. ' + err);
		})
		.then(() => Promise.resolve(this.setAccessToken(session, session.username)));
	}

	//Filesystem commands
	// An additional sftpSession is added to signature to support batch actions

	readdir(session, path, sftpSession) {
		const sftp = sftpSession ? new SFTPClient() : new SFTPClient(session);
		return sftp.ls(protectPath(path), sftpSession)
		.catch(parseError)
		.then((directory) => {
			if(!directory.entries) return Promise.reject('Target is not a directory');
			return directory.entries.reduce((memo, entry) => {
				if(this.showHiddenFile || !entry.filename.startsWith('.')) {
					const isDir = entry.longname.startsWith('d');
					memo.push({
						size: entry.attrs.size,
						modified: entry.attrs.mtime,
						name: entry.filename,
						isDir: isDir,
						mime: isDir ? 'application/directory' : Mime.getType(entry.filename)
					});
				}
				return memo;
			}, []);
		});
	}

	stat(session, path, sftpSession) {
		const sftp = sftpSession ? new SFTPClient() : new SFTPClient(session);
		return sftp.stat(protectPath(path), sftpSession)
		.catch(parseError)
		.then((entry) => {
			const filename = entry.path.split('/').pop();
			const isDir = entry.type === 'directory';
			return {
				size: entry.size,
				modified: entry.mtime,
				name: filename,
				isDir: isDir,
				mime: isDir ? 'application/directory' : Mime.getType(filename)
			};
		});
	}

	mkdir(session, path, sftpSession) {
		const sftp = sftpSession ? new SFTPClient() : new SFTPClient(session);
		return sftp.mkdir(path, sftpSession)
		.catch(parseError)
		.catch((err) => {
			if(err.code === 4) throw new Error('Unable to create remote dir. Does it already exist?');
			else throw err;
		});
	}

	writeFile(session, path, data, sftpSession) {
		const sftp = sftpSession ? new SFTPClient() : new SFTPClient(session);
		return sftp.putBuffer(new Buffer(data), path, sftpSession)
		.catch(parseError)
		.catch((err) => {
			if(err.code === 4) throw new Error('Unable to create remote file. Does its parent exist?');
			else throw err;
		});
	}

	createWriteStream(session, path, sftpSession) {
		const stream = new PassThrough();
		// Get stream for ssh2 directly
		if(sftpSession) {
			sftpSession.sftp((err, sftp) => {
				const sStream = sftp.createWriteStream(path)
				.on('close', () => {
					stream.emit('close');
				});

				stream.pipe(sStream);
			});
		} else {
			const connection = new Client();
			connection.on('ready', function() {
				connection.sftp((err, sftp) => {
					const sStream = sftp.createWriteStream(path)
					.on('close', () => {
						stream.emit('close');
						connection.end();
						connection.destroy();
					});

					stream.pipe(sStream);
				});
			});
			connection.on('error', function(err) {
				stream.emit('error', err);
			});

			connection.connect(session);
		}

		return stream;
	}

	readFile(session, path, sftpSession) {
		const sftp = sftpSession ? new SFTPClient() : new SFTPClient(session);
		return sftp.getBuffer(path, sftpSession)
		.catch(parseError);
	}

	createReadStream(session, path, sftpSession) {
		const stream = new PassThrough();
		// Get stream for ssh2 directly
		if(sftpSession) {
			sftpSession.sftp((err, sftp) => sftp.createReadStream(path).pipe(stream));
		} else {
			const connection = new Client();
			connection.on('ready', function() {
				connection.sftp((err, sftp) => {
					const sStream = sftp.createReadStream(path)
					.on('close', () => {
						stream.emit('close');
						connection.end();
						connection.destroy();
					})
					.on('error', (err) => stream.emit('error', err));

					sStream.pipe(stream);
				});
			});
			connection.on('error', function(err) {
				stream.emit('error', err);
			});

			connection.connect(session);
		}

		return stream;
	}

	rename(session, src, dest, sftpSession) {
		const sftp = sftpSession ? new SFTPClient() : new SFTPClient(session);
		return sftp.mv(src, dest, sftpSession)
		.catch(parseError);
	}

	unlink(session, path, sftpSession) {
		const sftp = sftpSession ? new SFTPClient() : new SFTPClient(session);
		return sftp.rm(path, sftpSession)
		.catch(parseError);
	}

	rmdir(session, path, sftpSession) {
		const sftp = sftpSession ? new SFTPClient() : new SFTPClient(session);
		return sftp.rmdir(path, sftpSession)
		.catch(parseError);
	}

	batch(session, actions, message) {
		const sftp = new SFTPClient();
		return sftp.session(session)
		.catch(parseError)
		.then((sftpSession) => {
			return Promise.each(actions, (action) => {
				const act = action.name.toLowerCase();
				switch (act) {
					case 'unlink':
					case 'rmdir':
					case 'mkdir':
						return this[act](session, action.path, sftpSession);
					case 'rename':
						return this[act](session, action.path, action.destination, sftpSession);
					case 'writefile':
						return this.writeFile(session, action.path, action.content, sftpSession);
					default:
						console.warn(`Unsupported batch action: ${action.name}`);
				}
			})
			// Close socket
			.then(() => sftpSession.end());
		});
	}
}

module.exports = SftpConnector;