Merge pull request #7689 from mattermost/release-2.11-7613

This commit is contained in:
Caleb Roseland 2023-11-28 12:28:04 -06:00 committed by GitHub
commit 502d304101
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 307 additions and 65 deletions

View file

@ -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(

View file

@ -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);

View file

@ -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<ChannelPathParams>(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<PermalinkPathParams>(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. <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;
}
}
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 => {

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

View file

@ -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);
});
}
});

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

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

View file

@ -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];