1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-08-24 20:19:10 +02:00

Compare commits

...

29 Commits

Author SHA1 Message Date
Laurent Cozic
d744f93e16 Merge branch 'dev' into publish_notes_e2ee 2022-09-06 17:23:13 +01:00
Laurent Cozic
3d8354c403 v2 header 2022-07-11 16:11:58 +01:00
Laurent Cozic
e2fa57c48f v2 encryption header 2022-07-11 15:42:27 +01:00
Laurent Cozic
4162b3cbac refactor encryption header 2022-07-11 15:09:07 +01:00
Laurent Cozic
e2731ca01f Merge branch 'dev' into publish_notes_e2ee 2022-07-11 14:10:42 +01:00
Laurent Cozic
d910de02d6 fixed tests 2022-06-28 18:31:18 +01:00
Laurent Cozic
9107f9a073 fix tests 2022-06-28 13:57:14 +01:00
Laurent Cozic
c7c0be5b21 Merge branch 'dev' into publish_notes_e2ee 2022-06-28 12:05:53 +01:00
Laurent Cozic
6bd809b347 Merge branch 'dev' into publish_notes_e2ee 2022-06-14 00:42:59 +01:00
Laurent Cozic
e8a3149d39 Merge branch 'dev' into publish_notes_e2ee 2022-06-07 18:30:50 +01:00
Laurent Cozic
f8a947fedd tests 2022-05-28 17:57:33 +01:00
Laurent Cozic
f9afe040fb Merge branch 'dev' into publish_notes_e2ee 2022-05-27 12:03:16 +01:00
Laurent Cozic
16b60bd910 Merge branch 'dev' into publish_notes_e2ee 2022-05-26 18:20:09 +01:00
Laurent Cozic
6a2a52ec78 password handling 2022-05-26 18:14:10 +01:00
Laurent Cozic
d5a0d7b4a7 update 2022-05-26 17:46:02 +01:00
Laurent Cozic
810ae1f763 fix hljs config 2022-05-26 16:49:06 +01:00
Laurent Cozic
985a988734 optimize bundle 2022-05-26 16:29:43 +01:00
Laurent Cozic
65436e6007 perf 2022-05-26 16:16:32 +01:00
Laurent Cozic
f60e1d498f Merge branch 'dev' into publish_notes_e2ee 2022-05-26 16:00:11 +01:00
Laurent Cozic
07ed4b4d62 getResourceUrl 2022-05-26 15:14:07 +01:00
Laurent Cozic
b9adcc80ac fix 2022-05-26 14:53:24 +01:00
Laurent Cozic
e251e09120 tests 2022-05-26 13:52:15 +01:00
Laurent Cozic
3011da7f53 Add type to extractResourceUrls call 2022-05-25 16:24:58 +01:00
Laurent Cozic
1661cf85de Provide detail info on extractFileUrls call 2022-05-24 18:04:38 +01:00
Laurent Cozic
04c286216e Provide detail info on extractFileUrls call 2022-05-24 17:35:29 +01:00
Laurent Cozic
4f201eb926 tests 2022-05-23 14:48:18 +01:00
Laurent Cozic
e1b1a78768 e2ee 2022-05-23 14:38:06 +01:00
Laurent Cozic
98ed58cc6e basic setup 2022-05-18 16:09:31 +01:00
Laurent Cozic
3f0943873e load encrypted note 2022-05-18 14:45:09 +01:00
30 changed files with 1244 additions and 183 deletions

View File

@@ -48,8 +48,8 @@
"tagServerLatest": "node packages/tools/tagServerLatest.js",
"buildServerDocker": "node packages/tools/buildServerDocker.js",
"setupNewRelease": "node ./packages/tools/setupNewRelease",
"test-ci": "yarn workspaces foreach --parallel --verbose --interlaced --jobs 2 run test-ci",
"test": "yarn workspaces foreach --parallel --verbose --interlaced --jobs 2 run test",
"test-ci": "BROWSERSLIST_IGNORE_OLD_DATA=1 yarn workspaces foreach --parallel --verbose --interlaced --jobs 2 run test-ci",
"test": "BROWSERSLIST_IGNORE_OLD_DATA=1 yarn workspaces foreach --parallel --verbose --interlaced --jobs 2 run test",
"tsc": "yarn workspaces foreach --parallel --verbose --interlaced run tsc",
"updateIgnored": "gulp updateIgnoredTypeScriptBuild",
"updatePluginTypes": "./packages/generator-joplin/updateTypes.sh",

View File

