1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-24 10:27:10 +02:00

Adding Dropbox sync to Electron app

This commit is contained in:
Laurent Cozic 2018-03-26 18:33:55 +01:00
parent 0f4324c2f8
commit ac07bf784d
17 changed files with 235 additions and 24 deletions

View File

@ -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 {

View File

@ -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 (
<div>
<Header style={headerStyle} />
<div style={{padding: theme.margin}}>
<p style={theme.textStyle}>{_('To allow Joplin to synchronise with Dropbox, please follow the steps below:')}</p>
<p style={theme.textStyle}>{_('Step 1: Open this URL in your browser to authorise the application:')}</p>
<a style={theme.textStyle} href="#" onClick={this.loginUrl_click}>{this.state.loginUrl}</a>
<p style={theme.textStyle}>{_('Step 2: Enter the code provided by Dropbox:')}</p>
<p><input type="text" value={this.state.authCode} onChange={this.authCodeInput_change} style={inputStyle}/></p>
<button disabled={this.state.checkingAuthToken} onClick={this.submit_click}>{_('Submit')}</button>
</div>
</div>
);
}
}
const mapStateToProps = (state) => {
return {
theme: state.settings.theme,
};
};
const DropboxLoginScreen = connect(mapStateToProps)(DropboxLoginScreenComponent);
module.exports = { DropboxLoginScreen };

View File

@ -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') },

View File

@ -62,6 +62,7 @@ globalStyle.icon = {
globalStyle.lineInput = {
color: globalStyle.color,
backgroundColor: globalStyle.backgroundColor,
fontFamily: globalStyle.fontFamily,
};
globalStyle.textStyle = {

View File

@ -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'));
}

View File

@ -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';
}

View File

@ -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;

View File

@ -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'));
}

View File

@ -19,7 +19,7 @@ class SyncTargetFilesystem extends BaseSyncTarget {
return _('File system');
}
isAuthenticated() {
async isAuthenticated() {
return true;
}

View File

@ -19,7 +19,7 @@ class SyncTargetMemory extends BaseSyncTarget {
return 'Memory';
}
isAuthenticated() {
async isAuthenticated() {
return true;
}

View File

@ -28,7 +28,7 @@ class SyncTargetNextcloud extends BaseSyncTarget {
return _('Nextcloud');
}
isAuthenticated() {
async isAuthenticated() {
return true;
}

View File

@ -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'));
}

View File

@ -24,7 +24,7 @@ class SyncTargetWebDAV extends BaseSyncTarget {
return _('WebDAV');
}
isAuthenticated() {
async isAuthenticated() {
return true;
}

View File

@ -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',

View File

@ -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_;

View File

@ -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) {

View File

@ -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;