diff --git a/app/utils/deep_link/index.ts b/app/utils/deep_link/index.ts index 41abb8e20..5b77d6ed0 100644 --- a/app/utils/deep_link/index.ts +++ b/app/utils/deep_link/index.ts @@ -1,6 +1,7 @@ // 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'; @@ -21,7 +22,12 @@ 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 type {DeepLinkChannel, DeepLinkDM, DeepLinkGM, DeepLinkPermalink, DeepLinkWithData, LaunchProps} from '@typings/launch'; import type {AvailableScreens} from '@typings/screens/navigation'; @@ -100,12 +106,6 @@ export async function handleDeepLink(deepLinkUrl: string, intlShape?: IntlShape, showPermalink(existingServerUrl, deepLinkData.teamName, deepLinkData.postId); break; } - case DeepLink.Plugin: { - // https://mattermost.atlassian.net/browse/MM-49846 - // const deepLinkData = parsed.data as DeepLinkPlugin; - // showModal('PluginInternal', deepLinkData.id, {link: location}); - break; - } } return {error: false}; } catch (error) { @@ -114,37 +114,66 @@ export async function handleDeepLink(deepLinkUrl: string, intlShape?: IntlShape, } } +const matcherOpts: Parameters[1] = {decode: decodeURIComponent}; + +type ChannelPathParams = { + hostname: string; + subpath?: string; + teamName: string; + path: 'channels' | 'messages'; + identifier: string; + postId?: string; +}; + +const CHANNEL_PATH = `/:subpath?/:teamName(${TEAM_NAME_PATH_PATTERN})/:path(channels|messages)/:identifier(${IDENTIFIER_PATH_PATTERN}|${ID_PATH_PATTERN})/:postId(${ID_PATH_PATTERN})?`; +export const matchChannelDeeplink = match(CHANNEL_PATH, matcherOpts); + +type PermalinkPathParams = { + hostname: string; + subpath?: string; + teamName: string; + postId: string; +}; +const PERMALINK_PATH = `/:subpath?/:teamName(${TEAM_NAME_PATH_PATTERN})/pl/:postId(${ID_PATH_PATTERN})`; +export const matchPermalinkDeeplink = match(PERMALINK_PATH, matcherOpts); + +const getServerUrl = (host: string, subpath?: string) => host + (subpath ? '/' + subpath : ''); + export function parseDeepLink(deepLinkUrl: string): DeepLinkWithData { try { - const url = removeProtocol(decodeURIComponent(deepLinkUrl)); + const {host, pathname} = urlParse(decodeURIComponent(deepLinkUrl)); + const channelMatch = matchChannelDeeplink(pathname); - if (isDeepLinkInvalid(url)) { - return {type: DeepLink.Invalid, url: deepLinkUrl}; + if (channelMatch) { + const {params: {subpath, teamName, path, identifier, postId}} = channelMatch; + const serverUrl = getServerUrl(host, subpath); + + if (path === 'channels') { + return {type: DeepLink.Channel, url: deepLinkUrl, data: {serverUrl, teamName, channelName: identifier, postId}}; + } + + if (path === 'messages') { + if (identifier.startsWith('@')) { + return {type: DeepLink.DirectMessage, url: deepLinkUrl, data: {serverUrl, teamName, userName: identifier}}; + } + + return {type: DeepLink.GroupMessage, url: deepLinkUrl, data: {serverUrl, teamName, channelId: 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(pathname); + if (permalinkMatch) { + const {params: {subpath, teamName, postId}} = permalinkMatch; + const serverUrl = getServerUrl(host, subpath); + + 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]}}; - } - - 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: ''}}; + const pluginMatch = match<{subpath?: string; id: string}>(`/:subpath?/plugins/:id(${PLUGIN_ID_PATH_PATTERN})`, matcherOpts)(pathname); + if (pluginMatch) { + const {params: {subpath, id}} = pluginMatch; + const serverUrl = getServerUrl(host, subpath); + return {type: DeepLink.Plugin, url: deepLinkUrl, data: {serverUrl, teamName: '', id}}; } } catch { // do nothing just return invalid deeplink @@ -153,11 +182,6 @@ export function parseDeepLink(deepLinkUrl: string): DeepLinkWithData { return {type: DeepLink.Invalid, url: deepLinkUrl}; } -function isDeepLinkInvalid(decodedUrl: string) { - const url = decodeURIComponent(decodedUrl); - return Boolean((/([/\\]\.\.|\.\.[/\\])/).exec(url)?.length); -} - export function matchDeepLink(url?: string, serverURL?: string, siteURL?: string) { if (!url || (!serverURL && !siteURL)) { return ''; @@ -171,8 +195,8 @@ export function matchDeepLink(url?: string, serverURL?: string, siteURL?: string 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; } } diff --git a/app/utils/url/path.ts b/app/utils/url/path.ts new file mode 100644 index 000000000..bf3afe95c --- /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-_.]+$'; + +// 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/package-lock.json b/package-lock.json index dc6e5a2af..961bb2714 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,6 +52,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.4.4", @@ -18577,6 +18578,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", @@ -36657,6 +36663,11 @@ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, + "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==" + }, "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 2590a3e11..87ee599d4 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,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.4.4",