unifile-dropbox.js

'use strict';

const {PassThrough} = require('stream');
const Promise = require('bluebird');
const request = require('request');
const Mime = require('mime');

const Tools = require('unifile-common-tools');
const {UnifileError, BatchError} = require('./error');

const NAME = 'dropbox';
const DB_OAUTH_URL = 'https://www.dropbox.com/oauth2';

const charsToEncode = /[\u007f-\uffff]/g;

/**
 * Make a call to the Dropbox API
 * @param {Object} session - Dropbox session storage
 * @param {string} path - End point path
 * @param {Object} data - Data to pass. Convert to querystring if method is GET or to the request body
 * @param {string} [subdomain=api] - Subdomain of the endpoint to call (api/content)
 * @param {boolean} [isJson=true] - Whether to stringify the body or not
 * @param {Object} [headers={}] - Override of addition to the request headers
 * @return {Promise} a Promise of the result send by server
 * @private
 */
function callAPI(session, path, data, subdomain = 'api', isJson = true, headers = null) {
	const authorization = 'Bearer ' + session.token;

	const reqOptions = {
		url: `https://${subdomain}.dropboxapi.com/2${path}`,
		method: 'POST',
		headers: {
			'Authorization': authorization,
			'User-Agent': 'Unifile'
		},
		json: isJson,
		encoding: null
	};

	if(data && Object.keys(data).length !== 0) reqOptions.body = data;

	if(headers) {
		for(const header in headers) {
			reqOptions.headers[header] = headers[header];
		}
	}

	return new Promise(function(resolve, reject) {
		request(reqOptions, function(err, res, body) {
			if(err) {
				reject(err);
			} else if(res.statusCode >= 400) {
				let errorMessage = null;
				// In case of users/get_current_account, Dropbox return a String with the error
				// Since isJson = true, it gets parsed by request
				if(Buffer.isBuffer(body)) {
					try {
						errorMessage = JSON.parse(body.toString()).error_summary;
					} catch (e) {
						errorMessage = body.toString();
					}
				} else {
					errorMessage = (isJson ? body : JSON.parse(body)).error_summary;
				}
				// Dropbox only uses 409 for endpoints specific errors
				let filename = null;
				try {
					filename = res.request.headers.hasOwnProperty('Dropbox-API-Arg') ?
						JSON.parse(res.request.headers['Dropbox-API-Arg']).path
						: JSON.parse(res.request.body).path;
				} catch (e) {}
				if(errorMessage.includes('/not_found/')) {
					reject(new UnifileError(UnifileError.ENOENT, `Not Found: ${filename}`));
				} else if(errorMessage.startsWith('path/conflict/')) {
					reject(new UnifileError(UnifileError.EINVAL, `Creation failed due to conflict: ${filename}`));
				} else if(errorMessage.startsWith('path/not_file/')) {
					reject(new UnifileError(UnifileError.EINVAL, `Path is a directory: ${filename}`));
				} else if(res.statusCode === 401) {
					reject(new UnifileError(UnifileError.EACCES, errorMessage));
				} else {
					reject(new UnifileError(UnifileError.EIO, errorMessage));
				}
			} else resolve(body);
		});
	});
}

function openUploadSession(session, data, autoclose) {
	return callAPI(session, '/files/upload_session/start', data, 'content', false, {
		'Content-Type': 'application/octet-stream',
		'Dropbox-API-Arg': JSON.stringify({
			close: autoclose
		})
	})
	.then((result) => JSON.parse(result));
}

