You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-08-24 20:19:10 +02:00
Compare commits
29 Commits
server-v3.
...
publish_no
Author | SHA1 | Date | |
---|---|---|---|
|
d744f93e16 | ||
|
3d8354c403 | ||
|
e2fa57c48f | ||
|
4162b3cbac | ||
|
e2731ca01f | ||
|
d910de02d6 | ||
|
9107f9a073 | ||
|
c7c0be5b21 | ||
|
6bd809b347 | ||
|
e8a3149d39 | ||
|
f8a947fedd | ||
|
f9afe040fb | ||
|
16b60bd910 | ||
|
6a2a52ec78 | ||
|
d5a0d7b4a7 | ||
|
810ae1f763 | ||
|
985a988734 | ||
|
65436e6007 | ||
|
f60e1d498f | ||
|
07ed4b4d62 | ||
|
b9adcc80ac | ||
|
e251e09120 | ||
|
3011da7f53 | ||
|
1661cf85de | ||
|
04c286216e | ||
|
4f201eb926 | ||
|
e1b1a78768 | ||
|
98ed58cc6e | ||
|
3f0943873e |
@@ -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",
|
||||
|
@@ -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;
|
||||
|
@@ -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
|
||||
|
@@ -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']],
|
||||
['', [':/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 = [
|
||||
[
|
||||
' [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 = [
|
||||
|
@@ -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) {
|
||||
|
@@ -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');
|
||||
}));
|
||||
|
||||
});
|
||||
|
@@ -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;
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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",
|
||||
|
@@ -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);
|
||||
|
@@ -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);
|
||||
|
@@ -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)];
|
||||
|
@@ -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();
|
||||
|
@@ -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');
|
||||
},
|
||||
|
||||
|
@@ -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++;
|
||||
|
@@ -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 = '/';
|
||||
|
||||
|
@@ -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'],
|
||||
],
|
||||
[
|
||||
' 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(' 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 () => {
|
||||
|
@@ -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');
|
||||
|
||||
|
@@ -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)}'`);
|
||||
|
@@ -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,
|
||||
|
@@ -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;
|
||||
|
6
packages/server/.gitignore
vendored
6
packages/server/.gitignore
vendored
@@ -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
|
||||
|
@@ -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"
|
||||
}
|
||||
}
|
||||
|
@@ -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:
|
||||
//
|
||||
|
147
packages/server/src/utils/e2ee/index.test.ts
Normal file
147
packages/server/src/utils/e2ee/index.test.ts
Normal 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
|
||||
|
||||

|
||||
|
||||
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('');
|
||||
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:  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\'>');
|
||||
});
|
||||
|
||||
});
|
277
packages/server/src/utils/e2ee/index.ts
Normal file
277
packages/server/src/utils/e2ee/index.ts
Normal 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 };
|
@@ -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 });
|
||||
|
@@ -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) {
|
||||
|
35
packages/server/webpack.config.js
Normal file
35
packages/server/webpack.config.js
Normal 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
229
yarn.lock
@@ -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"
|
||||
|
Reference in New Issue
Block a user