You've already forked joplin
							
							
				mirror of
				https://github.com/laurent22/joplin.git
				synced 2025-10-31 00:07:48 +02:00 
			
		
		
		
	All: Security: Fixed potential Arbitrary File Read via XSS
This commit is contained in:
		| @@ -55,3 +55,4 @@ ElectronClient/app/gui/ShareNoteDialog.js | ||||
| ReactNativeClient/lib/JoplinServerApi.js | ||||
| ReactNativeClient/PluginAssetsLoader.js | ||||
| ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/mermaid.js | ||||
| ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/sanitize_html.js | ||||
|   | ||||
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -51,3 +51,4 @@ ElectronClient/app/gui/ShareNoteDialog.js | ||||
| ReactNativeClient/lib/JoplinServerApi.js | ||||
| ReactNativeClient/PluginAssetsLoader.js | ||||
| ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/mermaid.js | ||||
| ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/sanitize_html.js | ||||
|   | ||||
							
								
								
									
										14
									
								
								CliClient/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										14
									
								
								CliClient/package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -2667,6 +2667,11 @@ | ||||
|       "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", | ||||
|       "integrity": "sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4=" | ||||
|     }, | ||||
|     "memory-cache": { | ||||
|       "version": "0.2.0", | ||||
|       "resolved": "https://registry.npmjs.org/memory-cache/-/memory-cache-0.2.0.tgz", | ||||
|       "integrity": "sha1-eJCwHVLADI68nVM+H46xfjA0hxo=" | ||||
|     }, | ||||
|     "micromatch": { | ||||
|       "version": "3.1.10", | ||||
|       "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", | ||||
| @@ -2920,6 +2925,15 @@ | ||||
|         "is-stream": "^1.0.1" | ||||
|       } | ||||
|     }, | ||||
|     "node-html-parser": { | ||||
|       "version": "1.2.4", | ||||
|       "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-1.2.4.tgz", | ||||
|       "integrity": "sha512-qHwPdGyGr9pOZBoSgUOuNPG20QYZVN00lFcxKQgjPUODSxVH7obQeLVVawa3B4cfSNtLIeczSzoy/xYA8XG5WQ==", | ||||
|       "dev": true, | ||||
|       "requires": { | ||||
|         "he": "1.1.1" | ||||
|       } | ||||
|     }, | ||||
|     "node-persist": { | ||||
|       "version": "2.1.0", | ||||
|       "resolved": "https://registry.npmjs.org/node-persist/-/node-persist-2.1.0.tgz", | ||||
|   | ||||
| @@ -72,6 +72,7 @@ | ||||
|     "markdown-it-toc-done-right": "^4.1.0", | ||||
|     "md5": "^2.2.1", | ||||
|     "md5-file": "^4.0.0", | ||||
|     "memory-cache": "^0.2.0", | ||||
|     "mime": "^2.0.3", | ||||
|     "moment": "^2.24.0", | ||||
|     "multiparty": "^4.2.1", | ||||
| @@ -104,7 +105,8 @@ | ||||
|     "valid-url": "^1.0.9", | ||||
|     "word-wrap": "^1.2.3", | ||||
|     "xml2js": "^0.4.19", | ||||
|     "yargs-parser": "^7.0.0" | ||||
|     "yargs-parser": "^7.0.0", | ||||
|     "node-html-parser": "^1.2.4" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "jasmine": "^3.5.0" | ||||
|   | ||||
							
								
								
									
										79
									
								
								CliClient/tests/HtmlToHtml.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								CliClient/tests/HtmlToHtml.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,79 @@ | ||||
| /* eslint-disable no-unused-vars */ | ||||
|  | ||||
| require('app-module-path').addPath(__dirname); | ||||
|  | ||||
| const os = require('os'); | ||||
| const { time } = require('lib/time-utils.js'); | ||||
| const { filename } = require('lib/path-utils.js'); | ||||
| const { asyncTest, fileContentEqual, setupDatabase, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync } = require('test-utils.js'); | ||||
| const Folder = require('lib/models/Folder.js'); | ||||
| const Note = require('lib/models/Note.js'); | ||||
| const BaseModel = require('lib/BaseModel.js'); | ||||
| const { shim } = require('lib/shim'); | ||||
| const HtmlToHtml = require('lib/joplin-renderer/HtmlToHtml'); | ||||
| const { enexXmlToMd } = require('lib/import-enex-md-gen.js'); | ||||
|  | ||||
| jasmine.DEFAULT_TIMEOUT_INTERVAL = 60 * 60 * 1000; // Can run for a while since everything is in the same test unit | ||||
|  | ||||
| process.on('unhandledRejection', (reason, p) => { | ||||
| 	console.log('Unhandled Rejection at: Promise', p, 'reason:', reason); | ||||
| }); | ||||
|  | ||||
| describe('HtmlToHtml', function() { | ||||
|  | ||||
| 	beforeEach(async (done) => { | ||||
| 		await setupDatabaseAndSynchronizer(1); | ||||
| 		await switchClient(1); | ||||
| 		done(); | ||||
| 	}); | ||||
|  | ||||
| 	it('should convert from Html to Html', asyncTest(async () => { | ||||
| 		const basePath = `${__dirname}/html_to_html`; | ||||
| 		const files = await shim.fsDriver().readDirStats(basePath); | ||||
| 		const htmlToHtml = new HtmlToHtml(); | ||||
|  | ||||
| 		for (let i = 0; i < files.length; i++) { | ||||
| 			const htmlSourceFilename = files[i].path; | ||||
| 			if (htmlSourceFilename.indexOf('.src.html') < 0) continue; | ||||
|  | ||||
| 			const htmlSourceFilePath = `${basePath}/${htmlSourceFilename}`; | ||||
| 			const htmlDestPath = `${basePath}/${filename(filename(htmlSourceFilePath))}.dest.html`; | ||||
|  | ||||
| 			// if (htmlSourceFilename !== 'table_with_header.html') continue; | ||||
|  | ||||
| 			const htmlToHtmlOptions = { | ||||
| 				bodyOnly: true, | ||||
| 			}; | ||||
|  | ||||
| 			const sourceHtml = await shim.fsDriver().readFile(htmlSourceFilePath); | ||||
| 			let expectedHtml = await shim.fsDriver().readFile(htmlDestPath); | ||||
|  | ||||
| 			const result = await htmlToHtml.render(sourceHtml, null, htmlToHtmlOptions); | ||||
| 			let actualHtml = result.html; | ||||
|  | ||||
| 			if (os.EOL === '\r\n') { | ||||
| 				expectedHtml = expectedHtml.replace(/\r\n/g, '\n'); | ||||
| 				actualHtml = actualHtml.replace(/\r\n/g, '\n'); | ||||
| 			} | ||||
|  | ||||
| 			if (actualHtml !== expectedHtml) { | ||||
| 				console.info(''); | ||||
| 				console.info(`Error converting file: ${htmlSourceFilename}`); | ||||
| 				console.info('--------------------------------- Got:'); | ||||
| 				console.info(actualHtml); | ||||
| 				console.info('--------------------------------- Raw:'); | ||||
| 				console.info(actualHtml.split('\n')); | ||||
| 				console.info('--------------------------------- Expected:'); | ||||
| 				console.info(expectedHtml.split('\n')); | ||||
| 				console.info('--------------------------------------------'); | ||||
| 				console.info(''); | ||||
|  | ||||
| 				expect(false).toBe(true); | ||||
| 				// return; | ||||
| 			} else { | ||||
| 				expect(true).toBe(true); | ||||
| 			} | ||||
| 		} | ||||
| 	})); | ||||
|  | ||||
| }); | ||||
							
								
								
									
										79
									
								
								CliClient/tests/MdToHtml.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								CliClient/tests/MdToHtml.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,79 @@ | ||||
| /* eslint-disable no-unused-vars */ | ||||
|  | ||||
| require('app-module-path').addPath(__dirname); | ||||
|  | ||||
| const os = require('os'); | ||||
| const { time } = require('lib/time-utils.js'); | ||||
| const { filename } = require('lib/path-utils.js'); | ||||
| const { asyncTest, fileContentEqual, setupDatabase, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync } = require('test-utils.js'); | ||||
| const Folder = require('lib/models/Folder.js'); | ||||
| const Note = require('lib/models/Note.js'); | ||||
| const BaseModel = require('lib/BaseModel.js'); | ||||
| const { shim } = require('lib/shim'); | ||||
| const MdToHtml = require('lib/joplin-renderer/MdToHtml'); | ||||
| const { enexXmlToMd } = require('lib/import-enex-md-gen.js'); | ||||
|  | ||||
| jasmine.DEFAULT_TIMEOUT_INTERVAL = 60 * 60 * 1000; // Can run for a while since everything is in the same test unit | ||||
|  | ||||
| process.on('unhandledRejection', (reason, p) => { | ||||
| 	console.log('Unhandled Rejection at: Promise', p, 'reason:', reason); | ||||
| }); | ||||
|  | ||||
| describe('MdToHtml', function() { | ||||
|  | ||||
| 	beforeEach(async (done) => { | ||||
| 		await setupDatabaseAndSynchronizer(1); | ||||
| 		await switchClient(1); | ||||
| 		done(); | ||||
| 	}); | ||||
|  | ||||
| 	it('should convert from Markdown to Html', asyncTest(async () => { | ||||
| 		const basePath = `${__dirname}/md_to_html`; | ||||
| 		const files = await shim.fsDriver().readDirStats(basePath); | ||||
| 		const mdToHtml = new MdToHtml(); | ||||
|  | ||||
| 		for (let i = 0; i < files.length; i++) { | ||||
| 			const mdFilename = files[i].path; | ||||
| 			if (mdFilename.indexOf('.md') < 0) continue; | ||||
|  | ||||
| 			const mdFilePath = `${basePath}/${mdFilename}`; | ||||
| 			const htmlPath = `${basePath}/${filename(mdFilePath)}.html`; | ||||
|  | ||||
| 			// if (mdFilename !== 'table_with_header.html') continue; | ||||
|  | ||||
| 			const mdToHtmlOptions = { | ||||
| 				bodyOnly: true, | ||||
| 			}; | ||||
|  | ||||
| 			const markdown = await shim.fsDriver().readFile(mdFilePath); | ||||
| 			let expectedHtml = await shim.fsDriver().readFile(htmlPath); | ||||
|  | ||||
| 			const result = await mdToHtml.render(markdown, null, mdToHtmlOptions); | ||||
| 			let actualHtml = result.html; | ||||
|  | ||||
| 			if (os.EOL === '\r\n') { | ||||
| 				expectedHtml = expectedHtml.replace(/\r\n/g, '\n'); | ||||
| 				actualHtml = actualHtml.replace(/\r\n/g, '\n'); | ||||
| 			} | ||||
|  | ||||
| 			if (actualHtml !== expectedHtml) { | ||||
| 				console.info(''); | ||||
| 				console.info(`Error converting file: ${mdFilename}`); | ||||
| 				console.info('--------------------------------- Got:'); | ||||
| 				console.info(actualHtml); | ||||
| 				console.info('--------------------------------- Raw:'); | ||||
| 				console.info(actualHtml.split('\n')); | ||||
| 				console.info('--------------------------------- Expected:'); | ||||
| 				console.info(expectedHtml.split('\n')); | ||||
| 				console.info('--------------------------------------------'); | ||||
| 				console.info(''); | ||||
|  | ||||
| 				expect(false).toBe(true); | ||||
| 				// return; | ||||
| 			} else { | ||||
| 				expect(true).toBe(true); | ||||
| 			} | ||||
| 		} | ||||
| 	})); | ||||
|  | ||||
| }); | ||||
							
								
								
									
										2
									
								
								CliClient/tests/html_to_html/sanitize.dest.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								CliClient/tests/html_to_html/sanitize.dest.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| <img src onerror="" /> | ||||
| <img src onerror="" /> | ||||
							
								
								
									
										3
									
								
								CliClient/tests/html_to_html/sanitize.src.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								CliClient/tests/html_to_html/sanitize.src.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| <img src="" onerror="alert('ohno')"/> | ||||
| <img src="" | ||||
|    onerror="alert('ohno')"/> | ||||
							
								
								
									
										2
									
								
								CliClient/tests/md_to_html/sanitize.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								CliClient/tests/md_to_html/sanitize.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| <img src onerror="" /> | ||||
| <img src onerror="" /> | ||||
							
								
								
									
										3
									
								
								CliClient/tests/md_to_html/sanitize.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								CliClient/tests/md_to_html/sanitize.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| <img src="" onerror="alert('ohno')"/> | ||||
| <img src="" | ||||
|    onerror="alert('ohno')"/> | ||||
| @@ -21,7 +21,7 @@ | ||||
| 	function absoluteUrl(url) { | ||||
| 		if (!url) return url; | ||||
| 		const protocol = url.toLowerCase().split(':')[0]; | ||||
| 		if (['http', 'https', 'file'].indexOf(protocol) >= 0) return url; | ||||
| 		if (['http', 'https', 'file', 'data'].indexOf(protocol) >= 0) return url; | ||||
|  | ||||
| 		if (url.indexOf('//') === 0) { | ||||
| 			return location.protocol + url; | ||||
|   | ||||
							
								
								
									
										156
									
								
								ElectronClient/app/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										156
									
								
								ElectronClient/app/package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -2233,10 +2233,68 @@ | ||||
|         "minimist": "^1.1.1" | ||||
|       }, | ||||
|       "dependencies": { | ||||
|         "domelementtype": { | ||||
|           "version": "1.3.1", | ||||
|           "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz", | ||||
|           "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==" | ||||
|         }, | ||||
|         "domhandler": { | ||||
|           "version": "2.4.2", | ||||
|           "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz", | ||||
|           "integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==", | ||||
|           "requires": { | ||||
|             "domelementtype": "1" | ||||
|           } | ||||
|         }, | ||||
|         "domutils": { | ||||
|           "version": "1.7.0", | ||||
|           "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz", | ||||
|           "integrity": "sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==", | ||||
|           "requires": { | ||||
|             "dom-serializer": "0", | ||||
|             "domelementtype": "1" | ||||
|           } | ||||
|         }, | ||||
|         "htmlparser2": { | ||||
|           "version": "3.10.1", | ||||
|           "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz", | ||||
|           "integrity": "sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==", | ||||
|           "requires": { | ||||
|             "domelementtype": "^1.3.1", | ||||
|             "domhandler": "^2.3.0", | ||||
|             "domutils": "^1.5.1", | ||||
|             "entities": "^1.1.1", | ||||
|             "inherits": "^2.0.1", | ||||
|             "readable-stream": "^3.1.1" | ||||
|           } | ||||
|         }, | ||||
|         "minimist": { | ||||
|           "version": "1.2.0", | ||||
|           "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", | ||||
|           "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=" | ||||
|         }, | ||||
|         "readable-stream": { | ||||
|           "version": "3.6.0", | ||||
|           "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", | ||||
|           "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", | ||||
|           "requires": { | ||||
|             "inherits": "^2.0.3", | ||||
|             "string_decoder": "^1.1.1", | ||||
|             "util-deprecate": "^1.0.1" | ||||
|           } | ||||
|         }, | ||||
|         "safe-buffer": { | ||||
|           "version": "5.2.0", | ||||
|           "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz", | ||||
|           "integrity": "sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg==" | ||||
|         }, | ||||
|         "string_decoder": { | ||||
|           "version": "1.3.0", | ||||
|           "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", | ||||
|           "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", | ||||
|           "requires": { | ||||
|             "safe-buffer": "~5.2.0" | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
| @@ -3158,19 +3216,14 @@ | ||||
|       } | ||||
|     }, | ||||
|     "dom-serializer": { | ||||
|       "version": "0.2.1", | ||||
|       "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.1.tgz", | ||||
|       "integrity": "sha512-sK3ujri04WyjwQXVoK4PU3y8ula1stq10GJZpqHIUgoGZdsGzAGu65BnU3d08aTVSvO7mGPZUc0wTEDL+qGE0Q==", | ||||
|       "version": "0.2.2", | ||||
|       "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz", | ||||
|       "integrity": "sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==", | ||||
|       "requires": { | ||||
|         "domelementtype": "^2.0.1", | ||||
|         "entities": "^2.0.0" | ||||
|       }, | ||||
|       "dependencies": { | ||||
|         "domelementtype": { | ||||
|           "version": "2.0.1", | ||||
|           "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.0.1.tgz", | ||||
|           "integrity": "sha512-5HOHUDsYZWV8FGWN0Njbr/Rn7f/eWSQi1v7+HsUVwXgn8nWWlL64zKDkS0n8ZmQ3mlWOMuXOnR+7Nx/5tMO5AQ==" | ||||
|         }, | ||||
|         "entities": { | ||||
|           "version": "2.0.0", | ||||
|           "resolved": "https://registry.npmjs.org/entities/-/entities-2.0.0.tgz", | ||||
| @@ -3179,9 +3232,9 @@ | ||||
|       } | ||||
|     }, | ||||
|     "domelementtype": { | ||||
|       "version": "1.3.1", | ||||
|       "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz", | ||||
|       "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==" | ||||
|       "version": "2.0.1", | ||||
|       "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.0.1.tgz", | ||||
|       "integrity": "sha512-5HOHUDsYZWV8FGWN0Njbr/Rn7f/eWSQi1v7+HsUVwXgn8nWWlL64zKDkS0n8ZmQ3mlWOMuXOnR+7Nx/5tMO5AQ==" | ||||
|     }, | ||||
|     "domexception": { | ||||
|       "version": "1.0.1", | ||||
| @@ -3192,20 +3245,21 @@ | ||||
|       } | ||||
|     }, | ||||
|     "domhandler": { | ||||
|       "version": "2.4.2", | ||||
|       "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz", | ||||
|       "integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==", | ||||
|       "version": "3.0.0", | ||||
|       "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-3.0.0.tgz", | ||||
|       "integrity": "sha512-eKLdI5v9m67kbXQbJSNn1zjh0SDzvzWVWtX+qEI3eMjZw8daH9k8rlj1FZY9memPwjiskQFbe7vHVVJIAqoEhw==", | ||||
|       "requires": { | ||||
|         "domelementtype": "1" | ||||
|         "domelementtype": "^2.0.1" | ||||
|       } | ||||
|     }, | ||||
|     "domutils": { | ||||
|       "version": "1.7.0", | ||||
|       "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz", | ||||
|       "integrity": "sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==", | ||||
|       "version": "2.0.0", | ||||
|       "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.0.0.tgz", | ||||
|       "integrity": "sha512-n5SelJ1axbO636c2yUtOGia/IcJtVtlhQbFiVDBZHKV5ReJO1ViX7sFEemtuyoAnBxk5meNSYgA8V4s0271efg==", | ||||
|       "requires": { | ||||
|         "dom-serializer": "0", | ||||
|         "domelementtype": "1" | ||||
|         "dom-serializer": "^0.2.1", | ||||
|         "domelementtype": "^2.0.1", | ||||
|         "domhandler": "^3.0.0" | ||||
|       } | ||||
|     }, | ||||
|     "dot-prop": { | ||||
| @@ -4640,40 +4694,20 @@ | ||||
|       } | ||||
|     }, | ||||
|     "htmlparser2": { | ||||
|       "version": "3.10.1", | ||||
|       "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz", | ||||
|       "integrity": "sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==", | ||||
|       "version": "4.0.0", | ||||
|       "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-4.0.0.tgz", | ||||
|       "integrity": "sha512-cChwXn5Vam57fyXajDtPXL1wTYc8JtLbr2TN76FYu05itVVVealxLowe2B3IEznJG4p9HAYn/0tJaRlGuEglFQ==", | ||||
|       "requires": { | ||||
|         "domelementtype": "^1.3.1", | ||||
|         "domhandler": "^2.3.0", | ||||
|         "domutils": "^1.5.1", | ||||
|         "entities": "^1.1.1", | ||||
|         "inherits": "^2.0.1", | ||||
|         "readable-stream": "^3.1.1" | ||||
|         "domelementtype": "^2.0.1", | ||||
|         "domhandler": "^3.0.0", | ||||
|         "domutils": "^2.0.0", | ||||
|         "entities": "^2.0.0" | ||||
|       }, | ||||
|       "dependencies": { | ||||
|         "readable-stream": { | ||||
|           "version": "3.4.0", | ||||
|           "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.4.0.tgz", | ||||
|           "integrity": "sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ==", | ||||
|           "requires": { | ||||
|             "inherits": "^2.0.3", | ||||
|             "string_decoder": "^1.1.1", | ||||
|             "util-deprecate": "^1.0.1" | ||||
|           } | ||||
|         }, | ||||
|         "safe-buffer": { | ||||
|           "version": "5.2.0", | ||||
|           "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz", | ||||
|           "integrity": "sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg==" | ||||
|         }, | ||||
|         "string_decoder": { | ||||
|           "version": "1.3.0", | ||||
|           "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", | ||||
|           "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", | ||||
|           "requires": { | ||||
|             "safe-buffer": "~5.2.0" | ||||
|           } | ||||
|         "entities": { | ||||
|           "version": "2.0.0", | ||||
|           "resolved": "https://registry.npmjs.org/entities/-/entities-2.0.0.tgz", | ||||
|           "integrity": "sha512-D9f7V0JSRwIxlRI2mjMqufDrRDnx8p+eEOz7aUM9SuvF8gsBzra0/6tbjl1m8eQHrZlYj6PxqE00hZ1SAIKPLw==" | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
| @@ -5582,6 +5616,11 @@ | ||||
|       "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.0.4.tgz", | ||||
|       "integrity": "sha512-P0z5IeAH6qHHGkJIXWw0xC2HNEgkx/9uWWBQw64FJj3/ol14VYdfVGWWr0fXfjhhv3TKVIqUq65os6O4GUNksA==" | ||||
|     }, | ||||
|     "memory-cache": { | ||||
|       "version": "0.2.0", | ||||
|       "resolved": "https://registry.npmjs.org/memory-cache/-/memory-cache-0.2.0.tgz", | ||||
|       "integrity": "sha1-eJCwHVLADI68nVM+H46xfjA0hxo=" | ||||
|     }, | ||||
|     "mermaid": { | ||||
|       "version": "8.4.6", | ||||
|       "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-8.4.6.tgz", | ||||
| @@ -6183,6 +6222,21 @@ | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "node-html-parser": { | ||||
|       "version": "1.2.4", | ||||
|       "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-1.2.4.tgz", | ||||
|       "integrity": "sha512-qHwPdGyGr9pOZBoSgUOuNPG20QYZVN00lFcxKQgjPUODSxVH7obQeLVVawa3B4cfSNtLIeczSzoy/xYA8XG5WQ==", | ||||
|       "requires": { | ||||
|         "he": "1.1.1" | ||||
|       }, | ||||
|       "dependencies": { | ||||
|         "he": { | ||||
|           "version": "1.1.1", | ||||
|           "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz", | ||||
|           "integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0=" | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "node-notifier": { | ||||
|       "version": "6.0.0", | ||||
|       "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-6.0.0.tgz", | ||||
|   | ||||
| @@ -131,11 +131,13 @@ | ||||
|     "markdown-it-toc-done-right": "^4.1.0", | ||||
|     "md5": "^2.2.1", | ||||
|     "md5-file": "^4.0.0", | ||||
|     "memory-cache": "^0.2.0", | ||||
|     "mermaid": "^8.4.6", | ||||
|     "moment": "^2.22.2", | ||||
|     "multiparty": "^4.2.1", | ||||
|     "mustache": "^3.0.1", | ||||
|     "node-fetch": "^1.7.3", | ||||
|     "node-html-parser": "^1.2.4", | ||||
|     "node-notifier": "^6.0.0", | ||||
|     "promise": "^8.0.1", | ||||
|     "query-string": "^5.1.1", | ||||
|   | ||||
| @@ -5,8 +5,8 @@ BUILD_DIR="$ROOT_DIR/app" | ||||
|  | ||||
| rsync -a --delete "$ROOT_DIR/../ReactNativeClient/lib/" "$BUILD_DIR/lib/" | ||||
|  | ||||
| cd "$ROOT_DIR/.." | ||||
| npm run tsc | ||||
| # cd "$ROOT_DIR/.." | ||||
| # npm run tsc | ||||
|  | ||||
| cd "$BUILD_DIR" | ||||
| npm run compile | ||||
|   | ||||
| @@ -539,27 +539,6 @@ class BaseApplication { | ||||
| 		return `${os.homedir()}/.config/${Setting.value('appName')}`; | ||||
| 	} | ||||
|  | ||||
| 	async testing() { | ||||
| 		const markdownUtils = require('lib/markdownUtils'); | ||||
| 		const ClipperServer = require('lib/ClipperServer'); | ||||
| 		const server = new ClipperServer(); | ||||
| 		const HtmlToMd = require('lib/HtmlToMd'); | ||||
| 		const service = new HtmlToMd(); | ||||
| 		const html = await shim.fsDriver().readFile('/mnt/d/test.html'); | ||||
| 		let markdown = service.parse(html, { baseUrl: 'https://duckduckgo.com/' }); | ||||
| 		console.info(markdown); | ||||
| 		console.info('--------------------------------------------------'); | ||||
|  | ||||
| 		const imageUrls = markdownUtils.extractImageUrls(markdown); | ||||
| 		let result = await server.downloadImages_(imageUrls); | ||||
| 		result = await server.createResourcesFromPaths_(result); | ||||
| 		console.info(result); | ||||
| 		markdown = server.replaceImageUrlsByResources_(markdown, result); | ||||
| 		console.info('--------------------------------------------------'); | ||||
| 		console.info(markdown); | ||||
| 		console.info('--------------------------------------------------'); | ||||
| 	} | ||||
|  | ||||
| 	async start(argv) { | ||||
| 		let startFlags = await this.handleStartFlags_(argv); | ||||
|  | ||||
|   | ||||
| @@ -1,33 +1,50 @@ | ||||
| const htmlUtils = require('./htmlUtils'); | ||||
| const utils = require('./utils'); | ||||
| const noteStyle = require('./noteStyle'); | ||||
| const memoryCache = require('memory-cache'); | ||||
| const md5 = require('md5'); | ||||
|  | ||||
| class HtmlToHtml { | ||||
| 	constructor(options) { | ||||
| 		if (!options) options = {}; | ||||
| 		this.resourceBaseUrl_ = 'resourceBaseUrl' in options ? options.resourceBaseUrl : null; | ||||
| 		this.ResourceModel_ = options.ResourceModel; | ||||
| 		this.cache_ = new memoryCache.Cache(); | ||||
| 	} | ||||
|  | ||||
| 	render(markup, theme, options) { | ||||
| 		const html = htmlUtils.processImageTags(markup, data => { | ||||
| 			if (!data.src) return null; | ||||
| 	async render(markup, theme, options) { | ||||
| 		const cacheKey = md5(escape(markup)); | ||||
| 		let html = this.cache_.get(cacheKey); | ||||
|  | ||||
| 			const r = utils.imageReplacement(this.ResourceModel_, data.src, options.resources, this.resourceBaseUrl_); | ||||
| 			if (!r) return null; | ||||
| 		if (!html) { | ||||
| 			html = htmlUtils.sanitizeHtml(markup); | ||||
|  | ||||
| 			if (typeof r === 'string') { | ||||
| 				return { | ||||
| 					type: 'replaceElement', | ||||
| 					html: r, | ||||
| 				}; | ||||
| 			} else { | ||||
| 				return { | ||||
| 					type: 'setAttributes', | ||||
| 					attrs: r, | ||||
| 				}; | ||||
| 			} | ||||
| 		}); | ||||
| 			html = htmlUtils.processImageTags(html, data => { | ||||
| 				if (!data.src) return null; | ||||
|  | ||||
| 				const r = utils.imageReplacement(this.ResourceModel_, data.src, options.resources, this.resourceBaseUrl_); | ||||
| 				if (!r) return null; | ||||
|  | ||||
| 				if (typeof r === 'string') { | ||||
| 					return { | ||||
| 						type: 'replaceElement', | ||||
| 						html: r, | ||||
| 					}; | ||||
| 				} else { | ||||
| 					return { | ||||
| 						type: 'setAttributes', | ||||
| 						attrs: r, | ||||
| 					}; | ||||
| 				} | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
| 		if (options.bodyOnly) return { | ||||
| 			html: html, | ||||
| 			pluginAssets: [], | ||||
| 		}; | ||||
|  | ||||
| 		this.cache_.put(cacheKey, html, 1000 * 60 * 10); | ||||
|  | ||||
| 		const cssStrings = noteStyle(theme, options); | ||||
| 		const styleHtml = `<style>${cssStrings.join('\n')}</style>`; | ||||
|   | ||||
| @@ -33,7 +33,7 @@ class MarkupToHtml { | ||||
| 		return ''; | ||||
| 	} | ||||
|  | ||||
| 	render(markupLanguage, markup, theme, options) { | ||||
| 	async render(markupLanguage, markup, theme, options) { | ||||
| 		return this.renderer(markupLanguage).render(markup, theme, options); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -2,6 +2,7 @@ const MarkdownIt = require('markdown-it'); | ||||
| const md5 = require('md5'); | ||||
| const noteStyle = require('./noteStyle'); | ||||
| const { fileExtension } = require('./pathUtils'); | ||||
| const memoryCache = require('memory-cache'); | ||||
| const rules = { | ||||
| 	image: require('./MdToHtml/rules/image'), | ||||
| 	checkbox: require('./MdToHtml/rules/checkbox'), | ||||
| @@ -12,6 +13,7 @@ const rules = { | ||||
| 	code_inline: require('./MdToHtml/rules/code_inline'), | ||||
| 	fountain: require('./MdToHtml/rules/fountain'), | ||||
| 	mermaid: require('./MdToHtml/rules/mermaid').default, | ||||
| 	sanitize_html: require('./MdToHtml/rules/sanitize_html').default, | ||||
| }; | ||||
| const setupLinkify = require('./MdToHtml/setupLinkify'); | ||||
| const hljs = require('highlight.js'); | ||||
| @@ -50,6 +52,7 @@ class MdToHtml { | ||||
| 		this.cachedHighlightedCode_ = {}; | ||||
| 		this.ResourceModel_ = options.ResourceModel; | ||||
| 		this.pluginOptions_ = options.pluginOptions ? options.pluginOptions : {}; | ||||
| 		this.contextCache_ = new memoryCache.Cache(); | ||||
| 	} | ||||
|  | ||||
| 	pluginOptions(name) { | ||||
| @@ -106,6 +109,7 @@ class MdToHtml { | ||||
|  | ||||
| 	async render(body, style = null, options = null) { | ||||
| 		if (!options) options = {}; | ||||
| 		if (!('bodyOnly' in options)) options.bodyOnly = false; | ||||
| 		if (!options.postMessageSyntax) options.postMessageSyntax = 'postMessage'; | ||||
| 		if (!options.paddingBottom) options.paddingBottom = '0'; | ||||
| 		if (!options.highlightedKeywords) options.highlightedKeywords = []; | ||||
| @@ -129,6 +133,7 @@ class MdToHtml { | ||||
| 		const context = { | ||||
| 			css: {}, | ||||
| 			pluginAssets: {}, | ||||
| 			cache: this.contextCache_, | ||||
| 		}; | ||||
|  | ||||
| 		const ruleOptions = Object.assign({}, options, { | ||||
| @@ -203,6 +208,7 @@ class MdToHtml { | ||||
| 		if (this.pluginEnabled('katex')) markdownIt.use(rules.katex(context, ruleOptions)); | ||||
| 		if (this.pluginEnabled('fountain')) markdownIt.use(rules.fountain(context, ruleOptions)); | ||||
| 		if (this.pluginEnabled('mermaid')) markdownIt.use(rules.mermaid(context, ruleOptions)); | ||||
| 		markdownIt.use(rules.sanitize_html(context, ruleOptions)); | ||||
| 		markdownIt.use(rules.highlight_keywords(context, ruleOptions)); | ||||
| 		markdownIt.use(rules.code_inline(context, ruleOptions)); | ||||
| 		markdownIt.use(markdownItAnchor, { slugify: uslugify }); | ||||
|   | ||||
| @@ -0,0 +1,40 @@ | ||||
| const md5 = require('md5'); | ||||
| const htmlUtils = require('../../htmlUtils'); | ||||
|  | ||||
| // @ts-ignore: Keep the function signature as-is despite unusued arguments | ||||
| function installRule(markdownIt:any, mdOptions:any, ruleOptions:any, context:any) { | ||||
| 	markdownIt.core.ruler.push('sanitize_html', (state:any) => { | ||||
| 		const tokens = state.tokens; | ||||
|  | ||||
| 		const walkHtmlTokens = (tokens:any[]) => { | ||||
| 			if (!tokens || !tokens.length) return; | ||||
|  | ||||
| 			for (const token of tokens) { | ||||
| 				if (!['html_block', 'html_inline'].includes(token.type)) { | ||||
| 					walkHtmlTokens(token.children); | ||||
| 					continue; | ||||
| 				} | ||||
|  | ||||
| 				const cacheKey = md5(escape(token.content)); | ||||
| 				let sanitizedContent = context.cache.get(cacheKey); | ||||
|  | ||||
| 				if (!sanitizedContent) { | ||||
| 					sanitizedContent = htmlUtils.sanitizeHtml(token.content); | ||||
| 				} | ||||
|  | ||||
| 				token.content = sanitizedContent; | ||||
|  | ||||
| 				context.cache.put(cacheKey, sanitizedContent, 1000 * 60 * 60); | ||||
| 				walkHtmlTokens(token.children); | ||||
| 			} | ||||
| 		}; | ||||
|  | ||||
| 		walkHtmlTokens(tokens); | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| export default function(context:any, ruleOptions:any) { | ||||
| 	return function(md:any, mdOptions:any) { | ||||
| 		installRule(md, mdOptions, ruleOptions, context); | ||||
| 	}; | ||||
| } | ||||
| @@ -2,8 +2,11 @@ const Entities = require('html-entities').AllHtmlEntities; | ||||
| const htmlentities = new Entities().encode; | ||||
|  | ||||
| // [\s\S] instead of . for multiline matching | ||||
| const NodeHtmlParser = require('node-html-parser'); | ||||
|  | ||||
| // https://stackoverflow.com/a/16119722/561309 | ||||
| const imageRegex = /<img([\s\S]*?)src=["']([\s\S]*?)["']([\s\S]*?)>/gi; | ||||
| const JS_EVENT_NAMES = ['onabort', 'onafterprint', 'onbeforeprint', 'onbeforeunload', 'onblur', 'oncanplay', 'oncanplaythrough', 'onchange', 'onclick', 'oncontextmenu', 'oncopy', 'oncuechange', 'oncut', 'ondblclick', 'ondrag', 'ondragend', 'ondragenter', 'ondragleave', 'ondragover', 'ondragstart', 'ondrop', 'ondurationchange', 'onemptied', 'onended', 'onerror', 'onfocus', 'onhashchange', 'oninput', 'oninvalid', 'onkeydown', 'onkeypress', 'onkeyup', 'onload', 'onloadeddata', 'onloadedmetadata', 'onloadstart', 'onmessage', 'onmousedown', 'onmousemove', 'onmouseout', 'onmouseover', 'onmouseup', 'onmousewheel', 'onoffline', 'ononline', 'onpagehide', 'onpageshow', 'onpaste', 'onpause', 'onplay', 'onplaying', 'onpopstate', 'onprogress', 'onratechange', 'onreset', 'onresize', 'onscroll', 'onsearch', 'onseeked', 'onseeking', 'onselect', 'onstalled', 'onstorage', 'onsubmit', 'onsuspend', 'ontimeupdate', 'ontoggle', 'onunload', 'onvolumechange', 'onwaiting', 'onwheel']; | ||||
|  | ||||
| class HtmlUtils { | ||||
|  | ||||
| @@ -43,6 +46,34 @@ class HtmlUtils { | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	sanitizeHtml(html) { | ||||
| 		const walkHtmlNodes = (nodes) => { | ||||
| 			if (!nodes || !nodes.length) return; | ||||
|  | ||||
| 			for (const node of nodes) { | ||||
| 				for (const attr in node.attributes) { | ||||
| 					if (!node.attributes.hasOwnProperty(attr)) continue; | ||||
| 					if (JS_EVENT_NAMES.includes(attr)) node.setAttribute(attr, ''); | ||||
| 				} | ||||
| 				walkHtmlNodes(node.childNodes); | ||||
| 			} | ||||
| 		}; | ||||
|  | ||||
| 		// Need to wrap in div, otherwise elements at the root will be skipped | ||||
| 		// The DIV tags are removed below | ||||
| 		const dom = NodeHtmlParser.parse(`<div>${html}</div>`, { | ||||
| 			script: false, | ||||
| 			style: true, | ||||
| 			pre: true, | ||||
| 			comment: false, | ||||
| 		}); | ||||
|  | ||||
| 		walkHtmlNodes([dom]); | ||||
| 		const output = dom.toString(); | ||||
| 		return output.substr(5, output.length - 11); | ||||
| 	} | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
| const htmlUtils = new HtmlUtils(); | ||||
|   | ||||
| @@ -37,6 +37,8 @@ | ||||
|     "markdown-it-toc-done-right": "^4.1.0", | ||||
|     "md5": "^2.2.1", | ||||
|     "mermaid": "^8.4.6", | ||||
|     "memory-cache": "^0.2.0", | ||||
|     "node-html-parser": "^1.2.4", | ||||
|     "uslug": "^1.0.4" | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -214,7 +214,7 @@ function shimInit() { | ||||
| 		if (shim.isElectron()) { | ||||
| 			const nativeImage = require('electron').nativeImage; | ||||
| 			let image = nativeImage.createFromDataURL(imageDataUrl); | ||||
| 			if (image.isEmpty()) throw new Error('Could not convert data URL to image'); // Would throw for example if the image format is no supported (eg. image/gif) | ||||
| 			if (image.isEmpty()) throw new Error('Could not convert data URL to image - perhaps the format is not supported (eg. image/gif)'); // Would throw for example if the image format is no supported (eg. image/gif) | ||||
| 			if (options.cropRect) { | ||||
| 				// Crop rectangle values need to be rounded or the crop() call will fail | ||||
| 				const c = options.cropRect; | ||||
|   | ||||
							
								
								
									
										20
									
								
								ReactNativeClient/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										20
									
								
								ReactNativeClient/package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -5689,6 +5689,11 @@ | ||||
|         "mimic-fn": "^1.0.0" | ||||
|       } | ||||
|     }, | ||||
|     "memory-cache": { | ||||
|       "version": "0.2.0", | ||||
|       "resolved": "https://registry.npmjs.org/memory-cache/-/memory-cache-0.2.0.tgz", | ||||
|       "integrity": "sha1-eJCwHVLADI68nVM+H46xfjA0hxo=" | ||||
|     }, | ||||
|     "merge-stream": { | ||||
|       "version": "1.0.1", | ||||
|       "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-1.0.1.tgz", | ||||
| @@ -6380,6 +6385,21 @@ | ||||
|         "is-stream": "^1.0.1" | ||||
|       } | ||||
|     }, | ||||
|     "node-html-parser": { | ||||
|       "version": "1.2.4", | ||||
|       "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-1.2.4.tgz", | ||||
|       "integrity": "sha512-qHwPdGyGr9pOZBoSgUOuNPG20QYZVN00lFcxKQgjPUODSxVH7obQeLVVawa3B4cfSNtLIeczSzoy/xYA8XG5WQ==", | ||||
|       "requires": { | ||||
|         "he": "1.1.1" | ||||
|       }, | ||||
|       "dependencies": { | ||||
|         "he": { | ||||
|           "version": "1.1.1", | ||||
|           "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz", | ||||
|           "integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0=" | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "node-int64": { | ||||
|       "version": "0.4.0", | ||||
|       "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", | ||||
|   | ||||
| @@ -72,6 +72,8 @@ | ||||
|     "react-native-version-info": "^0.5.1", | ||||
|     "react-native-webview": "^5.12.0", | ||||
|     "react-redux": "5.0.7", | ||||
|     "memory-cache": "^0.2.0", | ||||
|     "node-html-parser": "^1.2.4", | ||||
|     "redux": "4.0.0", | ||||
|     "reselect": "^4.0.0", | ||||
|     "rn-fetch-blob": "^0.12.0", | ||||
|   | ||||
		Reference in New Issue
	
	Block a user