/**
 * Close an upload batch session
 * @param {Object} session - Dropbox session
 * @param {Object[]} entries - Files identifiers that have been uploaded during this session
 * @param {Object} entries[].cursor - Upload cursor
 * @param {string} entries[].cursor.session_id - Id of the upload session for this file
 * @param {string} entries[].cursor.offset - The amount of data already transfered
 * @param {string} entries[].commit - Path and modifier for the file
 * @param {string} entries[].commit.path - Path of the file
 * @param {string} entries[].commit.mode - Write mode of the file
 * @param {string} [entries[].commit.autorename=false] - Rename strategy in case of conflict
 * @param {string} [entries[].commit.client_modified] - Force this timestamp as the last modification
 * @param {string} [entries[].commit.mute=false] - Don't notify the client about this change
 * @return {Promise<string, string>} a Promise of an async_job_id or a 'complete' tag
 * @private
 */
function closeUploadBatchSession(session, entries) {
	return callAPI(session, '/files/upload_session/finish_batch', {
		entries: entries
	});
}

function checkBatchEnd(session, result, checkRoute, jobId) {
	let newId = null;
	switch (result['.tag']) {
		case 'async_job_id':
			newId = result.async_job_id;
			// falls through
		case 'in_progress':
			newId = newId || jobId;
			return callAPI(session, checkRoute, {
				async_job_id: newId
			})
			.then((result) => checkBatchEnd(session, result, checkRoute, newId));
		case 'complete':
			const failed = result.entries.reduce((memo, entry, index) => {
				if(entry['.tag'] === 'failure') memo.push({entry, index});
				return memo;
			}, []);
			if(failed.length > 0) {
				const errors = failed.map(({entry, index}) => {
					const failureTag = entry.failure['.tag'];
					return `Could not complete action ${index}: ${failureTag + '/' + entry.failure[failureTag]['.tag']}`;
				});
				return Promise.reject(new UnifileError(
					UnifileError.EIO, errors.join(', ')));
			}
			return Promise.resolve();
	}
}

function makePathAbsolute(path) {
	return path === '' ? path : '/' + path.split('/').filter((token) => token != '').join('/');
}

/**
 * Stringifies a JSON object and make it header-safe by encoding
 * non-ASCII characters.
 *
 * @param {Object} v - JSON object to stringify
 * @returns {String} the stringified object with special chars encoded
 *
 * @see https://www.dropboxforum.com/t5/API-support/HTTP-header-quot-Dropbox-API-Arg-quot-could-not-decode-input-as/td-p/173822
 */
function safeStringify(v) {
	return JSON.stringify(v).replace(charsToEncode,
		function(c) {
			return '\\u' + ('000' + c.charCodeAt(0).toString(16)).slice(-4);
		}
	);
}

/**
 * Service connector for {@link https://dropbox.com|Dropbox} plateform.
 *
 * This will need a registered Dropbox application with valid redirection for your server.
 * You can register a new application {@link https://www.dropbox.com/developers/apps|here} and
 * learn more about Dropbox OAuth Web application flow
 * {@link https://www.dropbox.com/developers/reference/oauth-guide|here}
 */
class DropboxConnector {
	/**
	 * @constructor
	 * @param {Object} config - Configuration object
	 * @param {string} config.redirectUri - Dropbox application redirect URI
	 * @param {string} config.clientId - Dropbox application client ID
	 * @param {string} config.clientSecret - Dropbox application client secret
	 * @param {string} [config.writeMode=overwrite] - Write mode when files conflicts. Must be one of
	 * 	'add'/'overwrite'/'update'.
	 * {@link https://www.dropbox.com/developers/documentation/http/documentation#files-upload|see Dropbox manual}
	 * @param {ConnectorStaticInfos} [config.infos] - Connector infos to override
	 */
	constructor(config) {
		if(!config || !config.clientId || !config.clientSecret || !config.redirectUri)
			throw new Error('Invalid configuration. Please refer to the documentation to get the required fields.');
		this.redirectUri = config.redirectUri;
		this.clientId = config.clientId;
		this.clientSecret = config.clientSecret;

		this.infos = Tools.mergeInfos(config.infos, {
			name: NAME,
			displayName: 'Dropbox',
			icon: '../assets/dropbox.png',
			description: 'Edit files from your Dropbox.'
		});

		this.name = this.infos.name;
		if(!config.writeMode || ['add', 'overwrite', 'update'].every((mode) => mode !== config.writeMode))
			this.writeMode = 'overwrite';
		else this.writeMode = config.writeMode;
	}

