mirror of https://github.com/keeweb/keeweb
Merge branch 'develop' into usb
# Conflicts: # app/scripts/models/app-settings-model.js # desktop/app.jspull/1513/head
commit
66b2b77791
|
@ -411,11 +411,19 @@ async function run() {
|
|||
const regex = new RegExp(
|
||||
`#####\\s+v${version.replace(/\./g, '\\.')}.*?\n([\\s\\S]*?)\n#####`
|
||||
);
|
||||
const body = releaseNotes
|
||||
|
||||
const bodyReleaseNotes = releaseNotes
|
||||
.match(regex)[1]
|
||||
.trim()
|
||||
.replace(/\s*\n/g, '\n');
|
||||
|
||||
const bodyTemplate =
|
||||
'{release_notes}\n\n' +
|
||||
'Want to keep releases happening? ' +
|
||||
'Donate to KeeWeb on [OpenCollective](https://opencollective.com/keeweb). ' +
|
||||
'Thank you!';
|
||||
const body = bodyTemplate.replace('{release_notes}', bodyReleaseNotes);
|
||||
|
||||
console.log(`Updating release with notes:\n${body}`);
|
||||
|
||||
await github.repos.updateRelease({
|
||||
|
|
|
@ -3,19 +3,6 @@
|
|||
<head lang="en">
|
||||
<meta charset="UTF-8" />
|
||||
<title>KeeWeb</title>
|
||||
<meta name="application-name" content="KeeWeb" />
|
||||
<meta name="kw-signature" content="" />
|
||||
<meta name="kw-config" content="(no-config)" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="KeeWeb" />
|
||||
<meta name="theme-color" content="#6386ec" />
|
||||
<meta name="msapplication-config" content="browserconfig.xml" />
|
||||
<meta name="msapplication-TileColor" content="#6386ec" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="
|
||||
|
@ -31,6 +18,19 @@
|
|||
form-action 'none';
|
||||
"
|
||||
/>
|
||||
<meta name="application-name" content="KeeWeb" />
|
||||
<meta name="kw-signature" content="" />
|
||||
<meta name="kw-config" content="(no-config)" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="KeeWeb" />
|
||||
<meta name="theme-color" content="#6386ec" />
|
||||
<meta name="msapplication-config" content="browserconfig.xml" />
|
||||
<meta name="msapplication-TileColor" content="#6386ec" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="icons/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="icons/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="icons/favicon-16x16.png" />
|
||||
|
|
|
@ -41,7 +41,7 @@ DropboxChooser.prototype.buildUrl = function() {
|
|||
};
|
||||
|
||||
DropboxChooser.prototype.onMessage = function(e) {
|
||||
if (e.source !== this.popup) {
|
||||
if (e.source !== this.popup || e.origin !== 'https://www.dropbox.com') {
|
||||
return;
|
||||
}
|
||||
const data = JSON.parse(e.data);
|
||||
|
|
|
@ -21,7 +21,7 @@ const AuthReceiver = {
|
|||
url.split(/[?#&]/g).forEach(part => {
|
||||
const parts = part.split('=');
|
||||
if (parts.length === 2) {
|
||||
message[parts[0]] = parts[1];
|
||||
message[parts[0]] = decodeURIComponent(parts[1]);
|
||||
}
|
||||
});
|
||||
return message;
|
||||
|
|
|
@ -1,11 +1,10 @@
|
|||
import { Events } from 'framework/events';
|
||||
import { AuthReceiver } from 'comp/browser/auth-receiver';
|
||||
import { Launcher } from 'comp/launcher';
|
||||
import { Alerts } from 'comp/ui/alerts';
|
||||
import { Links } from 'const/links';
|
||||
import { Timeouts } from 'const/timeouts';
|
||||
import { Locale } from 'util/locale';
|
||||
import { Logger } from 'util/logger';
|
||||
import { noop } from 'util/fn';
|
||||
|
||||
const PopupNotifier = {
|
||||
logger: null,
|
||||
|
@ -14,7 +13,7 @@ const PopupNotifier = {
|
|||
this.logger = new Logger('popup-notifier');
|
||||
|
||||
if (Launcher) {
|
||||
window.open = this._openLauncherWindow.bind(this);
|
||||
window.open = noop;
|
||||
} else {
|
||||
const windowOpen = window.open;
|
||||
window.open = function(...args) {
|
||||
|
@ -35,100 +34,6 @@ const PopupNotifier = {
|
|||
}
|
||||
},
|
||||
|
||||
_openLauncherWindow(url, title, settings) {
|
||||
const opts = { show: false };
|
||||
if (settings) {
|
||||
const settingsObj = {};
|
||||
settings.split(',').forEach(part => {
|
||||
const parts = part.split('=');
|
||||
settingsObj[parts[0].trim()] = parts[1].trim();
|
||||
});
|
||||
if (settingsObj.width) {
|
||||
opts.width = +settingsObj.width;
|
||||
}
|
||||
if (settingsObj.height) {
|
||||
opts.height = +settingsObj.height;
|
||||
}
|
||||
if (settingsObj.top) {
|
||||
opts.y = +settingsObj.top;
|
||||
}
|
||||
if (settingsObj.left) {
|
||||
opts.x = +settingsObj.left;
|
||||
}
|
||||
}
|
||||
let win = Launcher.openWindow(opts);
|
||||
win.webContents.on('will-redirect', (e, url) => {
|
||||
if (PopupNotifier.isOwnUrl(url)) {
|
||||
win.webContents.stop();
|
||||
win.close();
|
||||
PopupNotifier.processReturnToApp(url);
|
||||
}
|
||||
});
|
||||
win.webContents.on('will-navigate', (e, url) => {
|
||||
if (PopupNotifier.isOwnUrl(url)) {
|
||||
e.preventDefault();
|
||||
win.close();
|
||||
PopupNotifier.processReturnToApp(url);
|
||||
}
|
||||
});
|
||||
win.webContents.on('crashed', (e, killed) => {
|
||||
this.logger.debug('crashed', e, killed);
|
||||
this.deferCheckClosed(win);
|
||||
win.close();
|
||||
win = null;
|
||||
});
|
||||
win.webContents.on(
|
||||
'did-fail-load',
|
||||
(e, errorCode, errorDescription, validatedUrl, isMainFrame) => {
|
||||
this.logger.debug(
|
||||
'did-fail-load',
|
||||
e,
|
||||
errorCode,
|
||||
errorDescription,
|
||||
validatedUrl,
|
||||
isMainFrame
|
||||
);
|
||||
this.deferCheckClosed(win);
|
||||
win.close();
|
||||
win = null;
|
||||
}
|
||||
);
|
||||
win.once('page-title-updated', () => {
|
||||
setTimeout(() => {
|
||||
if (win) {
|
||||
win.show();
|
||||
win.focus();
|
||||
}
|
||||
}, Timeouts.PopupWaitTime);
|
||||
});
|
||||
win.on('closed', () => {
|
||||
setTimeout(
|
||||
PopupNotifier.triggerClosed.bind(PopupNotifier, win),
|
||||
Timeouts.CheckWindowClosed
|
||||
);
|
||||
win = null;
|
||||
});
|
||||
win.loadURL(url);
|
||||
Events.emit('popup-opened', win);
|
||||
return win;
|
||||
},
|
||||
|
||||
isOwnUrl(url) {
|
||||
return (
|
||||
url.lastIndexOf(Links.WebApp, 0) === 0 ||
|
||||
url.lastIndexOf(location.origin + location.pathname, 0) === 0
|
||||
);
|
||||
},
|
||||
|
||||
processReturnToApp(url) {
|
||||
const returnMessage = AuthReceiver.urlArgsToMessage(url);
|
||||
if (Object.keys(returnMessage).length > 0) {
|
||||
const evt = new Event('message');
|
||||
evt.data = returnMessage;
|
||||
window.dispatchEvent(evt);
|
||||
}
|
||||
},
|
||||
|
||||
deferCheckClosed(win) {
|
||||
setTimeout(PopupNotifier.checkClosed.bind(PopupNotifier, win), Timeouts.CheckWindowClosed);
|
||||
},
|
||||
|
|
|
@ -47,8 +47,8 @@ function walkEntry(db, entry, parents) {
|
|||
let html = false;
|
||||
if (field.markdown && AppSettingsModel.useMarkdown) {
|
||||
const converted = MdToHtml.convert(value);
|
||||
if (converted !== value) {
|
||||
value = converted;
|
||||
if (converted.html) {
|
||||
value = converted.html;
|
||||
html = true;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -221,9 +221,6 @@ const Launcher = {
|
|||
resolveProxy(url, callback) {
|
||||
/* skip in cordova */
|
||||
},
|
||||
openWindow(opts) {
|
||||
/* skip in cordova */
|
||||
},
|
||||
hideApp() {
|
||||
/* skip in cordova */
|
||||
},
|
||||
|
|
|
@ -205,9 +205,6 @@ const Launcher = {
|
|||
callback(proxy);
|
||||
});
|
||||
},
|
||||
openWindow(opts) {
|
||||
return this.remoteApp().openWindow(opts);
|
||||
},
|
||||
hideApp() {
|
||||
const app = this.remoteApp();
|
||||
if (this.canMinimize()) {
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
// Secrets are not really secrets and are supposed to be embedded in the app code according to
|
||||
// the Google's guide: https://developers.google.com/identity/protocols/oauth2#installed
|
||||
// The process results in a client ID and, in some cases, a client secret,
|
||||
// which you embed in the source code of your application.
|
||||
// (In this context, the client secret is obviously not treated as a secret.)
|
||||
|
||||
const DropboxApps = {
|
||||
AppFolder: { id: 'qp7ctun6qt5n9d6', secret: '07s5r4ck1uvlj6a' },
|
||||
FullDropbox: { id: 'eor7hvv6u6oslq9', secret: 'ez04o1iwf6yprq3' }
|
||||
};
|
||||
|
||||
const GDriveApps = {
|
||||
Local: {
|
||||
id: '783608538594-36tkdh8iscrq8t8dq87gghubnhivhjp5.apps.googleusercontent.com',
|
||||
secret: 'yAtyfc9TIQ9GyQgQmo3i0HAP'
|
||||
},
|
||||
Production: {
|
||||
id: '847548101761-koqkji474gp3i2gn3k5omipbfju7pbt1.apps.googleusercontent.com',
|
||||
secret: '42HeSBybXDZjvweotq4o4CkJ'
|
||||
},
|
||||
Desktop: {
|
||||
id: '847548101761-h2pcl2p6m1tssnlqm0vrm33crlveccbr.apps.googleusercontent.com',
|
||||
secret: 'nTSCiqXtUNmURIIdASaC1TJK'
|
||||
}
|
||||
};
|
||||
|
||||
const OneDriveApps = {
|
||||
Local: {
|
||||
id: 'b97c53d5-db5b-4124-aab9-d39195293815',
|
||||
secret: 'V9b6:iJU]N7cImE1f_OLNjqZJDBnumR?'
|
||||
},
|
||||
Production: {
|
||||
id: 'bbc74d1b-3a9c-46e6-9da4-4c645e830923',
|
||||
secret: 'aOMJaktJEAs_Tmh]fx4iQ[Zd3mp3KK7-'
|
||||
}
|
||||
};
|
||||
|
||||
export { DropboxApps, GDriveApps, OneDriveApps };
|
|
@ -0,0 +1,68 @@
|
|||
const DefaultAppSettings = {
|
||||
theme: 'fb', // UI theme
|
||||
locale: null, // user interface language
|
||||
expandGroups: true, // show entries from all subgroups
|
||||
listViewWidth: null, // width of the entry list representation
|
||||
menuViewWidth: null, // width of the left menu
|
||||
tagsViewHeight: null, // tags menu section height
|
||||
autoUpdate: 'install', // auto-update options: "install", "check", ""
|
||||
clipboardSeconds: 0, // number of seconds after which the clipboard will be cleared
|
||||
autoSave: true, // auto-save open files
|
||||
autoSaveInterval: 0, // interval between performing automatic sync, minutes
|
||||
rememberKeyFiles: false, // remember keyfiles selected on the Open screen
|
||||
idleMinutes: 15, // app lock timeout after inactivity, minutes
|
||||
minimizeOnClose: false, // minimise the app instead of closing
|
||||
tableView: false, // view entries as a table instead of list
|
||||
colorfulIcons: false, // use colorful custom icons instead of grayscale
|
||||
useMarkdown: true, // use Markdown in Notes field
|
||||
directAutotype: true, // if only one matching entry is found, select that one automatically
|
||||
titlebarStyle: 'default', // window titlebar style
|
||||
lockOnMinimize: true, // lock the app when it's minimized
|
||||
lockOnCopy: false, // lock the app after a password was copied
|
||||
lockOnAutoType: false, // lock the app after performing auto-type
|
||||
lockOnOsLock: false, // lock the app when the computer is locked
|
||||
helpTipCopyShown: false, // disable the tooltip about copying fields
|
||||
templateHelpShown: false, // disable the tooltip about entry templates
|
||||
skipOpenLocalWarn: false, // disable the warning about opening a local file
|
||||
hideEmptyFields: false, // hide empty fields in entries
|
||||
skipHttpsWarning: false, // disable the non-HTTPS warning
|
||||
demoOpened: false, // hide the demo button inside the More... menu
|
||||
fontSize: 0, // font size: 0, 1, 2
|
||||
tableViewColumns: null, // columns displayed in the table view
|
||||
generatorPresets: null, // presets used in the password generator
|
||||
generatorHidePassword: false, // hide password in the generator
|
||||
cacheConfigSettings: false, // cache config settings and use them if the config can't be loaded
|
||||
allowIframes: false, // allow displaying the app in IFrames
|
||||
useGroupIconForEntries: false, // automatically use group icon when creating new entries
|
||||
|
||||
canOpen: true, // can select and open new files
|
||||
canOpenDemo: true, // can open a demo file
|
||||
canOpenSettings: true, // can go to settings
|
||||
canCreate: true, // can create new files
|
||||
canImportXml: true, // can import files from XML
|
||||
canImportCsv: true, // can import files from CSV
|
||||
canRemoveLatest: true, // can remove files from the recent file list
|
||||
canExportXml: true, // can export files as XML
|
||||
canExportHtml: true, // can export files as HTML
|
||||
canSaveTo: true, // can save existing files to filesystem
|
||||
canOpenStorage: true, // can open files from cloud storage providers
|
||||
canOpenGenerator: true, // can open password generator
|
||||
|
||||
dropbox: true, // enable Dropbox integration
|
||||
dropboxFolder: null, // default folder path
|
||||
dropboxAppKey: null, // custom Dropbox app key
|
||||
dropboxSecret: null, // custom Dropbox app secret
|
||||
|
||||
webdav: true, // enable WebDAV integration
|
||||
webdavSaveMethod: 'move', // how to save files with WebDAV: "move" or "put"
|
||||
|
||||
gdrive: true, // enable Google Drive integration
|
||||
gdriveClientId: null, // custom Google Drive client id
|
||||
gdriveSecret: null, // custom Google Drive client secret
|
||||
|
||||
onedrive: true, // enable OneDrive integration
|
||||
onedriveClientId: null, // custom OneDrive client id
|
||||
onedriveSecret: null // custom OneDrive client secret
|
||||
};
|
||||
|
||||
export { DefaultAppSettings };
|
|
@ -444,6 +444,7 @@
|
|||
"setGenLockAutoType": "On auto-type",
|
||||
"setGenLockOrSleep": "When the computer is locked or put to sleep",
|
||||
"setGenStorage": "Storage",
|
||||
"setGenStorageLogout": "Log out",
|
||||
"setGenShowAdvanced": "Show advanced settings",
|
||||
"setGenDevTools": "Show dev tools",
|
||||
"setGenTryBeta": "Try beta version until restart",
|
||||
|
@ -607,6 +608,8 @@
|
|||
"dropboxSetupDesc": "Some configuration is required to use Dropbox in a self-hosted app. Please create your own Dropbox app and fill in its key below.",
|
||||
"dropboxAppKey": "Dropbox app key",
|
||||
"dropboxAppKeyDesc": "Copy the key from your Dropbox app (Developer settings)",
|
||||
"dropboxAppSecret": "Dropbox app secret",
|
||||
"dropboxAppSecretDesc": "The secret can be found next to the app key",
|
||||
"dropboxFolder": "App folder",
|
||||
"dropboxFolderDesc": "If your app is linked to entire Dropbox (not app folder), set the folder with your kdbx files here",
|
||||
"dropboxFolderSettingsDesc": "Select any folder in your Dropbox where files will be stored (root folder by default)",
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { Model } from 'framework/model';
|
||||
import { SettingsStore } from 'comp/settings/settings-store';
|
||||
import { DefaultAppSettings } from 'const/default-app-settings';
|
||||
|
||||
class AppSettingsModel extends Model {
|
||||
constructor() {
|
||||
|
@ -23,70 +24,17 @@ class AppSettingsModel extends Model {
|
|||
}
|
||||
|
||||
save() {
|
||||
SettingsStore.save('app-settings', this);
|
||||
const values = {};
|
||||
for (const [key, value] of Object.entries(this)) {
|
||||
if (DefaultAppSettings[key] !== value) {
|
||||
values[key] = value;
|
||||
}
|
||||
}
|
||||
SettingsStore.save('app-settings', values);
|
||||
}
|
||||
}
|
||||
|
||||
AppSettingsModel.defineModelProperties(
|
||||
{
|
||||
theme: 'fb',
|
||||
locale: null,
|
||||
expandGroups: true,
|
||||
listViewWidth: null,
|
||||
menuViewWidth: null,
|
||||
tagsViewHeight: null,
|
||||
autoUpdate: 'install',
|
||||
clipboardSeconds: 0,
|
||||
autoSave: true,
|
||||
autoSaveInterval: 0,
|
||||
rememberKeyFiles: false,
|
||||
idleMinutes: 15,
|
||||
minimizeOnClose: false,
|
||||
tableView: false,
|
||||
colorfulIcons: false,
|
||||
useMarkdown: true,
|
||||
directAutotype: true,
|
||||
titlebarStyle: 'default',
|
||||
lockOnMinimize: true,
|
||||
lockOnCopy: false,
|
||||
lockOnAutoType: false,
|
||||
lockOnOsLock: false,
|
||||
helpTipCopyShown: false,
|
||||
templateHelpShown: false,
|
||||
skipOpenLocalWarn: false,
|
||||
hideEmptyFields: false,
|
||||
skipHttpsWarning: false,
|
||||
demoOpened: false,
|
||||
fontSize: 0,
|
||||
tableViewColumns: null,
|
||||
generatorPresets: null,
|
||||
generatorHidePassword: false,
|
||||
cacheConfigSettings: false,
|
||||
allowIframes: false,
|
||||
useGroupIconForEntries: false,
|
||||
enableUsb: true,
|
||||
|
||||
canOpen: true,
|
||||
canOpenDemo: true,
|
||||
canOpenSettings: true,
|
||||
canCreate: true,
|
||||
canImportXml: true,
|
||||
canImportCsv: true,
|
||||
canRemoveLatest: true,
|
||||
canExportXml: true,
|
||||
canExportHtml: true,
|
||||
canSaveTo: true,
|
||||
canOpenWebdav: true,
|
||||
canOpenGenerator: true,
|
||||
canOpenOtpDevice: true,
|
||||
|
||||
dropbox: true,
|
||||
webdav: true,
|
||||
gdrive: true,
|
||||
onedrive: true
|
||||
},
|
||||
{ extensions: true }
|
||||
);
|
||||
AppSettingsModel.defineModelProperties(DefaultAppSettings, { extensions: true });
|
||||
|
||||
const instance = new AppSettingsModel();
|
||||
|
||||
|
|
|
@ -1,16 +1,14 @@
|
|||
import { StorageBase } from 'storage/storage-base';
|
||||
import { Features } from 'util/features';
|
||||
import { UrlFormat } from 'util/formatting/url-format';
|
||||
|
||||
const DropboxKeys = {
|
||||
AppFolder: 'qp7ctun6qt5n9d6',
|
||||
FullDropbox: 'eor7hvv6u6oslq9'
|
||||
};
|
||||
import { DropboxApps } from 'const/cloud-storage-apps';
|
||||
|
||||
const DropboxCustomErrors = {
|
||||
BadKey: 'bad-key'
|
||||
};
|
||||
|
||||
// https://www.dropbox.com/developers/documentation/http/documentation#oauth2-authorize
|
||||
|
||||
class StorageDropbox extends StorageBase {
|
||||
name = 'dropbox';
|
||||
icon = 'dropbox';
|
||||
|
@ -49,12 +47,23 @@ class StorageDropbox extends StorageBase {
|
|||
}
|
||||
|
||||
_getKey() {
|
||||
return this.appSettings.dropboxAppKey || DropboxKeys.AppFolder;
|
||||
return this.appSettings.dropboxAppKey || DropboxApps.AppFolder.id;
|
||||
}
|
||||
|
||||
_getSecret() {
|
||||
const key = this._getKey();
|
||||
if (key === DropboxApps.AppFolder.id) {
|
||||
return DropboxApps.AppFolder.secret;
|
||||
}
|
||||
if (key === DropboxApps.FullDropbox.id) {
|
||||
return DropboxApps.FullDropbox.secret;
|
||||
}
|
||||
return this.appSettings.dropboxSecret;
|
||||
}
|
||||
|
||||
_isValidKey() {
|
||||
const key = this._getKey();
|
||||
const isBuiltIn = key === DropboxKeys.AppFolder || key === DropboxKeys.FullDropbox;
|
||||
const isBuiltIn = key === DropboxApps.AppFolder.id || key === DropboxApps.FullDropbox.id;
|
||||
return key && key.indexOf(' ') < 0 && (!isBuiltIn || this._canUseBuiltInKeys());
|
||||
}
|
||||
|
||||
|
@ -66,14 +75,17 @@ class StorageDropbox extends StorageBase {
|
|||
return {
|
||||
scope: '',
|
||||
url: 'https://www.dropbox.com/oauth2/authorize',
|
||||
tokenUrl: 'https://api.dropboxapi.com/oauth2/token',
|
||||
clientId: this._getKey(),
|
||||
clientSecret: this._getSecret(),
|
||||
pkce: false,
|
||||
width: 600,
|
||||
height: 400
|
||||
};
|
||||
}
|
||||
|
||||
needShowOpenConfig() {
|
||||
return !this._isValidKey();
|
||||
return !this._isValidKey() || !this._getSecret();
|
||||
}
|
||||
|
||||
getOpenConfig() {
|
||||
|
@ -88,6 +100,14 @@ class StorageDropbox extends StorageBase {
|
|||
required: true,
|
||||
pattern: '\\w+'
|
||||
},
|
||||
{
|
||||
id: 'secret',
|
||||
title: 'dropboxAppSecret',
|
||||
desc: 'dropboxAppSecretDesc',
|
||||
type: 'text',
|
||||
required: true,
|
||||
pattern: '\\w+'
|
||||
},
|
||||
{
|
||||
id: 'folder',
|
||||
title: 'dropboxFolder',
|
||||
|
@ -118,6 +138,15 @@ class StorageDropbox extends StorageBase {
|
|||
pattern: '\\w+',
|
||||
value: appKey
|
||||
};
|
||||
const secretField = {
|
||||
id: 'secret',
|
||||
title: 'dropboxAppSecret',
|
||||
desc: 'dropboxAppSecretDesc',
|
||||
type: 'text',
|
||||
required: true,
|
||||
pattern: '\\w+',
|
||||
value: this.appSettings.dropboxSecret || ''
|
||||
};
|
||||
const folderField = {
|
||||
id: 'folder',
|
||||
title: 'dropboxFolder',
|
||||
|
@ -128,24 +157,26 @@ class StorageDropbox extends StorageBase {
|
|||
const canUseBuiltInKeys = this._canUseBuiltInKeys();
|
||||
if (canUseBuiltInKeys) {
|
||||
fields.push(linkField);
|
||||
if (appKey === DropboxKeys.AppFolder) {
|
||||
if (appKey === DropboxApps.AppFolder.id) {
|
||||
linkField.value = 'app';
|
||||
} else if (appKey === DropboxKeys.FullDropbox) {
|
||||
} else if (appKey === DropboxApps.FullDropbox.id) {
|
||||
linkField.value = 'full';
|
||||
fields.push(folderField);
|
||||
} else {
|
||||
fields.push(keyField);
|
||||
fields.push(secretField);
|
||||
fields.push(folderField);
|
||||
}
|
||||
} else {
|
||||
fields.push(keyField);
|
||||
fields.push(secretField);
|
||||
fields.push(folderField);
|
||||
}
|
||||
return { fields };
|
||||
}
|
||||
|
||||
applyConfig(config, callback) {
|
||||
if (config.key === DropboxKeys.AppFolder || config.key === DropboxKeys.FullDropbox) {
|
||||
if (config.key === DropboxApps.AppFolder.id || config.key === DropboxApps.FullDropbox.id) {
|
||||
return callback(DropboxCustomErrors.BadKey);
|
||||
}
|
||||
// TODO: try to connect using new key
|
||||
|
@ -154,6 +185,7 @@ class StorageDropbox extends StorageBase {
|
|||
}
|
||||
this.appSettings.set({
|
||||
dropboxAppKey: config.key,
|
||||
dropboxSecret: config.secret,
|
||||
dropboxFolder: config.folder
|
||||
});
|
||||
callback();
|
||||
|
@ -165,10 +197,10 @@ class StorageDropbox extends StorageBase {
|
|||
key = 'dropboxAppKey';
|
||||
switch (value) {
|
||||
case 'app':
|
||||
value = DropboxKeys.AppFolder;
|
||||
value = DropboxApps.AppFolder.id;
|
||||
break;
|
||||
case 'full':
|
||||
value = DropboxKeys.FullDropbox;
|
||||
value = DropboxApps.FullDropbox.id;
|
||||
break;
|
||||
case 'custom':
|
||||
value = '(your app key)';
|
||||
|
@ -176,11 +208,15 @@ class StorageDropbox extends StorageBase {
|
|||
default:
|
||||
return;
|
||||
}
|
||||
this._oauthRevokeToken();
|
||||
this.logout();
|
||||
break;
|
||||
case 'key':
|
||||
key = 'dropboxAppKey';
|
||||
this._oauthRevokeToken();
|
||||
this.logout();
|
||||
break;
|
||||
case 'secret':
|
||||
key = 'dropboxSecret';
|
||||
this.logout();
|
||||
break;
|
||||
case 'folder':
|
||||
key = 'dropboxFolder';
|
||||
|
@ -365,11 +401,10 @@ class StorageDropbox extends StorageBase {
|
|||
});
|
||||
}
|
||||
|
||||
setEnabled(enabled) {
|
||||
if (!enabled) {
|
||||
this._oauthRevokeToken();
|
||||
}
|
||||
super.setEnabled(enabled);
|
||||
logout() {
|
||||
this._oauthRevokeToken('https://api.dropboxapi.com/2/auth/token/revoke', {
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,22 +1,12 @@
|
|||
import { StorageBase } from 'storage/storage-base';
|
||||
import { Locale } from 'util/locale';
|
||||
import { Features } from 'util/features';
|
||||
import { GDriveApps } from 'const/cloud-storage-apps';
|
||||
|
||||
const GDriveClientId = {
|
||||
Local: '783608538594-36tkdh8iscrq8t8dq87gghubnhivhjp5.apps.googleusercontent.com',
|
||||
Production: '847548101761-koqkji474gp3i2gn3k5omipbfju7pbt1.apps.googleusercontent.com',
|
||||
Desktop: '847548101761-h2pcl2p6m1tssnlqm0vrm33crlveccbr.apps.googleusercontent.com'
|
||||
};
|
||||
const GDriveClientSecret = {
|
||||
// They are not really secrets and are supposed to be embedded in the app code according to
|
||||
// the official guide: https://developers.google.com/identity/protocols/oauth2#installed
|
||||
// The process results in a client ID and, in some cases, a client secret,
|
||||
// which you embed in the source code of your application.
|
||||
// (In this context, the client secret is obviously not treated as a secret.)
|
||||
Desktop: 'nTSCiqXtUNmURIIdASaC1TJK'
|
||||
};
|
||||
const NewFileIdPrefix = 'NewFile:';
|
||||
|
||||
// https://developers.google.com/identity/protocols/oauth2/web-server
|
||||
|
||||
class StorageGDrive extends StorageBase {
|
||||
name = 'gdrive';
|
||||
enabled = true;
|
||||
|
@ -239,25 +229,20 @@ class StorageGDrive extends StorageBase {
|
|||
});
|
||||
}
|
||||
|
||||
setEnabled(enabled) {
|
||||
if (!enabled) {
|
||||
this._oauthRevokeToken('https://accounts.google.com/o/oauth2/revoke?token={token}');
|
||||
}
|
||||
super.setEnabled(enabled);
|
||||
logout() {
|
||||
this._oauthRevokeToken('https://accounts.google.com/o/oauth2/revoke?token={token}');
|
||||
}
|
||||
|
||||
_getOAuthConfig() {
|
||||
let clientId = this.appSettings.gdriveClientId;
|
||||
let clientSecret;
|
||||
if (!clientId) {
|
||||
let clientSecret = this.appSettings.gdriveClientSecret;
|
||||
if (!clientId || !clientSecret) {
|
||||
if (Features.isDesktop) {
|
||||
clientId = GDriveClientId.Desktop;
|
||||
clientSecret = GDriveClientSecret.Desktop;
|
||||
({ id: clientId, secret: clientSecret } = GDriveApps.Desktop);
|
||||
} else if (Features.isLocal) {
|
||||
({ id: clientId, secret: clientSecret } = GDriveApps.Local);
|
||||
} else {
|
||||
clientId =
|
||||
location.origin.indexOf('localhost') >= 0
|
||||
? GDriveClientId.Local
|
||||
: GDriveClientId.Production;
|
||||
({ id: clientId, secret: clientSecret } = GDriveApps.Production);
|
||||
}
|
||||
}
|
||||
return {
|
||||
|
@ -267,13 +252,13 @@ class StorageGDrive extends StorageBase {
|
|||
clientId,
|
||||
clientSecret,
|
||||
width: 600,
|
||||
height: 400
|
||||
height: 400,
|
||||
pkce: true,
|
||||
redirectUrlParams: {
|
||||
'access_type': 'offline'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
_useLocalOAuthRedirectListener() {
|
||||
return Features.isDesktop;
|
||||
}
|
||||
}
|
||||
|
||||
export { StorageGDrive };
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
import { StorageBase } from 'storage/storage-base';
|
||||
import { noop } from 'util/fn';
|
||||
import { OneDriveApps } from 'const/cloud-storage-apps';
|
||||
import { Features } from 'util/features';
|
||||
|
||||
const OneDriveClientId = {
|
||||
Production: '000000004818ED3A',
|
||||
Local: '0000000044183D18'
|
||||
};
|
||||
// https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow
|
||||
|
||||
class StorageOneDrive extends StorageBase {
|
||||
name = 'onedrive';
|
||||
|
@ -216,56 +214,31 @@ class StorageOneDrive extends StorageBase {
|
|||
});
|
||||
}
|
||||
|
||||
setEnabled(enabled) {
|
||||
if (!enabled) {
|
||||
const url = 'https://login.microsoftonline.com/common/oauth2/v2.0/logout?post_logout_redirect_uri={url}'.replace(
|
||||
'{url}',
|
||||
this._getOauthRedirectUrl()
|
||||
);
|
||||
this._oauthRevokeToken(url);
|
||||
}
|
||||
super.setEnabled(enabled);
|
||||
}
|
||||
|
||||
_getClientId() {
|
||||
let clientId = this.appSettings.onedriveClientId;
|
||||
if (!clientId) {
|
||||
clientId =
|
||||
location.origin.indexOf('localhost') >= 0
|
||||
? OneDriveClientId.Local
|
||||
: OneDriveClientId.Production;
|
||||
}
|
||||
return clientId;
|
||||
logout(enabled) {
|
||||
this._oauthRevokeToken();
|
||||
}
|
||||
|
||||
_getOAuthConfig() {
|
||||
const clientId = this._getClientId();
|
||||
let clientId = this.appSettings.onedriveClientId;
|
||||
let clientSecret = this.appSettings.onedriveClientSecret;
|
||||
if (!clientId || !clientSecret) {
|
||||
if (Features.isLocal) {
|
||||
({ id: clientId, secret: clientSecret } = OneDriveApps.Local);
|
||||
} else {
|
||||
({ id: clientId, secret: clientSecret } = OneDriveApps.Production);
|
||||
}
|
||||
}
|
||||
return {
|
||||
url: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
|
||||
scope: 'files.readwrite',
|
||||
tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
|
||||
scope: 'files.readwrite offline_access',
|
||||
clientId,
|
||||
clientSecret,
|
||||
pkce: true,
|
||||
width: 600,
|
||||
height: 500
|
||||
};
|
||||
}
|
||||
|
||||
_popupOpened(popupWindow) {
|
||||
if (popupWindow.webContents) {
|
||||
popupWindow.webContents.on('did-finish-load', e => {
|
||||
const webContents = e.sender.webContents;
|
||||
const url = webContents.getURL();
|
||||
if (
|
||||
url &&
|
||||
url.startsWith('https://login.microsoftonline.com/common/oauth2/v2.0/authorize')
|
||||
) {
|
||||
// click the login button mentioned in #821
|
||||
const script = `const selector = '[role="button"][aria-describedby="tileError loginHeader"]';
|
||||
if (document.querySelectorAll(selector).length === 1) document.querySelector(selector).click()`;
|
||||
webContents.executeJavaScript(script).catch(noop);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { StorageOneDrive };
|
||||
|
|
|
@ -6,6 +6,7 @@ import { StorageFileCache } from 'storage/impl/storage-file-cache';
|
|||
import { StorageGDrive } from 'storage/impl/storage-gdrive';
|
||||
import { StorageOneDrive } from 'storage/impl/storage-onedrive';
|
||||
import { StorageWebDav } from 'storage/impl/storage-webdav';
|
||||
import { createOAuthSession } from 'storage/pkce';
|
||||
|
||||
const BuiltInStorage = {
|
||||
file: new StorageFile(),
|
||||
|
@ -24,4 +25,6 @@ if (!Launcher || Launcher.thirdPartyStoragesSupported) {
|
|||
Object.assign(Storage, ThirdPartyStorage);
|
||||
}
|
||||
|
||||
requestAnimationFrame(createOAuthSession);
|
||||
|
||||
export { Storage };
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
import kdbxweb from 'kdbxweb';
|
||||
|
||||
let newOAuthSession;
|
||||
|
||||
function createOAuthSession() {
|
||||
const session = newOAuthSession;
|
||||
|
||||
const state = kdbxweb.ByteUtils.bytesToHex(kdbxweb.Random.getBytes(64));
|
||||
const codeVerifier = kdbxweb.ByteUtils.bytesToHex(kdbxweb.Random.getBytes(50));
|
||||
|
||||
const codeVerifierBytes = kdbxweb.ByteUtils.arrayToBuffer(
|
||||
kdbxweb.ByteUtils.stringToBytes(codeVerifier)
|
||||
);
|
||||
kdbxweb.CryptoEngine.sha256(codeVerifierBytes).then(hash => {
|
||||
const codeChallenge = kdbxweb.ByteUtils.bytesToBase64(hash)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
|
||||
newOAuthSession = {
|
||||
state,
|
||||
codeChallenge,
|
||||
codeVerifier
|
||||
};
|
||||
});
|
||||
|
||||
newOAuthSession = null;
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export { createOAuthSession };
|
|
@ -8,6 +8,8 @@ import { UrlFormat } from 'util/formatting/url-format';
|
|||
import { Launcher } from 'comp/launcher';
|
||||
import { omitEmpty } from 'util/fn';
|
||||
import { Timeouts } from 'const/timeouts';
|
||||
import { Features } from 'util/features';
|
||||
import { createOAuthSession } from 'storage/pkce';
|
||||
|
||||
const MaxRequestRetries = 3;
|
||||
|
||||
|
@ -38,9 +40,18 @@ class StorageBase {
|
|||
}
|
||||
|
||||
setEnabled(enabled) {
|
||||
if (!enabled) {
|
||||
this.logout();
|
||||
}
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
get loggedIn() {
|
||||
return !!this.runtimeData[this.name + 'OAuthToken'];
|
||||
}
|
||||
|
||||
logout() {}
|
||||
|
||||
_xhr(config) {
|
||||
this.logger.info('HTTP request', config.method || 'GET', config.url);
|
||||
if (config.data) {
|
||||
|
@ -88,8 +99,8 @@ class StorageBase {
|
|||
}
|
||||
|
||||
_httpRequest(config, onLoad) {
|
||||
const httpRequest = Launcher ? this._httpRequestLauncher : this._httpRequestWeb;
|
||||
httpRequest(config, onLoad);
|
||||
const httpRequest = Features.isDesktop ? this._httpRequestLauncher : this._httpRequestWeb;
|
||||
httpRequest.call(this, config, onLoad);
|
||||
}
|
||||
|
||||
_httpRequestWeb(config, onLoad) {
|
||||
|
@ -127,49 +138,81 @@ class StorageBase {
|
|||
}
|
||||
|
||||
_httpRequestLauncher(config, onLoad) {
|
||||
const https = Launcher.req('https');
|
||||
const req = https.request(config.url, {
|
||||
method: config.method || 'GET',
|
||||
headers: config.headers,
|
||||
timeout: Timeouts.DefaultHttpRequest
|
||||
});
|
||||
req.on('response', res => {
|
||||
const chunks = [];
|
||||
res.on('data', chunk => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
let response = Buffer.concat(chunks);
|
||||
if (config.responseType === 'json') {
|
||||
response = JSON.parse(response.toString('utf8'));
|
||||
Launcher.resolveProxy(config.url, proxy => {
|
||||
const https = Launcher.req('https');
|
||||
|
||||
const opts = Launcher.req('url').parse(config.url);
|
||||
|
||||
opts.method = config.method || 'GET';
|
||||
opts.headers = {
|
||||
'User-Agent': navigator.userAgent,
|
||||
...config.headers
|
||||
};
|
||||
opts.timeout = Timeouts.DefaultHttpRequest;
|
||||
|
||||
let data;
|
||||
if (config.data) {
|
||||
if (config.dataIsMultipart) {
|
||||
data = Buffer.concat(config.data.map(chunk => Buffer.from(chunk)));
|
||||
} else {
|
||||
response = response.buffer.slice(
|
||||
response.byteOffset,
|
||||
response.byteOffset + response.length
|
||||
);
|
||||
data = Buffer.from(config.data);
|
||||
}
|
||||
onLoad({
|
||||
status: res.statusCode,
|
||||
response,
|
||||
getResponseHeader: name => res.headers[name.toLowerCase()]
|
||||
opts.headers['Content-Length'] = data.byteLength;
|
||||
}
|
||||
|
||||
if (proxy) {
|
||||
opts.headers.Host = opts.host;
|
||||
opts.host = proxy.host;
|
||||
opts.port = proxy.port;
|
||||
opts.path = config.url;
|
||||
}
|
||||
|
||||
const req = https.request(opts);
|
||||
|
||||
req.on('response', res => {
|
||||
const chunks = [];
|
||||
res.on('data', chunk => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
this.logger.debug(
|
||||
'HTTP response',
|
||||
opts.method,
|
||||
config.url,
|
||||
res.statusCode,
|
||||
res.headers
|
||||
);
|
||||
|
||||
let response = Buffer.concat(chunks);
|
||||
if (config.responseType === 'json') {
|
||||
try {
|
||||
response = JSON.parse(response.toString('utf8'));
|
||||
} catch (e) {
|
||||
return config.error && config.error('json parse error');
|
||||
}
|
||||
} else {
|
||||
response = response.buffer.slice(
|
||||
response.byteOffset,
|
||||
response.byteOffset + response.length
|
||||
);
|
||||
}
|
||||
onLoad({
|
||||
status: res.statusCode,
|
||||
response,
|
||||
getResponseHeader: name => res.headers[name.toLowerCase()]
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
req.on('error', () => {
|
||||
return config.error && config.error('network error', {});
|
||||
});
|
||||
req.on('timeout', () => {
|
||||
req.abort();
|
||||
return config.error && config.error('timeout', {});
|
||||
});
|
||||
if (config.data) {
|
||||
let data;
|
||||
if (config.dataIsMultipart) {
|
||||
data = Buffer.concat(config.data.map(chunk => Buffer.from(chunk)));
|
||||
} else {
|
||||
data = Buffer.from(config.data);
|
||||
req.on('error', () => {
|
||||
return config.error && config.error('network error', {});
|
||||
});
|
||||
req.on('timeout', () => {
|
||||
req.abort();
|
||||
return config.error && config.error('timeout', {});
|
||||
});
|
||||
if (data) {
|
||||
req.write(data);
|
||||
}
|
||||
req.write(data);
|
||||
}
|
||||
req.end();
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
_openPopup(url, title, width, height, extras) {
|
||||
|
@ -231,58 +274,67 @@ class StorageBase {
|
|||
return this._oauthExchangeRefreshToken(callback);
|
||||
}
|
||||
|
||||
if (this._useLocalOAuthRedirectListener()) {
|
||||
return StorageOAuthListener.listen()
|
||||
.then(listener => {
|
||||
const url = UrlFormat.makeUrl(opts.url, {
|
||||
'client_id': opts.clientId,
|
||||
'scope': opts.scope,
|
||||
'state': listener.state,
|
||||
'redirect_uri': listener.redirectUri,
|
||||
'response_type': 'code',
|
||||
'code_challenge': listener.codeChallenge,
|
||||
'code_challenge_method': 'S256'
|
||||
});
|
||||
Launcher.openLink(url);
|
||||
callback('browser-auth-started');
|
||||
listener.callback = code => this._oauthCodeReceived(code, listener);
|
||||
})
|
||||
.catch(err => callback(err));
|
||||
const session = createOAuthSession();
|
||||
|
||||
let listener;
|
||||
if (Features.isDesktop) {
|
||||
listener = StorageOAuthListener.listen();
|
||||
session.redirectUri = listener.redirectUri;
|
||||
} else {
|
||||
session.redirectUri = this._getOauthRedirectUrl();
|
||||
}
|
||||
|
||||
const pkceParams = opts.pkce
|
||||
? {
|
||||
'code_challenge': session.codeChallenge,
|
||||
'code_challenge_method': 'S256'
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const url = UrlFormat.makeUrl(opts.url, {
|
||||
'client_id': opts.clientId,
|
||||
'scope': opts.scope,
|
||||
'response_type': 'token',
|
||||
'redirect_uri': this._getOauthRedirectUrl()
|
||||
'state': session.state,
|
||||
'redirect_uri': session.redirectUri,
|
||||
'response_type': 'code',
|
||||
...pkceParams
|
||||
});
|
||||
|
||||
this.logger.debug('OAuth: popup opened');
|
||||
if (listener) {
|
||||
listener.on('ready', () => {
|
||||
Launcher.openLink(url);
|
||||
callback('browser-auth-started');
|
||||
});
|
||||
listener.on('error', err => callback(err));
|
||||
listener.on('result', result => this._oauthCodeReceived(result, session));
|
||||
return;
|
||||
}
|
||||
|
||||
const popupWindow = this._openPopup(url, 'OAuth', opts.width, opts.height);
|
||||
if (!popupWindow) {
|
||||
return callback('OAuth: cannot open popup');
|
||||
}
|
||||
this._popupOpened(popupWindow);
|
||||
|
||||
this.logger.debug('OAuth: popup opened');
|
||||
|
||||
const popupClosed = () => {
|
||||
Events.off('popup-closed', popupClosed);
|
||||
window.removeEventListener('message', windowMessage);
|
||||
this.logger.error('OAuth error', 'popup closed');
|
||||
callback('OAuth: popup closed');
|
||||
};
|
||||
|
||||
const windowMessage = e => {
|
||||
if (!e.data) {
|
||||
if (e.origin !== location.origin) {
|
||||
return;
|
||||
}
|
||||
const token = this._oauthProcessReturn(e.data);
|
||||
if (token) {
|
||||
if (e.data && e.data.error) {
|
||||
this.logger.error('OAuth error', e.data.error, e.data.error_description);
|
||||
callback('OAuth: ' + e.data.error);
|
||||
} else if (e.data && e.data.code) {
|
||||
Events.off('popup-closed', popupClosed);
|
||||
window.removeEventListener('message', windowMessage);
|
||||
if (token.error) {
|
||||
this.logger.error('OAuth error', token.error, token.errorDescription);
|
||||
callback('OAuth: ' + token.error);
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
this._oauthCodeReceived(e.data, session, callback);
|
||||
} else {
|
||||
this.logger.debug('Skipped OAuth message', e.data);
|
||||
}
|
||||
|
@ -291,8 +343,6 @@ class StorageBase {
|
|||
window.addEventListener('message', windowMessage);
|
||||
}
|
||||
|
||||
_popupOpened(popupWindow) {}
|
||||
|
||||
_oauthProcessReturn(message) {
|
||||
const token = this._oauthMsgToToken(message);
|
||||
if (token && !token.error) {
|
||||
|
@ -333,13 +383,14 @@ class StorageBase {
|
|||
}
|
||||
}
|
||||
|
||||
_oauthRevokeToken(url) {
|
||||
_oauthRevokeToken(url, requestOptions) {
|
||||
const token = this.runtimeData[this.name + 'OAuthToken'];
|
||||
if (token) {
|
||||
if (url) {
|
||||
this._xhr({
|
||||
url: url.replace('{token}', token.accessToken),
|
||||
statuses: [200, 401]
|
||||
statuses: [200, 401],
|
||||
...requestOptions
|
||||
});
|
||||
}
|
||||
delete this.runtimeData[this.name + 'OAuthToken'];
|
||||
|
@ -357,34 +408,54 @@ class StorageBase {
|
|||
return true;
|
||||
}
|
||||
|
||||
_useLocalOAuthRedirectListener() {
|
||||
return false;
|
||||
}
|
||||
_oauthCodeReceived(result, session, callback) {
|
||||
if (!result.state) {
|
||||
this.logger.info('OAuth result has no state');
|
||||
return callback && callback('OAuth result has no state');
|
||||
}
|
||||
if (result.state !== session.state) {
|
||||
this.logger.info('OAuth result has bad state');
|
||||
return callback && callback('OAuth result has bad state');
|
||||
}
|
||||
|
||||
if (!result.code) {
|
||||
this.logger.info('OAuth result has no code');
|
||||
return callback && callback('OAuth result has no code');
|
||||
}
|
||||
|
||||
_oauthCodeReceived(code, listener) {
|
||||
this.logger.debug('OAuth code received');
|
||||
Launcher.showMainWindow();
|
||||
|
||||
if (Features.isDesktop) {
|
||||
Launcher.showMainWindow();
|
||||
}
|
||||
const config = this._getOAuthConfig();
|
||||
const pkceParams = config.pkce ? { 'code_verifier': session.codeVerifier } : undefined;
|
||||
|
||||
this._xhr({
|
||||
url: config.tokenUrl,
|
||||
method: 'POST',
|
||||
responseType: 'json',
|
||||
skipAuth: true,
|
||||
data: JSON.stringify({
|
||||
data: UrlFormat.buildFormData({
|
||||
'client_id': config.clientId,
|
||||
'client_secret': config.clientSecret,
|
||||
'grant_type': 'authorization_code',
|
||||
code,
|
||||
'code_verifier': listener.codeVerifier,
|
||||
'redirect_uri': listener.redirectUri
|
||||
'code': result.code,
|
||||
'redirect_uri': session.redirectUri,
|
||||
...pkceParams
|
||||
}),
|
||||
dataType: 'application/json',
|
||||
dataType: 'application/x-www-form-urlencoded',
|
||||
success: response => {
|
||||
this.logger.debug('OAuth code exchanged');
|
||||
this._oauthProcessReturn(response);
|
||||
this.logger.debug('OAuth code exchanged', response);
|
||||
const token = this._oauthProcessReturn(response);
|
||||
if (token && token.error) {
|
||||
return callback && callback('OAuth code exchange error: ' + token.error);
|
||||
}
|
||||
callback && callback();
|
||||
},
|
||||
error: err => {
|
||||
this.logger.error('Error exchanging OAuth code', err);
|
||||
callback && callback('OAuth code exchange error: ' + err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -398,13 +469,13 @@ class StorageBase {
|
|||
method: 'POST',
|
||||
responseType: 'json',
|
||||
skipAuth: true,
|
||||
data: JSON.stringify({
|
||||
data: UrlFormat.buildFormData({
|
||||
'client_id': config.clientId,
|
||||
'client_secret': config.clientSecret,
|
||||
'grant_type': 'refresh_token',
|
||||
'refresh_token': refreshToken
|
||||
}),
|
||||
dataType: 'application/json',
|
||||
dataType: 'application/x-www-form-urlencoded',
|
||||
success: response => {
|
||||
this.logger.debug('Refresh token exchanged');
|
||||
this._oauthProcessReturn({
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import EventEmitter from 'events';
|
||||
import { Logger } from 'util/logger';
|
||||
import { Launcher } from 'comp/launcher';
|
||||
import { noop } from 'util/fn';
|
||||
import { Locale } from 'util/locale';
|
||||
|
||||
const DefaultPort = 48149;
|
||||
|
@ -10,40 +10,41 @@ const StorageOAuthListener = {
|
|||
server: null,
|
||||
|
||||
listen() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.server) {
|
||||
this.stop();
|
||||
}
|
||||
if (this.server) {
|
||||
this.stop();
|
||||
}
|
||||
|
||||
const listener = {
|
||||
callback: noop,
|
||||
state: Math.round(Math.random() * Date.now()).toString()
|
||||
};
|
||||
|
||||
const http = Launcher.req('http');
|
||||
const server = http.createServer((req, resp) => {
|
||||
resp.writeHead(200, 'OK', {
|
||||
'Content-Type': 'text/plain; charset=UTF-8'
|
||||
});
|
||||
resp.end(Locale.appBrowserAuthComplete);
|
||||
this.handleResult(req.url, listener);
|
||||
});
|
||||
|
||||
const port = DefaultPort;
|
||||
logger.info(`Starting OAuth listener on port ${port}...`);
|
||||
server.listen(port);
|
||||
server.on('error', err => {
|
||||
logger.error('Failed to start OAuth listener', err);
|
||||
reject('Failed to start OAuth listener: ' + err);
|
||||
server.close();
|
||||
});
|
||||
server.on('listening', () => {
|
||||
this.server = server;
|
||||
listener.redirectUri = `http://127.0.0.1:${port}/oauth-result`;
|
||||
this._setCodeVerifier(listener);
|
||||
resolve(listener);
|
||||
});
|
||||
const listener = {};
|
||||
Object.keys(EventEmitter.prototype).forEach(key => {
|
||||
listener[key] = EventEmitter.prototype[key];
|
||||
});
|
||||
|
||||
const http = Launcher.req('http');
|
||||
const server = http.createServer((req, resp) => {
|
||||
resp.writeHead(200, 'OK', {
|
||||
'Content-Type': 'text/plain; charset=UTF-8'
|
||||
});
|
||||
resp.end(Locale.appBrowserAuthComplete);
|
||||
this.handleResult(req.url, listener);
|
||||
});
|
||||
|
||||
const port = DefaultPort;
|
||||
|
||||
logger.info(`Starting OAuth listener on port ${port}...`);
|
||||
server.listen(port);
|
||||
|
||||
server.on('error', err => {
|
||||
logger.error('Failed to start OAuth listener', err);
|
||||
listener.emit('error', 'Failed to start OAuth listener: ' + err);
|
||||
server.close();
|
||||
});
|
||||
server.on('listening', () => {
|
||||
this.server = server;
|
||||
listener.emit('ready');
|
||||
});
|
||||
|
||||
listener.redirectUri = `http://localhost:${port}/oauth-result`;
|
||||
return listener;
|
||||
},
|
||||
|
||||
stop() {
|
||||
|
@ -58,35 +59,8 @@ const StorageOAuthListener = {
|
|||
this.stop();
|
||||
url = new URL(url, 'http://localhost');
|
||||
const state = url.searchParams.get('state');
|
||||
if (!state) {
|
||||
logger.info('OAuth result has no state');
|
||||
return;
|
||||
}
|
||||
if (state !== listener.state) {
|
||||
logger.info('OAuth result has bad state');
|
||||
return;
|
||||
}
|
||||
const code = url.searchParams.get('code');
|
||||
if (!code) {
|
||||
logger.info('OAuth result has no code');
|
||||
return;
|
||||
}
|
||||
listener.callback(code);
|
||||
},
|
||||
|
||||
_setCodeVerifier(listener) {
|
||||
const crypto = Launcher.req('crypto');
|
||||
|
||||
listener.codeVerifier = crypto.randomBytes(50).toString('hex');
|
||||
|
||||
const hash = crypto.createHash('sha256');
|
||||
hash.update(listener.codeVerifier);
|
||||
|
||||
listener.codeChallenge = hash
|
||||
.digest('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||