unifile-remoteStorage.js

'use strict';

const Promise = require('bluebird');
const WebFinger = require('webfinger.js');
const request = require('request');
const Mime = require('mime');

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

const NAME = 'remotestorage';

/**
 * Create the commons option for the API request
 * @param {string} url - URL of the request
 * @param {string} path - Path of the endpoint
 * @param {string} token - Bearer token for OAuth
 * @return {Object} request options
 * @private
 */
function getRequestOptions(url, path, token) {
	return {
		url: url + '/' + path,
		headers: {
			'Authorization': 'Bearer ' + token
		},
		encoding: null
	};
}

/**
 * Performs a GET request to the API
 * @param {string} url - URL of the request
 * @param {string} path - Path of the endpoint
 * @param {string} token - Bearer token for OAuth
 * @param {boolean} isStream - Access the API as a stream or not
 * @return {Promise<Object>} a promise of the body returned
 * @private
 */
function get(url, path, token, isStream = false) {
	const opts = getRequestOptions(url, path, token);
	if(isStream) return request.get(opts);
	else {
		return new Promise(function(resolve, reject) {
			request.get(opts, function(err, res, body) {
				resolve(body);
			});
		});
	}
}

/**
 * Performs a PUT request to the API
 * @param {string} url - URL of the request
 * @param {string} path - Path of the endpoint
 * @param {Object} content - Content of the request body
 * @param {string} token - Bearer token for OAuth
 * @param {boolean} isStream - Access the API as a stream or not
 * @return {Promise<Object>} a promise of the body returned
 * @private
 */
function put(url, path, content, token, isStream = false) {
	const opts = getRequestOptions(url, path, token);
	if(isStream) return request.put(opts);
	else {
		opts.body = content;
		return new Promise(function(resolve, reject) {
			request.put(opts, function(err, res, body) {
				resolve(body);
			});
		});
	}
}

/**
 * Performs a DELETE request to the API
 * @param {string} url - URL of the request
 * @param {string} path - Path of the endpoint
 * @param {string} token - Bearer token for OAuth
 * @return {Promise<Object>} a promise of the body returned
 * @private
 */
function del(url, path, token) {
	const opts = getRequestOptions(url, path, token);
	return new Promise(function(resolve, reject) {
		request.del(opts, function(err, res, body) {
			resolve(body);
		});
	});
}

function toFileInfos(filename, stat) {
	return {
		size: stat['Content-Length'] || 'N/A',
		modified: 'N/A',
		name: filename,
		isDir: filename.endsWith('/'),
		mime: Mime.getType(filename)
	};
}

/**
 * Service connector for {@link https://remotestorage.io/|remoteStorage} server.
 *
 * This connector use WebFinger to find the endpoint of your server.
 */
class RemoteStorageConnector {
	/**
   * @constructor
   * @param {Object} config - Configuration object
   * @param {string} config.redirectUri - RemoteStorage application redirect URI
   * @param {string} config.userAddress - Url of the user RemoteStorage server.
   * @param {ConnectorStaticInfos} [config.infos] - Connector infos to override
   */
	constructor(config) {
		this.config = config;
		this.infos = Tools.mergeInfos(config.infos, {
			name: NAME,
			displayName: 'RemoteStorage',
			icon: '../assets/rs.png',
			description: 'Edit files on a RemoteStorage service'
		});
		this.name = this.infos.name;
	}

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

	getAuthorizeURL(session) {
		return new Promise((resolve, reject) => {
			new WebFinger().lookup(session.userAddress, (err, res) => {
				if(err)
					reject(err);
				else {
					const infos = res.object.links[0];
					session.infos = {
						href: infos.href,
						storageType: infos.properties['http://remotestorage.io/spec/version'],
						authURL: infos.properties['http://tools.ietf.org/html/rfc6749#section-4.2'],
						properties: infos.properties
					};
					let query = 'redirect_uri=' + this.config.redirectUri
										+ '&client_id=Unifile'
										+ '&scope=*:rw'
										+ '&response_type=token';
					if(session.infos.authURL.indexOf('?') > -1) query = '&' + query;
					else query = '?' + query;

					resolve(session.infos.authURL + query);
				}
			});
		});
	}

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

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

