Merge branch 'develop'

This commit is contained in:
antelle 2020-04-18 08:30:17 +02:00
commit 45923d034e
No known key found for this signature in database
GPG Key ID: 094A2F2D6136A4EE
38 changed files with 1210 additions and 867 deletions

View File

@ -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" />

View File

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

View File

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

View File

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

View File

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

View File

@ -221,9 +221,6 @@ const Launcher = {
resolveProxy(url, callback) {
/* skip in cordova */
},
openWindow(opts) {
/* skip in cordova */
},
hideApp() {
/* skip in cordova */
},

View File

@ -205,9 +205,6 @@ const Launcher = {
callback(proxy);
});
},
openWindow(opts) {
return this.remoteApp().openWindow(opts);
},
hideApp() {
const app = this.remoteApp();
if (this.canMinimize()) {

View File

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

View File

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

View File

@ -437,6 +437,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",
@ -597,6 +598,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)",

View File

@ -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,68 +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,
canOpen: true,
canOpenDemo: true,
canOpenSettings: true,
canCreate: true,
canImportXml: true,
canImportCsv: true,
canRemoveLatest: true,
canExportXml: true,
canExportHtml: true,
canSaveTo: true,
canOpenWebdav: true,
canOpenGenerator: true,
dropbox: true,
webdav: true,
gdrive: true,
onedrive: true
},
{ extensions: true }
);
AppSettingsModel.defineModelProperties(DefaultAppSettings, { extensions: true });
const instance = new AppSettingsModel();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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, '');
listener.emit('result', { state, code });
}
};

View File

@ -15,6 +15,7 @@ const Features = {
isSelfHosted:
!isDesktop &&
!/^http(s?):\/\/((localhost:8085)|((app|beta)\.keeweb\.info))/.test(location.href),
isLocal: location.origin.indexOf('localhost') >= 0,
needFixClicks: /Edge\/14/.test(navigator.appVersion),
canUseWasmInWebWorker: !isDesktop && !/Chrome/.test(navigator.appVersion),

View File

@ -21,10 +21,10 @@ const MdToHtml = {
const htmlWithoutLineBreaks = html.replace(whiteSpaceRegex, '');
const mdWithoutLineBreaks = md.replace(whiteSpaceRegex, '');
if (htmlWithoutLineBreaks === mdWithoutLineBreaks) {
return md;
return { text: md };
} else {
const sanitized = dompurify.sanitize(html, { ADD_ATTR: ['target'] });
return `<div class="markdown">${sanitized}</div>`;
return { html: `<div class="markdown">${sanitized}</div>` };
}
}
};

View File

@ -29,6 +29,12 @@ const UrlFormat = {
.map(([key, value]) => key + '=' + encodeURIComponent(value))
.join('&');
return base + '?' + queryString;
},
buildFormData(params) {
return Object.entries(params)
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
.join('&');
}
};

View File

@ -44,7 +44,7 @@ Tip.prototype.show = function() {
const tipEl = (this.tipEl = $('<div></div>')
.addClass('tip')
.appendTo('body')
.html(this.title));
.text(this.title));
const rect = this.el[0].getBoundingClientRect();
const tipRect = this.tipEl[0].getBoundingClientRect();
const placement = this.placement || this.getAutoPlacement(rect, tipRect);

View File

@ -24,7 +24,11 @@ class FieldViewText extends FieldView {
if (value && value.isProtected) {
value = value.getText();
}
return MdToHtml.convert(value);
const converted = MdToHtml.convert(value);
if (converted.html) {
return converted.html;
}
value = converted.text;
}
return value && value.isProtected
? PasswordPresenter.presentValueWithLineBreaks(value)

View File

