mirror of
https://github.com/laurent22/joplin.git
synced 2026-10-26 11:15:57 +00:00
Compare commits
9
Commits
server-v3.7.3
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c9e457db8 | ||
|
|
588bd60825 | ||
|
|
3f19ce23b7 | ||
|
|
4fc42750cd | ||
|
|
62982077cf | ||
|
|
1d7c091d55 | ||
|
|
bad38b7fff | ||
|
|
75f888ff0d | ||
|
|
94168d33d6 |
No files matched your search
+11
@@ -426,6 +426,8 @@ packages/app-desktop/gui/NoteListItem/utils/useRenderedNote.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useRootElement.test.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useRootElement.js
|
||||
packages/app-desktop/gui/NoteListWrapper/NoteListWrapper.js
|
||||
packages/app-desktop/gui/NoteLockMigrationDialog/Dialog.test.js
|
||||
packages/app-desktop/gui/NoteLockMigrationDialog/Dialog.js
|
||||
packages/app-desktop/gui/NoteLockUnlockDialog/Dialog.js
|
||||
packages/app-desktop/gui/NotePropertiesDialog.js
|
||||
packages/app-desktop/gui/NoteRevisionViewer.js
|
||||
@@ -712,6 +714,7 @@ packages/app-desktop/utils/initializeNetworkConnectionAttemptTimeout.js
|
||||
packages/app-desktop/utils/isSafeToOpen.test.js
|
||||
packages/app-desktop/utils/isSafeToOpen.js
|
||||
packages/app-desktop/utils/layout/layoutKeyToLabel.js
|
||||
packages/app-desktop/utils/noteLockPrompts.js
|
||||
packages/app-desktop/utils/restartInSafeModeFromMain.test.js
|
||||
packages/app-desktop/utils/restartInSafeModeFromMain.js
|
||||
packages/app-desktop/utils/sourceMapSetup.js
|
||||
@@ -950,6 +953,8 @@ packages/app-mobile/components/screens/Note/commands/insertDateTime.js
|
||||
packages/app-mobile/components/screens/Note/commands/setTags.js
|
||||
packages/app-mobile/components/screens/Note/commands/toggleVisiblePanes.js
|
||||
packages/app-mobile/components/screens/Note/types.js
|
||||
packages/app-mobile/components/screens/NoteLockMigrationScreen.test.js
|
||||
packages/app-mobile/components/screens/NoteLockMigrationScreen.js
|
||||
packages/app-mobile/components/screens/NoteRevisionViewer.test.js
|
||||
packages/app-mobile/components/screens/NoteRevisionViewer.js
|
||||
packages/app-mobile/components/screens/NoteTagsDialog.js
|
||||
@@ -1745,6 +1750,7 @@ packages/lib/services/e2ee/ppk/webCrypto/buildRsaCryptoProvider.js
|
||||
packages/lib/services/e2ee/types.js
|
||||
packages/lib/services/e2ee/utils.test.js
|
||||
packages/lib/services/e2ee/utils.js
|
||||
packages/lib/services/interop/InteropService.noteLock.test.js
|
||||
packages/lib/services/interop/InteropService.test.js
|
||||
packages/lib/services/interop/InteropService.js
|
||||
packages/lib/services/interop/InteropService_Exporter_Base.js
|
||||
@@ -1804,6 +1810,8 @@ packages/lib/services/noteList/renderViewProps.test.js
|
||||
packages/lib/services/noteList/renderViewProps.js
|
||||
packages/lib/services/noteList/renderers.js
|
||||
packages/lib/services/noteLock/NoteLockKey.js
|
||||
packages/lib/services/noteLock/NoteLockKeyMigration.test.js
|
||||
packages/lib/services/noteLock/NoteLockKeyMigration.js
|
||||
packages/lib/services/noteLock/NoteLockNote.js
|
||||
packages/lib/services/noteLock/NoteLockService.test.js
|
||||
packages/lib/services/noteLock/NoteLockService.js
|
||||
@@ -1811,6 +1819,8 @@ packages/lib/services/noteLock/NoteLockSession.test.js
|
||||
packages/lib/services/noteLock/NoteLockSession.js
|
||||
packages/lib/services/noteLock/hasLockedNoteWhileSessionLocked.js
|
||||
packages/lib/services/noteLock/isNoteLockEnabled.js
|
||||
packages/lib/services/noteLock/noteLockPrompts.test.js
|
||||
packages/lib/services/noteLock/noteLockPrompts.js
|
||||
packages/lib/services/noteLock/setNoteLockState.test.js
|
||||
packages/lib/services/noteLock/setNoteLockState.js
|
||||
packages/lib/services/ocr/OcrDriverBase.js
|
||||
@@ -1965,6 +1975,7 @@ packages/lib/services/synchronizer/Synchronizer.baseFields.test.js
|
||||
packages/lib/services/synchronizer/Synchronizer.basics.test.js
|
||||
packages/lib/services/synchronizer/Synchronizer.conflicts.test.js
|
||||
packages/lib/services/synchronizer/Synchronizer.e2ee.test.js
|
||||
packages/lib/services/synchronizer/Synchronizer.noteLock.test.js
|
||||
packages/lib/services/synchronizer/Synchronizer.ppk.test.js
|
||||
packages/lib/services/synchronizer/Synchronizer.resources.test.js
|
||||
packages/lib/services/synchronizer/Synchronizer.revisions.test.js
|
||||
|
||||
@@ -452,6 +452,8 @@ packages/app-desktop/gui/NoteListItem/utils/useRenderedNote.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useRootElement.test.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useRootElement.js
|
||||
packages/app-desktop/gui/NoteListWrapper/NoteListWrapper.js
|
||||
packages/app-desktop/gui/NoteLockMigrationDialog/Dialog.test.js
|
||||
packages/app-desktop/gui/NoteLockMigrationDialog/Dialog.js
|
||||
packages/app-desktop/gui/NoteLockUnlockDialog/Dialog.js
|
||||
packages/app-desktop/gui/NotePropertiesDialog.js
|
||||
packages/app-desktop/gui/NoteRevisionViewer.js
|
||||
@@ -738,6 +740,7 @@ packages/app-desktop/utils/initializeNetworkConnectionAttemptTimeout.js
|
||||
packages/app-desktop/utils/isSafeToOpen.test.js
|
||||
packages/app-desktop/utils/isSafeToOpen.js
|
||||
packages/app-desktop/utils/layout/layoutKeyToLabel.js
|
||||
packages/app-desktop/utils/noteLockPrompts.js
|
||||
packages/app-desktop/utils/restartInSafeModeFromMain.test.js
|
||||
packages/app-desktop/utils/restartInSafeModeFromMain.js
|
||||
packages/app-desktop/utils/sourceMapSetup.js
|
||||
@@ -976,6 +979,8 @@ packages/app-mobile/components/screens/Note/commands/insertDateTime.js
|
||||
packages/app-mobile/components/screens/Note/commands/setTags.js
|
||||
packages/app-mobile/components/screens/Note/commands/toggleVisiblePanes.js
|
||||
packages/app-mobile/components/screens/Note/types.js
|
||||
packages/app-mobile/components/screens/NoteLockMigrationScreen.test.js
|
||||
packages/app-mobile/components/screens/NoteLockMigrationScreen.js
|
||||
packages/app-mobile/components/screens/NoteRevisionViewer.test.js
|
||||
packages/app-mobile/components/screens/NoteRevisionViewer.js
|
||||
packages/app-mobile/components/screens/NoteTagsDialog.js
|
||||
@@ -1771,6 +1776,7 @@ packages/lib/services/e2ee/ppk/webCrypto/buildRsaCryptoProvider.js
|
||||
packages/lib/services/e2ee/types.js
|
||||
packages/lib/services/e2ee/utils.test.js
|
||||
packages/lib/services/e2ee/utils.js
|
||||
packages/lib/services/interop/InteropService.noteLock.test.js
|
||||
packages/lib/services/interop/InteropService.test.js
|
||||
packages/lib/services/interop/InteropService.js
|
||||
packages/lib/services/interop/InteropService_Exporter_Base.js
|
||||
@@ -1830,6 +1836,8 @@ packages/lib/services/noteList/renderViewProps.test.js
|
||||
packages/lib/services/noteList/renderViewProps.js
|
||||
packages/lib/services/noteList/renderers.js
|
||||
packages/lib/services/noteLock/NoteLockKey.js
|
||||
packages/lib/services/noteLock/NoteLockKeyMigration.test.js
|
||||
packages/lib/services/noteLock/NoteLockKeyMigration.js
|
||||
packages/lib/services/noteLock/NoteLockNote.js
|
||||
packages/lib/services/noteLock/NoteLockService.test.js
|
||||
packages/lib/services/noteLock/NoteLockService.js
|
||||
@@ -1837,6 +1845,8 @@ packages/lib/services/noteLock/NoteLockSession.test.js
|
||||
packages/lib/services/noteLock/NoteLockSession.js
|
||||
packages/lib/services/noteLock/hasLockedNoteWhileSessionLocked.js
|
||||
packages/lib/services/noteLock/isNoteLockEnabled.js
|
||||
packages/lib/services/noteLock/noteLockPrompts.test.js
|
||||
packages/lib/services/noteLock/noteLockPrompts.js
|
||||
packages/lib/services/noteLock/setNoteLockState.test.js
|
||||
packages/lib/services/noteLock/setNoteLockState.js
|
||||
packages/lib/services/ocr/OcrDriverBase.js
|
||||
@@ -1991,6 +2001,7 @@ packages/lib/services/synchronizer/Synchronizer.baseFields.test.js
|
||||
packages/lib/services/synchronizer/Synchronizer.basics.test.js
|
||||
packages/lib/services/synchronizer/Synchronizer.conflicts.test.js
|
||||
packages/lib/services/synchronizer/Synchronizer.e2ee.test.js
|
||||
packages/lib/services/synchronizer/Synchronizer.noteLock.test.js
|
||||
packages/lib/services/synchronizer/Synchronizer.ppk.test.js
|
||||
packages/lib/services/synchronizer/Synchronizer.resources.test.js
|
||||
packages/lib/services/synchronizer/Synchronizer.revisions.test.js
|
||||
|
||||
@@ -2,6 +2,7 @@ import shim from '@joplin/lib/shim';
|
||||
import * as os from 'os';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { filename } from '@joplin/lib/path-utils';
|
||||
import { dirname } from 'path';
|
||||
import HtmlToMd, { ParseOptions } from '@joplin/lib/HtmlToMd';
|
||||
import MdToHtml from '@joplin/renderer/MdToHtml';
|
||||
|
||||
@@ -130,6 +131,31 @@ describe('HtmlToMd', () => {
|
||||
expect(collapsedResult).toContain('\n\n');
|
||||
});
|
||||
|
||||
it('should not re-read the rows of a table for every row', async () => {
|
||||
// Under domino, each access to `table.rows` walks the whole table, so reading it once per row made
|
||||
// converting large tables quadratic: https://github.com/laurent22/joplin/issues/16894
|
||||
const resolveFrom = (moduleName: string, fromPath: string) => require.resolve(moduleName, { paths: [dirname(fromPath)] });
|
||||
const domino: { createDocument: (html: string)=> Document } = require(resolveFrom('@mixmark-io/domino', resolveFrom('@joplin/turndown', require.resolve('@joplin/lib/HtmlToMd'))));
|
||||
|
||||
const rowCount = 100;
|
||||
const rows = Array.from({ length: rowCount }, (_, i) => `<tr><td>${i}</td><td>b</td><td>c</td></tr>`).join('');
|
||||
const root = domino.createDocument(`<div><table><thead><tr><th>A</th><th>B</th><th>C</th></tr></thead><tbody>${rows}</tbody></table></div>`).querySelector('div');
|
||||
// Turndown converts a clone of the element it's given, which wouldn't have the counter below
|
||||
Object.defineProperty(root, 'cloneNode', { value: () => root });
|
||||
|
||||
const table = root.querySelector('table');
|
||||
const getRows = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(table), 'rows').get;
|
||||
let rowsReadCount = 0;
|
||||
Object.defineProperty(table, 'rows', { get: () => {
|
||||
rowsReadCount++;
|
||||
return getRows.call(table);
|
||||
} });
|
||||
|
||||
const markdown = new HtmlToMd().parse(root);
|
||||
expect(markdown.split('\n')).toHaveLength(rowCount + 2);
|
||||
expect(rowsReadCount).toBeLessThan(rowCount);
|
||||
});
|
||||
|
||||
// Covers how newlines are carried between children when joining them, which was rewritten to fix
|
||||
// https://github.com/laurent22/joplin/issues/16895
|
||||
it.each([
|
||||
|
||||
@@ -8,8 +8,12 @@ import { ExportModule } from '@joplin/lib/services/interop/Module';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import bridge from './services/bridge';
|
||||
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
|
||||
import { unlockNoteLockSession } from '@joplin/lib/services/noteLock/noteLockPrompts';
|
||||
import noteLockPrompts from './utils/noteLockPrompts';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import { friendlySafeFilename } from '@joplin/lib/path-utils';
|
||||
import time from '@joplin/lib/time';
|
||||
import { BrowserWindow, BrowserWindowConstructorOptions } from 'electron';
|
||||
@@ -36,6 +40,8 @@ export default class InteropServiceHelper {
|
||||
format: ExportModuleOutputFormat.Html,
|
||||
target: FileSystemItem.File,
|
||||
sourceNoteIds: [noteId],
|
||||
// The PDF export and printing ask first.
|
||||
decryptLockedNotes: true,
|
||||
customCss: '', ...exportOptions };
|
||||
|
||||
const service = InteropService.instance();
|
||||
@@ -193,7 +199,30 @@ export default class InteropServiceHelper {
|
||||
return `${filename}.${fileExtension}`;
|
||||
}
|
||||
|
||||
public static async export(_dispatch: Dispatch, module: ExportModule, options: ExportNoteOptions = null) {
|
||||
// Decrypting exports write locked notes out as plain text, so the user is asked even with the session unlocked,
|
||||
// which only skips the password.
|
||||
public static async confirmLockedNoteExport(dispatch: Dispatch, noteIds: string[], folderIds: string[] = null) {
|
||||
if (!isNoteLockEnabled()) return 'export';
|
||||
let hasLockedNotes = false;
|
||||
if (noteIds?.length) {
|
||||
hasLockedNotes = (await Note.byIds(noteIds, { fields: ['is_locked'] })).some(note => !!note.is_locked);
|
||||
} else if (folderIds?.length) {
|
||||
hasLockedNotes = await Note.hasLockedNoteInFolderTrees(folderIds);
|
||||
} else {
|
||||
// Same scope as the export of everything: notebooks not in the trash.
|
||||
hasLockedNotes = await Note.hasLockedNoteInFolderTrees(await Folder.childrenIds(''));
|
||||
}
|
||||
if (!hasLockedNotes) return 'export';
|
||||
|
||||
const answer = bridge().showMessageBox(_('Some notes are locked and this export format requires them to be unlocked. Would you like to export these notes unprotected, or export without these notes?'), {
|
||||
buttons: [_('Export unprotected'), _('Export without locked notes'), _('Cancel')],
|
||||
});
|
||||
if (answer === 1) return 'skipLocked';
|
||||
if (answer === 0 && await unlockNoteLockSession(noteLockPrompts(dispatch))) return 'export';
|
||||
return 'cancel';
|
||||
}
|
||||
|
||||
public static async export(dispatch: Dispatch, module: ExportModule, options: ExportNoteOptions = null) {
|
||||
if (!options) options = {};
|
||||
|
||||
let path = null;
|
||||
@@ -214,6 +243,18 @@ export default class InteropServiceHelper {
|
||||
|
||||
if (Array.isArray(path)) path = path[0];
|
||||
|
||||
// Backups keep locked notes encrypted, so only the decrypting formats ask.
|
||||
const isBackup = module.format === ExportModuleOutputFormat.Raw || module.format === ExportModuleOutputFormat.Jex;
|
||||
const lockedNoteChoice = isBackup ? 'export' : await this.confirmLockedNoteExport(dispatch, options.sourceNoteIds, options.sourceFolderIds);
|
||||
if (lockedNoteChoice === 'cancel') return;
|
||||
|
||||
let sourceNoteIds = options.sourceNoteIds;
|
||||
// A selection left with no note would make a single-file export fail on the file it never wrote.
|
||||
if (sourceNoteIds?.length && lockedNoteChoice === 'skipLocked') {
|
||||
sourceNoteIds = (await Note.byIds(sourceNoteIds, { fields: ['id', 'is_locked'] })).filter(note => !note.is_locked).map(note => note.id);
|
||||
if (!sourceNoteIds.length) return;
|
||||
}
|
||||
|
||||
void CommandService.instance().execute('showModalMessage', _('Exporting to "%s" as "%s" format. Please wait...', path, module.format));
|
||||
|
||||
const exportOptions: ExportOptions = {};
|
||||
@@ -225,7 +266,9 @@ export default class InteropServiceHelper {
|
||||
exportOptions.target = module.target;
|
||||
exportOptions.includeConflicts = !!options.includeConflicts;
|
||||
if (options.sourceFolderIds) exportOptions.sourceFolderIds = options.sourceFolderIds;
|
||||
if (options.sourceNoteIds) exportOptions.sourceNoteIds = options.sourceNoteIds;
|
||||
if (sourceNoteIds) exportOptions.sourceNoteIds = sourceNoteIds;
|
||||
exportOptions.skipLockedNotes = lockedNoteChoice === 'skipLocked';
|
||||
exportOptions.decryptLockedNotes = lockedNoteChoice === 'export';
|
||||
|
||||
const service = InteropService.instance();
|
||||
let keepModalOpen = false;
|
||||
|
||||
@@ -787,6 +787,7 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
noteTitle={lockedNoteMetadata.title}
|
||||
hasNoteLockKey={props.hasNoteLockKey}
|
||||
dispatch={props.dispatch}
|
||||
migrationRunning={props.noteLockMigrationRunning}
|
||||
lockedInShare={noteIsLockedInShare(lockedNoteMetadata)}
|
||||
/>
|
||||
</div>
|
||||
@@ -921,6 +922,7 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
|
||||
whiteboardForceMarkdown: windowState.whiteboardForceMarkdown ?? {},
|
||||
noteLockSessionUnlocked: state.noteLockSessionUnlocked,
|
||||
hasNoteLockKey: hasNoteLockKey(state.settings['syncInfoCache']),
|
||||
noteLockMigrationRunning: !!state.noteLockMigrationStatus?.running,
|
||||
editorNoteReloadTimeRequest: windowState.windowEditorNoteReloadTimeRequest,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ interface Props {
|
||||
hasNoteLockKey: boolean;
|
||||
dispatch: Dispatch;
|
||||
undecryptable?: boolean;
|
||||
migrationRunning?: boolean;
|
||||
lockedInShare?: boolean;
|
||||
}
|
||||
|
||||
@@ -50,6 +51,10 @@ export default function NoteLockPanel(props: Props) {
|
||||
}, [props.dispatch]);
|
||||
|
||||
const renderAction = () => {
|
||||
if (props.migrationRunning) {
|
||||
return <p className="message">{_('This note cannot be read while a note lock key migration is in progress.')}</p>;
|
||||
}
|
||||
|
||||
if (props.undecryptable) {
|
||||
return <p className="message">{_('This note could not be unlocked. If it was locked prior to a password reset, the content is no longer recoverable.')}</p>;
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ export interface NoteEditorProps {
|
||||
whiteboardForceMarkdown: Record<string, boolean>;
|
||||
noteLockSessionUnlocked: boolean;
|
||||
hasNoteLockKey: boolean;
|
||||
noteLockMigrationRunning: boolean;
|
||||
editorNoteReloadTimeRequest: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import * as React from 'react';
|
||||
import * as ReactDom from 'react-dom';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import NoteLockMigrationDialog from './Dialog';
|
||||
import { confirmAndSkipNoteLockKeyMigration, startNoteLockKeyMigration } from '@joplin/lib/services/noteLock/NoteLockKeyMigration';
|
||||
|
||||
jest.mock('@joplin/lib/services/noteLock/NoteLockKeyMigration', () => ({
|
||||
startNoteLockKeyMigration: jest.fn(),
|
||||
confirmAndSkipNoteLockKeyMigration: jest.fn(),
|
||||
}));
|
||||
|
||||
// The dialog renders through a portal, jsdom has no modal dialog support, and DialogTitle relies on
|
||||
// the React global the app bundle provides.
|
||||
shim.setReactDom(ReactDom);
|
||||
(globalThis as unknown as { React: typeof React }).React = React;
|
||||
HTMLDialogElement.prototype.showModal = function() { this.open = true; };
|
||||
HTMLDialogElement.prototype.close = function() { this.open = false; };
|
||||
|
||||
const startMock = startNoteLockKeyMigration as jest.Mock;
|
||||
const skipMock = confirmAndSkipNoteLockKeyMigration as jest.Mock;
|
||||
|
||||
const renderDialog = () => {
|
||||
const dispatch = jest.fn();
|
||||
render(<NoteLockMigrationDialog themeId={1} dispatch={dispatch}/>);
|
||||
fireEvent.change(screen.getByLabelText('Note lock password on this device'), { target: { value: 'local' } });
|
||||
fireEvent.change(screen.getByLabelText('Note lock password on the sync target'), { target: { value: 'target' } });
|
||||
return dispatch;
|
||||
};
|
||||
|
||||
describe('NoteLockMigrationDialog/Dialog', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should close once both passwords are accepted and leave the run to the banner', async () => {
|
||||
startMock.mockImplementation(async (_local: string, _target: string, _dispatch: unknown, onStarted: ()=> void) => onStarted());
|
||||
const dispatch = renderDialog();
|
||||
|
||||
fireEvent.click(screen.getByText('Migrate'));
|
||||
|
||||
await waitFor(() => expect(dispatch).toHaveBeenCalledWith({ type: 'DIALOG_CLOSE', name: 'noteLockMigration' }));
|
||||
expect(startMock).toHaveBeenCalledWith('local', 'target', dispatch, expect.any(Function));
|
||||
expect(skipMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should report a wrong password and stay open', async () => {
|
||||
startMock.mockRejectedValue(Object.assign(new Error('bad'), { name: 'OperationError' }));
|
||||
const dispatch = renderDialog();
|
||||
|
||||
fireEvent.click(screen.getByText('Migrate'));
|
||||
|
||||
expect((await screen.findByRole('alert')).textContent).toContain('Invalid password');
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each([true, false])('should close only once the skip is confirmed (%s)', async (confirmed) => {
|
||||
skipMock.mockResolvedValue(confirmed);
|
||||
const dispatch = renderDialog();
|
||||
|
||||
fireEvent.click(screen.getByText('Skip'));
|
||||
|
||||
await waitFor(() => expect(skipMock).toHaveBeenCalledWith(dispatch));
|
||||
if (confirmed) await waitFor(() => expect(dispatch).toHaveBeenCalledWith({ type: 'DIALOG_CLOSE', name: 'noteLockMigration' }));
|
||||
else expect(dispatch).not.toHaveBeenCalled();
|
||||
expect(startMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import * as React from 'react';
|
||||
import { Dispatch } from 'redux';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import DialogButtonRow, { ClickEvent } from '../DialogButtonRow';
|
||||
import Dialog from '@joplin/lib/components/Dialog';
|
||||
import DialogTitle from '../DialogTitle';
|
||||
import LabelledPasswordInput from '../PasswordInput/LabelledPasswordInput';
|
||||
import { confirmAndSkipNoteLockKeyMigration, startNoteLockKeyMigration } from '@joplin/lib/services/noteLock/NoteLockKeyMigration';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
dispatch: Dispatch;
|
||||
}
|
||||
|
||||
export default function(props: Props) {
|
||||
const [localPassword, setLocalPassword] = useState('');
|
||||
const [targetPassword, setTargetPassword] = useState('');
|
||||
const [migrating, setMigrating] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
props.dispatch({
|
||||
type: 'DIALOG_CLOSE',
|
||||
name: 'noteLockMigration',
|
||||
});
|
||||
}, [props.dispatch]);
|
||||
|
||||
const onLocalPasswordChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setLocalPassword(event.target.value);
|
||||
}, []);
|
||||
|
||||
const onTargetPasswordChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTargetPassword(event.target.value);
|
||||
}, []);
|
||||
|
||||
const onButtonRowClick = useCallback(async (event: ClickEvent) => {
|
||||
if (event.buttonName === 'cancel') {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.buttonName === 'skip') {
|
||||
if (await confirmAndSkipNoteLockKeyMigration(props.dispatch)) onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.buttonName === 'ok') {
|
||||
setMigrating(true);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
await startNoteLockKeyMigration(localPassword, targetPassword, props.dispatch, onClose);
|
||||
} catch (error) {
|
||||
setErrorMessage(error.name === 'OperationError' ? _('Invalid password') : error.message);
|
||||
setMigrating(false);
|
||||
}
|
||||
}
|
||||
}, [localPassword, targetPassword, onClose, props.dispatch]);
|
||||
|
||||
return (
|
||||
<Dialog onCancel={migrating ? undefined : onClose} className="note-lock-migration-dialog">
|
||||
<div className="dialog-root">
|
||||
<DialogTitle title={_('Migrate locked notes')}/>
|
||||
<div className="dialog-content">
|
||||
<p>{_('The sync target uses a different note lock key to the one on your device. Your locked notes must be re-encrypted with the synced key before synchronisation can continue. Locked notes cannot be read while they are being migrated.')}</p>
|
||||
<div className="form">
|
||||
<LabelledPasswordInput
|
||||
labelText={_('Note lock password on this device')}
|
||||
value={localPassword}
|
||||
onChange={onLocalPasswordChange}
|
||||
/>
|
||||
<LabelledPasswordInput
|
||||
labelText={_('Note lock password on the sync target')}
|
||||
value={targetPassword}
|
||||
onChange={onTargetPasswordChange}
|
||||
/>
|
||||
</div>
|
||||
{!!errorMessage && <p className="error-message" role="alert">{errorMessage}</p>}
|
||||
</div>
|
||||
<DialogButtonRow
|
||||
themeId={props.themeId}
|
||||
onClick={onButtonRowClick}
|
||||
okButtonLabel={_('Migrate')}
|
||||
okButtonDisabled={!localPassword || !targetPassword || migrating}
|
||||
cancelButtonDisabled={migrating}
|
||||
customButtons={[{ name: 'skip', label: _('Skip'), disabled: migrating }]}
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,8 @@ interface Props {
|
||||
value: string;
|
||||
inputId: string;
|
||||
onChange: ChangeEventHandler;
|
||||
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
|
||||
inputRef?: React.Ref<HTMLInputElement>;
|
||||
|
||||
'aria-invalid'?: boolean;
|
||||
'aria-errormessage'?: string;
|
||||
@@ -34,6 +36,8 @@ const PasswordInput = (props: Props) => {
|
||||
type={inputType}
|
||||
value={props.value}
|
||||
onChange={props.onChange}
|
||||
onKeyDown={props.onKeyDown}
|
||||
ref={props.inputRef}
|
||||
/>
|
||||
<button type="button" onClick={onShowPassword} className="showpasswordbutton">
|
||||
<i className={icon} role='img' aria-label={title} title={title}></i>
|
||||
|
||||
@@ -9,6 +9,7 @@ import Dialog from '@joplin/lib/components/Dialog';
|
||||
import { ChangeEvent } from 'react';
|
||||
import { formatDateTimeLocalToMs, isValidDate } from '@joplin/utils/time';
|
||||
import lightTheme from '@joplin/lib/themes/light';
|
||||
import PasswordInput from './PasswordInput/PasswordInput';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
@@ -321,6 +322,8 @@ export default class PromptDialog extends React.Component<Props, any> {
|
||||
} else if (this.props.inputType === 'dropdown') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- makeAnimated's generic doesn't unify with react-select's expected SelectComponents shape
|
||||
inputComp = <Select className="item-selector" onMenuOpen={this.select_menuOpen} onMenuClose={this.select_menuClose} styles={styles.select} theme={styles.selectTheme} ref={this.answerInput_} components={makeAnimated() as any} value={this.props.answer} defaultValue={this.props.defaultValue} isClearable={false} options={this.props.autocomplete} onChange={onSelectChange} onKeyDown={onKeyDown} />;
|
||||
} else if (this.props.inputType === 'password') {
|
||||
inputComp = <PasswordInput inputId='prompt-dialog-password' inputRef={this.answerInput_} value={this.state.answer} onChange={event => onChange(event)} onKeyDown={event => onKeyDown(event)} />;
|
||||
} else {
|
||||
inputComp = <input style={styles.input} ref={this.answerInput_} value={this.state.answer} type="text" onChange={event => onChange(event)} onKeyDown={event => onKeyDown(event)} />;
|
||||
}
|
||||
@@ -355,10 +358,12 @@ export default class PromptDialog extends React.Component<Props, any> {
|
||||
);
|
||||
}
|
||||
|
||||
// A password prompt puts its input below the label at a fixed width, shrinking with the window.
|
||||
const isPassword = this.props.inputType === 'password';
|
||||
return (
|
||||
<Dialog className='prompt-dialog' contentStyle={styles.dialog} onCancel={() => onClose(false, 'cancel')}>
|
||||
<label style={styles.label}>{this.props.label ? this.props.label : ''}</label>
|
||||
<div style={{ display: 'inline-block', color: 'black', backgroundColor: theme.backgroundColor }}>
|
||||
<Dialog className='prompt-dialog' contentStyle={isPassword ? { boxSizing: 'border-box', width: 500, maxWidth: '100%' } : styles.dialog} onCancel={() => onClose(false, 'cancel')}>
|
||||
<label style={isPassword ? { ...styles.label, display: 'block', marginBottom: 10 } : styles.label}>{this.props.label ? this.props.label : ''}</label>
|
||||
<div style={{ display: isPassword ? 'block' : 'inline-block', color: 'black', backgroundColor: theme.backgroundColor }}>
|
||||
{inputComp}
|
||||
{descComp}
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,9 @@ import { AppState } from '../app.reducer';
|
||||
import { localSyncInfoFromState } from '@joplin/lib/services/synchronizer/syncInfoUtils';
|
||||
import EncryptionService from '@joplin/lib/services/e2ee/EncryptionService';
|
||||
import { showMissingMasterKeyMessage, showUnknownKeyFormatBanner } from '@joplin/lib/services/e2ee/utils';
|
||||
import { noteLockKeyConflictFromState } from '@joplin/lib/services/synchronizer/syncInfoUtils';
|
||||
import { NoteLockMigrationStatus } from '@joplin/lib/reducer';
|
||||
import { confirmAndSkipNoteLockKeyMigration, notMigratedMessage, withSkippedCount } from '@joplin/lib/services/noteLock/NoteLockKeyMigration';
|
||||
import shouldShowMissingPasswordWarning from '@joplin/lib/components/shared/config/shouldShowMissingPasswordWarning';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
@@ -34,6 +37,8 @@ interface Props {
|
||||
hasDisabledSyncItems: boolean;
|
||||
showUnknownKeyFormatMessage: boolean;
|
||||
showMissingMasterKeyMessage: boolean;
|
||||
showNoteLockKeyConflictMessage: boolean;
|
||||
noteLockMigrationStatus: NoteLockMigrationStatus|null;
|
||||
mustUpgradeAppMessage: string;
|
||||
syncTargetAppMinVersion: string;
|
||||
shouldSwitchToAppleSiliconVersion: boolean;
|
||||
@@ -76,6 +81,20 @@ const WarningBanner: React.FC<Props> = props => {
|
||||
});
|
||||
};
|
||||
|
||||
const onMigrateLockedNotes = () => {
|
||||
props.dispatch({
|
||||
type: 'DIALOG_OPEN',
|
||||
name: 'noteLockMigration',
|
||||
});
|
||||
};
|
||||
|
||||
const onDismissNoteLockMigrationStatus = () => {
|
||||
props.dispatch({
|
||||
type: 'NOTE_LOCK_MIGRATION_STATUS_SET',
|
||||
value: null,
|
||||
});
|
||||
};
|
||||
|
||||
const onViewJoplinCloudLoginScreen = () => {
|
||||
props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
@@ -143,6 +162,28 @@ const WarningBanner: React.FC<Props> = props => {
|
||||
_('Set the password'),
|
||||
onViewSyncSettingsScreen,
|
||||
);
|
||||
} else if (props.noteLockMigrationStatus?.running) {
|
||||
msg = renderNotificationMessage(_('Re-encrypting your locked notes with the synced key. Migration is in progress...'));
|
||||
} else if (props.noteLockMigrationStatus?.failed || props.noteLockMigrationStatus?.awaitingDecryption) {
|
||||
msg = renderNotificationMessage(
|
||||
notMigratedMessage(props.noteLockMigrationStatus),
|
||||
_('Retry'),
|
||||
onMigrateLockedNotes,
|
||||
_('Skip'),
|
||||
() => void confirmAndSkipNoteLockKeyMigration(props.dispatch),
|
||||
);
|
||||
} else if (props.noteLockMigrationStatus) {
|
||||
msg = renderNotificationMessage(
|
||||
withSkippedCount(_('The migration of your locked notes has completed.'), props.noteLockMigrationStatus),
|
||||
_('Dismiss'),
|
||||
onDismissNoteLockMigrationStatus,
|
||||
);
|
||||
} else if (props.showNoteLockKeyConflictMessage) {
|
||||
msg = renderNotificationMessage(
|
||||
_('Synchronisation is stopped because the sync target uses a different note lock key to the one on your device.'),
|
||||
_('Migrate your locked notes'),
|
||||
onMigrateLockedNotes,
|
||||
);
|
||||
} else if (props.shouldUpgradeSyncTarget) {
|
||||
msg = renderNotificationMessage(
|
||||
_('The sync target needs to be upgraded before Joplin can sync. The operation may take a few minutes to complete and the app needs to be restarted. To proceed please click on the link.'),
|
||||
@@ -291,6 +332,8 @@ const mapStateToProps = (state: AppState) => {
|
||||
hasDisabledEncryptionItems: state.hasDisabledEncryptionItems,
|
||||
showMissingMasterKeyMessage: showMissingMasterKeyMessage(syncInfo, state.notLoadedMasterKeys),
|
||||
showUnknownKeyFormatMessage: showUnknownKeyFormatBanner(syncInfo),
|
||||
showNoteLockKeyConflictMessage: noteLockKeyConflictFromState(state),
|
||||
noteLockMigrationStatus: state.noteLockMigrationStatus,
|
||||
showNeedUpgradingMasterKeyMessage: showNeedUpgradingEnabledMasterKeyMessage,
|
||||
showShouldReencryptMessage: state.settings['encryption.shouldReencrypt'] >= Setting.SHOULD_REENCRYPT_YES,
|
||||
shouldUpgradeSyncTarget: state.settings['sync.upgradeState'] === Setting.SYNC_UPGRADE_STATE_SHOULD_DO,
|
||||
|
||||
@@ -65,6 +65,6 @@ export const runtime = (): CommandRuntime => {
|
||||
bridge().showErrorMessageBox(error.message);
|
||||
}
|
||||
},
|
||||
enabledCondition: 'oneNoteSelected && !noteIsLocked && !noteIsReadOnly && !noteIsDeleted && !inTrash && !inConflictFolder',
|
||||
enabledCondition: 'oneNoteSelected && !noteIsLocked && !noteIsReadOnly && !noteIsDeleted && !inTrash && !inConflictFolder && !noteLockMigrationRunning',
|
||||
};
|
||||
};
|
||||
@@ -19,6 +19,13 @@ export const runtime = (comp: WindowControl): CommandRuntime => {
|
||||
|
||||
if (!noteIds.length) throw new Error('No notes selected for pdf export');
|
||||
|
||||
const lockedNoteChoice = await InteropServiceHelper.confirmLockedNoteExport(context.dispatch, noteIds);
|
||||
if (lockedNoteChoice === 'cancel') return;
|
||||
if (lockedNoteChoice === 'skipLocked') {
|
||||
noteIds = (await Note.byIds(noteIds, { fields: ['id', 'is_locked'] })).filter(note => !note.is_locked).map(note => note.id);
|
||||
if (!noteIds.length) return;
|
||||
}
|
||||
|
||||
let path = null;
|
||||
if (noteIds.length === 1) {
|
||||
path = await bridge().showSaveDialog({
|
||||
@@ -61,6 +68,6 @@ export const runtime = (comp: WindowControl): CommandRuntime => {
|
||||
}
|
||||
},
|
||||
|
||||
enabledCondition: 'someNotesSelected',
|
||||
enabledCondition: 'someNotesSelected && (!noteLockContentUnavailable || !noteLockSessionUnlocked)',
|
||||
};
|
||||
};
|
||||
@@ -12,10 +12,25 @@ import shim from '@joplin/lib/shim';
|
||||
import { ImportModule } from '@joplin/lib/services/interop/Module';
|
||||
import { isRecoverableError } from '@joplin/lib/import-enex';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { Dispatch } from 'redux';
|
||||
import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types';
|
||||
import { promptForImportedNoteLockKey } from '@joplin/lib/services/noteLock/noteLockPrompts';
|
||||
import noteLockPrompts from '../../../utils/noteLockPrompts';
|
||||
import { ErrorCode } from '@joplin/lib/errors';
|
||||
const packageInfo: PackageInfo = require('../../../packageInfo.js');
|
||||
|
||||
const logger = Logger.create('importFrom');
|
||||
|
||||
const makeImportNoteLockKeyHandler = (modalMessage: string, dispatch: Dispatch) => async (backupKey: MasterKeyEntity) => {
|
||||
// The password prompts are DOM dialogs and would render behind the import modal overlay.
|
||||
void CommandService.instance().execute('hideModalMessage');
|
||||
try {
|
||||
return await promptForImportedNoteLockKey(backupKey, noteLockPrompts(dispatch));
|
||||
} finally {
|
||||
void CommandService.instance().execute('showModalMessage', modalMessage);
|
||||
}
|
||||
};
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'importFrom',
|
||||
label: () => _('Import...'),
|
||||
@@ -143,6 +158,7 @@ export const runtime = (control: WindowControl): CommandRuntime => {
|
||||
console.warn(error);
|
||||
},
|
||||
destinationFolderId: options.destinationFolderId,
|
||||
onNoteLockKey: makeImportNoteLockKeyHandler(modalMessage, context.dispatch),
|
||||
};
|
||||
|
||||
const service = InteropService.instance();
|
||||
@@ -151,8 +167,10 @@ export const runtime = (control: WindowControl): CommandRuntime => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.info('Import result: ', result);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
bridge().showErrorMessageBox(error.message);
|
||||
if (error.code !== ErrorCode.Cancelled) {
|
||||
logger.error(error);
|
||||
bridge().showErrorMessageBox(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
void CommandService.instance().execute('hideModalMessage');
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { WindowControl } from '../utils/useWindowControl';
|
||||
import bridge from '../../../services/bridge';
|
||||
import InteropServiceHelper from '../../../InteropServiceHelper';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'print',
|
||||
@@ -16,6 +17,8 @@ export const runtime = (comp: WindowControl): CommandRuntime => {
|
||||
|
||||
try {
|
||||
if (noteIds.length !== 1) throw new Error(_('Only one note can be printed at a time.'));
|
||||
// A plugin can run this with any note, so a locked one is only printed once the user agrees, as for the PDF export.
|
||||
if (await InteropServiceHelper.confirmLockedNoteExport(context.dispatch, noteIds) !== 'export') return;
|
||||
await comp.printTo('printer', { noteId: noteIds[0] });
|
||||
} catch (error) {
|
||||
bridge().showErrorMessageBox(error.message);
|
||||
|
||||
@@ -10,6 +10,7 @@ enum PromptInputType {
|
||||
Datetime = 'datetime',
|
||||
Tags = 'tags',
|
||||
Text = 'text',
|
||||
Password = 'password',
|
||||
}
|
||||
|
||||
interface PromptConfig {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Dispatch } from 'redux';
|
||||
import SyncWizardDialog from '../../SyncWizard/Dialog';
|
||||
import MasterPasswordDialog from '../../MasterPasswordDialog/Dialog';
|
||||
import NoteLockUnlockDialog from '../../NoteLockUnlockDialog/Dialog';
|
||||
import NoteLockMigrationDialog from '../../NoteLockMigrationDialog/Dialog';
|
||||
import EditFolderDialog from '../../EditFolderDialog/Dialog';
|
||||
import PdfViewer from '../../PdfViewer';
|
||||
|
||||
@@ -40,6 +41,13 @@ const appDialogs: Record<string, RegisteredDialog> = {
|
||||
},
|
||||
},
|
||||
|
||||
noteLockMigration: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- See RegisteredDialog.render
|
||||
render: (props: RegisteredDialogProps, customProps: any) => {
|
||||
return <NoteLockMigrationDialog key={props.key} dispatch={props.dispatch} themeId={props.themeId} {...customProps}/>;
|
||||
},
|
||||
},
|
||||
|
||||
editFolder: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- See RegisteredDialog.render
|
||||
render: (props: RegisteredDialogProps, customProps: any) => {
|
||||
|
||||
@@ -319,7 +319,13 @@ Component-specific classes
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.master-password-dialog .dialog-content, .enable-encryption-dialog .dialog-content, .disable-encryption-dialog .dialog-content {
|
||||
.note-lock-migration-dialog > .content {
|
||||
box-sizing: border-box;
|
||||
width: 500px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.master-password-dialog .dialog-content, .enable-encryption-dialog .dialog-content, .disable-encryption-dialog .dialog-content, .note-lock-migration-dialog .dialog-content {
|
||||
background-color: var(--joplin-background-color3);
|
||||
padding: 1em;
|
||||
padding-bottom: 1px;
|
||||
|
||||
@@ -207,7 +207,7 @@
|
||||
"md5": "2.3.0",
|
||||
"moment": "2.30.1",
|
||||
"mustache": "4.2.0",
|
||||
"nan": "2.27.0",
|
||||
"nan": "2.28.0",
|
||||
"node-notifier": "10.0.1",
|
||||
"node-rsa": "1.1.1",
|
||||
"pdfjs-dist": "3.11.174",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Dispatch } from 'redux';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import { NoteLockPrompts } from '@joplin/lib/services/noteLock/noteLockPrompts';
|
||||
|
||||
const noteLockPrompts = (dispatch: Dispatch): NoteLockPrompts => ({
|
||||
promptPassword: async label => {
|
||||
const result: { answer: string|null } = await CommandService.instance().execute('showPrompt', { label, inputType: 'password' });
|
||||
return result.answer;
|
||||
},
|
||||
goToNoteLockSetup: () => {
|
||||
dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Config',
|
||||
props: { defaultSection: 'noteLock' },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default noteLockPrompts;
|
||||
+11
-9
@@ -45,7 +45,7 @@ public class SharePackage implements ReactPackage {
|
||||
}
|
||||
|
||||
public static class ShareModule extends ReactContextBaseJavaModule implements ActivityEventListener, LifecycleEventListener {
|
||||
private boolean handledStartIntent = false;
|
||||
private Activity handledStartIntentActivity = null;
|
||||
private Intent receivedShareIntent = null;
|
||||
|
||||
ShareModule(@NonNull ReactApplicationContext reactContext) {
|
||||
@@ -206,15 +206,16 @@ public class SharePackage implements ReactPackage {
|
||||
|
||||
@Override
|
||||
public void onHostResume() {
|
||||
if (this.getCurrentActivity() != null) {
|
||||
Intent intent = this.getCurrentActivity().getIntent();
|
||||
if (this.handledStartIntent) {
|
||||
// sometimes onHostResume is fired after onNewIntent
|
||||
// and we only care about the activity intent when the first time app opens
|
||||
return;
|
||||
Activity currentActivity = this.getCurrentActivity();
|
||||
if (currentActivity != null && currentActivity != this.handledStartIntentActivity) {
|
||||
// Read the start intent once per activity. A later onNewIntent may be
|
||||
// followed by onHostResume, while a share after Back may create a new activity.
|
||||
this.handledStartIntentActivity = currentActivity;
|
||||
Intent intent = currentActivity.getIntent();
|
||||
// Recents can relaunch the original share intent, but it is not a new share.
|
||||
if (intent != null && (intent.getFlags() & Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) == 0) {
|
||||
this.onNewIntent(intent);
|
||||
}
|
||||
this.handledStartIntent = true;
|
||||
this.onNewIntent(intent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,6 +224,7 @@ public class SharePackage implements ReactPackage {
|
||||
|
||||
@Override
|
||||
public void onHostDestroy() {
|
||||
this.handledStartIntentActivity = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,8 @@ const TextInputDialog: React.FC<Props> = ({ dialog, containerStyle, themeId }) =
|
||||
style={styles.textInput}
|
||||
value={text}
|
||||
onChangeText={setText}
|
||||
secureTextEntry={dialog.secureTextEntry}
|
||||
autoCapitalize={dialog.secureTextEntry ? 'none' : undefined}
|
||||
// Underline styles are set via styles
|
||||
underlineColorAndroid='transparent'
|
||||
/>
|
||||
|
||||
@@ -92,7 +92,7 @@ const useDialogControl = (setPromptDialogs: SetPromptDialogs) => {
|
||||
});
|
||||
});
|
||||
},
|
||||
promptForText: (message: string, initialValue?: string) => {
|
||||
promptForText: (message: string, initialValue?: string, secureTextEntry?: boolean) => {
|
||||
return new Promise<string|null>((resolve) => {
|
||||
const dismiss = () => {
|
||||
onDismiss(dialog);
|
||||
@@ -103,6 +103,7 @@ const useDialogControl = (setPromptDialogs: SetPromptDialogs) => {
|
||||
key: `prompt-dialog-${nextDialogIdRef.current++}`,
|
||||
message,
|
||||
initialValue,
|
||||
secureTextEntry,
|
||||
onSubmit: (text) => {
|
||||
resolve(text);
|
||||
dismiss();
|
||||
|
||||
@@ -24,7 +24,7 @@ export interface DialogControl {
|
||||
info(message: string): Promise<void>;
|
||||
error(message: string): Promise<void>;
|
||||
prompt(title: string, message: string, buttons?: PromptButtonSpec[], options?: PromptOptions): void;
|
||||
promptForText(message: string, initialValue?: string): Promise<string>;
|
||||
promptForText(message: string, initialValue?: string, secureTextEntry?: boolean): Promise<string>;
|
||||
showMenu<IdType>(title: string, choices: MenuChoice<IdType>[]): Promise<IdType>;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface TextInputDialogData {
|
||||
key: string;
|
||||
message: string;
|
||||
initialValue?: string;
|
||||
secureTextEntry?: boolean;
|
||||
onSubmit: (text: string)=> void;
|
||||
onDismiss: ()=> void;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Store } from 'redux';
|
||||
import createMockReduxStore from '../../utils/testing/createMockReduxStore';
|
||||
import setupGlobalStore from '../../utils/testing/setupGlobalStore';
|
||||
import MasterKey from '@joplin/lib/models/MasterKey';
|
||||
import shim from '@joplin/lib/shim';
|
||||
|
||||
interface WrapperProps {
|
||||
store: Store<AppState>;
|
||||
@@ -136,6 +137,47 @@ describe('WarningBanner', () => {
|
||||
expect(screen.getByText(query)).toBeVisible();
|
||||
});
|
||||
|
||||
test('the note lock key conflict banner should link to the migration screen', () => {
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
Setting.setValue('noteLock.conflictNoteLockKey', { noteLockKey: { id: 'sync-target-key' }, syncMigrationId: 'lineage' });
|
||||
const mock = createMockStore();
|
||||
|
||||
render(<WarningBannerWrapper store={mock.store}/>);
|
||||
const banner = screen.getByText(/different note lock key/);
|
||||
expect(banner).toBeVisible();
|
||||
fireEvent.press(banner);
|
||||
|
||||
expect(mock.getRouteName()).toBe('NoteLockMigration');
|
||||
});
|
||||
|
||||
test('the note lock migration banner should follow the run and be dismissed once it has completed', () => {
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
Setting.setValue('noteLock.conflictNoteLockKey', { noteLockKey: { id: 'sync-target-key' }, syncMigrationId: 'lineage' });
|
||||
const mock = createMockStore();
|
||||
const setStatus = (value: unknown) => act(() => {
|
||||
mock.store.dispatch({ type: 'NOTE_LOCK_MIGRATION_STATUS_SET', value });
|
||||
});
|
||||
render(<WarningBannerWrapper store={mock.store}/>);
|
||||
|
||||
setStatus({ running: true, failed: 0, skipped: 0 });
|
||||
expect(screen.getByText(/Migration is in progress/)).toBeVisible();
|
||||
expect(screen.queryByText(/different note lock key/)).toBeNull();
|
||||
|
||||
setStatus({ running: false, failed: 2, skipped: 0 });
|
||||
expect(screen.getByText(/2 locked notes could not be migrated/)).toBeVisible();
|
||||
const showMessageBox = jest.spyOn(shim, 'showMessageBox').mockResolvedValue(1);
|
||||
fireEvent.press(screen.getByText('Skip'));
|
||||
expect(showMessageBox).toHaveBeenCalled();
|
||||
showMessageBox.mockRestore();
|
||||
fireEvent.press(screen.getByText('Retry'));
|
||||
expect(mock.getRouteName()).toBe('NoteLockMigration');
|
||||
|
||||
setStatus({ running: false, failed: 0, skipped: 1 });
|
||||
expect(screen.getByText(/has completed.*1 locked note was skipped/)).toBeVisible();
|
||||
fireEvent.press(screen.getByText(/has completed/));
|
||||
expect(mock.store.getState().noteLockMigrationStatus).toBeNull();
|
||||
});
|
||||
|
||||
test('invalid credentials banner for Joplin Cloud should link to a login screen', () => {
|
||||
Setting.setValue('sync.target', 10);
|
||||
const mock = createMockStore();
|
||||
|
||||
@@ -3,10 +3,14 @@ import { useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Platform } from 'react-native';
|
||||
import { AppState } from '../../utils/types';
|
||||
import WarningBox, { WarningBoxTarget } from './WarningBox';
|
||||
import WarningBox, { WarningBoxAction, WarningBoxTarget } from './WarningBox';
|
||||
import NavService from '@joplin/lib/services/NavService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { showMissingMasterKeyMessage, showUnknownKeyFormatBanner } from '@joplin/lib/services/e2ee/utils';
|
||||
import { localSyncInfoFromState } from '@joplin/lib/services/synchronizer/syncInfoUtils';
|
||||
import { localSyncInfoFromState, noteLockKeyConflictFromState } from '@joplin/lib/services/synchronizer/syncInfoUtils';
|
||||
import { NoteLockMigrationStatus } from '@joplin/lib/reducer';
|
||||
import { confirmAndSkipNoteLockKeyMigration, notMigratedMessage, withSkippedCount } from '@joplin/lib/services/noteLock/NoteLockKeyMigration';
|
||||
import { Dispatch } from 'redux';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { ShareInvitation, ShareUserStatus } from '@joplin/lib/services/share/reducer';
|
||||
import { substrWithEllipsis } from '@joplin/lib/string-utils';
|
||||
@@ -30,6 +34,10 @@ interface Props {
|
||||
shareInvitations: ShareInvitation[];
|
||||
processingShareInvitationResponse: boolean;
|
||||
showInvalidJoplinCloudCredential: boolean;
|
||||
noteLockKeyConflict: boolean;
|
||||
showNoteLockKeyConflictMessage: boolean|undefined;
|
||||
noteLockMigrationStatus: NoteLockMigrationStatus|null;
|
||||
dispatch: Dispatch;
|
||||
}
|
||||
|
||||
const androidGooglePlayUrl = 'https://play.google.com/store/apps/details?id=net.cozic.joplin';
|
||||
@@ -60,13 +68,14 @@ const WarningBannerComponent: React.FC<Props> = props => {
|
||||
}
|
||||
}, [props.mustUpgradeAppMessage, props.syncTargetAppMinVersion]);
|
||||
|
||||
const renderWarningBox = (key: string, message: string, target: WarningBoxTarget) => {
|
||||
const renderWarningBox = (key: string, message: string, target: WarningBoxTarget, actions?: WarningBoxAction[]) => {
|
||||
return <WarningBox
|
||||
key={key}
|
||||
themeId={props.themeId}
|
||||
target={target}
|
||||
message={message}
|
||||
testID='warning-box'
|
||||
actions={actions}
|
||||
/>;
|
||||
};
|
||||
|
||||
@@ -128,6 +137,22 @@ const WarningBannerComponent: React.FC<Props> = props => {
|
||||
if (props.hasDisabledEncryptionItems) {
|
||||
warningComps.push(renderWarningBox('cannotDecrypt', _('Some items cannot be decrypted.'), { screen: 'Status' }));
|
||||
}
|
||||
const migration = props.noteLockMigrationStatus;
|
||||
if (props.showNoteLockKeyConflictMessage !== false) {
|
||||
if (migration?.running) {
|
||||
warningComps.push(renderWarningBox('noteLockMigration', _('Re-encrypting your locked notes with the synced key. Migration is in progress...'), null));
|
||||
} else if (migration?.failed || migration?.awaitingDecryption) {
|
||||
warningComps.push(renderWarningBox('noteLockMigration', notMigratedMessage(migration), null, [
|
||||
{ title: _('Retry'), onPress: () => void NavService.go('NoteLockMigration') },
|
||||
{ title: _('Skip'), onPress: () => void confirmAndSkipNoteLockKeyMigration(props.dispatch) },
|
||||
]));
|
||||
} else if (migration) {
|
||||
const onDismiss = () => props.dispatch({ type: 'NOTE_LOCK_MIGRATION_STATUS_SET', value: null });
|
||||
warningComps.push(renderWarningBox('noteLockMigration', `${withSkippedCount(_('The migration of your locked notes has completed.'), migration)} ${_('Press to dismiss.')}`, { onPress: onDismiss }));
|
||||
} else if (props.noteLockKeyConflict) {
|
||||
warningComps.push(renderWarningBox('noteLockMigration', _('The sync target uses a different note lock key to the one on your device. Press to migrate your locked notes.'), { screen: 'NoteLockMigration' }));
|
||||
}
|
||||
}
|
||||
if (props.showInvalidJoplinCloudCredential) {
|
||||
const target = { screen: 'JoplinCloudLogin' };
|
||||
warningComps.push(renderWarningBox('syncLogin', _('Your Joplin Cloud credentials are invalid, please login.'), target));
|
||||
@@ -185,5 +210,7 @@ export default connect((state: AppState) => {
|
||||
shareInvitations: state.shareService.shareInvitations,
|
||||
processingShareInvitationResponse: state.shareService.processingShareInvitationResponse,
|
||||
showInvalidJoplinCloudCredential: state.settings['sync.target'] === 10 && !isSyncLoginRoute(state) && state.mustAuthenticate,
|
||||
noteLockKeyConflict: noteLockKeyConflictFromState(state),
|
||||
noteLockMigrationStatus: state.noteLockMigrationStatus,
|
||||
};
|
||||
})(WarningBannerComponent);
|
||||
@@ -17,13 +17,26 @@ interface ScreenTarget {
|
||||
url?: undefined;
|
||||
}
|
||||
|
||||
export type WarningBoxTarget = UrlTarget|ScreenTarget;
|
||||
interface CallbackTarget {
|
||||
onPress: ()=> void;
|
||||
|
||||
url?: undefined;
|
||||
screen?: undefined;
|
||||
}
|
||||
|
||||
export type WarningBoxTarget = UrlTarget|ScreenTarget|CallbackTarget;
|
||||
|
||||
export interface WarningBoxAction {
|
||||
title: string;
|
||||
onPress: ()=> void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
target: WarningBoxTarget;
|
||||
message: string;
|
||||
testID?: string;
|
||||
actions?: WarningBoxAction[];
|
||||
}
|
||||
|
||||
const useStyles = (themeId: number) => {
|
||||
@@ -39,6 +52,12 @@ const useStyles = (themeId: number) => {
|
||||
flex: 1,
|
||||
color: 'black',
|
||||
},
|
||||
actionText: {
|
||||
color: 'black',
|
||||
fontWeight: 'bold',
|
||||
textDecorationLine: 'underline',
|
||||
marginLeft: theme.marginLeft,
|
||||
},
|
||||
});
|
||||
}, [themeId]);
|
||||
};
|
||||
@@ -56,6 +75,8 @@ const WarningBox: React.FC<Props> = props => {
|
||||
const isUrlTarget = (target: WarningBoxTarget): target is UrlTarget => !!target.url;
|
||||
if (isUrlTarget(target)) {
|
||||
void Linking.openURL(target.url);
|
||||
} else if ('onPress' in target) {
|
||||
target.onPress();
|
||||
} else {
|
||||
void NavService.go(target.screen, target.screenProps);
|
||||
}
|
||||
@@ -63,6 +84,18 @@ const WarningBox: React.FC<Props> = props => {
|
||||
|
||||
const hasTarget = !!propsRef.current.target;
|
||||
const bannerContent = <Text style={styles.text}>{props.message}</Text>;
|
||||
if (props.actions) {
|
||||
return (
|
||||
<View style={styles.container} testID={props.testID}>
|
||||
{bannerContent}
|
||||
{props.actions.map(action => (
|
||||
<TouchableOpacity key={action.title} onPress={action.onPress} accessibilityRole='button'>
|
||||
<Text style={styles.actionText}>{action.title}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (hasTarget) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
|
||||
@@ -86,6 +86,7 @@ interface ScreenHeaderProps {
|
||||
|
||||
historyCanGoBack?: boolean;
|
||||
showShouldUpgradeSyncTargetMessage?: boolean;
|
||||
showNoteLockKeyConflictMessage?: boolean;
|
||||
|
||||
themeId: number;
|
||||
}
|
||||
@@ -731,6 +732,7 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
|
||||
</View>
|
||||
<WarningBanner
|
||||
showShouldUpgradeSyncTargetMessage={this.props.showShouldUpgradeSyncTargetMessage}
|
||||
showNoteLockKeyConflictMessage={this.props.showNoteLockKeyConflictMessage}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -583,7 +583,7 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
|
||||
const importJexLabel = () => _('Import from JEX');
|
||||
const importJexDescription = () => _('Import notes from a JEX (Joplin Export) file.');
|
||||
addSettingComponent(
|
||||
<NoteImportButton key='import_as_jex_button' styles={this.styles()} defaultTitle={importJexLabel()} description={importJexDescription()} format='jex' />,
|
||||
<NoteImportButton key='import_as_jex_button' styles={this.styles()} defaultTitle={importJexLabel()} description={importJexDescription()} format='jex' onOpenNoteLockSetup={() => this.onJumpToSection_('noteLock')} />,
|
||||
[importJexLabel(), importJexDescription()],
|
||||
);
|
||||
const importTxtLabel = () => _('Import from text file');
|
||||
@@ -593,7 +593,7 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
|
||||
return _('Import a note from a text file (%s). The note will be imported into notebook \'%s\'.', textImportExtensions.join(', '), substrWithEllipsis(folderTitle, 0, 32));
|
||||
};
|
||||
addSettingComponent(
|
||||
<NoteImportButton key='import_as_txt_button' styles={this.styles()} defaultTitle={importTxtLabel()} description={importTxtDescription()} format='txt' activeFolder={this.state.activeFolder} />,
|
||||
<NoteImportButton key='import_as_txt_button' styles={this.styles()} defaultTitle={importTxtLabel()} description={importTxtDescription()} format='txt' activeFolder={this.state.activeFolder} onOpenNoteLockSetup={() => this.onJumpToSection_('noteLock')} />,
|
||||
[importTxtLabel(), importTxtDescription()],
|
||||
);
|
||||
addSettingComponent(
|
||||
|
||||
+17
-1
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { FunctionComponent, useContext } from 'react';
|
||||
import { join, basename } from 'path';
|
||||
import { ConfigScreenStyles } from '../configScreenStyles';
|
||||
import InteropService from '@joplin/lib/services/interop/InteropService';
|
||||
@@ -13,6 +13,9 @@ import { Platform } from 'react-native';
|
||||
import { FolderEntity } from '@joplin/lib/services/database/types';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import { fileExtension } from '@joplin/lib/path-utils';
|
||||
import { NoteLockPrompts, promptForImportedNoteLockKey } from '@joplin/lib/services/noteLock/noteLockPrompts';
|
||||
import { ErrorCode } from '@joplin/lib/errors';
|
||||
import { DialogContext } from '../../../DialogManager';
|
||||
|
||||
const logger = Logger.create('NoteImportButton');
|
||||
|
||||
@@ -22,6 +25,8 @@ interface Props {
|
||||
description: string;
|
||||
format: string;
|
||||
activeFolder?: FolderEntity;
|
||||
// Navigating to the Config screen does nothing while it is already open, so the screen switches its own section.
|
||||
onOpenNoteLockSetup: ()=> void;
|
||||
}
|
||||
|
||||
export const importedFolderTitle = () => {
|
||||
@@ -42,6 +47,12 @@ const importedFolder = async () => {
|
||||
};
|
||||
|
||||
const NoteImportButton: FunctionComponent<Props> = props => {
|
||||
const dialogs = useContext(DialogContext);
|
||||
const noteLockPrompts: NoteLockPrompts = {
|
||||
promptPassword: label => dialogs.promptForText(label, '', true),
|
||||
goToNoteLockSetup: props.onOpenNoteLockSetup,
|
||||
};
|
||||
|
||||
const getTitle = (taskStatus: TaskStatus) => {
|
||||
if (taskStatus === TaskStatus.InProgress) {
|
||||
return _('Importing...');
|
||||
@@ -96,11 +107,16 @@ const NoteImportButton: FunctionComponent<Props> = props => {
|
||||
path: importTargetPath,
|
||||
format: props.format,
|
||||
destinationFolderId: activeFolderId,
|
||||
onNoteLockKey: backupKey => promptForImportedNoteLockKey(backupKey, noteLockPrompts),
|
||||
});
|
||||
|
||||
logger.info('Imported successfully');
|
||||
return { success: true, warnings: status.warnings };
|
||||
} catch (error) {
|
||||
if (error.code === ErrorCode.Cancelled) {
|
||||
logger.info('Canceled.');
|
||||
return { success: false, warnings: [] };
|
||||
}
|
||||
logger.error('Import failed with error', error);
|
||||
throw new Error(_('Import failed. Make sure a %s file was selected.\nDetails: %s', props.format, error.toString()));
|
||||
}
|
||||
|
||||
@@ -476,6 +476,9 @@ describe('screens/Note/Note', () => {
|
||||
});
|
||||
|
||||
expect(screen.getByDisplayValue('Shared then locked')).toBeDisabled();
|
||||
await act(() => waitForWithRealTimers(async () => {
|
||||
expect((await Note.load(noteId)).is_locked).toBe(1);
|
||||
}));
|
||||
|
||||
unmount();
|
||||
Setting.setValue('featureFlag.noteLock', false);
|
||||
|
||||
@@ -39,6 +39,7 @@ import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
|
||||
import NoteLockKey, { DecryptedNoteLockKey } from '@joplin/lib/services/noteLock/NoteLockKey';
|
||||
import NoteLockNote from '@joplin/lib/services/noteLock/NoteLockNote';
|
||||
import { disableNoteLock, enableNoteLock } from '@joplin/lib/services/noteLock/setNoteLockState';
|
||||
import { ErrorCode } from '@joplin/lib/errors';
|
||||
import eventManager, { EventName, NoteLockNoteStateChangeEvent } from '@joplin/lib/eventManager';
|
||||
import NoteLockPanel from './NoteLockPanel';
|
||||
import DismissibleDialog, { DialogVariant } from '../../DismissibleDialog';
|
||||
@@ -135,6 +136,7 @@ interface Props extends BaseProps {
|
||||
canPublish: boolean;
|
||||
noteVisiblePanes: string[];
|
||||
noteLockSessionUnlocked: boolean;
|
||||
noteLockMigrationRunning: boolean;
|
||||
}
|
||||
|
||||
interface ComponentProps extends Props {
|
||||
@@ -974,7 +976,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
}
|
||||
} catch (error) {
|
||||
reg.logger().warn('Could not change the note lock state:', error);
|
||||
await this.props.dialogs.error(_('Could not update the note lock. Please try again.'));
|
||||
await this.props.dialogs.error(error.code === ErrorCode.NoteLockKeyConflict ? error.message : _('Could not update the note lock. Please try again.'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1459,7 +1461,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
|
||||
const pluginCommands = pluginUtils.commandNamesFromViews(this.props.plugins, 'noteToolbar');
|
||||
|
||||
const cacheKey = md5([isTodo, isSaved, pluginCommands.join(','), readOnly, this.state.mode, isCodeView, note.is_locked, this.props.noteLockSessionUnlocked, !!this.state.noteLockKey, this.state.noteLockUndecryptable].join('_'));
|
||||
const cacheKey = md5([isTodo, isSaved, pluginCommands.join(','), readOnly, this.state.mode, isCodeView, note.is_locked, this.props.noteLockSessionUnlocked, !!this.state.noteLockKey, this.state.noteLockUndecryptable, this.props.noteLockMigrationRunning].join('_'));
|
||||
if (!this.menuOptionsCache_) this.menuOptionsCache_ = {};
|
||||
|
||||
if (this.menuOptionsCache_[cacheKey]) return this.menuOptionsCache_[cacheKey];
|
||||
@@ -1591,7 +1593,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
onPress: () => {
|
||||
this.enableNoteEncryption_onPress();
|
||||
},
|
||||
disabled: readOnly,
|
||||
disabled: readOnly || this.props.noteLockMigrationRunning,
|
||||
});
|
||||
} else {
|
||||
output.push({
|
||||
@@ -1923,6 +1925,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
themeId={this.props.themeId}
|
||||
hasNoteLockKey={!!NoteLockKey.instance().load()}
|
||||
undecryptable={this.state.noteLockUndecryptable}
|
||||
migrationRunning={this.props.noteLockMigrationRunning}
|
||||
/>;
|
||||
} else if (editorView) {
|
||||
bodyComponent = renderPluginEditor();
|
||||
@@ -2238,6 +2241,7 @@ const NoteScreen = connect((state: AppState) => {
|
||||
useEditorBeta: !state.settings['editor.usePlainText'],
|
||||
canPublish: whenClause.joplinServerConnected && !whenClause.inTrash,
|
||||
noteLockSessionUnlocked: state.noteLockSessionUnlocked,
|
||||
noteLockMigrationRunning: !!state.noteLockMigrationStatus?.running,
|
||||
};
|
||||
})(NoteScreenWrapper);
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ interface Props {
|
||||
themeId: number;
|
||||
hasNoteLockKey: boolean;
|
||||
undecryptable?: boolean;
|
||||
migrationRunning?: boolean;
|
||||
onUnlocked?: ()=> void;
|
||||
}
|
||||
|
||||
@@ -94,6 +95,10 @@ const NoteLockPanel = (props: Props) => {
|
||||
}, [password, unlocking, props.onUnlocked]);
|
||||
|
||||
const renderForm = () => {
|
||||
if (props.migrationRunning) {
|
||||
return <Text style={styles.message}>{_('This note cannot be read while a note lock key migration is in progress.')}</Text>;
|
||||
}
|
||||
|
||||
if (props.undecryptable) {
|
||||
return (
|
||||
<Text style={styles.message}>{_('This note could not be unlocked. If it was locked prior to a password reset, the content is no longer recoverable.')}</Text>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import * as React from 'react';
|
||||
import { Store } from 'redux';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
import { confirmAndSkipNoteLockKeyMigration, startNoteLockKeyMigration } from '@joplin/lib/services/noteLock/NoteLockKeyMigration';
|
||||
import { NoteLockMigrationScreenComponent } from './NoteLockMigrationScreen';
|
||||
import TestProviderStack from '../testing/TestProviderStack';
|
||||
import createMockReduxStore from '../../utils/testing/createMockReduxStore';
|
||||
import setupGlobalStore from '../../utils/testing/setupGlobalStore';
|
||||
import { AppState } from '../../utils/types';
|
||||
import { fireEvent, render, screen, waitFor } from '../../utils/testing/testingLibrary';
|
||||
import BackButtonService from '../../services/BackButtonService';
|
||||
|
||||
jest.mock('@joplin/lib/services/noteLock/NoteLockKeyMigration', () => ({
|
||||
startNoteLockKeyMigration: jest.fn(),
|
||||
confirmAndSkipNoteLockKeyMigration: jest.fn(),
|
||||
}));
|
||||
|
||||
const startMock = startNoteLockKeyMigration as jest.Mock;
|
||||
const skipMock = confirmAndSkipNoteLockKeyMigration as jest.Mock;
|
||||
|
||||
let store: Store<AppState>;
|
||||
const renderScreen = () => {
|
||||
const dispatch = jest.fn();
|
||||
render(
|
||||
<TestProviderStack store={store}>
|
||||
<NoteLockMigrationScreenComponent themeId={Setting.THEME_LIGHT} dispatch={dispatch}/>
|
||||
</TestProviderStack>,
|
||||
);
|
||||
fireEvent.changeText(screen.getByLabelText('Note lock password on this device'), 'local');
|
||||
fireEvent.changeText(screen.getByLabelText('Note lock password on the sync target'), 'target');
|
||||
return dispatch;
|
||||
};
|
||||
|
||||
describe('NoteLockMigrationScreen', () => {
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(0);
|
||||
await switchClient(0);
|
||||
store = createMockReduxStore();
|
||||
setupGlobalStore(store);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
afterEach(() => {
|
||||
screen.unmount();
|
||||
});
|
||||
|
||||
test('should go back once both passwords are accepted and leave the run to the banner', async () => {
|
||||
startMock.mockImplementation(async (_local: string, _target: string, _dispatch: unknown, onStarted: ()=> void) => onStarted());
|
||||
const dispatch = renderScreen();
|
||||
|
||||
fireEvent.press(screen.getByText('Migrate'));
|
||||
|
||||
await waitFor(() => expect(dispatch).toHaveBeenCalledWith({ type: 'NAV_BACK' }));
|
||||
expect(startMock).toHaveBeenCalledWith('local', 'target', dispatch, expect.any(Function));
|
||||
expect(skipMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should block going back while the passwords are checked', async () => {
|
||||
startMock.mockReturnValue(new Promise(() => {}));
|
||||
renderScreen();
|
||||
expect(screen.getByLabelText('Back')).toBeVisible();
|
||||
|
||||
fireEvent.press(screen.getByText('Migrate'));
|
||||
|
||||
await waitFor(() => expect(screen.queryByLabelText('Back')).toBeNull());
|
||||
expect(await BackButtonService.back()).toBe(true);
|
||||
});
|
||||
|
||||
test('should not show the migration banner on its own screen', async () => {
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
Setting.setValue('noteLock.conflictNoteLockKey', { noteLockKey: { id: 'target-key' }, syncMigrationId: 'lineage' });
|
||||
store = createMockReduxStore();
|
||||
setupGlobalStore(store);
|
||||
renderScreen();
|
||||
|
||||
expect(screen.queryByText(/Press to migrate/)).toBeNull();
|
||||
});
|
||||
|
||||
test('should report a wrong password and stay', async () => {
|
||||
startMock.mockRejectedValue(Object.assign(new Error('bad'), { name: 'OperationError' }));
|
||||
const dispatch = renderScreen();
|
||||
|
||||
fireEvent.press(screen.getByText('Migrate'));
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Invalid password');
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each([true, false])('should leave the screen only once the skip is confirmed (%s)', async (confirmed) => {
|
||||
skipMock.mockResolvedValue(confirmed);
|
||||
const dispatch = renderScreen();
|
||||
|
||||
fireEvent.press(screen.getByText('Skip'));
|
||||
|
||||
await waitFor(() => expect(skipMock).toHaveBeenCalledWith(dispatch));
|
||||
if (confirmed) await waitFor(() => expect(dispatch).toHaveBeenCalledWith({ type: 'NAV_BACK' }));
|
||||
else expect(dispatch).not.toHaveBeenCalled();
|
||||
expect(startMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import * as React from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { View, Text, TextInput, StyleSheet } from 'react-native';
|
||||
import { connect } from 'react-redux';
|
||||
import { Dispatch } from 'redux';
|
||||
import { themeStyle } from '../global-style';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { confirmAndSkipNoteLockKeyMigration, startNoteLockKeyMigration } from '@joplin/lib/services/noteLock/NoteLockKeyMigration';
|
||||
import ScreenHeader from '../ScreenHeader';
|
||||
import { PrimaryButton, SecondaryButton } from '../buttons';
|
||||
import { AppState } from '../../utils/types';
|
||||
import BackButtonService from '../../services/BackButtonService';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
dispatch: Dispatch;
|
||||
}
|
||||
|
||||
export const NoteLockMigrationScreenComponent: React.FC<Props> = props => {
|
||||
const [localPassword, setLocalPassword] = useState('');
|
||||
const [targetPassword, setTargetPassword] = useState('');
|
||||
const [migrating, setMigrating] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
const theme = useMemo(() => themeStyle(props.themeId), [props.themeId]);
|
||||
|
||||
const styles = useMemo(() => {
|
||||
return StyleSheet.create({
|
||||
root: theme.rootStyle,
|
||||
container: {
|
||||
padding: theme.margin,
|
||||
},
|
||||
normalText: {
|
||||
fontSize: theme.fontSize,
|
||||
color: theme.color,
|
||||
marginBottom: 5,
|
||||
},
|
||||
// Same input style as ConfigScreen/NoteLockConfig.tsx
|
||||
passwordInput: {
|
||||
marginTop: 5,
|
||||
marginBottom: theme.itemMarginBottom,
|
||||
color: theme.color,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.dividerColor,
|
||||
borderRadius: 3,
|
||||
padding: 10,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: theme.fontSize,
|
||||
color: theme.colorError,
|
||||
marginBottom: theme.itemMarginBottom,
|
||||
},
|
||||
buttonContainer: {
|
||||
marginTop: theme.itemMarginTop,
|
||||
gap: theme.margin,
|
||||
},
|
||||
});
|
||||
}, [theme]);
|
||||
|
||||
// Leaving while the passwords are checked would let the run start and navigate from under whatever screen replaced this one.
|
||||
useEffect(() => {
|
||||
if (!migrating) return () => {};
|
||||
const handler = () => true;
|
||||
BackButtonService.addHandler(handler);
|
||||
return () => BackButtonService.removeHandler(handler);
|
||||
}, [migrating]);
|
||||
|
||||
// Duplicates the migrate and skip handlers in app-desktop/gui/NoteLockMigrationDialog/Dialog.tsx.
|
||||
const onMigrate = useCallback(async () => {
|
||||
setMigrating(true);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
await startNoteLockKeyMigration(localPassword, targetPassword, props.dispatch, () => props.dispatch({ type: 'NAV_BACK' }));
|
||||
} catch (error) {
|
||||
setErrorMessage(error.name === 'OperationError' ? _('Invalid password') : error.message);
|
||||
setMigrating(false);
|
||||
}
|
||||
}, [localPassword, targetPassword, props.dispatch]);
|
||||
|
||||
const onSkip = useCallback(async () => {
|
||||
if (await confirmAndSkipNoteLockKeyMigration(props.dispatch)) props.dispatch({ type: 'NAV_BACK' });
|
||||
}, [props.dispatch]);
|
||||
|
||||
const localLabelId = 'note-lock-migration-local-password';
|
||||
const targetLabelId = 'note-lock-migration-target-password';
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<ScreenHeader title={_('Migrate locked notes')} showSearchButton={false} showBackButton={!migrating} showNoteLockKeyConflictMessage={false}/>
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.normalText}>{_('The sync target uses a different note lock key to the one on your device. Your locked notes must be re-encrypted with the synced key before synchronisation can continue. Locked notes cannot be read while they are being migrated.')}</Text>
|
||||
<Text nativeID={localLabelId} style={styles.normalText}>{_('Note lock password on this device')}</Text>
|
||||
<TextInput
|
||||
accessibilityLabelledBy={localLabelId}
|
||||
selectionColor={theme.textSelectionColor}
|
||||
keyboardAppearance={theme.keyboardAppearance}
|
||||
style={styles.passwordInput}
|
||||
secureTextEntry={true}
|
||||
autoCapitalize='none'
|
||||
autoCorrect={false}
|
||||
textContentType='password'
|
||||
value={localPassword}
|
||||
onChangeText={setLocalPassword}
|
||||
/>
|
||||
<Text nativeID={targetLabelId} style={styles.normalText}>{_('Note lock password on the sync target')}</Text>
|
||||
<TextInput
|
||||
accessibilityLabelledBy={targetLabelId}
|
||||
selectionColor={theme.textSelectionColor}
|
||||
keyboardAppearance={theme.keyboardAppearance}
|
||||
style={styles.passwordInput}
|
||||
secureTextEntry={true}
|
||||
autoCapitalize='none'
|
||||
autoCorrect={false}
|
||||
textContentType='password'
|
||||
value={targetPassword}
|
||||
onChangeText={setTargetPassword}
|
||||
/>
|
||||
{!!errorMessage && <Text style={styles.errorText} role='alert'>{errorMessage}</Text>}
|
||||
<View style={styles.buttonContainer}>
|
||||
<PrimaryButton onPress={onMigrate} disabled={!localPassword || !targetPassword || migrating}>{_('Migrate')}</PrimaryButton>
|
||||
<SecondaryButton onPress={onSkip} disabled={migrating}>{_('Skip')}</SecondaryButton>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default connect((state: AppState) => {
|
||||
return {
|
||||
themeId: state.settings.theme,
|
||||
};
|
||||
})(NoteLockMigrationScreenComponent);
|
||||
@@ -9,6 +9,7 @@ import time from '@joplin/lib/time';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import NoteScreen from './components/screens/Note/Note';
|
||||
import UpgradeSyncTargetScreen from './components/screens/UpgradeSyncTargetScreen';
|
||||
import NoteLockMigrationScreen from './components/screens/NoteLockMigrationScreen';
|
||||
import Setting, { } from '@joplin/lib/models/Setting';
|
||||
import PoorManIntervals from '@joplin/lib/PoorManIntervals';
|
||||
import { NotesParent, serializeNotesParent } from '@joplin/lib/reducer';
|
||||
@@ -503,6 +504,11 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
|
||||
}
|
||||
|
||||
this.urlOpenListener_ = Linking.addEventListener('url', this.handleOpenURL_);
|
||||
// A new activity can remount this component with biometrics already done.
|
||||
// Its native share event may have arrived before the ShareExtension listener was attached.
|
||||
if (this.props.biometricsDone) {
|
||||
void this.handleShareData();
|
||||
}
|
||||
|
||||
BackButtonService.initialize(this.backButtonHandler_);
|
||||
|
||||
@@ -776,6 +782,7 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
|
||||
JoplinServerSamlLogin: { screen: SsoLoginScreen(new SamlShared()) },
|
||||
EncryptionConfig: { screen: EncryptionConfigScreen },
|
||||
UpgradeSyncTarget: { screen: UpgradeSyncTargetScreen },
|
||||
NoteLockMigration: { screen: NoteLockMigrationScreen },
|
||||
ShareManager: { screen: ShareManager },
|
||||
ProfileSwitcher: { screen: ProfileSwitcher },
|
||||
ProfileEditor: { screen: ProfileEditor },
|
||||
|
||||
@@ -25,7 +25,9 @@ import TaskQueue from './TaskQueue';
|
||||
import ItemUploader from './services/synchronizer/ItemUploader';
|
||||
import { FileApi, getSupportsDeltaWithItems, isLocalServer, PaginatedList, RemoteItem, enableEnhancedBasicDeltaAlgorithm } from './file-api';
|
||||
import JoplinDatabase from './JoplinDatabase';
|
||||
import { checkIfCanSync, fetchSyncInfo, checkSyncTargetIsValid, getActiveMasterKey, localSyncInfo, mergeSyncInfos, saveLocalSyncInfo, setMasterKeyHasBeenUsed, SyncInfo, syncInfoEquals, uploadSyncInfo } from './services/synchronizer/syncInfoUtils';
|
||||
import { parkedNoteLockKeyInUse } from './services/noteLock/NoteLockKeyMigration';
|
||||
import NoteLockSession from './services/noteLock/NoteLockSession';
|
||||
import { checkIfCanSync, fetchSyncInfo, checkSyncTargetIsValid, getActiveMasterKey, localSyncInfo, mergeSyncInfos, saveLocalSyncInfo, setMasterKeyHasBeenUsed, SyncInfo, syncInfoEquals, uploadSyncInfo, checkNoteLockKeyConflict, checkNoteLockKeyMigrationId, checkNoteLockKeyUnchanged, checkRemoteNoteLockKeyUnchanged, noteLockKeyConflict } from './services/synchronizer/syncInfoUtils';
|
||||
import { getMasterPassword, setupAndDisableEncryption, setupAndEnableEncryption } from './services/e2ee/utils';
|
||||
import { generateKeyPair } from './services/e2ee/ppk/ppk';
|
||||
import syncDebugLog from './services/synchronizer/syncDebugLog';
|
||||
@@ -494,6 +496,11 @@ export default class Synchronizer {
|
||||
this.api().setTempDirName(Dirnames.Temp);
|
||||
|
||||
try {
|
||||
// Checked before anything is uploaded so a key without an id never reaches the target. The CLI cannot create or reset
|
||||
// a note lock key or run a migration: it adopts the target's key, and a migration it would need (its locked notes
|
||||
// meeting a different key, for example after switching the sync target) stops its sync. Revisit if it gains these.
|
||||
checkNoteLockKeyMigrationId(localSyncInfo(), false);
|
||||
|
||||
let remoteInfo = await fetchSyncInfo(this.api());
|
||||
logger.info('Sync target remote info:', remoteInfo.filterSyncInfo());
|
||||
eventManager.emit(EventName.SessionEstablished);
|
||||
@@ -514,7 +521,10 @@ export default class Synchronizer {
|
||||
const appVersion = shim.appVersion();
|
||||
if (appVersion !== 'unknown') checkIfCanSync(remoteInfo, appVersion);
|
||||
|
||||
let localInfo = await localSyncInfo();
|
||||
let localInfo = localSyncInfo();
|
||||
// Read in the same tick as the key, so a reset landing mid-sync is seen whole or not at all.
|
||||
const noteLockPasswordReset = Setting.value('noteLock.passwordReset');
|
||||
const noteLockKeyIdToReset = Setting.value('noteLock.keyIdToReset');
|
||||
logger.info('Sync target local info:', localInfo.filterSyncInfo());
|
||||
|
||||
localInfo = await this.setPpkIfNotExist(localInfo, remoteInfo);
|
||||
@@ -523,18 +533,50 @@ export default class Synchronizer {
|
||||
localInfo = setMasterKeyHasBeenUsed(localInfo, localInfo.activeMasterKeyId);
|
||||
}
|
||||
|
||||
checkNoteLockKeyMigrationId(remoteInfo, true);
|
||||
const hasLockedNotes = await Note.hasLockedNotes();
|
||||
// A reset only overwrites the target key it replaced, so a target reset elsewhere since then wins.
|
||||
const resetPropagates = noteLockPasswordReset && (!remoteInfo.noteLockKey || remoteInfo.noteLockKey.id === noteLockKeyIdToReset);
|
||||
const staleReset = noteLockPasswordReset && !resetPropagates;
|
||||
const notesUseParkedKey = await parkedNoteLockKeyInUse();
|
||||
const hadParkedKey = !!noteLockKeyConflict();
|
||||
// Read again after the await: a migration may have finished meanwhile. A running one is checked in the same tick
|
||||
// as the conflict, so it cannot start in between.
|
||||
checkNoteLockKeyConflict(localSyncInfo(), remoteInfo, hasLockedNotes, staleReset, notesUseParkedKey || NoteLockSession.instance().isMigrating());
|
||||
// The target no longer conflicts, so a banner left by an earlier run with nothing moved has nothing to retry.
|
||||
if (hadParkedKey) this.dispatch({ type: 'NOTE_LOCK_MIGRATION_STATUS_SET', value: null });
|
||||
|
||||
// console.info('LOCAL', localInfo);
|
||||
// console.info('REMOTE', remoteInfo);
|
||||
|
||||
let reconciledNoteLockKeyId = localInfo.noteLockKey?.id;
|
||||
if (!syncInfoEquals(localInfo, remoteInfo)) {
|
||||
let newInfo = mergeSyncInfos(localInfo, remoteInfo);
|
||||
// The merge works from localInfo, which a key change since it was read would make stale.
|
||||
checkNoteLockKeyUnchanged(localInfo);
|
||||
let newInfo = mergeSyncInfos(localInfo, remoteInfo, { resetPropagates, hasLocalLockedNotes: hasLockedNotes });
|
||||
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());
|
||||
|
||||
// The checks above let the target key replace the local one, so it is adopted before the upload: a note locked
|
||||
// from now on is encrypted with the key the target holds.
|
||||
if (localInfo.noteLockKey && newInfo.noteLockKey?.id !== localInfo.noteLockKey.id) {
|
||||
const adoptedInfo = localSyncInfo();
|
||||
adoptedInfo.noteLockKey = localInfo.noteLockKey = newInfo.noteLockKey;
|
||||
adoptedInfo.syncMigrationId = localInfo.syncMigrationId = newInfo.syncMigrationId;
|
||||
saveLocalSyncInfo(adoptedInfo);
|
||||
}
|
||||
|
||||
await this.lockHandler().acquireLock(LockType.Exclusive, this.lockClientType(), this.clientId_, { clearExistingSyncLocksFromTheSameClient: true });
|
||||
// Must stay immediately before the upload. Sync locks are disabled (the lock above does nothing), so another device can
|
||||
// upload its own info.json after this sync fetched the target's, for example a password reset, which the upload below
|
||||
// would revert to the old key.
|
||||
checkRemoteNoteLockKeyUnchanged(remoteInfo, await fetchSyncInfo(this.api()));
|
||||
await uploadSyncInfo(this.api(), newInfo);
|
||||
// A reset or password change on this device during the upload would be overwritten by the save below.
|
||||
checkNoteLockKeyUnchanged(localInfo);
|
||||
await saveLocalSyncInfo(newInfo);
|
||||
reconciledNoteLockKeyId = newInfo.noteLockKey?.id;
|
||||
await this.lockHandler().releaseLock(LockType.Exclusive, this.lockClientType(), this.clientId_);
|
||||
|
||||
// console.info('NEW', newInfo);
|
||||
@@ -562,6 +604,20 @@ export default class Synchronizer {
|
||||
// Set it to remote anyway so that timestamps are the same
|
||||
// Note: that's probably not needed anymore?
|
||||
// await uploadSyncInfo(this.api(), remoteInfo);
|
||||
|
||||
// A reset that landed mid-sync keeps its flag for the next sync.
|
||||
if (noteLockPasswordReset) checkNoteLockKeyUnchanged(localInfo);
|
||||
}
|
||||
|
||||
// Either the reset key is on the target now or a different lineage replaced it, so the flag is spent. A reset
|
||||
// that landed after the local save replaces the key the target holds now, so it keeps its flag.
|
||||
if (noteLockPasswordReset) {
|
||||
if (localSyncInfo().noteLockKey?.id === reconciledNoteLockKeyId) {
|
||||
Setting.setValue('noteLock.passwordReset', false);
|
||||
Setting.setValue('noteLock.keyIdToReset', '');
|
||||
} else {
|
||||
Setting.setValue('noteLock.keyIdToReset', reconciledNoteLockKeyId ?? '');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code === 403) {
|
||||
|
||||
@@ -266,6 +266,37 @@ describe('convertNoteToMarkdown', () => {
|
||||
expect(converted.deleted_time).toBe(0);
|
||||
});
|
||||
|
||||
it('should not save a decrypted locked note when note lock is turned off during the conversion', async () => {
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
shim.showErrorDialog = jest.fn();
|
||||
NoteLockService.destroyInstance();
|
||||
NoteLockSession.destroyInstance();
|
||||
NoteLockKey.destroyInstance();
|
||||
EncryptionService.instance_ = encryptionService();
|
||||
await NoteLockKey.instance().create('123456');
|
||||
await NoteLockSession.instance().unlock('123456');
|
||||
const folder = await Folder.save({ title: 'test_folder' });
|
||||
const htmlNote = await Note.save({ title: 'test', body: '<p>Hello</p>', parent_id: folder.id, markup_language: MarkupLanguage.Html });
|
||||
const lockedNote = { ...(await Note.load(htmlNote.id)), is_locked: 1, isDecrypted: true };
|
||||
await Note.save(lockedNote, { useNoteLock: true });
|
||||
state.selectedNoteIds = [htmlNote.id];
|
||||
|
||||
// The backup copy is made between the gated load and the save.
|
||||
const duplicate = Note.duplicate.bind(Note);
|
||||
const spy = jest.spyOn(Note, 'duplicate').mockImplementation((...args) => {
|
||||
Setting.setValue('featureFlag.noteLock', false);
|
||||
return duplicate(...args);
|
||||
});
|
||||
try {
|
||||
await convertHtmlToMarkdown.runtime().execute({ state, dispatch: jest.fn() });
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
expect(shim.showErrorDialog).toHaveBeenCalledTimes(1);
|
||||
expect((await Note.load(htmlNote.id)).body).not.toContain('Hello');
|
||||
});
|
||||
|
||||
it('should finish converting the remaining locked notes when the session locks mid-run', async () => {
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
shim.showErrorDialog = jest.fn();
|
||||
|
||||
@@ -10,7 +10,7 @@ import { ModelType } from '../BaseModel';
|
||||
import ItemChange from '../models/ItemChange';
|
||||
import Setting from '../models/Setting';
|
||||
import isNoteLockEnabled from '../services/noteLock/isNoteLockEnabled';
|
||||
import NoteLockNote from '../services/noteLock/NoteLockNote';
|
||||
import NoteLockNote, { NoteLockNoteEntity } from '../services/noteLock/NoteLockNote';
|
||||
import NoteLockSession from '../services/noteLock/NoteLockSession';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
|
||||
@@ -50,11 +50,9 @@ export const runtime = (): CommandRuntime => {
|
||||
if (await itemIsReadOnly(Note, ModelType.Note, ItemChange.SOURCE_UNSPECIFIED, note.id, Setting.value('sync.userId'), context.state.shareService)) {
|
||||
throw new Error(_('Cannot convert read-only item: "%s"', note.title));
|
||||
}
|
||||
const noteIsLocked = isNoteLockEnabled() && NoteLockNote.isLocked(note);
|
||||
|
||||
// A locked note converts through a full gated load and save, so the body is
|
||||
// Every note converts through a full gated load and save, so a locked body is
|
||||
// decrypted for the conversion and the converted note is encrypted again.
|
||||
const sourceNote = noteIsLocked ? await Note.load(note.id, { useNoteLock: true, noteLockKey }) : note;
|
||||
const sourceNote: NoteLockNoteEntity = await Note.load(note.id, { useNoteLock: true, noteLockKey });
|
||||
const markdownBody = await convertHtmlToMarkdown().execute(context, sourceNote.body);
|
||||
const backupNote = await Note.duplicate(note.id, {
|
||||
changes: {
|
||||
@@ -67,7 +65,7 @@ export const runtime = (): CommandRuntime => {
|
||||
note.markup_language = MarkupLanguage.Markdown;
|
||||
note.updated_time = Date.now();
|
||||
|
||||
const toSave = { ...note, isDecrypted: true };
|
||||
const toSave = { ...note, isDecrypted: sourceNote.isDecrypted };
|
||||
await Note.save(toSave, { autoTimestamp: false, useNoteLock: true, noteLockKey });
|
||||
await Note.delete(backupNote.id, { toTrash: true });
|
||||
processedCount ++;
|
||||
|
||||
@@ -7,6 +7,10 @@ const React = shim.react();
|
||||
const { useEffect, useRef, useState } = shim.react();
|
||||
|
||||
type OnCancelListener = ()=> void;
|
||||
|
||||
// Closes made by the app, not the user, so the close event must not report them as a cancel.
|
||||
const removedReturnValue = 'removed-from-dom';
|
||||
const hiddenReturnValue = 'hidden';
|
||||
type OnShowListener = ()=> void;
|
||||
type OnBeforeShowListener = (dialog: HTMLDialogElement)=> void;
|
||||
|
||||
@@ -52,7 +56,8 @@ const Dialog: FC<Props> = props => {
|
||||
dialogElement.showModal();
|
||||
propsRef.current.onShow?.();
|
||||
} else if (dialogElement.open && !open) {
|
||||
dialogElement.close();
|
||||
// The close event fires later, when a dialog shown next may already own onCancel.
|
||||
dialogElement.close(hiddenReturnValue);
|
||||
}
|
||||
}, [dialogElement, contentRendered, props.open]);
|
||||
|
||||
@@ -168,9 +173,11 @@ const useDialogElement = (containerDocument: Document, onCancel: undefined|OnCan
|
||||
}
|
||||
});
|
||||
|
||||
const removedReturnValue = 'removed-from-dom';
|
||||
dialog.addEventListener('close', () => {
|
||||
const closedByCancel = dialog.returnValue !== removedReturnValue;
|
||||
const closedByCancel = dialog.returnValue !== removedReturnValue && dialog.returnValue !== hiddenReturnValue;
|
||||
// A cancel leaves the return value as it was, so it is cleared here rather than on show, which can
|
||||
// happen before this event arrives.
|
||||
dialog.returnValue = '';
|
||||
if (closedByCancel) {
|
||||
if (!preventAutoCloseOnCancel || !skipCloseCancelRef.current) {
|
||||
onCancelRef.current?.();
|
||||
|
||||
@@ -6,4 +6,6 @@ export enum ErrorCode {
|
||||
UnsupportedMimeType = 'unsupportedMimeType',
|
||||
MustUpgradeApp = 'mustUpgradeApp',
|
||||
DownloadLimiter = 'downloadLimiter',
|
||||
Cancelled = 'cancelled',
|
||||
NoteLockKeyConflict = 'noteLockKeyConflict',
|
||||
}
|
||||
@@ -405,6 +405,38 @@ describe('models/Note', () => {
|
||||
expect((await Note.load(note.id)).body).toBe('overwrite');
|
||||
});
|
||||
|
||||
it('should find a locked note anywhere in a notebook tree', async () => {
|
||||
const parent = await Folder.save({ title: 'parent' });
|
||||
const child = await Folder.save({ title: 'child', parent_id: parent.id });
|
||||
const other = await Folder.save({ title: 'other' });
|
||||
await Note.save({ title: 'plain', body: 'plain', parent_id: parent.id });
|
||||
await Note.save({ title: 'other plain', body: 'plain', parent_id: other.id });
|
||||
const locked = await Note.save({ title: 'locked', body: 'JLD01cipher', parent_id: child.id });
|
||||
await db().exec('UPDATE notes SET is_locked = 1 WHERE id = ?', [locked.id]);
|
||||
|
||||
expect(await Note.hasLockedNoteInFolderTrees([parent.id])).toBe(true);
|
||||
expect(await Note.hasLockedNoteInFolderTrees([child.id])).toBe(true);
|
||||
expect(await Note.hasLockedNoteInFolderTrees([other.id])).toBe(false);
|
||||
});
|
||||
|
||||
it('should refuse to save a decrypted locked note once the feature is disabled', async () => {
|
||||
await NoteLockKey.instance().create('123456');
|
||||
await NoteLockSession.instance().unlock('123456');
|
||||
const note = await Note.save({ body: 'secret', is_locked: 1 }, { useNoteLock: true });
|
||||
const lockedNote = await Note.load(note.id, { useNoteLock: true });
|
||||
const unlockedNote = await Note.load((await Note.save({ body: 'plain' })).id, { useNoteLock: true });
|
||||
const cipherText = (await Note.load(note.id)).body;
|
||||
|
||||
Setting.setValue('featureFlag.noteLock', false);
|
||||
const error = 'Locked notes cannot be saved while the note lock feature is turned off';
|
||||
await expect(Note.save({ ...lockedNote, body: 'edited' }, { useNoteLock: true })).rejects.toThrow(error);
|
||||
await expect(Note.save({ id: note.id, body: 'edited', isDecrypted: true } as NoteEntity, { useNoteLock: true })).rejects.toThrow(error);
|
||||
expect((await Note.load(note.id)).body).toBe(cipherText);
|
||||
|
||||
await Note.save({ ...unlockedNote, body: 'plain edited' }, { useNoteLock: true });
|
||||
expect((await Note.load(unlockedNote.id)).body).toBe('plain edited');
|
||||
});
|
||||
|
||||
it('should encrypt a gated save with a captured key while the session is locked, but not after a key rotation', async () => {
|
||||
await NoteLockKey.instance().create('123456');
|
||||
await NoteLockSession.instance().unlock('123456');
|
||||
|
||||
@@ -608,6 +608,49 @@ export default class Note extends BaseItem {
|
||||
return r && r.total ? r.total : 0;
|
||||
}
|
||||
|
||||
// Like the export of a notebook, the check includes its sub-notebooks.
|
||||
public static async hasLockedNoteInFolderTrees(folderIds: string[]) {
|
||||
if (!folderIds.length) return false;
|
||||
|
||||
const result = await this.db().selectOne(`
|
||||
WITH RECURSIVE folder_tree(id) AS (
|
||||
SELECT id
|
||||
FROM folders
|
||||
WHERE id IN (${this.escapeIdsForSql(folderIds)})
|
||||
|
||||
UNION
|
||||
|
||||
SELECT folder.id
|
||||
FROM folders folder
|
||||
INNER JOIN folder_tree parent
|
||||
ON folder.parent_id = parent.id
|
||||
WHERE folder.deleted_time = 0
|
||||
)
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM notes note
|
||||
INNER JOIN folder_tree folder
|
||||
ON note.parent_id = folder.id
|
||||
WHERE note.is_locked = 1
|
||||
AND note.is_conflict = 0
|
||||
AND note.deleted_time = 0
|
||||
LIMIT 1
|
||||
) AS value
|
||||
`);
|
||||
|
||||
return !!result.value;
|
||||
}
|
||||
|
||||
// Trashed and conflict notes count too: their content still depends on the note lock key.
|
||||
public static async hasLockedNotes() {
|
||||
return !!(await this.db().selectOne('SELECT 1 FROM notes WHERE is_locked = 1 LIMIT 1'));
|
||||
}
|
||||
|
||||
public static async lockedNoteIds() {
|
||||
const rows = await this.db().selectAll<{ id: string }>('SELECT id FROM notes WHERE is_locked = 1');
|
||||
return rows.map(row => row.id);
|
||||
}
|
||||
|
||||
public static unconflictedNotes() {
|
||||
return this.modelSelectAll('SELECT * FROM notes WHERE is_conflict = 0');
|
||||
}
|
||||
@@ -918,6 +961,8 @@ export default class Note extends BaseItem {
|
||||
// before calling a gated save, but caution should be used if doing so, because if the body
|
||||
// is ever not decrypted when doing so, it will cause the contents to be double encrypted,
|
||||
// causing the plain text to become inaccessible in UI, with no automatic remedy available.
|
||||
// It must also never be set to true without first checking that isNoteLockEnabled() is true,
|
||||
// as a note marked decrypted is refused when the feature is turned off before the save.
|
||||
public static async save(o: NoteEntity, options: SaveOptions = null): Promise<NoteEntity> {
|
||||
const isNew = this.isNew(o, options);
|
||||
|
||||
@@ -960,6 +1005,11 @@ export default class Note extends BaseItem {
|
||||
// in the item_changes table
|
||||
const oldNote = !isNew && o.id ? await Note.load(o.id) : null;
|
||||
let plainTextBodyToReturn: string = null;
|
||||
// With the feature turned off the note lock path below is skipped, so a decrypted body would be written over the
|
||||
// ciphertext. The stored row decides for a partial save, which may not carry the lock state.
|
||||
if (!isNoteLockEnabled() && (o as NoteLockNoteEntity).isDecrypted && (NoteLockNote.isLocked(o) || NoteLockNote.isLocked(oldNote))) {
|
||||
throw new Error('Locked notes cannot be saved while the note lock feature is turned off');
|
||||
}
|
||||
if (isNoteLockEnabled()) {
|
||||
if (!options?.useNoteLock && o.is_locked !== undefined && 'body' in o && !!(o as NoteLockNoteEntity).isDecrypted) throw new Error('Saves including both is_locked and body fields must be gated when the body was loaded using a gated load');
|
||||
if (!isNew && o.is_locked !== undefined && !!o.is_locked !== !!oldNote?.is_locked && !('body' in o)) throw new Error('Saves that change is_locked must include the body field');
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AppType, SettingItemSubType, SettingItemType, SettingStorage, SyncStart
|
||||
import { defaultListColumns } from '../../services/plugins/api/noteListType';
|
||||
import type { PluginSettings } from '../../services/plugins/PluginService';
|
||||
import type { PublicPrivateKeyPair } from '../../services/e2ee/ppk/ppk';
|
||||
import type { NoteLockKeyConflict } from '../../services/synchronizer/syncInfoUtils';
|
||||
import { EmptyObject } from '@joplin/utils/types';
|
||||
const ObjectUtils = require('../../ObjectUtils');
|
||||
import { toTitleCase } from '../../string-utils';
|
||||
@@ -2357,6 +2358,24 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
|
||||
'noteLock.passwordReset': {
|
||||
value: false,
|
||||
type: SettingItemType.Bool,
|
||||
public: false,
|
||||
},
|
||||
|
||||
'noteLock.keyIdToReset': {
|
||||
value: '',
|
||||
type: SettingItemType.String,
|
||||
public: false,
|
||||
},
|
||||
|
||||
'noteLock.conflictNoteLockKey': {
|
||||
value: {} as Partial<NoteLockKeyConflict>,
|
||||
type: SettingItemType.Object,
|
||||
public: false,
|
||||
},
|
||||
|
||||
'featureFlag.autoUpdaterServiceEnabled': {
|
||||
value: false,
|
||||
type: SettingItemType.Bool,
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
"html-minifier": "4.0.0",
|
||||
"image-data-uri": "2.0.1",
|
||||
"immer": "9.0.21",
|
||||
"js-yaml": "4.2.0",
|
||||
"js-yaml": "4.3.0",
|
||||
"markdown-it": "13.0.2",
|
||||
"md5": "2.3.0",
|
||||
"md5-file": "5.0.0",
|
||||
|
||||
@@ -90,6 +90,14 @@ export interface StateLastDeletion {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// The background note lock key migration, kept after it ends until its banner is dismissed.
|
||||
export interface NoteLockMigrationStatus {
|
||||
running: boolean;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
awaitingDecryption: number;
|
||||
}
|
||||
|
||||
export type HighlightedWord = ComplexTerm|string;
|
||||
|
||||
export interface WindowState {
|
||||
@@ -191,6 +199,7 @@ export interface State extends WindowState {
|
||||
isInsertingNotes: boolean;
|
||||
hasEncryptedItems: boolean;
|
||||
noteLockSessionUnlocked: boolean;
|
||||
noteLockMigrationStatus: NoteLockMigrationStatus|null;
|
||||
needApiAuth: boolean;
|
||||
profileConfig: ProfileConfig;
|
||||
noteListRendererIds: string[];
|
||||
@@ -262,6 +271,7 @@ export const defaultState: State = {
|
||||
isInsertingNotes: false,
|
||||
hasEncryptedItems: false,
|
||||
noteLockSessionUnlocked: false,
|
||||
noteLockMigrationStatus: null,
|
||||
needApiAuth: false,
|
||||
profileConfig: null,
|
||||
noteListRendererIds: getListRendererIds(),
|
||||
@@ -1579,6 +1589,10 @@ const reducer = produce((draft: Draft<State> = defaultState, action: any) => {
|
||||
draft.noteLockSessionUnlocked = action.value;
|
||||
break;
|
||||
|
||||
case 'NOTE_LOCK_MIGRATION_STATUS_SET':
|
||||
draft.noteLockMigrationStatus = action.value;
|
||||
break;
|
||||
|
||||
case 'SET_ACTIVE_NOTE_IS_UNDECRYPTABLE':
|
||||
{
|
||||
// The editor reporting this may be in a background window, so the flag follows that
|
||||
|
||||
@@ -45,6 +45,7 @@ export interface WhenClauseContext {
|
||||
noteIsLocked: boolean;
|
||||
noteLockContentUnavailable: boolean;
|
||||
noteLockSessionUnlocked: boolean;
|
||||
noteLockMigrationRunning: boolean;
|
||||
noteIsMarkdown: boolean;
|
||||
noteIsReadOnly: boolean;
|
||||
noteIsReadOnlyShare: boolean;
|
||||
@@ -129,6 +130,7 @@ export default function stateToWhenClauseContext(state: State, options: WhenClau
|
||||
noteIsLocked,
|
||||
noteLockContentUnavailable,
|
||||
noteLockSessionUnlocked: state.noteLockSessionUnlocked,
|
||||
noteLockMigrationRunning: !!state.noteLockMigrationStatus?.running,
|
||||
noteIsReadOnly: noteLockContentUnavailable || noteIsReadOnlyShare || noteIsLockedInShare(selectedNote),
|
||||
noteIsReadOnlyShare,
|
||||
noteIsDeleted: selectedNote ? !!selectedNote.deleted_time : false,
|
||||
|
||||
@@ -810,9 +810,9 @@ export default class EncryptionService {
|
||||
return `${isNoteLock ? 'JLD' : 'JED'}01${encryptionMetadata}`;
|
||||
}
|
||||
|
||||
public async decodeHeaderString(cipherText: string) {
|
||||
public async decodeHeaderString(cipherText: string, isNoteLock = false) {
|
||||
const source = this.stringReader_(cipherText);
|
||||
return this.decodeHeaderSource_(source);
|
||||
return this.decodeHeaderSource_(source, isNoteLock);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- See encryptAbstract_ source/destination
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
import InteropService from './InteropService';
|
||||
import { ExportModuleOutputFormat } from './types';
|
||||
import Folder from '../../models/Folder';
|
||||
import Note from '../../models/Note';
|
||||
import Setting from '../../models/Setting';
|
||||
import EncryptionService from '../e2ee/EncryptionService';
|
||||
import NoteLockKey, { noteLockKeyFileName } from '../noteLock/NoteLockKey';
|
||||
import NoteLockService from '../noteLock/NoteLockService';
|
||||
import NoteLockSession from '../noteLock/NoteLockSession';
|
||||
import Resource from '../../models/Resource';
|
||||
import { createNoteAndResource, encryptionService, exportDir, setupDatabaseAndSynchronizer, switchClient } from '../../testing/test-utils';
|
||||
import shim from '../../shim';
|
||||
import * as fs from 'fs-extra';
|
||||
|
||||
const setUpUnlockedSession = async (password = '123456') => {
|
||||
await NoteLockKey.instance().create(password);
|
||||
await NoteLockSession.instance().unlock(password);
|
||||
};
|
||||
|
||||
const lockNote = async (id: string) => {
|
||||
const lockedNote = { ...(await Note.load(id)), is_locked: 1, isDecrypted: true };
|
||||
await Note.save(lockedNote, { useNoteLock: true });
|
||||
return Note.load(id);
|
||||
};
|
||||
|
||||
const rotateProfileKey = async (newPassword: string) => {
|
||||
NoteLockSession.instance().lock();
|
||||
await NoteLockKey.instance().reset(newPassword);
|
||||
await NoteLockSession.instance().unlock(newPassword);
|
||||
};
|
||||
|
||||
describe('InteropService.noteLock', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
NoteLockService.destroyInstance();
|
||||
NoteLockSession.destroyInstance();
|
||||
NoteLockKey.destroyInstance();
|
||||
EncryptionService.instance_ = encryptionService();
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
await fs.remove(exportDir());
|
||||
await fs.mkdirp(exportDir());
|
||||
});
|
||||
|
||||
it('should raw export a locked note with its ciphertext, resource and the key file', async () => {
|
||||
await setUpUnlockedSession();
|
||||
const folder = await Folder.save({ title: 'folder' });
|
||||
const { note, resource } = await createNoteAndResource({ parentId: folder.id });
|
||||
await Note.save({ id: note.id, title: 'note', body: `secret text ${note.body}` });
|
||||
const locked = await lockNote(note.id);
|
||||
expect(locked.body).not.toContain('secret');
|
||||
|
||||
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Raw });
|
||||
|
||||
const serialized = await fs.readFile(`${exportDir()}/${note.id}.md`, 'utf-8');
|
||||
expect(serialized).not.toContain('secret');
|
||||
expect(serialized).toContain('is_locked: 1');
|
||||
expect(serialized).toContain(resource.id);
|
||||
|
||||
const exportedResources = await fs.readdir(`${exportDir()}/resources`);
|
||||
expect(exportedResources.some(f => f.startsWith(resource.id))).toBe(true);
|
||||
|
||||
const keyFile = JSON.parse(await fs.readFile(`${exportDir()}/${noteLockKeyFileName}`, 'utf-8'));
|
||||
expect(keyFile.id).toBe(NoteLockKey.instance().load().id);
|
||||
});
|
||||
|
||||
it('should include the key file inside a jex archive', async () => {
|
||||
await setUpUnlockedSession();
|
||||
const folder = await Folder.save({ title: 'folder' });
|
||||
const note = await Note.save({ title: 'note', body: 'secret', parent_id: folder.id });
|
||||
await lockNote(note.id);
|
||||
|
||||
const jexPath = `${exportDir()}/test.jex`;
|
||||
await InteropService.instance().export({ path: jexPath, format: ExportModuleOutputFormat.Jex });
|
||||
|
||||
const extractDir = `${exportDir()}/extracted`;
|
||||
await fs.mkdirp(extractDir);
|
||||
await shim.fsDriver().tarExtract({ strict: true, portable: true, file: jexPath, cwd: extractDir });
|
||||
expect(await fs.pathExists(`${extractDir}/${noteLockKeyFileName}`)).toBe(true);
|
||||
});
|
||||
|
||||
it('should md export a locked note decrypted once the session is unlocked', async () => {
|
||||
await setUpUnlockedSession();
|
||||
const folder = await Folder.save({ title: 'folder' });
|
||||
const note = await Note.save({ title: 'locked note', body: 'secret text', parent_id: folder.id });
|
||||
await lockNote(note.id);
|
||||
|
||||
const result = await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Markdown, decryptLockedNotes: true });
|
||||
|
||||
const files = await fs.readdir(`${exportDir()}/folder`);
|
||||
expect(files.length).toBe(1);
|
||||
const exported = await fs.readFile(`${exportDir()}/folder/${files[0]}`, 'utf-8');
|
||||
expect(exported).toContain('secret text');
|
||||
expect(result.warnings).toEqual([]);
|
||||
// The database row stays encrypted, only the exported copy is decrypted.
|
||||
expect((await Note.load(note.id)).body).not.toContain('secret');
|
||||
});
|
||||
|
||||
const saveLockedNotes = async (folderId: string, titles: string[]) => {
|
||||
for (const title of titles) {
|
||||
const note = await Note.save({ title, body: `secret ${title}`, parent_id: folderId });
|
||||
await lockNote(note.id);
|
||||
}
|
||||
};
|
||||
|
||||
// Runs the action once, the first time a gated load or save starts.
|
||||
const onFirstGatedCall = (method: 'load'|'save', action: ()=> void) => {
|
||||
const original = (Note[method] as (...args: unknown[])=> Promise<unknown>).bind(Note);
|
||||
let done = false;
|
||||
return jest.spyOn(Note, method).mockImplementation(((...args: unknown[]) => {
|
||||
if (!done && (args[1] as { useNoteLock?: boolean })?.useNoteLock) {
|
||||
done = true;
|
||||
action();
|
||||
}
|
||||
return original(...args);
|
||||
}) as never);
|
||||
};
|
||||
|
||||
it('should md export every locked note when the session is locked partway through', async () => {
|
||||
await setUpUnlockedSession();
|
||||
const folder = await Folder.save({ title: 'folder' });
|
||||
await saveLockedNotes(folder.id, ['one', 'two']);
|
||||
|
||||
// Locks the session once the first note is done, as the second note starts loading.
|
||||
const load = Note.load.bind(Note);
|
||||
let plainLoads = 0;
|
||||
const spy = jest.spyOn(Note, 'load').mockImplementation((id, options) => {
|
||||
if (!options?.useNoteLock && ++plainLoads === 2) NoteLockSession.instance().lock();
|
||||
return load(id, options);
|
||||
});
|
||||
try {
|
||||
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Markdown, decryptLockedNotes: true });
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
const files = await fs.readdir(`${exportDir()}/folder`);
|
||||
expect(files.length).toBe(2);
|
||||
for (const file of files) {
|
||||
expect(await fs.readFile(`${exportDir()}/folder/${file}`, 'utf-8')).toContain('secret');
|
||||
}
|
||||
});
|
||||
|
||||
it('should skip locked notes rather than write their ciphertext when the feature flag is turned off partway through', async () => {
|
||||
await setUpUnlockedSession();
|
||||
const folder = await Folder.save({ title: 'folder' });
|
||||
await saveLockedNotes(folder.id, ['one', 'two']);
|
||||
|
||||
const spy = onFirstGatedCall('load', () => Setting.setValue('featureFlag.noteLock', false));
|
||||
try {
|
||||
await expect(InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Markdown, decryptLockedNotes: true })).rejects.toThrow('2 locked notes could not be unlocked and were not exported.');
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
expect(await fs.pathExists(`${exportDir()}/folder`) ? await fs.readdir(`${exportDir()}/folder`) : []).toEqual([]);
|
||||
});
|
||||
|
||||
it('should skip and count locked notes when the session is locked', async () => {
|
||||
await setUpUnlockedSession();
|
||||
const folder = await Folder.save({ title: 'folder' });
|
||||
await Note.save({ title: 'plain', body: 'plain text', parent_id: folder.id });
|
||||
const locked = await Note.save({ title: 'locked', body: 'secret', parent_id: folder.id });
|
||||
await lockNote(locked.id);
|
||||
NoteLockSession.instance().lock();
|
||||
|
||||
const result = await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Markdown, decryptLockedNotes: true });
|
||||
|
||||
expect(result.warnings).toEqual(['1 locked note(s) could not be unlocked and were not exported']);
|
||||
expect(await fs.readdir(`${exportDir()}/folder`)).toEqual(['plain.md']);
|
||||
});
|
||||
|
||||
it('should leave locked notes out without a warning when asked to, even with the session unlocked', async () => {
|
||||
await setUpUnlockedSession();
|
||||
const folder = await Folder.save({ title: 'folder' });
|
||||
await Note.save({ title: 'plain', body: 'plain text', parent_id: folder.id });
|
||||
const locked = await Note.save({ title: 'locked', body: 'secret', parent_id: folder.id });
|
||||
await lockNote(locked.id);
|
||||
|
||||
const result = await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Markdown, skipLockedNotes: true });
|
||||
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(await fs.readdir(`${exportDir()}/folder`)).toEqual(['plain.md']);
|
||||
});
|
||||
|
||||
it('should not decrypt locked notes for a caller that did not ask, even with the session unlocked', async () => {
|
||||
await setUpUnlockedSession();
|
||||
const folder = await Folder.save({ title: 'folder' });
|
||||
await Note.save({ title: 'plain', body: 'plain text', parent_id: folder.id });
|
||||
const locked = await Note.save({ title: 'locked', body: 'secret', parent_id: folder.id });
|
||||
await lockNote(locked.id);
|
||||
|
||||
// As the exportFolders command does when a plugin runs it.
|
||||
const result = await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Markdown });
|
||||
|
||||
expect(result.warnings).toEqual(['1 locked note(s) could not be unlocked and were not exported']);
|
||||
expect(await fs.readdir(`${exportDir()}/folder`)).toEqual(['plain.md']);
|
||||
});
|
||||
|
||||
it('should skip a locked note whose content cannot be decrypted', async () => {
|
||||
await setUpUnlockedSession();
|
||||
const folder = await Folder.save({ title: 'folder' });
|
||||
await Note.save({ title: 'broken', body: 'not ciphertext', parent_id: folder.id, is_locked: 1 });
|
||||
|
||||
await expect(InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Markdown, decryptLockedNotes: true })).rejects.toThrow('1 locked note could not be unlocked and was not exported.');
|
||||
});
|
||||
|
||||
it('should not write the key file when no note is locked', async () => {
|
||||
await setUpUnlockedSession();
|
||||
const folder = await Folder.save({ title: 'folder' });
|
||||
await Note.save({ title: 'note', body: 'plain body', parent_id: folder.id });
|
||||
|
||||
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Raw });
|
||||
|
||||
expect(await fs.pathExists(`${exportDir()}/${noteLockKeyFileName}`)).toBe(false);
|
||||
});
|
||||
|
||||
it('should write the key file for a note locked with the profile key even with note lock disabled', async () => {
|
||||
await setUpUnlockedSession();
|
||||
const folder = await Folder.save({ title: 'folder' });
|
||||
const note = await Note.save({ title: 'note', body: 'secret', parent_id: folder.id });
|
||||
await lockNote(note.id);
|
||||
|
||||
Setting.setValue('featureFlag.noteLock', false);
|
||||
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Raw });
|
||||
|
||||
expect(JSON.parse(await fs.readFile(`${exportDir()}/${noteLockKeyFileName}`, 'utf-8')).id).toBe(NoteLockKey.instance().load().id);
|
||||
});
|
||||
|
||||
it('should not write the key file when the locked notes use another key', async () => {
|
||||
await setUpUnlockedSession();
|
||||
const otherKey = await encryptionService().generateMasterKey('other');
|
||||
const body = await encryptionService().encryptString('secret', {
|
||||
masterKeyId: '0123456789abcdef0123456789abcdef',
|
||||
decryptedMasterKey: await encryptionService().decryptMasterKeyContent(otherKey, 'other'),
|
||||
isNoteLock: true,
|
||||
});
|
||||
const folder = await Folder.save({ title: 'folder' });
|
||||
await Note.save({ title: 'note', body, parent_id: folder.id, is_locked: 1 });
|
||||
|
||||
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Raw });
|
||||
|
||||
expect(await fs.pathExists(`${exportDir()}/${noteLockKeyFileName}`)).toBe(false);
|
||||
});
|
||||
|
||||
it('should import a backup from the same profile with locked notes unchanged', async () => {
|
||||
await setUpUnlockedSession();
|
||||
const folder = await Folder.save({ title: 'folder' });
|
||||
const { note, resource } = await createNoteAndResource({ parentId: folder.id });
|
||||
await Note.save({ id: note.id, title: 'note', body: `secret ${note.body}` });
|
||||
await lockNote(note.id);
|
||||
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Raw });
|
||||
|
||||
const result = await InteropService.instance().import({ path: exportDir(), format: 'raw' });
|
||||
|
||||
const imported = (await Note.all()).find(n => n.id !== note.id && !!n.is_locked);
|
||||
expect(imported.body).not.toContain('secret');
|
||||
expect((await Note.load(imported.id, { useNoteLock: true })).body).toContain('secret');
|
||||
expect(result.warnings).toEqual([]);
|
||||
|
||||
// The extracted list follows the remapped resource id, so the resource stays associated.
|
||||
const importedResourceIds = Note.unserializeExtractedResourceIds(imported.extracted_resource_ids);
|
||||
expect(importedResourceIds.length).toBe(1);
|
||||
expect(importedResourceIds[0]).not.toBe(resource.id);
|
||||
expect(!!(await Resource.load(importedResourceIds[0]))).toBe(true);
|
||||
});
|
||||
|
||||
it('should re-encrypt imported locked notes for this profile through the key handler', async () => {
|
||||
await setUpUnlockedSession('old password');
|
||||
const folder = await Folder.save({ title: 'folder' });
|
||||
const note = await Note.save({ title: 'note', body: 'secret old', parent_id: folder.id });
|
||||
await lockNote(note.id);
|
||||
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Raw });
|
||||
|
||||
await rotateProfileKey('new password');
|
||||
|
||||
const result = await InteropService.instance().import({
|
||||
path: exportDir(),
|
||||
format: 'raw',
|
||||
onNoteLockKey: keyFile => NoteLockKey.instance().decrypt('old password', keyFile),
|
||||
});
|
||||
|
||||
const imported = (await Note.all()).find(n => n.id !== note.id && !!n.is_locked);
|
||||
expect(imported.body).not.toContain('secret');
|
||||
expect((await Note.load(imported.id, { useNoteLock: true })).body).toBe('secret old');
|
||||
expect(result.warnings.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should re-encrypt every imported locked note when the session is locked partway through', async () => {
|
||||
await setUpUnlockedSession('old password');
|
||||
const folder = await Folder.save({ title: 'folder' });
|
||||
await saveLockedNotes(folder.id, ['one', 'two']);
|
||||
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Raw });
|
||||
const originalIds = (await Note.all()).map(n => n.id);
|
||||
|
||||
await rotateProfileKey('new password');
|
||||
|
||||
const spy = onFirstGatedCall('save', () => NoteLockSession.instance().lock());
|
||||
try {
|
||||
await InteropService.instance().import({
|
||||
path: exportDir(),
|
||||
format: 'raw',
|
||||
onNoteLockKey: keyFile => NoteLockKey.instance().decrypt('old password', keyFile),
|
||||
});
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
await NoteLockSession.instance().unlock('new password');
|
||||
const imported = (await Note.all()).filter(n => !originalIds.includes(n.id));
|
||||
expect(imported.length).toBe(2);
|
||||
for (const n of imported) expect((await Note.load(n.id, { useNoteLock: true })).body).toMatch(/^secret /);
|
||||
});
|
||||
|
||||
it('should stop the import without writing plain text when the feature flag is turned off while a locked note is re-encrypted', async () => {
|
||||
await setUpUnlockedSession('old password');
|
||||
const folder = await Folder.save({ title: 'folder' });
|
||||
const note = await Note.save({ title: 'note', body: 'secret old', parent_id: folder.id });
|
||||
await lockNote(note.id);
|
||||
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Raw });
|
||||
|
||||
await rotateProfileKey('new password');
|
||||
|
||||
const save = Note.save.bind(Note);
|
||||
const spy = jest.spyOn(Note, 'save').mockImplementation((o, options) => {
|
||||
Setting.setValue('featureFlag.noteLock', false);
|
||||
return save(o, options);
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(InteropService.instance().import({
|
||||
path: exportDir(),
|
||||
format: 'raw',
|
||||
onNoteLockKey: keyFile => NoteLockKey.instance().decrypt('old password', keyFile),
|
||||
})).rejects.toThrow('Locked notes cannot be saved while the note lock feature is turned off');
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
expect((await Note.all()).filter(n => n.body.includes('secret'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('should import foreign locked notes unchanged when no key handler is provided', async () => {
|
||||
await setUpUnlockedSession('old password');
|
||||
const folder = await Folder.save({ title: 'folder' });
|
||||
const note = await Note.save({ title: 'note', body: 'secret old', parent_id: folder.id });
|
||||
await lockNote(note.id);
|
||||
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Raw });
|
||||
|
||||
await rotateProfileKey('new password');
|
||||
|
||||
const result = await InteropService.instance().import({ path: exportDir(), format: 'raw' });
|
||||
|
||||
const imported = (await Note.all()).find(n => n.id !== note.id && !!n.is_locked);
|
||||
expect(imported.body).not.toContain('secret');
|
||||
await expect(Note.load(imported.id, { useNoteLock: true })).rejects.toThrow();
|
||||
expect(result.warnings).toEqual(['The locked notes in this backup have not been migrated to the current note lock key and will be unreadable']);
|
||||
});
|
||||
|
||||
it('should warn when a backup with locked notes is imported without a handler into a profile with no note lock key', async () => {
|
||||
await setUpUnlockedSession();
|
||||
const folder = await Folder.save({ title: 'folder' });
|
||||
const note = await Note.save({ title: 'note', body: 'secret', parent_id: folder.id });
|
||||
await lockNote(note.id);
|
||||
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Raw });
|
||||
|
||||
await setupDatabaseAndSynchronizer(2);
|
||||
await switchClient(2);
|
||||
NoteLockSession.destroyInstance();
|
||||
NoteLockKey.destroyInstance();
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
const result = await InteropService.instance().import({ path: exportDir(), format: 'raw' });
|
||||
|
||||
expect(result.warnings).toEqual(['The locked notes in this backup cannot be read, because no note lock key has been set up on this profile']);
|
||||
});
|
||||
|
||||
it('should keep locked notes unchanged and warn when the provided key does not fit', async () => {
|
||||
await setUpUnlockedSession('old password');
|
||||
const folder = await Folder.save({ title: 'folder' });
|
||||
const note = await Note.save({ title: 'note', body: 'secret old', parent_id: folder.id });
|
||||
await lockNote(note.id);
|
||||
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Raw });
|
||||
|
||||
await rotateProfileKey('new password');
|
||||
|
||||
const result = await InteropService.instance().import({
|
||||
path: exportDir(),
|
||||
format: 'raw',
|
||||
onNoteLockKey: async keyFile => ({ id: keyFile.id, plainText: 'not the key' }),
|
||||
});
|
||||
|
||||
const imported = (await Note.all()).find(n => n.id !== note.id && !!n.is_locked);
|
||||
expect(imported.body).not.toContain('secret');
|
||||
expect(result.warnings.some(w => w.includes('could not be unlocked'))).toBe(true);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ModuleType, FileSystemItem, ImportModuleOutputFormat, ImportOptions, ExportOptions, ImportExportResult, ExportProgressState, ExportModuleOutputFormat } from './types';
|
||||
import shim from '../../shim';
|
||||
import { _ } from '../../locale';
|
||||
import { _, _n } from '../../locale';
|
||||
import BaseItem from '../../models/BaseItem';
|
||||
import BaseModel, { ModelType } from '../../BaseModel';
|
||||
import Resource from '../../models/Resource';
|
||||
@@ -8,6 +8,11 @@ import Folder from '../../models/Folder';
|
||||
import NoteTag from '../../models/NoteTag';
|
||||
import Note from '../../models/Note';
|
||||
import * as ArrayUtils from '../../ArrayUtils';
|
||||
import isNoteLockEnabled from '../noteLock/isNoteLockEnabled';
|
||||
import NoteLockNote, { NoteLockNoteEntity } from '../noteLock/NoteLockNote';
|
||||
import { DecryptedNoteLockKey } from '../noteLock/NoteLockKey';
|
||||
import NoteLockSession from '../noteLock/NoteLockSession';
|
||||
import { NoteEntity } from '../database/types';
|
||||
import InteropService_Importer_Jex from './InteropService_Importer_Jex';
|
||||
import InteropService_Importer_Md from './InteropService_Importer_Md';
|
||||
import InteropService_Importer_Md_frontmatter from './InteropService_Importer_Md_frontmatter';
|
||||
@@ -360,6 +365,19 @@ export default class InteropService {
|
||||
}
|
||||
}
|
||||
|
||||
private async decryptedNoteForExport_(noteId: string, noteLockKey: DecryptedNoteLockKey): Promise<NoteEntity|null> {
|
||||
if (!noteLockKey) return null;
|
||||
try {
|
||||
const decrypted: NoteLockNoteEntity = await Note.load(noteId, { useNoteLock: true, noteLockKey });
|
||||
// Without the marker the load was not gated, as when the feature is turned off midway, so the body is still ciphertext.
|
||||
if (!decrypted.isDecrypted) return null;
|
||||
delete decrypted.isDecrypted;
|
||||
return { ...decrypted, is_locked: 0, extracted_resource_ids: '' };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async export(options: ExportOptions): Promise<ImportExportResult> {
|
||||
options = {
|
||||
format: ExportModuleOutputFormat.Jex,
|
||||
@@ -387,6 +405,14 @@ export default class InteropService {
|
||||
|
||||
const exportedNoteIds = [];
|
||||
let resourceIds: string[] = [];
|
||||
// Backups keep locked notes encrypted; other formats export them decrypted only when decryptLockedNotes is set.
|
||||
const keepsLockedNotes = options.format === ExportModuleOutputFormat.Raw || options.format === ExportModuleOutputFormat.Jex;
|
||||
let lockedNotesSkipped = 0;
|
||||
// Read once, so enabling the feature partway through cannot leave the locked notes queued before it as ciphertext.
|
||||
// Turning it off partway is caught by the gated load in decryptedNoteForExport_.
|
||||
const noteLockEnabled = isNoteLockEnabled();
|
||||
// Captured at the start, so locking the session partway through does not skip the remaining locked notes.
|
||||
const noteLockKey = NoteLockSession.instance().isUnlocked() ? NoteLockSession.instance().decryptedKey() : null;
|
||||
|
||||
// Recursively get all the folders that have valid parents
|
||||
const folderIds = await Folder.childrenIds('');
|
||||
@@ -412,15 +438,32 @@ export default class InteropService {
|
||||
for (let noteIndex = 0; noteIndex < noteIds.length; noteIndex++) {
|
||||
const noteId = noteIds[noteIndex];
|
||||
if (sourceNoteIds.length && sourceNoteIds.indexOf(noteId) < 0) continue;
|
||||
const note = await Note.load(noteId);
|
||||
let note = await Note.load(noteId);
|
||||
if (noteLockEnabled && NoteLockNote.isLocked(note) && !keepsLockedNotes) {
|
||||
if (options.skipLockedNotes) continue;
|
||||
const decrypted = options.decryptLockedNotes ? await this.decryptedNoteForExport_(noteId, noteLockKey) : null;
|
||||
if (!decrypted) {
|
||||
lockedNotesSkipped++;
|
||||
continue;
|
||||
}
|
||||
note = decrypted;
|
||||
}
|
||||
await queueExportItem(BaseModel.TYPE_NOTE, note);
|
||||
exportedNoteIds.push(noteId);
|
||||
|
||||
const rids = await Note.linkedResourceIds(note.body);
|
||||
// A locked note's body is ciphertext, so its resource ids come from the extracted list.
|
||||
const rids = noteLockEnabled && NoteLockNote.isLocked(note)
|
||||
? Note.unserializeExtractedResourceIds(note.extracted_resource_ids)
|
||||
: await Note.linkedResourceIds(note.body);
|
||||
resourceIds = resourceIds.concat(rids);
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing would be written, so a single-file export would otherwise fail on the file it never wrote.
|
||||
if (lockedNotesSkipped && !exportedNoteIds.length) {
|
||||
throw new Error(_n('%d locked note could not be unlocked and was not exported.', '%d locked notes could not be unlocked and were not exported.', lockedNotesSkipped, lockedNotesSkipped));
|
||||
}
|
||||
|
||||
resourceIds = ArrayUtils.unique(resourceIds);
|
||||
|
||||
for (let i = 0; i < resourceIds.length; i++) {
|
||||
@@ -515,6 +558,8 @@ export default class InteropService {
|
||||
options.onProgress?.(ExportProgressState.Closing, null);
|
||||
await exporter.close();
|
||||
|
||||
if (lockedNotesSkipped) result.warnings.push(`${lockedNotesSkipped} locked note(s) could not be unlocked and were not exported`);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@ export default class InteropService_Exporter_Jex extends InteropService_Exporter
|
||||
}
|
||||
|
||||
public async close() {
|
||||
await this.rawExporter_.close();
|
||||
|
||||
const stats = await shim.fsDriver().readDirStats(this.tempDir_, { recursive: true });
|
||||
const filePaths = stats.filter(a => !a.isDirectory()).map(a => a.path);
|
||||
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import InteropService_Exporter_Base from './InteropService_Exporter_Base';
|
||||
import BaseItem from '../../models/BaseItem';
|
||||
import BaseModel from '../../BaseModel';
|
||||
import { basename } from '../../path-utils';
|
||||
import shim from '../../shim';
|
||||
import { BaseItemEntity, ResourceEntity } from '../database/types';
|
||||
import { BaseItemEntity, NoteEntity, ResourceEntity } from '../database/types';
|
||||
import EncryptionService from '../e2ee/EncryptionService';
|
||||
import NoteLockNote from '../noteLock/NoteLockNote';
|
||||
import NoteLockKey, { noteLockKeyFileName } from '../noteLock/NoteLockKey';
|
||||
|
||||
export default class InteropService_Exporter_Raw extends InteropService_Exporter_Base {
|
||||
|
||||
private destDir_: string;
|
||||
private resourceDir_: string;
|
||||
private hasLockedNotes_ = false;
|
||||
|
||||
public async init(destDir: string) {
|
||||
this.destDir_ = destDir;
|
||||
@@ -17,7 +22,21 @@ export default class InteropService_Exporter_Raw extends InteropService_Exporter
|
||||
await shim.fsDriver().mkdir(this.resourceDir_);
|
||||
}
|
||||
|
||||
// Not behind the feature flag: importers that predate note lock ignore the key file. Only the profile's key can be exported,
|
||||
// so notes locked with another key do not bring it in.
|
||||
private async isLockedWithProfileKey_(note: NoteEntity) {
|
||||
if (!NoteLockNote.isLocked(note)) return false;
|
||||
const keyId = NoteLockKey.instance().load()?.id;
|
||||
if (!keyId) return false;
|
||||
try {
|
||||
return (await EncryptionService.instance().decodeHeaderString(note.body, true)).masterKeyId === keyId;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async processItem(itemType: number, item: BaseItemEntity) {
|
||||
if (itemType === BaseModel.TYPE_NOTE && !this.hasLockedNotes_) this.hasLockedNotes_ = await this.isLockedWithProfileKey_(item as NoteEntity);
|
||||
const ItemClass = BaseItem.getClassByItemType(itemType);
|
||||
const serialized = await ItemClass.serialize(item);
|
||||
const filePath = `${this.destDir_}/${ItemClass.systemPath(item)}`;
|
||||
@@ -29,5 +48,11 @@ export default class InteropService_Exporter_Raw extends InteropService_Exporter
|
||||
await shim.fsDriver().copy(filePath, destResourcePath);
|
||||
}
|
||||
|
||||
public async close() {}
|
||||
public async close() {
|
||||
// The key travels with the backup, so a backup plus the password is always enough to recover.
|
||||
if (this.hasLockedNotes_) {
|
||||
const key = NoteLockKey.instance().load();
|
||||
if (key) await shim.fsDriver().writeFile(`${this.destDir_}/${noteLockKeyFileName}`, JSON.stringify(key), 'utf-8');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,13 @@ const { sprintf } = require('sprintf-js');
|
||||
import shim from '../../shim';
|
||||
import { Stat } from '../../fs-driver-base';
|
||||
import { ResourceEntity } from '../database/types';
|
||||
import { MasterKeyEntity } from '../e2ee/types';
|
||||
import { fileExtension } from '../../path-utils';
|
||||
import uuid from '../../uuid';
|
||||
import isNoteLockEnabled from '../noteLock/isNoteLockEnabled';
|
||||
import NoteLockService from '../noteLock/NoteLockService';
|
||||
import NoteLockSession from '../noteLock/NoteLockSession';
|
||||
import NoteLockKey, { DecryptedNoteLockKey, noteLockKeyFileName } from '../noteLock/NoteLockKey';
|
||||
|
||||
export default class InteropService_Importer_Raw extends InteropService_Importer_Base {
|
||||
public async exec(result: ImportExportResult) {
|
||||
@@ -37,6 +42,24 @@ export default class InteropService_Importer_Raw extends InteropService_Importer
|
||||
|
||||
const stats = await shim.fsDriver().readDirStats(this.sourcePath_);
|
||||
|
||||
// A backup with locked notes carries their encrypted key (see the raw exporter).
|
||||
let importNoteLockKey: DecryptedNoteLockKey = null;
|
||||
let undecryptableNotes = 0;
|
||||
if (isNoteLockEnabled() && await shim.fsDriver().exists(`${this.sourcePath_}/${noteLockKeyFileName}`)) {
|
||||
const keyFile: MasterKeyEntity = JSON.parse(await shim.fsDriver().readFile(`${this.sourcePath_}/${noteLockKeyFileName}`));
|
||||
const profileKeyId = NoteLockKey.instance().load()?.id;
|
||||
if (keyFile?.id && this.options_.onNoteLockKey) {
|
||||
importNoteLockKey = await this.options_.onNoteLockKey(keyFile);
|
||||
} else if (keyFile?.id && !profileKeyId) {
|
||||
// Without a handler, as for the CLI, nothing can update the notes.
|
||||
result.warnings.push('The locked notes in this backup cannot be read, because no note lock key has been set up on this profile');
|
||||
} else if (keyFile?.id && keyFile.id !== profileKeyId) {
|
||||
result.warnings.push('The locked notes in this backup have not been migrated to the current note lock key and will be unreadable');
|
||||
}
|
||||
}
|
||||
// Captured once the prompts have unlocked the session, so locking it partway through does not fail the remaining notes.
|
||||
const profileNoteLockKey = importNoteLockKey ? NoteLockSession.instance().decryptedKey() : null;
|
||||
|
||||
const folderExists = function(stats: Stat[], folderId: string) {
|
||||
folderId = folderId.toLowerCase();
|
||||
for (let i = 0; i < stats.length; i++) {
|
||||
@@ -87,6 +110,7 @@ export default class InteropService_Importer_Raw extends InteropService_Importer
|
||||
const item = await BaseItem.unserialize(content);
|
||||
const itemType = item.type_;
|
||||
const ItemClass = BaseItem.itemClass(item);
|
||||
let useNoteLockSave = false;
|
||||
|
||||
delete item.type_;
|
||||
|
||||
@@ -97,6 +121,26 @@ export default class InteropService_Importer_Raw extends InteropService_Importer
|
||||
item.id = itemIdMap[item.id];
|
||||
item.parent_id = itemIdMap[item.parent_id];
|
||||
item.body = await replaceLinkedItemIds(item.body);
|
||||
|
||||
if (isNoteLockEnabled() && item.is_locked) {
|
||||
// The linked id rewrite cannot reach a ciphertext body, so remap the extracted
|
||||
// list instead, keeping the imported resources safe from orphan cleanup.
|
||||
item.extracted_resource_ids = Note.serializeExtractedResourceIds(Note.unserializeExtractedResourceIds(item.extracted_resource_ids).map(id => {
|
||||
if (!itemIdMap[id]) itemIdMap[id] = uuid.create();
|
||||
return itemIdMap[id];
|
||||
}));
|
||||
if (importNoteLockKey) {
|
||||
try {
|
||||
const plainBody = await NoteLockService.withDecryptedKey(scoped => scoped.decryptString(item.body), importNoteLockKey);
|
||||
item.body = await replaceLinkedItemIds(plainBody);
|
||||
// Marked like a gated load, so a save after the feature is turned off fails instead of writing it as plain text.
|
||||
item.isDecrypted = true;
|
||||
useNoteLockSave = true;
|
||||
} catch {
|
||||
undecryptableNotes++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (itemType === BaseModel.TYPE_FOLDER) {
|
||||
if (destinationFolderId) continue;
|
||||
|
||||
@@ -138,7 +182,7 @@ export default class InteropService_Importer_Raw extends InteropService_Importer
|
||||
continue;
|
||||
}
|
||||
|
||||
await ItemClass.save(item, { isNew: true, autoTimestamp: false });
|
||||
await ItemClass.save(item, { isNew: true, autoTimestamp: false, useNoteLock: useNoteLockSave, noteLockKey: profileNoteLockKey });
|
||||
} catch (error) {
|
||||
if (error.code === 'malformedItem') {
|
||||
result.warnings.push(sprintf('Skipped malformed item: %s: %s', stat.path, error.message));
|
||||
@@ -149,6 +193,8 @@ export default class InteropService_Importer_Raw extends InteropService_Importer
|
||||
}
|
||||
}
|
||||
|
||||
if (undecryptableNotes) result.warnings.push(`${undecryptableNotes} locked note(s) could not be unlocked with the provided key and were imported unchanged`);
|
||||
|
||||
for (let i = 0; i < noteTagsToCreate.length; i++) {
|
||||
const noteTag = noteTagsToCreate[i];
|
||||
const newNoteId = itemIdMap[noteTag.note_id];
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { FolderEntity } from '../database/types';
|
||||
import { PluginStates } from '../plugins/reducer';
|
||||
import { MasterKeyEntity } from '../e2ee/types';
|
||||
import { DecryptedNoteLockKey } from '../noteLock/NoteLockKey';
|
||||
|
||||
export interface CustomImportContext {
|
||||
sourcePath: string;
|
||||
@@ -54,6 +56,10 @@ export interface ImportOptions {
|
||||
xmlSerializer?: XMLSerializer;
|
||||
|
||||
defaultFolderTitle?: string;
|
||||
|
||||
// Called when a backup carries a note lock key. Return it decrypted to re-encrypt the imported
|
||||
// locked notes for this profile, or null to import them unchanged.
|
||||
onNoteLockKey?: (key: MasterKeyEntity)=> Promise<DecryptedNoteLockKey|null>;
|
||||
}
|
||||
|
||||
export enum ExportProgressState {
|
||||
@@ -76,6 +82,11 @@ export interface ExportOptions {
|
||||
customCss?: string;
|
||||
packIntoSingleFile?: boolean;
|
||||
shouldEmbedOnlyImages?: boolean;
|
||||
// The user chose to export without the locked notes, so they are left out without a warning.
|
||||
skipLockedNotes?: boolean;
|
||||
// Locked notes are left out with a warning unless this is set, so only an export the user confirmed writes them as
|
||||
// plain text, and the export commands plugins can run do not.
|
||||
decryptLockedNotes?: boolean;
|
||||
|
||||
onProgress?: OnExportProgressCallback;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import uuid from '../../uuid';
|
||||
import Setting from '../../models/Setting';
|
||||
import EncryptionService from '../e2ee/EncryptionService';
|
||||
import { MasterKeyEntity } from '../e2ee/types';
|
||||
import { localSyncInfo, saveLocalSyncInfo } from '../synchronizer/syncInfoUtils';
|
||||
@@ -8,6 +9,9 @@ export interface DecryptedNoteLockKey {
|
||||
plainText: string;
|
||||
}
|
||||
|
||||
// Importers that predate note lock skip this file, since they only read .md files.
|
||||
export const noteLockKeyFileName = 'note_lock_key.json';
|
||||
|
||||
export default class NoteLockKey {
|
||||
|
||||
public static instance_: NoteLockKey = null;
|
||||
@@ -39,6 +43,8 @@ export default class NoteLockKey {
|
||||
|
||||
const syncInfo = localSyncInfo();
|
||||
syncInfo.noteLockKey = key;
|
||||
// Only a key migration replaces the sync migration id, so a reset keeps the lineage it belongs to.
|
||||
if (!syncInfo.syncMigrationId) syncInfo.syncMigrationId = uuid.create();
|
||||
saveLocalSyncInfo(syncInfo);
|
||||
|
||||
return key;
|
||||
@@ -56,7 +62,13 @@ export default class NoteLockKey {
|
||||
// Rotate through NoteLockSession.reset() rather than calling this directly, so the session locks
|
||||
// and drops the old key as part of the rotation.
|
||||
public async reset(password: string) {
|
||||
return this.save(await this.encryptionService_.generateMasterKey(password));
|
||||
const generated = await this.encryptionService_.generateMasterKey(password);
|
||||
// Read once the key is ready, so a sync that finished meanwhile counts; a second reset before that sync keeps the first backup.
|
||||
if (!Setting.value('noteLock.keyIdToReset')) Setting.setValue('noteLock.keyIdToReset', this.load()?.id ?? '');
|
||||
const key = this.save(generated);
|
||||
// Lets the next sync push the new key to the target instead of adopting the old one back.
|
||||
Setting.setValue('noteLock.passwordReset', true);
|
||||
return key;
|
||||
}
|
||||
|
||||
public async changePassword(currentPassword: string, newPassword: string) {
|
||||
@@ -77,8 +89,8 @@ export default class NoteLockKey {
|
||||
return this.changePassword(password, password);
|
||||
}
|
||||
|
||||
public async decrypt(password: string): Promise<DecryptedNoteLockKey> {
|
||||
const key = this.load();
|
||||
public async decrypt(password: string, key: MasterKeyEntity = null): Promise<DecryptedNoteLockKey> {
|
||||
if (!key) key = this.load();
|
||||
if (!key) throw new Error('Note lock key has not been created');
|
||||
if (!key.id) throw new Error('Note lock key does not have an ID');
|
||||
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import Setting from '../../models/Setting';
|
||||
import Note from '../../models/Note';
|
||||
import uuid from '../../uuid';
|
||||
import { afterAllCleanUp, db, encryptionService, setupDatabaseAndSynchronizer, switchClient } from '../../testing/test-utils';
|
||||
import EncryptionService from '../e2ee/EncryptionService';
|
||||
import { MasterKeyEntity } from '../e2ee/types';
|
||||
import { localSyncInfo, noteLockKeyConflict } from '../synchronizer/syncInfoUtils';
|
||||
import NoteLockKey, { DecryptedNoteLockKey } from './NoteLockKey';
|
||||
import NoteLockNote from './NoteLockNote';
|
||||
import NoteLockService from './NoteLockService';
|
||||
import NoteLockSession from './NoteLockSession';
|
||||
import { confirmAndSkipNoteLockKeyMigration, finishNoteLockKeyMigration, migrateLockedNotes, notMigratedMessage, startNoteLockKeyMigration } from './NoteLockKeyMigration';
|
||||
import eventManager, { EventName, NoteLockSessionChangeEvent } from '../../eventManager';
|
||||
import shim from '../../shim';
|
||||
|
||||
const localPassword = '111111';
|
||||
const targetPassword = '222222';
|
||||
const targetSyncMigrationId = '0123456789abcdef0123456789abcdef';
|
||||
|
||||
const generateKey = async (password: string): Promise<MasterKeyEntity> => ({ ...await encryptionService().generateMasterKey(password), id: uuid.create() });
|
||||
|
||||
const decryptKey = async (key: MasterKeyEntity, password: string): Promise<DecryptedNoteLockKey> => ({ id: key.id, plainText: await encryptionService().decryptMasterKeyContent(key, password) });
|
||||
|
||||
// Encrypts outside the session so the fixture can use a key the session would refuse.
|
||||
const noteLockedWith = async (key: DecryptedNoteLockKey, body: string) => {
|
||||
const cipherText = await encryptionService().encryptString(body, { masterKeyId: key.id, decryptedMasterKey: key.plainText, isNoteLock: true });
|
||||
return Note.save({ title: body, body: cipherText, is_locked: 1 });
|
||||
};
|
||||
|
||||
const bodyDecryptedWith = async (noteId: string, key: DecryptedNoteLockKey) => (await NoteLockNote.decryptBody(await Note.load(noteId), key)).body;
|
||||
|
||||
const storedBodies = async () => (await Note.all({ fields: ['id', 'body'] })).map(n => n.body).sort();
|
||||
|
||||
describe('NoteLockKeyMigration', () => {
|
||||
|
||||
let targetKey: MasterKeyEntity;
|
||||
let decryptedTargetKey: DecryptedNoteLockKey;
|
||||
let decryptedLocalKey: DecryptedNoteLockKey;
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
NoteLockService.destroyInstance();
|
||||
NoteLockSession.destroyInstance();
|
||||
NoteLockKey.destroyInstance();
|
||||
EncryptionService.instance_ = encryptionService();
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
|
||||
await NoteLockKey.instance().create(localPassword);
|
||||
await NoteLockSession.instance().unlock(localPassword);
|
||||
decryptedLocalKey = await NoteLockKey.instance().decrypt(localPassword);
|
||||
targetKey = await generateKey(targetPassword);
|
||||
decryptedTargetKey = await decryptKey(targetKey, targetPassword);
|
||||
Setting.setValue('noteLock.conflictNoteLockKey', { noteLockKey: targetKey, syncMigrationId: targetSyncMigrationId });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await afterAllCleanUp();
|
||||
});
|
||||
|
||||
it('should re-encrypt the notes locked with the local key and report the rest', async () => {
|
||||
const note1 = await Note.save({ title: 'one', body: 'one', is_locked: 1 }, { useNoteLock: true });
|
||||
const note2 = await Note.save({ title: 'two', body: 'two', is_locked: 1 }, { useNoteLock: true });
|
||||
const staleKey = await generateKey('333333');
|
||||
const staleNote = await noteLockedWith(await decryptKey(staleKey, '333333'), 'stale');
|
||||
const corruptCipherText = (await Note.load(note1.id)).body;
|
||||
const corruptNote = await Note.save({ title: 'corrupt', body: `${corruptCipherText.slice(0, -8)}xxxxxxxx`, is_locked: 1 });
|
||||
await Note.save({ title: 'plain', body: 'plain' });
|
||||
const localKeyBefore = NoteLockKey.instance().load();
|
||||
|
||||
const result = await migrateLockedNotes(localPassword, targetPassword);
|
||||
|
||||
expect(result).toEqual({ migrated: 2, skipped: 1, failed: 1, awaitingDecryption: 0 });
|
||||
expect(await bodyDecryptedWith(note1.id, decryptedTargetKey)).toBe('one');
|
||||
expect(await bodyDecryptedWith(note2.id, decryptedTargetKey)).toBe('two');
|
||||
expect((await Note.load(staleNote.id)).body).toBe(staleNote.body);
|
||||
expect((await Note.load(corruptNote.id)).body).toBe(corruptNote.body);
|
||||
expect((await Note.load(note1.id)).is_locked).toBe(1);
|
||||
// Nothing is adopted until the user finishes, so an interrupted migration loses no key.
|
||||
expect(NoteLockKey.instance().load()).toEqual(localKeyBefore);
|
||||
expect(noteLockKeyConflict()).toEqual({ noteLockKey: targetKey, syncMigrationId: targetSyncMigrationId });
|
||||
});
|
||||
|
||||
it('should leave notes already under the target key alone on retry', async () => {
|
||||
await Note.save({ title: 'one', body: 'one', is_locked: 1 }, { useNoteLock: true });
|
||||
const staleKey = await generateKey('333333');
|
||||
await noteLockedWith(await decryptKey(staleKey, '333333'), 'stale');
|
||||
await migrateLockedNotes(localPassword, targetPassword);
|
||||
const bodiesAfterFirstRun = await storedBodies();
|
||||
|
||||
expect(await migrateLockedNotes(localPassword, targetPassword)).toEqual({ migrated: 0, skipped: 1, failed: 0, awaitingDecryption: 0 });
|
||||
expect(await storedBodies()).toEqual(bodiesAfterFirstRun);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['local', 'wrong', targetPassword],
|
||||
['target', localPassword, 'wrong'],
|
||||
])('should not touch any note when the %s password is wrong', async (_which, local, target) => {
|
||||
await Note.save({ title: 'one', body: 'one', is_locked: 1 }, { useNoteLock: true });
|
||||
const bodiesBefore = await storedBodies();
|
||||
|
||||
await expect(migrateLockedNotes(local, target)).rejects.toThrow();
|
||||
expect(await storedBodies()).toEqual(bodiesBefore);
|
||||
});
|
||||
|
||||
it('should fail closed when note lock is disabled', async () => {
|
||||
await Note.save({ title: 'one', body: 'one', is_locked: 1 }, { useNoteLock: true });
|
||||
const bodiesBefore = await storedBodies();
|
||||
Setting.setValue('featureFlag.noteLock', false);
|
||||
|
||||
await expect(migrateLockedNotes(localPassword, targetPassword)).rejects.toThrow('not enabled');
|
||||
expect(await storedBodies()).toEqual(bodiesBefore);
|
||||
});
|
||||
|
||||
it('should adopt the target key and lineage, clear the flags and lock the session on finish', async () => {
|
||||
Setting.setValue('noteLock.passwordReset', true);
|
||||
expect(NoteLockSession.instance().isUnlocked()).toBe(true);
|
||||
const events: NoteLockSessionChangeEvent[] = [];
|
||||
const listener = (event: NoteLockSessionChangeEvent) => events.push(event);
|
||||
eventManager.on(EventName.NoteLockSessionChange, listener);
|
||||
|
||||
try {
|
||||
finishNoteLockKeyMigration();
|
||||
|
||||
// The lock is announced right away rather than on the next session check, so the UI follows.
|
||||
expect(events).toEqual([{ unlocked: false }]);
|
||||
expect(NoteLockKey.instance().load()).toEqual(targetKey);
|
||||
expect(localSyncInfo().syncMigrationId).toBe(targetSyncMigrationId);
|
||||
expect(noteLockKeyConflict()).toBeNull();
|
||||
expect(Setting.value('noteLock.passwordReset')).toBe(false);
|
||||
await NoteLockSession.instance().unlock(targetPassword);
|
||||
expect(NoteLockSession.instance().isUnlocked()).toBe(true);
|
||||
} finally {
|
||||
eventManager.off(EventName.NoteLockSessionChange, listener);
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
['Yes', 0, true],
|
||||
['No', 1, false],
|
||||
])('should skip the migration only when Yes is chosen in the warning (answer: %s)', async (_answer, response, skipped) => {
|
||||
const showMessageBox = jest.spyOn(shim, 'showMessageBox').mockResolvedValue(response);
|
||||
const dispatch = jest.fn();
|
||||
|
||||
expect(await confirmAndSkipNoteLockKeyMigration(dispatch)).toBe(skipped);
|
||||
|
||||
expect(showMessageBox.mock.calls[0][0]).toContain('JEX backup');
|
||||
expect(showMessageBox.mock.calls[0][1].buttons).toEqual(['Yes', 'No']);
|
||||
expect(NoteLockKey.instance().load().id).toBe(skipped ? targetKey.id : decryptedLocalKey.id);
|
||||
expect(dispatch).toHaveBeenCalledTimes(skipped ? 1 : 0);
|
||||
showMessageBox.mockRestore();
|
||||
});
|
||||
|
||||
it('should report a background run that starts once the passwords are accepted', async () => {
|
||||
const note = await Note.save({ title: 'one', body: 'one', is_locked: 1 }, { useNoteLock: true });
|
||||
const dispatch = jest.fn();
|
||||
const onStarted = jest.fn();
|
||||
|
||||
await expect(startNoteLockKeyMigration(localPassword, 'wrong', dispatch, onStarted)).rejects.toThrow();
|
||||
expect(onStarted).not.toHaveBeenCalled();
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
|
||||
const corruptNote = await Note.save({ title: 'corrupt', body: `${(await Note.load(note.id)).body.slice(0, -8)}xxxxxxxx`, is_locked: 1 });
|
||||
await startNoteLockKeyMigration(localPassword, targetPassword, dispatch, onStarted);
|
||||
expect(onStarted).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch.mock.calls).toEqual([
|
||||
[{ type: 'NOTE_LOCK_MIGRATION_STATUS_SET', value: { running: true, failed: 0, skipped: 0, awaitingDecryption: 0 } }],
|
||||
[{ type: 'NOTE_LOCK_MIGRATION_STATUS_SET', value: { running: false, failed: 1, skipped: 0, awaitingDecryption: 0 } }],
|
||||
]);
|
||||
expect(noteLockKeyConflict()).not.toBeNull();
|
||||
|
||||
await Note.delete(corruptNote.id);
|
||||
dispatch.mockClear();
|
||||
await startNoteLockKeyMigration(localPassword, targetPassword, dispatch, onStarted);
|
||||
expect(dispatch).toHaveBeenLastCalledWith({ type: 'NOTE_LOCK_MIGRATION_STATUS_SET', value: { running: false, failed: 0, skipped: 0, awaitingDecryption: 0 } });
|
||||
expect(NoteLockKey.instance().load()).toEqual(targetKey);
|
||||
expect(await bodyDecryptedWith(note.id, decryptedTargetKey)).toBe('one');
|
||||
});
|
||||
|
||||
it('should cancel a run with a popup when the feature flag is turned off during it', async () => {
|
||||
await Note.save({ title: 'one', body: 'one', is_locked: 1 }, { useNoteLock: true });
|
||||
const dispatch = jest.fn();
|
||||
const showErrorDialog = jest.spyOn(shim, 'showErrorDialog').mockResolvedValue();
|
||||
const save = Note.save.bind(Note);
|
||||
const saveSpy = jest.spyOn(Note, 'save').mockImplementation((o, options) => {
|
||||
Setting.setValue('featureFlag.noteLock', false);
|
||||
return save(o, options);
|
||||
});
|
||||
|
||||
try {
|
||||
await startNoteLockKeyMigration(localPassword, targetPassword, dispatch, jest.fn());
|
||||
|
||||
expect(dispatch).toHaveBeenLastCalledWith({ type: 'NOTE_LOCK_MIGRATION_STATUS_SET', value: null });
|
||||
expect(showErrorDialog).toHaveBeenCalledWith('The note lock key migration was cancelled because the note lock feature was turned off.');
|
||||
expect(noteLockKeyConflict()).not.toBeNull();
|
||||
} finally {
|
||||
saveSpy.mockRestore();
|
||||
showErrorDialog.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('should keep locked notes closed and refuse a password reset only while a run goes', async () => {
|
||||
await Note.save({ title: 'one', body: 'one', is_locked: 1 }, { useNoteLock: true });
|
||||
let unlockDuringRun: Promise<Error> = null;
|
||||
let resetDuringRun: Promise<Error> = null;
|
||||
await migrateLockedNotes(localPassword, targetPassword, () => {
|
||||
expect(NoteLockSession.instance().isUnlocked()).toBe(false);
|
||||
unlockDuringRun = NoteLockSession.instance().unlock(localPassword).then((): Error => null, (error: Error) => error);
|
||||
resetDuringRun = NoteLockSession.instance().reset('333333').then((): Error => null, (error: Error) => error);
|
||||
});
|
||||
|
||||
expect((await unlockDuringRun)?.message).toContain('note lock key migration is in progress');
|
||||
expect((await resetDuringRun)?.message).toContain('cannot be reset while a note lock key migration is in progress');
|
||||
await NoteLockSession.instance().unlock(localPassword);
|
||||
expect(NoteLockSession.instance().isUnlocked()).toBe(true);
|
||||
});
|
||||
|
||||
it('should refuse to migrate or finish without a parked key', async () => {
|
||||
Setting.setValue('noteLock.conflictNoteLockKey', {});
|
||||
|
||||
await expect(migrateLockedNotes(localPassword, targetPassword)).rejects.toThrow('No note lock key conflict');
|
||||
expect(() => finishNoteLockKeyMigration()).toThrow('No note lock key conflict');
|
||||
expect(NoteLockKey.instance().load().id).toBe(decryptedLocalKey.id);
|
||||
});
|
||||
it('should keep the date a note was last changed by the user', async () => {
|
||||
const note = await Note.save({ title: 'one', body: 'one', is_locked: 1 }, { useNoteLock: true });
|
||||
await db().exec('UPDATE notes SET user_updated_time = 1000, updated_time = 1000 WHERE id = ?', [note.id]);
|
||||
|
||||
expect(await migrateLockedNotes(localPassword, targetPassword)).toEqual({ migrated: 1, skipped: 0, failed: 0, awaitingDecryption: 0 });
|
||||
|
||||
const migrated = await Note.load(note.id);
|
||||
expect(migrated.user_updated_time).toBe(1000);
|
||||
// Still moves, so the re-encrypted note is synced.
|
||||
expect(migrated.updated_time).toBeGreaterThan(1000);
|
||||
});
|
||||
|
||||
it('should leave a note still waiting for sync decryption for a retry and not finish the migration', async () => {
|
||||
const waiting = await Note.save({ title: 'waiting', body: 'waiting', is_locked: 1 }, { useNoteLock: true });
|
||||
// What sync leaves until the decryption worker has run: is_locked stays readable, the body is not there yet.
|
||||
await db().exec('UPDATE notes SET encryption_applied = 1, body = \'\' WHERE id = ?', [waiting.id]);
|
||||
const dispatch = jest.fn();
|
||||
|
||||
await startNoteLockKeyMigration(localPassword, targetPassword, dispatch, jest.fn());
|
||||
|
||||
const status = { running: false, failed: 0, skipped: 0, awaitingDecryption: 1 };
|
||||
expect(dispatch).toHaveBeenLastCalledWith({ type: 'NOTE_LOCK_MIGRATION_STATUS_SET', value: status });
|
||||
expect(noteLockKeyConflict()).not.toBeNull();
|
||||
expect(notMigratedMessage(status)).toBe('1 locked note is still waiting to be decrypted and was not migrated yet.');
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { Dispatch } from 'redux';
|
||||
import { _, _n } from '../../locale';
|
||||
import type { NoteLockMigrationStatus } from '../../reducer';
|
||||
import Note from '../../models/Note';
|
||||
import time from '../../time';
|
||||
import shim, { MessageBoxType } from '../../shim';
|
||||
import EncryptionService from '../e2ee/EncryptionService';
|
||||
import { adoptNoteLockKeyConflict, noteLockKeyConflict } from '../synchronizer/syncInfoUtils';
|
||||
import isNoteLockEnabled from './isNoteLockEnabled';
|
||||
import NoteLockKey, { DecryptedNoteLockKey } from './NoteLockKey';
|
||||
import NoteLockSession from './NoteLockSession';
|
||||
|
||||
const logger = Logger.create('NoteLockKeyMigration');
|
||||
|
||||
export interface NoteLockKeyMigrationResult {
|
||||
migrated: number;
|
||||
// Locked with a key that is neither the local nor the sync target one, so nothing here can read them.
|
||||
skipped: number;
|
||||
failed: number;
|
||||
// Still encrypted by sync, so their note lock body cannot be read yet. A retry once they are decrypted picks them up.
|
||||
awaitingDecryption: number;
|
||||
}
|
||||
|
||||
// Re-encrypts the notes locked with the local key to the parked target key; notes already under it are left
|
||||
// alone, so a retry only touches the rest. The local key stays until finish, so an interrupted run loses nothing.
|
||||
// onStarted runs once both passwords are accepted.
|
||||
export const migrateLockedNotes = async (localPassword: string, targetPassword: string, onStarted: ()=> void = null) => {
|
||||
if (!isNoteLockEnabled()) throw new Error('Note lock is not enabled');
|
||||
const conflict = noteLockKeyConflict();
|
||||
if (!conflict) throw new Error('No note lock key conflict to migrate');
|
||||
const encryptionService = EncryptionService.instance();
|
||||
const localKey = await NoteLockKey.instance().decrypt(localPassword);
|
||||
const targetKey: DecryptedNoteLockKey = {
|
||||
id: conflict.noteLockKey.id,
|
||||
plainText: await encryptionService.decryptMasterKeyContent(conflict.noteLockKey, targetPassword),
|
||||
};
|
||||
const result: NoteLockKeyMigrationResult = { migrated: 0, skipped: 0, failed: 0, awaitingDecryption: 0 };
|
||||
// A sync may have replaced the parked key while the passwords were checked. From here the sync leaves it alone.
|
||||
if (noteLockKeyConflict()?.noteLockKey.id !== targetKey.id) throw new Error(_('The note lock key on the sync target changed. Please try again.'));
|
||||
NoteLockSession.instance().setMigrating(true);
|
||||
try {
|
||||
onStarted?.();
|
||||
for (const noteId of await Note.lockedNoteIds()) {
|
||||
try {
|
||||
const { body, encryption_applied } = await Note.load(noteId, { fields: ['id', 'body', 'encryption_applied'] });
|
||||
if (encryption_applied) {
|
||||
result.awaitingDecryption++;
|
||||
continue;
|
||||
}
|
||||
const header = await encryptionService.decodeHeaderString(body, true);
|
||||
if (header.masterKeyId === targetKey.id) continue;
|
||||
if (header.masterKeyId !== localKey.id) {
|
||||
result.skipped++;
|
||||
continue;
|
||||
}
|
||||
const note = await Note.load(noteId, { useNoteLock: true, noteLockKey: localKey });
|
||||
// The note keeps its own "updated" date; updated_time still moves so the re-encrypted note syncs.
|
||||
await Note.save({ ...note, updated_time: time.unixMs() }, { useNoteLock: true, noteLockKey: targetKey, autoTimestamp: false });
|
||||
result.migrated++;
|
||||
} catch (error) {
|
||||
logger.warn(`Could not migrate note ${noteId}:`, error);
|
||||
result.failed++;
|
||||
}
|
||||
// With the feature turned off the note was not re-encrypted, whether Note.save refused it or saved it ungated.
|
||||
if (!isNoteLockEnabled()) throw new Error(_('The note lock key migration was cancelled because the note lock feature was turned off.'));
|
||||
}
|
||||
} finally {
|
||||
NoteLockSession.instance().setMigrating(false);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// Locked notes stay closed while a run goes, so after a run with no failures no note is left under the local key, apart
|
||||
// from an editor save that was already queued when the run started.
|
||||
export const finishNoteLockKeyMigration = () => {
|
||||
adoptNoteLockKeyConflict();
|
||||
NoteLockSession.instance().lock();
|
||||
};
|
||||
|
||||
export const confirmAndSkipNoteLockKeyMigration = async (dispatch: Dispatch) => {
|
||||
if (await shim.showMessageBox(_('Warning: Notes still locked with the current note lock key will become permanently unreadable. Before continuing, it is recommended to first export all notes on this device as a JEX backup, which can be imported again later if the correct password is used. Are you sure you want to continue without migrating the remaining notes?'), { buttons: [_('Yes'), _('No')], type: MessageBoxType.Confirm, cancelId: 1 }) !== 0) return false;
|
||||
finishNoteLockKeyMigration();
|
||||
dispatch({ type: 'NOTE_LOCK_MIGRATION_STATUS_SET', value: null });
|
||||
return true;
|
||||
};
|
||||
|
||||
// Runs in the background once both passwords are accepted, so the app stays usable, and reports its progress for the
|
||||
// banners. A run with failures keeps the target key parked for a retry or a skip.
|
||||
export const startNoteLockKeyMigration = async (localPassword: string, targetPassword: string, dispatch: Dispatch, onStarted: ()=> void) => {
|
||||
let started = false;
|
||||
let result: NoteLockKeyMigrationResult;
|
||||
try {
|
||||
result = await migrateLockedNotes(localPassword, targetPassword, () => {
|
||||
started = true;
|
||||
onStarted();
|
||||
dispatch({ type: 'NOTE_LOCK_MIGRATION_STATUS_SET', value: { running: true, failed: 0, skipped: 0, awaitingDecryption: 0 } });
|
||||
});
|
||||
} catch (error) {
|
||||
// Before the start the dialog is still open to show the error, after it the dialog is gone.
|
||||
if (!started) throw error;
|
||||
dispatch({ type: 'NOTE_LOCK_MIGRATION_STATUS_SET', value: null });
|
||||
await shim.showErrorDialog(error.message);
|
||||
return;
|
||||
}
|
||||
if (!result.failed && !result.awaitingDecryption) finishNoteLockKeyMigration();
|
||||
dispatch({ type: 'NOTE_LOCK_MIGRATION_STATUS_SET', value: { running: false, failed: result.failed, skipped: result.skipped, awaitingDecryption: result.awaitingDecryption } });
|
||||
};
|
||||
|
||||
// The notes a run left under the local key, so the banner offers a retry or a skip.
|
||||
export const notMigratedMessage = (status: NoteLockMigrationStatus) => {
|
||||
const parts = [];
|
||||
if (status.failed) parts.push(_n('%d locked note could not be migrated.', '%d locked notes could not be migrated.', status.failed, status.failed));
|
||||
if (status.awaitingDecryption) parts.push(_n('%d locked note is still waiting to be decrypted and was not migrated yet.', '%d locked notes are still waiting to be decrypted and were not migrated yet.', status.awaitingDecryption, status.awaitingDecryption));
|
||||
return withSkippedCount(parts.join(' '), status);
|
||||
};
|
||||
|
||||
// Notes already moved to the parked key need it, so a sync must not drop or replace it.
|
||||
export const parkedNoteLockKeyInUse = async () => {
|
||||
const conflict = noteLockKeyConflict();
|
||||
if (!conflict) return false;
|
||||
for (const noteId of await Note.lockedNoteIds()) {
|
||||
const { body, encryption_applied } = await Note.load(noteId, { fields: ['id', 'body', 'encryption_applied'] });
|
||||
if (encryption_applied) continue;
|
||||
try {
|
||||
if ((await EncryptionService.instance().decodeHeaderString(body, true)).masterKeyId === conflict.noteLockKey.id) return true;
|
||||
} catch {
|
||||
// A body that is not note lock ciphertext cannot use the parked key.
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const withSkippedCount = (message: string, status: NoteLockMigrationStatus) => {
|
||||
if (!status.skipped) return message;
|
||||
return `${message} ${_n('%d locked note was skipped because it is locked with another key.', '%d locked notes were skipped because they are locked with another key.', status.skipped, status.skipped)}`;
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterAllCleanUp, encryptionService, setupDatabaseAndSynchronizer, switchClient, synchronizerStart } from '../../testing/test-utils';
|
||||
import Setting from '../../models/Setting';
|
||||
import EncryptionService from '../e2ee/EncryptionService';
|
||||
import { localSyncInfo, saveLocalSyncInfo } from '../synchronizer/syncInfoUtils';
|
||||
import NoteLockKey, { DecryptedNoteLockKey } from './NoteLockKey';
|
||||
@@ -139,6 +140,23 @@ describe('NoteLockSession', () => {
|
||||
expect(session.isUnlocked()).toBe(false);
|
||||
});
|
||||
|
||||
it('should keep the sync migration id and the first replaced key id through password resets', async () => {
|
||||
const session = NoteLockSession.instance();
|
||||
const firstKey = await NoteLockKey.instance().create('123456');
|
||||
const syncMigrationId = localSyncInfo().syncMigrationId;
|
||||
expect(syncMigrationId).toHaveLength(32);
|
||||
expect(Setting.value('noteLock.passwordReset')).toBe(false);
|
||||
|
||||
await session.reset('654321');
|
||||
expect(localSyncInfo().syncMigrationId).toBe(syncMigrationId);
|
||||
expect(Setting.value('noteLock.passwordReset')).toBe(true);
|
||||
expect(Setting.value('noteLock.keyIdToReset')).toBe(firstKey.id);
|
||||
|
||||
// A second reset before a sync still has to replace the key the sync target holds.
|
||||
await session.reset('999999');
|
||||
expect(Setting.value('noteLock.keyIdToReset')).toBe(firstKey.id);
|
||||
});
|
||||
|
||||
it('should not install a key that was reset while unlocking', async () => {
|
||||
const session = NoteLockSession.instance();
|
||||
const originalKey = await NoteLockKey.instance().create('123456');
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import NoteLockKey, { DecryptedNoteLockKey } from './NoteLockKey';
|
||||
import eventManager, { EventName } from '../../eventManager';
|
||||
import { noteLockKeyConflict } from '../synchronizer/syncInfoUtils';
|
||||
import { _ } from '../../locale';
|
||||
|
||||
export default class NoteLockSession {
|
||||
|
||||
@@ -8,6 +10,7 @@ export default class NoteLockSession {
|
||||
private key_: DecryptedNoteLockKey = null;
|
||||
private lockGeneration_ = 0;
|
||||
private rotating_ = false;
|
||||
private migrating_ = false;
|
||||
|
||||
private constructor(private noteLockKey_: NoteLockKey = NoteLockKey.instance()) {}
|
||||
|
||||
@@ -29,6 +32,7 @@ export default class NoteLockSession {
|
||||
// check covers it.
|
||||
public async unlock(password: string) {
|
||||
if (this.rotating_) throw new Error('Cannot unlock: a note lock key reset is in progress');
|
||||
if (this.migrating_) throw new Error(_('This note cannot be read while a note lock key migration is in progress.'));
|
||||
const generation = this.lockGeneration_;
|
||||
const decrypted = await this.noteLockKey_.decrypt(password);
|
||||
if (this.lockGeneration_ !== generation) throw new Error('Cannot unlock: the session was locked while unlocking');
|
||||
@@ -47,6 +51,8 @@ export default class NoteLockSession {
|
||||
// Blocks unlock during rotation so the still-persisted old key can't be unlocked before the new one is saved.
|
||||
public async reset(password: string) {
|
||||
if (this.rotating_) throw new Error('A note lock key reset is already in progress');
|
||||
// The migration would adopt the target key over the new one when it ends.
|
||||
if (this.migrating_) throw new Error(_('The note lock password cannot be reset while a note lock key migration is in progress.'));
|
||||
this.rotating_ = true;
|
||||
this.lock();
|
||||
try {
|
||||
@@ -57,6 +63,16 @@ export default class NoteLockSession {
|
||||
}
|
||||
}
|
||||
|
||||
// Locked notes stay closed in every window while a migration run re-encrypts them.
|
||||
public setMigrating(migrating: boolean) {
|
||||
this.migrating_ = migrating;
|
||||
if (migrating) this.lock();
|
||||
}
|
||||
|
||||
public isMigrating() {
|
||||
return this.migrating_;
|
||||
}
|
||||
|
||||
private lockIfKeyChanged_() {
|
||||
if (this.key_ && this.key_.id !== this.noteLockKey_.load()?.id) this.lock();
|
||||
}
|
||||
@@ -76,7 +92,8 @@ export default class NoteLockSession {
|
||||
}
|
||||
|
||||
// A lock clears key_ but not the persisted key, so this only fails on a real rotation, or one in progress.
|
||||
// The key parked for migration is allowed too: migration re-encrypts to it before it is adopted.
|
||||
public assertCanEncryptWith(keyId: string) {
|
||||
if (this.rotating_ || this.noteLockKey_.load()?.id !== keyId) throw new Error('Note lock key changed during operation');
|
||||
if (this.rotating_ || (this.noteLockKey_.load()?.id !== keyId && noteLockKeyConflict()?.noteLockKey.id !== keyId)) throw new Error('Note lock key changed during operation');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import Setting from '../../models/Setting';
|
||||
import shim from '../../shim';
|
||||
import { encryptionService, setupDatabaseAndSynchronizer, switchClient } from '../../testing/test-utils';
|
||||
import EncryptionService from '../e2ee/EncryptionService';
|
||||
import NoteLockKey from './NoteLockKey';
|
||||
import NoteLockService from './NoteLockService';
|
||||
import NoteLockSession from './NoteLockSession';
|
||||
import { promptForImportedNoteLockKey } from './noteLockPrompts';
|
||||
import { ErrorCode } from '../../errors';
|
||||
|
||||
const makePrompts = (passwords: (string|null)[]) => ({
|
||||
promptPassword: jest.fn(async () => passwords.shift() ?? null),
|
||||
goToNoteLockSetup: jest.fn(),
|
||||
});
|
||||
|
||||
describe('noteLockPrompts', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
NoteLockService.destroyInstance();
|
||||
NoteLockSession.destroyInstance();
|
||||
NoteLockKey.destroyInstance();
|
||||
EncryptionService.instance_ = encryptionService();
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
shim.showErrorDialog = jest.fn(async () => {});
|
||||
});
|
||||
|
||||
const otherBackupKey = async () => ({ ...await encryptionService().generateMasterKey('backup'), id: 'backup' });
|
||||
|
||||
// Answers the dialogs in order: the "update them?" question (0 Yes, 1 No, 2 Cancel), then the setup question (0 Yes, 1 No).
|
||||
const answer = (...responses: number[]) => {
|
||||
shim.showMessageBox = jest.fn(async () => responses.shift());
|
||||
};
|
||||
|
||||
it('should import a backup of this profile as it is, without any prompt', async () => {
|
||||
const key = await NoteLockKey.instance().create('local');
|
||||
answer(0);
|
||||
const prompts = makePrompts([]);
|
||||
|
||||
expect(await promptForImportedNoteLockKey(key, prompts)).toBeNull();
|
||||
expect(shim.showMessageBox).not.toHaveBeenCalled();
|
||||
expect(prompts.promptPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should unlock the session, then decrypt the key of a backup from another profile', async () => {
|
||||
const backupKey = await otherBackupKey();
|
||||
await NoteLockKey.instance().create('local');
|
||||
answer(0);
|
||||
const prompts = makePrompts(['local', 'wrong', 'backup']);
|
||||
|
||||
expect((await promptForImportedNoteLockKey(backupKey, prompts)).id).toBe(backupKey.id);
|
||||
expect(NoteLockSession.instance().isUnlocked()).toBe(true);
|
||||
expect(prompts.promptPassword).toHaveBeenNthCalledWith(1, 'Enter your current note lock password');
|
||||
expect(shim.showErrorDialog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should import unchanged when the user does not want to update the notes', async () => {
|
||||
const backupKey = await otherBackupKey();
|
||||
await NoteLockKey.instance().create('local');
|
||||
answer(1);
|
||||
const prompts = makePrompts([]);
|
||||
|
||||
expect(await promptForImportedNoteLockKey(backupKey, prompts)).toBeNull();
|
||||
expect(prompts.promptPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['the question', []],
|
||||
['the current password', [null]],
|
||||
['the backup password', ['local', null]],
|
||||
])('should cancel the import when %s is cancelled', async (_step, passwords: (string|null)[]) => {
|
||||
const backupKey = await otherBackupKey();
|
||||
await NoteLockKey.instance().create('local');
|
||||
answer(passwords.length ? 0 : 2);
|
||||
|
||||
await expect(promptForImportedNoteLockKey(backupKey, makePrompts(passwords))).rejects.toMatchObject({ code: ErrorCode.Cancelled });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['opens the note lock setup', true],
|
||||
['does not', false],
|
||||
])('should cancel the import when no note lock key is set up and the user %s', async (_label, opensSetup) => {
|
||||
const backupKey = await otherBackupKey();
|
||||
answer(0, opensSetup ? 0 : 1);
|
||||
const prompts = makePrompts([]);
|
||||
|
||||
await expect(promptForImportedNoteLockKey(backupKey, prompts)).rejects.toMatchObject({ code: ErrorCode.Cancelled });
|
||||
expect(prompts.goToNoteLockSetup).toHaveBeenCalledTimes(opensSetup ? 1 : 0);
|
||||
expect(prompts.promptPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { _ } from '../../locale';
|
||||
import JoplinError from '../../JoplinError';
|
||||
import { ErrorCode } from '../../errors';
|
||||
import shim from '../../shim';
|
||||
import { MasterKeyEntity } from '../e2ee/types';
|
||||
import NoteLockKey, { DecryptedNoteLockKey } from './NoteLockKey';
|
||||
import NoteLockSession from './NoteLockSession';
|
||||
|
||||
// Shared by desktop and mobile, which each supply their own password prompt and settings navigation.
|
||||
export interface NoteLockPrompts {
|
||||
promptPassword: (label: string)=> Promise<string|null>;
|
||||
goToNoteLockSetup: ()=> void;
|
||||
}
|
||||
|
||||
const promptNoteLockSetup = async (prompts: NoteLockPrompts) => {
|
||||
const answer = await shim.showMessageBox(_('A note lock key is required to update these notes, but hasn\'t been set up yet on your profile. Would you like to set one up now?'), {
|
||||
buttons: [_('Yes'), _('No')],
|
||||
cancelId: 1,
|
||||
});
|
||||
if (answer === 0) {
|
||||
prompts.goToNoteLockSetup();
|
||||
}
|
||||
};
|
||||
|
||||
const cancelled = () => new JoplinError('Cancelled', ErrorCode.Cancelled);
|
||||
|
||||
// Returns false when the user cancels or has no note lock password to unlock with.
|
||||
export const unlockNoteLockSession = async (prompts: NoteLockPrompts) => {
|
||||
if (!NoteLockKey.instance().load()) {
|
||||
await promptNoteLockSetup(prompts);
|
||||
return false;
|
||||
}
|
||||
|
||||
while (!NoteLockSession.instance().isUnlocked()) {
|
||||
const password = await prompts.promptPassword(_('Enter your current note lock password'));
|
||||
if (!password) return false;
|
||||
try {
|
||||
await NoteLockSession.instance().unlock(password);
|
||||
} catch {
|
||||
await shim.showErrorDialog(_('Invalid password'));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// Returns the backup's key decrypted, to re-encrypt its locked notes for this profile, or null to import them unchanged.
|
||||
// Throws a cancelled error when the user cancels any step, so the whole import stops.
|
||||
export const promptForImportedNoteLockKey = async (backupKey: MasterKeyEntity, prompts: NoteLockPrompts): Promise<DecryptedNoteLockKey|null> => {
|
||||
// Already under the profile's key, so the notes need no update.
|
||||
if (backupKey.id === NoteLockKey.instance().load()?.id) return null;
|
||||
|
||||
const answer = await shim.showMessageBox(_('This backup contains notes locked with a different key. They must be updated now to use your current key, or they\'ll remain inaccessible and cannot be updated later. Would you like to update them?'), {
|
||||
buttons: [_('Yes'), _('No'), _('Cancel')],
|
||||
cancelId: 2,
|
||||
});
|
||||
if (answer === 1) return null;
|
||||
if (answer !== 0) throw cancelled();
|
||||
if (!await unlockNoteLockSession(prompts)) throw cancelled();
|
||||
|
||||
while (true) {
|
||||
const password = await prompts.promptPassword(_('Enter the note lock password the backup was created with'));
|
||||
if (!password) throw cancelled();
|
||||
try {
|
||||
return await NoteLockKey.instance().decrypt(password, backupKey);
|
||||
} catch {
|
||||
await shim.showErrorDialog(_('Invalid password'));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -84,6 +84,16 @@ describe('setNoteLockState', () => {
|
||||
expect((await Note.load(note.id)).is_locked).toBe(0);
|
||||
});
|
||||
|
||||
it('should refuse to change the lock state while a note lock key migration is required', async () => {
|
||||
await setUpUnlockedSession();
|
||||
const note = await Note.save({ title: 'note', body: 'secret' });
|
||||
const lockedNote = await Note.save({ title: 'locked note', body: 'secret', is_locked: 1 }, { useNoteLock: true });
|
||||
Setting.setValue('noteLock.conflictNoteLockKey', { noteLockKey: NoteLockKey.instance().load(), syncMigrationId: 'remote' });
|
||||
|
||||
await expect(enableNoteLock(note.id)).rejects.toThrow('note lock key migration is required');
|
||||
await expect(disableNoteLock(lockedNote.id)).rejects.toThrow('note lock key migration is required');
|
||||
});
|
||||
|
||||
it('should throw when note lock is not enabled', async () => {
|
||||
await setUpUnlockedSession();
|
||||
const note = await Note.save({ title: 'note', body: 'secret' });
|
||||
|
||||
@@ -8,6 +8,10 @@ import { NoteEntity } from '../database/types';
|
||||
import eventManager, { EventName } from '../../eventManager';
|
||||
import isNoteLockEnabled from './isNoteLockEnabled';
|
||||
import NoteLockSession from './NoteLockSession';
|
||||
import { noteLockKeyConflict } from '../synchronizer/syncInfoUtils';
|
||||
import JoplinError from '../../JoplinError';
|
||||
import { ErrorCode } from '../../errors';
|
||||
import { _ } from '../../locale';
|
||||
|
||||
// The UI hides the enable/disable actions for these cases, but the commands can also be
|
||||
// invoked directly (keyboard, plugins), so the transitions fail closed here too.
|
||||
@@ -18,6 +22,8 @@ const checkCanChangeLockState = (note: NoteEntity, noteId: string) => {
|
||||
if (note.is_conflict) throw new Error('Cannot change the note lock of a conflict note');
|
||||
if (itemIsReadOnlySync(ModelType.Note, ItemChange.SOURCE_UNSPECIFIED, note as ItemSlice, Setting.value('sync.userId'), BaseItem.syncShareCache)) throw new Error('Cannot change the note lock of a read-only note');
|
||||
if (!NoteLockSession.instance().isUnlocked()) throw new Error('Cannot change the note lock while the session is locked');
|
||||
// The migration re-encrypts the locked notes it finds, so a note locked or unlocked meanwhile could be missed or fail it.
|
||||
if (noteLockKeyConflict()) throw new JoplinError(_('Notes cannot be locked or unlocked while a note lock key migration is required.'), ErrorCode.NoteLockKeyConflict);
|
||||
};
|
||||
|
||||
const validationFields = ['id', 'is_locked', 'deleted_time', 'is_conflict', 'share_id'];
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
import Setting from '../../models/Setting';
|
||||
import Note from '../../models/Note';
|
||||
import { afterAllCleanUp, encryptionService, fileApi, loadEncryptionMasterKey, setupDatabaseAndSynchronizer, switchClient, synchronizer, synchronizerStart } from '../../testing/test-utils';
|
||||
import EncryptionService from '../e2ee/EncryptionService';
|
||||
import NoteLockKey from '../noteLock/NoteLockKey';
|
||||
import NoteLockSession from '../noteLock/NoteLockSession';
|
||||
import NoteLockService from '../noteLock/NoteLockService';
|
||||
import { fetchSyncInfo, localSyncInfo, noteLockKeyConflict, onRevisionServiceSettingsChanged, saveLocalSyncInfo } from './syncInfoUtils';
|
||||
import { ErrorCode } from '../../errors';
|
||||
import NoteLockNote from '../noteLock/NoteLockNote';
|
||||
import { finishNoteLockKeyMigration, migrateLockedNotes } from '../noteLock/NoteLockKeyMigration';
|
||||
import { MasterKeyEntity } from '../e2ee/types';
|
||||
import { setupAndEnableEncryption } from '../e2ee/utils';
|
||||
|
||||
// Duplicates the singleton reset from NoteLockSession.test.ts: the note lock singletons cache the
|
||||
// encryption service, so each client switch has to rebuild them.
|
||||
const switchToClient = async (id: number) => {
|
||||
await switchClient(id);
|
||||
NoteLockService.destroyInstance();
|
||||
NoteLockSession.destroyInstance();
|
||||
NoteLockKey.destroyInstance();
|
||||
EncryptionService.instance_ = encryptionService();
|
||||
};
|
||||
|
||||
const remoteNoteLockKeyId = async () => (await fetchSyncInfo(fileApi())).noteLockKey?.id;
|
||||
|
||||
const decryptedRemoteKey = async (key: MasterKeyEntity) => ({ id: key.id, plainText: await encryptionService().decryptMasterKeyContent(key, '111111') });
|
||||
|
||||
// Lands the action inside the running sync, after it took its local snapshot and before the conflict check.
|
||||
const duringTheLockedNotesCheck = (action: ()=> Promise<unknown>) => {
|
||||
const realHasLockedNotes = Note.hasLockedNotes.bind(Note);
|
||||
const spy = jest.spyOn(Note, 'hasLockedNotes').mockImplementation(async () => {
|
||||
spy.mockRestore();
|
||||
await action();
|
||||
return realHasLockedNotes();
|
||||
});
|
||||
};
|
||||
|
||||
describe('Synchronizer.noteLock', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await setupDatabaseAndSynchronizer(2);
|
||||
await switchToClient(1);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await afterAllCleanUp();
|
||||
});
|
||||
|
||||
it('should propagate a password reset to the sync target and then to other devices', async () => {
|
||||
const firstKey = await NoteLockKey.instance().create('111111');
|
||||
const syncMigrationId = localSyncInfo().syncMigrationId;
|
||||
await Note.save({ title: 'locked', is_locked: 1 });
|
||||
await synchronizerStart();
|
||||
|
||||
await switchToClient(2);
|
||||
await synchronizerStart();
|
||||
expect(NoteLockKey.instance().load().id).toBe(firstKey.id);
|
||||
const resetKey = await NoteLockSession.instance().reset('222222');
|
||||
await synchronizerStart();
|
||||
expect(await remoteNoteLockKeyId()).toBe(resetKey.id);
|
||||
expect(Setting.value('noteLock.passwordReset')).toBe(false);
|
||||
|
||||
// Locked notes do not stop a same-lineage adoption: a reset is meant to reach every device.
|
||||
await switchToClient(1);
|
||||
await synchronizerStart();
|
||||
expect(NoteLockKey.instance().load()).toEqual(resetKey);
|
||||
expect(localSyncInfo().syncMigrationId).toBe(syncMigrationId);
|
||||
});
|
||||
|
||||
it('should propagate the newest of repeated offline resets to the key they replaced', async () => {
|
||||
const firstKey = await NoteLockKey.instance().create('111111');
|
||||
await Note.save({ title: 'locked', is_locked: 1 });
|
||||
await synchronizerStart();
|
||||
|
||||
await switchToClient(2);
|
||||
await synchronizerStart();
|
||||
await NoteLockSession.instance().reset('222222');
|
||||
const lastResetKey = await NoteLockSession.instance().reset('333333');
|
||||
expect(Setting.value('noteLock.keyIdToReset')).toBe(firstKey.id);
|
||||
await synchronizerStart();
|
||||
expect(await remoteNoteLockKeyId()).toBe(lastResetKey.id);
|
||||
expect(Setting.value('noteLock.passwordReset')).toBe(false);
|
||||
expect(Setting.value('noteLock.keyIdToReset')).toBe('');
|
||||
|
||||
await switchToClient(1);
|
||||
await synchronizerStart();
|
||||
expect(NoteLockKey.instance().load()).toEqual(lastResetKey);
|
||||
});
|
||||
|
||||
it('should park the sync target key instead of propagating a reset when another device reset first', async () => {
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
await NoteLockKey.instance().create('111111');
|
||||
await synchronizerStart();
|
||||
|
||||
await switchToClient(2);
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
await synchronizerStart();
|
||||
await NoteLockSession.instance().reset('222222');
|
||||
await NoteLockSession.instance().unlock('222222');
|
||||
const note = await Note.save({ title: 'secret', body: 'secret body', is_locked: 1 }, { useNoteLock: true });
|
||||
|
||||
// The other device resets and syncs first, so the key this reset replaced is gone from the target.
|
||||
await switchToClient(1);
|
||||
const winningKey = await NoteLockSession.instance().reset('333333');
|
||||
await synchronizerStart();
|
||||
expect(await remoteNoteLockKeyId()).toBe(winningKey.id);
|
||||
|
||||
await switchToClient(2);
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await expect(synchronizerStart(null, { throwOnError: true })).rejects.toMatchObject({ code: ErrorCode.NoteLockKeyConflict });
|
||||
expect(await remoteNoteLockKeyId()).toBe(winningKey.id);
|
||||
expect(Setting.value('noteLock.conflictNoteLockKey').noteLockKey).toEqual(winningKey);
|
||||
}
|
||||
|
||||
expect(await migrateLockedNotes('222222', '333333')).toEqual({ migrated: 1, skipped: 0, failed: 0, awaitingDecryption: 0 });
|
||||
finishNoteLockKeyMigration();
|
||||
expect(Setting.value('noteLock.passwordReset')).toBe(false);
|
||||
expect(Setting.value('noteLock.keyIdToReset')).toBe('');
|
||||
await synchronizerStart(null, { throwOnError: true });
|
||||
expect(NoteLockKey.instance().load()).toEqual(winningKey);
|
||||
expect(await remoteNoteLockKeyId()).toBe(winningKey.id);
|
||||
const key = await NoteLockKey.instance().decrypt('333333');
|
||||
expect((await NoteLockNote.decryptBody(await Note.load(note.id), key)).body).toBe('secret body');
|
||||
});
|
||||
|
||||
it('should drop a reset that another device overtook when no note depends on it', async () => {
|
||||
await NoteLockKey.instance().create('111111');
|
||||
await synchronizerStart();
|
||||
|
||||
await switchToClient(2);
|
||||
await synchronizerStart();
|
||||
await NoteLockSession.instance().reset('222222');
|
||||
|
||||
await switchToClient(1);
|
||||
const winningKey = await NoteLockSession.instance().reset('333333');
|
||||
await synchronizerStart();
|
||||
|
||||
await switchToClient(2);
|
||||
await synchronizerStart();
|
||||
expect(NoteLockKey.instance().load()).toEqual(winningKey);
|
||||
expect(await remoteNoteLockKeyId()).toBe(winningKey.id);
|
||||
expect(Setting.value('noteLock.passwordReset')).toBe(false);
|
||||
expect(Setting.value('noteLock.keyIdToReset')).toBe('');
|
||||
expect(Setting.value('noteLock.conflictNoteLockKey')).toEqual({});
|
||||
});
|
||||
|
||||
it('should stop the sync and park the remote key when a different lineage meets local locked notes', async () => {
|
||||
const remoteKey = await NoteLockKey.instance().create('111111');
|
||||
const remoteSyncMigrationId = localSyncInfo().syncMigrationId;
|
||||
await synchronizerStart();
|
||||
|
||||
await switchToClient(2);
|
||||
const localKey = await NoteLockKey.instance().create('222222');
|
||||
const localSyncMigrationId = localSyncInfo().syncMigrationId;
|
||||
await Note.save({ title: 'locked', is_locked: 1 });
|
||||
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await expect(synchronizerStart(null, { throwOnError: true })).rejects.toMatchObject({ code: ErrorCode.NoteLockKeyConflict });
|
||||
expect(NoteLockKey.instance().load()).toEqual(localKey);
|
||||
expect(localSyncInfo().syncMigrationId).toBe(localSyncMigrationId);
|
||||
expect(await remoteNoteLockKeyId()).toBe(remoteKey.id);
|
||||
expect(Setting.value('noteLock.conflictNoteLockKey')).toEqual({ noteLockKey: remoteKey, syncMigrationId: remoteSyncMigrationId });
|
||||
}
|
||||
});
|
||||
|
||||
it('should drop a local key that no note depends on and adopt the sync target key', async () => {
|
||||
const remoteKey = await NoteLockKey.instance().create('111111');
|
||||
const remoteSyncMigrationId = localSyncInfo().syncMigrationId;
|
||||
await synchronizerStart();
|
||||
|
||||
await switchToClient(2);
|
||||
await NoteLockKey.instance().create('222222');
|
||||
await synchronizerStart();
|
||||
|
||||
expect(NoteLockKey.instance().load()).toEqual(remoteKey);
|
||||
expect(localSyncInfo().syncMigrationId).toBe(remoteSyncMigrationId);
|
||||
expect(await remoteNoteLockKeyId()).toBe(remoteKey.id);
|
||||
expect(Setting.value('noteLock.conflictNoteLockKey')).toEqual({});
|
||||
});
|
||||
|
||||
it('should keep the sync stopped and say how to migrate when note lock is off', async () => {
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
await NoteLockKey.instance().create('111111');
|
||||
await synchronizerStart();
|
||||
|
||||
await switchToClient(2);
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
const localKey = await NoteLockKey.instance().create('222222');
|
||||
await NoteLockSession.instance().unlock('222222');
|
||||
await Note.save({ title: 'secret', body: 'secret body', is_locked: 1 }, { useNoteLock: true });
|
||||
Setting.setValue('featureFlag.noteLock', false);
|
||||
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await expect(synchronizerStart(null, { throwOnError: true })).rejects.toThrow('Enable the note lock feature');
|
||||
}
|
||||
expect(NoteLockKey.instance().load()).toEqual(localKey);
|
||||
});
|
||||
|
||||
it('should sync again after migrating local locked notes to the sync target key', async () => {
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
const remoteKey = await NoteLockKey.instance().create('111111');
|
||||
const remoteSyncMigrationId = localSyncInfo().syncMigrationId;
|
||||
await synchronizerStart();
|
||||
|
||||
await switchToClient(2);
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
await NoteLockKey.instance().create('222222');
|
||||
await NoteLockSession.instance().unlock('222222');
|
||||
const note = await Note.save({ title: 'secret', body: 'secret body', is_locked: 1 }, { useNoteLock: true });
|
||||
await expect(synchronizerStart(null, { throwOnError: true })).rejects.toMatchObject({ code: ErrorCode.NoteLockKeyConflict });
|
||||
|
||||
expect(await migrateLockedNotes('222222', '111111')).toEqual({ migrated: 1, skipped: 0, failed: 0, awaitingDecryption: 0 });
|
||||
finishNoteLockKeyMigration();
|
||||
await synchronizerStart(null, { throwOnError: true });
|
||||
expect(NoteLockKey.instance().load()).toEqual(remoteKey);
|
||||
expect(localSyncInfo().syncMigrationId).toBe(remoteSyncMigrationId);
|
||||
expect(await remoteNoteLockKeyId()).toBe(remoteKey.id);
|
||||
|
||||
await switchToClient(1);
|
||||
await synchronizerStart();
|
||||
const key = await NoteLockKey.instance().decrypt('111111');
|
||||
expect((await NoteLockNote.decryptBody(await Note.load(note.id), key)).body).toBe('secret body');
|
||||
});
|
||||
|
||||
it('should adopt the sync target key before the upload, so a note cannot be locked with the dropped key mid-sync', async () => {
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
const remoteKey = await NoteLockKey.instance().create('111111');
|
||||
await synchronizerStart();
|
||||
|
||||
await switchToClient(2);
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
await NoteLockKey.instance().create('222222');
|
||||
await NoteLockSession.instance().unlock('222222');
|
||||
|
||||
// Tries to lock a note with the local key while the info.json upload is in flight.
|
||||
const api = synchronizer().api();
|
||||
const realPut = api.put.bind(api);
|
||||
let lateLockError: Error = null;
|
||||
const putSpy = jest.spyOn(api, 'put').mockImplementation(async (path, content, options) => {
|
||||
if (path === 'info.json' && !lateLockError) {
|
||||
try {
|
||||
await Note.save({ title: 'late', body: 'late body', is_locked: 1 }, { useNoteLock: true });
|
||||
} catch (error) {
|
||||
lateLockError = error;
|
||||
}
|
||||
}
|
||||
return realPut(path, content, options);
|
||||
});
|
||||
try {
|
||||
await synchronizerStart(null, { throwOnError: true });
|
||||
} finally {
|
||||
putSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(lateLockError?.message).toBe('Note lock session is locked');
|
||||
expect(NoteLockKey.instance().load()).toEqual(remoteKey);
|
||||
expect(noteLockKeyConflict()).toBeNull();
|
||||
});
|
||||
|
||||
it('should keep a reset that lands mid-sync for the next sync instead of clearing its flag', async () => {
|
||||
await NoteLockKey.instance().create('111111');
|
||||
await synchronizerStart();
|
||||
|
||||
await switchToClient(2);
|
||||
await synchronizerStart();
|
||||
let resetKey: MasterKeyEntity = null;
|
||||
duringTheLockedNotesCheck(async () => { resetKey = await NoteLockSession.instance().reset('222222'); });
|
||||
await synchronizerStart(null, { throwOnError: true });
|
||||
|
||||
expect(NoteLockKey.instance().load()).toEqual(resetKey);
|
||||
expect(Setting.value('noteLock.passwordReset')).toBe(true);
|
||||
await synchronizerStart(null, { throwOnError: true });
|
||||
expect(await remoteNoteLockKeyId()).toBe(resetKey.id);
|
||||
expect(Setting.value('noteLock.passwordReset')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not spend a leftover reset flag on a key that moved mid-sync', async () => {
|
||||
await NoteLockKey.instance().create('111111');
|
||||
await synchronizerStart();
|
||||
|
||||
await switchToClient(2);
|
||||
await synchronizerStart();
|
||||
// A flag left behind by an interrupted earlier sync.
|
||||
Setting.setValue('noteLock.passwordReset', true);
|
||||
let resetKey: MasterKeyEntity = null;
|
||||
duringTheLockedNotesCheck(async () => { resetKey = await NoteLockSession.instance().reset('222222'); });
|
||||
|
||||
await expect(synchronizerStart(null, { throwOnError: true })).rejects.toThrow('changed on this device');
|
||||
expect(Setting.value('noteLock.passwordReset')).toBe(true);
|
||||
await synchronizerStart(null, { throwOnError: true });
|
||||
expect(await remoteNoteLockKeyId()).toBe(resetKey.id);
|
||||
expect(Setting.value('noteLock.passwordReset')).toBe(false);
|
||||
});
|
||||
|
||||
it('should hand a reset that lands after the local save the key the sync target holds', async () => {
|
||||
await NoteLockKey.instance().create('111111');
|
||||
await synchronizerStart();
|
||||
|
||||
await switchToClient(2);
|
||||
await synchronizerStart();
|
||||
const firstResetKey = await NoteLockSession.instance().reset('222222');
|
||||
// Resets again while the sync is finishing up, after the merged info was saved locally.
|
||||
const handler = synchronizer().lockHandler();
|
||||
const realReleaseLock = handler.releaseLock.bind(handler);
|
||||
let secondResetKey: MasterKeyEntity = null;
|
||||
const spy = jest.spyOn(handler, 'releaseLock').mockImplementation(async (...args) => {
|
||||
spy.mockRestore();
|
||||
secondResetKey = await NoteLockSession.instance().reset('333333');
|
||||
return realReleaseLock(...args);
|
||||
});
|
||||
await synchronizerStart(null, { throwOnError: true });
|
||||
|
||||
expect(await remoteNoteLockKeyId()).toBe(firstResetKey.id);
|
||||
expect(Setting.value('noteLock.passwordReset')).toBe(true);
|
||||
expect(Setting.value('noteLock.keyIdToReset')).toBe(firstResetKey.id);
|
||||
await synchronizerStart(null, { throwOnError: true });
|
||||
expect(await remoteNoteLockKeyId()).toBe(secondResetKey.id);
|
||||
expect(Setting.value('noteLock.passwordReset')).toBe(false);
|
||||
});
|
||||
|
||||
it('should back up the key the target holds when a sync finishes while a second reset is generating its key', async () => {
|
||||
await NoteLockKey.instance().create('111111');
|
||||
await synchronizerStart();
|
||||
|
||||
await switchToClient(2);
|
||||
await synchronizerStart();
|
||||
const firstResetKey = await NoteLockSession.instance().reset('222222');
|
||||
const service = encryptionService();
|
||||
const realGenerate = service.generateMasterKey.bind(service);
|
||||
const spy = jest.spyOn(service, 'generateMasterKey').mockImplementation(async (...args) => {
|
||||
spy.mockRestore();
|
||||
await synchronizerStart(null, { throwOnError: true });
|
||||
return realGenerate(...args);
|
||||
});
|
||||
const secondResetKey = await NoteLockSession.instance().reset('333333');
|
||||
|
||||
expect(Setting.value('noteLock.keyIdToReset')).toBe(firstResetKey.id);
|
||||
await synchronizerStart(null, { throwOnError: true });
|
||||
expect(await remoteNoteLockKeyId()).toBe(secondResetKey.id);
|
||||
});
|
||||
|
||||
it('should adopt the sync target key in the sync that first uses a local master key', async () => {
|
||||
const remoteKey = await NoteLockKey.instance().create('111111');
|
||||
await synchronizerStart();
|
||||
|
||||
await switchToClient(2);
|
||||
await setupAndEnableEncryption(encryptionService(), await loadEncryptionMasterKey());
|
||||
await synchronizerStart(null, { throwOnError: true });
|
||||
|
||||
expect(NoteLockKey.instance().load()).toEqual(remoteKey);
|
||||
});
|
||||
|
||||
it('should keep a password change that lands during the info upload', async () => {
|
||||
await NoteLockKey.instance().create('111111');
|
||||
await synchronizerStart();
|
||||
|
||||
await switchToClient(2);
|
||||
await synchronizerStart();
|
||||
|
||||
await switchToClient(1);
|
||||
await NoteLockKey.instance().changePassword('111111', '222222');
|
||||
await synchronizerStart();
|
||||
|
||||
await switchToClient(2);
|
||||
const api = synchronizer().api();
|
||||
const realPut = api.put.bind(api);
|
||||
const putSpy = jest.spyOn(api, 'put').mockImplementation(async (path, content, options) => {
|
||||
if (path === 'info.json') {
|
||||
putSpy.mockRestore();
|
||||
await NoteLockKey.instance().changePassword('111111', '333333');
|
||||
}
|
||||
return realPut(path, content, options);
|
||||
});
|
||||
try {
|
||||
await expect(synchronizerStart(null, { throwOnError: true })).rejects.toThrow('changed on this device');
|
||||
} finally {
|
||||
putSpy.mockRestore();
|
||||
}
|
||||
|
||||
await expect(NoteLockKey.instance().decrypt('333333')).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not park the key again when the migration finishes while a sync is in flight', async () => {
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
const remoteKey = await NoteLockKey.instance().create('111111');
|
||||
await synchronizerStart();
|
||||
|
||||
await switchToClient(2);
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
await NoteLockKey.instance().create('222222');
|
||||
await NoteLockSession.instance().unlock('222222');
|
||||
await Note.save({ title: 'secret', body: 'secret body', is_locked: 1 }, { useNoteLock: true });
|
||||
await expect(synchronizerStart(null, { throwOnError: true })).rejects.toMatchObject({ code: ErrorCode.NoteLockKeyConflict });
|
||||
await migrateLockedNotes('222222', '111111');
|
||||
duringTheLockedNotesCheck(async () => finishNoteLockKeyMigration());
|
||||
|
||||
await expect(synchronizerStart(null, { throwOnError: true })).rejects.toThrow('changed on this device');
|
||||
expect(noteLockKeyConflict()).toBeNull();
|
||||
expect(NoteLockKey.instance().load()).toEqual(remoteKey);
|
||||
await synchronizerStart(null, { throwOnError: true });
|
||||
expect(await remoteNoteLockKeyId()).toBe(remoteKey.id);
|
||||
});
|
||||
|
||||
it('should not write back a key that another device replaced after this sync fetched the target info', async () => {
|
||||
await NoteLockKey.instance().create('111111');
|
||||
await synchronizerStart();
|
||||
const infoBeforeReset = await fileApi().get('info.json');
|
||||
|
||||
await switchToClient(2);
|
||||
await synchronizerStart();
|
||||
const resetKey = await NoteLockSession.instance().reset('222222');
|
||||
await synchronizerStart();
|
||||
const infoAfterReset = await fileApi().get('info.json');
|
||||
|
||||
// Client 1 reads the target before the reset lands, and has its own change to upload.
|
||||
await switchToClient(1);
|
||||
onRevisionServiceSettingsChanged('revisionService.ttlDays', 45);
|
||||
await fileApi().put('info.json', infoBeforeReset);
|
||||
duringTheLockedNotesCheck(() => fileApi().put('info.json', infoAfterReset));
|
||||
await expect(synchronizerStart(null, { throwOnError: true })).rejects.toThrow('changed during the sync');
|
||||
expect(await remoteNoteLockKeyId()).toBe(resetKey.id);
|
||||
|
||||
await synchronizerStart(null, { throwOnError: true });
|
||||
expect(await remoteNoteLockKeyId()).toBe(resetKey.id);
|
||||
expect(NoteLockKey.instance().load().id).toBe(resetKey.id);
|
||||
});
|
||||
|
||||
it('should stop the sync when a note lock key has no sync migration id', async () => {
|
||||
await NoteLockKey.instance().create('111111');
|
||||
await synchronizerStart();
|
||||
|
||||
const remoteInfo = await fetchSyncInfo(fileApi());
|
||||
remoteInfo.syncMigrationId = '';
|
||||
await fileApi().put('info.json', remoteInfo.serialize());
|
||||
await expect(synchronizerStart(null, { throwOnError: true })).rejects.toThrow('migration ID');
|
||||
|
||||
const localInfo = localSyncInfo();
|
||||
localInfo.syncMigrationId = '';
|
||||
saveLocalSyncInfo(localInfo);
|
||||
await expect(synchronizerStart(null, { throwOnError: true })).rejects.toThrow('migration ID');
|
||||
});
|
||||
// Sets up client 2 with a locked note and a conflict parked against client 1's key on the sync target.
|
||||
const parkConflictOnClient2 = async () => {
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
const remoteKey = await NoteLockKey.instance().create('111111');
|
||||
await synchronizerStart();
|
||||
|
||||
await switchToClient(2);
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
const localKey = await NoteLockKey.instance().create('222222');
|
||||
await NoteLockSession.instance().unlock('222222');
|
||||
const note = await Note.save({ title: 'secret', body: 'secret body', is_locked: 1 }, { useNoteLock: true });
|
||||
await expect(synchronizerStart(null, { throwOnError: true })).rejects.toMatchObject({ code: ErrorCode.NoteLockKeyConflict });
|
||||
expect(noteLockKeyConflict()?.noteLockKey.id).toBe(remoteKey.id);
|
||||
return { remoteKey, localKey, note };
|
||||
};
|
||||
|
||||
it('should sync again when the sync target no longer conflicts, for example after switching to an empty one', async () => {
|
||||
const { localKey } = await parkConflictOnClient2();
|
||||
|
||||
await fileApi().clearRoot();
|
||||
await synchronizerStart(null, { throwOnError: true });
|
||||
|
||||
expect(noteLockKeyConflict()).toBeNull();
|
||||
expect(NoteLockKey.instance().load()).toEqual(localKey);
|
||||
expect(await remoteNoteLockKeyId()).toBe(localKey.id);
|
||||
});
|
||||
|
||||
it('should keep the parked key once a migration has moved notes to it, even if the sync target changes', async () => {
|
||||
const { remoteKey, note } = await parkConflictOnClient2();
|
||||
const partner = await Note.save({ title: 'partner', body: 'partner body', is_locked: 1 }, { useNoteLock: true });
|
||||
// The first note moves, the second fails, so the run stops halfway with a note already under the parked key.
|
||||
const save = Note.save.bind(Note);
|
||||
const spy = jest.spyOn(Note, 'save').mockImplementation(async (n, options) => {
|
||||
if (n.id === partner.id) throw new Error('simulated failure');
|
||||
return save(n, options);
|
||||
});
|
||||
try {
|
||||
expect(await migrateLockedNotes('222222', '111111')).toMatchObject({ migrated: 1, failed: 1 });
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
await fileApi().clearRoot();
|
||||
await expect(synchronizerStart(null, { throwOnError: true })).rejects.toMatchObject({ code: ErrorCode.NoteLockKeyConflict });
|
||||
expect(noteLockKeyConflict()?.noteLockKey.id).toBe(remoteKey.id);
|
||||
const key = await decryptedRemoteKey(remoteKey);
|
||||
expect((await NoteLockNote.decryptBody(await Note.load(note.id), key)).body).toBe('secret body');
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,11 +1,13 @@
|
||||
import { afterAllCleanUp, setupDatabaseAndSynchronizer, logger, switchClient, encryptionService, msleep, fileApi } from '../../testing/test-utils';
|
||||
import MasterKey from '../../models/MasterKey';
|
||||
import { checkIfCanSync, localSyncInfo, localSyncInfoSelector, masterKeyEnabled, mergeSyncInfos, saveLocalSyncInfo, setMasterKeyEnabled, setMasterKeyHasBeenUsed, SyncInfo, syncInfoEquals, checkSyncTargetIsValid, fetchSyncInfo, onRevisionServiceSettingsChanged, setAppMinVersion } from './syncInfoUtils';
|
||||
import { checkIfCanSync, localSyncInfo, localSyncInfoSelector, masterKeyEnabled, mergeSyncInfos, saveLocalSyncInfo, setMasterKeyEnabled, setMasterKeyHasBeenUsed, SyncInfo, syncInfoEquals, checkSyncTargetIsValid, fetchSyncInfo, onRevisionServiceSettingsChanged, setAppMinVersion, checkNoteLockKeyConflict, checkNoteLockKeyMigrationId } from './syncInfoUtils';
|
||||
import Setting from '../../models/Setting';
|
||||
import BaseItem from '../../models/BaseItem';
|
||||
import BaseModel from '../../models/BaseItem';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { State } from '../../reducer';
|
||||
import { MasterKeyEntity } from '../e2ee/types';
|
||||
import { ErrorCode } from '../../errors';
|
||||
|
||||
describe('syncInfoUtils', () => {
|
||||
it('should keep selected sync info stable while the cache is unchanged', () => {
|
||||
@@ -150,23 +152,104 @@ describe('syncInfoUtils', () => {
|
||||
expect(mergeSyncInfos(syncInfo1, syncInfo2).appMinVersion).toBe('0.0.0');
|
||||
});
|
||||
|
||||
it('should merge sync target info and keep the latest note lock key', () => {
|
||||
const syncInfo1 = new SyncInfo();
|
||||
syncInfo1.noteLockKey = {
|
||||
id: '1',
|
||||
content: 'content1',
|
||||
updated_time: 100,
|
||||
};
|
||||
const noteLockKey = (id: string, updated_time: number): MasterKeyEntity => ({ id, content: `content${id}-${updated_time}`, updated_time });
|
||||
|
||||
const syncInfo2 = new SyncInfo();
|
||||
syncInfo2.noteLockKey = {
|
||||
id: '2',
|
||||
content: 'content2',
|
||||
updated_time: 200,
|
||||
};
|
||||
const syncInfoWithNoteLockKey = (key: MasterKeyEntity, syncMigrationId: string) => {
|
||||
const syncInfo = new SyncInfo();
|
||||
syncInfo.noteLockKey = key;
|
||||
syncInfo.syncMigrationId = syncMigrationId;
|
||||
return syncInfo;
|
||||
};
|
||||
|
||||
expect(mergeSyncInfos(syncInfo1, syncInfo2).noteLockKey).toEqual(syncInfo2.noteLockKey);
|
||||
expect(mergeSyncInfos(new SyncInfo(), syncInfo1).noteLockKey).toEqual(syncInfo1.noteLockKey);
|
||||
test.each([
|
||||
['the newest copy of the same key', noteLockKey('1', 100), 'L1', noteLockKey('1', 200), 'L1', false, 'remote'],
|
||||
['the local key when a password reset in the same lineage propagates', noteLockKey('2', 100), 'L1', noteLockKey('1', 200), 'L1', true, 'local'],
|
||||
['the remote key in the same lineage without a propagating reset', noteLockKey('2', 300), 'L1', noteLockKey('1', 200), 'L1', false, 'remote'],
|
||||
['the remote key from a different lineage even with a propagating reset', noteLockKey('2', 300), 'L2', noteLockKey('1', 200), 'L1', true, 'remote'],
|
||||
['the only key, when it is remote', null, '', noteLockKey('1', 200), 'L1', false, 'remote'],
|
||||
['the only key, when it is local', noteLockKey('1', 200), 'L1', null, '', false, 'local'],
|
||||
])('should merge sync target info and keep %s', (_description, localKey, localLineage, remoteKey, remoteLineage, resetPropagates, winner) => {
|
||||
const local = syncInfoWithNoteLockKey(localKey, localLineage);
|
||||
const remote = syncInfoWithNoteLockKey(remoteKey, remoteLineage);
|
||||
const expected = winner === 'local' ? local : remote;
|
||||
|
||||
const merged = mergeSyncInfos(local, remote, { resetPropagates });
|
||||
|
||||
expect(merged.noteLockKey).toEqual(expected.noteLockKey);
|
||||
expect(merged.syncMigrationId).toBe(expected.syncMigrationId);
|
||||
});
|
||||
|
||||
it('should refuse to merge a different lineage while local notes depend on the local key', () => {
|
||||
const local = syncInfoWithNoteLockKey(noteLockKey('2', 300), 'L2');
|
||||
const remote = syncInfoWithNoteLockKey(noteLockKey('1', 200), 'L1');
|
||||
|
||||
expect(() => mergeSyncInfos(local, remote, { hasLocalLockedNotes: true })).toThrow('different lineage');
|
||||
});
|
||||
|
||||
it('should stop the sync and park the remote key when lineages differ and local notes are locked', () => {
|
||||
const local = syncInfoWithNoteLockKey(noteLockKey('2', 300), 'L2');
|
||||
const remote = syncInfoWithNoteLockKey(noteLockKey('1', 200), 'L1');
|
||||
|
||||
expect(() => checkNoteLockKeyConflict(local, remote, true, false)).toThrow(expect.objectContaining({ code: ErrorCode.NoteLockKeyConflict }));
|
||||
expect(Setting.value('noteLock.conflictNoteLockKey')).toEqual({ noteLockKey: remote.noteLockKey, syncMigrationId: 'L1' });
|
||||
});
|
||||
|
||||
test.each([
|
||||
['lineages differ but no local note is locked', 'L2', false],
|
||||
['the keys share a lineage', 'L1', true],
|
||||
])('should let the sync continue when %s', (_description, localLineage, hasLocalLockedNotes) => {
|
||||
const local = syncInfoWithNoteLockKey(noteLockKey('2', 300), localLineage);
|
||||
const remote = syncInfoWithNoteLockKey(noteLockKey('1', 200), 'L1');
|
||||
|
||||
expect(() => checkNoteLockKeyConflict(local, remote, hasLocalLockedNotes, false)).not.toThrow();
|
||||
expect(Setting.value('noteLock.conflictNoteLockKey')).toEqual({});
|
||||
});
|
||||
|
||||
it('should keep rejecting syncs while notes depend on the parked key', () => {
|
||||
const parked = { noteLockKey: noteLockKey('1', 200), syncMigrationId: 'L1' };
|
||||
Setting.setValue('noteLock.conflictNoteLockKey', parked);
|
||||
const local = syncInfoWithNoteLockKey(noteLockKey('3', 300), 'L3');
|
||||
const remote = syncInfoWithNoteLockKey(noteLockKey('3', 300), 'L3');
|
||||
|
||||
expect(() => checkNoteLockKeyConflict(local, remote, false, false, true)).toThrow(expect.objectContaining({ code: ErrorCode.NoteLockKeyConflict }));
|
||||
expect(Setting.value('noteLock.conflictNoteLockKey')).toEqual(parked);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['the sync target has no key', null],
|
||||
['the sync target has the local key', noteLockKey('3', 300)],
|
||||
])('should drop a parked key that no note depends on when %s', (_description, remoteKey) => {
|
||||
Setting.setValue('noteLock.conflictNoteLockKey', { noteLockKey: noteLockKey('1', 200), syncMigrationId: 'L1' });
|
||||
const local = syncInfoWithNoteLockKey(noteLockKey('3', 300), 'L3');
|
||||
const remote = syncInfoWithNoteLockKey(remoteKey, remoteKey ? 'L3' : '');
|
||||
|
||||
expect(() => checkNoteLockKeyConflict(local, remote, true, false)).not.toThrow();
|
||||
expect(Setting.value('noteLock.conflictNoteLockKey')).toEqual({});
|
||||
});
|
||||
|
||||
it('should park the new sync target key when it changed and no note depends on the old one', () => {
|
||||
Setting.setValue('noteLock.conflictNoteLockKey', { noteLockKey: noteLockKey('1', 200), syncMigrationId: 'L1' });
|
||||
const local = syncInfoWithNoteLockKey(noteLockKey('3', 300), 'L3');
|
||||
const remote = syncInfoWithNoteLockKey(noteLockKey('4', 400), 'L4');
|
||||
|
||||
expect(() => checkNoteLockKeyConflict(local, remote, true, false)).toThrow(expect.objectContaining({ code: ErrorCode.NoteLockKeyConflict }));
|
||||
expect(Setting.value('noteLock.conflictNoteLockKey')).toEqual({ noteLockKey: remote.noteLockKey, syncMigrationId: 'L4' });
|
||||
});
|
||||
|
||||
test.each([
|
||||
['a key without a sync migration id', noteLockKey('1', 200), '', true],
|
||||
['a key with a sync migration id', noteLockKey('1', 200), 'L1', false],
|
||||
['no key', null, '', false],
|
||||
])('should check the sync migration id: %s', (_description, key, syncMigrationId, throws) => {
|
||||
const syncInfo = syncInfoWithNoteLockKey(key, syncMigrationId);
|
||||
for (const [isRemote, side] of [[false, 'on this device'], [true, 'on the sync target']] as const) {
|
||||
const check = () => checkNoteLockKeyMigrationId(syncInfo, isRemote);
|
||||
if (throws) {
|
||||
expect(check).toThrow(side);
|
||||
} else {
|
||||
expect(check).not.toThrow();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should merge sync target info and takes into account usage of master key - 1', async () => {
|
||||
@@ -313,6 +396,7 @@ describe('syncInfoUtils', () => {
|
||||
'value': '400227d2222c4d3bb7346514861c643b',
|
||||
},
|
||||
'appMinVersion': '0.0.0',
|
||||
'syncMigrationId': '',
|
||||
'e2ee': {
|
||||
'updatedTime': 0,
|
||||
'value': true,
|
||||
|
||||
@@ -12,6 +12,7 @@ import JoplinError from '../../JoplinError';
|
||||
import { ErrorCode } from '../../errors';
|
||||
import fastDeepEqual = require('fast-deep-equal');
|
||||
import { createSelector } from 'reselect';
|
||||
import isNoteLockEnabled from '../noteLock/isNoteLockEnabled';
|
||||
|
||||
const logger = Logger.create('syncInfoUtils');
|
||||
|
||||
@@ -35,6 +36,17 @@ export interface SyncInfoValuePublicPrivateKeyPair {
|
||||
updatedTime: number;
|
||||
}
|
||||
|
||||
export interface MergeSyncInfosOptions {
|
||||
resetPropagates?: boolean;
|
||||
hasLocalLockedNotes?: boolean;
|
||||
}
|
||||
|
||||
// The sync target's note lock key and sync migration id, parked while a sync is stopped on a conflict.
|
||||
export interface NoteLockKeyConflict {
|
||||
noteLockKey: MasterKeyEntity;
|
||||
syncMigrationId: string;
|
||||
}
|
||||
|
||||
// This should be set to the client version whenever we require all the clients to be at the same
|
||||
// version in order to synchronise. One example is when adding support for the trash feature - if an
|
||||
// old client that doesn't know about this feature synchronises data with a new client, the notes
|
||||
@@ -269,7 +281,7 @@ const mergeActiveMasterKeys = (s1: SyncInfo, s2: SyncInfo, output: SyncInfo) =>
|
||||
};
|
||||
|
||||
// If there is a distinction, s1 should be local sync info and s2 remote.
|
||||
export function mergeSyncInfos(s1: SyncInfo, s2: SyncInfo): SyncInfo {
|
||||
export function mergeSyncInfos(s1: SyncInfo, s2: SyncInfo, options: MergeSyncInfosOptions = {}): SyncInfo {
|
||||
const output: SyncInfo = new SyncInfo();
|
||||
|
||||
output.setWithTimestamp(s1.keyTimestamp('e2ee') > s2.keyTimestamp('e2ee') ? s1 : s2, 'e2ee');
|
||||
@@ -292,15 +304,23 @@ export function mergeSyncInfos(s1: SyncInfo, s2: SyncInfo): SyncInfo {
|
||||
}
|
||||
}
|
||||
|
||||
const noteLockKey1 = s1.noteLockKey;
|
||||
const noteLockKey2 = s2.noteLockKey;
|
||||
if (!noteLockKey1) {
|
||||
output.noteLockKey = noteLockKey2;
|
||||
} else if (!noteLockKey2) {
|
||||
output.noteLockKey = noteLockKey1;
|
||||
let noteLockKeySource: SyncInfo;
|
||||
if (!s2.noteLockKey) {
|
||||
noteLockKeySource = s1;
|
||||
} else if (!s1.noteLockKey) {
|
||||
noteLockKeySource = s2;
|
||||
} else if (s1.noteLockKey.id === s2.noteLockKey.id) {
|
||||
noteLockKeySource = (s1.noteLockKey.updated_time || 0) >= (s2.noteLockKey.updated_time || 0) ? s1 : s2;
|
||||
} else if (s1.syncMigrationId === s2.syncMigrationId) {
|
||||
noteLockKeySource = options.resetPropagates ? s1 : s2;
|
||||
} else if (!options.hasLocalLockedNotes) {
|
||||
noteLockKeySource = s2;
|
||||
} else {
|
||||
output.noteLockKey = (noteLockKey1.updated_time || 0) >= (noteLockKey2.updated_time || 0) ? noteLockKey1 : noteLockKey2;
|
||||
// checkNoteLockKeyConflict() stops the sync before this point, so this only guards against a future caller skipping it.
|
||||
throw new Error('Cannot merge a note lock key from a different lineage while local notes depend on the local key');
|
||||
}
|
||||
output.noteLockKey = noteLockKeySource.noteLockKey;
|
||||
output.syncMigrationId = noteLockKeySource.syncMigrationId;
|
||||
|
||||
// We use >= so that the version from s1 (local) is preferred to the version in s2 (remote).
|
||||
// For example, if s2 has appMinVersion 0.00 and s1 has appMinVersion 0.0.0, we choose the
|
||||
@@ -321,6 +341,7 @@ export class SyncInfo {
|
||||
private activeMasterKeyId_: SyncInfoValueString;
|
||||
private masterKeys_: MasterKeyEntity[] = [];
|
||||
private noteLockKey_: MasterKeyEntity = null;
|
||||
private syncMigrationId_ = '';
|
||||
private ppk_: SyncInfoValuePublicPrivateKeyPair;
|
||||
private appMinVersion_: string = appMinVersion_;
|
||||
private revisionServiceEnabled_: SyncInfoValueBoolean;
|
||||
@@ -343,6 +364,7 @@ export class SyncInfo {
|
||||
activeMasterKeyId: this.activeMasterKeyId_,
|
||||
masterKeys: this.masterKeys,
|
||||
noteLockKey: this.noteLockKey,
|
||||
syncMigrationId: this.syncMigrationId,
|
||||
ppk: this.ppk_,
|
||||
appMinVersion: this.appMinVersion,
|
||||
revisionServiceEnabled: this.revisionServiceEnabled_,
|
||||
@@ -393,6 +415,7 @@ export class SyncInfo {
|
||||
this.activeMasterKeyId_ = 'activeMasterKeyId' in s ? s.activeMasterKeyId : { value: '', updatedTime: 0 };
|
||||
this.masterKeys_ = 'masterKeys' in s ? s.masterKeys : [];
|
||||
this.noteLockKey_ = 'noteLockKey' in s ? s.noteLockKey : null;
|
||||
this.syncMigrationId_ = 'syncMigrationId' in s ? s.syncMigrationId : '';
|
||||
this.ppk_ = 'ppk' in s ? s.ppk : { value: null, updatedTime: 0 };
|
||||
this.appMinVersion_ = s.appMinVersion ? s.appMinVersion : '0.0.0';
|
||||
this.revisionServiceEnabled_ = 'revisionServiceEnabled' in s ? s.revisionServiceEnabled : { value: true, updatedTime: 0 };
|
||||
@@ -502,6 +525,16 @@ export class SyncInfo {
|
||||
this.noteLockKey_ = v;
|
||||
}
|
||||
|
||||
// Identifies the note lock key lineage: created with the first key, kept through password resets and
|
||||
// replaced only when the user migrates to a sync target's key.
|
||||
public get syncMigrationId(): string {
|
||||
return this.syncMigrationId_;
|
||||
}
|
||||
|
||||
public set syncMigrationId(v: string) {
|
||||
this.syncMigrationId_ = v;
|
||||
}
|
||||
|
||||
public keyTimestamp(name: string): number {
|
||||
const self = this as unknown as Record<string, { updatedTime: number }>;
|
||||
if (!(`${name}_` in self)) throw new Error(`Invalid name: ${name}`);
|
||||
@@ -576,8 +609,7 @@ export const setMasterKeyHasBeenUsed = (s: SyncInfo, mkId: string) => {
|
||||
updated_time: Date.now(),
|
||||
};
|
||||
|
||||
saveLocalSyncInfo(s);
|
||||
|
||||
// Saved by the caller with the rest of the merge, so the sync never persists a candidate its checks may still reject.
|
||||
return s;
|
||||
};
|
||||
|
||||
@@ -604,6 +636,83 @@ export function masterKeyById(id: string) {
|
||||
return localSyncInfo().masterKeys.find(mk => mk.id === id);
|
||||
}
|
||||
|
||||
const noteLockKeyConflictSettingKey = 'noteLock.conflictNoteLockKey';
|
||||
|
||||
export const noteLockKeyConflict = (): NoteLockKeyConflict | null => {
|
||||
const conflict = Setting.value(noteLockKeyConflictSettingKey) as Partial<NoteLockKeyConflict>;
|
||||
return conflict.noteLockKey ? conflict as NoteLockKeyConflict : null;
|
||||
};
|
||||
|
||||
export const noteLockKeyConflictFromState = (state: State) => {
|
||||
return isNoteLockEnabled() && !!(state.settings[noteLockKeyConflictSettingKey] as Partial<NoteLockKeyConflict>)?.noteLockKey;
|
||||
};
|
||||
|
||||
// Replaces the local key and lineage with the sync target's, so callers migrate first or warn: notes still locked
|
||||
// with the local key become unreadable. A pending reset belonged to the dropped lineage, so it is cleared too.
|
||||
export const adoptNoteLockKeyConflict = () => {
|
||||
const conflict = noteLockKeyConflict();
|
||||
if (!conflict) throw new Error('No note lock key conflict to adopt');
|
||||
const syncInfo = localSyncInfo();
|
||||
syncInfo.noteLockKey = conflict.noteLockKey;
|
||||
syncInfo.syncMigrationId = conflict.syncMigrationId;
|
||||
saveLocalSyncInfo(syncInfo);
|
||||
Setting.setValue(noteLockKeyConflictSettingKey, {});
|
||||
Setting.setValue('noteLock.passwordReset', false);
|
||||
Setting.setValue('noteLock.keyIdToReset', '');
|
||||
};
|
||||
|
||||
// Different lineages with local notes depending on the local key stop the sync: the remote key is parked for the
|
||||
// migration and keeps rejecting syncs until resolved. A stale reset (another device reset first) counts as a different lineage.
|
||||
// A parked key is checked again against the current target, for example after switching to another sync target, unless a
|
||||
// migration is running or has already moved notes to it (parkedKeyInUse), since those notes need it.
|
||||
export const checkNoteLockKeyConflict = (local: SyncInfo, remote: SyncInfo, hasLocalLockedNotes: boolean, staleReset: boolean, parkedKeyInUse = false) => {
|
||||
const parked = noteLockKeyConflict();
|
||||
if (!parked || !parkedKeyInUse) {
|
||||
const keysDiffer = !!local.noteLockKey && !!remote.noteLockKey && local.noteLockKey.id !== remote.noteLockKey.id;
|
||||
// The scenarios where a conflict occurs are when a different device has set up note lock for the first time (noteLockKey
|
||||
// and syncMigrationId both differ) and when both remote and local have reset the password at the point of syncing (which
|
||||
// makes staleReset true). staleReset is false both when a local password reset should propagate and when only a remote
|
||||
// password reset was made (which silently accepts the remote key), and syncMigrationId is what tells a password reset
|
||||
// apart from a key created for the first time.
|
||||
if (!keysDiffer || (local.syncMigrationId === remote.syncMigrationId && !staleReset) || !hasLocalLockedNotes) {
|
||||
if (parked) Setting.setValue(noteLockKeyConflictSettingKey, {});
|
||||
return;
|
||||
}
|
||||
const conflict: NoteLockKeyConflict = { noteLockKey: remote.noteLockKey, syncMigrationId: remote.syncMigrationId };
|
||||
if (!parked || parked.syncMigrationId !== conflict.syncMigrationId || parked.noteLockKey.id !== conflict.noteLockKey.id || parked.noteLockKey.updated_time !== conflict.noteLockKey.updated_time) {
|
||||
Setting.setValue(noteLockKeyConflictSettingKey, conflict);
|
||||
}
|
||||
}
|
||||
throw new JoplinError(isNoteLockEnabled()
|
||||
? _('Synchronisation was stopped because the sync target uses a different note lock key to the one on your device. Your locked notes must be migrated to the synced key before synchronisation can continue.')
|
||||
: _('Synchronisation was stopped because a note lock key migration is required. Enable the note lock feature to migrate your locked notes.'), ErrorCode.NoteLockKeyConflict);
|
||||
};
|
||||
|
||||
const sameNoteLockKey = (a: SyncInfo, b: SyncInfo) => {
|
||||
return a.noteLockKey?.id === b.noteLockKey?.id && a.noteLockKey?.updated_time === b.noteLockKey?.updated_time && a.syncMigrationId === b.syncMigrationId;
|
||||
};
|
||||
|
||||
// A reset, password change or migration that lands while a sync is in flight would be clobbered by the sync's
|
||||
// local save, so the sync stops instead and the next one starts from the new state.
|
||||
export const checkNoteLockKeyUnchanged = (snapshot: SyncInfo) => {
|
||||
if (sameNoteLockKey(localSyncInfo(), snapshot)) return;
|
||||
throw new Error(_('Synchronisation was stopped because the note lock key changed on this device during the sync. Please synchronise again.'));
|
||||
};
|
||||
|
||||
export const checkRemoteNoteLockKeyUnchanged = (fetched: SyncInfo, latest: SyncInfo) => {
|
||||
if (sameNoteLockKey(fetched, latest)) return;
|
||||
throw new Error(_('Synchronisation was stopped because the note lock key on the sync target changed during the sync. Please synchronise again.'));
|
||||
};
|
||||
|
||||
// Sync migration ids predate the release of the note lock feature, so a key without one comes from a
|
||||
// development build and cannot take part in the reset and migration handling.
|
||||
export const checkNoteLockKeyMigrationId = (s: SyncInfo, isRemote: boolean) => {
|
||||
if (!s.noteLockKey || s.syncMigrationId) return;
|
||||
throw new Error(isRemote
|
||||
? _('Synchronisation was stopped because the note lock key on the sync target has no sync migration ID.')
|
||||
: _('Synchronisation was stopped because the note lock key on this device has no sync migration ID. Please reset the note lock password.'));
|
||||
};
|
||||
|
||||
export const checkIfCanSync = (s: SyncInfo, appVersion: string) => {
|
||||
const isForwardCompatible = () => {
|
||||
// Forward compatibility: This version of Joplin supports the Joplin 3.7 sync target format
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"fs-extra": "11.3.6",
|
||||
"html-entities": "1.4.0",
|
||||
"jquery": "3.7.1",
|
||||
"knex": "3.2.10",
|
||||
"knex": "3.3.0",
|
||||
"koa": "2.16.4",
|
||||
"ldapts": "8.1.8",
|
||||
"markdown-it": "13.0.2",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { expectThrow } from '../../utils/testing/testUtils';
|
||||
import { expectHttpError, expectThrow } from '../../utils/testing/testUtils';
|
||||
import { defaultPagination, Pagination, createPaginationLinks, requestPagination, PaginationOrderDir } from './pagination';
|
||||
import { ErrorBadRequest } from '../../utils/errors';
|
||||
import { encodeBase64 } from '../../utils/base64';
|
||||
|
||||
describe('pagination', () => {
|
||||
|
||||
@@ -69,6 +71,38 @@ describe('pagination', () => {
|
||||
await expectThrow(async () => requestPagination({ page: 0 }));
|
||||
});
|
||||
|
||||
test.each([
|
||||
'title; DROP TABLE items',
|
||||
'items.title',
|
||||
'1',
|
||||
'',
|
||||
'title ASC',
|
||||
'count(*)',
|
||||
])('should reject an order_by that is not a column name: %s', async (orderBy) => {
|
||||
// The field ends up as a column name in the query, so anything that
|
||||
// could not be one must be reported as a bad request rather than
|
||||
// reaching the database.
|
||||
await expectHttpError(async () => requestPagination({ order_by: orderBy }), ErrorBadRequest.httpCode);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['not base64 at all', 'Invalid cursor'],
|
||||
[encodeBase64('{ not json'), 'Invalid cursor'],
|
||||
[encodeBase64(JSON.stringify({ limit: -1, page: 1, order: [] })), 'Limit out of bond: -1'],
|
||||
[encodeBase64(JSON.stringify({ limit: 10, page: 1, order: [{ by: 'title; DROP TABLE items', dir: 'asc' }] })), 'Invalid order_by'],
|
||||
[encodeBase64(JSON.stringify({ limit: 10, page: 1 })), 'Invalid order parameter'],
|
||||
])('should reject a malformed cursor: %s', async (cursor) => {
|
||||
// The cursor is supplied by the client, so it cannot be trusted just
|
||||
// because the server issued one earlier.
|
||||
await expectHttpError(async () => requestPagination({ cursor }), ErrorBadRequest.httpCode);
|
||||
});
|
||||
|
||||
test('should accept a cursor the server issued', async () => {
|
||||
const cursor = encodeBase64(JSON.stringify(defaultPagination()));
|
||||
const p = requestPagination({ cursor });
|
||||
expect(p.page).toBe(defaultPagination().page + 1);
|
||||
});
|
||||
|
||||
test('should create page link logic', async () => {
|
||||
expect(createPaginationLinks(1, 5)).toEqual([
|
||||
{ page: 1, isCurrent: true },
|
||||
|
||||
@@ -39,6 +39,17 @@ export const pageMaxSize = 100;
|
||||
const defaultOrderField_ = 'updated_time';
|
||||
const defaultOrderDir_ = PaginationOrderDir.DESC;
|
||||
|
||||
// The order field ends up as a column name in the SQL query. Knex quotes
|
||||
// identifiers, so this is not an injection vector, but an arbitrary string
|
||||
// still reaches the database and comes back as a 500. Only accept something
|
||||
// that could be a column name, and reject anything else as a bad request.
|
||||
const orderFieldRegex_ = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
|
||||
const validateOrderField = (field: string) => {
|
||||
if (typeof field !== 'string' || !orderFieldRegex_.test(field)) throw new ErrorBadRequest(`Invalid order_by parameter: ${field}`);
|
||||
return field;
|
||||
};
|
||||
|
||||
export function defaultPagination(): Pagination {
|
||||
return {
|
||||
limit: pageMaxSize,
|
||||
@@ -66,26 +77,39 @@ export function requestPaginationOrder(query: ParsedUrlQuery | PaginationQueryPa
|
||||
if (![PaginationOrderDir.ASC, PaginationOrderDir.DESC].includes(orderDir)) throw new ErrorBadRequest(`Invalid order_dir parameter: ${orderDir}`);
|
||||
|
||||
return [{
|
||||
by: orderBy,
|
||||
by: validateOrderField(orderBy),
|
||||
dir: orderDir,
|
||||
}];
|
||||
}
|
||||
|
||||
export function validatePagination(p: Pagination): Pagination {
|
||||
if (p.limit < 0 || p.limit > pageMaxSize) throw new ErrorBadRequest(`Limit out of bond: ${p.limit}`);
|
||||
if (p.page <= 0) throw new ErrorBadRequest(`Invalid page number: ${p.page}`);
|
||||
if (!p || typeof p !== 'object') throw new ErrorBadRequest('Invalid pagination');
|
||||
if (!Number.isFinite(p.limit) || p.limit < 0 || p.limit > pageMaxSize) throw new ErrorBadRequest(`Limit out of bond: ${p.limit}`);
|
||||
if (!Number.isFinite(p.page) || p.page <= 0) throw new ErrorBadRequest(`Invalid page number: ${p.page}`);
|
||||
if (!Array.isArray(p.order)) throw new ErrorBadRequest('Invalid order parameter');
|
||||
|
||||
for (const o of p.order) {
|
||||
if (![PaginationOrderDir.ASC, PaginationOrderDir.DESC].includes(o.dir)) throw new ErrorBadRequest(`Invalid order_dir parameter: ${o.dir}`);
|
||||
validateOrderField(o.by);
|
||||
}
|
||||
|
||||
return p;
|
||||
}
|
||||
|
||||
function processCursor(pagination: Pagination): Pagination {
|
||||
// If a cursor is present, we parse it and move to the next page.
|
||||
// If a cursor is present, we parse it and move to the next page. The
|
||||
// cursor comes from the client, so a malformed one must be reported as a
|
||||
// bad request rather than fail while parsing.
|
||||
if (pagination.cursor) {
|
||||
const p = validatePagination(JSON.parse(decodeBase64(pagination.cursor)));
|
||||
let decoded: Pagination = null;
|
||||
|
||||
try {
|
||||
decoded = JSON.parse(decodeBase64(pagination.cursor));
|
||||
} catch (error) {
|
||||
throw new ErrorBadRequest('Invalid cursor');
|
||||
}
|
||||
|
||||
const p = validatePagination(decoded);
|
||||
p.page++;
|
||||
return p;
|
||||
}
|
||||
@@ -100,9 +124,11 @@ export function requestPagination(query: (Pagination & PaginationQueryParams) |
|
||||
return processCursor(query);
|
||||
}
|
||||
|
||||
const limit = 'limit' in query ? query.limit : pageMaxSize;
|
||||
// These come from the query string, so they are strings at runtime even
|
||||
// though the type says otherwise.
|
||||
const limit = 'limit' in query ? Number(query.limit) : pageMaxSize;
|
||||
const order: PaginationOrder[] = requestPaginationOrder(query);
|
||||
const page: number = 'page' in query ? query.page : 1;
|
||||
const page: number = 'page' in query ? Number(query.page) : 1;
|
||||
|
||||
return validatePagination({ limit, order, page });
|
||||
}
|
||||
|
||||
@@ -375,4 +375,5 @@ twodo
|
||||
Gemma
|
||||
untruncated
|
||||
instr
|
||||
mixmark
|
||||
Hrvatski
|
||||
File diff suppressed because it is too large.
Load diff
@@ -59,7 +59,7 @@
|
||||
"gulp": "4.0.2",
|
||||
"html-entities": "1.4.0",
|
||||
"jest": "29.7.0",
|
||||
"js-yaml": "4.2.0",
|
||||
"js-yaml": "4.3.0",
|
||||
"rss": "1.2.2",
|
||||
"sass": "1.101.7",
|
||||
"sqlite3": "5.1.6",
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"dotenv": "17.4.2",
|
||||
"file-type": "16.5.4",
|
||||
"fs-extra": "11.3.6",
|
||||
"knex": "3.2.10",
|
||||
"knex": "3.3.0",
|
||||
"koa": "2.16.4",
|
||||
"koa-body": "6.0.1",
|
||||
"pg-boss": "10.4.2",
|
||||
|
||||
@@ -29,8 +29,11 @@ function getColumnAlignment(table, columnIndex) {
|
||||
|
||||
var align = '';
|
||||
|
||||
for (var i = 0; i < table.rows.length; ++i) {
|
||||
var row = table.rows[i];
|
||||
// Read `rows` once: under domino each access re-walks the whole table, which made converting large tables
|
||||
// quadratic: https://github.com/laurent22/joplin/issues/16894
|
||||
var rows = table.rows;
|
||||
for (var i = 0; i < rows.length; ++i) {
|
||||
var row = rows[i];
|
||||
if (columnIndex < row.childNodes.length) {
|
||||
var cellAlignment = getAlignment(row.childNodes[columnIndex]);
|
||||
++votes[cellAlignment];
|
||||
@@ -371,8 +374,9 @@ function handleColSpan(content, node, emptyChar) {
|
||||
|
||||
function tableColCount(node) {
|
||||
let maxColCount = 0;
|
||||
for (let i = 0; i < node.rows.length; i++) {
|
||||
const row = node.rows[i]
|
||||
const rows = node.rows;
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i]
|
||||
const colCount = row.childNodes.length
|
||||
if (colCount > maxColCount) maxColCount = colCount
|
||||
}
|
||||
|
||||
@@ -12590,7 +12590,7 @@ __metadata:
|
||||
md5: "npm:2.3.0"
|
||||
moment: "npm:2.30.1"
|
||||
mustache: "npm:4.2.0"
|
||||
nan: "npm:2.27.0"
|
||||
nan: "npm:2.28.0"
|
||||
node-notifier: "npm:10.0.1"
|
||||
node-rsa: "npm:1.1.1"
|
||||
onnxruntime-node: "npm:1.27.0"
|
||||
@@ -12951,7 +12951,7 @@ __metadata:
|
||||
immer: "npm:9.0.21"
|
||||
jest: "npm:29.7.0"
|
||||
jest-expect-message: "npm:1.1.3"
|
||||
js-yaml: "npm:4.2.0"
|
||||
js-yaml: "npm:4.3.0"
|
||||
jsdom: "npm:26.1.0"
|
||||
markdown-it: "npm:13.0.2"
|
||||
md5: "npm:2.3.0"
|
||||
@@ -13170,7 +13170,7 @@ __metadata:
|
||||
jest-expect-message: "npm:1.1.3"
|
||||
jquery: "npm:3.7.1"
|
||||
jsdom: "npm:26.1.0"
|
||||
knex: "npm:3.2.10"
|
||||
knex: "npm:3.3.0"
|
||||
koa: "npm:2.16.4"
|
||||
ldapts: "npm:8.1.8"
|
||||
markdown-it: "npm:13.0.2"
|
||||
@@ -13227,7 +13227,7 @@ __metadata:
|
||||
gulp: "npm:4.0.2"
|
||||
html-entities: "npm:1.4.0"
|
||||
jest: "npm:29.7.0"
|
||||
js-yaml: "npm:4.2.0"
|
||||
js-yaml: "npm:4.3.0"
|
||||
license-checker-rseidelsohn: "npm:4.4.2"
|
||||
markdown-it: "npm:13.0.2"
|
||||
md5-file: "npm:5.0.0"
|
||||
@@ -13267,7 +13267,7 @@ __metadata:
|
||||
gulp: "npm:4.0.2"
|
||||
jest: "npm:29.7.0"
|
||||
jest-expect-message: "npm:1.1.3"
|
||||
knex: "npm:3.2.10"
|
||||
knex: "npm:3.3.0"
|
||||
koa: "npm:2.16.4"
|
||||
koa-body: "npm:6.0.1"
|
||||
pg-boss: "npm:10.4.2"
|
||||
@@ -39285,14 +39285,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"js-yaml@npm:4.2.0, js-yaml@npm:^4.1.1":
|
||||
version: 4.2.0
|
||||
resolution: "js-yaml@npm:4.2.0"
|
||||
"js-yaml@npm:4.3.0":
|
||||
version: 4.3.0
|
||||
resolution: "js-yaml@npm:4.3.0"
|
||||
dependencies:
|
||||
argparse: "npm:^2.0.1"
|
||||
bin:
|
||||
js-yaml: bin/js-yaml.js
|
||||
checksum: 10/51de2067a2b44b07ba5206132e56005f8b568ff279bb4d2f645068958c56fa4827d40a6841c983234671fa0a134bf094d0b0717873c2a3d319185297af145a6d
|
||||
checksum: 10/2bcec3a8118d7f744badeb04e14366578d234a736f353d41fe35d2305e4ce2409a8e041d277f07cd6bbc8aaa12128d650a68ce43247072519bede20962d2126f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -39319,6 +39319,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"js-yaml@npm:^4.1.1":
|
||||
version: 4.2.0
|
||||
resolution: "js-yaml@npm:4.2.0"
|
||||
dependencies:
|
||||
argparse: "npm:^2.0.1"
|
||||
bin:
|
||||
js-yaml: bin/js-yaml.js
|
||||
checksum: 10/51de2067a2b44b07ba5206132e56005f8b568ff279bb4d2f645068958c56fa4827d40a6841c983234671fa0a134bf094d0b0717873c2a3d319185297af145a6d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jsbn@npm:~0.1.0":
|
||||
version: 0.1.1
|
||||
resolution: "jsbn@npm:0.1.1"
|
||||
@@ -39895,9 +39906,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"knex@npm:3.2.10":
|
||||
version: 3.2.10
|
||||
resolution: "knex@npm:3.2.10"
|
||||
"knex@npm:3.3.0":
|
||||
version: 3.3.0
|
||||
resolution: "knex@npm:3.3.0"
|
||||
dependencies:
|
||||
colorette: "npm:2.0.19"
|
||||
commander: "npm:^10.0.0"
|
||||
@@ -39911,13 +39922,15 @@ __metadata:
|
||||
pg-connection-string: "npm:2.6.2"
|
||||
rechoir: "npm:^0.8.0"
|
||||
resolve-from: "npm:^5.0.0"
|
||||
tarn: "npm:^3.0.2"
|
||||
tarn: "npm:^3.1.0"
|
||||
tildify: "npm:2.0.0"
|
||||
peerDependencies:
|
||||
pg-query-stream: ^4.14.0
|
||||
peerDependenciesMeta:
|
||||
better-sqlite3:
|
||||
optional: true
|
||||
mariadb:
|
||||
optional: true
|
||||
mysql:
|
||||
optional: true
|
||||
mysql2:
|
||||
@@ -39934,7 +39947,7 @@ __metadata:
|
||||
optional: true
|
||||
bin:
|
||||
knex: bin/cli.js
|
||||
checksum: 10/6d043d0d55bee2d6ecc977dd67fbff61121f8832d70a635f490a3979bf82cae001ee0b53a1d1222ef46c3f1bf5afac39b1fb918aaefabcc7a7a57d7c97e3cf7f
|
||||
checksum: 10/40924546fb223d186dd76b5eea415b5eaee517b9ba1d8ddad689e5c68a0e4e08fffe8e69f9e9d31ca533c8b8c0244f7bb47bc75b308efb58bceccc16322ec11d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -44481,12 +44494,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"nan@npm:2.27.0":
|
||||
version: 2.27.0
|
||||
resolution: "nan@npm:2.27.0"
|
||||
"nan@npm:2.28.0":
|
||||
version: 2.28.0
|
||||
resolution: "nan@npm:2.28.0"
|
||||
dependencies:
|
||||
node-gyp: "npm:latest"
|
||||
checksum: 10/bdce0630e417740501394c412bd9f0ed1c287825e3b8f9b7efb95cc3acd3ef69de60479b5f00a2d039b79321e5ce29b672b0b263cfe0e4d8f47c8f810a24a5ee
|
||||
checksum: 10/299ffb18f43cc53b0728cb16ed15e868960168b84d4140b54c8e057c54e6714d0291eee63c02c3cae4bcf5d0d06b6a93fc8717d8d9ae72ad6d4e8eba5ddacc66
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -56583,6 +56596,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tarn@npm:^3.1.0":
|
||||
version: 3.1.2
|
||||
resolution: "tarn@npm:3.1.2"
|
||||
checksum: 10/7b8bb2f439abd867ac40ab72c2a1d8f6bf7825523c15189b71bcc2fe38b38a404319f0358cf32fa0d3c3b731eeff5429dfadae69eeb14eaf2903a4f04c0d7a18
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tcompare@npm:^5.0.6, tcompare@npm:^5.0.7":
|
||||
version: 5.0.7
|
||||
resolution: "tcompare@npm:5.0.7"
|
||||
|
||||
Reference in new issue
Block a user