You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-11-23 22:36:32 +02:00
This commit is contained in:
@@ -1,16 +1,17 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import { View, Text, Button, FlatList, TextStyle, StyleSheet } from 'react-native';
|
import { View, Text, Button, FlatList, TextStyle, StyleSheet, Role } from 'react-native';
|
||||||
import Setting from '@joplin/lib/models/Setting';
|
import Setting from '@joplin/lib/models/Setting';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { ScreenHeader } from '../ScreenHeader';
|
import { ScreenHeader } from '../ScreenHeader';
|
||||||
import ReportService, { ReportSection } from '@joplin/lib/services/ReportService';
|
import ReportService, { ReportItemType, ReportSection } from '@joplin/lib/services/ReportService';
|
||||||
import { _ } from '@joplin/lib/locale';
|
import { _ } from '@joplin/lib/locale';
|
||||||
import { BaseScreenComponent } from '../base-screen';
|
import { BaseScreenComponent } from '../base-screen';
|
||||||
import { themeStyle } from '../global-style';
|
import { themeStyle } from '../global-style';
|
||||||
import { AppState } from '../../utils/types';
|
import { AppState } from '../../utils/types';
|
||||||
import checkDisabledSyncItemsNotification from '@joplin/lib/services/synchronizer/utils/checkDisabledSyncItemsNotification';
|
import checkDisabledSyncItemsNotification from '@joplin/lib/services/synchronizer/utils/checkDisabledSyncItemsNotification';
|
||||||
import { Dispatch } from 'redux';
|
import { Dispatch } from 'redux';
|
||||||
|
import Icon from '../Icon';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
themeId: number;
|
themeId: number;
|
||||||
@@ -21,6 +22,86 @@ interface State {
|
|||||||
report: ReportSection[];
|
report: ReportSection[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ProcessedLine {
|
||||||
|
key: string;
|
||||||
|
text?: string;
|
||||||
|
isSection?: boolean;
|
||||||
|
isDivider?: boolean;
|
||||||
|
retryAllHandler?: ()=> void;
|
||||||
|
retryHandler?: ()=> void;
|
||||||
|
ignoreHandler?: ()=> void;
|
||||||
|
listItems?: ProcessedLine[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type OnRefreshScreen = ()=> Promise<void>;
|
||||||
|
|
||||||
|
const processReport = (report: ReportSection[], refreshScreen: OnRefreshScreen, dispatch: Dispatch, baseStyle: TextStyle) => {
|
||||||
|
const lines: ProcessedLine[] = [];
|
||||||
|
let currentList: ProcessedLine[]|null = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < report.length; i++) {
|
||||||
|
const section = report[i];
|
||||||
|
|
||||||
|
let style: TextStyle = { ...baseStyle };
|
||||||
|
style.fontWeight = 'bold';
|
||||||
|
if (i > 0) style.paddingTop = 20;
|
||||||
|
lines.push({ key: `section_${i}`, isSection: true, text: section.title });
|
||||||
|
if (section.canRetryAll) {
|
||||||
|
lines.push({ key: `retry_all_${i}`, text: '', retryAllHandler: section.retryAllHandler });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const n in section.body) {
|
||||||
|
if (!section.body.hasOwnProperty(n)) continue;
|
||||||
|
style = { ...baseStyle };
|
||||||
|
const item = section.body[n];
|
||||||
|
|
||||||
|
let text = '';
|
||||||
|
|
||||||
|
let retryHandler = null;
|
||||||
|
let ignoreHandler = null;
|
||||||
|
if (typeof item === 'object') {
|
||||||
|
if (item.canRetry) {
|
||||||
|
retryHandler = async () => {
|
||||||
|
await item.retryHandler();
|
||||||
|
await refreshScreen();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (item.canIgnore) {
|
||||||
|
ignoreHandler = async () => {
|
||||||
|
await item.ignoreHandler();
|
||||||
|
await refreshScreen();
|
||||||
|
await checkDisabledSyncItemsNotification((action) => dispatch(action));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (item.type === ReportItemType.OpenList) {
|
||||||
|
currentList = [];
|
||||||
|
} else if (item.type === ReportItemType.CloseList) {
|
||||||
|
lines.push({ key: `list_${i}_${n}`, listItems: currentList });
|
||||||
|
currentList = null;
|
||||||
|
}
|
||||||
|
text = item.text;
|
||||||
|
} else {
|
||||||
|
text = item;
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = { key: `item_${i}_${n}`, text: text, retryHandler, ignoreHandler };
|
||||||
|
if (currentList) {
|
||||||
|
// The OpenList item, for example, might be empty and should be skipped:
|
||||||
|
const hasContent = line.text || retryHandler || ignoreHandler;
|
||||||
|
if (hasContent) {
|
||||||
|
currentList.push(line);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lines.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push({ key: `divider2_${i}`, isDivider: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
};
|
||||||
|
|
||||||
class StatusScreenComponent extends BaseScreenComponent<Props, State> {
|
class StatusScreenComponent extends BaseScreenComponent<Props, State> {
|
||||||
public constructor(props: Props) {
|
public constructor(props: Props) {
|
||||||
super(props);
|
super(props);
|
||||||
@@ -52,15 +133,11 @@ class StatusScreenComponent extends BaseScreenComponent<Props, State> {
|
|||||||
marginLeft: 2,
|
marginLeft: 2,
|
||||||
marginRight: 2,
|
marginRight: 2,
|
||||||
},
|
},
|
||||||
});
|
retryAllButton: {
|
||||||
}
|
flexGrow: 0,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
public override render() {
|
},
|
||||||
const theme = themeStyle(this.props.themeId);
|
baseStyle: {
|
||||||
const styles = this.styles();
|
|
||||||
|
|
||||||
const renderBody = (report: ReportSection[]) => {
|
|
||||||
const baseStyle = {
|
|
||||||
paddingLeft: 6,
|
paddingLeft: 6,
|
||||||
paddingRight: 6,
|
paddingRight: 6,
|
||||||
paddingTop: 2,
|
paddingTop: 2,
|
||||||
@@ -68,70 +145,49 @@ class StatusScreenComponent extends BaseScreenComponent<Props, State> {
|
|||||||
flex: 0,
|
flex: 0,
|
||||||
color: theme.color,
|
color: theme.color,
|
||||||
fontSize: theme.fontSize,
|
fontSize: theme.fontSize,
|
||||||
};
|
alignSelf: 'center',
|
||||||
|
},
|
||||||
const lines = [];
|
listWrapper: {
|
||||||
|
paddingBottom: 5,
|
||||||
for (let i = 0; i < report.length; i++) {
|
},
|
||||||
const section = report[i];
|
listBullet: {
|
||||||
|
fontSize: theme.fontSize / 3,
|
||||||
let style: TextStyle = { ...baseStyle };
|
color: theme.color,
|
||||||
style.fontWeight = 'bold';
|
alignSelf: 'center',
|
||||||
if (i > 0) style.paddingTop = 20;
|
justifyContent: 'center',
|
||||||
lines.push({ key: `section_${i}`, isSection: true, text: section.title });
|
flexGrow: 0,
|
||||||
if (section.canRetryAll) {
|
marginStart: 12,
|
||||||
lines.push({ key: `retry_all_${i}`, text: '', retryAllHandler: section.retryAllHandler });
|
marginEnd: 2,
|
||||||
|
},
|
||||||
|
divider: {
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: theme.dividerColor,
|
||||||
|
marginTop: 20,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const n in section.body) {
|
public override render() {
|
||||||
if (!section.body.hasOwnProperty(n)) continue;
|
const styles = this.styles();
|
||||||
style = { ...baseStyle };
|
|
||||||
const item = section.body[n];
|
|
||||||
|
|
||||||
let text = '';
|
const renderItem = (item: ProcessedLine, inList: boolean) => {
|
||||||
|
const style: TextStyle = { ...styles.baseStyle };
|
||||||
let retryHandler = null;
|
|
||||||
let ignoreHandler = null;
|
|
||||||
if (typeof item === 'object') {
|
|
||||||
if (item.canRetry) {
|
|
||||||
retryHandler = async () => {
|
|
||||||
await item.retryHandler();
|
|
||||||
await this.refreshScreen();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (item.canIgnore) {
|
|
||||||
ignoreHandler = async () => {
|
|
||||||
await item.ignoreHandler();
|
|
||||||
await this.refreshScreen();
|
|
||||||
await checkDisabledSyncItemsNotification((action) => this.props.dispatch(action));
|
|
||||||
};
|
|
||||||
}
|
|
||||||
text = item.text;
|
|
||||||
} else {
|
|
||||||
text = item;
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push({ key: `item_${i}_${n}`, text: text, retryHandler, ignoreHandler });
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push({ key: `divider2_${i}`, isDivider: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FlatList
|
|
||||||
data={lines}
|
|
||||||
renderItem={({ item }) => {
|
|
||||||
const style: TextStyle = { ...baseStyle };
|
|
||||||
|
|
||||||
|
let textRole: Role|null = undefined;
|
||||||
|
const text = item.text;
|
||||||
if (item.isSection === true) {
|
if (item.isSection === true) {
|
||||||
style.fontWeight = 'bold';
|
style.fontWeight = 'bold';
|
||||||
style.marginBottom = 5;
|
style.marginBottom = 5;
|
||||||
|
textRole = 'heading';
|
||||||
|
} else if (inList) {
|
||||||
|
textRole = 'listitem';
|
||||||
}
|
}
|
||||||
|
|
||||||
style.flex = 1;
|
style.flex = 1;
|
||||||
|
|
||||||
const retryAllButton = item.retryAllHandler ? (
|
const retryAllButton = item.retryAllHandler ? (
|
||||||
<View style={{ flex: 0 }}>
|
<View style={styles.retryAllButton}>
|
||||||
<Button title={_('Retry All')} onPress={item.retryAllHandler} />
|
<Button title={_('Retry All')} onPress={item.retryAllHandler} />
|
||||||
</View>
|
</View>
|
||||||
) : null;
|
) : null;
|
||||||
@@ -148,18 +204,36 @@ class StatusScreenComponent extends BaseScreenComponent<Props, State> {
|
|||||||
</View>
|
</View>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
|
const textComponent = text ? <Text style={style} role={textRole}>{text}</Text> : null;
|
||||||
if (item.isDivider) {
|
if (item.isDivider) {
|
||||||
return <View style={{ borderBottomWidth: 1, borderBottomColor: theme.dividerColor, marginTop: 20, marginBottom: 20 }} />;
|
return <View style={styles.divider} role='separator' key={item.key} />;
|
||||||
|
} else if (item.listItems) {
|
||||||
|
return <View role='list' style={styles.listWrapper} key={item.key}>
|
||||||
|
{textComponent}
|
||||||
|
{item.listItems.map(item => renderItem(item, true))}
|
||||||
|
</View>;
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, flexDirection: 'row' }}>
|
<View style={{ flex: 1, flexDirection: 'row' }} key={item.key}>
|
||||||
<Text style={style}>{item.text}</Text>
|
{inList ? <Icon style={styles.listBullet} name='fas fa-circle' accessibilityLabel={null} /> : null}
|
||||||
|
{textComponent}
|
||||||
{ignoreButton}
|
{ignoreButton}
|
||||||
{retryAllButton}
|
{retryAllButton}
|
||||||
{retryButton}
|
{retryButton}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderBody = (report: ReportSection[]) => {
|
||||||
|
const baseStyle = styles.baseStyle;
|
||||||
|
const lines = processReport(report, () => this.refreshScreen(), this.props.dispatch, baseStyle);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlatList
|
||||||
|
data={lines}
|
||||||
|
renderItem={({ item }) => {
|
||||||
|
return renderItem(item, false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,6 +18,16 @@ interface DecryptionResult {
|
|||||||
error: any;
|
error: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Key for use with the KvStore.
|
||||||
|
const decryptionErrorKeyPrefix = 'decryptErrorLabel:';
|
||||||
|
const decryptionErrorKey = (type: number, id: string) => {
|
||||||
|
return `${decryptionErrorKeyPrefix}${type}:${id}`;
|
||||||
|
};
|
||||||
|
const decryptionCounterKeyPrefix = 'decrypt:';
|
||||||
|
const decryptionCounterKey = (type: number, id: string) => {
|
||||||
|
return `${decryptionCounterKeyPrefix}${type}:${id}`;
|
||||||
|
};
|
||||||
|
|
||||||
export default class DecryptionWorker {
|
export default class DecryptionWorker {
|
||||||
|
|
||||||
public static instance_: DecryptionWorker = null;
|
public static instance_: DecryptionWorker = null;
|
||||||
@@ -96,24 +106,29 @@ export default class DecryptionWorker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async decryptionDisabledItems() {
|
public async decryptionDisabledItems() {
|
||||||
let items = await this.kvStore().searchByPrefix('decrypt:');
|
let items = await this.kvStore().searchByPrefix(decryptionCounterKeyPrefix);
|
||||||
items = items.filter(item => item.value > this.maxDecryptionAttempts_);
|
items = items.filter(item => item.value > this.maxDecryptionAttempts_);
|
||||||
items = items.map(item => {
|
return await Promise.all(items.map(async item => {
|
||||||
const s = item.key.split(':');
|
const s = item.key.split(':');
|
||||||
|
const type_ = Number(s[1]);
|
||||||
|
const id = s[2];
|
||||||
|
const errorDescription = await this.kvStore().value<string>(decryptionErrorKey(type_, id));
|
||||||
return {
|
return {
|
||||||
type_: Number(s[1]),
|
type_,
|
||||||
id: s[2],
|
id,
|
||||||
|
reason: errorDescription,
|
||||||
};
|
};
|
||||||
});
|
}));
|
||||||
return items;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async clearDisabledItem(typeId: string, itemId: string) {
|
public async clearDisabledItem(typeId: number, itemId: string) {
|
||||||
await this.kvStore().deleteValue(`decrypt:${typeId}:${itemId}`);
|
await this.kvStore().deleteValue(decryptionCounterKey(typeId, itemId));
|
||||||
|
await this.kvStore().deleteValue(decryptionErrorKey(typeId, itemId));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async clearDisabledItems() {
|
public async clearDisabledItems() {
|
||||||
await this.kvStore().deleteByPrefix('decrypt:');
|
await this.kvStore().deleteByPrefix(decryptionCounterKeyPrefix);
|
||||||
|
await this.kvStore().deleteByPrefix(decryptionErrorKeyPrefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||||
@@ -193,10 +208,14 @@ export default class DecryptionWorker {
|
|||||||
itemCount: items.length,
|
itemCount: items.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
const counterKey = `decrypt:${item.type_}:${item.id}`;
|
const counterKey = decryptionCounterKey(item.type_, item.id);
|
||||||
|
const errorKey = decryptionErrorKey(item.type_, item.id);
|
||||||
|
|
||||||
const clearDecryptionCounter = async () => {
|
const clearDecryptionCounter = async () => {
|
||||||
await this.kvStore().deleteValue(counterKey);
|
await this.kvStore().deleteValue(counterKey);
|
||||||
|
// The decryption error key stores the reason for the decryption counter's value.
|
||||||
|
// As such, the error should be reset when the decryption counter is reset:
|
||||||
|
await this.kvStore().deleteValue(errorKey);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Don't log in production as it results in many messages when importing many items
|
// Don't log in production as it results in many messages when importing many items
|
||||||
@@ -253,6 +272,8 @@ export default class DecryptionWorker {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.kvStore().setValue(errorKey, String(error));
|
||||||
|
|
||||||
if (options.errorHandler === 'log') {
|
if (options.errorHandler === 'log') {
|
||||||
this.logger().warn(`DecryptionWorker: error for: ${item.id} (${ItemClass.tableName()})`, error);
|
this.logger().warn(`DecryptionWorker: error for: ${item.id} (${ItemClass.tableName()})`, error);
|
||||||
this.logger().debug('Item with error:', item);
|
this.logger().debug('Item with error:', item);
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ enum ValueType {
|
|||||||
Text = 2,
|
Text = 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface KvStoreKeyValue {
|
||||||
|
key: string;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Partial refactoring of old code from before rule was applied
|
||||||
|
value: any;
|
||||||
|
type: ValueType;
|
||||||
|
}
|
||||||
|
|
||||||
export default class KvStore extends BaseService {
|
export default class KvStore extends BaseService {
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||||
@@ -110,7 +117,7 @@ export default class KvStore extends BaseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async searchByPrefix(prefix: string) {
|
public async searchByPrefix(prefix: string): Promise<KvStoreKeyValue[]> {
|
||||||
const results = await this.db().selectAll('SELECT `key`, `value`, `type` FROM key_values WHERE `key` LIKE ?', [`${prefix}%`]);
|
const results = await this.db().selectAll('SELECT `key`, `value`, `type` FROM key_values WHERE `key` LIKE ?', [`${prefix}%`]);
|
||||||
return this.formatValues_(results);
|
return this.formatValues_(results);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { _ } from '../locale';
|
import { _ } from '../locale';
|
||||||
import ReportService, { ReportSection } from './ReportService';
|
import ReportService, { ReportSection } from './ReportService';
|
||||||
import { createNTestNotes, decryptionWorker, setupDatabaseAndSynchronizer, supportDir, switchClient, syncTargetId, synchronizer, synchronizerStart } from '../testing/test-utils';
|
import { createNTestNotes, decryptionWorker, encryptionService, loadEncryptionMasterKey, setupDatabaseAndSynchronizer, supportDir, switchClient, syncTargetId, synchronizer, synchronizerStart } from '../testing/test-utils';
|
||||||
import Folder from '../models/Folder';
|
import Folder from '../models/Folder';
|
||||||
import BaseItem from '../models/BaseItem';
|
import BaseItem from '../models/BaseItem';
|
||||||
import DecryptionWorker from './DecryptionWorker';
|
|
||||||
import Note from '../models/Note';
|
import Note from '../models/Note';
|
||||||
import shim from '../shim';
|
import shim from '../shim';
|
||||||
|
import SyncTargetRegistry from '../SyncTargetRegistry';
|
||||||
|
import { loadMasterKeysFromSettings, setupAndEnableEncryption } from './e2ee/utils';
|
||||||
|
import Setting from '../models/Setting';
|
||||||
|
import DecryptionWorker from './DecryptionWorker';
|
||||||
|
import { ModelType } from '../BaseModel';
|
||||||
|
|
||||||
|
|
||||||
const firstSectionWithTitle = (report: ReportSection[], title: string) => {
|
const firstSectionWithTitle = (report: ReportSection[], title: string) => {
|
||||||
@@ -22,6 +26,10 @@ const getIgnoredSection = (report: ReportSection[]) => {
|
|||||||
return firstSectionWithTitle(report, _('Ignored items that cannot be synchronised'));
|
return firstSectionWithTitle(report, _('Ignored items that cannot be synchronised'));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getDecryptionErrorSection = (report: ReportSection[]): ReportSection|null => {
|
||||||
|
return firstSectionWithTitle(report, _('Items that cannot be decrypted'));
|
||||||
|
};
|
||||||
|
|
||||||
const sectionBodyToText = (section: ReportSection) => {
|
const sectionBodyToText = (section: ReportSection) => {
|
||||||
return section.body.map(item => {
|
return section.body.map(item => {
|
||||||
if (typeof item === 'string') {
|
if (typeof item === 'string') {
|
||||||
@@ -32,13 +40,71 @@ const sectionBodyToText = (section: ReportSection) => {
|
|||||||
}).join('\n');
|
}).join('\n');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getListItemsInBodyStartingWith = (section: ReportSection, keyPrefix: string) => {
|
||||||
|
return section.body.filter(item =>
|
||||||
|
typeof item !== 'string' && item.type === 'openList' && item.key.startsWith(keyPrefix),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCannotDecryptNotes = async (corruptedNoteCount: number) => {
|
||||||
|
await switchClient(2);
|
||||||
|
|
||||||
|
const notes = [];
|
||||||
|
for (let i = 0; i < corruptedNoteCount; i++) {
|
||||||
|
notes.push(await Note.save({ title: `Note ${i}` }));
|
||||||
|
}
|
||||||
|
|
||||||
|
await synchronizerStart();
|
||||||
|
await switchClient(1);
|
||||||
|
await synchronizerStart();
|
||||||
|
|
||||||
|
// First, simulate a broken note and check that the decryption worker
|
||||||
|
// gives up decrypting after a number of tries. This is mainly relevant
|
||||||
|
// for data that crashes the mobile application - we don't want to keep
|
||||||
|
// decrypting these.
|
||||||
|
|
||||||
|
for (const note of notes) {
|
||||||
|
await Note.save({ id: note.id, encryption_cipher_text: 'bad' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return notes.map(note => note.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addRemoteNotes = async (noteCount: number) => {
|
||||||
|
await switchClient(2);
|
||||||
|
|
||||||
|
const notes = [];
|
||||||
|
for (let i = 0; i < noteCount; i++) {
|
||||||
|
notes.push(await Note.save({ title: `Test Note ${i}` }));
|
||||||
|
}
|
||||||
|
|
||||||
|
await synchronizerStart();
|
||||||
|
await switchClient(1);
|
||||||
|
|
||||||
|
return notes.map(note => note.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setUpLocalAndRemoteEncryption = async () => {
|
||||||
|
await switchClient(2);
|
||||||
|
|
||||||
|
// Encryption setup
|
||||||
|
const masterKey = await loadEncryptionMasterKey();
|
||||||
|
await setupAndEnableEncryption(encryptionService(), masterKey, '123456');
|
||||||
|
await synchronizerStart();
|
||||||
|
|
||||||
|
// Give both clients the same master key
|
||||||
|
await switchClient(1);
|
||||||
|
await synchronizerStart();
|
||||||
|
|
||||||
|
Setting.setObjectValue('encryption.passwordCache', masterKey.id, '123456');
|
||||||
|
await loadMasterKeysFromSettings(encryptionService());
|
||||||
|
};
|
||||||
|
|
||||||
describe('ReportService', () => {
|
describe('ReportService', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await setupDatabaseAndSynchronizer(1);
|
await setupDatabaseAndSynchronizer(1);
|
||||||
await setupDatabaseAndSynchronizer(2);
|
await setupDatabaseAndSynchronizer(2);
|
||||||
await switchClient(1);
|
await switchClient(1);
|
||||||
// For compatibility with code that calls DecryptionWorker.instance()
|
|
||||||
DecryptionWorker.instance_ = decryptionWorker();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should move sync errors to the "ignored" section after clicking "ignore"', async () => {
|
it('should move sync errors to the "ignored" section after clicking "ignore"', async () => {
|
||||||
@@ -129,6 +195,7 @@ describe('ReportService', () => {
|
|||||||
let report = await service.status(syncTargetId());
|
let report = await service.status(syncTargetId());
|
||||||
|
|
||||||
const unsyncableSection = getCannotSyncSection(report);
|
const unsyncableSection = getCannotSyncSection(report);
|
||||||
|
expect(unsyncableSection).not.toBeNull();
|
||||||
expect(sectionBodyToText(unsyncableSection)).toContain('could not be downloaded');
|
expect(sectionBodyToText(unsyncableSection)).toContain('could not be downloaded');
|
||||||
|
|
||||||
// Item for the download error should be ignorable
|
// Item for the download error should be ignorable
|
||||||
@@ -159,4 +226,85 @@ describe('ReportService', () => {
|
|||||||
expect(getIgnoredSection(report)).toBeNull();
|
expect(getIgnoredSection(report)).toBeNull();
|
||||||
expect(getCannotSyncSection(report)).toBeNull();
|
expect(getCannotSyncSection(report)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should associate decryption failures with error message headers when errors are known', async () => {
|
||||||
|
await setUpLocalAndRemoteEncryption();
|
||||||
|
|
||||||
|
const service = new ReportService();
|
||||||
|
const syncTargetId = SyncTargetRegistry.nameToId('joplinServer');
|
||||||
|
let report = await service.status(syncTargetId);
|
||||||
|
|
||||||
|
// Initially, should not have a "cannot be decrypted section"
|
||||||
|
expect(getDecryptionErrorSection(report)).toBeNull();
|
||||||
|
|
||||||
|
const corruptedNoteIds = await addCannotDecryptNotes(4);
|
||||||
|
await addRemoteNotes(10);
|
||||||
|
await synchronizerStart();
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
report = await service.status(syncTargetId);
|
||||||
|
expect(getDecryptionErrorSection(report)).toBeNull();
|
||||||
|
|
||||||
|
// .start needs to be run multiple times for items to be disabled and thus
|
||||||
|
// added to the report
|
||||||
|
await decryptionWorker().start();
|
||||||
|
}
|
||||||
|
|
||||||
|
// After adding corrupted notes, it should have such a section.
|
||||||
|
report = await service.status(syncTargetId);
|
||||||
|
const decryptionErrorsSection = getDecryptionErrorSection(report);
|
||||||
|
expect(decryptionErrorsSection).not.toBeNull();
|
||||||
|
|
||||||
|
// There should be a list of errors (all errors are known)
|
||||||
|
const errorLists = getListItemsInBodyStartingWith(decryptionErrorsSection, 'itemsWithError');
|
||||||
|
expect(errorLists).toHaveLength(1);
|
||||||
|
|
||||||
|
// There should, however, be testIds.length ReportItems with the IDs of the notes.
|
||||||
|
const decryptionErrorsText = sectionBodyToText(decryptionErrorsSection);
|
||||||
|
for (const noteId of corruptedNoteIds) {
|
||||||
|
expect(decryptionErrorsText).toContain(noteId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not associate decryption failures with error message headers when errors are unknown', async () => {
|
||||||
|
const decryption = decryptionWorker();
|
||||||
|
|
||||||
|
// Create decryption errors:
|
||||||
|
const testIds = ['0123456789012345601234567890123456', '0123456789012345601234567890123457', '0123456789012345601234567890123458'];
|
||||||
|
|
||||||
|
// Adds items to the decryption error list **without also adding the reason**. This matches
|
||||||
|
// the format of older decryption errors.
|
||||||
|
const addIdsToDecryptionErrorList = async (worker: DecryptionWorker, ids: string[]) => {
|
||||||
|
for (const id of ids) {
|
||||||
|
// A value that is more than the maximum number of attempts:
|
||||||
|
const numDecryptionAttempts = 3;
|
||||||
|
|
||||||
|
// Add the failure manually so that the error message is unknown
|
||||||
|
await worker.kvStore().setValue(
|
||||||
|
`decrypt:${ModelType.Note}:${id}`, numDecryptionAttempts,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await addIdsToDecryptionErrorList(decryption, testIds);
|
||||||
|
|
||||||
|
const service = new ReportService();
|
||||||
|
const syncTargetId = SyncTargetRegistry.nameToId('joplinServer');
|
||||||
|
const report = await service.status(syncTargetId);
|
||||||
|
|
||||||
|
// Report should have an "Items that cannot be decrypted" section
|
||||||
|
const decryptionErrorSection = getDecryptionErrorSection(report);
|
||||||
|
expect(decryptionErrorSection).not.toBeNull();
|
||||||
|
|
||||||
|
// There should not be any lists of errors (no errors associated with the item).
|
||||||
|
const errorLists = getListItemsInBodyStartingWith(decryptionErrorSection, 'itemsWithError');
|
||||||
|
expect(errorLists).toHaveLength(0);
|
||||||
|
|
||||||
|
// There should be items with the correct messages:
|
||||||
|
const expectedMessages = testIds.map(id => `Note: ${id}`);
|
||||||
|
const bodyText = sectionBodyToText(decryptionErrorSection);
|
||||||
|
for (const message of expectedMessages) {
|
||||||
|
expect(bodyText).toContain(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ enum CanRetryType {
|
|||||||
ItemSync = 'itemSync',
|
ItemSync = 'itemSync',
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ReportItemType {
|
export enum ReportItemType {
|
||||||
OpenList = 'openList',
|
OpenList = 'openList',
|
||||||
CloseList = 'closeList',
|
CloseList = 'closeList',
|
||||||
}
|
}
|
||||||
@@ -255,17 +255,51 @@ export default class ReportService {
|
|||||||
|
|
||||||
section.body.push('');
|
section.body.push('');
|
||||||
|
|
||||||
|
const errorMessagesToItems: Map<string, ReportItem[]> = new Map();
|
||||||
|
|
||||||
for (let i = 0; i < decryptionDisabledItems.length; i++) {
|
for (let i = 0; i < decryptionDisabledItems.length; i++) {
|
||||||
const row = decryptionDisabledItems[i];
|
const row = decryptionDisabledItems[i];
|
||||||
section.body.push({
|
|
||||||
text: _('%s: %s', toTitleCase(BaseModel.modelTypeToName(row.type_)), row.id),
|
const resourceTypeName = toTitleCase(BaseModel.modelTypeToName(row.type_));
|
||||||
|
const message = _('%s: %s', resourceTypeName, row.id);
|
||||||
|
|
||||||
|
const item: ReportItem = {
|
||||||
|
text: message,
|
||||||
canRetry: true,
|
canRetry: true,
|
||||||
canRetryType: CanRetryType.E2EE,
|
canRetryType: CanRetryType.E2EE,
|
||||||
retryHandler: async () => {
|
retryHandler: async () => {
|
||||||
await DecryptionWorker.instance().clearDisabledItem(row.type_, row.id);
|
await DecryptionWorker.instance().clearDisabledItem(row.type_, row.id);
|
||||||
void DecryptionWorker.instance().scheduleStart();
|
void DecryptionWorker.instance().scheduleStart();
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const itemError = row.reason;
|
||||||
|
if (itemError) {
|
||||||
|
// If the error message is known, postpone adding the report item.
|
||||||
|
// Instead, add it under the error message as a heading
|
||||||
|
if (errorMessagesToItems.has(itemError)) {
|
||||||
|
errorMessagesToItems.get(itemError).push(item);
|
||||||
|
} else {
|
||||||
|
errorMessagesToItems.set(itemError, [item]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If there's no known error, add directly:
|
||||||
|
section.body.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Categorize any items under each known error:
|
||||||
|
let errorIdx = 0;
|
||||||
|
for (const itemError of errorMessagesToItems.keys()) {
|
||||||
|
section.body.push(_('Items with error: %s', itemError));
|
||||||
|
|
||||||
|
errorIdx++;
|
||||||
|
section.body.push({ type: ReportItemType.OpenList, key: `itemsWithError${errorIdx}` });
|
||||||
|
|
||||||
|
// Add all items associated with the header
|
||||||
|
section.body.push(...errorMessagesToItems.get(itemError));
|
||||||
|
|
||||||
|
section.body.push({ type: ReportItemType.CloseList });
|
||||||
}
|
}
|
||||||
|
|
||||||
section = this.addRetryAllHandler(section);
|
section = this.addRetryAllHandler(section);
|
||||||
|
|||||||
@@ -409,7 +409,7 @@ describe('Synchronizer.e2ee', () => {
|
|||||||
expect(disabledItems.length).toBe(1);
|
expect(disabledItems.length).toBe(1);
|
||||||
expect(disabledItems[0].id).toBe(note.id);
|
expect(disabledItems[0].id).toBe(note.id);
|
||||||
|
|
||||||
expect((await kvStore().all()).length).toBe(1);
|
expect((await kvStore().searchByPrefix('decrypt:')).length).toBe(1);
|
||||||
await kvStore().clear();
|
await kvStore().clear();
|
||||||
|
|
||||||
// Now check that if it fails once but succeed the second time, the note
|
// Now check that if it fails once but succeed the second time, the note
|
||||||
|
|||||||
@@ -287,6 +287,7 @@ async function switchClient(id: number, options: any = null) {
|
|||||||
Resource.encryptionService_ = encryptionServices_[id];
|
Resource.encryptionService_ = encryptionServices_[id];
|
||||||
BaseItem.revisionService_ = revisionServices_[id];
|
BaseItem.revisionService_ = revisionServices_[id];
|
||||||
ResourceFetcher.instance_ = resourceFetchers_[id];
|
ResourceFetcher.instance_ = resourceFetchers_[id];
|
||||||
|
DecryptionWorker.instance_ = decryptionWorker(id);
|
||||||
|
|
||||||
await Setting.reset();
|
await Setting.reset();
|
||||||
Setting.settingFilename = settingFilename(id);
|
Setting.settingFilename = settingFilename(id);
|
||||||
@@ -549,7 +550,7 @@ function revisionService(id: number = null) {
|
|||||||
function decryptionWorker(id: number = null) {
|
function decryptionWorker(id: number = null) {
|
||||||
if (id === null) id = currentClient_;
|
if (id === null) id = currentClient_;
|
||||||
const o = decryptionWorkers_[id];
|
const o = decryptionWorkers_[id];
|
||||||
o.setKvStore(kvStore(id));
|
o?.setKvStore(kvStore(id));
|
||||||
return o;
|
return o;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user