diff --git a/app/actions/remote/command.ts b/app/actions/remote/command.ts index 9360f62b5..93826f502 100644 --- a/app/actions/remote/command.ts +++ b/app/actions/remote/command.ts @@ -143,7 +143,7 @@ export const handleGotoLocation = async (serverUrl: string, intl: IntlShape, loc const match = matchDeepLink(location, serverUrl, config?.SiteURL); if (match) { - handleDeepLink(match, intl, location); + handleDeepLink(match.url, intl, location); } else { const {formatMessage} = intl; const onError = () => Alert.alert( diff --git a/app/components/markdown/markdown_link/markdown_link.tsx b/app/components/markdown/markdown_link/markdown_link.tsx index 4cc662260..2b65bdc3a 100644 --- a/app/components/markdown/markdown_link/markdown_link.tsx +++ b/app/components/markdown/markdown_link/markdown_link.tsx @@ -77,9 +77,9 @@ const MarkdownLink = ({children, experimentalNormalizeMarkdownLinks, href, siteU const match = matchDeepLink(url, serverUrl, siteURL); if (match) { - const {error} = await handleDeepLink(match, intl); + const {error} = await handleDeepLink(match.url, intl); if (error) { - tryOpenURL(match, onError); + tryOpenURL(match.url, onError); } } else { tryOpenURL(url, onError); diff --git a/app/utils/deep_link/index.ts b/app/utils/deep_link/index.ts index 7b2bb982d..c4ec593de 100644 --- a/app/utils/deep_link/index.ts +++ b/app/utils/deep_link/index.ts @@ -1,12 +1,14 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {match} from 'path-to-regexp'; import {createIntl, type IntlShape} from 'react-intl'; import urlParse from 'url-parse'; import {makeDirectChannel, switchToChannelByName} from '@actions/remote/channel'; import {showPermalink} from '@actions/remote/permalink'; import {fetchUsersByUsernames} from '@actions/remote/user'; +import DeepLinkType from '@app/constants/deep_linking'; import {DeepLink, Launch, Screens} from '@constants'; import {getDefaultThemeByAppearance} from '@context/theme'; import DatabaseManager from '@database/manager'; @@ -21,7 +23,14 @@ import {alertErrorWithFallback, errorBadChannel, errorUnkownUser} from '@utils/d import {logError} from '@utils/log'; import {escapeRegex} from '@utils/markdown'; import {addNewServer} from '@utils/server'; -import {removeProtocol} from '@utils/url'; +import { + TEAM_NAME_PATH_PATTERN, + IDENTIFIER_PATH_PATTERN, + ID_PATH_PATTERN, + PLUGIN_ID_PATH_PATTERN, +} from '@utils/url/path'; + +import {removeProtocol} from '../url'; import type {DeepLinkChannel, DeepLinkDM, DeepLinkGM, DeepLinkPermalink, DeepLinkWithData, LaunchProps} from '@typings/launch'; import type {AvailableScreens} from '@typings/screens/navigation'; @@ -86,7 +95,7 @@ export async function handleDeepLink(deepLinkUrl: string, intlShape?: IntlShape, } case DeepLink.GroupMessage: { const deepLinkData = parsed.data as DeepLinkGM; - switchToChannelByName(existingServerUrl, deepLinkData.channelId, deepLinkData.teamName, errorBadChannel, intl); + switchToChannelByName(existingServerUrl, deepLinkData.channelName, deepLinkData.teamName, errorBadChannel, intl); break; } case DeepLink.Permalink: { @@ -114,80 +123,89 @@ export async function handleDeepLink(deepLinkUrl: string, intlShape?: IntlShape, } } +type ChannelPathParams = { + hostname: string; + serverUrl: string; + teamName: string; + path: 'channels' | 'messages'; + identifier: string; +}; + +const CHANNEL_PATH = `:serverUrl(.*)/:teamName(${TEAM_NAME_PATH_PATTERN})/:path(channels|messages)/:identifier(${IDENTIFIER_PATH_PATTERN})`; +export const matchChannelDeeplink = match(CHANNEL_PATH); + +type PermalinkPathParams = { + serverUrl: string; + teamName: string; + postId: string; +}; +const PERMALINK_PATH = `:serverUrl(.*)/:teamName(${TEAM_NAME_PATH_PATTERN})/pl/:postId(${ID_PATH_PATTERN})`; +export const matchPermalinkDeeplink = match(PERMALINK_PATH); + export function parseDeepLink(deepLinkUrl: string): DeepLinkWithData { try { - const url = removeProtocol(decodeURIComponent(deepLinkUrl)); + const url = removeProtocol(deepLinkUrl); - if (url.includes('../') || url.includes('/..')) { - return {type: DeepLink.Invalid, url: deepLinkUrl}; + const channelMatch = matchChannelDeeplink(url); + if (channelMatch) { + const {params: {serverUrl, teamName, path, identifier}} = channelMatch; + + if (path === 'channels') { + return {type: DeepLink.Channel, url: deepLinkUrl, data: {serverUrl, teamName, channelName: identifier}}; + } + + if (path === 'messages') { + if (identifier.startsWith('@')) { + return {type: DeepLink.DirectMessage, url: deepLinkUrl, data: {serverUrl, teamName, userName: identifier.substring(1)}}; + } + + return {type: DeepLink.GroupMessage, url: deepLinkUrl, data: {serverUrl, teamName, channelName: identifier}}; + } } - let match = new RegExp('(.*)\\/([^\\/]+)\\/channels\\/(\\S+)').exec(url); - if (match) { - return {type: DeepLink.Channel, url: deepLinkUrl, data: {serverUrl: match[1], teamName: match[2], channelName: match[3]}}; + const permalinkMatch = matchPermalinkDeeplink(url); + if (permalinkMatch) { + const {params: {serverUrl, teamName, postId}} = permalinkMatch; + return {type: DeepLink.Permalink, url: deepLinkUrl, data: {serverUrl, teamName, postId}}; } - match = new RegExp('(.*)\\/([^\\/]+)\\/pl\\/(\\w+)').exec(url); - if (match) { - return {type: DeepLink.Permalink, url: deepLinkUrl, data: {serverUrl: match[1], teamName: match[2], postId: match[3]}}; + const pluginMatch = match<{serverUrl: string; id: string; route?: string}>(`:serverUrl(.*)/plugins/:id(${PLUGIN_ID_PATH_PATTERN})/:route(.*)?`)(url); + if (pluginMatch) { + const {params: {serverUrl, id, route}} = pluginMatch; + return {type: DeepLink.Plugin, url: deepLinkUrl, data: {serverUrl, teamName: '', id, route}}; } - - match = new RegExp('(.*)\\/([^\\/]+)\\/messages\\/@(\\S+)').exec(url); - if (match) { - return {type: DeepLink.DirectMessage, url: deepLinkUrl, data: {serverUrl: match[1], teamName: match[2], userName: match[3]}}; - } - - match = new RegExp('(.*)\\/([^\\/]+)\\/messages\\/(\\S+)').exec(url); - if (match) { - return {type: DeepLink.GroupMessage, url: deepLinkUrl, data: {serverUrl: match[1], teamName: match[2], channelId: match[3]}}; - } - - match = new RegExp('(.*)\\/plugins\\/([^\\/]+)\\/(\\S+)').exec(url); - if (match) { - return {type: DeepLink.Plugin, url: deepLinkUrl, data: {serverUrl: match[1], id: match[2], teamName: ''}}; - } - } catch { + } catch (err) { // do nothing just return invalid deeplink } return {type: DeepLink.Invalid, url: deepLinkUrl}; } -export function matchDeepLink(url?: string, serverURL?: string, siteURL?: string) { +export function matchDeepLink(url: string, serverURL?: string, siteURL?: string) { if (!url || (!serverURL && !siteURL)) { - return ''; + return null; } let urlToMatch = url; const urlBase = serverURL || siteURL || ''; const parsedUrl = urlParse(url); - const parsedBase = urlParse(urlBase); if (!parsedUrl.protocol) { // If url doesn't contain site or server URL, tack it on. // e.g. URLs from autolink plugin. - const match = new RegExp(escapeRegex(urlBase)).exec(url); - if (!match) { + const deepLinkMatch = new RegExp(escapeRegex(urlBase)).exec(url); + if (!deepLinkMatch) { urlToMatch = urlBase + url; } } - const finalUrl = urlParse(urlToMatch); - const baseSubpath = parsedBase.pathname.replace('/', ''); - const baseHostname = parsedBase.hostname; - const urlSubpath = finalUrl.pathname.split('/')[1]; - const urlHostname = finalUrl.hostname; + const parsed = parseDeepLink(urlToMatch); - if (baseSubpath) { - // if the server is in a subpath - if (urlHostname === baseHostname && urlSubpath === baseSubpath) { - return urlToMatch; - } - } else if (urlHostname === baseHostname) { - return urlToMatch; + if (parsed.type === DeepLinkType.Invalid) { + return null; } - return ''; + return parsed; } export const getLaunchPropsFromDeepLink = (deepLinkUrl: string, coldStart = false): LaunchProps => { diff --git a/app/utils/url/path.ts b/app/utils/url/path.ts new file mode 100644 index 000000000..19a579512 --- /dev/null +++ b/app/utils/url/path.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export const ID_PATH_PATTERN = '[a-z0-9]{26}'; +export const PLUGIN_ID_PATH_PATTERN = '[a-zA-Z0-9-_.]{3,190}'; + +// This should cover: +// - Team name (lowercase english characters, numbers or -) +// - Two ids separated by __ (userID__userID) +export const TEAM_NAME_PATH_PATTERN = '[a-z0-9\\-_]+'; + +// This should cover: +// - Channel name +// - Channel ID +// - Group Channel Name (40 length UID) +// - DM Name (userID__userID) +// - Username prefixed by a @ +// - User ID +// - Email +export const IDENTIFIER_PATH_PATTERN = '[@a-zA-Z\\-_0-9][@a-zA-Z\\-_0-9.]*'; diff --git a/app/utils/url/test.ts b/app/utils/url/test.ts index f7057e1f4..9e8f55af8 100644 --- a/app/utils/url/test.ts +++ b/app/utils/url/test.ts @@ -5,7 +5,7 @@ import {Linking} from 'react-native'; import DeepLinkType from '@constants/deep_linking'; import TestHelper from '@test/test_helper'; -import {matchDeepLink, parseDeepLink} from '@utils/deep_link'; +import {matchDeepLink} from '@utils/deep_link'; import * as UrlUtils from '@utils/url'; /* eslint-disable max-nested-callbacks */ @@ -129,32 +129,35 @@ describe('UrlUtils', () => { describe('matchDeepLink', () => { const URL_NO_PROTOCOL = 'localhost:8065'; - const URL_PATH_NO_PROTOCOL = 'localhost:8065/subpath'; + const URL_PATH_NO_PROTOCOL = 'localhost:8065/subpath/deepsubpath'; const SITE_URL = `http://${URL_NO_PROTOCOL}`; const SERVER_URL = `http://${URL_NO_PROTOCOL}`; const SERVER_WITH_SUBPATH = `http://${URL_PATH_NO_PROTOCOL}`; const DEEPLINK_URL_ROOT = `mattermost://${URL_NO_PROTOCOL}`; + const DM_USER = TestHelper.fakeUserWithId(); + const GM_CHANNEL_NAME = '4862db64e76a321d167fe6677f16e96e9275dabe'; + const tests = [ { name: 'should return null if all inputs are empty', input: {url: '', serverURL: '', siteURL: ''}, - expected: {type: 'invalid'}, + expected: null, }, { name: 'should return null if any of the input is null', input: {url: '', serverURL: '', siteURL: null}, - expected: {type: 'invalid'}, + expected: null, }, { name: 'should return null if any of the input is null', input: {url: '', serverURL: null, siteURL: ''}, - expected: {type: 'invalid'}, + expected: null, }, { name: 'should return null if any of the input is null', input: {url: null, serverURL: '', siteURL: ''}, - expected: {type: 'invalid'}, + expected: null, }, { name: 'should return null for not supported link', @@ -163,12 +166,12 @@ describe('UrlUtils', () => { serverURL: SERVER_URL, siteURL: SITE_URL, }, - expected: {type: 'invalid'}, + expected: null, }, { name: 'should return null despite url subset match', input: {url: 'http://myserver.com', serverURL: 'http://myserver.co'}, - expected: {type: 'invalid'}, + expected: null, }, { name: 'should match despite no server URL in input link', @@ -186,6 +189,51 @@ describe('UrlUtils', () => { type: DeepLinkType.Permalink, }, }, + { + name: 'should return null for invalid deeplink', + input: { + url: DEEPLINK_URL_ROOT + '/ad-1/channels/../town-square', + serverURL: SERVER_URL, + siteURL: SITE_URL, + }, + expected: null, + }, + { + name: 'should return null for backslash-invalid deeplink', + input: { + url: DEEPLINK_URL_ROOT + '/ad-1/channels/\\..town-square', + serverURL: SERVER_URL, + siteURL: SITE_URL, + }, + expected: null, + }, + { + name: 'should return null for backslash-invalid-alt deeplink', + input: { + url: DEEPLINK_URL_ROOT + '/ad-1/channels/t..\\town-square', + serverURL: SERVER_URL, + siteURL: SITE_URL, + }, + expected: null, + }, + { + name: 'should return null for double encoded invalid deeplink', + input: { + url: DEEPLINK_URL_ROOT + '/ad-1/channels/%252f%252e.town-square', + serverURL: SERVER_URL, + siteURL: SITE_URL, + }, + expected: null, + }, + { + name: 'should return null for double encoded backslash-invalid deeplink', + input: { + url: DEEPLINK_URL_ROOT + '/ad-1/channels/%255C%252e.town-square', + serverURL: SERVER_URL, + siteURL: SITE_URL, + }, + expected: null, + }, { name: 'should match channel link', input: { @@ -234,10 +282,74 @@ describe('UrlUtils', () => { type: DeepLinkType.Channel, }, }, + { + name: 'should match channel link (channel name: messages) with deeplink prefix', + input: { + url: DEEPLINK_URL_ROOT + '/ad-1/channels/messages', + serverURL: SERVER_URL, + siteURL: SITE_URL, + }, + expected: { + data: { + channelName: 'messages', + serverUrl: URL_NO_PROTOCOL, + teamName: 'ad-1', + }, + type: DeepLinkType.Channel, + }, + }, + { + name: 'should match DM channel link with deeplink prefix', + input: { + url: DEEPLINK_URL_ROOT + `/pl/messages/@${DM_USER.username}`, + serverURL: SERVER_URL, + siteURL: SITE_URL, + }, + expected: { + data: { + userName: DM_USER.username, + serverUrl: URL_NO_PROTOCOL, + teamName: 'pl', + }, + type: DeepLinkType.DirectMessage, + }, + }, + { + name: 'should match GM channel link with deeplink prefix', + input: { + url: DEEPLINK_URL_ROOT + `/pl/messages/${GM_CHANNEL_NAME}`, + serverURL: SERVER_URL, + siteURL: SITE_URL, + }, + expected: { + data: { + channelName: GM_CHANNEL_NAME, + serverUrl: URL_NO_PROTOCOL, + teamName: 'pl', + }, + type: DeepLinkType.GroupMessage, + }, + }, + { + name: 'should match channel link (team name: pl, channel name: messages) with deeplink prefix', + input: { + url: DEEPLINK_URL_ROOT + '/pl/channels/messages', + serverURL: SERVER_URL, + siteURL: SITE_URL, + }, + expected: { + data: { + channelName: 'messages', + serverUrl: URL_NO_PROTOCOL, + teamName: 'pl', + }, + type: DeepLinkType.Channel, + }, + }, { name: 'should match permalink with deeplink prefix on a Server hosted in a Subpath', input: { - url: DEEPLINK_URL_ROOT + '/subpath/ad-1/pl/qe93kkfd7783iqwuwfcwcxbsrr', + url: DEEPLINK_URL_ROOT + '/subpath/deepsubpath/ad-1/pl/qe93kkfd7783iqwuwfcwcxbsrr', serverURL: SERVER_WITH_SUBPATH, siteURL: SERVER_WITH_SUBPATH, }, @@ -247,7 +359,23 @@ describe('UrlUtils', () => { serverUrl: URL_PATH_NO_PROTOCOL, teamName: 'ad-1', }, - type: 'permalink', + type: DeepLinkType.Permalink, + }, + }, + { + name: 'should match permalink (team name: pl) with deeplink prefix on a Server hosted in a Subpath', + input: { + url: DEEPLINK_URL_ROOT + '/subpath/deepsubpath/pl/pl/qe93kkfd7783iqwuwfcwcxbsrr', + serverURL: SERVER_WITH_SUBPATH, + siteURL: SERVER_WITH_SUBPATH, + }, + expected: { + data: { + postId: 'qe93kkfd7783iqwuwfcwcxbsrr', + serverUrl: URL_PATH_NO_PROTOCOL, + teamName: 'pl', + }, + type: DeepLinkType.Permalink, }, }, { @@ -263,7 +391,7 @@ describe('UrlUtils', () => { serverUrl: URL_PATH_NO_PROTOCOL, teamName: 'ad-1', }, - type: 'permalink', + type: DeepLinkType.Permalink, }, }, { @@ -273,8 +401,75 @@ describe('UrlUtils', () => { serverURL: SERVER_WITH_SUBPATH, siteURL: SERVER_WITH_SUBPATH, }, + expected: null, + }, + { + name: 'should match plugin path on a Server hosted in a Subpath', + input: { + url: DEEPLINK_URL_ROOT + '/subpath/deepsubpath/plugins/com.acme.abc-test/api/testroute', + serverURL: SERVER_WITH_SUBPATH, + siteURL: SERVER_WITH_SUBPATH, + }, expected: { - type: 'invalid', + data: { + id: 'com.acme.abc-test', + route: 'api/testroute', + serverUrl: URL_PATH_NO_PROTOCOL, + teamName: '', + }, + type: DeepLinkType.Plugin, + }, + }, + { + name: 'should match plugin path with single-level route', + input: { + url: DEEPLINK_URL_ROOT + '/subpath/deepsubpath/plugins/abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_ab/testroute', + serverURL: SERVER_WITH_SUBPATH, + siteURL: SERVER_WITH_SUBPATH, + }, + expected: { + data: { + id: 'abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_ab', + route: 'testroute', + serverUrl: URL_PATH_NO_PROTOCOL, + teamName: '', + }, + type: DeepLinkType.Plugin, + }, + }, + { + name: 'should not match plugin path with invalid plugin id len=2', + input: { + url: DEEPLINK_URL_ROOT + '/subpath/deepsubpath/plugins/ab/api/testroute', + serverURL: SERVER_WITH_SUBPATH, + siteURL: SERVER_WITH_SUBPATH, + }, + expected: null, + }, + { + name: 'should not match plugin path with invalid plugin id len=191', + input: { + url: DEEPLINK_URL_ROOT + '/subpath/deepsubpath/plugins/abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc_abc/api/testroute', + serverURL: SERVER_WITH_SUBPATH, + siteURL: SERVER_WITH_SUBPATH, + }, + expected: null, + }, + { + name: 'should match plugin path without a route', + input: { + url: DEEPLINK_URL_ROOT + '/subpath/deepsubpath/plugins/abc', + serverURL: SERVER_WITH_SUBPATH, + siteURL: SERVER_WITH_SUBPATH, + }, + expected: { + data: { + id: 'abc', + route: undefined, + serverUrl: URL_PATH_NO_PROTOCOL, + teamName: '', + }, + type: DeepLinkType.Plugin, }, }, ]; @@ -283,10 +478,11 @@ describe('UrlUtils', () => { const {name, input, expected} = test; it(name, () => { - const match = matchDeepLink(input.url!, input.serverURL!, input.siteURL!); - const parsed = parseDeepLink(match); - Reflect.deleteProperty(parsed, 'url'); - expect(parsed).toEqual(expected); + const matched = matchDeepLink(input.url!, input.serverURL!, input.siteURL!); + if (matched) { + Reflect.deleteProperty(matched, 'url'); + } + expect(matched).toEqual(expected); }); } }); diff --git a/package-lock.json b/package-lock.json index eaf5fce73..9e5fa40a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,6 +50,7 @@ "mime-db": "1.52.0", "moment-timezone": "0.5.43", "pako": "2.1.0", + "path-to-regexp": "6.2.1", "react": "18.2.0", "react-freeze": "1.0.3", "react-intl": "6.5.5", @@ -16506,6 +16507,11 @@ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, + "node_modules/path-to-regexp": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz", + "integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==" + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", diff --git a/package.json b/package.json index 5b8fa65c0..6f4387813 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "mime-db": "1.52.0", "moment-timezone": "0.5.43", "pako": "2.1.0", + "path-to-regexp": "6.2.1", "react": "18.2.0", "react-freeze": "1.0.3", "react-intl": "6.5.5", diff --git a/types/launch/index.ts b/types/launch/index.ts index 334e9c392..4dffb4d71 100644 --- a/types/launch/index.ts +++ b/types/launch/index.ts @@ -21,11 +21,12 @@ export interface DeepLinkPermalink extends DeepLink { } export interface DeepLinkGM extends DeepLink { - channelId: string; + channelName: string; } export interface DeepLinkPlugin extends DeepLink { id: string; + route?: string; } export type DeepLinkType = typeof DeepLink[keyof typeof DeepLink];