You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-12-08 23:07:32 +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",
|
"tagServerLatest": "node packages/tools/tagServerLatest.js",
|
||||||
"buildServerDocker": "node packages/tools/buildServerDocker.js",
|
"buildServerDocker": "node packages/tools/buildServerDocker.js",
|
||||||
"setupNewRelease": "node ./packages/tools/setupNewRelease",
|
"setupNewRelease": "node ./packages/tools/setupNewRelease",
|
||||||
"test-ci": "yarn workspaces foreach --parallel --verbose --interlaced --jobs 2 run test-ci",
|
"test-ci": "BROWSERSLIST_IGNORE_OLD_DATA=1 yarn workspaces foreach --parallel --verbose --interlaced --jobs 2 run test-ci",
|
||||||
"test": "yarn workspaces foreach --parallel --verbose --interlaced --jobs 2 run test",
|
"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",
|
"tsc": "yarn workspaces foreach --parallel --verbose --interlaced run tsc",
|
||||||
"updateIgnored": "gulp updateIgnoredTypeScriptBuild",
|
"updateIgnored": "gulp updateIgnoredTypeScriptBuild",
|
||||||
"updatePluginTypes": "./packages/generator-joplin/updateTypes.sh",
|
"updatePluginTypes": "./packages/generator-joplin/updateTypes.sh",
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ const { escapeHtml } = require('./string-utils.js');
|
|||||||
|
|
||||||
// [\s\S] instead of . for multiline matching
|
// [\s\S] instead of . for multiline matching
|
||||||
// https://stackoverflow.com/a/16119722/561309
|
// https://stackoverflow.com/a/16119722/561309
|
||||||
const imageRegex = /<img([\s\S]*?)src=["']([\s\S]*?)["']([\s\S]*?)>/gi;
|
export const imageRegex = /<img([\s\S]*?)src=["']([\s\S]*?)["']([\s\S]*?)>/gi;
|
||||||
const anchorRegex = /<a([\s\S]*?)href=["']([\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 embedRegex = /<embed([\s\S]*?)src=["']([\s\S]*?)["']([\s\S]*?)>/gi;
|
||||||
const objectRegex = /<object([\s\S]*?)data=["']([\s\S]*?)["']([\s\S]*?)>/gi;
|
const objectRegex = /<object([\s\S]*?)data=["']([\s\S]*?)["']([\s\S]*?)>/gi;
|
||||||
const pdfUrlRegex = /[\s\S]*?\.pdf$/i;
|
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 stringPadding = require('string-padding');
|
||||||
const urlUtils = require('./urlUtils');
|
const urlUtils = require('./urlUtils');
|
||||||
const MarkdownIt = require('markdown-it');
|
const MarkdownIt = require('markdown-it');
|
||||||
@@ -25,6 +26,19 @@ export interface MarkdownTableRow {
|
|||||||
[key: string]: string;
|
[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 = {
|
const markdownUtils = {
|
||||||
// Titles for markdown links only need escaping for [ and ]
|
// Titles for markdown links only need escaping for [ and ]
|
||||||
escapeTitleText(text: string) {
|
escapeTitleText(text: string) {
|
||||||
@@ -69,28 +83,65 @@ const markdownUtils = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Returns the **encoded** URLs, so to be useful they should be decoded again before use.
|
// Returns the **encoded** URLs, so to be useful they should be decoded again before use.
|
||||||
extractFileUrls(md: string, onlyType: string = null): Array<string> {
|
extractFileUrls(md: string, options: ExtractFileUrlsOptions = null): string[] | ExtractFileUrlsResult[] {
|
||||||
const markdownIt = new MarkdownIt();
|
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
|
markdownIt.validateLink = validateLinks; // Necessary to support file:/// links
|
||||||
|
|
||||||
const env = {};
|
const env = {};
|
||||||
const tokens = markdownIt.parse(md, env);
|
const tokens = markdownIt.parse(md, env);
|
||||||
const output: string[] = [];
|
const output: ExtractFileUrlsResult[] = [];
|
||||||
|
|
||||||
let linkType = onlyType;
|
|
||||||
if (linkType === 'pdf') linkType = 'link_open';
|
|
||||||
|
|
||||||
const searchUrls = (tokens: any[]) => {
|
const searchUrls = (tokens: any[]) => {
|
||||||
for (let i = 0; i < tokens.length; i++) {
|
for (let i = 0; i < tokens.length; i++) {
|
||||||
const token = tokens[i];
|
const token = tokens[i];
|
||||||
if ((!onlyType && (token.type === 'link_open' || token.type === 'image')) || (!!onlyType && token.type === onlyType) || (onlyType === 'pdf' && token.type === 'link_open')) {
|
const type: string = token.type;
|
||||||
// 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 (type === 'image' || type === 'link_open') {
|
||||||
if (onlyType === 'pdf' && !(tokens.length > i + 1 && tokens[i + 1].type === 'text' && tokens[i + 1].content === 'embedded_pdf')) continue;
|
// 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++) {
|
for (let j = 0; j < token.attrs.length; j++) {
|
||||||
const a = token.attrs[j];
|
const a = token.attrs[j];
|
||||||
if ((a[0] === 'src' || a[0] === 'href') && a.length >= 2 && a[1]) {
|
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);
|
searchUrls(tokens);
|
||||||
|
|
||||||
return output;
|
if (options.detailedResults) {
|
||||||
|
return output;
|
||||||
|
} else {
|
||||||
|
return output.map(r => r.url);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
replaceResourceUrl(md: string, urlToReplace: string, id: string) {
|
replaceResourceUrl(md: string, urlToReplace: string, id: string) {
|
||||||
@@ -113,11 +168,18 @@ const markdownUtils = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
extractImageUrls(md: string) {
|
extractImageUrls(md: string) {
|
||||||
return markdownUtils.extractFileUrls(md, 'image');
|
return markdownUtils.extractFileUrls(md, {
|
||||||
|
includeImages: true,
|
||||||
|
includeAnchors: false,
|
||||||
|
}) as string[];
|
||||||
},
|
},
|
||||||
|
|
||||||
extractPdfUrls(md: 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
|
// The match results has 5 items
|
||||||
|
|||||||
@@ -67,17 +67,50 @@ describe('markdownUtils', function() {
|
|||||||
['[something](testing.html)', ['testing.html']],
|
['[something](testing.html)', ['testing.html']],
|
||||||
['[something](img.png)', ['img.png']],
|
['[something](img.png)', ['img.png']],
|
||||||
['[something](file://img.png)', ['file://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++) {
|
for (let i = 0; i < testCases.length; i++) {
|
||||||
const md = testCases[i][0] as string;
|
const md = testCases[i][0] as string;
|
||||||
const actual = markdownUtils.extractFileUrls(md);
|
const actual = markdownUtils.extractFileUrls(md, { html: true });
|
||||||
const expected = testCases[i][1];
|
const expected = testCases[i][1];
|
||||||
|
|
||||||
expect(actual.join(' ')).toBe((expected as string[]).join(' '));
|
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 () => {
|
it('escape a markdown link', (async () => {
|
||||||
|
|
||||||
const testCases = [
|
const testCases = [
|
||||||
|
|||||||
@@ -19,20 +19,38 @@ const mime = {
|
|||||||
return mime.fromFileExtension(splitted[splitted.length - 1]);
|
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) {
|
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();
|
mimeType = mimeType.toLowerCase();
|
||||||
for (let i = 0; i < mimeTypes.length; i++) {
|
for (let i = 0; i < mimeTypes.length; i++) {
|
||||||
const t = mimeTypes[i];
|
const t = mimeTypes[i];
|
||||||
if (mimeType === t.t) {
|
if (mimeType === t.t) {
|
||||||
// Return the first file extension that is 3 characters long
|
return t.e;
|
||||||
// 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 null;
|
return [];
|
||||||
},
|
},
|
||||||
|
|
||||||
fromDataUrl(dataUrl) {
|
fromDataUrl(dataUrl) {
|
||||||
|
|||||||
@@ -20,4 +20,12 @@ describe('mimeUils', function() {
|
|||||||
expect(mimeUtils.fromFilename('test')).toBe(null);
|
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 { ModelType, DeleteOptions } from '../BaseModel';
|
||||||
import { BaseItemEntity, NoteEntity } from '../services/database/types';
|
import { BaseItemEntity } from '../services/database/types';
|
||||||
import Setting from './Setting';
|
import Setting from './Setting';
|
||||||
import BaseModel from '../BaseModel';
|
import BaseModel from '../BaseModel';
|
||||||
import time from '../time';
|
import time from '../time';
|
||||||
@@ -19,6 +19,19 @@ export interface ItemsThatNeedDecryptionResult {
|
|||||||
items: any[];
|
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 {
|
export interface ItemThatNeedSync {
|
||||||
id: string;
|
id: string;
|
||||||
sync_time: number;
|
sync_time: number;
|
||||||
@@ -251,7 +264,7 @@ export default class BaseItem extends BaseModel {
|
|||||||
let conflictNoteIds: string[] = [];
|
let conflictNoteIds: string[] = [];
|
||||||
if (this.modelType() === BaseModel.TYPE_NOTE) {
|
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`);
|
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;
|
return n.id;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -474,7 +487,12 @@ export default class BaseItem extends BaseModel {
|
|||||||
return ItemClass.save(plainItem, { autoTimestamp: false, changeSource: ItemChange.SOURCE_DECRYPTION });
|
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');
|
const lines = content.split('\n');
|
||||||
let output: any = {};
|
let output: any = {};
|
||||||
let state = 'readingProps';
|
let state = 'readingProps';
|
||||||
@@ -512,11 +530,13 @@ export default class BaseItem extends BaseModel {
|
|||||||
if (output.type_ === BaseModel.TYPE_NOTE) output.body = body.join('\n');
|
if (output.type_ === BaseModel.TYPE_NOTE) output.body = body.join('\n');
|
||||||
|
|
||||||
const ItemClass = this.itemClass(output.type_);
|
const ItemClass = this.itemClass(output.type_);
|
||||||
output = ItemClass.removeUnknownFields(output);
|
if (!options.noDb) output = ItemClass.removeUnknownFields(output);
|
||||||
|
|
||||||
for (const n in output) {
|
if (!options.noDb) {
|
||||||
if (!output.hasOwnProperty(n)) continue;
|
for (const n in output) {
|
||||||
output[n] = await this.unserialize_format(output.type_, n, output[n]);
|
if (!output.hasOwnProperty(n)) continue;
|
||||||
|
output[n] = await this.unserialize_format(output.type_, n, output[n]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return output;
|
return output;
|
||||||
|
|||||||
@@ -2,13 +2,11 @@ import { BaseItemEntity } from '../../services/database/types';
|
|||||||
import { StateShare } from '../../services/share/reducer';
|
import { StateShare } from '../../services/share/reducer';
|
||||||
|
|
||||||
export default function(item: BaseItemEntity, share: StateShare): boolean {
|
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,
|
// 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
|
// 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
|
// view the note anymore. https://github.com/laurent22/joplin/issues/6645
|
||||||
if (item.share_id && (!share || !share.master_key_id)) return false;
|
if (item.share_id && (!share || !share.master_key_id)) return false;
|
||||||
|
|
||||||
|
// All items can now be encrypted, including published notes
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
"tsc": "tsc --project tsconfig.json",
|
"tsc": "tsc --project tsconfig.json",
|
||||||
"watch": "tsc --watch --preserveWatchOutput --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",
|
"generatePluginTypes": "rm -rf ./plugin_types && yarn run tsc --declaration --declarationDir ./plugin_types --project tsconfig.json",
|
||||||
"test": "jest --verbose=false",
|
"test": "BROWSERSLIST_IGNORE_OLD_DATA=1 jest --verbose=false",
|
||||||
"test-ci": "yarn test"
|
"test-ci": "BROWSERSLIST_IGNORE_OLD_DATA=1 yarn test"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/fs-extra": "^9.0.6",
|
"@types/fs-extra": "^9.0.6",
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
"@types/nanoid": "^3.0.0",
|
"@types/nanoid": "^3.0.0",
|
||||||
"async-mutex": "^0.1.3",
|
"async-mutex": "^0.1.3",
|
||||||
"base-64": "^0.1.0",
|
"base-64": "^0.1.0",
|
||||||
|
"base64-arraybuffer": "^1.0.2",
|
||||||
"base64-stream": "^1.0.0",
|
"base64-stream": "^1.0.0",
|
||||||
"builtin-modules": "^3.1.0",
|
"builtin-modules": "^3.1.0",
|
||||||
"chokidar": "^3.4.3",
|
"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 Folder from '../../models/Folder';
|
||||||
import Note from '../../models/Note';
|
import Note from '../../models/Note';
|
||||||
import Setting from '../../models/Setting';
|
import Setting from '../../models/Setting';
|
||||||
import BaseItem from '../../models/BaseItem';
|
import BaseItem from '../../models/BaseItem';
|
||||||
import MasterKey from '../../models/MasterKey';
|
import MasterKey from '../../models/MasterKey';
|
||||||
import EncryptionService, { EncryptionMethod } from './EncryptionService';
|
import EncryptionService, { EncryptionHeader, EncryptionMethod } from './EncryptionService';
|
||||||
import { setEncryptionEnabled } from '../synchronizer/syncInfoUtils';
|
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;
|
let service: EncryptionService = null;
|
||||||
|
|
||||||
describe('services_EncryptionService', function() {
|
describe('services/e2ee/EncryptionService', function() {
|
||||||
|
|
||||||
beforeEach(async (done) => {
|
beforeEach(async (done) => {
|
||||||
await setupDatabaseAndSynchronizer(1);
|
await setupDatabaseAndSynchronizer(1);
|
||||||
@@ -20,17 +34,11 @@ describe('services_EncryptionService', function() {
|
|||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should encode and decode header', (async () => {
|
it('should decode header v1', (async () => {
|
||||||
const header = {
|
const encodedHeader = 'JED0100002205c24138199f5b403fa3e9b8b4f22685c500027c';
|
||||||
encryptionMethod: EncryptionMethod.SJCL,
|
const decodedHeader: EncryptionHeader = service.decodeHeaderBytes_(encodedHeader);
|
||||||
masterKeyId: '01234568abcdefgh01234568abcdefgh',
|
expect(decodedHeader.encryptionMethod).toBe(5);
|
||||||
};
|
expect(decodedHeader.masterKeyId).toBe('c24138199f5b403fa3e9b8b4f22685c5');
|
||||||
|
|
||||||
const encodedHeader = service.encodeHeader_(header);
|
|
||||||
const decodedHeader = service.decodeHeaderBytes_(encodedHeader);
|
|
||||||
delete decodedHeader.length;
|
|
||||||
|
|
||||||
expect(objectsEqual(header, decodedHeader)).toBe(true);
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
it('should generate and decrypt a master key', (async () => {
|
it('should generate and decrypt a master key', (async () => {
|
||||||
@@ -245,12 +253,7 @@ describe('services_EncryptionService', function() {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
it('should encrypt and decrypt files', (async () => {
|
it('should encrypt and decrypt files', (async () => {
|
||||||
let masterKey = await service.generateMasterKey('123456');
|
const { sourcePath, encryptedPath } = await encryptFile(service);
|
||||||
masterKey = await MasterKey.save(masterKey);
|
|
||||||
await service.loadMasterKey(masterKey, '123456', true);
|
|
||||||
|
|
||||||
const sourcePath = `${supportDir}/photo.jpg`;
|
|
||||||
const encryptedPath = `${Setting.value('tempDir')}/photo.crypted`;
|
|
||||||
const decryptedPath = `${Setting.value('tempDir')}/photo.jpg`;
|
const decryptedPath = `${Setting.value('tempDir')}/photo.jpg`;
|
||||||
|
|
||||||
await service.encryptFile(sourcePath, encryptedPath);
|
await service.encryptFile(sourcePath, encryptedPath);
|
||||||
@@ -260,6 +263,14 @@ describe('services_EncryptionService', function() {
|
|||||||
expect(fileContentEqual(sourcePath, decryptedPath)).toBe(true);
|
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 () => {
|
it('should encrypt invalid UTF-8 data', (async () => {
|
||||||
let masterKey = await service.generateMasterKey('123456');
|
let masterKey = await service.generateMasterKey('123456');
|
||||||
masterKey = await MasterKey.save(masterKey);
|
masterKey = await MasterKey.save(masterKey);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import MasterKey from '../../models/MasterKey';
|
|||||||
import BaseItem from '../../models/BaseItem';
|
import BaseItem from '../../models/BaseItem';
|
||||||
import JoplinError from '../../JoplinError';
|
import JoplinError from '../../JoplinError';
|
||||||
import { getActiveMasterKeyId, setActiveMasterKeyId } from '../synchronizer/syncInfoUtils';
|
import { getActiveMasterKeyId, setActiveMasterKeyId } from '../synchronizer/syncInfoUtils';
|
||||||
|
import * as base64 from 'base64-arraybuffer';
|
||||||
const { padLeft } = require('../../string-utils.js');
|
const { padLeft } = require('../../string-utils.js');
|
||||||
|
|
||||||
const logger = Logger.create('EncryptionService');
|
const logger = Logger.create('EncryptionService');
|
||||||
@@ -40,11 +41,33 @@ export enum EncryptionMethod {
|
|||||||
Custom = 6,
|
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 {
|
export interface EncryptOptions {
|
||||||
encryptionMethod?: EncryptionMethod;
|
encryptionMethod?: EncryptionMethod;
|
||||||
onProgress?: Function;
|
onProgress?: Function;
|
||||||
encryptionHandler?: EncryptionCustomHandler;
|
encryptionHandler?: EncryptionCustomHandler;
|
||||||
masterKeyId?: string;
|
masterKeyId?: string;
|
||||||
|
appId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class EncryptionService {
|
export default class EncryptionService {
|
||||||
@@ -71,13 +94,13 @@ export default class EncryptionService {
|
|||||||
public defaultEncryptionMethod_ = EncryptionMethod.SJCL1a; // public because used in tests
|
public defaultEncryptionMethod_ = EncryptionMethod.SJCL1a; // public because used in tests
|
||||||
private defaultMasterKeyEncryptionMethod_ = EncryptionMethod.SJCL4;
|
private defaultMasterKeyEncryptionMethod_ = EncryptionMethod.SJCL4;
|
||||||
|
|
||||||
private headerTemplates_ = {
|
// private headerTemplates_ = {
|
||||||
// Template version 1
|
// // Template version 1
|
||||||
1: {
|
// 1: {
|
||||||
// Fields are defined as [name, valueSize, valueType]
|
// // Fields are defined as [name, valueSize, valueType]
|
||||||
fields: [['encryptionMethod', 2, 'int'], ['masterKeyId', 32, 'hex']],
|
// fields: [['encryptionMethod', 2, 'int'], ['masterKeyId', 32, 'hex']],
|
||||||
},
|
// },
|
||||||
};
|
// };
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// Note: 1 MB is very slow with Node and probably even worse on mobile.
|
// 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.decryptedMasterKeys_ = {};
|
||||||
this.defaultEncryptionMethod_ = EncryptionMethod.SJCL1a;
|
this.defaultEncryptionMethod_ = EncryptionMethod.SJCL1a;
|
||||||
this.defaultMasterKeyEncryptionMethod_ = EncryptionMethod.SJCL4;
|
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() {
|
public static instance() {
|
||||||
@@ -253,7 +268,7 @@ export default class EncryptionService {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
model.created_time = now;
|
model.created_time = now;
|
||||||
model.updated_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;
|
model.hasBeenUsed = false;
|
||||||
|
|
||||||
return model;
|
return model;
|
||||||
@@ -420,7 +435,7 @@ export default class EncryptionService {
|
|||||||
const masterKeyId = options.masterKeyId ? options.masterKeyId : this.activeMasterKeyId();
|
const masterKeyId = options.masterKeyId ? options.masterKeyId : this.activeMasterKeyId();
|
||||||
const masterKeyPlainText = this.loadedMasterKey(masterKeyId).plainText;
|
const masterKeyPlainText = this.loadedMasterKey(masterKeyId).plainText;
|
||||||
|
|
||||||
const header = {
|
const header: EncryptionHeader = {
|
||||||
encryptionMethod: method,
|
encryptionMethod: method,
|
||||||
masterKeyId: masterKeyId,
|
masterKeyId: masterKeyId,
|
||||||
};
|
};
|
||||||
@@ -450,7 +465,7 @@ export default class EncryptionService {
|
|||||||
async decryptAbstract_(source: any, destination: any, options: EncryptOptions = null) {
|
async decryptAbstract_(source: any, destination: any, options: EncryptOptions = null) {
|
||||||
if (!options) options = {};
|
if (!options) options = {};
|
||||||
|
|
||||||
const header: any = await this.decodeHeaderSource_(source);
|
const header = await this.decodeHeaderSource_(source);
|
||||||
const masterKeyPlainText = this.loadedMasterKey(header.masterKeyId).plainText;
|
const masterKeyPlainText = this.loadedMasterKey(header.masterKeyId).plainText;
|
||||||
|
|
||||||
let doneSize = 0;
|
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
|
if (!length) continue; // Weird but could be not completely invalid (block of size 0) so continue decrypting
|
||||||
|
|
||||||
doneSize += length;
|
doneSize += length;
|
||||||
if (options.onProgress) options.onProgress({ doneSize: doneSize });
|
if (options.onProgress) options.onProgress({ doneSize });
|
||||||
|
|
||||||
await shim.waitForFrame();
|
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);
|
const plainText = await this.decrypt(header.encryptionMethod, masterKeyPlainText, block);
|
||||||
await destination.append(plainText);
|
await destination.append(plainText);
|
||||||
@@ -497,6 +512,9 @@ export default class EncryptionService {
|
|||||||
return output.data.join('');
|
return output.data.join('');
|
||||||
},
|
},
|
||||||
close: function() {},
|
close: function() {},
|
||||||
|
size: async () => {
|
||||||
|
return output.data.join('').length;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
@@ -521,9 +539,18 @@ export default class EncryptionService {
|
|||||||
return this.fsDriver().appendFile(path, data, encoding);
|
return this.fsDriver().appendFile(path, data, encoding);
|
||||||
},
|
},
|
||||||
close: function() {},
|
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> {
|
public async encryptString(plainText: any, options: EncryptOptions = null): Promise<string> {
|
||||||
const source = this.stringReader_(plainText);
|
const source = this.stringReader_(plainText);
|
||||||
const destination = this.stringWriter_();
|
const destination = this.stringWriter_();
|
||||||
@@ -531,14 +558,19 @@ export default class EncryptionService {
|
|||||||
return destination.result();
|
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 source = this.stringReader_(cipherText);
|
||||||
const destination = this.stringWriter_();
|
const destination = this.stringWriter_();
|
||||||
await this.decryptAbstract_(source, destination, options);
|
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 source = await this.fileReader_(srcPath, 'base64');
|
||||||
let destination = await this.fileWriter_(destPath, 'ascii');
|
let destination = await this.fileWriter_(destPath, 'ascii');
|
||||||
|
|
||||||
@@ -563,7 +595,7 @@ export default class EncryptionService {
|
|||||||
await cleanUp();
|
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 source = await this.fileReader_(srcPath, 'ascii');
|
||||||
let destination = await this.fileWriter_(destPath, 'base64');
|
let destination = await this.fileWriter_(destPath, 'base64');
|
||||||
|
|
||||||
@@ -588,39 +620,77 @@ export default class EncryptionService {
|
|||||||
await cleanUp();
|
await cleanUp();
|
||||||
}
|
}
|
||||||
|
|
||||||
headerTemplate(version: number) {
|
// This can be used to decrypt a string that has been encoded using
|
||||||
const r = (this.headerTemplates_ as any)[version];
|
// encryptFile(). For example when download the encrypted file and
|
||||||
if (!r) throw new Error(`Unknown header version: ${version}`);
|
// decrypting the content to memory (without writing to a file as in
|
||||||
return r;
|
// 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) {
|
private headerTemplate(version: number) {
|
||||||
// Sanity check
|
// This deprecated function should only be called with v1 headers
|
||||||
if (header.masterKeyId.length !== 32) throw new Error(`Invalid master key ID size: ${header.masterKeyId}`);
|
if (version !== 1) throw new Error(`Unsupported header version: ${version}`);
|
||||||
|
|
||||||
let encryptionMetadata = '';
|
return {
|
||||||
encryptionMetadata += padLeft(header.encryptionMethod.toString(16), 2, '0');
|
// Fields are defined as [name, valueSize, valueType]
|
||||||
encryptionMetadata += header.masterKeyId;
|
fields: [['encryptionMethod', 2, 'int'], ['masterKeyId', 32, 'hex']],
|
||||||
encryptionMetadata = padLeft(encryptionMetadata.length.toString(16), 6, '0') + encryptionMetadata;
|
};
|
||||||
return `JED01${encryptionMetadata}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
const source = this.stringReader_(cipherText);
|
||||||
return this.decodeHeaderSource_(source);
|
return this.decodeHeaderSource_(source);
|
||||||
}
|
}
|
||||||
|
|
||||||
async decodeHeaderSource_(source: any) {
|
async decodeHeaderSource_(source: any): Promise<EncryptionHeader> {
|
||||||
const identifier = await source.read(5);
|
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');
|
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 mdSizeHex = await source.read(6);
|
||||||
const mdSize = parseInt(mdSizeHex, 16);
|
const mdSize = parseInt(mdSizeHex, 16);
|
||||||
if (isNaN(mdSize) || !mdSize) throw new Error(`Invalid header metadata size: ${mdSizeHex}`);
|
if (isNaN(mdSize) || !mdSize) throw new Error(`Invalid header metadata size: ${mdSizeHex}`);
|
||||||
const md = await source.read(parseInt(mdSizeHex, 16));
|
const md = await source.read(mdSize);
|
||||||
return this.decodeHeaderBytes_(identifier + mdSizeHex + md);
|
|
||||||
|
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 reader: any = this.stringReader_(headerHexaBytes, true);
|
||||||
const identifier = reader.read(3);
|
const identifier = reader.read(3);
|
||||||
const version = parseInt(reader.read(2), 16);
|
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) {
|
async importLocalFiles(filePath: string, md: string, parentFolderId: string) {
|
||||||
let updated = md;
|
let updated = md;
|
||||||
const markdownLinks = markdownUtils.extractFileUrls(md);
|
const markdownLinks = markdownUtils.extractFileUrls(md) as string[];
|
||||||
const htmlLinks = htmlUtils.extractFileUrls(md);
|
const htmlLinks = htmlUtils.extractFileUrls(md);
|
||||||
const fileLinks = unique(markdownLinks.concat(htmlLinks));
|
const fileLinks = unique(markdownLinks.concat(htmlLinks));
|
||||||
await Promise.all(fileLinks.map(async (encodedLink: string) => {
|
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
|
// 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)
|
// 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
|
// will appear encoded in the source. Other links (unicode chars) will not
|
||||||
const linksToReplace = [this.trimAnchorLink(link), this.trimAnchorLink(encodedLink)];
|
const linksToReplace = [this.trimAnchorLink(link), this.trimAnchorLink(encodedLink)];
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const createNoteForPagination = async (numOrTitle: number | string, time: number
|
|||||||
|
|
||||||
let api: Api = null;
|
let api: Api = null;
|
||||||
|
|
||||||
describe('services_rest_Api', function() {
|
describe('services/rest/Api', function() {
|
||||||
|
|
||||||
beforeEach(async (done) => {
|
beforeEach(async (done) => {
|
||||||
api = new Api();
|
api = new Api();
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ const shim = {
|
|||||||
throw new Error('Not implemented');
|
throw new Error('Not implemented');
|
||||||
},
|
},
|
||||||
|
|
||||||
waitForFrame: () => {
|
waitForFrame: (): any => {
|
||||||
throw new Error('Not implemented');
|
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
|
// 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.
|
// 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');
|
throw new Error('Not implemented');
|
||||||
},
|
},
|
||||||
|
|
||||||
setInterval: (_fn: Function, _interval: number) => {
|
setInterval: (_fn: Function, _interval: number): any => {
|
||||||
throw new Error('Not implemented');
|
throw new Error('Not implemented');
|
||||||
},
|
},
|
||||||
|
|
||||||
clearTimeout: (_id: any) => {
|
clearTimeout: (_id: any): any => {
|
||||||
throw new Error('Not implemented');
|
throw new Error('Not implemented');
|
||||||
},
|
},
|
||||||
|
|
||||||
clearInterval: (_id: any) => {
|
clearInterval: (_id: any): any => {
|
||||||
throw new Error('Not implemented');
|
throw new Error('Not implemented');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -762,7 +762,8 @@ async function allSyncTargetItemsEncrypted() {
|
|||||||
if (remoteContent.type_ === BaseModel.TYPE_RESOURCE) {
|
if (remoteContent.type_ === BaseModel.TYPE_RESOURCE) {
|
||||||
const content = await fileApi().get(`.resource/${remoteContent.id}`);
|
const content = await fileApi().get(`.resource/${remoteContent.id}`);
|
||||||
totalCount++;
|
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++;
|
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) {
|
urlUtils.extractResourceUrls = function(text) {
|
||||||
const markdownLinksRE = /\]\((.*?)\)/g;
|
const mdRegexes = [
|
||||||
const output = [];
|
{
|
||||||
let result = null;
|
regex: /!\[.*?\]\((.*?)\)/g,
|
||||||
|
type: 2, // image (matches LinkType)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
regex: /\[.*?\]\((.*?)\)/g,
|
||||||
|
type: 1, // anchor
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
while ((result = markdownLinksRE.exec(text)) !== null) {
|
const output = [];
|
||||||
const resourceUrlInfo = urlUtils.parseResourceUrl(result[1]);
|
|
||||||
if (resourceUrlInfo) output.push(resourceUrlInfo);
|
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 = [
|
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) {
|
for (const htmlRegex of htmlRegexes) {
|
||||||
while (true) {
|
while (true) {
|
||||||
const m = htmlRegex.exec(text);
|
const m = htmlRegex.regex.exec(text);
|
||||||
if (!m) break;
|
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
|
// - It always returns paths with forward slashes "/". This is normally handled
|
||||||
// properly everywhere.
|
// 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) {
|
function fileUriToPath_(uri, platform) {
|
||||||
const sep = '/';
|
const sep = '/';
|
||||||
|
|
||||||
|
|||||||
@@ -54,12 +54,34 @@ describe('urlUtils', function() {
|
|||||||
|
|
||||||
it('should extract resource URLs', (async () => {
|
it('should extract resource URLs', (async () => {
|
||||||
const testCases = [
|
const testCases = [
|
||||||
['Bla [](:/11111111111111111111111111111111) bla [](:/22222222222222222222222222222222) bla', ['11111111111111111111111111111111', '22222222222222222222222222222222']],
|
[
|
||||||
['Bla [](:/11111111111111111111111111111111 "Some title") bla [](:/22222222222222222222222222222222 "something else") bla', ['11111111111111111111111111111111', '22222222222222222222222222222222']],
|
'Bla [](:/11111111111111111111111111111111) bla [](:/22222222222222222222222222222222) bla',
|
||||||
['Bla <img src=":/fcca2938a96a22570e8eae2565bc6b0b"/> bla [](:/22222222222222222222222222222222) bla', ['fcca2938a96a22570e8eae2565bc6b0b', '22222222222222222222222222222222']],
|
['11111111111111111111111111111111', '22222222222222222222222222222222'],
|
||||||
['Bla <img src=":/fcca2938a96a22570e8eae2565bc6b0b"/> bla <a href=":/33333333333333333333333333333333"/>Some note link</a> blu [](:/22222222222222222222222222222222) bla', ['fcca2938a96a22570e8eae2565bc6b0b', '33333333333333333333333333333333', '22222222222222222222222222222222']],
|
],
|
||||||
['nothing here', []],
|
[
|
||||||
['', []],
|
'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) {
|
for (const t of testCases) {
|
||||||
@@ -69,6 +91,19 @@ describe('urlUtils', function() {
|
|||||||
const itemIds = result.map(r => r.itemId);
|
const itemIds = result.map(r => r.itemId);
|
||||||
expect(itemIds.sort().join(',')).toBe(expected.sort().join(','));
|
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 () => {
|
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,
|
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 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
|
// The keys must match the corresponding entry in Setting.js
|
||||||
const plugins: RendererPlugins = {
|
const plugins: RendererPlugins = {
|
||||||
mark: { module: require('markdown-it-mark') },
|
mark: { module: defaultify(require('markdown-it-mark')) },
|
||||||
footnote: { module: require('markdown-it-footnote') },
|
footnote: { module: defaultify(require('markdown-it-footnote')) },
|
||||||
sub: { module: require('markdown-it-sub') },
|
sub: { module: defaultify(require('markdown-it-sub')) },
|
||||||
sup: { module: require('markdown-it-sup') },
|
sup: { module: defaultify(require('markdown-it-sup')) },
|
||||||
deflist: { module: require('markdown-it-deflist') },
|
deflist: { module: defaultify(require('markdown-it-deflist')) },
|
||||||
abbr: { module: require('markdown-it-abbr') },
|
abbr: { module: defaultify(require('markdown-it-abbr')) },
|
||||||
emoji: { module: require('markdown-it-emoji') },
|
emoji: { module: defaultify(require('markdown-it-emoji')) },
|
||||||
insert: { module: require('markdown-it-ins') },
|
insert: { module: defaultify(require('markdown-it-ins')) },
|
||||||
multitable: { module: require('markdown-it-multimd-table'), options: { multiline: true, rowspan: true, headerless: true } },
|
multitable: { module: defaultify(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 } },
|
toc: { module: defaultify(require('markdown-it-toc-done-right')), options: { listType: 'ul', slugify: slugify } },
|
||||||
expand_tabs: { module: require('markdown-it-expand-tabs'), options: { tabWidth: 4 } },
|
expand_tabs: { module: defaultify(require('markdown-it-expand-tabs')), options: { tabWidth: 4 } },
|
||||||
};
|
};
|
||||||
const defaultNoteStyle = require('./defaultNoteStyle');
|
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 Entities = require('html-entities').AllHtmlEntities;
|
||||||
const htmlentities = new Entities().encode;
|
const htmlentities = new Entities().encode;
|
||||||
const urlUtils = require('../urlUtils.js');
|
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;
|
let resourceFullPath = resource && options?.ResourceModel?.fullPath ? options.ResourceModel.fullPath(resource) : null;
|
||||||
|
|
||||||
if (resourceId && options.itemIdToUrl) {
|
if (resourceId && options.itemIdToUrl) {
|
||||||
const url = options.itemIdToUrl(resourceId);
|
const r = options.itemIdToUrl(resourceId, LinkType.Anchor);
|
||||||
attrHtml.push(`href='${htmlentities(url)}'`);
|
const response: ItemIdToUrlResponse = typeof r === 'string' ? { url: r, attributes: {} } : r;
|
||||||
resourceFullPath = url;
|
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) {
|
} else if (options.plainResourceRendering || options.linkRenderingType === 2) {
|
||||||
icon = '';
|
icon = '';
|
||||||
attrHtml.push(`href='${htmlentities(href)}'`);
|
attrHtml.push(`href='${htmlentities(href)}'`);
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import validateLinks from './MdToHtml/validateLinks';
|
|||||||
import headerAnchor from './headerAnchor';
|
import headerAnchor from './headerAnchor';
|
||||||
const assetsToHeaders = require('./assetsToHeaders');
|
const assetsToHeaders = require('./assetsToHeaders');
|
||||||
|
|
||||||
|
export enum LinkType {
|
||||||
|
Anchor = 1,
|
||||||
|
Image = 2,
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
MarkupToHtml,
|
MarkupToHtml,
|
||||||
MarkupLanguage,
|
MarkupLanguage,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { LinkType } from '.';
|
||||||
|
|
||||||
const Entities = require('html-entities').AllHtmlEntities;
|
const Entities = require('html-entities').AllHtmlEntities;
|
||||||
const htmlentities = new Entities().encode;
|
const htmlentities = new Entities().encode;
|
||||||
|
|
||||||
@@ -122,7 +124,12 @@ utils.resourceStatus = function(ResourceModel: any, resourceInfo: any) {
|
|||||||
return resourceStatus;
|
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) {
|
utils.imageReplacement = function(ResourceModel: any, src: string, resources: any, resourceBaseUrl: string, itemIdToUrl: ItemIdToUrlHandler = null) {
|
||||||
if (!ResourceModel || !resources) return null;
|
if (!ResourceModel || !resources) return null;
|
||||||
@@ -138,13 +145,15 @@ utils.imageReplacement = function(ResourceModel: any, src: string, resources: an
|
|||||||
const icon = utils.resourceStatusImage(resourceStatus);
|
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>';
|
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) {
|
if (itemIdToUrl) {
|
||||||
newSrc = itemIdToUrl(resource.id);
|
return {
|
||||||
} else {
|
'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 = [];
|
const temp = [];
|
||||||
|
|
||||||
if (resourceBaseUrl) {
|
if (resourceBaseUrl) {
|
||||||
@@ -156,15 +165,13 @@ utils.imageReplacement = function(ResourceModel: any, src: string, resources: an
|
|||||||
temp.push(ResourceModel.filename(resource));
|
temp.push(ResourceModel.filename(resource));
|
||||||
temp.push(`?t=${resource.updated_time}`);
|
temp.push(`?t=${resource.updated_time}`);
|
||||||
|
|
||||||
newSrc = temp.join('');
|
const newSrc = temp.join('');
|
||||||
}
|
|
||||||
|
|
||||||
// let newSrc = `./${ResourceModel.filename(resource)}`;
|
return {
|
||||||
// newSrc += `?t=${resource.updated_time}`;
|
'data-resource-id': resource.id,
|
||||||
return {
|
src: newSrc,
|
||||||
'data-resource-id': resource.id,
|
};
|
||||||
src: newSrc,
|
}
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
6
packages/server/.gitignore
vendored
6
packages/server/.gitignore
vendored
@@ -7,4 +7,8 @@ db-*.sqlite
|
|||||||
logs/
|
logs/
|
||||||
tests/temp/
|
tests/temp/
|
||||||
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",
|
"test-debug": "node --inspect node_modules/.bin/jest -- --verbose=false",
|
||||||
"clean": "gulp clean",
|
"clean": "gulp clean",
|
||||||
"stripeListen": "stripe listen --forward-to http://joplincloud.local:22300/stripe/webhook",
|
"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"
|
"watch": "tsc --watch --preserveWatchOutput --project tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -70,12 +72,17 @@
|
|||||||
"@types/nodemailer": "^6.4.1",
|
"@types/nodemailer": "^6.4.1",
|
||||||
"@types/yargs": "^17.0.4",
|
"@types/yargs": "^17.0.4",
|
||||||
"@types/zxcvbn": "^4.4.1",
|
"@types/zxcvbn": "^4.4.1",
|
||||||
|
"crypto-browserify": "^3.12.0",
|
||||||
"gulp": "^4.0.2",
|
"gulp": "^4.0.2",
|
||||||
"jest": "^26.6.3",
|
"jest": "^26.6.3",
|
||||||
"jest-expect-message": "^1.0.2",
|
"jest-expect-message": "^1.0.2",
|
||||||
"jsdom": "^16.4.0",
|
"jsdom": "^16.4.0",
|
||||||
"node-mocks-http": "^1.10.0",
|
"node-mocks-http": "^1.10.0",
|
||||||
"source-map-support": "^0.5.13",
|
"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 { NotificationKey } from './NotificationModel';
|
||||||
import prettyBytes = require('pretty-bytes');
|
import prettyBytes = require('pretty-bytes');
|
||||||
import { Env } from '../utils/types';
|
import { Env } from '../utils/types';
|
||||||
|
import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types';
|
||||||
|
|
||||||
const logger = Logger.create('UserModel');
|
const logger = Logger.create('UserModel');
|
||||||
|
|
||||||
@@ -618,6 +619,14 @@ export default class UserModel extends BaseModel<User> {
|
|||||||
return syncInfo.ppk?.value || null;
|
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
|
// Note that when the "password" property is provided, it is going to be
|
||||||
// hashed automatically. It means that it is not safe to do:
|
// 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 { formatDateTime } from './time';
|
||||||
import { ErrorBadRequest, ErrorForbidden, ErrorNotFound } from './errors';
|
import { ErrorBadRequest, ErrorForbidden, ErrorNotFound } from './errors';
|
||||||
import { MarkupToHtml } from '@joplin/renderer';
|
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';
|
import { isValidHeaderIdentifier } from '@joplin/lib/services/e2ee/EncryptionService';
|
||||||
const { DatabaseDriverNode } = require('@joplin/lib/database-driver-node.js');
|
|
||||||
import { themeStyle } from '@joplin/lib/theme';
|
import { themeStyle } from '@joplin/lib/theme';
|
||||||
import Setting from '@joplin/lib/models/Setting';
|
import Setting from '@joplin/lib/models/Setting';
|
||||||
import { Models } from '../models/factory';
|
import { Models } from '../models/factory';
|
||||||
import MustacheService from '../services/MustacheService';
|
import MustacheService from '../services/MustacheService';
|
||||||
import Logger from '@joplin/lib/Logger';
|
import Logger from '@joplin/lib/Logger';
|
||||||
import config from '../config';
|
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';
|
import { TreeItem } from '../models/ItemResourceModel';
|
||||||
const { substrWithEllipsis } = require('@joplin/lib/string-utils');
|
const { substrWithEllipsis } = require('@joplin/lib/string-utils');
|
||||||
|
const { DatabaseDriverNode } = require('@joplin/lib/database-driver-node.js');
|
||||||
const logger = Logger.create('JoplinUtils');
|
const logger = Logger.create('JoplinUtils');
|
||||||
|
|
||||||
export interface FileViewerResponse {
|
export interface FileViewerResponse {
|
||||||
@@ -216,13 +217,41 @@ async function renderNote(share: Share, note: NoteEntity, resourceInfos: Resourc
|
|||||||
linkRenderingType: 2,
|
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({
|
const bodyHtml = await mustache_.renderView({
|
||||||
cssFiles: ['items/note'],
|
cssFiles: ['items/note'],
|
||||||
jsFiles: ['items/note'],
|
jsFiles: [
|
||||||
|
'items/note',
|
||||||
|
'bundle_e2ee',
|
||||||
|
],
|
||||||
name: 'note',
|
name: 'note',
|
||||||
title: `${substrWithEllipsis(note.title, 0, 100)} - ${config().appName}`,
|
title: `${substrWithEllipsis(noteTitle, 0, 100)} - ${config().appName}`,
|
||||||
titleOverride: true,
|
titleOverride: true,
|
||||||
path: 'index/items/note',
|
path: 'index/items/note',
|
||||||
content: {
|
content: {
|
||||||
@@ -288,6 +317,7 @@ const isInTree = (itemTree: TreeItem, jopId: string) => {
|
|||||||
interface RenderItemQuery {
|
interface RenderItemQuery {
|
||||||
resource_id?: string;
|
resource_id?: string;
|
||||||
note_id?: string;
|
note_id?: string;
|
||||||
|
resource_metadata?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// "item" is always the item associated with the share (the "root item"). It may
|
// "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 fileToRender: FileToRender;
|
||||||
let itemToRender: any = null;
|
let itemToRender: any = null;
|
||||||
|
|
||||||
|
let itemToRenderType: ModelType = item.jop_type;
|
||||||
|
|
||||||
if (query.resource_id) {
|
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
|
// 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
|
// Render a linked note
|
||||||
// ------------------------------------------------------------------------------------------
|
// ------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
if (!share.recursive) throw new ErrorForbidden('This linked note has not been published');
|
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 });
|
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.
|
// when it runs.
|
||||||
const packageRootDir = path.dirname(path.dirname(path.dirname(__dirname)));
|
const packageRootDir = path.dirname(path.dirname(path.dirname(__dirname)));
|
||||||
|
|
||||||
|
export const supportDir = `${path.dirname(packageRootDir)}/app-cli/tests/support`;
|
||||||
|
|
||||||
let db_: DbConnection = null;
|
let db_: DbConnection = null;
|
||||||
|
|
||||||
// require('source-map-support').install();
|
// require('source-map-support').install();
|
||||||
@@ -61,10 +63,10 @@ export async function makeTempFileWithContent(content: string | Buffer): Promise
|
|||||||
return filePath;
|
return filePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
function initGlobalLogger() {
|
export const initGlobalLogger = () => {
|
||||||
const globalLogger = new Logger();
|
const globalLogger = new Logger();
|
||||||
Logger.initializeGlobalLogger(globalLogger);
|
Logger.initializeGlobalLogger(globalLogger);
|
||||||
}
|
};
|
||||||
|
|
||||||
let createdDbPath_: string = null;
|
let createdDbPath_: string = null;
|
||||||
export async function beforeAllDb(unitName: string, createDbOptions: CreateDbOptions = 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
|
"@types/react": ^17.0.20
|
||||||
async-mutex: ^0.1.3
|
async-mutex: ^0.1.3
|
||||||
base-64: ^0.1.0
|
base-64: ^0.1.0
|
||||||
|
base64-arraybuffer: ^1.0.2
|
||||||
base64-stream: ^1.0.0
|
base64-stream: ^1.0.0
|
||||||
builtin-modules: ^3.1.0
|
builtin-modules: ^3.1.0
|
||||||
chokidar: ^3.4.3
|
chokidar: ^3.4.3
|
||||||
@@ -4469,6 +4470,7 @@ __metadata:
|
|||||||
bulma: ^0.9.1
|
bulma: ^0.9.1
|
||||||
bulma-prefers-dark: ^0.1.0-beta.0
|
bulma-prefers-dark: ^0.1.0-beta.0
|
||||||
compare-versions: ^3.6.0
|
compare-versions: ^3.6.0
|
||||||
|
crypto-browserify: ^3.12.0
|
||||||
dayjs: ^1.9.8
|
dayjs: ^1.9.8
|
||||||
formidable: ^1.2.2
|
formidable: ^1.2.2
|
||||||
fs-extra: ^8.1.0
|
fs-extra: ^8.1.0
|
||||||
@@ -4498,7 +4500,11 @@ __metadata:
|
|||||||
sqlite3: ^4.1.0
|
sqlite3: ^4.1.0
|
||||||
stripe: ^8.150.0
|
stripe: ^8.150.0
|
||||||
typescript: 4.1.2
|
typescript: 4.1.2
|
||||||
|
url: ^0.11.0
|
||||||
uuid: ^8.3.2
|
uuid: ^8.3.2
|
||||||
|
webpack: ^5.72.1
|
||||||
|
webpack-bundle-analyzer: ^4.5.0
|
||||||
|
webpack-cli: ^4.9.2
|
||||||
yargs: ^17.2.1
|
yargs: ^17.2.1
|
||||||
zxcvbn: ^4.4.2
|
zxcvbn: ^4.4.2
|
||||||
languageName: unknown
|
languageName: unknown
|
||||||
@@ -5785,6 +5791,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@popperjs/core@npm:^2.4.0":
|
||||||
version: 2.11.0
|
version: 2.11.0
|
||||||
resolution: "@popperjs/core@npm:2.11.0"
|
resolution: "@popperjs/core@npm:2.11.0"
|
||||||
@@ -7642,6 +7655,16 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@webpack-cli/configtest@npm:^1.2.0":
|
||||||
version: 1.2.0
|
version: 1.2.0
|
||||||
resolution: "@webpack-cli/configtest@npm:1.2.0"
|
resolution: "@webpack-cli/configtest@npm:1.2.0"
|
||||||
@@ -7663,6 +7686,17 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@webpack-cli/info@npm:^1.5.0":
|
||||||
version: 1.5.0
|
version: 1.5.0
|
||||||
resolution: "@webpack-cli/info@npm:1.5.0"
|
resolution: "@webpack-cli/info@npm:1.5.0"
|
||||||
@@ -7686,6 +7720,18 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@webpack-cli/serve@npm:^1.7.0":
|
||||||
version: 1.7.0
|
version: 1.7.0
|
||||||
resolution: "@webpack-cli/serve@npm:1.7.0"
|
resolution: "@webpack-cli/serve@npm:1.7.0"
|
||||||
@@ -7854,7 +7900,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"acorn-walk@npm:^8.1.1":
|
"acorn-walk@npm:^8.0.0, acorn-walk@npm:^8.1.1":
|
||||||
version: 8.2.0
|
version: 8.2.0
|
||||||
resolution: "acorn-walk@npm:8.2.0"
|
resolution: "acorn-walk@npm:8.2.0"
|
||||||
checksum: 1715e76c01dd7b2d4ca472f9c58968516a4899378a63ad5b6c2d668bba8da21a71976c14ec5f5b75f887b6317c4ae0b897ab141c831d741dc76024d8745f1ad1
|
checksum: 1715e76c01dd7b2d4ca472f9c58968516a4899378a63ad5b6c2d668bba8da21a71976c14ec5f5b75f887b6317c4ae0b897ab141c831d741dc76024d8745f1ad1
|
||||||
@@ -7897,6 +7943,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"acorn@npm:^8.2.4, acorn@npm:^8.4.1":
|
||||||
version: 8.6.0
|
version: 8.6.0
|
||||||
resolution: "acorn@npm:8.6.0"
|
resolution: "acorn@npm:8.6.0"
|
||||||
@@ -7906,15 +7961,6 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"acorn@npm:^8.7.1, acorn@npm:^8.8.0":
|
||||||
version: 8.8.0
|
version: 8.8.0
|
||||||
resolution: "acorn@npm:8.8.0"
|
resolution: "acorn@npm:8.8.0"
|
||||||
@@ -9623,6 +9669,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"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
|
version: 1.5.1
|
||||||
resolution: "base64-js@npm:1.5.1"
|
resolution: "base64-js@npm:1.5.1"
|
||||||
@@ -11488,7 +11541,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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
|
version: 7.2.0
|
||||||
resolution: "commander@npm:7.2.0"
|
resolution: "commander@npm:7.2.0"
|
||||||
checksum: 53501cbeee61d5157546c0bef0fedb6cdfc763a882136284bed9a07225f09a14b82d2a84e7637edfd1a679fb35ed9502fd58ef1d091e6287f60d790147f68ddc
|
checksum: 53501cbeee61d5157546c0bef0fedb6cdfc763a882136284bed9a07225f09a14b82d2a84e7637edfd1a679fb35ed9502fd58ef1d091e6287f60d790147f68ddc
|
||||||
@@ -14280,7 +14333,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"duplexer@npm:^0.1.1":
|
"duplexer@npm:^0.1.1, duplexer@npm:^0.1.2":
|
||||||
version: 0.1.2
|
version: 0.1.2
|
||||||
resolution: "duplexer@npm:0.1.2"
|
resolution: "duplexer@npm:0.1.2"
|
||||||
checksum: 62ba61a830c56801db28ff6305c7d289b6dc9f859054e8c982abd8ee0b0a14d2e9a8e7d086ffee12e868d43e2bbe8a964be55ddbd8c8957714c87373c7a4f9b0
|
checksum: 62ba61a830c56801db28ff6305c7d289b6dc9f859054e8c982abd8ee0b0a14d2e9a8e7d086ffee12e868d43e2bbe8a964be55ddbd8c8957714c87373c7a4f9b0
|
||||||
@@ -14662,6 +14715,16 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"enquirer@npm:^2.3.6":
|
||||||
version: 2.3.6
|
version: 2.3.6
|
||||||
resolution: "enquirer@npm:2.3.6"
|
resolution: "enquirer@npm:2.3.6"
|
||||||
@@ -18112,6 +18175,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"handlebars@npm:^4.0.3, handlebars@npm:^4.7.6, handlebars@npm:^4.7.7":
|
||||||
version: 4.7.7
|
version: 4.7.7
|
||||||
resolution: "handlebars@npm:4.7.7"
|
resolution: "handlebars@npm:4.7.7"
|
||||||
@@ -23318,7 +23390,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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
|
version: 4.17.21
|
||||||
resolution: "lodash@npm:4.17.21"
|
resolution: "lodash@npm:4.17.21"
|
||||||
checksum: eb835a2e51d381e561e508ce932ea50a8e5a68f4ebdd771ea240d3048244a8d13658acbd502cd4829768c56f2e16bdd4340b9ea141297d472517b83868e677f7
|
checksum: eb835a2e51d381e561e508ce932ea50a8e5a68f4ebdd771ea240d3048244a8d13658acbd502cd4829768c56f2e16bdd4340b9ea141297d472517b83868e677f7
|
||||||
@@ -25000,6 +25072,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"ms@npm:2.0.0":
|
||||||
version: 2.0.0
|
version: 2.0.0
|
||||||
resolution: "ms@npm:2.0.0"
|
resolution: "ms@npm:2.0.0"
|
||||||
@@ -26294,7 +26373,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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
|
version: 1.5.2
|
||||||
resolution: "opener@npm:1.5.2"
|
resolution: "opener@npm:1.5.2"
|
||||||
bin:
|
bin:
|
||||||
@@ -30795,6 +30874,17 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"sisteransi@npm:^1.0.5":
|
||||||
version: 1.0.5
|
version: 1.0.5
|
||||||
resolution: "sisteransi@npm:1.0.5"
|
resolution: "sisteransi@npm:1.0.5"
|
||||||
@@ -32978,6 +33068,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"touch@npm:^2.0.1":
|
||||||
version: 2.0.2
|
version: 2.0.2
|
||||||
resolution: "touch@npm:2.0.2"
|
resolution: "touch@npm:2.0.2"
|
||||||
@@ -34706,6 +34803,25 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"webpack-cli@npm:^4.10.0":
|
||||||
version: 4.10.0
|
version: 4.10.0
|
||||||
resolution: "webpack-cli@npm:4.10.0"
|
resolution: "webpack-cli@npm:4.10.0"
|
||||||
@@ -34772,6 +34888,39 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"webpack-merge@npm:^5.7.3":
|
||||||
version: 5.8.0
|
version: 5.8.0
|
||||||
resolution: "webpack-merge@npm:5.8.0"
|
resolution: "webpack-merge@npm:5.8.0"
|
||||||
@@ -34833,6 +34982,43 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"webpack@npm:^5.73.0, webpack@npm:^5.74.0":
|
||||||
version: 5.74.0
|
version: 5.74.0
|
||||||
resolution: "webpack@npm:5.74.0"
|
resolution: "webpack@npm:5.74.0"
|
||||||
@@ -35345,6 +35531,21 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"ws@npm:^8.2.3":
|
||||||
version: 8.8.0
|
version: 8.8.0
|
||||||
resolution: "ws@npm:8.8.0"
|
resolution: "ws@npm:8.8.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user