Merge pull request #4411 from matrix-org/t3chguy/backslash

Fix pills being broken by unescaped characters
This commit is contained in:
Michael Telatynski 2020-04-15 19:23:28 +01:00 committed by GitHub
commit 2929bcf998
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 80 additions and 30 deletions

View file

@ -1,6 +1,6 @@
/* /*
Copyright 2019 New Vector Ltd 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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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. limitations under the License.
*/ */
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { walkDOMDepthFirst } from "./dom"; import { walkDOMDepthFirst } from "./dom";
import { checkBlockNode } from "../HtmlUtils"; 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 ATROOM = "@room";
const parts = []; const parts = [];
text.split(ATROOM).forEach((textPart, i, arr) => { text.split(ATROOM).forEach((textPart, i, arr) => {
@ -37,7 +40,7 @@ function parseAtRoomMentions(text, partCreator) {
return parts; return parts;
} }
function parseLink(a, partCreator) { function parseLink(a: HTMLAnchorElement, partCreator: PartCreator) {
const {href} = a; const {href} = a;
const resourceId = getPrimaryPermalinkEntity(href); // The room/user ID const resourceId = getPrimaryPermalinkEntity(href); // The room/user ID
const prefix = resourceId ? resourceId[0] : undefined; // First character of ID const prefix = resourceId ? resourceId[0] : undefined; // First character of ID
@ -50,17 +53,17 @@ function parseLink(a, partCreator) {
if (href === a.textContent) { if (href === a.textContent) {
return partCreator.plain(a.textContent); return partCreator.plain(a.textContent);
} else { } else {
return partCreator.plain(`[${a.textContent}](${href})`); return partCreator.plain(`[${a.textContent.replace(/[[\\\]]/g, c => "\\" + c)}](${href})`);
} }
} }
} }
} }
function parseCodeBlock(n, partCreator) { function parseCodeBlock(n: HTMLElement, partCreator: PartCreator) {
const parts = []; const parts = [];
let language = ""; let language = "";
if (n.firstChild && n.firstChild.nodeName === "CODE") { if (n.firstChild && n.firstChild.nodeName === "CODE") {
for (const className of n.firstChild.classList) { for (const className of (<HTMLElement>n.firstChild).classList) {
if (className.startsWith("language-")) { if (className.startsWith("language-")) {
language = className.substr("language-".length); language = className.substr("language-".length);
break; break;
@ -77,12 +80,17 @@ function parseCodeBlock(n, partCreator) {
return parts; return parts;
} }
function parseHeader(el, partCreator) { function parseHeader(el: HTMLElement, partCreator: PartCreator) {
const depth = parseInt(el.nodeName.substr(1), 10); const depth = parseInt(el.nodeName.substr(1), 10);
return partCreator.plain("#".repeat(depth) + " "); 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) { switch (n.nodeName) {
case "H1": case "H1":
case "H2": case "H2":
@ -92,7 +100,7 @@ function parseElement(n, partCreator, lastNode, state) {
case "H6": case "H6":
return parseHeader(n, partCreator); return parseHeader(n, partCreator);
case "A": case "A":
return parseLink(n, partCreator); return parseLink(<HTMLAnchorElement>n, partCreator);
case "BR": case "BR":
return partCreator.newline(); return partCreator.newline();
case "EM": case "EM":
@ -123,11 +131,11 @@ function parseElement(n, partCreator, lastNode, state) {
break; break;
} }
case "OL": case "OL":
state.listIndex.push(n.start || 1); state.listIndex.push((<HTMLOListElement>n).start || 1);
// fallthrough /* falls through */
case "UL": case "UL":
state.listDepth = (state.listDepth || 0) + 1; state.listDepth = (state.listDepth || 0) + 1;
// fallthrough /* falls through */
default: default:
// don't textify block nodes we'll descend into // don't textify block nodes we'll descend into
if (!checkDescendInto(n)) { if (!checkDescendInto(n)) {
@ -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, // no nodes from parsing here should be inserted in the document,
// as scripts in event handlers, etc would be executed then. // as scripts in event handlers, etc would be executed then.
// we're only taking text, so that is fine // we're only taking text, so that is fine
@ -182,7 +190,7 @@ function parseHtmlMessage(html, partCreator, isQuotedMessage) {
const parts = []; const parts = [];
let lastNode; let lastNode;
let inQuote = isQuotedMessage; let inQuote = isQuotedMessage;
const state = { const state: IState = {
listIndex: [], listIndex: [],
}; };
@ -236,7 +244,7 @@ function parseHtmlMessage(html, partCreator, isQuotedMessage) {
break; break;
case "OL": case "OL":
state.listIndex.pop(); state.listIndex.pop();
// fallthrough /* falls through */
case "UL": case "UL":
state.listDepth -= 1; state.listDepth -= 1;
break; break;
@ -249,9 +257,9 @@ function parseHtmlMessage(html, partCreator, isQuotedMessage) {
return parts; 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 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) { if (isQuotedMessage) {
parts.push(partCreator.plain(QUOTE_LINE_PREFIX)); parts.push(partCreator.plain(QUOTE_LINE_PREFIX));
} }
@ -262,10 +270,9 @@ export function parsePlainTextMessage(body, partCreator, isQuotedMessage) {
} }
return parts; return parts;
}, []); }, []);
return parts;
} }
export function parseEvent(event, partCreator, {isQuotedMessage = false} = {}) { export function parseEvent(event: MatrixEvent, partCreator: PartCreator, {isQuotedMessage = false} = {}) {
const content = event.getContent(); const content = event.getContent();
let parts; let parts;
if (content.format === "org.matrix.custom.html") { if (content.format === "org.matrix.custom.html") {

View file

@ -1,6 +1,6 @@
/* /*
Copyright 2019 New Vector Ltd 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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 Markdown from '../Markdown';
import {makeGenericPermalink} from "../utils/permalinks/Permalinks"; 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) => { return model.parts.reduce((html, part) => {
switch (part.type) { switch (part.type) {
case "newline": case "newline":
@ -30,12 +31,12 @@ export function mdSerialize(model) {
return html + part.text; return html + part.text;
case "room-pill": case "room-pill":
case "user-pill": case "user-pill":
return html + `[${part.text}](${makeGenericPermalink(part.resourceId)})`; return html + `[${part.text.replace(/[[\\\]]/g, c => "\\" + c)}](${makeGenericPermalink(part.resourceId)})`;
} }
}, ""); }, "");
} }
export function htmlSerializeIfNeeded(model, {forceHTML = false} = {}) { export function htmlSerializeIfNeeded(model: EditorModel, {forceHTML = false} = {}) {
const md = mdSerialize(model); const md = mdSerialize(model);
const parser = new Markdown(md); const parser = new Markdown(md);
if (!parser.isPlainText() || forceHTML) { 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) => { return model.parts.reduce((text, part) => {
switch (part.type) { switch (part.type) {
case "newline": case "newline":
@ -60,11 +61,11 @@ export function textSerialize(model) {
}, ""); }, "");
} }
export function containsEmote(model) { export function containsEmote(model: EditorModel) {
return startsWith(model, "/me "); return startsWith(model, "/me ");
} }
export function startsWith(model, prefix) { export function startsWith(model: EditorModel, prefix: string) {
const firstPart = model.parts[0]; const firstPart = model.parts[0];
// part type will be "plain" while editing, // part type will be "plain" while editing,
// and "command" while composing a message. // and "command" while composing a message.
@ -73,18 +74,18 @@ export function startsWith(model, prefix) {
firstPart.text.startsWith(prefix); firstPart.text.startsWith(prefix);
} }
export function stripEmoteCommand(model) { export function stripEmoteCommand(model: EditorModel) {
// trim "/me " // trim "/me "
return stripPrefix(model, "/me "); return stripPrefix(model, "/me ");
} }
export function stripPrefix(model, prefix) { export function stripPrefix(model: EditorModel, prefix: string) {
model = model.clone(); model = model.clone();
model.removeText({index: 0, offset: 0}, prefix.length); model.removeText({index: 0, offset: 0}, prefix.length);
return model; return model;
} }
export function unescapeMessage(model) { export function unescapeMessage(model: EditorModel) {
const {parts} = model; const {parts} = model;
if (parts.length) { if (parts.length) {
const firstPart = parts[0]; const firstPart = parts[0];

View file

@ -148,6 +148,30 @@ describe('editor/deserialize', function() {
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: "!"}); expect(parts[2]).toStrictEqual({type: "plain", text: "!"});
}); });
it('user pill with displayname containing backslash', function() {
const html = "Hi <a href=\"https://matrix.to/#/@alice:hs.tld\">Alice\\</a>!";
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 opening square bracket', function() {
const html = "Hi <a href=\"https://matrix.to/#/@alice:hs.tld\">Alice[[</a>!";
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 <a href=\"https://matrix.to/#/@alice:hs.tld\">Alice]</a>!";
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() { it('room pill', function() {
const html = "Try <a href=\"https://matrix.to/#/#room:hs.tld\">#room:hs.tld</a>?"; const html = "Try <a href=\"https://matrix.to/#/#room:hs.tld\">#room:hs.tld</a>?";
const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); const parts = normalize(parseEvent(htmlMessage(html), createPartCreator()));

View file

@ -43,4 +43,22 @@ describe('editor/serialize', function() {
const html = htmlSerializeIfNeeded(model, {}); const html = htmlSerializeIfNeeded(model, {});
expect(html).toBe("<em>hello</em> world"); expect(html).toBe("<em>hello</em> 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("<a href=\"https://matrix.to/#/@user:server\">Displayname\\</a>");
});
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("<a href=\"https://matrix.to/#/@user:server\">Displayname[[</a>");
});
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("<a href=\"https://matrix.to/#/@user:server\">Displayname]</a>");
});
}); });