UNPKG

18.8 kBJavaScriptView Raw
1'use strict';
2
3const {PassThrough} = require('stream');
4const Promise = require('bluebird');
5const request = require('request');
6const Mime = require('mime');
7
8const Tools = require('unifile-common-tools');
9const {UnifileError, BatchError} = require('./error');
10
11const NAME = 'dropbox';
12const DB_OAUTH_URL = 'https://www.dropbox.com/oauth2';
13
14const charsToEncode = /[\u007f-\uffff]/g;
15
16/**
17 * Make a call to the Dropbox API
18 * @param {Object} session - Dropbox session storage
19 * @param {string} path - End point path
20 * @param {Object} data - Data to pass. Convert to querystring if method is GET or to the request body
21 * @param {string} [subdomain=api] - Subdomain of the endpoint to call (api/content)
22 * @param {boolean} [isJson=true] - Whether to stringify the body or not
23 * @param {Object} [headers={}] - Override of addition to the request headers
24 * @return {Promise} a Promise of the result send by server
25 * @private
26 */
27function callAPI(session, path, data, subdomain = 'api', isJson = true, headers = null) {
28 const authorization = 'Bearer ' + session.token;
29
30 const reqOptions = {
31 url: `https://${subdomain}.dropboxapi.com/2${path}`,
32 method: 'POST',
33 headers: {
34 'Authorization': authorization,
35 'User-Agent': 'Unifile'
36 },
37 json: isJson,
38 encoding: null
39 };
40
41 if(data && Object.keys(data).length !== 0) reqOptions.body = data;
42
43 if(headers) {
44 for(const header in headers) {
45 reqOptions.headers[header] = headers[header];
46 }
47 }
48
49 return new Promise(function(resolve, reject) {
50 request(reqOptions, function(err, res, body) {
51 if(err) {
52 reject(err);
53 } else if(res.statusCode >= 400) {
54 let errorMessage = null;
55 // In case of users/get_current_account, Dropbox return a String with the error
56 // Since isJson = true, it gets parsed by request
57 if(Buffer.isBuffer(body)) {
58 try {
59 errorMessage = JSON.parse(body.toString()).error_summary;
60 } catch (e) {
61 errorMessage = body.toString();
62 }
63 } else {
64 errorMessage = (isJson ? body : JSON.parse(body)).error_summary;
65 }
66 // Dropbox only uses 409 for endpoints specific errors
67 let filename = null;
68 try {
69 filename = res.request.headers.hasOwnProperty('Dropbox-API-Arg') ?
70 JSON.parse(res.request.headers['Dropbox-API-Arg']).path
71 : JSON.parse(res.request.body).path;
72 } catch (e) {}
73 if(errorMessage.includes('/not_found/')) {
74 reject(new UnifileError(UnifileError.ENOENT, `Not Found: ${filename}`));
75 } else if(errorMessage.startsWith('path/conflict/')) {
76 reject(new UnifileError(UnifileError.EINVAL, `Creation failed due to conflict: ${filename}`));
77 } else if(errorMessage.startsWith('path/not_file/')) {
78 reject(new UnifileError(UnifileError.EINVAL, `Path is a directory: ${filename}`));
79 } else if(res.statusCode === 401) {
80 reject(new UnifileError(UnifileError.EACCES, errorMessage));
81 } else {
82 reject(new UnifileError(UnifileError.EIO, errorMessage));
83 }
84 } else resolve(body);
85 });
86 });
87}
88
89function openUploadSession(session, data, autoclose) {
90 return callAPI(session, '/files/upload_session/start', data, 'content', false, {
91 'Content-Type': 'application/octet-stream',
92 'Dropbox-API-Arg': JSON.stringify({
93 close: autoclose
94 })
95 })
96 .then((result) => JSON.parse(result));
97}
98
99/**
100 * Close an upload batch session
101 * @param {Object} session - Dropbox session
102 * @param {Object[]} entries - Files identifiers that have been uploaded during this session
103 * @param {Object} entries[].cursor - Upload cursor
104 * @param {string} entries[].cursor.session_id - Id of the upload session for this file
105 * @param {string} entries[].cursor.offset - The amount of data already transfered
106 * @param {string} entries[].commit - Path and modifier for the file
107 * @param {string} entries[].commit.path - Path of the file
108 * @param {string} entries[].commit.mode - Write mode of the file
109 * @param {string} [entries[].commit.autorename=false] - Rename strategy in case of conflict
110 * @param {string} [entries[].commit.client_modified] - Force this timestamp as the last modification
111 * @param {string} [entries[].commit.mute=false] - Don't notify the client about this change
112 * @return {Promise<string, string>} a Promise of an async_job_id or a 'complete' tag
113 * @private
114 */
115function closeUploadBatchSession(session, entries) {
116 return callAPI(session, '/files/upload_session/finish_batch', {
117 entries: entries
118 });
119}
120
121function checkBatchEnd(session, result, checkRoute, jobId) {
122 let newId = null;
123 switch (result['.tag']) {
124 case 'async_job_id':
125 newId = result.async_job_id;
126 // falls through
127 case 'in_progress':
128 newId = newId || jobId;
129 return callAPI(session, checkRoute, {
130 async_job_id: newId
131 })
132 .then((result) => checkBatchEnd(session, result, checkRoute, newId));
133 case 'complete':
134 const failed = result.entries.reduce((memo, entry, index) => {
135 if(entry['.tag'] === 'failure') memo.push({entry, index});
136 return memo;
137 }, []);
138 if(failed.length > 0) {
139 const errors = failed.map(({entry, index}) => {
140 const failureTag = entry.failure['.tag'];
141 return `Could not complete action ${index}: ${failureTag + '/' + entry.failure[failureTag]['.tag']}`;
142 });
143 return Promise.reject(new UnifileError(
144 UnifileError.EIO, errors.join(', ')));
145 }
146 return Promise.resolve();
147 }
148}
149
150function makePathAbsolute(path) {
151 return path === '' ? path : '/' + path.split('/').filter((token) => token != '').join('/');
152}
153
154/**
155 * Stringifies a JSON object and make it header-safe by encoding
156 * non-ASCII characters.
157 *
158 * @param {Object} v - JSON object to stringify
159 * @returns {String} the stringified object with special chars encoded
160 *
161 * @see https://www.dropboxforum.com/t5/API-support/HTTP-header-quot-Dropbox-API-Arg-quot-could-not-decode-input-as/td-p/173822
162 */
163function safeStringify(v) {
164 return JSON.stringify(v).replace(charsToEncode,
165 function(c) {
166 return '\\u' + ('000' + c.charCodeAt(0).toString(16)).slice(-4);
167 }
168 );
169}
170
171/**
172 * Service connector for {@link https://dropbox.com|Dropbox} plateform.
173 *
174 * This will need a registered Dropbox application with valid redirection for your server.
175 * You can register a new application {@link https://www.dropbox.com/developers/apps|here} and
176 * learn more about Dropbox OAuth Web application flow
177 * {@link https://www.dropbox.com/developers/reference/oauth-guide|here}
178 */
179class DropboxConnector {
180 /**
181 * @constructor
182 * @param {Object} config - Configuration object
183 * @param {string} config.redirectUri - Dropbox application redirect URI
184 * @param {string} config.clientId - Dropbox application client ID
185 * @param {string} config.clientSecret - Dropbox application client secret
186 * @param {string} [config.writeMode=overwrite] - Write mode when files conflicts. Must be one of
187 * 'add'/'overwrite'/'update'.
188 * {@link https://www.dropbox.com/developers/documentation/http/documentation#files-upload|see Dropbox manual}
189 * @param {ConnectorStaticInfos} [config.infos] - Connector infos to override
190 */
191 constructor(config) {
192 if(!config || !config.clientId || !config.clientSecret || !config.redirectUri)
193 throw new Error('Invalid configuration. Please refer to the documentation to get the required fields.');
194 this.redirectUri = config.redirectUri;
195 this.clientId = config.clientId;
196 this.clientSecret = config.clientSecret;
197
198 this.infos = Tools.mergeInfos(config.infos, {
199 name: NAME,
200 displayName: 'Dropbox',
201 icon: '../assets/dropbox.png',
202 description: 'Edit files from your Dropbox.'
203 });
204
205 this.name = this.infos.name;
206 if(!config.writeMode || ['add', 'overwrite', 'update'].every((mode) => mode !== config.writeMode))
207 this.writeMode = 'overwrite';
208 else this.writeMode = config.writeMode;
209 }
210
211 getInfos(session) {
212 return Object.assign({
213 isLoggedIn: (session && 'token' in session),
214 isOAuth: true,
215 username: session.account ? session.account.name.display_name : undefined
216 }, this.infos);
217 }
218
219 setAccessToken(session, token) {
220 session.token = token;
221 const accountFields = [
222 'account_id',
223 'name',
224 'email'
225 ];
226 const filterAccountInfos = (account) => {
227 return Object.keys(account).reduce((memo, key) => {
228 if(accountFields.includes(key)) memo[key] = account[key];
229 return memo;
230 }, {});
231 };
232 let accountPromised = null;
233 if(session.account && 'id' in session.account) {
234 accountPromised = callAPI(session, '/users/get_account', {
235 account_id: session.account.id
236 });
237 } else {
238 accountPromised = callAPI(session, '/users/get_current_account');
239 }
240 return accountPromised.then((account) => {
241 session.account = filterAccountInfos(account);
242 return token;
243 })
244 .catch((err) => Promise.reject(new UnifileError(UnifileError.EACCES, 'Bad credentials')));
245 }
246
247 clearAccessToken(session) {
248 Tools.clearSession(session);
249 return Promise.resolve();
250 }
251
252 getAuthorizeURL(session) {
253 // Generate a random string for the state
254 session.state = (+new Date() * Math.random()).toString(36).replace('.', '');
255 let url = DB_OAUTH_URL
256 + '/authorize?response_type=code&client_id=' + this.clientId
257 + '&state=' + session.state;
258
259 // For CLI, don't use redirectUri and ask for `code` to be paste in the app
260 if(this.redirectUri) url += '&redirect_uri=' + this.redirectUri;
261
262 return Promise.resolve(url);
263 }
264
265 login(session, loginInfos) {
266 let returnPromise;
267 function processResponse(resolve, reject, err, response, body) {
268 if(err) return reject('Error while calling Dropbox API. ' + err);
269 session.account = {id: body.account_id};
270 return resolve(body.access_token);
271 }
272
273 if(typeof loginInfos === 'object' && 'state' in loginInfos && 'code' in loginInfos) {
274 if(loginInfos.state !== session.state)
275 return Promise.reject(new UnifileError(UnifileError.EACCES, 'Invalid request (cross-site request)'));
276 returnPromise = new Promise((resolve, reject) => {
277 request({
278 url: 'https://api.dropboxapi.com/oauth2/token',
279 method: 'POST',
280 form: {
281 code: loginInfos.code,
282 grant_type: 'authorization_code',
283 client_id: this.clientId,
284 client_secret: this.clientSecret,
285 redirect_uri: this.redirectUri
286 },
287 json: true
288 }, processResponse.bind(this, resolve, reject));
289 });
290 } else {
291 return Promise.reject(new UnifileError(UnifileError.EACCES, 'Invalid credentials'));
292 }
293 return returnPromise.then((token) => {
294 return this.setAccessToken(session, token);
295 });
296 }
297
298 //Filesystem commands
299
300 readdir(session, path) {
301 return callAPI(session, '/files/list_folder', {
302 path: makePathAbsolute(path)
303 })
304 .then((result) => {
305 return result.entries.map((entry) => {
306 return {
307 size: entry.size,
308 modified: entry.client_modified,
309 name: entry.name,
310 isDir: entry['.tag'] == 'folder',
311 mime: Mime.getType(entry.name)
312 };
313 });
314 });
315 }
316
317 stat(session, path) {
318 if(!path) return Promise.reject(new UnifileError(UnifileError.EINVAL, 'You must provide a path to stat'));
319
320 return callAPI(session, '/files/get_metadata', {
321 path: makePathAbsolute(path)
322 })
323 .then((stat) => {
324 return {
325 size: stat.size,
326 modified: stat.client_modified,
327 name: stat.name,
328 isDir: stat['.tag'] == 'folder',
329 mime: Mime.getType(stat.name)
330 };
331 });
332 }
333
334 mkdir(session, path) {
335 if(!path) return Promise.reject(new UnifileError(UnifileError.EINVAL, 'Cannot create dir with an empty name.'));
336 return callAPI(session, '/files/create_folder_v2', {
337 path: makePathAbsolute(path)
338 });
339 }
340
341 writeFile(session, path, data) {
342 // TODO Use upload session for files bigger than 150Mo
343 // (https://www.dropbox.com/developers/documentation/http/documentation#files-upload_session-start)
344 // TODO Handle file conflict and write mode
345 return callAPI(session, '/files/upload', data, 'content', false, {
346 'Content-Type': 'application/octet-stream',
347 'Dropbox-API-Arg': safeStringify({
348 path: makePathAbsolute(path),
349 mode: this.writeMode
350 })
351 });
352 }
353
354 createWriteStream(session, path) {
355
356 const writeStream = request({
357 url: 'https://content.dropboxapi.com/2/files/upload',
358 method: 'POST',
359 headers: {
360 'Authorization': 'Bearer ' + session.token,
361 'Content-Type': 'application/octet-stream',
362 'User-Agent': 'Unifile',
363 'Dropbox-API-Arg': safeStringify({
364 path: makePathAbsolute(path),
365 mode: this.writeMode
366 })
367 }
368 })
369 .on('response', (response) => {
370 switch (response.statusCode) {
371 case 200:
372 writeStream.emit('close');
373 break;
374 case 400: //falltrough
375 case 409:
376 writeStream.emit('error', new UnifileError(UnifileError.EINVAL, 'Invalid stream'));
377 break;
378 default:
379 writeStream.emit('error', new UnifileError(UnifileError.EIO, 'Creation failed'));
380 }
381 });
382
383 return writeStream;
384 }
385
386 readFile(session, path) {
387 return callAPI(session, '/files/download', {}, 'content', false, {
388 'Dropbox-API-Arg': safeStringify({
389 path: makePathAbsolute(path)
390 })
391 });
392 }
393
394 createReadStream(session, path) {
395 const readStream = new PassThrough();
396 const req = request({
397 url: 'https://content.dropboxapi.com/2/files/download',
398 method: 'POST',
399 headers: {
400 'Authorization': 'Bearer ' + session.token,
401 'User-Agent': 'Unifile',
402 'Dropbox-API-Arg': safeStringify({
403 path: makePathAbsolute(path)
404 })
405 }
406 })
407 .on('response', (response) => {
408 if(response.statusCode === 200) req.pipe(readStream);
409
410 switch (response.statusCode) {
411 case 400: readStream.emit('error', new UnifileError(UnifileError.EINVAL, 'Invalid request'));
412 break;
413 case 409:
414 const chunks = [];
415 req.on('data', (data) => {
416 chunks.push(data);
417 });
418 req.on('end', () => {
419 const body = JSON.parse(Buffer.concat(chunks).toString());
420 if(body.error_summary.startsWith('path/not_found'))
421 readStream.emit('error', new UnifileError(UnifileError.ENOENT, 'Not Found'));
422 else if(body.error_summary.startsWith('path/not_file'))
423 readStream.emit('error', new UnifileError(UnifileError.EISDIR, 'Path is a directory'));
424 else
425 readStream.emit('error', new UnifileError(UnifileError.EIO, 'Unable to read file'));
426 });
427 }
428 });
429 return readStream;
430 }
431
432 rename(session, src, dest) {
433 if(!src)
434 return Promise.reject(new UnifileError(UnifileError.EINVAL, 'Cannot rename path with an empty name'));
435 if(!dest)
436 return Promise.reject(new UnifileError(UnifileError.EINVAL, 'Cannot rename path with an empty destination'));
437 return callAPI(session, '/files/move', {
438 from_path: makePathAbsolute(src),
439 to_path: makePathAbsolute(dest)
440 });
441 }
442
443 unlink(session, path) {
444 if(!path)
445 return Promise.reject(new UnifileError(UnifileError.EINVAL, 'Cannot remove path with an empty name'));
446 return callAPI(session, '/files/delete_v2', {
447 path: makePathAbsolute(path)
448 });
449 }
450
451 rmdir(session, path) {
452 return this.unlink(session, path);
453 }
454
455 batch(session, actions, message) {
456 const writeMode = this.writeMode;
457 let actionsChain = Promise.resolve();
458
459 let uploadEntries = [];
460 let deleteEntries = [];
461 let moveEntries = [];
462
463 const batchMap = {
464 writefile: uploadBatch,
465 rmdir: deleteBatch,
466 rename: moveBatch
467 };
468
469 function closeBatchs(action) {
470 for(const key in batchMap) {
471 if(key !== action) {
472 batchMap[key]();
473 }
474 }
475 }
476
477 function moveBatch() {
478 if(moveEntries.length === 0) return Promise.resolve();
479
480 const toMove = moveEntries.slice();
481 actionsChain = actionsChain.then(() => {
482 return callAPI(session, '/files/move_batch', {
483 entries: toMove
484 })
485 .then((result) => checkBatchEnd(session, result, '/files/move_batch/check'));
486 });
487 moveEntries = [];
488 }
489
490 function deleteBatch() {
491 if(deleteEntries.length === 0) return Promise.resolve();
492
493 /*
494 Dropbox executes all the deletion at the same time,
495 so we remove all the descendant of a deleted folder beforehand
496 */
497 const toDelete = deleteEntries.slice().sort((a, b) => a.path.length - b.path.length);
498 const deduplicated = [];
499 while(toDelete.length !== 0) {
500 if(!deduplicated.some(({path}) => toDelete[0].path.includes(path + '/'))) {
501 deduplicated.push(toDelete.shift());
502 } else toDelete.shift();
503 }
504 actionsChain = actionsChain.then(() => {
505 return callAPI(session, '/files/delete_batch', {
506 entries: deduplicated
507 })
508 .then((result) => checkBatchEnd(session, result, '/files/delete_batch/check'));
509 });
510 deleteEntries = [];
511 }
512
513 function uploadBatch() {
514 if(uploadEntries.length === 0) return Promise.resolve();
515
516 const toUpload = uploadEntries.slice();
517 actionsChain = actionsChain.then(() => {
518 return Promise.map(toUpload, (action) => {
519 const bitContent = new Buffer(action.content);
520 return openUploadSession(session, bitContent, true)
521 .then((result) => {
522 return {
523 cursor: {
524 session_id: result.session_id,
525 offset: bitContent.length
526 },
527 commit: {
528 path: makePathAbsolute(action.path),
529 mode: writeMode
530 }
531 };
532 });
533 })
534 .then((commits) => closeUploadBatchSession(session, commits))
535 .then((result) => checkBatchEnd(session, result, '/files/upload_session/finish_batch/check'));
536 });
537 uploadEntries = [];
538 }
539
540 for(const action of actions) {
541 if(!action.path)
542 return Promise.reject(new BatchError(UnifileError.EINVAL,
543 'Cannot execute batch action without a path'));
544 switch (action.name.toLowerCase()) {
545 case 'unlink':
546 case 'rmdir':
547 closeBatchs('rmdir');
548 deleteEntries.push({
549 path: makePathAbsolute(action.path)
550 });
551 break;
552 case 'rename':
553 closeBatchs(action.name.toLowerCase());
554 if(!action.destination)
555 return Promise.reject(new BatchError(
556 UnifileError.EINVAL,
557 'Rename actions should have a destination'));
558 moveEntries.push({
559 from_path: makePathAbsolute(action.path),
560 to_path: makePathAbsolute(action.destination)
561 });
562 break;
563 case 'writefile':
564 closeBatchs(action.name.toLowerCase());
565 if(!action.content)
566 return Promise.reject(new BatchError(
567 UnifileError.EINVAL,
568 'Write actions should have a content'));
569 uploadEntries.push(action);
570 break;
571 case 'mkdir':
572 closeBatchs(action.name.toLowerCase());
573 actionsChain = actionsChain.then(() => {
574 return this.mkdir(session, action.path);
575 })
576 .catch((err) => err.name !== 'BatchError',
577 (err) => {
578 throw new BatchError(
579 UnifileError.EINVAL,
580 `Could not complete action ${action.name}: ${err.message}`);
581 });
582 break;
583 default:
584 console.warn(`Unsupported batch action: ${action.name}`);
585 }
586 }
587 closeBatchs('');
588
589 return actionsChain;
590 }
591}
592
593module.exports = DropboxConnector;