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

Merge branch 'dev' into release-2.8

This commit is contained in:
Laurent Cozic 2022-04-14 10:02:03 +01:00
commit 95d5866955
37 changed files with 514 additions and 297 deletions

View File

@ -232,6 +232,9 @@ packages/app-desktop/gui/Dialog.js.map
packages/app-desktop/gui/DialogButtonRow.d.ts
packages/app-desktop/gui/DialogButtonRow.js
packages/app-desktop/gui/DialogButtonRow.js.map
packages/app-desktop/gui/DialogButtonRow/useKeyboardHandler.d.ts
packages/app-desktop/gui/DialogButtonRow/useKeyboardHandler.js
packages/app-desktop/gui/DialogButtonRow/useKeyboardHandler.js.map
packages/app-desktop/gui/DialogTitle.d.ts
packages/app-desktop/gui/DialogTitle.js
packages/app-desktop/gui/DialogTitle.js.map
@ -1060,6 +1063,9 @@ packages/lib/database.js.map
packages/lib/debug/DebugService.d.ts
packages/lib/debug/DebugService.js
packages/lib/debug/DebugService.js.map
packages/lib/dom.d.ts
packages/lib/dom.js
packages/lib/dom.js.map
packages/lib/dummy.test.d.ts
packages/lib/dummy.test.js
packages/lib/dummy.test.js.map

6
.gitignore vendored
View File

@ -222,6 +222,9 @@ packages/app-desktop/gui/Dialog.js.map
packages/app-desktop/gui/DialogButtonRow.d.ts
packages/app-desktop/gui/DialogButtonRow.js
packages/app-desktop/gui/DialogButtonRow.js.map
packages/app-desktop/gui/DialogButtonRow/useKeyboardHandler.d.ts
packages/app-desktop/gui/DialogButtonRow/useKeyboardHandler.js
packages/app-desktop/gui/DialogButtonRow/useKeyboardHandler.js.map
packages/app-desktop/gui/DialogTitle.d.ts
packages/app-desktop/gui/DialogTitle.js
packages/app-desktop/gui/DialogTitle.js.map
@ -1050,6 +1053,9 @@ packages/lib/database.js.map
packages/lib/debug/DebugService.d.ts
packages/lib/debug/DebugService.js
packages/lib/debug/DebugService.js.map
packages/lib/dom.d.ts
packages/lib/dom.js
packages/lib/dom.js.map
packages/lib/dummy.test.d.ts
packages/lib/dummy.test.js
packages/lib/dummy.test.js.map

View File

@ -45,7 +45,7 @@ while [ "$NUM" -lt 400 ]; do
echo "config keychain.supported 0" >> "$CMD_FILE"
echo "config sync.target 10" >> "$CMD_FILE"
echo "config sync.10.username $USER_EMAIL" >> "$CMD_FILE"
echo "config sync.10.password hunter1hunter2hunter3" >> "$CMD_FILE"
echo "config sync.10.password 111111" >> "$CMD_FILE"
echo "sync" >> "$CMD_FILE"
yarn start --profile "$PROFILE_DIR" batch "$CMD_FILE"

View File

