diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js index 6ede36ee81..7a212b2497 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -2,6 +2,7 @@ Copyright 2015, 2016 OpenMarket Ltd Copyright 2017, 2018 New Vector Ltd Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2019 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. @@ -33,6 +34,7 @@ import url from 'url'; import EMOJIBASE from 'emojibase-data/en/compact.json'; import EMOJIBASE_REGEX from 'emojibase-regex'; +import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks"; linkifyMatrix(linkify); @@ -158,30 +160,10 @@ const transformTags = { // custom to matrix if (attribs.href) { attribs.target = '_blank'; // by default - let m; - // FIXME: horrible duplication with linkify-matrix - m = attribs.href.match(linkifyMatrix.VECTOR_URL_PATTERN); - if (m) { - attribs.href = m[1]; + const transformed = tryTransformPermalinkToLocalHref(attribs.href); + if (transformed !== attribs.href || attribs.href.match(linkifyMatrix.VECTOR_URL_PATTERN)) { + attribs.href = transformed; delete attribs.target; - } else { - m = attribs.href.match(linkifyMatrix.MATRIXTO_URL_PATTERN); - if (m) { - const entity = m[1]; - switch (entity[0]) { - case '@': - attribs.href = '#/user/' + entity; - break; - case '+': - attribs.href = '#/group/' + entity; - break; - case '#': - case '!': - attribs.href = '#/room/' + entity; - break; - } - delete attribs.target; - } } } attribs.rel = 'noopener'; // https://mathiasbynens.github.io/rel-noopener/ @@ -465,10 +447,12 @@ export function bodyToHtml(content, highlights, opts={}) { const match = BIGEMOJI_REGEX.exec(contentBodyTrimmed); emojiBody = match && match[0] && match[0].length === contentBodyTrimmed.length && // Prevent user pills expanding for users with only emoji in - // their username + // their username. Permalinks (links in pills) can be any URL + // now, so we just check for an HTTP-looking thing. ( content.formatted_body == undefined || - !content.formatted_body.includes("https://matrix.to/") + (!content.formatted_body.includes("http:") && + !content.formatted_body.includes("https:")) ); } diff --git a/src/SlashCommands.js b/src/SlashCommands.js index 21c837030b..d9fe28cc6d 100644 --- a/src/SlashCommands.js +++ b/src/SlashCommands.js @@ -23,8 +23,6 @@ import dis from './dispatcher'; import sdk from './index'; import {_t, _td} from './languageHandler'; import Modal from './Modal'; -import {MATRIXTO_URL_PATTERN} from "./linkify-matrix"; -import * as querystring from "querystring"; import MultiInviter from './utils/MultiInviter'; import { linkifyAndSanitizeHtml } from './HtmlUtils'; import QuestionDialog from "./components/views/dialogs/QuestionDialog"; @@ -34,6 +32,7 @@ import Promise from "bluebird"; import { getAddressType } from './UserAddress'; import { abbreviateUrl } from './utils/UrlUtils'; import { getDefaultIdentityServerUrl, useDefaultIdentityServer } from './utils/IdentityServerUtils'; +import {isPermalinkHost, parsePermalink} from "./utils/permalinks/Permalinks"; const singleMxcUpload = async () => { return new Promise((resolve) => { @@ -441,7 +440,19 @@ export const CommandMap = { const params = args.split(' '); if (params.length < 1) return reject(this.getUsage()); - const matrixToMatches = params[0].match(MATRIXTO_URL_PATTERN); + let isPermalink = false; + if (params[0].startsWith("http:") || params[0].startsWith("https:")) { + // It's at least a URL - try and pull out a hostname to check against the + // permalink handler + const parsedUrl = new URL(params[0]); + const hostname = parsedUrl.host || parsedUrl.hostname; // takes first non-falsey value + + // if we're using a Riot permalink handler, this will catch it before we get much further. + // see below where we make assumptions about parsing the URL. + if (isPermalinkHost(hostname)) { + isPermalink = true; + } + } if (params[0][0] === '#') { let roomAlias = params[0]; if (!roomAlias.includes(':')) { @@ -469,29 +480,25 @@ export const CommandMap = { auto_join: true, }); return success(); - } else if (matrixToMatches) { - let entity = matrixToMatches[1]; - let eventId = null; - let viaServers = []; + } else if (isPermalink) { + const permalinkParts = parsePermalink(params[0]); - if (entity[0] !== '!' && entity[0] !== '#') return reject(this.getUsage()); - - if (entity.indexOf('?') !== -1) { - const parts = entity.split('?'); - entity = parts[0]; - - const parsed = querystring.parse(parts[1]); - viaServers = parsed["via"]; - if (typeof viaServers === 'string') viaServers = [viaServers]; + // This check technically isn't needed because we already did our + // safety checks up above. However, for good measure, let's be sure. + if (!permalinkParts) { + return reject(this.getUsage()); } - // We quietly support event ID permalinks too - if (entity.indexOf('/$') !== -1) { - const parts = entity.split("/$"); - entity = parts[0]; - eventId = `$${parts[1]}`; + // If for some reason someone wanted to join a group or user, we should + // stop them now. + if (!permalinkParts.roomIdOrAlias) { + return reject(this.getUsage()); } + const entity = permalinkParts.roomIdOrAlias; + const viaServers = permalinkParts.viaServers; + const eventId = permalinkParts.eventId; + const dispatch = { action: 'view_room', auto_join: true, diff --git a/src/autocomplete/CommunityProvider.js b/src/autocomplete/CommunityProvider.js index 992df0f773..0acfd426fb 100644 --- a/src/autocomplete/CommunityProvider.js +++ b/src/autocomplete/CommunityProvider.js @@ -23,7 +23,7 @@ import QueryMatcher from './QueryMatcher'; import {PillCompletion} from './Components'; import sdk from '../index'; import _sortBy from 'lodash/sortBy'; -import {makeGroupPermalink} from "../matrix-to"; +import {makeGroupPermalink} from "../utils/permalinks/Permalinks"; import type {Completion, SelectionRange} from "./Autocompleter"; import FlairStore from "../stores/FlairStore"; diff --git a/src/autocomplete/RoomProvider.js b/src/autocomplete/RoomProvider.js index 79986657b8..b67abc388e 100644 --- a/src/autocomplete/RoomProvider.js +++ b/src/autocomplete/RoomProvider.js @@ -26,7 +26,7 @@ import {PillCompletion} from './Components'; import {getDisplayAliasForRoom} from '../Rooms'; import sdk from '../index'; import _sortBy from 'lodash/sortBy'; -import {makeRoomPermalink} from "../matrix-to"; +import {makeRoomPermalink} from "../utils/permalinks/Permalinks"; import type {Completion, SelectionRange} from "./Autocompleter"; const ROOM_REGEX = /\B#\S*/g; diff --git a/src/autocomplete/UserProvider.js b/src/autocomplete/UserProvider.js index 451ae0bb83..ac159c8213 100644 --- a/src/autocomplete/UserProvider.js +++ b/src/autocomplete/UserProvider.js @@ -28,7 +28,7 @@ import _sortBy from 'lodash/sortBy'; import MatrixClientPeg from '../MatrixClientPeg'; import type {MatrixEvent, Room, RoomMember, RoomState} from 'matrix-js-sdk'; -import {makeUserPermalink} from "../matrix-to"; +import {makeUserPermalink} from "../utils/permalinks/Permalinks"; import type {Completion, SelectionRange} from "./Autocompleter"; const USER_REGEX = /\B@\S*/g; diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index 70d8b2e298..dd520bb7d4 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -36,7 +36,7 @@ import classnames from 'classnames'; import GroupStore from '../../stores/GroupStore'; import FlairStore from '../../stores/FlairStore'; import { showGroupAddRoomDialog } from '../../GroupAddressPicker'; -import {makeGroupPermalink, makeUserPermalink} from "../../matrix-to"; +import {makeGroupPermalink, makeUserPermalink} from "../../utils/permalinks/Permalinks"; import {Group} from "matrix-js-sdk"; const LONG_DESC_PLACEHOLDER = _td( diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 3446901331..4d52158dae 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -31,7 +31,7 @@ import Promise from 'bluebird'; import classNames from 'classnames'; import {Room} from "matrix-js-sdk"; import { _t } from '../../languageHandler'; -import {RoomPermalinkCreator} from '../../matrix-to'; +import {RoomPermalinkCreator} from '../../utils/permalinks/Permalinks'; import MatrixClientPeg from '../../MatrixClientPeg'; import ContentMessages from '../../ContentMessages'; diff --git a/src/components/views/dialogs/ShareDialog.js b/src/components/views/dialogs/ShareDialog.js index bd6746a1e5..f6d5b65fd6 100644 --- a/src/components/views/dialogs/ShareDialog.js +++ b/src/components/views/dialogs/ShareDialog.js @@ -20,7 +20,7 @@ import {Room, User, Group, RoomMember, MatrixEvent} from 'matrix-js-sdk'; import sdk from '../../../index'; import { _t } from '../../../languageHandler'; import QRCode from 'qrcode-react'; -import {RoomPermalinkCreator, makeGroupPermalink, makeUserPermalink} from "../../../matrix-to"; +import {RoomPermalinkCreator, makeGroupPermalink, makeUserPermalink} from "../../../utils/permalinks/Permalinks"; import * as ContextualMenu from "../../structures/ContextualMenu"; const socials = [ diff --git a/src/components/views/elements/Pill.js b/src/components/views/elements/Pill.js index 4c987a0095..12830488b1 100644 --- a/src/components/views/elements/Pill.js +++ b/src/components/views/elements/Pill.js @@ -1,6 +1,7 @@ /* Copyright 2017 Vector Creations Ltd Copyright 2018 New Vector Ltd +Copyright 2019 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. @@ -22,23 +23,21 @@ import classNames from 'classnames'; import { Room, RoomMember, MatrixClient } from 'matrix-js-sdk'; import PropTypes from 'prop-types'; import MatrixClientPeg from '../../../MatrixClientPeg'; -import { MATRIXTO_URL_PATTERN } from '../../../linkify-matrix'; import { getDisplayAliasForRoom } from '../../../Rooms'; import FlairStore from "../../../stores/FlairStore"; - -const REGEX_MATRIXTO = new RegExp(MATRIXTO_URL_PATTERN); +import {getPrimaryPermalinkEntity} from "../../../utils/permalinks/Permalinks"; // For URLs of matrix.to links in the timeline which have been reformatted by // HttpUtils transformTags to relative links. This excludes event URLs (with `[^\/]*`) -const REGEX_LOCAL_MATRIXTO = /^#\/(?:user|room|group)\/(([#!@+])[^/]*)$/; +const REGEX_LOCAL_PERMALINK = /^#\/(?:user|room|group)\/(([#!@+])[^/]*)$/; const Pill = createReactClass({ statics: { isPillUrl: (url) => { - return !!REGEX_MATRIXTO.exec(url); + return !!getPrimaryPermalinkEntity(url); }, isMessagePillUrl: (url) => { - return !!REGEX_LOCAL_MATRIXTO.exec(url); + return !!REGEX_LOCAL_PERMALINK.exec(url); }, roomNotifPos: (text) => { return text.indexOf("@room"); @@ -95,22 +94,21 @@ const Pill = createReactClass({ }, async componentWillReceiveProps(nextProps) { - let regex = REGEX_MATRIXTO; - if (nextProps.inMessage) { - regex = REGEX_LOCAL_MATRIXTO; - } - - let matrixToMatch; let resourceId; let prefix; if (nextProps.url) { - // Default to the empty array if no match for simplicity - // resource and prefix will be undefined instead of throwing - matrixToMatch = regex.exec(nextProps.url) || []; + if (nextProps.inMessage) { + // Default to the empty array if no match for simplicity + // resource and prefix will be undefined instead of throwing + const matrixToMatch = REGEX_LOCAL_PERMALINK.exec(nextProps.url) || []; - resourceId = matrixToMatch[1]; // The room/user ID - prefix = matrixToMatch[2]; // The first character of prefix + resourceId = matrixToMatch[1]; // The room/user ID + prefix = matrixToMatch[2]; // The first character of prefix + } else { + resourceId = getPrimaryPermalinkEntity(nextProps.url); + prefix = resourceId ? resourceId[0] : undefined; + } } const pillType = this.props.type || { diff --git a/src/components/views/elements/ReplyThread.js b/src/components/views/elements/ReplyThread.js index 08630a16a5..fac0a71617 100644 --- a/src/components/views/elements/ReplyThread.js +++ b/src/components/views/elements/ReplyThread.js @@ -21,7 +21,7 @@ import PropTypes from 'prop-types'; import dis from '../../../dispatcher'; import {wantsDateSeparator} from '../../../DateUtils'; import {MatrixEvent, MatrixClient} from 'matrix-js-sdk'; -import {makeUserPermalink, RoomPermalinkCreator} from "../../../matrix-to"; +import {makeUserPermalink, RoomPermalinkCreator} from "../../../utils/permalinks/Permalinks"; import SettingsStore from "../../../settings/SettingsStore"; // This component does no cycle detection, simply because the only way to make such a cycle would be to diff --git a/src/components/views/messages/RoomCreate.js b/src/components/views/messages/RoomCreate.js index bf0ef32460..9bb6fcc0d8 100644 --- a/src/components/views/messages/RoomCreate.js +++ b/src/components/views/messages/RoomCreate.js @@ -19,7 +19,7 @@ import PropTypes from 'prop-types'; import createReactClass from 'create-react-class'; import dis from '../../../dispatcher'; -import { RoomPermalinkCreator } from '../../../matrix-to'; +import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks'; import { _t } from '../../../languageHandler'; import MatrixClientPeg from '../../../MatrixClientPeg'; diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.js index 7143d02e74..a9e0da143e 100644 --- a/src/components/views/messages/TextualBody.js +++ b/src/components/views/messages/TextualBody.js @@ -30,9 +30,9 @@ import { _t } from '../../../languageHandler'; import * as ContextualMenu from '../../structures/ContextualMenu'; import SettingsStore from "../../../settings/SettingsStore"; import ReplyThread from "../elements/ReplyThread"; -import {host as matrixtoHost} from '../../../matrix-to'; import {pillifyLinks} from '../../../utils/pillify'; import {IntegrationManagers} from "../../../integrations/IntegrationManagers"; +import {isPermalinkHost} from "../../../utils/permalinks/Permalinks"; module.exports = createReactClass({ displayName: 'TextualBody', @@ -248,10 +248,10 @@ module.exports = createReactClass({ const url = node.getAttribute("href"); const host = url.match(/^https?:\/\/(.*?)(\/|$)/)[1]; - // never preview matrix.to links (if anything we should give a smart + // never preview permalinks (if anything we should give a smart // preview of the room/user they point to: nobody needs to be reminded // what the matrix.to site looks like). - if (host === matrixtoHost) return false; + if (isPermalinkHost(host)) return false; if (node.textContent.toLowerCase().trim().startsWith(host.toLowerCase())) { // it's a "foo.pl" style link diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index 022d45e60e..632ca53f82 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -23,7 +23,7 @@ import sdk from '../../../index'; import dis from '../../../dispatcher'; import RoomViewStore from '../../../stores/RoomViewStore'; import Stickerpicker from './Stickerpicker'; -import { makeRoomPermalink } from '../../../matrix-to'; +import { makeRoomPermalink } from '../../../utils/permalinks/Permalinks'; import ContentMessages from '../../../ContentMessages'; import classNames from 'classnames'; import E2EIcon from './E2EIcon'; diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index df7ba27493..cc92f7c750 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -48,13 +48,11 @@ import Markdown from '../../../Markdown'; import MessageComposerStore from '../../../stores/MessageComposerStore'; import ContentMessages from '../../../ContentMessages'; -import {MATRIXTO_URL_PATTERN} from '../../../linkify-matrix'; - import EMOJIBASE from 'emojibase-data/en/compact.json'; import EMOTICON_REGEX from 'emojibase-regex/emoticon'; import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore"; -import {makeUserPermalink} from "../../../matrix-to"; +import {getPrimaryPermalinkEntity, makeUserPermalink} from "../../../utils/permalinks/Permalinks"; import ReplyPreview from "./ReplyPreview"; import RoomViewStore from '../../../stores/RoomViewStore'; import ReplyThread from "../elements/ReplyThread"; @@ -224,18 +222,15 @@ export default class MessageComposerInput extends React.Component { // special case links if (tag === 'a') { const href = el.getAttribute('href'); - let m; - if (href) { - m = href.match(MATRIXTO_URL_PATTERN); - } - if (m) { + const permalinkEntity = getPrimaryPermalinkEntity(href); + if (permalinkEntity) { return { object: 'inline', type: 'pill', data: { href, completion: el.innerText, - completionId: m[1], + completionId: permalinkEntity, }, }; } else { @@ -541,7 +536,7 @@ export default class MessageComposerInput extends React.Component { const textWithMdPills = this.plainWithMdPills.serialize(editorState); const markdown = new Markdown(textWithMdPills); - // HTML deserialize has custom rules to turn matrix.to links into pill objects. + // HTML deserialize has custom rules to turn permalinks into pill objects. return this.html.deserialize(markdown.toHTML()); } diff --git a/src/components/views/rooms/ReplyPreview.js b/src/components/views/rooms/ReplyPreview.js index 58e7237801..caf8feeea2 100644 --- a/src/components/views/rooms/ReplyPreview.js +++ b/src/components/views/rooms/ReplyPreview.js @@ -21,7 +21,7 @@ import { _t } from '../../../languageHandler'; import RoomViewStore from '../../../stores/RoomViewStore'; import SettingsStore from "../../../settings/SettingsStore"; import PropTypes from "prop-types"; -import {RoomPermalinkCreator} from "../../../matrix-to"; +import {RoomPermalinkCreator} from "../../../utils/permalinks/Permalinks"; function cancelQuoting() { dis.dispatch({ diff --git a/src/components/views/rooms/SlateMessageComposer.js b/src/components/views/rooms/SlateMessageComposer.js index d7aa745753..4bb2f29e61 100644 --- a/src/components/views/rooms/SlateMessageComposer.js +++ b/src/components/views/rooms/SlateMessageComposer.js @@ -25,7 +25,7 @@ import dis from '../../../dispatcher'; import RoomViewStore from '../../../stores/RoomViewStore'; import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore"; import Stickerpicker from './Stickerpicker'; -import { makeRoomPermalink } from '../../../matrix-to'; +import { makeRoomPermalink } from '../../../utils/permalinks/Permalinks'; import ContentMessages from '../../../ContentMessages'; import classNames from 'classnames'; diff --git a/src/editor/deserialize.js b/src/editor/deserialize.js index 04edd4541c..d41e413dbc 100644 --- a/src/editor/deserialize.js +++ b/src/editor/deserialize.js @@ -15,11 +15,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MATRIXTO_URL_PATTERN } from '../linkify-matrix'; import { walkDOMDepthFirst } from "./dom"; import { checkBlockNode } from "../HtmlUtils"; - -const REGEX_MATRIXTO = new RegExp(MATRIXTO_URL_PATTERN); +import {getPrimaryPermalinkEntity} from "../utils/permalinks/Permalinks"; function parseAtRoomMentions(text, partCreator) { const ATROOM = "@room"; @@ -41,9 +39,8 @@ function parseAtRoomMentions(text, partCreator) { function parseLink(a, partCreator) { const {href} = a; - const pillMatch = REGEX_MATRIXTO.exec(href) || []; - const resourceId = pillMatch[1]; // The room/user ID - const prefix = pillMatch[2]; // The first character of prefix + const resourceId = getPrimaryPermalinkEntity(href); // The room/user ID + const prefix = resourceId ? resourceId[0] : undefined; // First character of ID switch (prefix) { case "@": return partCreator.userPill(a.textContent, resourceId); diff --git a/src/editor/serialize.js b/src/editor/serialize.js index 07a1ad908e..a55eed97da 100644 --- a/src/editor/serialize.js +++ b/src/editor/serialize.js @@ -16,6 +16,7 @@ limitations under the License. */ import Markdown from '../Markdown'; +import {makeGenericPermalink} from "../utils/permalinks/Permalinks"; export function mdSerialize(model) { return model.parts.reduce((html, part) => { @@ -29,7 +30,7 @@ export function mdSerialize(model) { return html + part.text; case "room-pill": case "user-pill": - return html + `[${part.text}](https://matrix.to/#/${part.resourceId})`; + return html + `[${part.text}](${makeGenericPermalink(part.resourceId)})`; } }, ""); } diff --git a/src/linkify-matrix.js b/src/linkify-matrix.js index a9e894d582..fabd9d15ad 100644 --- a/src/linkify-matrix.js +++ b/src/linkify-matrix.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2019 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. @@ -14,7 +15,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {baseUrl} from "./matrix-to"; +import {baseUrl} from "./utils/permalinks/SpecPermalinkConstructor"; +import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks"; function matrixLinkify(linkify) { // Text tokens @@ -189,13 +191,6 @@ matrixLinkify.MATRIXTO_MD_LINK_PATTERN = '\\[([^\\]]*)\\]\\((?:https?://)?(?:www\\.)?matrix\\.to/#/([#@!+][^\\)]*)\\)'; matrixLinkify.MATRIXTO_BASE_URL= baseUrl; -const matrixToEntityMap = { - '@': '#/user/', - '#': '#/room/', - '!': '#/room/', - '+': '#/group/', -}; - matrixLinkify.options = { events: function(href, type) { switch (type) { @@ -225,20 +220,8 @@ matrixLinkify.options = { case 'roomalias': case 'userid': case 'groupid': - return matrixLinkify.MATRIXTO_BASE_URL + '/#/' + href; default: { - // FIXME: horrible duplication with HtmlUtils' transform tags - let m = href.match(matrixLinkify.VECTOR_URL_PATTERN); - if (m) { - return m[1]; - } - m = href.match(matrixLinkify.MATRIXTO_URL_PATTERN); - if (m) { - const entity = m[1]; - if (matrixToEntityMap[entity[0]]) return matrixToEntityMap[entity[0]] + entity; - } - - return href; + return tryTransformPermalinkToLocalHref(href); } } }, @@ -249,8 +232,8 @@ matrixLinkify.options = { target: function(href, type) { if (type === 'url') { - if (href.match(matrixLinkify.VECTOR_URL_PATTERN) || - href.match(matrixLinkify.MATRIXTO_URL_PATTERN)) { + const transformed = tryTransformPermalinkToLocalHref(href); + if (transformed !== href || href.match(matrixLinkify.VECTOR_URL_PATTERN)) { return null; } else { return '_blank'; diff --git a/src/utils/permalinks/PermalinkConstructor.js b/src/utils/permalinks/PermalinkConstructor.js new file mode 100644 index 0000000000..f74c432bf0 --- /dev/null +++ b/src/utils/permalinks/PermalinkConstructor.js @@ -0,0 +1,83 @@ +/* +Copyright 2019 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. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Interface for classes that actually produce permalinks (strings). + * TODO: Convert this to a real TypeScript interface + */ +export default class PermalinkConstructor { + forEvent(roomId: string, eventId: string, serverCandidates: string[]): string { + throw new Error("Not implemented"); + } + + forRoom(roomIdOrAlias: string, serverCandidates: string[]): string { + throw new Error("Not implemented"); + } + + forGroup(groupId: string): string { + throw new Error("Not implemented"); + } + + forUser(userId: string): string { + throw new Error("Not implemented"); + } + + forEntity(entityId: string): string { + throw new Error("Not implemented"); + } + + isPermalinkHost(host: string): boolean { + throw new Error("Not implemented"); + } + + parsePermalink(fullUrl: string): PermalinkParts { + throw new Error("Not implemented"); + } +} + +// Inspired by/Borrowed with permission from the matrix-bot-sdk: +// https://github.com/turt2live/matrix-js-bot-sdk/blob/7c4665c9a25c2c8e0fe4e509f2616505b5b66a1c/src/Permalinks.ts#L1-L6 +export class PermalinkParts { + roomIdOrAlias: string; + eventId: string; + userId: string; + groupId: string; + viaServers: string[]; + + constructor(roomIdOrAlias: string, eventId: string, userId: string, groupId: string, viaServers: string[]) { + this.roomIdOrAlias = roomIdOrAlias; + this.eventId = eventId; + this.groupId = groupId; + this.userId = userId; + this.viaServers = viaServers; + } + + static forUser(userId: string): PermalinkParts { + return new PermalinkParts(null, null, userId, null, null); + } + + static forGroup(groupId: string): PermalinkParts { + return new PermalinkParts(null, null, null, groupId, null); + } + + static forRoom(roomIdOrAlias: string, viaServers: string[]): PermalinkParts { + return new PermalinkParts(roomIdOrAlias, null, null, null, viaServers || []); + } + + static forEvent(roomId: string, eventId: string, viaServers: string[]): PermalinkParts { + return new PermalinkParts(roomId, eventId, null, null, viaServers || []); + } +} diff --git a/src/matrix-to.js b/src/utils/permalinks/Permalinks.js similarity index 69% rename from src/matrix-to.js rename to src/utils/permalinks/Permalinks.js index 14467cb4c5..19dcbd062a 100644 --- a/src/matrix-to.js +++ b/src/utils/permalinks/Permalinks.js @@ -14,12 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -import MatrixClientPeg from "./MatrixClientPeg"; +import MatrixClientPeg from "../../MatrixClientPeg"; import isIp from "is-ip"; import utils from 'matrix-js-sdk/lib/utils'; +import SpecPermalinkConstructor, {baseUrl as matrixtoBaseUrl} from "./SpecPermalinkConstructor"; +import PermalinkConstructor, {PermalinkParts} from "./PermalinkConstructor"; +import RiotPermalinkConstructor from "./RiotPermalinkConstructor"; +import * as matrixLinkify from "../../linkify-matrix"; -export const host = "matrix.to"; -export const baseUrl = `https://${host}`; +const SdkConfig = require("../../SdkConfig"); // The maximum number of servers to pick when working out which servers // to add to permalinks. The servers are appended as ?via=example.org @@ -73,7 +76,7 @@ export class RoomPermalinkCreator { // We support being given a roomId as a fallback in the event the `room` object // doesn't exist or is not healthy for us to rely on. For example, loading a // permalink to a room which the MatrixClient doesn't know about. - constructor(room, roomId=null) { + constructor(room, roomId = null) { this._room = room; this._roomId = room ? room.roomId : roomId; this._highestPlUserId = null; @@ -124,15 +127,11 @@ export class RoomPermalinkCreator { } forEvent(eventId) { - const roomId = this._roomId; - const permalinkBase = `${baseUrl}/#/${roomId}/${eventId}`; - return `${permalinkBase}${encodeServerCandidates(this._serverCandidates)}`; + return getPermalinkConstructor().forEvent(this._roomId, eventId, this._serverCandidates); } forRoom() { - const roomId = this._roomId; - const permalinkBase = `${baseUrl}/#/${roomId}`; - return `${permalinkBase}${encodeServerCandidates(this._serverCandidates)}`; + return getPermalinkConstructor().forRoom(this._roomId, this._serverCandidates); } onRoomState(event) { @@ -182,8 +181,8 @@ export class RoomPermalinkCreator { } const serverName = getServerName(userId); return !isHostnameIpAddress(serverName) && - !isHostInRegex(serverName, this._bannedHostsRegexps) && - isHostInRegex(serverName, this._allowedHostsRegexps); + !isHostInRegex(serverName, this._bannedHostsRegexps) && + isHostInRegex(serverName, this._allowedHostsRegexps); }); const maxEntry = allowedEntries.reduce((max, entry) => { return (entry[1] > max[1]) ? entry : max; @@ -221,7 +220,7 @@ export class RoomPermalinkCreator { } _updatePopulationMap() { - const populationMap: {[server:string]:number} = {}; + const populationMap: { [server: string]: number } = {}; for (const member of this._room.getJoinedMembers()) { const serverName = getServerName(member.userId); if (!populationMap[serverName]) { @@ -242,9 +241,9 @@ export class RoomPermalinkCreator { .sort((a, b) => this._populationMap[b] - this._populationMap[a]) .filter(a => { return !candidates.includes(a) && - !isHostnameIpAddress(a) && - !isHostInRegex(a, this._bannedHostsRegexps) && - isHostInRegex(a, this._allowedHostsRegexps); + !isHostnameIpAddress(a) && + !isHostInRegex(a, this._bannedHostsRegexps) && + isHostInRegex(a, this._allowedHostsRegexps); }); const remainingServers = serversByPopulation.slice(0, MAX_SERVER_CANDIDATES - candidates.length); @@ -254,25 +253,27 @@ export class RoomPermalinkCreator { } } +export function makeGenericPermalink(entityId: string): string { + return getPermalinkConstructor().forEntity(entityId); +} + export function makeUserPermalink(userId) { - return `${baseUrl}/#/${userId}`; + return getPermalinkConstructor().forUser(userId); } export function makeRoomPermalink(roomId) { - const permalinkBase = `${baseUrl}/#/${roomId}`; - if (!roomId) { throw new Error("can't permalink a falsey roomId"); } // If the roomId isn't actually a room ID, don't try to list the servers. // Aliases are already routable, and don't need extra information. - if (roomId[0] !== '!') return permalinkBase; + if (roomId[0] !== '!') return getPermalinkConstructor().forRoom(roomId, []); const client = MatrixClientPeg.get(); const room = client.getRoom(roomId); if (!room) { - return permalinkBase; + return getPermalinkConstructor().forRoom(roomId, []); } const permalinkCreator = new RoomPermalinkCreator(room); permalinkCreator.load(); @@ -280,12 +281,96 @@ export function makeRoomPermalink(roomId) { } export function makeGroupPermalink(groupId) { - return `${baseUrl}/#/${groupId}`; + return getPermalinkConstructor().forGroup(groupId); } -export function encodeServerCandidates(candidates) { - if (!candidates || candidates.length === 0) return ''; - return `?via=${candidates.map(c => encodeURIComponent(c)).join("&via=")}`; +export function isPermalinkHost(host: string): boolean { + // Always check if the permalink is a spec permalink (callers are likely to call + // parsePermalink after this function). + if (new SpecPermalinkConstructor().isPermalinkHost(host)) return true; + return getPermalinkConstructor().isPermalinkHost(host); +} + +/** + * Transforms a permalink (or possible permalink) into a local URL if possible. If + * the given permalink is found to not be a permalink, it'll be returned unaltered. + * @param {string} permalink The permalink to try and transform. + * @returns {string} The transformed permalink or original URL if unable. + */ +export function tryTransformPermalinkToLocalHref(permalink: string): string { + if (!permalink.startsWith("http:") && !permalink.startsWith("https:")) { + return permalink; + } + + const m = permalink.match(matrixLinkify.VECTOR_URL_PATTERN); + if (m) { + return m[1]; + } + + // A bit of a hack to convert permalinks of unknown origin to Riot links + try { + const permalinkParts = parsePermalink(permalink); + if (permalinkParts) { + if (permalinkParts.roomIdOrAlias) { + const eventIdPart = permalinkParts.eventId ? `/${permalinkParts.eventId}` : ''; + permalink = `#/room/${permalinkParts.roomIdOrAlias}${eventIdPart}`; + } else if (permalinkParts.groupId) { + permalink = `#/group/${permalinkParts.groupId}`; + } else if (permalinkParts.userId) { + permalink = `#/user/${permalinkParts.userId}`; + } // else not a valid permalink for our purposes - do not handle + } + } catch (e) { + // Not an href we need to care about + } + + return permalink; +} + +export function getPrimaryPermalinkEntity(permalink: string): string { + try { + let permalinkParts = parsePermalink(permalink); + + // If not a permalink, try the vector patterns. + if (!permalinkParts) { + const m = permalink.match(matrixLinkify.VECTOR_URL_PATTERN); + if (m) { + // A bit of a hack, but it gets the job done + const handler = new RiotPermalinkConstructor("http://localhost"); + const entityInfo = m[1].split('#').slice(1).join('#'); + permalinkParts = handler.parsePermalink(`http://localhost/#${entityInfo}`); + } + } + + if (!permalinkParts) return null; // not processable + if (permalinkParts.userId) return permalinkParts.userId; + if (permalinkParts.groupId) return permalinkParts.groupId; + if (permalinkParts.roomIdOrAlias) return permalinkParts.roomIdOrAlias; + } catch (e) { + // no entity - not a permalink + } + + return null; +} + +function getPermalinkConstructor(): PermalinkConstructor { + const riotPrefix = SdkConfig.get()['permalinkPrefix']; + if (riotPrefix && riotPrefix !== matrixtoBaseUrl) { + return new RiotPermalinkConstructor(riotPrefix); + } + + return new SpecPermalinkConstructor(); +} + +export function parsePermalink(fullUrl: string): PermalinkParts { + const riotPrefix = SdkConfig.get()['permalinkPrefix']; + if (fullUrl.startsWith(matrixtoBaseUrl)) { + return new SpecPermalinkConstructor().parsePermalink(fullUrl); + } else if (riotPrefix && fullUrl.startsWith(riotPrefix)) { + return new RiotPermalinkConstructor(riotPrefix).parsePermalink(fullUrl); + } + + return null; // not a permalink we can handle } function getServerName(userId) { diff --git a/src/utils/permalinks/RiotPermalinkConstructor.js b/src/utils/permalinks/RiotPermalinkConstructor.js new file mode 100644 index 0000000000..176100aa8b --- /dev/null +++ b/src/utils/permalinks/RiotPermalinkConstructor.js @@ -0,0 +1,111 @@ +/* +Copyright 2019 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. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import PermalinkConstructor, {PermalinkParts} from "./PermalinkConstructor"; + +/** + * Generates permalinks that self-reference the running webapp + */ +export default class RiotPermalinkConstructor extends PermalinkConstructor { + _riotUrl: string; + + constructor(riotUrl: string) { + super(); + this._riotUrl = riotUrl; + + if (!this._riotUrl.startsWith("http:") && !this._riotUrl.startsWith("https:")) { + throw new Error("Riot prefix URL does not appear to be an HTTP(S) URL"); + } + } + + forEvent(roomId: string, eventId: string, serverCandidates: string[]): string { + return `${this._riotUrl}/#/room/${roomId}/${eventId}${this.encodeServerCandidates(serverCandidates)}`; + } + + forRoom(roomIdOrAlias: string, serverCandidates: string[]): string { + return `${this._riotUrl}/#/room/${roomIdOrAlias}${this.encodeServerCandidates(serverCandidates)}`; + } + + forUser(userId: string): string { + return `${this._riotUrl}/#/user/${userId}`; + } + + forGroup(groupId: string): string { + return `${this._riotUrl}/#/group/${groupId}`; + } + + forEntity(entityId: string): string { + if (entityId[0] === '!' || entityId[0] === '#') { + return this.forRoom(entityId); + } else if (entityId[0] === '@') { + return this.forUser(entityId); + } else if (entityId[0] === '+') { + return this.forGroup(entityId); + } else throw new Error("Unrecognized entity"); + } + + isPermalinkHost(testHost: string): boolean { + const parsedUrl = new URL(this._riotUrl); + return testHost === (parsedUrl.host || parsedUrl.hostname); // one of the hosts should match + } + + encodeServerCandidates(candidates: string[]) { + if (!candidates || candidates.length === 0) return ''; + return `?via=${candidates.map(c => encodeURIComponent(c)).join("&via=")}`; + } + + // Heavily inspired by/borrowed from the matrix-bot-sdk (with permission): + // https://github.com/turt2live/matrix-js-bot-sdk/blob/7c4665c9a25c2c8e0fe4e509f2616505b5b66a1c/src/Permalinks.ts#L33-L61 + // Adapted for Riot's URL format + parsePermalink(fullUrl: string): PermalinkParts { + if (!fullUrl || !fullUrl.startsWith(this._riotUrl)) { + throw new Error("Does not appear to be a permalink"); + } + + const parts = fullUrl.substring(`${this._riotUrl}/#/`.length).split("/"); + if (parts.length < 2) { // we're expecting an entity and an ID of some kind at least + throw new Error("URL is missing parts"); + } + + const entityType = parts[0]; + const entity = parts[1]; + if (entityType === 'user') { + // Probably a user, no further parsing needed. + return PermalinkParts.forUser(entity); + } else if (entityType === 'group') { + // Probably a group, no further parsing needed. + return PermalinkParts.forGroup(entity); + } else if (entityType === 'room') { + if (parts.length === 2) { + return PermalinkParts.forRoom(entity, []); + } + + // rejoin the rest because v3 events can have slashes (annoyingly) + const eventIdAndQuery = parts.length > 2 ? parts.slice(2).join('/') : ""; + const secondaryParts = eventIdAndQuery.split("?"); + + const eventId = secondaryParts[0]; + const query = secondaryParts.length > 1 ? secondaryParts[1] : ""; + + // TODO: Verify Riot works with via args + const via = query.split("via=").filter(p => !!p); + + return PermalinkParts.forEvent(entity, eventId, via); + } else { + throw new Error("Unknown entity type in permalink"); + } + } +} diff --git a/src/utils/permalinks/SpecPermalinkConstructor.js b/src/utils/permalinks/SpecPermalinkConstructor.js new file mode 100644 index 0000000000..1c80ff8975 --- /dev/null +++ b/src/utils/permalinks/SpecPermalinkConstructor.js @@ -0,0 +1,94 @@ +/* +Copyright 2019 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. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import PermalinkConstructor, {PermalinkParts} from "./PermalinkConstructor"; + +export const host = "matrix.to"; +export const baseUrl = `https://${host}`; + +/** + * Generates matrix.to permalinks + */ +export default class SpecPermalinkConstructor extends PermalinkConstructor { + constructor() { + super(); + } + + forEvent(roomId: string, eventId: string, serverCandidates: string[]): string { + return `${baseUrl}/#/${roomId}/${eventId}${this.encodeServerCandidates(serverCandidates)}`; + } + + forRoom(roomIdOrAlias: string, serverCandidates: string[]): string { + return `${baseUrl}/#/${roomIdOrAlias}${this.encodeServerCandidates(serverCandidates)}`; + } + + forUser(userId: string): string { + return `${baseUrl}/#/${userId}`; + } + + forGroup(groupId: string): string { + return `${baseUrl}/#/${groupId}`; + } + + forEntity(entityId: string): string { + return `${baseUrl}/#/${entityId}`; + } + + isPermalinkHost(testHost: string): boolean { + return testHost === host; + } + + encodeServerCandidates(candidates: string[]) { + if (!candidates || candidates.length === 0) return ''; + return `?via=${candidates.map(c => encodeURIComponent(c)).join("&via=")}`; + } + + // Heavily inspired by/borrowed from the matrix-bot-sdk (with permission): + // https://github.com/turt2live/matrix-js-bot-sdk/blob/7c4665c9a25c2c8e0fe4e509f2616505b5b66a1c/src/Permalinks.ts#L33-L61 + parsePermalink(fullUrl: string): PermalinkParts { + if (!fullUrl || !fullUrl.startsWith(baseUrl)) { + throw new Error("Does not appear to be a permalink"); + } + + const parts = fullUrl.substring(`${baseUrl}/#/`.length).split("/"); + + const entity = parts[0]; + if (entity[0] === '@') { + // Probably a user, no further parsing needed. + return PermalinkParts.forUser(entity); + } else if (entity[0] === '+') { + // Probably a group, no further parsing needed. + return PermalinkParts.forGroup(entity); + } else if (entity[0] === '#' || entity[0] === '!') { + if (parts.length === 1) { + return PermalinkParts.forRoom(entity, []); + } + + // rejoin the rest because v3 events can have slashes (annoyingly) + const eventIdAndQuery = parts.length > 1 ? parts.slice(1).join('/') : ""; + const secondaryParts = eventIdAndQuery.split("?"); + + const eventId = secondaryParts[0]; + const query = secondaryParts.length > 1 ? secondaryParts[1] : ""; + + const via = query.split("via=").filter(p => !!p); + + return PermalinkParts.forEvent(entity, eventId, via); + } else { + throw new Error("Unknown entity type in permalink"); + } + } +} diff --git a/test/matrix-to-test.js b/test/utils/permalinks/Permalinks-test.js similarity index 98% rename from test/matrix-to-test.js rename to test/utils/permalinks/Permalinks-test.js index 23434a57e2..27f06b44cb 100644 --- a/test/matrix-to-test.js +++ b/test/utils/permalinks/Permalinks-test.js @@ -1,5 +1,7 @@ /* Copyright 2018 New Vector Ltd +Copyright 2019 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. You may obtain a copy of the License at @@ -12,14 +14,14 @@ limitations under the License. */ import expect from 'expect'; -import peg from '../src/MatrixClientPeg'; +import peg from '../../../src/MatrixClientPeg'; import { makeGroupPermalink, makeRoomPermalink, makeUserPermalink, RoomPermalinkCreator, -} from "../src/matrix-to"; -import * as testUtils from "./test-utils"; +} from "../../../src/utils/permalinks/Permalinks"; +import * as testUtils from "../../test-utils"; function mockRoom(roomId, members, serverACL) { members.forEach(m => m.membership = "join"); @@ -62,7 +64,7 @@ function mockRoom(roomId, members, serverACL) { }; } -describe('matrix-to', function() { +describe('Permalinks', function() { let sandbox; beforeEach(function() {