	login(session, loginInfos) {
		if(loginInfos.constructor === String) {
			return this.setAccessToken(session, loginInfos);
		} else {
			return this.setAccessToken(session, loginInfos.token);
		}
	}

	readdir(session, path) {
		if(!path.endsWith('/')) {
			return Promise.reject('Folder path must end with a /.'
								+ 'If you want to see a file content, call readFile() instead');
		} else {
			return get(session.infos.href, path, session.token)
			.then((result) => {
				var obj = JSON.parse(result);
				return Object.keys(obj.items).map((key) => {
					return toFileInfos(key, obj.items[key]);
				});
			});
		}
	}

	stat(session, path) {
		return new Promise(function(resolve, reject) {
			request.get(getRequestOptions(session.infos.href, path, session.token), function(err, res, body) {
				resolve(body, res.headers['content-length']);
			});
		})
		.then((body, size) => {
			var obj = JSON.parse(body);
			return toFileInfos(obj.name, {'Content-Length': size});
		});
	}

	mkdir(session, path) {
		if(!path.endsWith('/')) {
			return Promise.reject('Folder path must end with a /. If you want to create a file, call writeFile() instead');
		} else {
			return put(session.infos.href, path + '/.keep', '', session.token);
		}
	}

	writeFile(session, path, content) {
		if(path.endsWith('/')) {
			return Promise.reject('File path cannot end with a /. If you want to create a folder, call mkdir() instead');
		} else {
			return put(session.infos.href, path, content, session.token);
		}
	}

	createWriteStream(session, path) {
		if(path.endsWith('/')) {
			return Promise.reject('File path cannot end with a /. If you want to create a folder, call mkdir() instead');
		} else {
			return put(session.infos.href, path, null, session.token, true);
		}
	}

	readFile(session, path) {
		if(path.endsWith('/')) {
			return Promise.reject('File path cannot end with a /.'
								+ 'If you want to see a folder listing, call readdir() instead');
		} else {
			return get(session.infos.href, path, session.token);
		}
	}

	createReadStream(session, path) {
		if(path.endsWith('/')) {
			return Promise.reject('File path cannot end with a /.'
								+ 'If you want to see a folder listing, call readdir() instead');
		} else {
			return get(session.infos.href, path, session.token, true);
		}
	}

	rename(session, src, dest) {
		let originContent;
		return get(session.infos.href, src, session.token)
		.then((content) => originContent = content)
		.then(() => del(session.infos.href, src, session.token))
		.then(() => put(session.infos.href, dest, originContent, session.token));
	}

	unlink(session, path) {
		if(path.endsWith('/')) {
			return Promise.reject('File path cannot end with a /. If you want to delete a folder, call rmdir() instead');
		} else {
			return del(session.infos.href, path, session.token);
		}
	}

	rmdir(session, path) {
		if(!path.endsWith('/')) {
			return Promise.reject('Folder path must end with a /. If you want to delete a file, call unlink() instead');
		} else {
			return del(session.infos.href, path + '/.keep', session.token);
		}
	}

	batch(session, actions, message) {
		return Promise.each(actions, (action) => {
			const act = action.name.toLowerCase();
			switch (act) {
				case 'unlink':
				case 'rmdir':
				case 'mkdir':
					this[act](session, action.path);
					break;
				case 'rename':
					this[act](session, action.path, action.destination);
					break;
				case 'writefile':
					this.writeFile(session, action.path, action.content);
					break;
				default:
					console.warn(`Unsupported batch action: ${action.name}`);
			}
		});
	}
}

module.exports = RemoteStorageConnector;