From 95eaf94cd89c12c56eb2e909fb4d9a5c8092f27f Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 15 Apr 2020 00:40:38 +0100 Subject: [PATCH 1/6] Fix pills being broken by unescaped characters Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/editor/deserialize.js | 2 +- src/editor/serialize.js | 2 +- test/editor/deserialize-test.js | 16 ++++++++++++++++ test/editor/serialize-test.js | 12 ++++++++++++ 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/editor/deserialize.js b/src/editor/deserialize.js index 190963f357..d80a62b981 100644 --- a/src/editor/deserialize.js +++ b/src/editor/deserialize.js @@ -50,7 +50,7 @@ function parseLink(a, partCreator) { if (href === a.textContent) { return partCreator.plain(a.textContent); } else { - return partCreator.plain(`[${a.textContent}](${href})`); + return partCreator.plain(`[${a.textContent.replace(/[\\\]]/, c => "\\" + c)}](${href})`); } } } diff --git a/src/editor/serialize.js b/src/editor/serialize.js index ba380f2809..341d92d3c8 100644 --- a/src/editor/serialize.js +++ b/src/editor/serialize.js @@ -30,7 +30,7 @@ export function mdSerialize(model) { return html + part.text; case "room-pill": case "user-pill": - return html + `[${part.text}](${makeGenericPermalink(part.resourceId)})`; + return html + `[${part.text.replace(/[\\\]]/, c => "\\" + c)}](${makeGenericPermalink(part.resourceId)})`; } }, ""); } diff --git a/test/editor/deserialize-test.js b/test/editor/deserialize-test.js index 1c58a6c40b..be8fe8aeab 100644 --- a/test/editor/deserialize-test.js +++ b/test/editor/deserialize-test.js @@ -148,6 +148,22 @@ describe('editor/deserialize', function() { expect(parts[1]).toStrictEqual({type: "user-pill", text: "Alice", resourceId: "@alice:hs.tld"}); expect(parts[2]).toStrictEqual({type: "plain", text: "!"}); }); + it('user pill with displayname containing backslash', function() { + const html = "Hi Alice\!"; + const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); + expect(parts.length).toBe(3); + expect(parts[0]).toStrictEqual({type: "plain", text: "Hi "}); + expect(parts[1]).toStrictEqual({type: "user-pill", text: "Alice\\", resourceId: "@alice:hs.tld"}); + expect(parts[2]).toStrictEqual({type: "plain", text: "!"}); + }); + it('user pill with displayname containing closing square bracket', function() { + const html = "Hi Alice]!"; + const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); + expect(parts.length).toBe(3); + expect(parts[0]).toStrictEqual({type: "plain", text: "Hi "}); + expect(parts[1]).toStrictEqual({type: "user-pill", text: "Alice]", resourceId: "@alice:hs.tld"}); + expect(parts[2]).toStrictEqual({type: "plain", text: "!"}); + }); it('room pill', function() { const html = "Try #room:hs.tld?"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); diff --git a/test/editor/serialize-test.js b/test/editor/serialize-test.js index 7517e46437..d5fb800600 100644 --- a/test/editor/serialize-test.js +++ b/test/editor/serialize-test.js @@ -43,4 +43,16 @@ describe('editor/serialize', function() { const html = htmlSerializeIfNeeded(model, {}); expect(html).toBe("hello world"); }); + it('displaynames ending in a backslash work', function () { + const pc = createPartCreator(); + const model = new EditorModel([pc.userPill("Displayname\\", "@user:server")]); + const html = htmlSerializeIfNeeded(model, {}); + expect(html).toBe("Displayname\"); + }); + it('displaynames containing a closing square bracket work', function () { + const pc = createPartCreator(); + const model = new EditorModel([pc.userPill("Displayname]", "@user:server")]); + const html = htmlSerializeIfNeeded(model, {}); + expect(html).toBe("Displayname]"); + }); }); From c72139fc3f5de58371d3b4e998e1b0b1d8223c3a Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 15 Apr 2020 00:49:08 +0100 Subject: [PATCH 2/6] Convert serialize and deserialize to TypeScript Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/editor/{deserialize.js => deserialize.ts} | 36 +++++++++++-------- src/editor/{serialize.js => serialize.ts} | 19 +++++----- 2 files changed, 32 insertions(+), 23 deletions(-) rename src/editor/{deserialize.js => deserialize.ts} (87%) rename src/editor/{serialize.js => serialize.ts} (82%) diff --git a/src/editor/deserialize.js b/src/editor/deserialize.ts similarity index 87% rename from src/editor/deserialize.js rename to src/editor/deserialize.ts index d80a62b981..8878a8877c 100644 --- a/src/editor/deserialize.js +++ b/src/editor/deserialize.ts @@ -1,6 +1,6 @@ /* Copyright 2019 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,11 +15,14 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; + import { walkDOMDepthFirst } from "./dom"; import { checkBlockNode } from "../HtmlUtils"; -import {getPrimaryPermalinkEntity} from "../utils/permalinks/Permalinks"; +import { getPrimaryPermalinkEntity } from "../utils/permalinks/Permalinks"; +import { PartCreator } from "./parts"; -function parseAtRoomMentions(text, partCreator) { +function parseAtRoomMentions(text: string, partCreator: PartCreator) { const ATROOM = "@room"; const parts = []; text.split(ATROOM).forEach((textPart, i, arr) => { @@ -37,7 +40,7 @@ function parseAtRoomMentions(text, partCreator) { return parts; } -function parseLink(a, partCreator) { +function parseLink(a: HTMLAnchorElement, partCreator: PartCreator) { const {href} = a; const resourceId = getPrimaryPermalinkEntity(href); // The room/user ID const prefix = resourceId ? resourceId[0] : undefined; // First character of ID @@ -56,11 +59,11 @@ function parseLink(a, partCreator) { } } -function parseCodeBlock(n, partCreator) { +function parseCodeBlock(n: HTMLElement, partCreator: PartCreator) { const parts = []; let language = ""; if (n.firstChild && n.firstChild.nodeName === "CODE") { - for (const className of n.firstChild.classList) { + for (const className of (n.firstChild).classList) { if (className.startsWith("language-")) { language = className.substr("language-".length); break; @@ -77,12 +80,17 @@ function parseCodeBlock(n, partCreator) { return parts; } -function parseHeader(el, partCreator) { +function parseHeader(el: HTMLElement, partCreator: PartCreator) { const depth = parseInt(el.nodeName.substr(1), 10); return partCreator.plain("#".repeat(depth) + " "); } -function parseElement(n, partCreator, lastNode, state) { +interface IState { + listIndex: number[]; + listDepth?: number; +} + +function parseElement(n: HTMLElement, partCreator: PartCreator, lastNode: HTMLElement | undefined, state: IState) { switch (n.nodeName) { case "H1": case "H2": @@ -92,7 +100,7 @@ function parseElement(n, partCreator, lastNode, state) { case "H6": return parseHeader(n, partCreator); case "A": - return parseLink(n, partCreator); + return parseLink(n, partCreator); case "BR": return partCreator.newline(); case "EM": @@ -123,7 +131,7 @@ function parseElement(n, partCreator, lastNode, state) { break; } case "OL": - state.listIndex.push(n.start || 1); + state.listIndex.push((n).start || 1); // fallthrough case "UL": state.listDepth = (state.listDepth || 0) + 1; @@ -174,7 +182,7 @@ function prefixQuoteLines(isFirstNode, parts, partCreator) { } } -function parseHtmlMessage(html, partCreator, isQuotedMessage) { +function parseHtmlMessage(html: string, partCreator: PartCreator, isQuotedMessage: boolean) { // no nodes from parsing here should be inserted in the document, // as scripts in event handlers, etc would be executed then. // we're only taking text, so that is fine @@ -182,7 +190,7 @@ function parseHtmlMessage(html, partCreator, isQuotedMessage) { const parts = []; let lastNode; let inQuote = isQuotedMessage; - const state = { + const state: IState = { listIndex: [], }; @@ -249,7 +257,7 @@ function parseHtmlMessage(html, partCreator, isQuotedMessage) { return parts; } -export function parsePlainTextMessage(body, partCreator, isQuotedMessage) { +export function parsePlainTextMessage(body: string, partCreator: PartCreator, isQuotedMessage: boolean) { const lines = body.split(/\r\n|\r|\n/g); // split on any new-line combination not just \n, collapses \r\n const parts = lines.reduce((parts, line, i) => { if (isQuotedMessage) { @@ -265,7 +273,7 @@ export function parsePlainTextMessage(body, partCreator, isQuotedMessage) { return parts; } -export function parseEvent(event, partCreator, {isQuotedMessage = false} = {}) { +export function parseEvent(event: MatrixEvent, partCreator: PartCreator, {isQuotedMessage = false} = {}) { const content = event.getContent(); let parts; if (content.format === "org.matrix.custom.html") { diff --git a/src/editor/serialize.js b/src/editor/serialize.ts similarity index 82% rename from src/editor/serialize.js rename to src/editor/serialize.ts index 341d92d3c8..9ff1cfbd80 100644 --- a/src/editor/serialize.js +++ b/src/editor/serialize.ts @@ -1,6 +1,6 @@ /* Copyright 2019 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -17,8 +17,9 @@ limitations under the License. import Markdown from '../Markdown'; import {makeGenericPermalink} from "../utils/permalinks/Permalinks"; +import EditorModel from "./model"; -export function mdSerialize(model) { +export function mdSerialize(model: EditorModel) { return model.parts.reduce((html, part) => { switch (part.type) { case "newline": @@ -35,7 +36,7 @@ export function mdSerialize(model) { }, ""); } -export function htmlSerializeIfNeeded(model, {forceHTML = false} = {}) { +export function htmlSerializeIfNeeded(model: EditorModel, {forceHTML = false} = {}) { const md = mdSerialize(model); const parser = new Markdown(md); if (!parser.isPlainText() || forceHTML) { @@ -43,7 +44,7 @@ export function htmlSerializeIfNeeded(model, {forceHTML = false} = {}) { } } -export function textSerialize(model) { +export function textSerialize(model: EditorModel) { return model.parts.reduce((text, part) => { switch (part.type) { case "newline": @@ -60,11 +61,11 @@ export function textSerialize(model) { }, ""); } -export function containsEmote(model) { +export function containsEmote(model: EditorModel) { return startsWith(model, "/me "); } -export function startsWith(model, prefix) { +export function startsWith(model: EditorModel, prefix: string) { const firstPart = model.parts[0]; // part type will be "plain" while editing, // and "command" while composing a message. @@ -73,18 +74,18 @@ export function startsWith(model, prefix) { firstPart.text.startsWith(prefix); } -export function stripEmoteCommand(model) { +export function stripEmoteCommand(model: EditorModel) { // trim "/me " return stripPrefix(model, "/me "); } -export function stripPrefix(model, prefix) { +export function stripPrefix(model: EditorModel, prefix: string) { model = model.clone(); model.removeText({index: 0, offset: 0}, prefix.length); return model; } -export function unescapeMessage(model) { +export function unescapeMessage(model: EditorModel) { const {parts} = model; if (parts.length) { const firstPart = parts[0]; From f1def8b0de4eadaa6672cb87d3fa3ffc80d6c43b Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 15 Apr 2020 00:50:28 +0100 Subject: [PATCH 3/6] delint Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- test/editor/deserialize-test.js | 2 +- test/editor/serialize-test.js | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/test/editor/deserialize-test.js b/test/editor/deserialize-test.js index be8fe8aeab..4184552559 100644 --- a/test/editor/deserialize-test.js +++ b/test/editor/deserialize-test.js @@ -149,7 +149,7 @@ describe('editor/deserialize', function() { expect(parts[2]).toStrictEqual({type: "plain", text: "!"}); }); it('user pill with displayname containing backslash', function() { - const html = "Hi Alice\!"; + const html = "Hi Alice\\!"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(3); expect(parts[0]).toStrictEqual({type: "plain", text: "Hi "}); diff --git a/test/editor/serialize-test.js b/test/editor/serialize-test.js index d5fb800600..a69e3598e3 100644 --- a/test/editor/serialize-test.js +++ b/test/editor/serialize-test.js @@ -43,13 +43,13 @@ describe('editor/serialize', function() { const html = htmlSerializeIfNeeded(model, {}); expect(html).toBe("hello world"); }); - it('displaynames ending in a backslash work', function () { + it('displaynames ending in a backslash work', function() { const pc = createPartCreator(); const model = new EditorModel([pc.userPill("Displayname\\", "@user:server")]); const html = htmlSerializeIfNeeded(model, {}); - expect(html).toBe("Displayname\"); + expect(html).toBe("Displayname\\"); }); - it('displaynames containing a closing square bracket work', function () { + it('displaynames containing a closing square bracket work', function() { const pc = createPartCreator(); const model = new EditorModel([pc.userPill("Displayname]", "@user:server")]); const html = htmlSerializeIfNeeded(model, {}); From cb10640eafed873a9dce27c9f357b33c22430b2e Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 15 Apr 2020 00:53:35 +0100 Subject: [PATCH 4/6] detslint Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/editor/deserialize.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/editor/deserialize.ts b/src/editor/deserialize.ts index 8878a8877c..6680029130 100644 --- a/src/editor/deserialize.ts +++ b/src/editor/deserialize.ts @@ -132,10 +132,10 @@ function parseElement(n: HTMLElement, partCreator: PartCreator, lastNode: HTMLEl } case "OL": state.listIndex.push((n).start || 1); - // fallthrough + /* falls through */ case "UL": state.listDepth = (state.listDepth || 0) + 1; - // fallthrough + /* falls through */ default: // don't textify block nodes we'll descend into if (!checkDescendInto(n)) { @@ -244,7 +244,7 @@ function parseHtmlMessage(html: string, partCreator: PartCreator, isQuotedMessag break; case "OL": state.listIndex.pop(); - // fallthrough + /* falls through */ case "UL": state.listDepth -= 1; break; @@ -259,7 +259,7 @@ function parseHtmlMessage(html: string, partCreator: PartCreator, isQuotedMessag export function parsePlainTextMessage(body: string, partCreator: PartCreator, isQuotedMessage: boolean) { const lines = body.split(/\r\n|\r|\n/g); // split on any new-line combination not just \n, collapses \r\n - const parts = lines.reduce((parts, line, i) => { + return lines.reduce((parts, line, i) => { if (isQuotedMessage) { parts.push(partCreator.plain(QUOTE_LINE_PREFIX)); } @@ -270,7 +270,6 @@ export function parsePlainTextMessage(body: string, partCreator: PartCreator, is } return parts; }, []); - return parts; } export function parseEvent(event: MatrixEvent, partCreator: PartCreator, {isQuotedMessage = false} = {}) { From 4454db30d67e9ff82848da0a6d242f1452e9f93a Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 15 Apr 2020 01:02:08 +0100 Subject: [PATCH 5/6] Escape opening square bracket too Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/editor/deserialize.ts | 2 +- src/editor/serialize.ts | 2 +- test/editor/deserialize-test.js | 8 ++++++++ test/editor/serialize-test.js | 6 ++++++ 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/editor/deserialize.ts b/src/editor/deserialize.ts index 6680029130..5322f09f11 100644 --- a/src/editor/deserialize.ts +++ b/src/editor/deserialize.ts @@ -53,7 +53,7 @@ function parseLink(a: HTMLAnchorElement, partCreator: PartCreator) { if (href === a.textContent) { return partCreator.plain(a.textContent); } else { - return partCreator.plain(`[${a.textContent.replace(/[\\\]]/, c => "\\" + c)}](${href})`); + return partCreator.plain(`[${a.textContent.replace(/[[\\\]]/, c => "\\" + c)}](${href})`); } } } diff --git a/src/editor/serialize.ts b/src/editor/serialize.ts index 9ff1cfbd80..d501bdd47e 100644 --- a/src/editor/serialize.ts +++ b/src/editor/serialize.ts @@ -31,7 +31,7 @@ export function mdSerialize(model: EditorModel) { return html + part.text; case "room-pill": case "user-pill": - return html + `[${part.text.replace(/[\\\]]/, c => "\\" + c)}](${makeGenericPermalink(part.resourceId)})`; + return html + `[${part.text.replace(/[[\\\]]/, c => "\\" + c)}](${makeGenericPermalink(part.resourceId)})`; } }, ""); } diff --git a/test/editor/deserialize-test.js b/test/editor/deserialize-test.js index 4184552559..fb97d75752 100644 --- a/test/editor/deserialize-test.js +++ b/test/editor/deserialize-test.js @@ -156,6 +156,14 @@ describe('editor/deserialize', function() { expect(parts[1]).toStrictEqual({type: "user-pill", text: "Alice\\", resourceId: "@alice:hs.tld"}); expect(parts[2]).toStrictEqual({type: "plain", text: "!"}); }); + it('user pill with displayname containing opening square bracket', function() { + const html = "Hi Alice[!"; + const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); + expect(parts.length).toBe(3); + expect(parts[0]).toStrictEqual({type: "plain", text: "Hi "}); + expect(parts[1]).toStrictEqual({type: "user-pill", text: "Alice[", resourceId: "@alice:hs.tld"}); + expect(parts[2]).toStrictEqual({type: "plain", text: "!"}); + }); it('user pill with displayname containing closing square bracket', function() { const html = "Hi Alice]!"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); diff --git a/test/editor/serialize-test.js b/test/editor/serialize-test.js index a69e3598e3..a114f89de2 100644 --- a/test/editor/serialize-test.js +++ b/test/editor/serialize-test.js @@ -49,6 +49,12 @@ describe('editor/serialize', function() { const html = htmlSerializeIfNeeded(model, {}); expect(html).toBe("Displayname\\"); }); + it('displaynames containing an opening square bracket work', function() { + const pc = createPartCreator(); + const model = new EditorModel([pc.userPill("Displayname[", "@user:server")]); + const html = htmlSerializeIfNeeded(model, {}); + expect(html).toBe("Displayname["); + }); it('displaynames containing a closing square bracket work', function() { const pc = createPartCreator(); const model = new EditorModel([pc.userPill("Displayname]", "@user:server")]); From 9c1939b75679980f14f3ee550af00e65346c1fd0 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 15 Apr 2020 02:31:30 +0100 Subject: [PATCH 6/6] match all, not just first instance of tokens to escape Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/editor/deserialize.ts | 2 +- src/editor/serialize.ts | 2 +- test/editor/deserialize-test.js | 4 ++-- test/editor/serialize-test.js | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/editor/deserialize.ts b/src/editor/deserialize.ts index 5322f09f11..48d1d98ae4 100644 --- a/src/editor/deserialize.ts +++ b/src/editor/deserialize.ts @@ -53,7 +53,7 @@ function parseLink(a: HTMLAnchorElement, partCreator: PartCreator) { if (href === a.textContent) { return partCreator.plain(a.textContent); } else { - return partCreator.plain(`[${a.textContent.replace(/[[\\\]]/, c => "\\" + c)}](${href})`); + return partCreator.plain(`[${a.textContent.replace(/[[\\\]]/g, c => "\\" + c)}](${href})`); } } } diff --git a/src/editor/serialize.ts b/src/editor/serialize.ts index d501bdd47e..4d0b8cd03a 100644 --- a/src/editor/serialize.ts +++ b/src/editor/serialize.ts @@ -31,7 +31,7 @@ export function mdSerialize(model: EditorModel) { return html + part.text; case "room-pill": case "user-pill": - return html + `[${part.text.replace(/[[\\\]]/, c => "\\" + c)}](${makeGenericPermalink(part.resourceId)})`; + return html + `[${part.text.replace(/[[\\\]]/g, c => "\\" + c)}](${makeGenericPermalink(part.resourceId)})`; } }, ""); } diff --git a/test/editor/deserialize-test.js b/test/editor/deserialize-test.js index fb97d75752..2bd5d7e4c6 100644 --- a/test/editor/deserialize-test.js +++ b/test/editor/deserialize-test.js @@ -157,11 +157,11 @@ describe('editor/deserialize', function() { expect(parts[2]).toStrictEqual({type: "plain", text: "!"}); }); it('user pill with displayname containing opening square bracket', function() { - const html = "Hi Alice[!"; + const html = "Hi Alice[[!"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(3); expect(parts[0]).toStrictEqual({type: "plain", text: "Hi "}); - expect(parts[1]).toStrictEqual({type: "user-pill", text: "Alice[", resourceId: "@alice:hs.tld"}); + expect(parts[1]).toStrictEqual({type: "user-pill", text: "Alice[[", resourceId: "@alice:hs.tld"}); expect(parts[2]).toStrictEqual({type: "plain", text: "!"}); }); it('user pill with displayname containing closing square bracket', function() { diff --git a/test/editor/serialize-test.js b/test/editor/serialize-test.js index a114f89de2..bd26ae91bb 100644 --- a/test/editor/serialize-test.js +++ b/test/editor/serialize-test.js @@ -51,9 +51,9 @@ describe('editor/serialize', function() { }); it('displaynames containing an opening square bracket work', function() { const pc = createPartCreator(); - const model = new EditorModel([pc.userPill("Displayname[", "@user:server")]); + const model = new EditorModel([pc.userPill("Displayname[[", "@user:server")]); const html = htmlSerializeIfNeeded(model, {}); - expect(html).toBe("Displayname["); + expect(html).toBe("Displayname[["); }); it('displaynames containing a closing square bracket work', function() { const pc = createPartCreator();