refactor parseDeepLink with path-to-regexp

This commit is contained in:
Caleb Roseland 2023-11-15 21:24:48 -06:00
parent 744d5c1120
commit bf25f10c38
4 changed files with 94 additions and 38 deletions

View file

@ -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<typeof match>[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<ChannelPathParams>(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<PermalinkPathParams>(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. <jump to convo> 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;
}
}

20
app/utils/url/path.ts Normal file
View file

@ -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.]*';

11
package-lock.json generated
View file

@ -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",

View file

@ -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",