@ -44,7 +44,7 @@ const processUser = async (userNum: number) => {
try {
const userEmail = `user${userNum}@example.com`;
const userPassword = 'hunter1hunter2hunter3';
const userPassword = '111111';
const commandFile = `${tempDir}/populateDatabase-${userNum}.txt`;
const profileDir = `${homedir()}/.config/joplindev-populate/joplindev-testing-${userNum}`;

View File

@ -554,11 +554,17 @@ class Application extends BaseApplication {
// setTimeout(() => {
// this.dispatch({
// type: 'DIALOG_OPEN',
// name: 'editFolder',
// props: { folderId: '3d90f7da26b947dc9c8c6c65e86cd231' },
// name: 'syncWizard',
// });
// }, 2000);
// setTimeout(() => {
// this.dispatch({
// type: 'DIALOG_OPEN',
// name: 'editFolder',
// });
// }, 3000);
// setTimeout(() => {
// this.dispatch({
// type: 'NAV_GO',

View File

@ -1,6 +1,6 @@
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import Setting from '../../lib/models/Setting';
import { openFileWithExternalEditor } from '../../lib/services/ExternalEditWatcher/utils';
import Setting from '@joplin/lib/models/Setting';
import { openFileWithExternalEditor } from '@joplin/lib/services/ExternalEditWatcher/utils';
import bridge from '../services/bridge';
import { _ } from '@joplin/lib/locale';

View File

@ -1,19 +1,12 @@
import CommandService, { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { AppState } from '../app.reducer';
import bridge from '../services/bridge';
import { isInsideContainer } from '@joplin/lib/dom';
export const declaration: CommandDeclaration = {
name: 'replaceMisspelling',
};
function isInsideContainer(node: any, className: string): boolean {
while (node) {
if (node.classList && node.classList.contains(className)) return true;
node = node.parentNode;
}
return false;
}
export const runtime = (): CommandRuntime => {
return {
execute: async (context: CommandContext, suggestion: string) => {

View File

@ -7,6 +7,7 @@ import bridge from '../../services/bridge';
import Setting, { AppType, SyncStartupOperation } from '@joplin/lib/models/Setting';
import control_PluginsStates from './controls/plugins/PluginsStates';
import EncryptionConfigScreen from '../EncryptionConfigScreen/EncryptionConfigScreen';
import { reg } from '@joplin/lib/registry';
const { connect } = require('react-redux');
const { themeStyle } = require('@joplin/lib/theme');
const pathUtils = require('@joplin/lib/path-utils');
@ -26,7 +27,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
constructor(props: any) {
super(props);
shared.init(this);
shared.init(this, reg);
this.state = {
selectedSectionName: 'general',

View File

@ -1,4 +1,3 @@
import { useEffect, useCallback } from 'react';
import styled from 'styled-components';
const DialogModalLayer = styled.div`
@ -33,20 +32,6 @@ interface Props {
}
export default function Dialog(props: Props) {
const onWindowKeydown = useCallback((event: any) => {
if (event.key === 'Escape') {
if (props.onClose) props.onClose();
}
}, [props.onClose]);
useEffect(() => {
window.addEventListener('keydown', onWindowKeydown);
return () => {
window.removeEventListener('keydown', onWindowKeydown);
};
}, [onWindowKeydown]);
return (
<DialogModalLayer className={props.className}>
<DialogRoot>

View File

@ -1,7 +1,8 @@
const React = require('react');
import { useMemo } from 'react';
const { _ } = require('@joplin/lib/locale');
const { themeStyle } = require('@joplin/lib/theme');
import * as React from 'react';
import { useMemo, useCallback } from 'react';
import { _ } from '@joplin/lib/locale';
import { themeStyle } from '@joplin/lib/theme';
import useKeyboardHandler from './DialogButtonRow/useKeyboardHandler';
export interface ButtonSpec {
name: string;
@ -37,32 +38,26 @@ export default function DialogButtonRow(props: Props) {
};
}, [theme.buttonStyle]);
const okButton_click = () => {
if (props.onClick) props.onClick({ buttonName: 'ok' });
};
const onOkButtonClick = useCallback(() => {
if (props.onClick && !props.okButtonDisabled) props.onClick({ buttonName: 'ok' });
}, [props.onClick, props.okButtonDisabled]);
const cancelButton_click = () => {
if (props.onClick) props.onClick({ buttonName: 'cancel' });
};
const onCancelButtonClick = useCallback(() => {
if (props.onClick && !props.cancelButtonDisabled) props.onClick({ buttonName: 'cancel' });
}, [props.onClick, props.cancelButtonDisabled]);
const customButton_click = (event: ClickEvent) => {
const onCustomButtonClick = useCallback((event: ClickEvent) => {
if (props.onClick) props.onClick(event);
};
}, [props.onClick]);
const onKeyDown = (event: any) => {
if (event.keyCode === 13) {
okButton_click();
} else if (event.keyCode === 27) {
cancelButton_click();
}
};
const onKeyDown = useKeyboardHandler({ onOkButtonClick, onCancelButtonClick });
const buttonComps = [];
if (props.customButtons) {
for (const b of props.customButtons) {
buttonComps.push(
<button key={b.name} style={buttonStyle} onClick={() => customButton_click({ buttonName: b.name })} onKeyDown={onKeyDown}>
<button key={b.name} style={buttonStyle} onClick={() => onCustomButtonClick({ buttonName: b.name })} onKeyDown={onKeyDown}>
{b.label}
</button>
);
@ -71,7 +66,7 @@ export default function DialogButtonRow(props: Props) {
if (props.okButtonShow !== false) {
buttonComps.push(
<button disabled={props.okButtonDisabled} key="ok" style={buttonStyle} onClick={okButton_click} ref={props.okButtonRef} onKeyDown={onKeyDown}>
<button disabled={props.okButtonDisabled} key="ok" style={buttonStyle} onClick={onOkButtonClick} ref={props.okButtonRef} onKeyDown={onKeyDown}>
{props.okButtonLabel ? props.okButtonLabel : _('OK')}
</button>
);
@ -79,7 +74,7 @@ export default function DialogButtonRow(props: Props) {
if (props.cancelButtonShow !== false) {
buttonComps.push(
<button disabled={props.cancelButtonDisabled} key="cancel" style={Object.assign({}, buttonStyle)} onClick={cancelButton_click}>
<button disabled={props.cancelButtonDisabled} key="cancel" style={Object.assign({}, buttonStyle)} onClick={onCancelButtonClick}>
{props.cancelButtonLabel ? props.cancelButtonLabel : _('Cancel')}
</button>
);

View File

@ -0,0 +1,62 @@
import { useEffect, useState, useRef, useCallback } from 'react';
import { isInsideContainer } from '@joplin/lib/dom';
interface Props {
onOkButtonClick: Function;
onCancelButtonClick: Function;
}
const globalKeydownHandlers: string[] = [];
export default (props: Props) => {
const [elementId] = useState(`${Math.round(Math.random() * 10000000)}`);
const globalKeydownHandlersRef = useRef(globalKeydownHandlers);
useEffect(() => {
globalKeydownHandlersRef.current.push(elementId);
return () => {
const idx = globalKeydownHandlersRef.current.findIndex(e => e === elementId);
globalKeydownHandlersRef.current.splice(idx, 1);
};
}, []);
const isTopDialog = () => {
const ln = globalKeydownHandlersRef.current.length;
return ln && globalKeydownHandlersRef.current[ln - 1] === elementId;
};
const isInSubModal = (targetElement: any) => {
// If we are inside a sub-modal within the dialog, we shouldn't handle
// global key events. It can be for example the emoji picker. In general
// it's difficult to know whether an element is a modal or not, so we'll
// have to add special cases here. Normally there shouldn't be many of
// these.
if (isInsideContainer(targetElement, 'emoji-picker')) return true;
return false;
};
const onKeyDown = useCallback((event: any) => {
// Early exit if it's neither ENTER nor ESCAPE, because isInSubModal
// function can be costly.
if (event.keyCode !== 13 && event.keyCode !== 27) return;
if (!isTopDialog() || isInSubModal(event.target)) return;
if (event.keyCode === 13) {
if (event.target.nodeName !== 'TEXTAREA') {
props.onOkButtonClick();
}
} else if (event.keyCode === 27) {
props.onCancelButtonClick();
}
}, [props.onOkButtonClick, props.onCancelButtonClick]);
useEffect(() => {
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('keydown', onKeyDown);
};
}, [onKeyDown]);
return onKeyDown;
};

View File

@ -137,7 +137,7 @@ const EncryptionConfigScreen = (props: Props) => {
return (
<td style={theme.textStyle}>
<input type="password" style={passwordStyle} value={password} onChange={event => onInputPasswordChange(mk, event.target.value)} />{' '}
<button style={theme.buttonStyle} onClick={() => onSavePasswordClick(mk, props.passwords)}>
<button style={theme.buttonStyle} onClick={() => onSavePasswordClick(mk, { ...props.passwords, ...inputPasswords })}>
{_('Save')}
</button>
</td>
@ -268,7 +268,7 @@ const EncryptionConfigScreen = (props: Props) => {
const buttonTitle = CommandService.instance().label('openMasterPasswordDialog');
const needPasswordMessage = !needMasterPassword ? null : (
<p className="needpassword">{_('Your master password is needed to decrypt some of your data.')}<br/>{_('Please click on "%s" to proceed', buttonTitle)}</p>
<p className="needpassword">{_('Your password is needed to decrypt some of your data.')}<br/>{_('Please click on "%s" to proceed, or set the passwords in the "%s" list below.', buttonTitle, _('Encryption keys'))}</p>
);
return (

View File

@ -5,6 +5,7 @@
.manage-password-section > .status {
display: flex;
flex-direction: row;
align-items: center;
}
.manage-password-section > .needpassword {

View File

@ -37,7 +37,7 @@ import { localSyncInfoFromState } from '@joplin/lib/services/synchronizer/syncIn
import { parseCallbackUrl } from '@joplin/lib/callbackUrlUtils';
import ElectronAppWrapper from '../../ElectronAppWrapper';
import { showMissingMasterKeyMessage } from '@joplin/lib/services/e2ee/utils';
import { MasterKeyEntity } from '../../../lib/services/e2ee/types';
import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types';
import commands from './commands/index';
import invitationRespond from '../../services/share/invitationRespond';
const { connect } = require('react-redux');

View File

@ -34,6 +34,12 @@ export default function(props: Props) {
const [updatingPassword, setUpdatingPassword] = useState(false);
const [mode, setMode] = useState<Mode>(Mode.Set);
const showCurrentPassword = useMemo(() => {
if ([MasterPasswordStatus.NotSet, MasterPasswordStatus.Invalid].includes(status)) return false;
if (mode === Mode.Reset) return false;
return true;
}, [status]);
const onClose = useCallback(() => {
props.dispatch({
type: 'DIALOG_CLOSE',
@ -63,7 +69,7 @@ export default function(props: Props) {
setUpdatingPassword(true);
try {
if (mode === Mode.Set) {
await updateMasterPassword(currentPassword, password1);
await updateMasterPassword(showCurrentPassword ? currentPassword : null, password1);
} else if (mode === Mode.Reset) {
await resetMasterPassword(EncryptionService.instance(), KvStore.instance(), ShareService.instance(), password1);
} else {
@ -115,7 +121,7 @@ export default function(props: Props) {
}, [password1, password2, updatingPassword, needToRepeatPassword]);
useEffect(() => {
setShowPasswordForm(status === MasterPasswordStatus.NotSet);
setShowPasswordForm([MasterPasswordStatus.NotSet, MasterPasswordStatus.Invalid].includes(status));
}, [status]);
useAsyncEffect(async (event: AsyncEffectEvent) => {
@ -131,8 +137,7 @@ export default function(props: Props) {
function renderPasswordForm() {
const renderCurrentPassword = () => {
if (status === MasterPasswordStatus.NotSet) return null;
if (mode === Mode.Reset) return null;
if (!showCurrentPassword) return null;
// If the master password is in the keychain we preload it into the
// field and allow displaying it. That way if the user has forgotten

View File

@ -20,7 +20,7 @@ import bridge from '../services/bridge';
import checkForUpdates from '../checkForUpdates';
const { connect } = require('react-redux');
import { reg } from '@joplin/lib/registry';
import { ProfileConfig } from '../../lib/services/profileConfig/types';
import { ProfileConfig } from '@joplin/lib/services/profileConfig/types';
const packageInfo = require('../packageInfo.js');
const { clipboard } = require('electron');
const Menu = bridge().Menu;

View File

@ -15,7 +15,7 @@ import Button from './Button/Button';
import { connect } from 'react-redux';
import { AppState } from '../app.reducer';
import { getEncryptionEnabled } from '@joplin/lib/services/synchronizer/syncInfoUtils';
import SyncTargetRegistry from '../../lib/SyncTargetRegistry';
import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry';
const { clipboard } = require('electron');
interface Props {

View File

@ -275,4 +275,5 @@ Component-specific classes
.master-password-dialog .fa-times {
color: var(--joplin-color-error);
margin-left: 5px;
}

View File

@ -92,7 +92,7 @@ do
echo "config sync.target 10" >> "$CMD_FILE"
# echo "config sync.10.path http://api.joplincloud.local:22300" >> "$CMD_FILE"
echo "config sync.10.username $USER_EMAIL" >> "$CMD_FILE"
echo "config sync.10.password hunter1hunter2hunter3" >> "$CMD_FILE"
echo "config sync.10.password 111111" >> "$CMD_FILE"
elif [[ $CMD == "e2ee" ]]; then

View File

@ -41,7 +41,7 @@ class ConfigScreenComponent extends BaseScreenComponent {
this.scrollViewRef_ = React.createRef();
shared.init(this);
shared.init(this, reg);
this.checkSyncConfig_ = async () => {
// to ignore TLS erros we need to chage the global state of the app, if the check fails we need to restore the original state

View File

@ -507,7 +507,7 @@ async function initialize(dispatch: Function) {
// Setting.setValue('sync.target', 10);
// Setting.setValue('sync.10.username', 'user1@example.com');
// Setting.setValue('sync.10.password', 'hunter1hunter2hunter3');
// Setting.setValue('sync.10.password', '111111');
}
if (Setting.value('db.ftsEnabled') === -1) {

View File

@ -22,7 +22,7 @@ import TaskQueue from './TaskQueue';
import ItemUploader from './services/synchronizer/ItemUploader';
import { FileApi, RemoteItem } from './file-api';
import JoplinDatabase from './JoplinDatabase';
import { fetchSyncInfo, getActiveMasterKey, localSyncInfo, mergeSyncInfos, saveLocalSyncInfo, SyncInfo, syncInfoEquals, uploadSyncInfo } from './services/synchronizer/syncInfoUtils';
import { fetchSyncInfo, getActiveMasterKey, localSyncInfo, mergeSyncInfos, saveLocalSyncInfo, setMasterKeyHasBeenUsed, SyncInfo, syncInfoEquals, uploadSyncInfo } from './services/synchronizer/syncInfoUtils';
import { getMasterPassword, setupAndDisableEncryption, setupAndEnableEncryption } from './services/e2ee/utils';
import { generateKeyPair } from './services/e2ee/ppk';
import syncDebugLog from './services/synchronizer/syncDebugLog';
@ -439,10 +439,13 @@ export default class Synchronizer {
let remoteInfo = await fetchSyncInfo(this.api());
logger.info('Sync target remote info:', remoteInfo);
let syncTargetIsNew = false;
if (!remoteInfo.version) {
logger.info('Sync target is new - setting it up...');
await this.migrationHandler().upgrade(Setting.value('syncVersion'));
remoteInfo = await fetchSyncInfo(this.api());
syncTargetIsNew = true;
}
logger.info('Sync target is already setup - checking it...');
@ -455,11 +458,16 @@ export default class Synchronizer {
localInfo = await this.setPpkIfNotExist(localInfo, remoteInfo);
if (syncTargetIsNew && localInfo.activeMasterKeyId) {
localInfo = setMasterKeyHasBeenUsed(localInfo, localInfo.activeMasterKeyId);
}
// console.info('LOCAL', localInfo);
// console.info('REMOTE', remoteInfo);
if (!syncInfoEquals(localInfo, remoteInfo)) {
const newInfo = mergeSyncInfos(localInfo, remoteInfo);
let newInfo = mergeSyncInfos(localInfo, remoteInfo);
if (newInfo.activeMasterKeyId) newInfo = setMasterKeyHasBeenUsed(newInfo, newInfo.activeMasterKeyId);
const previousE2EE = localInfo.e2ee;
logger.info('Sync target info differs between local and remote - merging infos: ', newInfo.toObject());

View File

@ -3,15 +3,35 @@ const SyncTargetRegistry = require('../../SyncTargetRegistry').default;
const ObjectUtils = require('../../ObjectUtils');
const { _ } = require('../../locale');
const { createSelector } = require('reselect');
const Logger = require('@joplin/lib/Logger').default;
const logger = Logger.create('config/lib');
const shared = {};
shared.init = function(comp) {
shared.onSettingsSaved = () => {};
shared.init = function(comp, reg) {
if (!comp.state) comp.state = {};
comp.state.checkSyncConfigResult = null;
comp.state.settings = {};
comp.state.changedSettingKeys = [];
comp.state.showAdvancedSettings = false;
shared.onSettingsSaved = (event) => {
const savedSettingKeys = event.savedSettingKeys;
// After changing the sync settings we immediately trigger a sync
// operation. This will ensure that the client gets the sync info as
// early as possible, in particular the encryption state (encryption
// keys, whether it's enabled, etc.). This should prevent situations
// where the user tried to setup E2EE on the client even though it's
// already been done on another client.
if (savedSettingKeys.find(s => s.startsWith('sync.'))) {
logger.info('Sync settings have been changed - scheduling a sync');
void reg.scheduleSync();
}
};
};
shared.advancedSettingsButton_click = (comp) => {
@ -79,6 +99,8 @@ shared.scheduleSaveSettings = function(comp) {
};
shared.saveSettings = function(comp) {
const savedSettingKeys = comp.state.changedSettingKeys.slice();
for (const key in comp.state.settings) {
if (!comp.state.settings.hasOwnProperty(key)) continue;
if (comp.state.changedSettingKeys.indexOf(key) < 0) continue;
@ -86,6 +108,8 @@ shared.saveSettings = function(comp) {
}
comp.setState({ changedSettingKeys: [] });
shared.onSettingsSaved({ savedSettingKeys });
};
shared.settingsToComponents = function(comp, device, settings) {

9
packages/lib/dom.ts Normal file
View File

@ -0,0 +1,9 @@
/* eslint-disable import/prefer-default-export */
export const isInsideContainer = (node: any, className: string): boolean => {
while (node) {
if (node.classList && node.classList.contains(className)) return true;
node = node.parentNode;
}
return false;
};

View File

@ -254,6 +254,7 @@ export default class EncryptionService {
model.created_time = now;
model.updated_time = now;
model.source_application = Setting.value('appId');
model.hasBeenUsed = false;
return model;
}

View File

@ -8,6 +8,7 @@ export interface MasterKeyEntity {
content?: string;
type_?: number;
enabled?: number;
hasBeenUsed?: boolean;
}
export type RSAKeyPair = any; // Depends on implementation

View File

@ -9,7 +9,7 @@ import ResourceFetcher from '../../services/ResourceFetcher';
import MasterKey from '../../models/MasterKey';
import BaseItem from '../../models/BaseItem';
import Synchronizer from '../../Synchronizer';
import { getEncryptionEnabled, setEncryptionEnabled } from '../synchronizer/syncInfoUtils';
import { fetchSyncInfo, getEncryptionEnabled, localSyncInfo, setEncryptionEnabled } from '../synchronizer/syncInfoUtils';
import { loadMasterKeysFromSettings, setupAndDisableEncryption, setupAndEnableEncryption } from '../e2ee/utils';
let insideBeforeEach = false;
@ -73,6 +73,32 @@ describe('Synchronizer.e2ee', function() {
expect(!folder1_2.encryption_cipher_text).toBe(true);
}));
it('should mark the key has having been used when synchronising the first time', (async () => {
setEncryptionEnabled(true);
await loadEncryptionMasterKey();
await Folder.save({ title: 'folder1' });
await synchronizerStart();
const localInfo = localSyncInfo();
const remoteInfo = await fetchSyncInfo(fileApi());
expect(localInfo.masterKeys[0].hasBeenUsed).toBe(true);
expect(remoteInfo.masterKeys[0].hasBeenUsed).toBe(true);
}));
it('should mark the key has having been used when synchronising after enabling encryption', (async () => {
await Folder.save({ title: 'folder1' });
await synchronizerStart();
setEncryptionEnabled(true);
await loadEncryptionMasterKey();
await synchronizerStart();
const localInfo = localSyncInfo();
const remoteInfo = await fetchSyncInfo(fileApi());
expect(localInfo.masterKeys[0].hasBeenUsed).toBe(true);
expect(remoteInfo.masterKeys[0].hasBeenUsed).toBe(true);
}));
it('should enable encryption automatically when downloading new master key (and none was previously available)',(async () => {
// Enable encryption on client 1 and sync an item
setEncryptionEnabled(true);

View File

@ -1,6 +1,6 @@
import { afterAllCleanUp, setupDatabaseAndSynchronizer, switchClient, encryptionService } from '../../testing/test-utils';
import { afterAllCleanUp, setupDatabaseAndSynchronizer, switchClient, encryptionService, msleep } from '../../testing/test-utils';
import MasterKey from '../../models/MasterKey';
import { masterKeyEnabled, setMasterKeyEnabled, SyncInfo, syncInfoEquals } from './syncInfoUtils';
import { masterKeyEnabled, mergeSyncInfos, setMasterKeyEnabled, SyncInfo, syncInfoEquals } from './syncInfoUtils';
describe('syncInfoUtils', function() {
@ -92,4 +92,33 @@ describe('syncInfoUtils', function() {
}
});
it('should merge sync target info and takes into account usage of master key - 1', async () => {
const syncInfo1 = new SyncInfo();
syncInfo1.masterKeys = [{
id: '1',
content: 'content1',
hasBeenUsed: true,
}];
syncInfo1.activeMasterKeyId = '1';
await msleep(1);
const syncInfo2 = new SyncInfo();
syncInfo2.masterKeys = [{
id: '2',
content: 'content2',
hasBeenUsed: false,
}];
syncInfo2.activeMasterKeyId = '2';
// If one master key has been used and the other not, it should select
// the one that's been used regardless of timestamps.
expect(mergeSyncInfos(syncInfo1, syncInfo2).activeMasterKeyId).toBe('1');
// If both master keys have been used it should rely on timestamp
// (latest modified is picked).
syncInfo2.masterKeys[0].hasBeenUsed = true;
expect(mergeSyncInfos(syncInfo1, syncInfo2).activeMasterKeyId).toBe('2');
});
});

View File

@ -90,14 +90,62 @@ export function localSyncInfoFromState(state: State): SyncInfo {
return new SyncInfo(state.settings['syncInfoCache']);
}
// When deciding which master key should be active we should take into account
// whether it's been used or not. If it's been used before it should most likely
// remain the active one, regardless of timestamps. This is because the extra
// key was most likely created by mistake by the user, in particular in this
// kind of scenario:
//
// - Client 1 setup sync with sync target
// - Client 1 enable encryption
// - Client 1 sync
//
// Then user 2 does the same:
//
// - Client 2 setup sync with sync target
// - Client 2 enable encryption
// - Client 2 sync
//
// The problem is that enabling encryption was not needed since it was already
// done (and recorded in info.json) on the sync target. As a result an extra key
// has been created and it has been set as the active one, but we shouldn't use
// it. Instead the key created by client 1 should be used and made active again.
//
// And we can do this using the "hasBeenUsed" field which tells us which keys
// has already been used to encrypt data. In this case, at the moment we compare
// local and remote sync info (before synchronising the data), key1.hasBeenUsed
// is true, but key2.hasBeenUsed is false.
const mergeActiveMasterKeys = (s1: SyncInfo, s2: SyncInfo, output: SyncInfo) => {
const activeMasterKey1 = getActiveMasterKey(s1);
const activeMasterKey2 = getActiveMasterKey(s2);
let doDefaultAction = false;
if (activeMasterKey1 && activeMasterKey2) {
if (activeMasterKey1.hasBeenUsed && !activeMasterKey2.hasBeenUsed) {
output.setWithTimestamp(s1, 'activeMasterKeyId');
} else if (!activeMasterKey1.hasBeenUsed && activeMasterKey2.hasBeenUsed) {
output.setWithTimestamp(s2, 'activeMasterKeyId');
} else {
doDefaultAction = true;
}
} else {
doDefaultAction = true;
}
if (doDefaultAction) {
output.setWithTimestamp(s1.keyTimestamp('activeMasterKeyId') > s2.keyTimestamp('activeMasterKeyId') ? s1 : s2, 'activeMasterKeyId');
}
};
export function mergeSyncInfos(s1: SyncInfo, s2: SyncInfo): SyncInfo {
const output: SyncInfo = new SyncInfo();
output.setWithTimestamp(s1.keyTimestamp('e2ee') > s2.keyTimestamp('e2ee') ? s1 : s2, 'e2ee');
output.setWithTimestamp(s1.keyTimestamp('activeMasterKeyId') > s2.keyTimestamp('activeMasterKeyId') ? s1 : s2, 'activeMasterKeyId');
output.setWithTimestamp(s1.keyTimestamp('ppk') > s2.keyTimestamp('ppk') ? s1 : s2, 'ppk');
output.version = s1.version > s2.version ? s1.version : s2.version;
mergeActiveMasterKeys(s1, s2, output);
output.masterKeys = s1.masterKeys.slice();
for (const mk of s2.masterKeys) {
@ -154,6 +202,14 @@ export class SyncInfo {
this.activeMasterKeyId_ = 'activeMasterKeyId' in s ? s.activeMasterKeyId : { value: '', updatedTime: 0 };
this.masterKeys_ = 'masterKeys' in s ? s.masterKeys : [];
this.ppk_ = 'ppk' in s ? s.ppk : { value: null, updatedTime: 0 };
// Migration for master keys that didn't have "hasBeenUsed" property -
// in that case we assume they've been used at least once.
for (const mk of this.masterKeys_) {
if (!('hasBeenUsed' in mk) || mk.hasBeenUsed === undefined) {
mk.hasBeenUsed = true;
}
}
}
public setWithTimestamp(fromSyncInfo: SyncInfo, propName: string) {
@ -275,6 +331,21 @@ export function setMasterKeyEnabled(mkId: string, enabled: boolean = true) {
saveLocalSyncInfo(s);
}
export const setMasterKeyHasBeenUsed = (s: SyncInfo, mkId: string) => {
const idx = s.masterKeys.findIndex(mk => mk.id === mkId);
if (idx < 0) throw new Error(`No such master key: ${mkId}`);
s.masterKeys[idx] = {
...s.masterKeys[idx],
hasBeenUsed: true,
updated_time: Date.now(),
};
saveLocalSyncInfo(s);
return s;
};
export function masterKeyEnabled(mk: MasterKeyEntity): boolean {
if ('enabled' in mk) return !!mk.enabled;
return true;

View File

@ -129,11 +129,11 @@ const features: Record<FeatureId, PlanFeature> = {
pro: true,
teams: true,
basicInfo: '1 GB storage space',
proInfo: '200 GB storage space',
teamsInfo: '200 GB storage space',
proInfo: '10 GB storage space',
teamsInfo: '10 GB storage space',
basicInfoShort: '1 GB',
proInfoShort: '200 GB',
teamsInfoShort: '200 GB',
proInfoShort: '10 GB',
teamsInfoShort: '10 GB',
},
publishNote: {
title: 'Publish notes to the internet',

View File

@ -5,7 +5,7 @@ import uuidgen from '../utils/uuidgen';
import { ErrorUnprocessableEntity, ErrorBadRequest } from '../utils/errors';
import { Models, NewModelFactoryHandler } from './factory';
import * as EventEmitter from 'events';
import { Config } from '../utils/types';
import { Config, Env } from '../utils/types';
import personalizedUserContentBaseUrl from '@joplin/lib/services/joplinServer/personalizedUserContentBaseUrl';
import Logger from '@joplin/lib/Logger';
import dbuuid from '../utils/dbuuid';
@ -85,6 +85,10 @@ export default abstract class BaseModel<T> {
return this.config_.userContentBaseUrl;
}
protected get env(): Env {
return this.config_.env;
}
protected personalizedUserContentBaseUrl(userId: Uuid): string {
return personalizedUserContentBaseUrl(userId, this.baseUrl, this.userContentBaseUrl);
}

View File

@ -27,6 +27,7 @@ import changeEmailConfirmationTemplate from '../views/emails/changeEmailConfirma
import changeEmailNotificationTemplate from '../views/emails/changeEmailNotificationTemplate';
import { NotificationKey } from './NotificationModel';
import prettyBytes = require('pretty-bytes');
import { Env } from '../utils/types';
const logger = Logger.create('UserModel');
@ -237,6 +238,8 @@ export default class UserModel extends BaseModel<User> {
}
private validatePassword(password: string) {
if (this.env === Env.Dev) return;
const result = zxcvbn(password);
if (result.score < 3) {
let msg: string[] = [result.feedback.warning];

View File

@ -161,8 +161,8 @@ describe('admin/users', function() {
await patchUser(session.id, {
id: user.id,
email: 'changed@example.com',
password: 'hunter11hunter22hunter33',
password2: 'hunter11hunter22hunter33',
password: '111111',
password2: '111111',
}, '/admin/users/me');
const sessions = await models().session().all();

View File

@ -33,7 +33,7 @@ export async function createTestUsers(db: DbConnection, config: Config, options:
...options,
};
const password = 'hunter1hunter2hunter3';
const password = '111111';
const models = newModelFactory(db, config);

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
import { execCommand2, githubRelease, gitPullTry, rootDir } from './tool-utils';
import { execCommand2, gitCurrentBranch, githubRelease, gitPullTry, rootDir } from './tool-utils';
const appDir = `${rootDir}/packages/app-desktop`;
@ -27,10 +27,12 @@ async function main() {
console.info('Release options: ', releaseOptions);
const release = await githubRelease('joplin', tagName, releaseOptions);
const currentBranch = await gitCurrentBranch();
console.info(`Created GitHub release: ${release.html_url}`);
console.info('GitHub release page: https://github.com/laurent22/joplin/releases');
console.info(`To create changelog: node packages/tools/git-changelog.js ${version}`);
console.info(`To merge the version update: git checkout dev && git mergeff ${currentBranch} && git push && git checkout ${currentBranch}`);
}
main().catch((error) => {

View File

@ -335,6 +335,11 @@ export async function gitPullTry(ignoreIfNotBranch = true) {
}
}
export const gitCurrentBranch = async (): Promise<string> => {
const output = await execCommand2('git rev-parse --abbrev-ref HEAD', { quiet: true });
return output.trim();
};
export async function githubUsername(email: string, name: string) {
const cache = await loadGitHubUsernameCache();
const cacheKey = `${email}:${name}`;