const { Logger } = require('lib/logger.js'); const { shim } = require('lib/shim.js'); const JoplinError = require('lib/JoplinError'); const URL = require('url-parse'); const { time } = require('lib/time-utils'); const EventDispatcher = require('lib/EventDispatcher'); class DropboxApi { constructor(options) { this.logger_ = new Logger(); this.options_ = options; this.authToken_ = null; this.dispatcher_ = new EventDispatcher(); } clientId() { return this.options_.id; } clientSecret() { return this.options_.secret; } setLogger(l) { this.logger_ = l; } logger() { return this.logger_; } authToken() { return this.authToken_; // Without the "Bearer " prefix } on(eventName, callback) { return this.dispatcher_.on(eventName, callback); } setAuthToken(v) { this.authToken_ = v; this.dispatcher_.dispatch('authRefreshed', this.authToken()); } loginUrl() { return 'https://www.dropbox.com/oauth2/authorize?response_type=code&client_id=' + this.clientId(); } baseUrl(endPointFormat) { if (['content', 'api'].indexOf(endPointFormat) < 0) throw new Error('Invalid end point format: ' + endPointFormat); return 'https://' + endPointFormat + '.dropboxapi.com/2'; } requestToCurl_(url, options) { let output = []; output.push('curl'); if (options.method) output.push('-X ' + options.method); if (options.headers) { for (let n in options.headers) { if (!options.headers.hasOwnProperty(n)) continue; output.push('-H ' + "'" + n + ': ' + options.headers[n] + "'"); } } if (options.body) output.push('--data ' + '"' + options.body + '"'); output.push(url); return output.join(' '); } async execAuthToken(authCode) { const postData = { code: authCode, grant_type: 'authorization_code', client_id: this.clientId(), client_secret: this.clientSecret(), }; var formBody = []; for (var property in postData) { var encodedKey = encodeURIComponent(property); var encodedValue = encodeURIComponent(postData[property]); formBody.push(encodedKey + "=" + encodedValue); } formBody = formBody.join("&"); const response = await shim.fetch('https://api.dropboxapi.com/oauth2/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' }, body: formBody }); const responseText = await response.text(); if (!response.ok) throw new Error(responseText); return JSON.parse(responseText); } isTokenError(status, responseText) { if (status === 401) return true; if (responseText.indexOf('OAuth 2 access token is malformed') >= 0) return true; // eg. Error: POST files/create_folder_v2: Error (400): Error in call to API function "files/create_folder_v2": Must provide HTTP header "Authorization" or URL parameter "authorization". if (responseText.indexOf('Must provide HTTP header "Authorization"') >= 0) return true; return false; } async exec(method, path = '', body = null, headers = null, options = null) { if (headers === null) headers = {}; if (options === null) options = {}; if (!options.target) options.target = 'string'; const authToken = this.authToken(); if (authToken) headers['Authorization'] = 'Bearer ' + authToken; const endPointFormat = ['files/upload', 'files/download'].indexOf(path) >= 0 ? 'content' : 'api'; if (endPointFormat === 'api') { headers['Content-Type'] = 'application/json'; if (body && typeof body === 'object') body = JSON.stringify(body); } else { headers['Content-Type'] = 'application/octet-stream'; } const fetchOptions = {}; fetchOptions.headers = headers; fetchOptions.method = method; if (options.path) fetchOptions.path = options.path; if (body) fetchOptions.body = body; const url = path.indexOf('https://') === 0 ? path : this.baseUrl(endPointFormat) + '/' + path; let tryCount = 0; while (true) { try { let response = null; // console.info(this.requestToCurl_(url, fetchOptions)); // console.info(method + ' ' + url); if (options.source == 'file' && (method == 'POST' || method == 'PUT')) { response = await shim.uploadBlob(url, fetchOptions); } else if (options.target == 'string') { response = await shim.fetch(url, fetchOptions); } else { // file response = await shim.fetchBlob(url, fetchOptions); } const responseText = await response.text(); // console.info('Response: ' + responseText); let responseJson_ = null; const loadResponseJson = () => { if (!responseText) return null; if (responseJson_) return responseJson_; try { responseJson_ = JSON.parse(responseText); } catch (error) { return { error: responseText }; } return responseJson_; } // Creates an error object with as much data as possible as it will appear in the log, which will make debugging easier const newError = (message) => { const json = loadResponseJson(); let code = ''; if (json && json.error_summary) { code = json.error_summary; } // Gives a shorter response for error messages. Useful for cases where a full HTML page is accidentally loaded instead of // JSON. That way the error message will still show there's a problem but without filling up the log or screen. const shortResponseText = (responseText + '').substr(0, 1024); const error = new JoplinError(method + ' ' + path + ': ' + message + ' (' + response.status + '): ' + shortResponseText, code); error.httpStatus = response.status; return error; } if (!response.ok) { const json = loadResponseJson(); if (this.isTokenError(response.status, responseText)) { this.setAuthToken(null); } // When using fetchBlob we only get a string (not xml or json) back if (options.target === 'file') throw newError('fetchBlob error'); throw newError('Error'); } if (options.responseFormat === 'text') return responseText; return loadResponseJson(); } catch (error) { tryCount++; if (error && typeof error.code === 'string' && error.code.indexOf('too_many_write_operations') >= 0) { this.logger().warn('too_many_write_operations ' + tryCount); if (tryCount >= 3) { throw error; } await time.sleep(tryCount * 2); } else { throw error; } } } } } module.exports = DropboxApi;