@@ -5,8 +5,8 @@ const { escapeHtml } = require('./string-utils.js');
// [\s\S] instead of . for multiline matching
// https://stackoverflow.com/a/16119722/561309
const imageRegex = /<img([\s\S]*?)src=["']([\s\S]*?)["']([\s\S]*?)>/gi;
const anchorRegex = /<a([\s\S]*?)href=["']([\s\S]*?)["']([\s\S]*?)>/gi;
export const imageRegex = /<img([\s\S]*?)src=["']([\s\S]*?)["']([\s\S]*?)>/gi;
export const anchorRegex = /<a([\s\S]*?)href=["']([\s\S]*?)["']([\s\S]*?)>/gi;
const embedRegex = /<embed([\s\S]*?)src=["']([\s\S]*?)["']([\s\S]*?)>/gi;
const objectRegex = /<object([\s\S]*?)data=["']([\s\S]*?)["']([\s\S]*?)>/gi;
const pdfUrlRegex = /[\s\S]*?\.pdf$/i;

View File

@@ -1,4 +1,5 @@
import { validateLinks } from '@joplin/renderer';
import { validateLinks, LinkType } from '@joplin/renderer';
import { anchorRegex, imageRegex } from './htmlUtils';
const stringPadding = require('string-padding');
const urlUtils = require('./urlUtils');
const MarkdownIt = require('markdown-it');
@@ -25,6 +26,19 @@ export interface MarkdownTableRow {
[key: string]: string;
}
export interface ExtractFileUrlsOptions {
includeImages?: boolean;
includeAnchors?: boolean;
includePdfs?: boolean;
detailedResults?: boolean;
html?: boolean;
}
export interface ExtractFileUrlsResult {
url: string;
type: LinkType;
}
const markdownUtils = {
// Titles for markdown links only need escaping for [ and ]
escapeTitleText(text: string) {
@@ -69,28 +83,65 @@ const markdownUtils = {
},
// Returns the **encoded** URLs, so to be useful they should be decoded again before use.
extractFileUrls(md: string, onlyType: string = null): Array<string> {
const markdownIt = new MarkdownIt();
extractFileUrls(md: string, options: ExtractFileUrlsOptions = null): string[] | ExtractFileUrlsResult[] {
options = {
includeAnchors: true,
includeImages: true,
includePdfs: false,
detailedResults: false,
// Should probably be true by default but since it was added
// afterwards we make it false for now.
html: false,
...options,
};
const markdownIt = new MarkdownIt({ html: options.html });
markdownIt.validateLink = validateLinks; // Necessary to support file:/// links
const env = {};
const tokens = markdownIt.parse(md, env);
const output: string[] = [];
let linkType = onlyType;
if (linkType === 'pdf') linkType = 'link_open';
const output: ExtractFileUrlsResult[] = [];
const searchUrls = (tokens: any[]) => {
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
if ((!onlyType && (token.type === 'link_open' || token.type === 'image')) || (!!onlyType && token.type === onlyType) || (onlyType === 'pdf' && token.type === 'link_open')) {
// Pdf embeds are a special case, they are represented as 'link_open' tokens but are marked with 'embedded_pdf' as link name by the parser
// We are making sure if its in the proper pdf link format, only then we add it to the list
if (onlyType === 'pdf' && !(tokens.length > i + 1 && tokens[i + 1].type === 'text' && tokens[i + 1].content === 'embedded_pdf')) continue;
const type: string = token.type;
if (type === 'image' || type === 'link_open') {
// Pdf embeds are a special case, they are represented as
// 'link_open' tokens but are marked with 'embedded_pdf' as
// link name by the parser We are making sure if its in the
// proper pdf link format, only then we add it to the list
const isPdf = tokens.length > i + 1 && tokens[i + 1].type === 'text' && tokens[i + 1].content === 'embedded_pdf';
if (type === 'image' && !options.includeImages) continue;
if (type === 'link_open') {
if (!options.includeAnchors && !options.includePdfs) continue;
}
if (isPdf && !options.includePdfs) continue;
for (let j = 0; j < token.attrs.length; j++) {
const a = token.attrs[j];
if ((a[0] === 'src' || a[0] === 'href') && a.length >= 2 && a[1]) {
output.push(a[1]);
output.push({
url: a[1],
type: type === 'image' ? LinkType.Image : LinkType.Anchor,
});
}
}
} else if (type === 'html_block' || type === 'html_inline') {
for (const regex of [imageRegex, anchorRegex]) {
if (regex === imageRegex && !options.includeImages) continue;
if (regex === anchorRegex && !options.includeAnchors) continue;
let result: RegExpExecArray = null;
while ((result = regex.exec(token.content)) !== null) {
output.push({
url: result[2],
type: regex === imageRegex ? LinkType.Image : LinkType.Anchor,
});
}
}
}
@@ -103,7 +154,11 @@ const markdownUtils = {
searchUrls(tokens);
return output;
if (options.detailedResults) {
return output;
} else {
return output.map(r => r.url);
}
},
replaceResourceUrl(md: string, urlToReplace: string, id: string) {
@@ -113,11 +168,18 @@ const markdownUtils = {
},
extractImageUrls(md: string) {
return markdownUtils.extractFileUrls(md, 'image');
return markdownUtils.extractFileUrls(md, {
includeImages: true,
includeAnchors: false,
}) as string[];
},
extractPdfUrls(md: string) {
return markdownUtils.extractFileUrls(md, 'pdf');
return markdownUtils.extractFileUrls(md, {
includeImages: false,
includeAnchors: false,
includePdfs: true,
}) as string[];
},
// The match results has 5 items

View File

@@ -67,17 +67,50 @@ describe('markdownUtils', function() {
['[something](testing.html)', ['testing.html']],
['[something](img.png)', ['img.png']],
['[something](file://img.png)', ['file://img.png']],
['[something](file://img.png)', ['file://img.png']],
['[pdf file](:/94e5f66b8521436aa4f07bf8dcc18758)', [':/94e5f66b8521436aa4f07bf8dcc18758']],
['![some image](:/94e5f66b8521436aa4f07bf8dcc18758)', [':/94e5f66b8521436aa4f07bf8dcc18758']],
['<img src=":/94e5f66b8521436aa4f07bf8dcc18758"/>', [':/94e5f66b8521436aa4f07bf8dcc18758']],
['<img src=":/94e5f66b8521436aa4f07bf8dcc18758"/> <a href=":/94e5f66b8521436aa4f07bf8dcc17777">testing</a>', [':/94e5f66b8521436aa4f07bf8dcc18758', ':/94e5f66b8521436aa4f07bf8dcc17777']],
];
for (let i = 0; i < testCases.length; i++) {
const md = testCases[i][0] as string;
const actual = markdownUtils.extractFileUrls(md);
const actual = markdownUtils.extractFileUrls(md, { html: true });
const expected = testCases[i][1];
expect(actual.join(' ')).toBe((expected as string[]).join(' '));
}
}));
it('should extract files URLs with details', (async () => {
const testCases = [
[
'![some image](:/94e5f66b8521436aa4f07bf8dcc18758) [something](http://test.com/img.png)', [
{ url: ':/94e5f66b8521436aa4f07bf8dcc18758', type: 2 },
{ url: 'http://test.com/img.png', type: 1 },
],
],
[
'<img src=":/94e5f66b8521436aa4f07bf8dcc18758"/> <a href=":/94e5f66b8521436aa4f07bf8dcc17777">testing</a>', [
{ url: ':/94e5f66b8521436aa4f07bf8dcc18758', type: 2 },
{ url: ':/94e5f66b8521436aa4f07bf8dcc17777', type: 1 },
],
],
[
'', [],
],
];
for (let i = 0; i < testCases.length; i++) {
const md = testCases[i][0] as string;
const actual = markdownUtils.extractFileUrls(md, { detailedResults: true, html: true });
const expected = testCases[i][1];
expect(actual).toEqual(expected);
}
}));
it('escape a markdown link', (async () => {
const testCases = [

View File

@@ -19,20 +19,38 @@ const mime = {
return mime.fromFileExtension(splitted[splitted.length - 1]);
},
appendExtensionFromMime(name, mimeType) {
const extensions = mime.toFileExtensions(mimeType);
if (!extensions.length) return `${name}.bin`;
for (const ext of extensions) {
if (name.toLowerCase().endsWith(`.${ext}`)) return name;
}
return name += `.${mime.toFileExtension(mimeType)}`;
},
toFileExtension(mimeType) {
const extensions = mime.toFileExtensions(mimeType);
// Return the first file extension that is 3 characters long
// If none exist return the first one in the list.
for (let j = 0; j < extensions.length; j++) {
if (extensions[j].length === 3) return extensions[j];
}
return extensions.length ? extensions[0] : null;
},
toFileExtensions(mimeType) {
mimeType = mimeType.toLowerCase();
for (let i = 0; i < mimeTypes.length; i++) {
const t = mimeTypes[i];
if (mimeType === t.t) {
// Return the first file extension that is 3 characters long
// If none exist return the first one in the list.
for (let j = 0; j < t.e.length; j++) {
if (t.e[j].length === 3) return t.e[j];
}
return t.e[0];
return t.e;
}
}
return null;
return [];
},
fromDataUrl(dataUrl) {

View File

@@ -20,4 +20,12 @@ describe('mimeUils', function() {
expect(mimeUtils.fromFilename('test')).toBe(null);
}));
it('should append a file extension to a filename', (async () => {
expect(mimeUtils.appendExtensionFromMime('test', 'image/jpeg')).toBe('test.jpg');
expect(mimeUtils.appendExtensionFromMime('test.bmp', 'image/jpeg')).toBe('test.bmp.jpg');
expect(mimeUtils.appendExtensionFromMime('test.JPG', 'image/jpeg')).toBe('test.JPG');
expect(mimeUtils.appendExtensionFromMime('test.jpeg', 'image/jpeg')).toBe('test.jpeg');
expect(mimeUtils.appendExtensionFromMime('test', 'image/doesntexist')).toBe('test.bin');
}));
});

View File

@@ -1,5 +1,5 @@
import { ModelType, DeleteOptions } from '../BaseModel';
import { BaseItemEntity, NoteEntity } from '../services/database/types';
import { BaseItemEntity } from '../services/database/types';
import Setting from './Setting';
import BaseModel from '../BaseModel';
import time from '../time';
@@ -19,6 +19,19 @@ export interface ItemsThatNeedDecryptionResult {
items: any[];
}
interface UnserializeOptions {
// Currently, to unserialize a note we need access to the database to get
// the correct type for each field. However that's a problem when we need
// unserialize in a context where we have no db (in the browser in
// particular). So as a hack we have this "noDb" option here which ensures
// there's no db access when it's on.
//
// That could be improved by pre-generating a schema object using
// generate-database-type. In which case db access won't be necessary, but
// it's not done yet.
noDb?: boolean;
}
export interface ItemThatNeedSync {
id: string;
sync_time: number;
@@ -251,7 +264,7 @@ export default class BaseItem extends BaseModel {
let conflictNoteIds: string[] = [];
if (this.modelType() === BaseModel.TYPE_NOTE) {
const conflictNotes = await this.db().selectAll(`SELECT id FROM notes WHERE id IN ("${ids.join('","')}") AND is_conflict = 1`);
conflictNoteIds = conflictNotes.map((n: NoteEntity) => {
conflictNoteIds = conflictNotes.map((n: any) => {
return n.id;
});
}
@@ -474,7 +487,12 @@ export default class BaseItem extends BaseModel {
return ItemClass.save(plainItem, { autoTimestamp: false, changeSource: ItemChange.SOURCE_DECRYPTION });
}
static async unserialize(content: string) {
static async unserialize(content: string, options: UnserializeOptions = null) {
options = {
noDb: false,
...options,
};
const lines = content.split('\n');
let output: any = {};
let state = 'readingProps';
@@ -512,11 +530,13 @@ export default class BaseItem extends BaseModel {
if (output.type_ === BaseModel.TYPE_NOTE) output.body = body.join('\n');
const ItemClass = this.itemClass(output.type_);
output = ItemClass.removeUnknownFields(output);
if (!options.noDb) output = ItemClass.removeUnknownFields(output);
for (const n in output) {
if (!output.hasOwnProperty(n)) continue;
output[n] = await this.unserialize_format(output.type_, n, output[n]);
if (!options.noDb) {
for (const n in output) {
if (!output.hasOwnProperty(n)) continue;
output[n] = await this.unserialize_format(output.type_, n, output[n]);
}
}
return output;

View File

@@ -2,13 +2,11 @@ import { BaseItemEntity } from '../../services/database/types';
import { StateShare } from '../../services/share/reducer';
export default function(item: BaseItemEntity, share: StateShare): boolean {
// Note has been published - currently we don't encrypt
if (item.is_shared) return false;
// Item has been shared with user, but sharee is not encrypting his notes,
// so we shouldn't encrypt it either. Otherwise sharee will not be able to
// view the note anymore. https://github.com/laurent22/joplin/issues/6645
if (item.share_id && (!share || !share.master_key_id)) return false;
// All items can now be encrypted, including published notes
return true;
}

View File

@@ -12,8 +12,8 @@
"tsc": "tsc --project tsconfig.json",
"watch": "tsc --watch --preserveWatchOutput --project tsconfig.json",
"generatePluginTypes": "rm -rf ./plugin_types && yarn run tsc --declaration --declarationDir ./plugin_types --project tsconfig.json",
"test": "jest --verbose=false",
"test-ci": "yarn test"
"test": "BROWSERSLIST_IGNORE_OLD_DATA=1 jest --verbose=false",
"test-ci": "BROWSERSLIST_IGNORE_OLD_DATA=1 yarn test"
},
"devDependencies": {
"@types/fs-extra": "^9.0.6",
@@ -40,6 +40,7 @@
"@types/nanoid": "^3.0.0",
"async-mutex": "^0.1.3",
"base-64": "^0.1.0",
"base64-arraybuffer": "^1.0.2",
"base64-stream": "^1.0.0",
"builtin-modules": "^3.1.0",
"chokidar": "^3.4.3",

View File

@@ -1,15 +1,29 @@
import { fileContentEqual, setupDatabaseAndSynchronizer, supportDir, switchClient, objectsEqual, checkThrowAsync, msleep } from '../../testing/test-utils';
import { fileContentEqual, setupDatabaseAndSynchronizer, supportDir, switchClient, checkThrowAsync, msleep } from '../../testing/test-utils';
import Folder from '../../models/Folder';
import Note from '../../models/Note';
import Setting from '../../models/Setting';
import BaseItem from '../../models/BaseItem';
import MasterKey from '../../models/MasterKey';
import EncryptionService, { EncryptionMethod } from './EncryptionService';
import EncryptionService, { EncryptionHeader, EncryptionMethod } from './EncryptionService';
import { setEncryptionEnabled } from '../synchronizer/syncInfoUtils';
import { readFile } from 'fs-extra';
const encryptFile = async (service: EncryptionService) => {
let masterKey = await service.generateMasterKey('123456');
masterKey = await MasterKey.save(masterKey);
await service.loadMasterKey(masterKey, '123456', true);
const sourcePath = `${supportDir}/photo.jpg`;
const encryptedPath = `${Setting.value('tempDir')}/photo.crypted`;
await service.encryptFile(sourcePath, encryptedPath);
return { sourcePath, encryptedPath };
};
let service: EncryptionService = null;
describe('services_EncryptionService', function() {
describe('services/e2ee/EncryptionService', function() {
beforeEach(async (done) => {
await setupDatabaseAndSynchronizer(1);
@@ -20,17 +34,11 @@ describe('services_EncryptionService', function() {
done();
});
it('should encode and decode header', (async () => {
const header = {
encryptionMethod: EncryptionMethod.SJCL,
masterKeyId: '01234568abcdefgh01234568abcdefgh',
};
const encodedHeader = service.encodeHeader_(header);
const decodedHeader = service.decodeHeaderBytes_(encodedHeader);
delete decodedHeader.length;
expect(objectsEqual(header, decodedHeader)).toBe(true);
it('should decode header v1', (async () => {
const encodedHeader = 'JED0100002205c24138199f5b403fa3e9b8b4f22685c500027c';
const decodedHeader: EncryptionHeader = service.decodeHeaderBytes_(encodedHeader);
expect(decodedHeader.encryptionMethod).toBe(5);
expect(decodedHeader.masterKeyId).toBe('c24138199f5b403fa3e9b8b4f22685c5');
}));
it('should generate and decrypt a master key', (async () => {
@@ -245,12 +253,7 @@ describe('services_EncryptionService', function() {
}));
it('should encrypt and decrypt files', (async () => {
let masterKey = await service.generateMasterKey('123456');
masterKey = await MasterKey.save(masterKey);
await service.loadMasterKey(masterKey, '123456', true);
const sourcePath = `${supportDir}/photo.jpg`;
const encryptedPath = `${Setting.value('tempDir')}/photo.crypted`;
const { sourcePath, encryptedPath } = await encryptFile(service);
const decryptedPath = `${Setting.value('tempDir')}/photo.jpg`;
await service.encryptFile(sourcePath, encryptedPath);
@@ -260,6 +263,14 @@ describe('services_EncryptionService', function() {
expect(fileContentEqual(sourcePath, decryptedPath)).toBe(true);
}));
it('should decrypt to base64', async () => {
const { sourcePath, encryptedPath } = await encryptFile(service);
const ciphertext = await readFile(encryptedPath, 'utf8');
const originalPlaintext = await readFile(sourcePath, 'base64');
const plaintext = await service.decryptBase64(ciphertext);
expect(plaintext).toBe(originalPlaintext);
});
it('should encrypt invalid UTF-8 data', (async () => {
let masterKey = await service.generateMasterKey('123456');
masterKey = await MasterKey.save(masterKey);

View File

@@ -6,6 +6,7 @@ import MasterKey from '../../models/MasterKey';
import BaseItem from '../../models/BaseItem';
import JoplinError from '../../JoplinError';
import { getActiveMasterKeyId, setActiveMasterKeyId } from '../synchronizer/syncInfoUtils';
import * as base64 from 'base64-arraybuffer';
const { padLeft } = require('../../string-utils.js');
const logger = Logger.create('EncryptionService');
@@ -40,11 +41,33 @@ export enum EncryptionMethod {
Custom = 6,
}
const headerVersions = [1, 2];
const currentEncryptionHeaderVersion = headerVersions[headerVersions.length - 1];
enum EncryptionHeaderKeyEncryptionMode {
MasterKey = 1,
Password = 2,
}
interface EncryptionHeaderKey {
encryptionMode: EncryptionHeaderKeyEncryptionMode;
masterKeyId?: string;
ciphertext: string;
}
export interface EncryptionHeader {
encryptionMethod: EncryptionMethod;
masterKeyId: string;
keys?: EncryptionHeaderKey[];
}
export interface EncryptOptions {
encryptionMethod?: EncryptionMethod;
onProgress?: Function;
encryptionHandler?: EncryptionCustomHandler;
masterKeyId?: string;
appId?: string;
}
export default class EncryptionService {
@@ -71,13 +94,13 @@ export default class EncryptionService {
public defaultEncryptionMethod_ = EncryptionMethod.SJCL1a; // public because used in tests
private defaultMasterKeyEncryptionMethod_ = EncryptionMethod.SJCL4;
private headerTemplates_ = {
// Template version 1
1: {
// Fields are defined as [name, valueSize, valueType]
fields: [['encryptionMethod', 2, 'int'], ['masterKeyId', 32, 'hex']],
},
};
// private headerTemplates_ = {
// // Template version 1
// 1: {
// // Fields are defined as [name, valueSize, valueType]
// fields: [['encryptionMethod', 2, 'int'], ['masterKeyId', 32, 'hex']],
// },
// };
constructor() {
// Note: 1 MB is very slow with Node and probably even worse on mobile.
@@ -97,14 +120,6 @@ export default class EncryptionService {
this.decryptedMasterKeys_ = {};
this.defaultEncryptionMethod_ = EncryptionMethod.SJCL1a;
this.defaultMasterKeyEncryptionMethod_ = EncryptionMethod.SJCL4;
this.headerTemplates_ = {
// Template version 1
1: {
// Fields are defined as [name, valueSize, valueType]
fields: [['encryptionMethod', 2, 'int'], ['masterKeyId', 32, 'hex']],
},
};
}
public static instance() {
@@ -253,7 +268,7 @@ export default class EncryptionService {
const now = Date.now();
model.created_time = now;
model.updated_time = now;
model.source_application = Setting.value('appId');
model.source_application = options?.appId ? options.appId : Setting.value('appId');
model.hasBeenUsed = false;
return model;
@@ -420,7 +435,7 @@ export default class EncryptionService {
const masterKeyId = options.masterKeyId ? options.masterKeyId : this.activeMasterKeyId();
const masterKeyPlainText = this.loadedMasterKey(masterKeyId).plainText;
const header = {
const header: EncryptionHeader = {
encryptionMethod: method,
masterKeyId: masterKeyId,
};
@@ -450,7 +465,7 @@ export default class EncryptionService {
async decryptAbstract_(source: any, destination: any, options: EncryptOptions = null) {
if (!options) options = {};
const header: any = await this.decodeHeaderSource_(source);
const header = await this.decodeHeaderSource_(source);
const masterKeyPlainText = this.loadedMasterKey(header.masterKeyId).plainText;
let doneSize = 0;
@@ -463,11 +478,11 @@ export default class EncryptionService {
if (!length) continue; // Weird but could be not completely invalid (block of size 0) so continue decrypting
doneSize += length;
if (options.onProgress) options.onProgress({ doneSize: doneSize });
if (options.onProgress) options.onProgress({ doneSize });
await shim.waitForFrame();
const block = await source.read(length);
const block: string = await source.read(length);
const plainText = await this.decrypt(header.encryptionMethod, masterKeyPlainText, block);
await destination.append(plainText);
@@ -497,6 +512,9 @@ export default class EncryptionService {
return output.data.join('');
},
close: function() {},
size: async () => {
return output.data.join('').length;
},
};
return output;
}
@@ -521,9 +539,18 @@ export default class EncryptionService {
return this.fsDriver().appendFile(path, data, encoding);
},
close: function() {},
size: async () => {
const s = await this.fsDriver().stat(path);
return s.size;
},
};
}
// Note that data encrypted using encryptString should only be decrypted
// using decryptString. And likewise data encrypted using encryptFile should
// be decrypted using decryptFile. This is because encryptFile encode the
// data as base64 so it needs to be decoded back to binary (while
// decryptString would treat it as simple strings).
public async encryptString(plainText: any, options: EncryptOptions = null): Promise<string> {
const source = this.stringReader_(plainText);
const destination = this.stringWriter_();
@@ -531,14 +558,19 @@ export default class EncryptionService {
return destination.result();
}
public async decryptString(cipherText: any, options: EncryptOptions = null): Promise<string> {
public async decryptStringToChunks(cipherText: any, options: EncryptOptions = null): Promise<string[]> {
const source = this.stringReader_(cipherText);
const destination = this.stringWriter_();
await this.decryptAbstract_(source, destination, options);
return destination.data.join('');
return destination.data;
}
async encryptFile(srcPath: string, destPath: string, options: EncryptOptions = null) {
public async decryptString(cipherText: any, options: EncryptOptions = null): Promise<string> {
const data = await this.decryptStringToChunks(cipherText, options);
return data.join('');
}
public async encryptFile(srcPath: string, destPath: string, options: EncryptOptions = null) {
let source = await this.fileReader_(srcPath, 'base64');
let destination = await this.fileWriter_(destPath, 'ascii');
@@ -563,7 +595,7 @@ export default class EncryptionService {
await cleanUp();
}
async decryptFile(srcPath: string, destPath: string, options: EncryptOptions = null) {
public async decryptFile(srcPath: string, destPath: string, options: EncryptOptions = null) {
let source = await this.fileReader_(srcPath, 'ascii');
let destination = await this.fileWriter_(destPath, 'base64');
@@ -588,39 +620,77 @@ export default class EncryptionService {
await cleanUp();
}
headerTemplate(version: number) {
const r = (this.headerTemplates_ as any)[version];
if (!r) throw new Error(`Unknown header version: ${version}`);
return r;
// This can be used to decrypt a string that has been encoded using
// encryptFile(). For example when download the encrypted file and
// decrypting the content to memory (without writing to a file as in
// decryptFile).
public async decryptBase64(cipherText: any, options: EncryptOptions = null): Promise<string> {
const plaintext = await this.decryptStringToChunks(cipherText, options);
let totalSize: number = 0;
const decodedBuffers: ArrayBuffer[] = [];
for (const chunk of plaintext) {
const c = base64.decode(chunk);
totalSize += c.byteLength;
decodedBuffers.push(c);
}
const fullBuffer = new Uint8Array(new ArrayBuffer(totalSize));
let pos = 0;
for (const decodedBuffer of decodedBuffers) {
fullBuffer.set(new Uint8Array(decodedBuffer), pos);
pos += decodedBuffer.byteLength;
}
return base64.encode(fullBuffer.buffer);
}
encodeHeader_(header: any) {
// Sanity check
if (header.masterKeyId.length !== 32) throw new Error(`Invalid master key ID size: ${header.masterKeyId}`);
private headerTemplate(version: number) {
// This deprecated function should only be called with v1 headers
if (version !== 1) throw new Error(`Unsupported header version: ${version}`);
let encryptionMetadata = '';
encryptionMetadata += padLeft(header.encryptionMethod.toString(16), 2, '0');
encryptionMetadata += header.masterKeyId;
encryptionMetadata = padLeft(encryptionMetadata.length.toString(16), 6, '0') + encryptionMetadata;
return `JED01${encryptionMetadata}`;
return {
// Fields are defined as [name, valueSize, valueType]
fields: [['encryptionMethod', 2, 'int'], ['masterKeyId', 32, 'hex']],
};
}
async decodeHeaderString(cipherText: any) {
public encodeHeader_(header: EncryptionHeader) {
const encoded = JSON.stringify(header);
return `JED0${currentEncryptionHeaderVersion}${padLeft(encoded.length.toString(16), 6, '0')}${encoded}`;
}
public async decodeHeaderString(cipherText: any) {
const source = this.stringReader_(cipherText);
return this.decodeHeaderSource_(source);
}
async decodeHeaderSource_(source: any) {
const identifier = await source.read(5);
async decodeHeaderSource_(source: any): Promise<EncryptionHeader> {
const identifier: string = await source.read(5);
if (!isValidHeaderIdentifier(identifier)) throw new JoplinError(`Invalid encryption identifier. Data is not actually encrypted? ID was: ${identifier}`, 'invalidIdentifier');
const version = Number(identifier[identifier.length - 1]);
const mdSizeHex = await source.read(6);
const mdSize = parseInt(mdSizeHex, 16);
if (isNaN(mdSize) || !mdSize) throw new Error(`Invalid header metadata size: ${mdSizeHex}`);
const md = await source.read(parseInt(mdSizeHex, 16));
return this.decodeHeaderBytes_(identifier + mdSizeHex + md);
const md = await source.read(mdSize);
if (version === 1) {
return this.decodeHeaderBytes_(identifier + mdSizeHex + md);
} else if (version === 2) {
try {
const header = JSON.parse(md);
if (!header) throw new Error('Could not parse JSON');
return header;
} catch (error) {
throw new Error(`Could not decode encryption header: ${error.message}: ${md}`);
}
} else {
throw new Error(`Unsupported header version: ${version}`);
}
}
decodeHeaderBytes_(headerHexaBytes: any) {
decodeHeaderBytes_(headerHexaBytes: string) {
const reader: any = this.stringReader_(headerHexaBytes, true);
const identifier = reader.read(3);
const version = parseInt(reader.read(2), 16);

View File

@@ -96,7 +96,7 @@ export default class InteropService_Importer_Md extends InteropService_Importer_
*/
async importLocalFiles(filePath: string, md: string, parentFolderId: string) {
let updated = md;
const markdownLinks = markdownUtils.extractFileUrls(md);
const markdownLinks = markdownUtils.extractFileUrls(md) as string[];
const htmlLinks = htmlUtils.extractFileUrls(md);
const fileLinks = unique(markdownLinks.concat(htmlLinks));
await Promise.all(fileLinks.map(async (encodedLink: string) => {
@@ -125,7 +125,7 @@ export default class InteropService_Importer_Md extends InteropService_Importer_
}
// The first is a normal link, the second is supports the <link> and [](<link with spaces>) syntax
// Only opening patterns are consider in order to cover all occurances
// Only opening patterns are consider in order to cover all occurrences
// We need to use the encoded link as well because some links (link's with spaces)
// will appear encoded in the source. Other links (unicode chars) will not
const linksToReplace = [this.trimAnchorLink(link), this.trimAnchorLink(encodedLink)];

View File

@@ -35,7 +35,7 @@ const createNoteForPagination = async (numOrTitle: number | string, time: number
let api: Api = null;
describe('services_rest_Api', function() {
describe('services/rest/Api', function() {
beforeEach(async (done) => {
api = new Api();

View File

@@ -254,7 +254,7 @@ const shim = {
throw new Error('Not implemented');
},
waitForFrame: () => {
waitForFrame: (): any => {
throw new Error('Not implemented');
},
@@ -303,19 +303,19 @@ const shim = {
//
// Having the timers wrapped in that way would also make it easier to debug timing issue and
// find out what timers have been fired or not.
setTimeout: (_fn: Function, _interval: number) => {
setTimeout: (_fn: Function, _interval: number): any => {
throw new Error('Not implemented');
},
setInterval: (_fn: Function, _interval: number) => {
setInterval: (_fn: Function, _interval: number): any => {
throw new Error('Not implemented');
},
clearTimeout: (_id: any) => {
clearTimeout: (_id: any): any => {
throw new Error('Not implemented');
},
clearInterval: (_id: any) => {
clearInterval: (_id: any): any => {
throw new Error('Not implemented');
},

View File

@@ -762,7 +762,8 @@ async function allSyncTargetItemsEncrypted() {
if (remoteContent.type_ === BaseModel.TYPE_RESOURCE) {
const content = await fileApi().get(`.resource/${remoteContent.id}`);
totalCount++;
if (content.substr(0, 5) === 'JED01') encryptedCount++;
const headerStart = content.substr(0, 5);
if (['JED01', 'JED02'].includes(headerStart)) encryptedCount++;
}
if (remoteContent.encryption_applied) encryptedCount++;

View File

@@ -65,26 +65,54 @@ urlUtils.parseResourceUrl = function(url) {
};
};
// Note: this duplicates what htmlUtils/markdownUtils.extractFileUrls do
urlUtils.extractResourceUrls = function(text) {
const markdownLinksRE = /\]\((.*?)\)/g;
const output = [];
let result = null;
const mdRegexes = [
{
regex: /!\[.*?\]\((.*?)\)/g,
type: 2, // image (matches LinkType)
},
{
regex: /\[.*?\]\((.*?)\)/g,
type: 1, // anchor
},
];
while ((result = markdownLinksRE.exec(text)) !== null) {
const resourceUrlInfo = urlUtils.parseResourceUrl(result[1]);
if (resourceUrlInfo) output.push(resourceUrlInfo);
const output = [];
for (const mdRegex of mdRegexes) {
let result = null;
while ((result = mdRegex.regex.exec(text)) !== null) {
const resourceUrlInfo = urlUtils.parseResourceUrl(result[1]);
if (resourceUrlInfo && !output.find(o => o.itemId === resourceUrlInfo.itemId)) {
output.push({
...resourceUrlInfo,
type: mdRegex.type,
});
}
}
}
const htmlRegexes = [
/<img[\s\S]*?src=["']:\/([a-zA-Z0-9]{32})["'][\s\S]*?>/gi,
/<a[\s\S]*?href=["']:\/([a-zA-Z0-9]{32})["'][\s\S]*?>/gi,
{
regex: /<img[\s\S]*?src=["']:\/([a-zA-Z0-9]{32})["'][\s\S]*?>/gi,
type: 2, // image
},
{
regex: /<a[\s\S]*?href=["']:\/([a-zA-Z0-9]{32})["'][\s\S]*?>/gi,
type: 1, // anchor
},
];
for (const htmlRegex of htmlRegexes) {
while (true) {
const m = htmlRegex.exec(text);
const m = htmlRegex.regex.exec(text);
if (!m) break;
output.push({ itemId: m[1], hash: '' });
output.push({
itemId: m[1],
hash: '',
type: htmlRegex.type,
});
}
}
@@ -113,7 +141,7 @@ urlUtils.objectToQueryString = function(query) {
// - It always returns paths with forward slashes "/". This is normally handled
// properly everywhere.
//
// - Adds the "platform" parameter to optionall return paths with "\" for win32
// - Adds the "platform" parameter to optionally return paths with "\" for win32
function fileUriToPath_(uri, platform) {
const sep = '/';

View File

@@ -54,12 +54,34 @@ describe('urlUtils', function() {
it('should extract resource URLs', (async () => {
const testCases = [
['Bla [](:/11111111111111111111111111111111) bla [](:/22222222222222222222222222222222) bla', ['11111111111111111111111111111111', '22222222222222222222222222222222']],
['Bla [](:/11111111111111111111111111111111 "Some title") bla [](:/22222222222222222222222222222222 "something else") bla', ['11111111111111111111111111111111', '22222222222222222222222222222222']],
['Bla <img src=":/fcca2938a96a22570e8eae2565bc6b0b"/> bla [](:/22222222222222222222222222222222) bla', ['fcca2938a96a22570e8eae2565bc6b0b', '22222222222222222222222222222222']],
['Bla <img src=":/fcca2938a96a22570e8eae2565bc6b0b"/> bla <a href=":/33333333333333333333333333333333"/>Some note link</a> blu [](:/22222222222222222222222222222222) bla', ['fcca2938a96a22570e8eae2565bc6b0b', '33333333333333333333333333333333', '22222222222222222222222222222222']],
['nothing here', []],
['', []],
[
'Bla [](:/11111111111111111111111111111111) bla [](:/22222222222222222222222222222222) bla',
['11111111111111111111111111111111', '22222222222222222222222222222222'],
],
[
'Bla [](:/11111111111111111111111111111111 "Some title") bla [](:/22222222222222222222222222222222 "something else") bla',
['11111111111111111111111111111111', '22222222222222222222222222222222'],
],
[
'Bla <img src=":/fcca2938a96a22570e8eae2565bc6b0b"/> bla [](:/22222222222222222222222222222222) bla',
['fcca2938a96a22570e8eae2565bc6b0b', '22222222222222222222222222222222'],
],
[
'Bla <img src=":/fcca2938a96a22570e8eae2565bc6b0b"/> bla <a href=":/33333333333333333333333333333333"/>Some note link</a> blu [](:/22222222222222222222222222222222) bla',
['fcca2938a96a22570e8eae2565bc6b0b', '33333333333333333333333333333333', '22222222222222222222222222222222'],
],
[
'![some image](:/11111111111111111111111111111111) and [some link](:/22222222222222222222222222222222)',
['11111111111111111111111111111111', '22222222222222222222222222222222'],
],
[
'nothing here',
[],
],
[
'',
[],
],
];
for (const t of testCases) {
@@ -69,6 +91,19 @@ describe('urlUtils', function() {
const itemIds = result.map(r => r.itemId);
expect(itemIds.sort().join(',')).toBe(expected.sort().join(','));
}
expect(urlUtils.extractResourceUrls('![some image](:/11111111111111111111111111111111) and [some link](:/22222222222222222222222222222222)')).toEqual([
{
itemId: '11111111111111111111111111111111',
hash: '',
type: 2,
},
{
itemId: '22222222222222222222222222222222',
hash: '',
type: 1,
},
]);
}));
it('should convert a file URI to a file path', (async () => {

View File

@@ -70,22 +70,31 @@ const rules: RendererRules = {
source_map: require('./MdToHtml/rules/source_map').default,
};
// This is an ugly hack to go around this bug in Webpack:
// https://github.com/webpack/webpack/issues/4742 If the module exposes a
// default key, that's what we should use. This is not an issue in Node or React
// Native.
const defaultify = (module: any) => {
return module.default ? module.default : module;
};
const uslug = require('@joplin/fork-uslug');
const markdownItAnchor = require('markdown-it-anchor');
const markdownItAnchor = defaultify(require('markdown-it-anchor'));
// The keys must match the corresponding entry in Setting.js
const plugins: RendererPlugins = {
mark: { module: require('markdown-it-mark') },
footnote: { module: require('markdown-it-footnote') },
sub: { module: require('markdown-it-sub') },
sup: { module: require('markdown-it-sup') },
deflist: { module: require('markdown-it-deflist') },
abbr: { module: require('markdown-it-abbr') },
emoji: { module: require('markdown-it-emoji') },
insert: { module: require('markdown-it-ins') },
multitable: { module: require('markdown-it-multimd-table'), options: { multiline: true, rowspan: true, headerless: true } },
toc: { module: require('markdown-it-toc-done-right'), options: { listType: 'ul', slugify: slugify } },
expand_tabs: { module: require('markdown-it-expand-tabs'), options: { tabWidth: 4 } },
mark: { module: defaultify(require('markdown-it-mark')) },
footnote: { module: defaultify(require('markdown-it-footnote')) },
sub: { module: defaultify(require('markdown-it-sub')) },
sup: { module: defaultify(require('markdown-it-sup')) },
deflist: { module: defaultify(require('markdown-it-deflist')) },
abbr: { module: defaultify(require('markdown-it-abbr')) },
emoji: { module: defaultify(require('markdown-it-emoji')) },
insert: { module: defaultify(require('markdown-it-ins')) },
multitable: { module: defaultify(require('markdown-it-multimd-table')), options: { multiline: true, rowspan: true, headerless: true } },
toc: { module: defaultify(require('markdown-it-toc-done-right')), options: { listType: 'ul', slugify: slugify } },
expand_tabs: { module: defaultify(require('markdown-it-expand-tabs')), options: { tabWidth: 4 } },
};
const defaultNoteStyle = require('./defaultNoteStyle');

View File

@@ -1,4 +1,5 @@
import utils, { ItemIdToUrlHandler } from '../utils';
import { LinkType } from '..';
import utils, { ItemIdToUrlHandler, ItemIdToUrlResponse } from '../utils';
const Entities = require('html-entities').AllHtmlEntities;
const htmlentities = new Entities().encode;
const urlUtils = require('../urlUtils.js');
@@ -115,9 +116,13 @@ export default function(href: string, options: Options = null): LinkReplacementR
let resourceFullPath = resource && options?.ResourceModel?.fullPath ? options.ResourceModel.fullPath(resource) : null;
if (resourceId && options.itemIdToUrl) {
const url = options.itemIdToUrl(resourceId);
attrHtml.push(`href='${htmlentities(url)}'`);
resourceFullPath = url;
const r = options.itemIdToUrl(resourceId, LinkType.Anchor);
const response: ItemIdToUrlResponse = typeof r === 'string' ? { url: r, attributes: {} } : r;
attrHtml.push(`href='${htmlentities(response.url)}'`);
for (const [key, value] of Object.entries(response.attributes)) {
attrHtml.push(`${key}='${htmlentities(value)}'`);
}
resourceFullPath = response.url;
} else if (options.plainResourceRendering || options.linkRenderingType === 2) {
icon = '';
attrHtml.push(`href='${htmlentities(href)}'`);

View File

@@ -7,6 +7,11 @@ import validateLinks from './MdToHtml/validateLinks';
import headerAnchor from './headerAnchor';
const assetsToHeaders = require('./assetsToHeaders');
export enum LinkType {
Anchor = 1,
Image = 2,
}
export {
MarkupToHtml,
MarkupLanguage,

View File

@@ -1,3 +1,5 @@
import { LinkType } from '.';
const Entities = require('html-entities').AllHtmlEntities;
const htmlentities = new Entities().encode;
@@ -122,7 +124,12 @@ utils.resourceStatus = function(ResourceModel: any, resourceInfo: any) {
return resourceStatus;
};
export type ItemIdToUrlHandler = (resource: any)=> string;
export interface ItemIdToUrlResponse {
url: string;
attributes: Record<string, string>;
}
export type ItemIdToUrlHandler = (resource: any, linkType: LinkType)=> string | ItemIdToUrlResponse;
utils.imageReplacement = function(ResourceModel: any, src: string, resources: any, resourceBaseUrl: string, itemIdToUrl: ItemIdToUrlHandler = null) {
if (!ResourceModel || !resources) return null;
@@ -138,13 +145,15 @@ utils.imageReplacement = function(ResourceModel: any, src: string, resources: an
const icon = utils.resourceStatusImage(resourceStatus);
return `<div class="not-loaded-resource resource-status-${resourceStatus}" data-resource-id="${resourceId}">` + `<img src="data:image/svg+xml;utf8,${htmlentities(icon)}"/>` + '</div>';
}
const mime = resource.mime ? resource.mime.toLowerCase() : '';
if (ResourceModel.isSupportedImageMimeType(mime)) {
let newSrc = '';
if (itemIdToUrl) {
newSrc = itemIdToUrl(resource.id);
} else {
if (itemIdToUrl) {
return {
'data-resource-id': resource.id,
src: itemIdToUrl(resource.id, LinkType.Image),
};
} else {
const mime = resource.mime ? resource.mime.toLowerCase() : '';
if (ResourceModel.isSupportedImageMimeType(mime)) {
const temp = [];
if (resourceBaseUrl) {
@@ -156,15 +165,13 @@ utils.imageReplacement = function(ResourceModel: any, src: string, resources: an
temp.push(ResourceModel.filename(resource));
temp.push(`?t=${resource.updated_time}`);
newSrc = temp.join('');
}
const newSrc = temp.join('');
// let newSrc = `./${ResourceModel.filename(resource)}`;
// newSrc += `?t=${resource.updated_time}`;
return {
'data-resource-id': resource.id,
src: newSrc,
};
return {
'data-resource-id': resource.id,
src: newSrc,
};
}
}
return null;

View File

@@ -7,4 +7,8 @@ db-*.sqlite
logs/
tests/temp/
temp/
.env
.env
stats.json
public/js/bundle_e2ee.js
public/js/bundle_e2ee.js.LICENSE.txt

View File

@@ -18,6 +18,8 @@
"test-debug": "node --inspect node_modules/.bin/jest -- --verbose=false",
"clean": "gulp clean",
"stripeListen": "stripe listen --forward-to http://joplincloud.local:22300/stripe/webhook",
"webpack": "webpack",
"webpackAnalyze": "webpack --json > stats.json && webpack-bundle-analyzer stats.json",
"watch": "tsc --watch --preserveWatchOutput --project tsconfig.json"
},
"dependencies": {
@@ -70,12 +72,17 @@
"@types/nodemailer": "^6.4.1",
"@types/yargs": "^17.0.4",
"@types/zxcvbn": "^4.4.1",
"crypto-browserify": "^3.12.0",
"gulp": "^4.0.2",
"jest": "^26.6.3",
"jest-expect-message": "^1.0.2",
"jsdom": "^16.4.0",
"node-mocks-http": "^1.10.0",
"source-map-support": "^0.5.13",
"typescript": "4.1.2"
"typescript": "4.1.2",
"url": "^0.11.0",
"webpack": "^5.72.1",
"webpack-bundle-analyzer": "^4.5.0",
"webpack-cli": "^4.9.2"
}
}

View File

@@ -28,6 +28,7 @@ import changeEmailNotificationTemplate from '../views/emails/changeEmailNotifica
import { NotificationKey } from './NotificationModel';
import prettyBytes = require('pretty-bytes');
import { Env } from '../utils/types';
import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types';
const logger = Logger.create('UserModel');
@@ -618,6 +619,14 @@ export default class UserModel extends BaseModel<User> {
return syncInfo.ppk?.value || null;
}
public async masterKeyById(userId: Uuid, masterKeyId: string): Promise<MasterKeyEntity> {
const syncInfo = await this.syncInfo(userId);
if (!syncInfo.masterKeys) throw new ErrorBadRequest(`This user does not have any master key: ${userId}`);
const mk = syncInfo.masterKeys.find((mk: any) => mk.id === masterKeyId);
if (!mk) throw new ErrorBadRequest(`Could not find key ${masterKeyId} for user ${userId}`);
return mk;
}
// Note that when the "password" property is provided, it is going to be
// hashed automatically. It means that it is not safe to do:
//

View File

@@ -0,0 +1,147 @@
import EncryptionService from '@joplin/lib/services/e2ee/EncryptionService';
import MasterKey from '@joplin/lib/models/MasterKey';
import { decryptNote, DownloadResourceHandler, renderNote, setupModels } from './index';
import { initGlobalLogger, supportDir, tempDir } from '../testing/testUtils';
import { readFile } from 'fs-extra';
import FsDriverNode from '@joplin/lib/fs-driver-node';
import { LinkType, MarkupLanguage } from '@joplin/renderer';
import { ModelType } from '@joplin/lib/BaseModel';
const noteContent = `note test
![test.jpg](:/879da30580d94e4d899e54f029c84dd2)
id: 1bdb6d2cb7b840e0b414b5c8e682e2a6
parent_id: f6b6a796b65c4c239ed5b5d031f9172e
created_time: 2022-05-18T14:27:30.866Z
updated_time: 2022-05-19T15:05:16.630Z
is_conflict: 0
latitude: 51.50735090
longitude: -0.12775830
altitude: 0.0000
author:
source_url:
is_todo: 0
todo_due: 0
todo_completed: 0
source: joplindev
source_application: net.cozic.joplindev-cli
application_data:
order: 1652884050866
user_created_time: 2022-05-18T14:27:30.866Z
user_updated_time: 2022-05-19T15:05:16.630Z
encryption_cipher_text:
encryption_applied: 0
markup_language: 1
is_shared: 1
share_id:
conflict_original_id:
master_key_id:
type_: 1`;
const resourceMetadataContent = `photo.jpg
id: 879da30580d94e4d899e54f029c84dd2
mime: image/jpeg
filename: photo.jpg
created_time: 2021-08-07T17:03:33.701Z
updated_time: 2021-08-07T17:03:33.701Z
user_created_time: 2021-08-07T17:03:33.701Z
user_updated_time: 2021-08-07T17:03:33.701Z
file_extension: jpg
encryption_cipher_text:
encryption_applied: 0
encryption_blob_encrypted: 0
size: 2720
is_shared: 0
share_id:
type_: 4`;
const setupEncryptionService = async () => {
const fsDriver = new FsDriverNode();
EncryptionService.fsDriver_ = fsDriver;
const encryptionService = new EncryptionService();
let masterKey = await encryptionService.generateMasterKey('111111', { appId: 'testing' });
masterKey = await MasterKey.save(masterKey);
await encryptionService.loadMasterKey(masterKey, '111111', true);
return { encryptionService, masterKey };
};
describe('e2ee/index', () => {
beforeAll(() => {
initGlobalLogger();
setupModels();
});
it('should decrypt note info', async () => {
const { encryptionService, masterKey } = await setupEncryptionService();
const ciphertext = await encryptionService.encryptString(noteContent);
const noteInfo = await decryptNote(encryptionService, {
ciphertext,
masterKey,
});
expect(noteInfo.note.body).toBe('![test.jpg](:/879da30580d94e4d899e54f029c84dd2)');
expect(noteInfo.note.title).toBe('note test');
expect(noteInfo.note.type_).toBe(1);
expect(Object.keys(noteInfo.linkedItemInfos).length).toBe(1);
const linkedItemInfo = noteInfo.linkedItemInfos['879da30580d94e4d899e54f029c84dd2'];
expect(linkedItemInfo).toEqual({
item: {
id: '879da30580d94e4d899e54f029c84dd2',
type: 2,
type_: 4,
},
localState: {
fetch_status: 2,
},
});
});
it('should render a note', async () => {
const { encryptionService } = await setupEncryptionService();
const tmp = await tempDir();
const encryptedFilePath = `${tmp}/photo.crypted`;
await encryptionService.encryptFile(`${supportDir}/photo.jpg`, encryptedFilePath);
const encryptedMd = await encryptionService.encryptString(resourceMetadataContent);
const mockDownloadResource: DownloadResourceHandler = async (_getResourceTemplateUrl: string, _shareId: string, _resourceId: string, metadataOnly: boolean) => {
if (metadataOnly) {
return { encryption_cipher_text: encryptedMd } as any;
} else {
return readFile(encryptedFilePath, 'utf8');
}
};
const result = await renderNote(encryptionService, {
title: 'note test',
body: '**bold** and an image: ![test.jpg](:/879da30580d94e4d899e54f029c84dd2) and a link to a resource: [link](:/879da30580d94e4d899e54f029c84dd2)',
markup_language: MarkupLanguage.Markdown,
}, {
'879da30580d94e4d899e54f029c84dd2': {
localState: {
fetch_status: 2,
},
item: {
id: '879da30580d94e4d899e54f029c84dd2',
type_: ModelType.Resource,
type: LinkType.Image,
},
},
}, 'http://localhost/shares/SHARE_ID?resource_id=RESOURCE_ID&resource_metadata=RESOURCE_METADATA', 'abcdefgh', mockDownloadResource);
// Check that the image is being displayed
expect(result.html).toContain('<img data-from-md data-resource-id="879da30580d94e4d899e54f029c84dd2" src="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAkGBwgHBgkIBwgKCgkLDRYPDQwMDRsUFRAWIB0iIiAdHx8kKDQsJCYxJx8fLT0tMTU3Ojo6Iys/RD84QzQ5Ojf/2wBDAQoKCg0MDRoPDxo3JR8lNzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzf/wAARCAByAHsDASIAAhEBAxEB/8QAHAAAAAcBAQAAAAAAAAAAAAAAAA');
// Check that the link to the encrypted resource is available
expect(result.html).toContain('FF2QQQAfuiO6CCYf/9k=\' download=\'photo.jpg\'>');
});
});

View File

@@ -0,0 +1,277 @@
import EncryptionService from '@joplin/lib/services/e2ee/EncryptionService';
import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types';
import Logger, { LogLevel, TargetType } from '@joplin/lib/Logger';
import shim from '@joplin/lib/shim';
import Note from '@joplin/lib/models/Note';
import { NoteEntity, ResourceEntity } from '@joplin/lib/services/database/types';
import BaseItem from '@joplin/lib/models/BaseItem';
import { LinkType, MarkupToHtml } from '@joplin/renderer';
import Resource from '@joplin/lib/models/Resource';
import { OptionsResourceModel } from '@joplin/renderer/MarkupToHtml';
import { ModelType } from '@joplin/lib/BaseModel';
import { Uuid } from '../../services/database/types';
import { themeStyle } from '@joplin/lib/theme';
import Setting from '@joplin/lib/models/Setting';
import { ItemIdToUrlResponse } from '@joplin/renderer/utils';
const sjcl = require('@joplin/lib/vendor/sjcl.js');
const urlUtils = require('@joplin/lib/urlUtils');
const mimeUtils = require('@joplin/lib/mime-utils.js').mime;
interface LinkedItemInfoLocalState {
fetch_status: number;
}
interface LinkedItemInfoItem {
id: string;
type_: ModelType;
type: LinkType;
}
interface LinkedItemInfo {
localState: LinkedItemInfoLocalState;
item: LinkedItemInfoItem;
}
type LinkedItemInfos = Record<string, LinkedItemInfo>;
interface JoplinNsNote {
ciphertext: string;
masterKey: MasterKeyEntity;
}
interface JoplinNs {
note: JoplinNsNote;
getResourceTemplateUrl: string;
}
export type DownloadResourceHandler = (getResourceTemplateUrl: string, shareId: string, resourceId: string, metadataOnly: boolean)=> Promise<string>;
const setupGlobalLogger = () => {
const mainLogger = new Logger();
mainLogger.addTarget(TargetType.Console);
mainLogger.setLevel(LogLevel.Debug);
Logger.initializeGlobalLogger(mainLogger);
return mainLogger;
};
const setupShim = () => {
shim.sjclModule = sjcl;
shim.setTimeout = (fn, interval) => {
return setTimeout(fn, interval);
};
shim.setInterval = (fn, interval) => {
return setInterval(fn, interval);
};
shim.clearTimeout = (id) => {
return clearTimeout(id);
};
shim.clearInterval = (id) => {
return clearInterval(id);
};
shim.waitForFrame = () => {};
};
const setupEncryptionService = async (masterKey: MasterKeyEntity, password: string) => {
const encryptionService = new EncryptionService();
await encryptionService.loadMasterKey(masterKey, password, false);
return encryptionService;
};
export const setupModels = () => {
BaseItem.loadClass('Note', Note);
BaseItem.loadClass('Resource', Resource);
};
const makeResourceUrl = (getResourceTemplateUrl: string, shareId: string, resourceId: string, metadataOnly: boolean) => {
return getResourceTemplateUrl.replace(/SHARE_ID/, shareId).replace(/RESOURCE_ID/, resourceId).replace(/RESOURCE_METADATA/, metadataOnly ? '1' : '0');
};
const downloadResource: DownloadResourceHandler = async (getResourceTemplateUrl: string, shareId: string, resourceId: string, metadataOnly: boolean) => {
const url = makeResourceUrl(getResourceTemplateUrl, shareId, resourceId, metadataOnly);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Could not download resource: ${url}: ${await response.text()}`);
}
if (metadataOnly) {
return response.json();
} else {
return response.text();
}
};
const decryptNote = async (encryptionService: EncryptionService, joplinNsNote: JoplinNsNote) => {
setupModels();
const serializedContent = await encryptionService.decryptString(joplinNsNote.ciphertext);
const note: NoteEntity = await Note.unserialize(serializedContent, { noDb: true });
note.markup_language = Number(note.markup_language);
const linkedItems = urlUtils.extractResourceUrls(note.body);
const linkedItemInfos: LinkedItemInfos = {};
for (const linkedItem of linkedItems) {
linkedItemInfos[linkedItem.itemId] = {
item: {
id: linkedItem.itemId,
type: linkedItem.type,
type_: ModelType.Resource,
},
localState: {
fetch_status: 2,
},
};
}
return {
note,
linkedItemInfos,
};
};
const renderNote = async (encryptionService: EncryptionService, note: NoteEntity, linkedItemInfos: LinkedItemInfos, getResourceTemplateUrl: string, shareId: string, downloadResource: DownloadResourceHandler) => {
const markupToHtml = new MarkupToHtml({
ResourceModel: Resource as OptionsResourceModel,
});
interface FetchedResource {
metadata: ResourceEntity;
content: string;
}
const fetchedResources: Record<string, FetchedResource> = {};
for (const [itemId] of Object.entries(linkedItemInfos)) {
const encryptedMd = await downloadResource(getResourceTemplateUrl, shareId, itemId, true) as ResourceEntity;
const plaintextMd = await encryptionService.decryptString(encryptedMd.encryption_cipher_text);
const md = await Resource.unserialize(plaintextMd, { noDb: true }) as ResourceEntity;
const content = await downloadResource(getResourceTemplateUrl, shareId, itemId, false);
const decrypted = await encryptionService.decryptBase64(content);
fetchedResources[itemId] = {
metadata: md,
content: `data:${md.mime};base64,${decrypted}`,
};
}
const renderOptions: any = {
resources: linkedItemInfos,
itemIdToUrl: (itemId: Uuid, linkType: LinkType): string|ItemIdToUrlResponse => {
if (!linkedItemInfos[itemId]) return '#';
const item = linkedItemInfos[itemId].item;
if (!item) throw new Error(`No such item in this note: ${itemId}`);
if (item.type_ === ModelType.Note) {
return '#';
} else if (item.type_ === ModelType.Resource) {
const fetchedResource = fetchedResources[itemId];
if (linkType === LinkType.Image) {
return fetchedResource.content;
} else {
return {
url: fetchedResource.content,
attributes: {
download: mimeUtils.appendExtensionFromMime(fetchedResource.metadata.filename || 'file', fetchedResource.metadata.mime),
},
};
}
} else {
throw new Error(`Unsupported item type: ${item.type_}`);
}
},
// Switch-off the media players because there's no option to toggle
// them on and off.
audioPlayerEnabled: false,
videoPlayerEnabled: false,
pdfViewerEnabled: false,
checkboxDisabled: true,
linkRenderingType: 2,
};
const result = await markupToHtml.render(note.markup_language, note.body, themeStyle(Setting.THEME_LIGHT), renderOptions);
return result;
};
if (typeof window !== 'undefined') {
(() => {
const getPassword = () => {
const params = new URLSearchParams(window.location.search);
const queryPassword = params.get('password');
if (queryPassword) return atob(queryPassword);
const answer = prompt('This note is encrypted. Please enter the password:');
if (!answer) throw new Error('The note cannot be decrypted without a password');
return answer.trim();
};
const getShareId = () => {
const p = location.pathname.split('/');
const shareId = p.pop();
return shareId;
};
const initPage = async () => {
const joplin = (window as any).__joplin as JoplinNs;
const logger = setupGlobalLogger();
setupShim();
const password = getPassword();
const shareId = getShareId();
console.info('Share ID', shareId);
console.info('Password', password);
const encryptionService = await setupEncryptionService(joplin.note.masterKey, password);
const decrypted = await (async () => {
try {
return decryptNote(encryptionService, joplin.note);
} catch (error) {
error.message = `Could not decrypt note: ${error.message}`;
throw error;
}
})();
logger.info('Decrypted note');
logger.info(decrypted);
const result = await (async () => {
try {
return renderNote(encryptionService, decrypted.note, decrypted.linkedItemInfos, joplin.getResourceTemplateUrl, shareId, downloadResource);
} catch (error) {
error.message = `Could not render note: ${error.message}`;
throw error;
}
})();
const contentElement = document.createElement('div');
contentElement.innerHTML = result.html;
document.body.appendChild(contentElement);
};
const initPageIID = setInterval(async () => {
if (document.readyState !== 'loading') {
clearInterval(initPageIID);
try {
await initPage();
} catch (error) {
console.error(error);
alert(`There was an error loading this page: ${error.message}`);
}
}
}, 10);
})();
}
export { decryptNote, renderNote };

View File

@@ -16,18 +16,19 @@ import { NoteEntity } from '@joplin/lib/services/database/types';
import { formatDateTime } from './time';
import { ErrorBadRequest, ErrorForbidden, ErrorNotFound } from './errors';
import { MarkupToHtml } from '@joplin/renderer';
import { OptionsResourceModel } from '@joplin/renderer/MarkupToHtml';
import { OptionsResourceModel, RenderResult } from '@joplin/renderer/MarkupToHtml';
import { isValidHeaderIdentifier } from '@joplin/lib/services/e2ee/EncryptionService';
const { DatabaseDriverNode } = require('@joplin/lib/database-driver-node.js');
import { themeStyle } from '@joplin/lib/theme';
import Setting from '@joplin/lib/models/Setting';
import { Models } from '../models/factory';
import MustacheService from '../services/MustacheService';
import Logger from '@joplin/lib/Logger';
import config from '../config';
import EncryptionService from '@joplin/lib/services/e2ee/EncryptionService';
import personalizedUserContentBaseUrl from '@joplin/lib/services/joplinServer/personalizedUserContentBaseUrl';
import { TreeItem } from '../models/ItemResourceModel';
const { substrWithEllipsis } = require('@joplin/lib/string-utils');
const { DatabaseDriverNode } = require('@joplin/lib/database-driver-node.js');
const logger = Logger.create('JoplinUtils');
export interface FileViewerResponse {
@@ -216,13 +217,41 @@ async function renderNote(share: Share, note: NoteEntity, resourceInfos: Resourc
linkRenderingType: 2,
};
const result = await markupToHtml.render(note.markup_language, note.body, themeStyle(Setting.THEME_LIGHT), renderOptions);
let result: RenderResult = null;
let noteTitle: string = note.title;
if (note.encryption_applied) {
const encryptionService = new EncryptionService();
const header = await encryptionService.decodeHeaderString(note.encryption_cipher_text);
const masterKey = await models_.user().masterKeyById(share.owner_id, header.masterKeyId);
const userContentBaseUrl = personalizedUserContentBaseUrl(share.owner_id, config().baseUrl, config().userContentBaseUrl);
result = {
cssStrings: [],
html: `<script>
if (!window.__joplin) window.__joplin = {};
window.__joplin.getResourceTemplateUrl = ${JSON.stringify(`${userContentBaseUrl}/shares/SHARE_ID?resource_id=RESOURCE_ID&resource_metadata=RESOURCE_METADATA`)},
window.__joplin.note = {
ciphertext: ${JSON.stringify(note.encryption_cipher_text)},
masterKey: ${JSON.stringify(masterKey)},
};
</script>`,
pluginAssets: [],
};
noteTitle = '🔑';
} else {
result = await markupToHtml.render(note.markup_language, note.body, themeStyle(Setting.THEME_LIGHT), renderOptions);
}
const bodyHtml = await mustache_.renderView({
cssFiles: ['items/note'],
jsFiles: ['items/note'],
jsFiles: [
'items/note',
'bundle_e2ee',
],
name: 'note',
title: `${substrWithEllipsis(note.title, 0, 100)} - ${config().appName}`,
title: `${substrWithEllipsis(noteTitle, 0, 100)} - ${config().appName}`,
titleOverride: true,
path: 'index/items/note',
content: {
@@ -288,6 +317,7 @@ const isInTree = (itemTree: TreeItem, jopId: string) => {
interface RenderItemQuery {
resource_id?: string;
note_id?: string;
resource_metadata?: string;
}
// "item" is always the item associated with the share (the "root item"). It may
@@ -308,7 +338,46 @@ export async function renderItem(userId: Uuid, item: Item, share: Share, query:
let fileToRender: FileToRender;
let itemToRender: any = null;
let itemToRenderType: ModelType = item.jop_type;
if (query.resource_id) {
const resourceMetadataOnly = query.resource_metadata === '1';
if (resourceMetadataOnly) {
const resourceItem = await models_.item().loadByJopId(userId, query.resource_id, { fields: ['*'] });
if (!resourceItem) throw new ErrorNotFound(`No such resource: ${query.resource_id}`);
if (!resourceItem.jop_encryption_applied) throw new ErrorBadRequest('Not supported');
const asJoplinItem = JSON.stringify(models_.item().itemToJoplinItem(resourceItem));
return {
body: asJoplinItem,
filename: '',
mime: 'text/json',
size: Buffer.byteLength(asJoplinItem),
};
}
const resourceItem = await models_.item().loadByName(userId, resourceBlobPath(query.resource_id), { fields: ['*'], withContent: !resourceMetadataOnly });
if (!resourceItem) throw new ErrorNotFound(`No such resource: ${query.resource_id}`);
fileToRender = {
item: resourceItem,
content: resourceItem.content,
jopItemId: query.resource_id,
};
itemToRenderType = ModelType.Resource;
}
// If the item is encrypted we cannot know if the resource is part of the
// note or not so we skip this check. But this is fine because access
// control is done via the password.
// TODO: restore?
// if (fileToRender.item !== item && !linkedItemInfos[fileToRender.jopItemId] && !item.jop_encryption_applied) {
// throw new ErrorNotFound(`Item "${fileToRender.jopItemId}" does not belong to this note`);
// }
if (itemToRenderType === ModelType.Resource) {
// ------------------------------------------------------------------------------------------
// Render a resource that is attached to a note
// ------------------------------------------------------------------------------------------
@@ -330,7 +399,6 @@ export async function renderItem(userId: Uuid, item: Item, share: Share, query:
// Render a linked note
// ------------------------------------------------------------------------------------------
if (!share.recursive) throw new ErrorForbidden('This linked note has not been published');
const noteItem = await models_.item().loadByName(userId, `${query.note_id}.md`, { fields: ['*'], withContent: true });

View File

@@ -30,6 +30,8 @@ import { URL } from 'url';
// when it runs.
const packageRootDir = path.dirname(path.dirname(path.dirname(__dirname)));
export const supportDir = `${path.dirname(packageRootDir)}/app-cli/tests/support`;
let db_: DbConnection = null;
// require('source-map-support').install();
@@ -61,10 +63,10 @@ export async function makeTempFileWithContent(content: string | Buffer): Promise
return filePath;
}
function initGlobalLogger() {
export const initGlobalLogger = () => {
const globalLogger = new Logger();
Logger.initializeGlobalLogger(globalLogger);
}
};
let createdDbPath_: string = null;
export async function beforeAllDb(unitName: string, createDbOptions: CreateDbOptions = null) {

View File

@@ -0,0 +1,35 @@
const path = require('path');
const webpack = require('webpack');
module.exports = {
entry: './dist/utils/e2ee/index.js',
devtool: 'source-map',
mode: 'production',
output: {
path: path.resolve(__dirname, 'public', 'js'),
filename: 'bundle_e2ee.js',
},
resolve: {
fallback: {
'events': require.resolve('events/'),
'url': require.resolve('url/'),
},
},
externals: {
// SJCL is designed to work both in Node and the browser, thus it has
// conditional likes "if crypto is undefined, require('crypto')". That
// works fine in the browser, but WebPack see the "require()" statement
// and then ask to include polyfills, but we don't need this (since
// crypto, etc. are available in the browser). As a result we define
// them as "external" here.
'crypto': 'commonjs crypto',
'stream': 'commonjs stream',
// Exclude locales because it's a lot of files and they aren't used
'./locales/index.js': 'commonjs locales',
},
plugins: [
// https://github.com/moment/moment/issues/2416
new webpack.IgnorePlugin({ resourceRegExp: /^\.\/locale$/, contextRegExp: /moment$/ }),
],
};

229
yarn.lock
View File

@@ -4274,6 +4274,7 @@ __metadata:
"@types/react": ^17.0.20
async-mutex: ^0.1.3
base-64: ^0.1.0
base64-arraybuffer: ^1.0.2
base64-stream: ^1.0.0
builtin-modules: ^3.1.0
chokidar: ^3.4.3
@@ -4469,6 +4470,7 @@ __metadata:
bulma: ^0.9.1
bulma-prefers-dark: ^0.1.0-beta.0
compare-versions: ^3.6.0
crypto-browserify: ^3.12.0
dayjs: ^1.9.8
formidable: ^1.2.2
fs-extra: ^8.1.0
@@ -4498,7 +4500,11 @@ __metadata:
sqlite3: ^4.1.0
stripe: ^8.150.0
typescript: 4.1.2
url: ^0.11.0
uuid: ^8.3.2
webpack: ^5.72.1
webpack-bundle-analyzer: ^4.5.0
webpack-cli: ^4.9.2
yargs: ^17.2.1
zxcvbn: ^4.4.2
languageName: unknown
@@ -5785,6 +5791,13 @@ __metadata:
languageName: node
linkType: hard
"@polka/url@npm:^1.0.0-next.20":
version: 1.0.0-next.21
resolution: "@polka/url@npm:1.0.0-next.21"
checksum: c7654046d38984257dd639eab3dc770d1b0340916097b2fac03ce5d23506ada684e05574a69b255c32ea6a144a957c8cd84264159b545fca031c772289d88788
languageName: node
linkType: hard
"@popperjs/core@npm:^2.4.0":
version: 2.11.0
resolution: "@popperjs/core@npm:2.11.0"
@@ -7642,6 +7655,16 @@ __metadata:
languageName: node
linkType: hard
"@webpack-cli/configtest@npm:^1.1.1":
version: 1.1.1
resolution: "@webpack-cli/configtest@npm:1.1.1"
peerDependencies:
webpack: 4.x.x || 5.x.x
webpack-cli: 4.x.x
checksum: c4e7fca21315e487655fbdc7d079092c3f88b274a720d245ca2e13dce7553009fb3f9d82218c33f5c9b208832d72bb4114a9cca97d53b66212eff5da1d3ad44b
languageName: node
linkType: hard
"@webpack-cli/configtest@npm:^1.2.0":
version: 1.2.0
resolution: "@webpack-cli/configtest@npm:1.2.0"
@@ -7663,6 +7686,17 @@ __metadata:
languageName: node
linkType: hard
"@webpack-cli/info@npm:^1.4.1":
version: 1.4.1
resolution: "@webpack-cli/info@npm:1.4.1"
dependencies:
envinfo: ^7.7.3
peerDependencies:
webpack-cli: 4.x.x
checksum: 7a7cac2ba4f2528caa329311599da1685b1bc099bfc5b7210932b7c86024c1277fd7857b08557902b187ea01247a8e8f72f7f5719af72b0c8d97f22087aa0c14
languageName: node
linkType: hard
"@webpack-cli/info@npm:^1.5.0":
version: 1.5.0
resolution: "@webpack-cli/info@npm:1.5.0"
@@ -7686,6 +7720,18 @@ __metadata:
languageName: node
linkType: hard
"@webpack-cli/serve@npm:^1.6.1":
version: 1.6.1
resolution: "@webpack-cli/serve@npm:1.6.1"
peerDependencies:
webpack-cli: 4.x.x
peerDependenciesMeta:
webpack-dev-server:
optional: true
checksum: 8b273f906aeffa60c7d5700ae25f98d4b66b7e922cad38acb9575d55ff83872cd20b9894aacfa81c4d54e5b51b16253ae0e70c5e9e0608dc8768276e15c74536
languageName: node
linkType: hard
"@webpack-cli/serve@npm:^1.7.0":
version: 1.7.0
resolution: "@webpack-cli/serve@npm:1.7.0"
@@ -7854,7 +7900,7 @@ __metadata:
languageName: node
linkType: hard
"acorn-walk@npm:^8.1.1":
"acorn-walk@npm:^8.0.0, acorn-walk@npm:^8.1.1":
version: 8.2.0
resolution: "acorn-walk@npm:8.2.0"
checksum: 1715e76c01dd7b2d4ca472f9c58968516a4899378a63ad5b6c2d668bba8da21a71976c14ec5f5b75f887b6317c4ae0b897ab141c831d741dc76024d8745f1ad1
@@ -7897,6 +7943,15 @@ __metadata:
languageName: node
linkType: hard
"acorn@npm:^8.0.4, acorn@npm:^8.5.0":
version: 8.7.1
resolution: "acorn@npm:8.7.1"
bin:
acorn: bin/acorn
checksum: aca0aabf98826717920ac2583fdcad0a6fbe4e583fdb6e843af2594e907455aeafe30b1e14f1757cd83ce1776773cf8296ffc3a4acf13f0bd3dfebcf1db6ae80
languageName: node
linkType: hard
"acorn@npm:^8.2.4, acorn@npm:^8.4.1":
version: 8.6.0
resolution: "acorn@npm:8.6.0"
@@ -7906,15 +7961,6 @@ __metadata:
languageName: node
linkType: hard
"acorn@npm:^8.5.0":
version: 8.7.1
resolution: "acorn@npm:8.7.1"
bin:
acorn: bin/acorn
checksum: aca0aabf98826717920ac2583fdcad0a6fbe4e583fdb6e843af2594e907455aeafe30b1e14f1757cd83ce1776773cf8296ffc3a4acf13f0bd3dfebcf1db6ae80
languageName: node
linkType: hard
"acorn@npm:^8.7.1, acorn@npm:^8.8.0":
version: 8.8.0
resolution: "acorn@npm:8.8.0"
@@ -9623,6 +9669,13 @@ __metadata:
languageName: node
linkType: hard
"base64-arraybuffer@npm:^1.0.2":
version: 1.0.2
resolution: "base64-arraybuffer@npm:1.0.2"
checksum: 15e6400d2d028bf18be4ed97702b11418f8f8779fb8c743251c863b726638d52f69571d4cc1843224da7838abef0949c670bde46936663c45ad078e89fee5c62
languageName: node
linkType: hard
"base64-js@npm:*, base64-js@npm:^1.0.2, base64-js@npm:^1.1.2, base64-js@npm:^1.3.1, base64-js@npm:^1.5.1":
version: 1.5.1
resolution: "base64-js@npm:1.5.1"
@@ -11488,7 +11541,7 @@ __metadata:
languageName: node
linkType: hard
"commander@npm:7, commander@npm:^7.0.0, commander@npm:^7.1.0":
"commander@npm:7, commander@npm:^7.0.0, commander@npm:^7.1.0, commander@npm:^7.2.0":
version: 7.2.0
resolution: "commander@npm:7.2.0"
checksum: 53501cbeee61d5157546c0bef0fedb6cdfc763a882136284bed9a07225f09a14b82d2a84e7637edfd1a679fb35ed9502fd58ef1d091e6287f60d790147f68ddc
@@ -14280,7 +14333,7 @@ __metadata:
languageName: node
linkType: hard
"duplexer@npm:^0.1.1":
"duplexer@npm:^0.1.1, duplexer@npm:^0.1.2":
version: 0.1.2
resolution: "duplexer@npm:0.1.2"
checksum: 62ba61a830c56801db28ff6305c7d289b6dc9f859054e8c982abd8ee0b0a14d2e9a8e7d086ffee12e868d43e2bbe8a964be55ddbd8c8957714c87373c7a4f9b0
@@ -14662,6 +14715,16 @@ __metadata:
languageName: node
linkType: hard
"enhanced-resolve@npm:^5.9.3":
version: 5.9.3
resolution: "enhanced-resolve@npm:5.9.3"
dependencies:
graceful-fs: ^4.2.4
tapable: ^2.2.0
checksum: 64c2dbbdd608d1a4df93b6e60786c603a1faf3b2e66dfd051d62cf4cfaeeb5e800166183685587208d62e9f7afff3f78f3d5978e32cd80125ba0c83b59a79d78
languageName: node
linkType: hard
"enquirer@npm:^2.3.6":
version: 2.3.6
resolution: "enquirer@npm:2.3.6"
@@ -18112,6 +18175,15 @@ __metadata:
languageName: node
linkType: hard
"gzip-size@npm:^6.0.0":
version: 6.0.0
resolution: "gzip-size@npm:6.0.0"
dependencies:
duplexer: ^0.1.2
checksum: 2df97f359696ad154fc171dcb55bc883fe6e833bca7a65e457b9358f3cb6312405ed70a8da24a77c1baac0639906cd52358dc0ce2ec1a937eaa631b934c94194
languageName: node
linkType: hard
"handlebars@npm:^4.0.3, handlebars@npm:^4.7.6, handlebars@npm:^4.7.7":
version: 4.7.7
resolution: "handlebars@npm:4.7.7"
@@ -23318,7 +23390,7 @@ __metadata:
languageName: node
linkType: hard
"lodash@npm:^4.0.0, lodash@npm:^4.17.10, lodash@npm:^4.17.11, lodash@npm:^4.17.12, lodash@npm:^4.17.14, lodash@npm:^4.17.15, lodash@npm:^4.17.19, lodash@npm:^4.17.21, lodash@npm:^4.17.4, lodash@npm:^4.17.5, lodash@npm:^4.2.1, lodash@npm:^4.3.0, lodash@npm:^4.7.0":
"lodash@npm:^4.0.0, lodash@npm:^4.17.10, lodash@npm:^4.17.11, lodash@npm:^4.17.12, lodash@npm:^4.17.14, lodash@npm:^4.17.15, lodash@npm:^4.17.19, lodash@npm:^4.17.20, lodash@npm:^4.17.21, lodash@npm:^4.17.4, lodash@npm:^4.17.5, lodash@npm:^4.2.1, lodash@npm:^4.3.0, lodash@npm:^4.7.0":
version: 4.17.21
resolution: "lodash@npm:4.17.21"
checksum: eb835a2e51d381e561e508ce932ea50a8e5a68f4ebdd771ea240d3048244a8d13658acbd502cd4829768c56f2e16bdd4340b9ea141297d472517b83868e677f7
@@ -25000,6 +25072,13 @@ __metadata:
languageName: node
linkType: hard
"mrmime@npm:^1.0.0":
version: 1.0.0
resolution: "mrmime@npm:1.0.0"
checksum: 2c72a40942af7c53bc97d1e9e9c5cb0e6541d18f736811c3a1b46fa2a2b2362480d687daa8ae8372523acaacd82426a4f7ce34b0bf1825ea83b3983e8cb91afd
languageName: node
linkType: hard
"ms@npm:2.0.0":
version: 2.0.0
resolution: "ms@npm:2.0.0"
@@ -26294,7 +26373,7 @@ __metadata:
languageName: node
linkType: hard
"opener@npm:^1.4.1, opener@npm:^1.5.1":
"opener@npm:^1.4.1, opener@npm:^1.5.1, opener@npm:^1.5.2":
version: 1.5.2
resolution: "opener@npm:1.5.2"
bin:
@@ -30795,6 +30874,17 @@ __metadata:
languageName: node
linkType: hard
"sirv@npm:^1.0.7":
version: 1.0.19
resolution: "sirv@npm:1.0.19"
dependencies:
"@polka/url": ^1.0.0-next.20
mrmime: ^1.0.0
totalist: ^1.0.0
checksum: c943cfc61baf85f05f125451796212ec35d4377af4da90ae8ec1fa23e6d7b0b4d9c74a8fbf65af83c94e669e88a09dc6451ba99154235eead4393c10dda5b07c
languageName: node
linkType: hard
"sisteransi@npm:^1.0.5":
version: 1.0.5
resolution: "sisteransi@npm:1.0.5"
@@ -32978,6 +33068,13 @@ __metadata:
languageName: node
linkType: hard
"totalist@npm:^1.0.0":
version: 1.1.0
resolution: "totalist@npm:1.1.0"
checksum: dfab80c7104a1d170adc8c18782d6c04b7df08352dec452191208c66395f7ef2af7537ddfa2cf1decbdcfab1a47afbbf0dec6543ea191da98c1c6e1599f86adc
languageName: node
linkType: hard
"touch@npm:^2.0.1":
version: 2.0.2
resolution: "touch@npm:2.0.2"
@@ -34706,6 +34803,25 @@ __metadata:
languageName: node
linkType: hard
"webpack-bundle-analyzer@npm:^4.5.0":
version: 4.5.0
resolution: "webpack-bundle-analyzer@npm:4.5.0"
dependencies:
acorn: ^8.0.4
acorn-walk: ^8.0.0
chalk: ^4.1.0
commander: ^7.2.0
gzip-size: ^6.0.0
lodash: ^4.17.20
opener: ^1.5.2
sirv: ^1.0.7
ws: ^7.3.1
bin:
webpack-bundle-analyzer: lib/bin/analyzer.js
checksum: 158e96810ec213d5665ca1c0b257097db44e1f11c4befefab8352b9e5b10890fcb3e3fc1f7bb400dd58762a8edce5621c92afeca86eb4687d2eb64e93186bfcb
languageName: node
linkType: hard
"webpack-cli@npm:^4.10.0":
version: 4.10.0
resolution: "webpack-cli@npm:4.10.0"
@@ -34772,6 +34888,39 @@ __metadata:
languageName: node
linkType: hard
"webpack-cli@npm:^4.9.2":
version: 4.9.2
resolution: "webpack-cli@npm:4.9.2"
dependencies:
"@discoveryjs/json-ext": ^0.5.0
"@webpack-cli/configtest": ^1.1.1
"@webpack-cli/info": ^1.4.1
"@webpack-cli/serve": ^1.6.1
colorette: ^2.0.14
commander: ^7.0.0
execa: ^5.0.0
fastest-levenshtein: ^1.0.12
import-local: ^3.0.2
interpret: ^2.2.0
rechoir: ^0.7.0
webpack-merge: ^5.7.3
peerDependencies:
webpack: 4.x.x || 5.x.x
peerDependenciesMeta:
"@webpack-cli/generators":
optional: true
"@webpack-cli/migrate":
optional: true
webpack-bundle-analyzer:
optional: true
webpack-dev-server:
optional: true
bin:
webpack-cli: bin/cli.js
checksum: ffb4c5d53ab65ce9f1e8efd34fca4cb858ec6afc91ece0d9375094edff2e7615708c8a586991057fd9cc8d37aab0eb0511913b178daac534e51bcf7d3583e61c
languageName: node
linkType: hard
"webpack-merge@npm:^5.7.3":
version: 5.8.0
resolution: "webpack-merge@npm:5.8.0"
@@ -34833,6 +34982,43 @@ __metadata:
languageName: node
linkType: hard
"webpack@npm:^5.72.1":
version: 5.72.1
resolution: "webpack@npm:5.72.1"
dependencies:
"@types/eslint-scope": ^3.7.3
"@types/estree": ^0.0.51
"@webassemblyjs/ast": 1.11.1
"@webassemblyjs/wasm-edit": 1.11.1
"@webassemblyjs/wasm-parser": 1.11.1
acorn: ^8.4.1
acorn-import-assertions: ^1.7.6
browserslist: ^4.14.5
chrome-trace-event: ^1.0.2
enhanced-resolve: ^5.9.3
es-module-lexer: ^0.9.0
eslint-scope: 5.1.1
events: ^3.2.0
glob-to-regexp: ^0.4.1
graceful-fs: ^4.2.9
json-parse-even-better-errors: ^2.3.1
loader-runner: ^4.2.0
mime-types: ^2.1.27
neo-async: ^2.6.2
schema-utils: ^3.1.0
tapable: ^2.1.1
terser-webpack-plugin: ^5.1.3
watchpack: ^2.3.1
webpack-sources: ^3.2.3
peerDependenciesMeta:
webpack-cli:
optional: true
bin:
webpack: bin/webpack.js
checksum: d1eff085eee1c67a68f7bf1d077ea202c1e68a0de0e0866274984769838c3f224fbc64e847e1a1bbc6eba9fb6a9965098809cc0be9292b573767bb5d8d2df96e
languageName: node
linkType: hard
"webpack@npm:^5.73.0, webpack@npm:^5.74.0":
version: 5.74.0
resolution: "webpack@npm:5.74.0"
@@ -35345,6 +35531,21 @@ __metadata:
languageName: node
linkType: hard
"ws@npm:^7.3.1":
version: 7.5.7
resolution: "ws@npm:7.5.7"
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: ^5.0.2
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
checksum: 5c1f669a166fb57560b4e07f201375137fa31d9186afde78b1508926345ce546332f109081574ddc4e38cc474c5406b5fc71c18d71eb75f6e2d2245576976cba
languageName: node
linkType: hard
"ws@npm:^8.2.3":
version: 8.8.0
resolution: "ws@npm:8.8.0"