1 | 'use strict';
|
2 |
|
3 | const {PassThrough} = require('stream');
|
4 | const Promise = require('bluebird');
|
5 | const request = require('request');
|
6 | const Mime = require('mime');
|
7 |
|
8 | const Tools = require('unifile-common-tools');
|
9 | const {UnifileError, BatchError} = require('./error');
|
10 |
|
11 | const NAME = 'dropbox';
|
12 | const DB_OAUTH_URL = 'https://www.dropbox.com/oauth2';
|
13 |
|
14 | const charsToEncode = /[\u007f-\uffff]/g;
|
15 |
|
16 |
|
17 |
|
18 |
|
19 |
|
20 |
|
21 |
|
22 |
|
23 |
|
24 |
|
25 |
|
26 |
|
27 | function 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 |
|
56 |
|
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 |
|
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 |
|
89 | function 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 |
|
101 |
|
102 |
|
103 |
|
104 |
|
105 |
|
106 |
|
107 |
|
108 |
|
109 |
|
110 |
|
111 |
|
112 |
|
113 |
|
114 |
|
115 | function closeUploadBatchSession(session, entries) {
|
116 | return callAPI(session, '/files/upload_session/finish_batch', {
|
117 | entries: entries
|
118 | });
|
119 | }
|
120 |
|
121 | function 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 |
|
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 |
|
150 | function makePathAbsolute(path) {
|
151 | return path === '' ? path : '/' + path.split('/').filter((token) => token != '').join('/');
|
152 | }
|
153 |
|
154 |
|
155 |
|
156 |
|
157 |
|
158 |
|
159 |
|
160 |
|
161 |
|
162 |
|
163 | function 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 |
|
173 |
|
174 |
|
175 |
|
176 |
|
177 |
|
178 |
|
179 | class DropboxConnector {
|
180 | |
181 |
|
182 |
|
183 |
|
184 |
|
185 |
|
186 |
|
187 |
|
188 |
|
189 |
|
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 |
|
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 |
|
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 |
|
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 |
|
343 |
|
344 |
|
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:
|
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 |
|
495 |
|
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 |
|
593 | module.exports = DropboxConnector;
|