diff --git a/package.json b/package.json index cab094aef8a..e3c992872a6 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,9 @@ "is-ip": "^3.1.0", "jszip": "^3.7.0", "katex": "^0.12.0", - "linkifyjs": "^2.1.9", + "linkify-element": "^3.0.4", + "linkify-string": "^3.0.4", + "linkifyjs": "^3.0.5", "lodash": "^4.17.20", "maplibre-gl": "^1.15.2", "matrix-analytics-events": "https://github.com/matrix-org/matrix-analytics-events.git#1eab4356548c97722a183912fda1ceabbe8cc7c1", diff --git a/src/linkify-matrix.ts b/src/linkify-matrix.ts index bd3e6f3be57..ee85e66b945 100644 --- a/src/linkify-matrix.ts +++ b/src/linkify-matrix.ts @@ -16,9 +16,10 @@ limitations under the License. */ import * as linkifyjs from 'linkifyjs'; -import linkifyElement from 'linkifyjs/element'; -import linkifyString from 'linkifyjs/string'; +import linkifyElement from 'linkify-element'; +import linkifyString from 'linkify-string'; import { RoomMember } from 'matrix-js-sdk/src/models/room-member'; +import { registerPlugin } from 'linkifyjs'; import { baseUrl } from "./utils/permalinks/SpecPermalinkConstructor"; import { @@ -37,73 +38,82 @@ enum Type { GroupId = "groupid" } -// Linkifyjs types don't have parser, which really makes this harder. -const linkifyTokens = (linkifyjs as any).scanner.TOKENS; -enum MatrixLinkInitialToken { - POUND = linkifyTokens.POUND, - PLUS = linkifyTokens.PLUS, - AT = linkifyTokens.AT, -} +// Linkify stuff doesn't type scanner/parser/utils properly :/ +function matrixOpaqueIdLinkifyParser({ + scanner, + parser, + utils, + token, + name, +}: { + scanner: any; + parser: any; + utils: any; + token: '#' | '+' | '@'; + name: Type; +}) { + const { + DOMAIN, + DOT, + // A generic catchall text token + TEXT, + NUM, + TLD, + COLON, + SYM, + UNDERSCORE, + // because 'localhost' is tokenised to the localhost token, + // usernames @localhost:foo.com are otherwise not matched! + LOCALHOST, + } = scanner.tokens; -/** - * Token should be one of the type of linkify.parser.TOKENS[AT | PLUS | POUND] - * but due to typing issues it's just not a feasible solution. - * This problem kind of gets solved in linkify 3.0 - */ -function parseFreeformMatrixLinks(linkify, token: MatrixLinkInitialToken, type: Type): void { - // Text tokens - const TT = linkify.scanner.TOKENS; - // Multi tokens - const MT = linkify.parser.TOKENS; - const MultiToken = MT.Base; - const S_START = linkify.parser.start; - - const TOKEN = function(value) { - MultiToken.call(this, value); - this.type = type; - this.isLink = true; - }; - TOKEN.prototype = new MultiToken(); - - const S_TOKEN = S_START.jump(token); - const S_TOKEN_NAME = new linkify.parser.State(); - const S_TOKEN_NAME_COLON = new linkify.parser.State(); - const S_TOKEN_NAME_COLON_DOMAIN = new linkify.parser.State(TOKEN); - const S_TOKEN_NAME_COLON_DOMAIN_DOT = new linkify.parser.State(); - const S_MX_LINK = new linkify.parser.State(TOKEN); - const S_MX_LINK_COLON = new linkify.parser.State(); - const S_MX_LINK_COLON_NUM = new linkify.parser.State(TOKEN); - - const allowedFreeformTokens = [ - TT.DOT, - TT.PLUS, - TT.NUM, - TT.DOMAIN, - TT.TLD, - TT.UNDERSCORE, - token, + const S_START = parser.start; + const matrixSymbol = utils.createTokenClass(name, { isLink: true }); + + const localpartTokens = [ + DOMAIN, + // IPV4 necessity + NUM, + TLD, // because 'localhost' is tokenised to the localhost token, // usernames @localhost:foo.com are otherwise not matched! - TT.LOCALHOST, + LOCALHOST, + SYM, + UNDERSCORE, + TEXT, ]; + const domainpartTokens = [DOMAIN, NUM, TLD, LOCALHOST]; + + const INITIAL_STATE = S_START.tt(token); - S_TOKEN.on(allowedFreeformTokens, S_TOKEN_NAME); - S_TOKEN_NAME.on(allowedFreeformTokens, S_TOKEN_NAME); - S_TOKEN_NAME.on(TT.DOMAIN, S_TOKEN_NAME); + const LOCALPART_STATE = INITIAL_STATE.tt(DOMAIN); + for (const token of localpartTokens) { + INITIAL_STATE.tt(token, LOCALPART_STATE); + LOCALPART_STATE.tt(token, LOCALPART_STATE); + } + const LOCALPART_STATE_DOT = LOCALPART_STATE.tt(DOT); + for (const token of localpartTokens) { + LOCALPART_STATE_DOT.tt(token, LOCALPART_STATE); + } - S_TOKEN_NAME.on(TT.COLON, S_TOKEN_NAME_COLON); + const DOMAINPART_STATE_DOT = LOCALPART_STATE.tt(COLON); + const DOMAINPART_STATE = DOMAINPART_STATE_DOT.tt(DOMAIN); + DOMAINPART_STATE.tt(DOT, DOMAINPART_STATE_DOT); + for (const token of domainpartTokens) { + DOMAINPART_STATE.tt(token, DOMAINPART_STATE); + // we are done if we have a domain + DOMAINPART_STATE.tt(token, matrixSymbol); + } - S_TOKEN_NAME_COLON.on(TT.DOMAIN, S_TOKEN_NAME_COLON_DOMAIN); - S_TOKEN_NAME_COLON.on(TT.LOCALHOST, S_MX_LINK); // accept #foo:localhost - S_TOKEN_NAME_COLON.on(TT.TLD, S_MX_LINK); // accept #foo:com (mostly for (TLD|DOMAIN)+ mixing) - S_TOKEN_NAME_COLON_DOMAIN.on(TT.DOT, S_TOKEN_NAME_COLON_DOMAIN_DOT); - S_TOKEN_NAME_COLON_DOMAIN_DOT.on(TT.DOMAIN, S_TOKEN_NAME_COLON_DOMAIN); - S_TOKEN_NAME_COLON_DOMAIN_DOT.on(TT.TLD, S_MX_LINK); + // accept repeated TLDs (e.g .org.uk) but do not accept double dots: .. + for (const token of domainpartTokens) { + DOMAINPART_STATE_DOT.tt(token, DOMAINPART_STATE); + } - S_MX_LINK.on(TT.DOT, S_TOKEN_NAME_COLON_DOMAIN_DOT); // accept repeated TLDs (e.g .org.uk) - S_MX_LINK.on(TT.COLON, S_MX_LINK_COLON); // do not accept trailing `:` - S_MX_LINK_COLON.on(TT.NUM, S_MX_LINK_COLON_NUM); // but do accept :NUM (port specifier) + const PORT_STATE = DOMAINPART_STATE.tt(COLON); + + PORT_STATE.tt(NUM, matrixSymbol); } function onUserClick(event: MouseEvent, userId: string) { @@ -199,10 +209,12 @@ export const options = { } }, - linkAttributes: { + attributes: { rel: 'noreferrer noopener', }, + className: 'linkified', + target: function(href: string, type: Type | string): string { if (type === Type.URL) { try { @@ -221,12 +233,38 @@ export const options = { }; // Run the plugins -// Linkify room aliases -parseFreeformMatrixLinks(linkifyjs, MatrixLinkInitialToken.POUND, Type.RoomAlias); -// Linkify group IDs -parseFreeformMatrixLinks(linkifyjs, MatrixLinkInitialToken.PLUS, Type.GroupId); -// Linkify user IDs -parseFreeformMatrixLinks(linkifyjs, MatrixLinkInitialToken.AT, Type.UserId); +registerPlugin(Type.RoomAlias, ({ scanner, parser, utils }) => { + const token = scanner.tokens.POUND as '#'; + return matrixOpaqueIdLinkifyParser({ + scanner, + parser, + utils, + token, + name: Type.RoomAlias, + }); +}); + +registerPlugin(Type.GroupId, ({ scanner, parser, utils }) => { + const token = scanner.tokens.PLUS as '+'; + return matrixOpaqueIdLinkifyParser({ + scanner, + parser, + utils, + token, + name: Type.GroupId, + }); +}); + +registerPlugin(Type.UserId, ({ scanner, parser, utils }) => { + const token = scanner.tokens.AT as '@'; + return matrixOpaqueIdLinkifyParser({ + scanner, + parser, + utils, + token, + name: Type.UserId, + }); +}); export const linkify = linkifyjs; export const _linkifyElement = linkifyElement; diff --git a/test/linkify-matrix-test.ts b/test/linkify-matrix-test.ts index bedf1664bcc..b3776ab7e9e 100644 --- a/test/linkify-matrix-test.ts +++ b/test/linkify-matrix-test.ts @@ -16,215 +16,252 @@ limitations under the License. import { linkify } from '../src/linkify-matrix'; describe('linkify-matrix', () => { - describe('roomalias', () => { - it('properly parses #_foonetic_xkcd:matrix.org', () => { - const test = '#_foonetic_xkcd:matrix.org'; + const linkTypesByInitialCharacter = { + '#': 'roomalias', + '@': 'userid', + '+': 'groupid', + }; + + /** + * + * @param testName Due to all the tests using the same logic underneath, it makes to generate it in a bit smarter way + * @param char + */ + function genTests(char: '#' | '@' | '+') { + const type = linkTypesByInitialCharacter[char]; + it('should not parse ' + char + 'foo without domain', () => { + const test = char + "foo"; + const found = linkify.find(test); + expect(found).toEqual(([])); + }); + describe('ip v4 tests', () => { + it('should properly parse IPs v4 as the domain name', () => { + const test = char + 'potato:1.2.3.4'; + const found = linkify.find(test); + expect(found).toEqual(([{ + href: char + 'potato:1.2.3.4', + type, + isLink: true, + start: 0, + end: test.length, + value: char + 'potato:1.2.3.4', + }])); + }); + it('should properly parse IPs v4 with port as the domain name with attached', () => { + const test = char + 'potato:1.2.3.4:1337'; + const found = linkify.find(test); + expect(found).toEqual(([{ + href: char + 'potato:1.2.3.4:1337', + type, + isLink: true, + start: 0, + end: test.length, + value: char + 'potato:1.2.3.4:1337', + }])); + }); + it('should properly parse IPs v4 as the domain name while ignoring missing port', () => { + const test = char + 'potato:1.2.3.4:'; + const found = linkify.find(test); + expect(found).toEqual(([{ + href: char + 'potato:1.2.3.4', + type, + isLink: true, + start: 0, + end: test.length - 1, + value: char + 'potato:1.2.3.4', + }])); + }); + }); + // Currently those tests are failing, as there's missing implementation. + describe.skip('ip v6 tests', () => { + it('should properly parse IPs v6 as the domain name', () => { + const test = char + "username:[1234:5678::abcd]"; + const found = linkify.find(test); + expect(found).toEqual([{ + href: char + 'username:[1234:5678::abcd]', + type, + isLink: true, + start: 0, + end: test.length, + value: char + 'username:[1234:5678::abcd]', + }, + ]); + }); + + it('should properly parse IPs v6 with port as the domain name', () => { + const test = char + "username:[1234:5678::abcd]:1337"; + const found = linkify.find(test); + expect(found).toEqual([{ + href: char + 'username:[1234:5678::abcd]:1337', + type, + isLink: true, + start: 0, + end: test.length, + value: char + 'username:[1234:5678::abcd]:1337', + }, + ]); + }); + // eslint-disable-next-line max-len + it('should properly parse IPs v6 while ignoring dangling comma when without port name as the domain name', () => { + const test = char + "username:[1234:5678::abcd]:"; + const found = linkify.find(test); + expect(found).toEqual([{ + href: char + 'username:[1234:5678::abcd]:', + type, + isLink: true, + start: 0, + end: test.length - 1, + value: char + 'username:[1234:5678::abcd]:', + }, + ]); + }); + }); + it('properly parses ' + char + '_foonetic_xkcd:matrix.org', () => { + const test = '' + char + '_foonetic_xkcd:matrix.org'; const found = linkify.find(test); expect(found).toEqual(([{ - href: "#_foonetic_xkcd:matrix.org", - type: "roomalias", - value: "#_foonetic_xkcd:matrix.org", + href: char + "_foonetic_xkcd:matrix.org", + type, + value: char + "_foonetic_xkcd:matrix.org", + start: 0, + end: test.length, + isLink: true, }])); }); - it('properly parses #foo:localhost', () => { - const test = "#foo:localhost"; + it('properly parses ' + char + 'foo:localhost', () => { + const test = char + "foo:localhost"; const found = linkify.find(test); expect(found).toEqual(([{ - href: "#foo:localhost", - type: "roomalias", - value: "#foo:localhost", + href: char + "foo:localhost", + type, + value: char + "foo:localhost", + start: 0, + end: test.length, + isLink: true, }])); }); - it('accept #foo:bar.com', () => { - const test = '#foo:bar.com'; + it('accept ' + char + 'foo:bar.com', () => { + const test = '' + char + 'foo:bar.com'; const found = linkify.find(test); expect(found).toEqual(([{ - href: "#foo:bar.com", - type: "roomalias", - value: "#foo:bar.com", + href: char + "foo:bar.com", + type, + value: char + "foo:bar.com", + start: 0, + end: test.length, + + isLink: true, }])); }); - it('accept #foo:com (mostly for (TLD|DOMAIN)+ mixing)', () => { - const test = '#foo:com'; + it('accept ' + char + 'foo:com (mostly for (TLD|DOMAIN)+ mixing)', () => { + const test = '' + char + 'foo:com'; const found = linkify.find(test); expect(found).toEqual(([{ - href: "#foo:com", - type: "roomalias", - value: "#foo:com", + href: char + "foo:com", + type, + value: char + "foo:com", + start: 0, + end: test.length, + isLink: true, }])); }); it('accept repeated TLDs (e.g .org.uk)', () => { - const test = '#foo:bar.org.uk'; + const test = '' + char + 'foo:bar.org.uk'; const found = linkify.find(test); expect(found).toEqual(([{ - href: "#foo:bar.org.uk", - type: "roomalias", - value: "#foo:bar.org.uk", + href: char + "foo:bar.org.uk", + type, + value: char + "foo:bar.org.uk", + start: 0, + end: test.length, + isLink: true, }])); }); it('ignores trailing `:`', () => { - const test = '#foo:bar.com:'; + const test = '' + char + 'foo:bar.com:'; const found = linkify.find(test); expect(found).toEqual(([{ - href: "#foo:bar.com", - type: "roomalias", - value: "#foo:bar.com", + type, + value: char + "foo:bar.com", + href: char + 'foo:bar.com', + start: 0, + end: test.length - ":".length, + + isLink: true, }])); }); it('accept :NUM (port specifier)', () => { - const test = '#foo:bar.com:2225'; + const test = '' + char + 'foo:bar.com:2225'; const found = linkify.find(test); expect(found).toEqual(([{ - href: "#foo:bar.com:2225", - type: "roomalias", - value: "#foo:bar.com:2225", + href: char + "foo:bar.com:2225", + type, + value: char + "foo:bar.com:2225", + start: 0, + end: test.length, + isLink: true, }])); }); it('ignores all the trailing :', () => { - const test = '#foo:bar.com::::'; + const test = '' + char + 'foo:bar.com::::'; const found = linkify.find(test); expect(found).toEqual(([{ - href: "#foo:bar.com", - type: "roomalias", - value: "#foo:bar.com", + href: char + "foo:bar.com", + type, + value: char + "foo:bar.com", + end: test.length - 4, + start: 0, + isLink: true, }])); }); it('properly parses room alias with dots in name', () => { - const test = '#foo.asdf:bar.com::::'; + const test = '' + char + 'foo.asdf:bar.com::::'; const found = linkify.find(test); expect(found).toEqual(([{ - href: "#foo.asdf:bar.com", - type: "roomalias", - value: "#foo.asdf:bar.com", + href: char + "foo.asdf:bar.com", + type, + value: char + "foo.asdf:bar.com", + start: 0, + end: test.length - ":".repeat(4).length, + + isLink: true, }])); }); it('does not parse room alias with too many separators', () => { - const test = '#foo:::bar.com'; + const test = '' + char + 'foo:::bar.com'; const found = linkify.find(test); expect(found).toEqual(([{ href: "http://bar.com", type: "url", value: "bar.com", + isLink: true, + start: 7, + end: test.length, }])); }); it('does not parse multiple room aliases in one string', () => { - const test = '#foo:bar.com-baz.com'; + const test = '' + char + 'foo:bar.com-baz.com'; const found = linkify.find(test); expect(found).toEqual(([{ - "href": "#foo:bar.com-baz.com", - "type": "roomalias", - "value": "#foo:bar.com-baz.com", + href: char + "foo:bar.com-baz.com", + type, + value: char + "foo:bar.com-baz.com", + end: 20, + start: 0, + isLink: true, }])); }); + } + + describe('roomalias plugin', () => { + genTests('#'); }); - describe('groupid', () => { - it('properly parses +foo:localhost', () => { - const test = "+foo:localhost"; - const found = linkify.find(test); - expect(found).toEqual(([{ - href: "+foo:localhost", - type: "groupid", - value: "+foo:localhost", - }])); - }); - it('accept +foo:bar.com', () => { - const test = '+foo:bar.com'; - const found = linkify.find(test); - expect(found).toEqual(([{ - href: "+foo:bar.com", - type: "groupid", - value: "+foo:bar.com", - }])); - }); - it('accept +foo:com (mostly for (TLD|DOMAIN)+ mixing)', () => { - const test = '+foo:com'; - const found = linkify.find(test); - expect(found).toEqual(([{ - href: "+foo:com", - type: "groupid", - value: "+foo:com", - }])); - }); - it('accept repeated TLDs (e.g .org.uk)', () => { - const test = '+foo:bar.org.uk'; - const found = linkify.find(test); - expect(found).toEqual(([{ - href: "+foo:bar.org.uk", - type: "groupid", - value: "+foo:bar.org.uk", - }])); - }); - it('ignore trailing `:`', () => { - const test = '+foo:bar.com:'; - const found = linkify.find(test); - expect(found).toEqual(([{ - "href": "+foo:bar.com", - "type": "groupid", - "value": "+foo:bar.com", - }])); - }); - it('accept :NUM (port specifier)', () => { - const test = '+foo:bar.com:2225'; - const found = linkify.find(test); - expect(found).toEqual(([{ - href: "+foo:bar.com:2225", - type: "groupid", - value: "+foo:bar.com:2225", - }])); - }); + describe('groupid plugin', () => { + genTests('+'); }); - describe('userid', () => { - it('should not parse @foo without domain', () => { - const test = "@foo"; - const found = linkify.find(test); - expect(found).toEqual(([])); - }); - it('accept @foo:bar.com', () => { - const test = '@foo:bar.com'; - const found = linkify.find(test); - expect(found).toEqual(([{ - href: "@foo:bar.com", - type: "userid", - value: "@foo:bar.com", - }])); - }); - it('accept @foo:com (mostly for (TLD|DOMAIN)+ mixing)', () => { - const test = '@foo:com'; - const found = linkify.find(test); - expect(found).toEqual(([{ - href: "@foo:com", - type: "userid", - value: "@foo:com", - }])); - }); - it('accept repeated TLDs (e.g .org.uk)', () => { - const test = '@foo:bar.org.uk'; - const found = linkify.find(test); - expect(found).toEqual(([{ - href: "@foo:bar.org.uk", - type: "userid", - value: "@foo:bar.org.uk", - }])); - }); - it('do not accept trailing `:`', () => { - const test = '@foo:bar.com:'; - const found = linkify.find(test); - expect(found).toEqual(([{ - href: "@foo:bar.com", - type: "userid", - value: "@foo:bar.com", - }])); - }); - it('accept :NUM (port specifier)', () => { - const test = '@foo:bar.com:2225'; - const found = linkify.find(test); - expect(found).toEqual(([{ - href: "@foo:bar.com:2225", - type: "userid", - value: "@foo:bar.com:2225", - }])); - }); + describe('userid plugin', () => { + genTests('@'); }); }); diff --git a/yarn.lock b/yarn.lock index 4fd8bf3e169..7d95f61f511 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3706,7 +3706,7 @@ eslint-module-utils@^2.7.2: debug "^3.2.7" find-up "^2.1.0" -eslint-plugin-import@^2.25.2, eslint-plugin-import@^2.25.4: +eslint-plugin-import@^2.25.4: version "2.25.4" resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.25.4.tgz#322f3f916a4e9e991ac7af32032c25ce313209f1" integrity sha512-/KJBASVFxpu0xg1kIBn9AUa8hQVnszpwgE7Ld0lKAlx7Ie87yzEzCgSkekt+le/YVhiaosO4Y14GDAOc41nfxA== @@ -5968,10 +5968,20 @@ lines-and-columns@^1.1.6: resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== -linkifyjs@^2.1.9: - version "2.1.9" - resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-2.1.9.tgz#af06e45a2866ff06c4766582590d098a4d584702" - integrity sha512-74ivurkK6WHvHFozVaGtQWV38FzBwSTGNmJolEgFp7QgR2bl6ArUWlvT4GcHKbPe1z3nWYi+VUdDZk16zDOVug== +linkify-element@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/linkify-element/-/linkify-element-3.0.4.tgz#3566a3b48d4c211a684f42a23a9964bf53f3a31a" + integrity sha512-xrj2Upv4/XUxvvczoDwojEnzKnfJCHlorAxYmdFPSGNwLz2sXYkYyB7Lw1flkGS7L0yS0dq/HwOotG0Kpaiaxw== + +linkify-string@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/linkify-string/-/linkify-string-3.0.4.tgz#6abf1a5e436e800c729274ae08f5703484647f84" + integrity sha512-OnNqqRjlYXaXipIAbBC8sDXsSumI1ftatzFg141Pw9HEXWjTVLFcMZoKbFupshqWRavtNJ6QHLa+u6AlxxgeRw== + +linkifyjs@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-3.0.5.tgz#99e51a3a0c0e232fcb63ebb89eea3ff923378f34" + integrity sha512-1Y9XQH65eQKA9p2xtk+zxvnTeQBG7rdAXSkUG97DmuI/Xhji9uaUzaWxRj6rf9YC0v8KKHkxav7tnLX82Sz5Fg== loader-utils@^2.0.0: version "2.0.2"