	getInfos(session) {
		return Object.assign({
			isLoggedIn: (session && 'token' in session),
			isOAuth: true,
			username: session.account ? session.account.name.display_name : undefined
		}, this.infos);
	}

	setAccessToken(session, token) {
		session.token = token;
		const accountFields = [
			'account_id',
			'name',
			'email'
		];
		const filterAccountInfos = (account) => {
			return Object.keys(account).reduce((memo, key) => {
				if(accountFields.includes(key)) memo[key] = account[key];
				return memo;
			}, {});
		};
		let accountPromised = null;
		if(session.account && 'id' in session.account) {
			accountPromised = callAPI(session, '/users/get_account', {
				account_id: session.account.id
			});
		} else {
			accountPromised = callAPI(session, '/users/get_current_account');
		}
		return accountPromised.then((account) => {
			session.account = filterAccountInfos(account);
			return token;
		})
		.catch((err) => Promise.reject(new UnifileError(UnifileError.EACCES, 'Bad credentials')));
	}

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

	getAuthorizeURL(session) {
		// Generate a random string for the state
		session.state = (+new Date() * Math.random()).toString(36).replace('.', '');
		let url = DB_OAUTH_URL
						+ '/authorize?response_type=code&client_id=' + this.clientId
						+ '&state=' + session.state;

		// For CLI, don't use redirectUri and ask for `code` to be paste in the app
		if(this.redirectUri) url += '&redirect_uri=' + this.redirectUri;

		return Promise.resolve(url);
	}

	login(session, loginInfos) {
		let returnPromise;
		function processResponse(resolve, reject, err, response, body) {
			if(err) return reject('Error while calling Dropbox API. ' + err);
			session.account = {id: body.account_id};
			return resolve(body.access_token);
		}

		if(typeof loginInfos === 'object' && 'state' in loginInfos && 'code' in loginInfos) {
			if(loginInfos.state !== session.state)
				return Promise.reject(new UnifileError(UnifileError.EACCES, 'Invalid request (cross-site request)'));
			returnPromise = new Promise((resolve, reject) => {
				request({
					url: 'https://api.dropboxapi.com/oauth2/token',
					method: 'POST',
					form: {
						code: loginInfos.code,
						grant_type: 'authorization_code',
						client_id: this.clientId,
						client_secret: this.clientSecret,
						redirect_uri: this.redirectUri
					},
					json: true
				}, processResponse.bind(this, resolve, reject));
			});
		} else {
			return Promise.reject(new UnifileError(UnifileError.EACCES, 'Invalid credentials'));
		}
		return returnPromise.then((token) => {
			return this.setAccessToken(session, token);
		});
	}

	//Filesystem commands

	readdir(session, path) {
		return callAPI(session, '/files/list_folder', {
			path: makePathAbsolute(path)
		})
		.then((result) => {
			return result.entries.map((entry) => {
				return {
					size: entry.size,
					modified: entry.client_modified,
					name: entry.name,
					isDir: entry['.tag'] == 'folder',
					mime: Mime.getType(entry.name)
				};
			});
		});
	}

	stat(session, path) {
		if(!path) return Promise.reject(new UnifileError(UnifileError.EINVAL, 'You must provide a path to stat'));

		return callAPI(session, '/files/get_metadata', {
			path: makePathAbsolute(path)
		})
		.then((stat) => {
			return {
				size: stat.size,
				modified: stat.client_modified,
				name: stat.name,
				isDir: stat['.tag'] == 'folder',
				mime: Mime.getType(stat.name)
			};
		});
	}

