diff --git a/CliClient/app/command-sync.js b/CliClient/app/command-sync.js index e31d18f2c..1e9d03c45 100644 --- a/CliClient/app/command-sync.js +++ b/CliClient/app/command-sync.js @@ -78,10 +78,26 @@ class Command extends BaseCommand { return false; } + return true; + } else if (syncTargetMd.name === 'dropbox') { // Dropbox + const api = await syncTarget.api(); + const loginUrl = api.loginUrl(); + this.stdout(_('To allow Joplin to synchronise with Dropbox, please follow the steps below:')); + this.stdout(_('Step 1: Open this URL in your browser to authorise the application:')); + this.stdout(loginUrl); + const authCode = await this.prompt(_('Step 2: Enter the code provided by Dropbox:'), { type: 'string' }); + if (!authCode) { + this.stdout(_('Authentication was not completed (did not receive an authentication token).')); + return false; + } + + const response = await api.execAuthToken(authCode); + Setting.setValue('sync.' + this.syncTargetId_ + '.auth', JSON.stringify(response)); + api.setAuthToken(response.access_token); return true; } - this.stdout(_('Not authentified with %s. Please provide any missing credentials.', syncTarget.label())); + this.stdout(_('Not authentified with %s. Please provide any missing credentials.', syncTargetMd.label)); return false; } @@ -100,6 +116,7 @@ class Command extends BaseCommand { this.releaseLockFn_ = null; // Lock is unique per profile/database + // TODO: use SQLite database to do lock? const lockFilePath = require('os').tmpdir() + '/synclock_' + md5(escape(Setting.value('profileDir'))); // https://github.com/pvorb/node-md5/issues/41 if (!await fs.pathExists(lockFilePath)) await fs.writeFile(lockFilePath, 'synclock'); @@ -130,7 +147,7 @@ class Command extends BaseCommand { const syncTarget = reg.syncTarget(this.syncTargetId_); - if (!syncTarget.isAuthenticated()) { + if (!await syncTarget.isAuthenticated()) { app().gui().showConsole(); app().gui().maximizeConsole(); @@ -197,7 +214,7 @@ class Command extends BaseCommand { const syncTarget = reg.syncTarget(syncTargetId); - if (syncTarget.isAuthenticated()) { + if (await syncTarget.isAuthenticated()) { const sync = await syncTarget.synchronizer(); if (sync) await sync.cancel(); } else { diff --git a/ElectronClient/app/gui/DropboxLoginScreen.jsx b/ElectronClient/app/gui/DropboxLoginScreen.jsx new file mode 100644 index 000000000..f85562be2 --- /dev/null +++ b/ElectronClient/app/gui/DropboxLoginScreen.jsx @@ -0,0 +1,111 @@ +const React = require('react'); +const { connect } = require('react-redux'); +const { reg } = require('lib/registry.js'); +const { bridge } = require('electron').remote.require('./bridge'); +const { Header } = require('./Header.min.js'); +const { themeStyle } = require('../theme.js'); +const SyncTargetRegistry = require('lib/SyncTargetRegistry'); +const { _ } = require('lib/locale.js'); + +class DropboxLoginScreenComponent extends React.Component { + + constructor() { + super(); + + this.dropboxApi_ = null; + + this.state = { + loginUrl: '', + authCode: '', + checkingAuthToken: false, + }; + + this.loginUrl_click = () => { + if (!this.state.loginUrl) return; + bridge().openExternal(this.state.loginUrl) + } + + this.authCodeInput_change = (event) => { + this.setState({ + authCode: event.target.value + }); + } + + this.submit_click = async () => { + this.setState({ checkingAuthToken: true }); + + const api = await this.dropboxApi(); + try { + const response = await api.execAuthToken(this.state.authCode); + Setting.setValue('sync.' + this.syncTargetId() + '.auth', JSON.stringify(response)); + api.setAuthToken(response.access_token); + bridge().showInfoMessageBox(_('The application has been authorised!')); + this.props.dispatch({ type: 'NAV_BACK' }); + } catch (error) { + bridge().showErrorMessageBox(_('Could not authorise application:\n\n%s\n\nPlease try again.', error.message)); + } finally { + this.setState({ checkingAuthToken: false }); + } + } + } + + componentWillMount() { + this.refreshUrl(); + } + + syncTargetId() { + return SyncTargetRegistry.nameToId('dropbox'); + } + + async dropboxApi() { + if (this.dropboxApi_) return this.dropboxApi_; + + const syncTarget = reg.syncTarget(this.syncTargetId()); + this.dropboxApi_ = await syncTarget.api(); + return this.dropboxApi_; + } + + async refreshUrl() { + const api = await this.dropboxApi(); + + this.setState({ + loginUrl: api.loginUrl(), + }); + } + + render() { + const style = this.props.style; + const theme = themeStyle(this.props.theme); + + const headerStyle = { + width: style.width, + }; + + const inputStyle = Object.assign({}, theme.inputStyle, { width: 500 }); + + return ( +
+
+
+

{_('To allow Joplin to synchronise with Dropbox, please follow the steps below:')}

+

{_('Step 1: Open this URL in your browser to authorise the application:')}

+ {this.state.loginUrl} +

{_('Step 2: Enter the code provided by Dropbox:')}

+

+ +
+
+ ); + } + +} + +const mapStateToProps = (state) => { + return { + theme: state.settings.theme, + }; +}; + +const DropboxLoginScreen = connect(mapStateToProps)(DropboxLoginScreenComponent); + +module.exports = { DropboxLoginScreen }; \ No newline at end of file diff --git a/ElectronClient/app/gui/Root.jsx b/ElectronClient/app/gui/Root.jsx index 66dda0344..c1a740970 100644 --- a/ElectronClient/app/gui/Root.jsx +++ b/ElectronClient/app/gui/Root.jsx @@ -8,6 +8,7 @@ const Setting = require('lib/models/Setting.js'); const { MainScreen } = require('./MainScreen.min.js'); const { OneDriveLoginScreen } = require('./OneDriveLoginScreen.min.js'); +const { DropboxLoginScreen } = require('./DropboxLoginScreen.min.js'); const { StatusScreen } = require('./StatusScreen.min.js'); const { ImportScreen } = require('./ImportScreen.min.js'); const { ConfigScreen } = require('./ConfigScreen.min.js'); @@ -75,6 +76,7 @@ class RootComponent extends React.Component { const screens = { Main: { screen: MainScreen }, OneDriveLogin: { screen: OneDriveLoginScreen, title: () => _('OneDrive Login') }, + DropboxLogin: { screen: DropboxLoginScreen, title: () => _('Dropbox Login') }, Import: { screen: ImportScreen, title: () => _('Import') }, Config: { screen: ConfigScreen, title: () => _('Options') }, Status: { screen: StatusScreen, title: () => _('Synchronisation Status') }, diff --git a/ElectronClient/app/theme.js b/ElectronClient/app/theme.js index 93d8f62de..389980d69 100644 --- a/ElectronClient/app/theme.js +++ b/ElectronClient/app/theme.js @@ -62,6 +62,7 @@ globalStyle.icon = { globalStyle.lineInput = { color: globalStyle.color, backgroundColor: globalStyle.backgroundColor, + fontFamily: globalStyle.fontFamily, }; globalStyle.textStyle = { diff --git a/ReactNativeClient/lib/BaseApplication.js b/ReactNativeClient/lib/BaseApplication.js index b091d268b..2e6bdcd05 100644 --- a/ReactNativeClient/lib/BaseApplication.js +++ b/ReactNativeClient/lib/BaseApplication.js @@ -29,6 +29,7 @@ const SyncTargetOneDrive = require('lib/SyncTargetOneDrive.js'); const SyncTargetOneDriveDev = require('lib/SyncTargetOneDriveDev.js'); const SyncTargetNextcloud = require('lib/SyncTargetNextcloud.js'); const SyncTargetWebDAV = require('lib/SyncTargetWebDAV.js'); +const SyncTargetDropbox = require('lib/SyncTargetDropbox.js'); const EncryptionService = require('lib/services/EncryptionService'); const DecryptionWorker = require('lib/services/DecryptionWorker'); const BaseService = require('lib/services/BaseService'); @@ -38,6 +39,7 @@ SyncTargetRegistry.addClass(SyncTargetOneDrive); SyncTargetRegistry.addClass(SyncTargetOneDriveDev); SyncTargetRegistry.addClass(SyncTargetNextcloud); SyncTargetRegistry.addClass(SyncTargetWebDAV); +SyncTargetRegistry.addClass(SyncTargetDropbox); class BaseApplication { @@ -421,8 +423,14 @@ class BaseApplication { if (Setting.value('firstStart')) { const locale = shim.detectAndSetLocale(Setting); reg.logger().info('First start: detected locale as ' + locale); - if (Setting.value('env') === 'dev') Setting.setValue('sync.target', SyncTargetRegistry.nameToId('onedrive_dev')); - Setting.setValue('firstStart', 0) + + if (Setting.value('env') === 'dev') { + Setting.setValue('showTrayIcon', 0); + Setting.setValue('autoUpdateEnabled', 0); + Setting.setValue('sync.interval', 3600); + } + + Setting.setValue('firstStart', 0); } else { setLocale(Setting.value('locale')); } diff --git a/ReactNativeClient/lib/BaseSyncTarget.js b/ReactNativeClient/lib/BaseSyncTarget.js index 10380b564..32edc5b12 100644 --- a/ReactNativeClient/lib/BaseSyncTarget.js +++ b/ReactNativeClient/lib/BaseSyncTarget.js @@ -30,7 +30,7 @@ class BaseSyncTarget { return this.db_; } - isAuthenticated() { + async isAuthenticated() { return false; } @@ -113,7 +113,7 @@ class BaseSyncTarget { async syncStarted() { if (!this.synchronizer_) return false; - if (!this.isAuthenticated()) return false; + if (!await this.isAuthenticated()) return false; const sync = await this.synchronizer(); return sync.state() != 'idle'; } diff --git a/ReactNativeClient/lib/DropboxApi.js b/ReactNativeClient/lib/DropboxApi.js index 382d8e989..dadf3e7c0 100644 --- a/ReactNativeClient/lib/DropboxApi.js +++ b/ReactNativeClient/lib/DropboxApi.js @@ -12,6 +12,14 @@ class DropboxApi { this.authToken_ = null; } + clientId() { + return this.options_.id; + } + + clientSecret() { + return this.options_.secret; + } + setLogger(l) { this.logger_ = l; } @@ -21,13 +29,17 @@ class DropboxApi { } authToken() { - return this.authToken_; // Must be "Bearer XXXXXXXXXXXXXXXXXX" + return this.authToken_; // Without the "Bearer " prefix } setAuthToken(v) { this.authToken_ = v; } + 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'; @@ -49,6 +61,35 @@ class DropboxApi { 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); + } + async exec(method, path = '', body = null, headers = null, options = null) { if (headers === null) headers = {}; if (options === null) options = {}; @@ -56,7 +97,7 @@ class DropboxApi { const authToken = this.authToken(); - if (authToken) headers['Authorization'] = authToken; + if (authToken) headers['Authorization'] = 'Bearer ' + authToken; const endPointFormat = ['files/upload', 'files/download'].indexOf(path) >= 0 ? 'content' : 'api'; @@ -73,7 +114,7 @@ class DropboxApi { if (options.path) fetchOptions.path = options.path; if (body) fetchOptions.body = body; - const url = this.baseUrl(endPointFormat) + '/' + path; + const url = path.indexOf('https://') === 0 ? path : this.baseUrl(endPointFormat) + '/' + path; let tryCount = 0; diff --git a/ReactNativeClient/lib/SyncTargetDropbox.js b/ReactNativeClient/lib/SyncTargetDropbox.js index 8edc2a559..60b609523 100644 --- a/ReactNativeClient/lib/SyncTargetDropbox.js +++ b/ReactNativeClient/lib/SyncTargetDropbox.js @@ -26,9 +26,18 @@ class SyncTargetDropbox extends BaseSyncTarget { return _('Dropbox'); } - isAuthenticated() { - const f = this.fileApiSync(); - return f && f.driver().api().authToken(); + authRouteName() { + return 'DropboxLogin'; + } + + async isAuthenticated() { + const f = await this.fileApi(); + return !!f.driver().api().authToken(); + } + + async api() { + const fileApi = await this.fileApi(); + return fileApi.driver().api(); } syncTargetId() { @@ -36,7 +45,19 @@ class SyncTargetDropbox extends BaseSyncTarget { } async initFileApi() { - const api = new DropboxApi(); + const params = parameters().dropbox; + + const api = new DropboxApi({ + id: params.id, + secret: params.secret, + }); + + const authJson = Setting.value('sync.' + SyncTargetDropbox.id() + '.auth'); + if (authJson) { + const auth = JSON.parse(authJson); + api.setAuthToken(auth.access_token); + } + const appDir = ''; const fileApi = new FileApi(appDir, new FileApiDriverDropbox(api)); fileApi.setSyncTargetId(this.syncTargetId()); @@ -45,7 +66,7 @@ class SyncTargetDropbox extends BaseSyncTarget { } async initSynchronizer() { - if (!this.isAuthenticated()) throw new Error('User is not authentified'); + if (!(await this.isAuthenticated())) throw new Error('User is not authentified'); return new Synchronizer(this.db(), await this.fileApi(), Setting.value('appType')); } diff --git a/ReactNativeClient/lib/SyncTargetFilesystem.js b/ReactNativeClient/lib/SyncTargetFilesystem.js index 6dd4f4851..5f6a5580c 100644 --- a/ReactNativeClient/lib/SyncTargetFilesystem.js +++ b/ReactNativeClient/lib/SyncTargetFilesystem.js @@ -19,7 +19,7 @@ class SyncTargetFilesystem extends BaseSyncTarget { return _('File system'); } - isAuthenticated() { + async isAuthenticated() { return true; } diff --git a/ReactNativeClient/lib/SyncTargetMemory.js b/ReactNativeClient/lib/SyncTargetMemory.js index d0ac33461..1e3d2812d 100644 --- a/ReactNativeClient/lib/SyncTargetMemory.js +++ b/ReactNativeClient/lib/SyncTargetMemory.js @@ -19,7 +19,7 @@ class SyncTargetMemory extends BaseSyncTarget { return 'Memory'; } - isAuthenticated() { + async isAuthenticated() { return true; } diff --git a/ReactNativeClient/lib/SyncTargetNextcloud.js b/ReactNativeClient/lib/SyncTargetNextcloud.js index 57ba9900c..094a63f34 100644 --- a/ReactNativeClient/lib/SyncTargetNextcloud.js +++ b/ReactNativeClient/lib/SyncTargetNextcloud.js @@ -28,7 +28,7 @@ class SyncTargetNextcloud extends BaseSyncTarget { return _('Nextcloud'); } - isAuthenticated() { + async isAuthenticated() { return true; } diff --git a/ReactNativeClient/lib/SyncTargetOneDrive.js b/ReactNativeClient/lib/SyncTargetOneDrive.js index 50f0a04eb..027b1eafa 100644 --- a/ReactNativeClient/lib/SyncTargetOneDrive.js +++ b/ReactNativeClient/lib/SyncTargetOneDrive.js @@ -26,7 +26,7 @@ class SyncTargetOneDrive extends BaseSyncTarget { return _('OneDrive'); } - isAuthenticated() { + async isAuthenticated() { return this.api().auth(); } @@ -80,7 +80,7 @@ class SyncTargetOneDrive extends BaseSyncTarget { } async initSynchronizer() { - if (!this.isAuthenticated()) throw new Error('User is not authentified'); + if (!await this.isAuthenticated()) throw new Error('User is not authentified'); return new Synchronizer(this.db(), await this.fileApi(), Setting.value('appType')); } diff --git a/ReactNativeClient/lib/SyncTargetWebDAV.js b/ReactNativeClient/lib/SyncTargetWebDAV.js index 6c17e9617..e5ebf99a8 100644 --- a/ReactNativeClient/lib/SyncTargetWebDAV.js +++ b/ReactNativeClient/lib/SyncTargetWebDAV.js @@ -24,7 +24,7 @@ class SyncTargetWebDAV extends BaseSyncTarget { return _('WebDAV'); } - isAuthenticated() { + async isAuthenticated() { return true; } diff --git a/ReactNativeClient/lib/components/shared/side-menu-shared.js b/ReactNativeClient/lib/components/shared/side-menu-shared.js index 5cb87ec0a..bfdba6971 100644 --- a/ReactNativeClient/lib/components/shared/side-menu-shared.js +++ b/ReactNativeClient/lib/components/shared/side-menu-shared.js @@ -36,7 +36,7 @@ shared.synchronize_press = async function(comp) { const action = comp.props.syncStarted ? 'cancel' : 'start'; - if (!reg.syncTarget().isAuthenticated()) { + if (!await reg.syncTarget().isAuthenticated()) { if (reg.syncTarget().authRouteName()) { comp.props.dispatch({ type: 'NAV_GO', diff --git a/ReactNativeClient/lib/models/Setting.js b/ReactNativeClient/lib/models/Setting.js index 0b59e59fb..d0db461b0 100644 --- a/ReactNativeClient/lib/models/Setting.js +++ b/ReactNativeClient/lib/models/Setting.js @@ -99,7 +99,7 @@ class Setting extends BaseModel { }}, 'noteVisiblePanes': { value: ['editor', 'viewer'], type: Setting.TYPE_ARRAY, public: false, appTypes: ['desktop'] }, 'showAdvancedOptions': { value: false, type: Setting.TYPE_BOOL, public: true, appTypes: ['mobile' ], label: () => _('Show advanced options') }, - 'sync.target': { value: SyncTargetRegistry.nameToId('onedrive'), type: Setting.TYPE_INT, isEnum: true, public: true, label: () => _('Synchronisation target'), description: (appType) => { return appType !== 'cli' ? null : _('The target to synchonise to. Each sync target may have additional parameters which are named as `sync.NUM.NAME` (all documented below).') }, options: () => { + 'sync.target': { value: SyncTargetRegistry.nameToId('dropbox'), type: Setting.TYPE_INT, isEnum: true, public: true, label: () => _('Synchronisation target'), description: (appType) => { return appType !== 'cli' ? null : _('The target to synchonise to. Each sync target may have additional parameters which are named as `sync.NUM.NAME` (all documented below).') }, options: () => { return SyncTargetRegistry.idAndLabelPlainObject(); }}, @@ -121,12 +121,14 @@ class Setting extends BaseModel { 'sync.3.auth': { value: '', type: Setting.TYPE_STRING, public: false }, 'sync.4.auth': { value: '', type: Setting.TYPE_STRING, public: false }, + 'sync.7.auth': { value: '', type: Setting.TYPE_STRING, public: false }, 'sync.1.context': { value: '', type: Setting.TYPE_STRING, public: false }, 'sync.2.context': { value: '', type: Setting.TYPE_STRING, public: false }, 'sync.3.context': { value: '', type: Setting.TYPE_STRING, public: false }, 'sync.4.context': { value: '', type: Setting.TYPE_STRING, public: false }, 'sync.5.context': { value: '', type: Setting.TYPE_STRING, public: false }, 'sync.6.context': { value: '', type: Setting.TYPE_STRING, public: false }, + 'sync.7.context': { value: '', type: Setting.TYPE_STRING, public: false }, }; return this.metadata_; diff --git a/ReactNativeClient/lib/parameters.js b/ReactNativeClient/lib/parameters.js index 53002131d..e73ff06d7 100644 --- a/ReactNativeClient/lib/parameters.js +++ b/ReactNativeClient/lib/parameters.js @@ -11,6 +11,10 @@ parameters_.dev = { id: '606fd4d7-4dfb-4310-b8b7-a47d96aa22b6', secret: 'qabchuPYL7931$ePDEQ3~_$', }, + dropbox: { + id: 'cx9li9ur8taq1z7', + secret: 'i8f9a1mvx3bijrt', + }, }; parameters_.prod = { @@ -22,6 +26,10 @@ parameters_.prod = { id: '606fd4d7-4dfb-4310-b8b7-a47d96aa22b6', secret: 'qabchuPYL7931$ePDEQ3~_$', }, + dropbox: { + id: 'm044w3cvmxhzvop', + secret: 'r298deqisz0od56', + }, }; function parameters(env = null) { diff --git a/ReactNativeClient/lib/registry.js b/ReactNativeClient/lib/registry.js index f92531f72..719bb7eb2 100644 --- a/ReactNativeClient/lib/registry.js +++ b/ReactNativeClient/lib/registry.js @@ -70,7 +70,7 @@ reg.scheduleSync = async (delay = null, syncOptions = null) => { const syncTargetId = Setting.value('sync.target'); - if (!reg.syncTarget(syncTargetId).isAuthenticated()) { + if (!await reg.syncTarget(syncTargetId).isAuthenticated()) { reg.logger().info('Synchroniser is missing credentials - manual sync required to authenticate.'); promiseResolve(); return;