@ -1,22 +1,35 @@
import { FieldViewText } from 'views/fields/field-view-text';
import { escape } from 'util/fn';
const AllowedProtocols = ['http:', 'https:', 'ftp:', 'ftps:', 'mailto:'];
class FieldViewUrl extends FieldViewText {
displayUrlRegex = /^https:\/\//i;
cssClass = 'url';
renderValue(value) {
return value
? '<a href="' +
escape(this.fixUrl(value)) +
'" rel="noreferrer noopener" target="_blank">' +
escape(this.displayUrl(value)) +
'</a>'
: '';
try {
return value
? '<a href="' +
escape(this.fixUrl(value)) +
'" rel="noreferrer noopener" target="_blank">' +
escape(this.displayUrl(value)) +
'</a>'
: '';
} catch (e) {
return escape(value);
}
}
fixUrl(url) {
return url.indexOf(':') < 0 ? 'https://' + url : url;
const proto = new URL(url, 'dummy://').protocol;
if (proto === 'dummy:') {
return 'https://' + url;
}
if (!AllowedProtocols.includes(proto)) {
throw new Error('Bad url');
}
return url;
}
displayUrl(url) {

View File

@ -135,7 +135,7 @@ class ListSearchView extends View {
setLocale() {
this.sortOptions.forEach(opt => {
opt.text = opt.loc();
opt.html = opt.loc();
});
const entryDesc = Features.isMobile
? ''
@ -145,7 +145,7 @@ class ListSearchView extends View {
Shortcuts.altShortcutSymbol(true) +
'N)</span>';
this.createOptions = [
{ value: 'entry', icon: 'key', text: StringFormat.capFirst(Locale.entry) + entryDesc },
{ value: 'entry', icon: 'key', html: StringFormat.capFirst(Locale.entry) + entryDesc },
{ value: 'group', icon: 'folder', text: StringFormat.capFirst(Locale.group) }
];
if (this.el) {

View File

@ -80,15 +80,14 @@ class OpenView extends View {
clearTimeout(this.dragTimeout);
}
const storageProviders = [];
Object.keys(Storage).forEach(name => {
const prv = Storage[name];
if (!prv.system && prv.enabled) {
if (name === 'webdav' && !this.model.settings.canOpenWebdav) {
return;
if (this.model.settings.canOpenStorage) {
Object.keys(Storage).forEach(name => {
const prv = Storage[name];
if (!prv.system && prv.enabled) {
storageProviders.push(prv);
}
storageProviders.push(prv);
}
});
});
}
storageProviders.sort((x, y) => (x.uipos || Infinity) - (y.uipos || Infinity));
const showMore =
storageProviders.length ||

View File

@ -49,6 +49,7 @@ class SettingsGeneralView extends View {
'click .settings__general-download-update-btn': 'downloadUpdate',
'click .settings__general-update-found-btn': 'installFoundUpdate',
'change .settings__general-prv-check': 'changeStorageEnabled',
'click .settings__general-prv-logout': 'logoutFromStorage',
'click .settings__general-show-advanced': 'showAdvancedSettings',
'click .settings__general-dev-tools-link': 'openDevTools',
'click .settings__general-try-beta-link': 'tryBeta',
@ -195,7 +196,8 @@ class SettingsGeneralView extends View {
return storageProviders.map(sp => ({
name: sp.name,
enabled: sp.enabled,
hasConfig: !!sp.getSettingsConfig
hasConfig: !!sp.getSettingsConfig,
loggedIn: sp.loggedIn
}));
}
@ -354,6 +356,14 @@ class SettingsGeneralView extends View {
}
}
logoutFromStorage(e) {
const storage = Storage[$(e.target).data('storage')];
if (storage) {
storage.logout();
$(e.target).remove();
}
}
showAdvancedSettings() {
this.$el
.find('.settings__general-show-advanced, .settings__general-advanced')

View File

@ -171,6 +171,9 @@
&__general-prv {
margin-bottom: $base-padding-v;
}
&__general-prv-logout {
margin-bottom: $base-padding-v;
}
&__logs {
user-select: text;
margin-top: $base-padding-v;

View File

@ -2,7 +2,13 @@
{{#each options as |option|}}
<div class="dropdown__item {{#if option.active}}dropdown__item--active{{/if}}" data-value="{{option.value}}">
<i class="fa fa-{{option.icon}} dropdown__item-icon"></i>
<span class="dropdown__item-text">{{{option.text}}}</span>
<span class="dropdown__item-text">
{{~#if option.text~}}
{{option.text}}
{{~else~}}
{{{option.html}}}
{{~/if~}}
</span>
</div>
{{/each}}
</div>

View File

@ -205,6 +205,8 @@
data-storage="{{prv.name}}" {{#if prv.enabled}}checked{{/if}}
/><label for="settings__general-prv-check-{{prv.name}}">{{res prv.name}}</label></h4>
<div class="settings__general-prv-wrap settings__general-{{prv.name}} {{#ifeq prv.enabled false}}hide{{/ifeq}}"></div>
{{#if prv.loggedIn}}<button class="btn-silent settings__general-prv-logout"
data-storage="{{prv.name}}">{{res 'setGenStorageLogout'}}</button>{{/if}}
{{/each}}
<h2>{{res 'advanced'}}</h2>

View File

@ -104,6 +104,18 @@ app.on('second-instance', () => {
restoreMainWindow();
}
});
app.on('web-contents-created', (event, contents) => {
contents.on('new-window', async (e, url) => {
e.preventDefault();
emitRemoteEvent('log', { message: `Prevented new window: ${url}` });
});
contents.on('will-navigate', (e, url) => {
if (!url.startsWith('https://beta.keeweb.info/') && !url.startsWith(htmlPath)) {
e.preventDefault();
emitRemoteEvent('log', { message: `Prevented navigation: ${url}` });
}
});
});
app.restartApp = function() {
restartPending = true;
mainWindow.close();
@ -111,9 +123,6 @@ app.restartApp = function() {
restartPending = false;
}, 1000);
};
app.openWindow = function(opts) {
return new electron.BrowserWindow(opts);
};
app.minimizeApp = function(menuItemLabels) {
let imagePath;
mainWindow.hide();
@ -236,12 +245,6 @@ function createMainWindow() {
mainWindow.on('session-end', () => {
emitRemoteEvent('os-lock');
});
mainWindow.webContents.on('will-navigate', (e, url) => {
if (!url.startsWith('https://beta.keeweb.info/')) {
emitRemoteEvent('log', { message: `Prevented navigation: ${url}` });
e.preventDefault();
}
});
perfTimestamps &&
perfTimestamps.push({ name: 'configuring main window', ts: process.hrtime() });

View File

@ -1,6 +1,6 @@
{
"name": "KeeWeb",
"version": "1.13.4",
"version": "1.14.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {

View File

@ -1,6 +1,6 @@
{
"name": "KeeWeb",
"version": "1.13.4",
"version": "1.14.0",
"description": "Free cross-platform password manager compatible with KeePass",
"main": "main.js",
"homepage": "https://keeweb.info",

1021
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "keeweb",
"version": "1.13.4",
"version": "1.14.0",
"description": "Free cross-platform password manager compatible with KeePass",
"main": "Gruntfile.js",
"private": true,
@ -13,10 +13,10 @@
"@babel/core": "^7.9.0",
"@babel/plugin-external-helpers": "^7.8.3",
"@babel/plugin-proposal-class-properties": "^7.8.3",
"@babel/preset-env": "^7.9.0",
"@babel/preset-env": "^7.9.5",
"adm-zip": "^0.4.14",
"argon2-browser": "1.13.0",
"autoprefixer": "^9.7.5",
"autoprefixer": "^9.7.6",
"babel-cli": "^6.26.0",
"babel-eslint": "^10.1.0",
"babel-loader": "8.1.0",
@ -27,14 +27,14 @@
"chai": "^4.2.0",
"cross-env": "^7.0.2",
"dompurify": "^2.0.8",
"electron": "^8.2.0",
"electron-builder": "^22.4.1",
"electron": "^8.2.3",
"electron-builder": "^22.5.1",
"eslint": "^6.8.0",
"eslint-config-prettier": "^6.10.1",
"eslint-config-standard": "^14.1.1",
"eslint-plugin-import": "^2.20.2",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^3.1.2",
"eslint-plugin-prettier": "^3.1.3",
"eslint-plugin-promise": "4.2.1",
"eslint-plugin-standard": "4.0.1",
"exports-loader": "0.7.0",
@ -54,14 +54,14 @@
"grunt-inline-alt": "github:keeweb/grunt-inline-alt#ec9f6ad",
"grunt-string-replace": "1.3.1",
"grunt-webpack": "3.1.3",
"handlebars": "^4.7.3",
"handlebars": "^4.7.6",
"handlebars-loader": "1.7.1",
"html-minifier": "4.0.0",
"ignore-loader": "^0.1.2",
"jquery": "3.4.1",
"jquery": "3.5.0",
"json-loader": "^0.5.7",
"jsqrcode": "github:antelle/jsqrcode#0.1.3",
"kdbxweb": "1.5.8",
"kdbxweb": "1.6.0",
"load-grunt-tasks": "5.1.0",
"lodash": "^4.17.15",
"marked": "^0.8.2",
@ -76,8 +76,8 @@
"pkcs11-smartcard-sign": "^1.0.1",
"postcss-loader": "^3.0.0",
"prettier": "^1.19.1",
"puppeteer": "^2.1.1",
"raw-loader": "^4.0.0",
"puppeteer": "^3.0.0",
"raw-loader": "^4.0.1",
"run-remote-task": "^0.3.0",
"sass-loader": "^8.0.2",
"stats-webpack-plugin": "0.7.0",
@ -86,14 +86,14 @@
"sumchecker": "^3.0.1",
"terser-webpack-plugin": "^2.3.5",
"time-grunt": "2.0.0",
"url-loader": "^4.0.0",
"url-loader": "^4.1.0",
"webpack": "^4.42.1",
"webpack-bundle-analyzer": "^3.6.1",
"webpack-bundle-analyzer": "^3.7.0",
"webpack-dev-server": "^3.10.3"
},
"optionalDependencies": {
"grunt-appdmg": "github:keeweb/grunt-appdmg#874ad83",
"keytar": "^5.4.0"
"keytar": "^5.5.0"
},
"scripts": {
"start": "grunt",

View File

@ -1,5 +1,13 @@
Release notes
-------------
##### v1.14.0 (2020-04-18)
`+` using OAuth authorization code grant for all storage providers
`-` fixed a number of vulnerabilities in opening untrusted kdbx files
`+` applied recommendations from the electron security checklist
`*` canOpenWebdav is now canOpenStorage
`+` option to log out from storages
`*` saving only modified settings instead of everything
##### v1.13.4 (2020-04-15)
`-` fix #1457: fixed styles in theme plugins
`+` #1456: options to hide webdav and password generator

View File

@ -3,20 +3,21 @@ import { MdToHtml } from 'util/formatting/md-to-html';
describe('MdToHtml', () => {
it('should convert markdown', () => {
expect(MdToHtml.convert('## head\n_italic_')).to.eql(
'<div class="markdown"><h2>head</h2>\n<p><em>italic</em></p>\n</div>'
);
expect(MdToHtml.convert('## head\n_italic_')).to.eql({
html: '<div class="markdown"><h2>head</h2>\n<p><em>italic</em></p>\n</div>'
});
});
it('should not add markdown wrapper tags for plaintext', () => {
expect(MdToHtml.convert('plain\ntext')).to.eql('plain\ntext');
expect(MdToHtml.convert('plain\ntext')).to.eql({ text: 'plain\ntext' });
});
it('should convert links', () => {
expect(MdToHtml.convert('[link](https://x)')).to.eql(
'<div class="markdown">' +
expect(MdToHtml.convert('[link](https://x)')).to.eql({
html:
'<div class="markdown">' +
'<p><a href="https://x" rel="noreferrer noopener" target="_blank">link</a></p>\n' +
'</div>'
);
});
});
});

View File

@ -32,4 +32,13 @@ describe('UrlFormat', () => {
})
).to.eql('/path?hello=world&data=%3D%20%26');
});
it('should make form-data params', () => {
expect(
UrlFormat.buildFormData({
hello: 'world',
data: '= &'
})
).to.eql('hello=world&data=%3D%20%26');
});
});