	mkdir(session, path) {
		if(!path) return Promise.reject(new UnifileError(UnifileError.EINVAL, 'Cannot create dir with an empty name.'));
		return callAPI(session, '/files/create_folder_v2', {
			path: makePathAbsolute(path)
		});
	}

	writeFile(session, path, data) {
		// TODO Use upload session for files bigger than 150Mo
		// (https://www.dropbox.com/developers/documentation/http/documentation#files-upload_session-start)
		// TODO Handle file conflict and write mode
		return callAPI(session, '/files/upload', data, 'content', false, {
			'Content-Type': 'application/octet-stream',
			'Dropbox-API-Arg': safeStringify({
				path: makePathAbsolute(path),
				mode: this.writeMode
			})
		});
	}

	createWriteStream(session, path) {

		const writeStream = request({
			url: 'https://content.dropboxapi.com/2/files/upload',
			method: 'POST',
			headers: {
				'Authorization': 'Bearer ' + session.token,
				'Content-Type': 'application/octet-stream',
				'User-Agent': 'Unifile',
				'Dropbox-API-Arg': safeStringify({
					path: makePathAbsolute(path),
					mode: this.writeMode
				})
			}
		})
		.on('response', (response) => {
			switch (response.statusCode) {
				case 200:
					writeStream.emit('close');
					break;
				case 400: //falltrough
				case 409:
					writeStream.emit('error', new UnifileError(UnifileError.EINVAL, 'Invalid stream'));
					break;
				default:
					writeStream.emit('error', new UnifileError(UnifileError.EIO, 'Creation failed'));
			}
		});

		return writeStream;
	}

	readFile(session, path) {
		return callAPI(session, '/files/download', {}, 'content', false, {
			'Dropbox-API-Arg': safeStringify({
				path: makePathAbsolute(path)
			})
		});
	}

	createReadStream(session, path) {
		const readStream = new PassThrough();
		const req = request({
			url: 'https://content.dropboxapi.com/2/files/download',
			method: 'POST',
			headers: {
				'Authorization': 'Bearer ' + session.token,
				'User-Agent': 'Unifile',
				'Dropbox-API-Arg': safeStringify({
					path: makePathAbsolute(path)
				})
			}
		})
		.on('response', (response) => {
			if(response.statusCode === 200) req.pipe(readStream);

			switch (response.statusCode) {
				case 400: readStream.emit('error', new UnifileError(UnifileError.EINVAL, 'Invalid request'));
					break;
				case 409:
					const chunks = [];
					req.on('data', (data) => {
						chunks.push(data);
					});
					req.on('end', () => {
						const body = JSON.parse(Buffer.concat(chunks).toString());
						if(body.error_summary.startsWith('path/not_found'))
							readStream.emit('error', new UnifileError(UnifileError.ENOENT, 'Not Found'));
						else if(body.error_summary.startsWith('path/not_file'))
							readStream.emit('error', new UnifileError(UnifileError.EISDIR, 'Path is a directory'));
						else
							readStream.emit('error', new UnifileError(UnifileError.EIO, 'Unable to read file'));
					});
			}
		});
		return readStream;
	}

	rename(session, src, dest) {
		if(!src)
			return Promise.reject(new UnifileError(UnifileError.EINVAL, 'Cannot rename path with an empty name'));
		if(!dest)
			return Promise.reject(new UnifileError(UnifileError.EINVAL, 'Cannot rename path with an empty destination'));
		return callAPI(session, '/files/move', {
			from_path: makePathAbsolute(src),
			to_path: makePathAbsolute(dest)
		});
	}

	unlink(session, path) {
		if(!path)
			return Promise.reject(new UnifileError(UnifileError.EINVAL, 'Cannot remove path with an empty name'));
		return callAPI(session, '/files/delete_v2', {
			path: makePathAbsolute(path)
		});
	}

	rmdir(session, path) {
		return this.unlink(session, path);
	}

