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;