	batch(session, actions, message) {
		const writeMode = this.writeMode;
		let actionsChain = Promise.resolve();

		let uploadEntries = [];
		let deleteEntries = [];
		let moveEntries = [];

		const batchMap = {
			writefile: uploadBatch,
			rmdir: deleteBatch,
			rename: moveBatch
		};

		function closeBatchs(action) {
			for(const key in batchMap) {
				if(key !== action) {
					batchMap[key]();
				}
			}
		}

		function moveBatch() {
			if(moveEntries.length === 0) return Promise.resolve();

			const toMove = moveEntries.slice();
			actionsChain = actionsChain.then(() => {
				return callAPI(session, '/files/move_batch', {
					entries: toMove
				})
				.then((result) => checkBatchEnd(session, result, '/files/move_batch/check'));
			});
			moveEntries = [];
		}

		function deleteBatch() {
			if(deleteEntries.length === 0) return Promise.resolve();

			/*
				Dropbox executes all the deletion at the same time,
				so we remove all the descendant of a deleted folder beforehand
			*/
			const toDelete = deleteEntries.slice().sort((a, b) => a.path.length - b.path.length);
			const deduplicated = [];
			while(toDelete.length !== 0) {
				if(!deduplicated.some(({path}) => toDelete[0].path.includes(path + '/'))) {
					deduplicated.push(toDelete.shift());
				} else toDelete.shift();
			}
			actionsChain = actionsChain.then(() => {
				return callAPI(session, '/files/delete_batch', {
					entries: deduplicated
				})
				.then((result) => checkBatchEnd(session, result, '/files/delete_batch/check'));
			});
			deleteEntries = [];
		}

		function uploadBatch() {
			if(uploadEntries.length === 0) return Promise.resolve();

			const toUpload = uploadEntries.slice();
			actionsChain = actionsChain.then(() => {
				return Promise.map(toUpload, (action) => {
					const bitContent = new Buffer(action.content);
					return openUploadSession(session, bitContent, true)
					.then((result) => {
						return {
							cursor: {
								session_id: result.session_id,
								offset: bitContent.length
							},
							commit: {
								path: makePathAbsolute(action.path),
								mode: writeMode
							}
						};
					});
				})
				.then((commits) => closeUploadBatchSession(session, commits))
				.then((result) => checkBatchEnd(session, result, '/files/upload_session/finish_batch/check'));
			});
			uploadEntries = [];
		}

		for(const action of actions) {
			if(!action.path)
				return Promise.reject(new BatchError(UnifileError.EINVAL,
					'Cannot execute batch action without a path'));
			switch (action.name.toLowerCase()) {
				case 'unlink':
				case 'rmdir':
					closeBatchs('rmdir');
					deleteEntries.push({
						path: makePathAbsolute(action.path)
					});
					break;
				case 'rename':
					closeBatchs(action.name.toLowerCase());
					if(!action.destination)
						return Promise.reject(new BatchError(
							UnifileError.EINVAL,
							'Rename actions should have a destination'));
					moveEntries.push({
						from_path: makePathAbsolute(action.path),
						to_path: makePathAbsolute(action.destination)
					});
					break;
				case 'writefile':
					closeBatchs(action.name.toLowerCase());
					if(!action.content)
						return Promise.reject(new BatchError(
							UnifileError.EINVAL,
							'Write actions should have a content'));
					uploadEntries.push(action);
					break;
				case 'mkdir':
					closeBatchs(action.name.toLowerCase());
					actionsChain = actionsChain.then(() => {
						return this.mkdir(session, action.path);
					})
					.catch((err) => err.name !== 'BatchError',
						(err) => {
							throw new BatchError(
								UnifileError.EINVAL,
								`Could not complete action ${action.name}: ${err.message}`);
						});
					break;
				default:
					console.warn(`Unsupported batch action: ${action.name}`);
			}
		}
		closeBatchs('');

		return actionsChain;
	}
}

module.exports = DropboxConnector;