Skip to content

Commit

Permalink
fix cannot read data when opening a deeplink (#8109)
Browse files Browse the repository at this point in the history
  • Loading branch information
enahum authored Jul 26, 2024
1 parent ca00ab2 commit 4048b70
Show file tree
Hide file tree
Showing 10 changed files with 151 additions and 16 deletions.
1 change: 1 addition & 0 deletions app/constants/deep_linking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const DeepLinkType = {
Invalid: 'invalid',
Permalink: 'permalink',
Redirect: '_redirect',
Server: 'server',
} as const;

export default DeepLinkType;
11 changes: 10 additions & 1 deletion app/init/launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import EphemeralStore from '@store/ephemeral_store';
import {getLaunchPropsFromDeepLink} from '@utils/deep_link';
import {logInfo} from '@utils/log';
import {convertToNotificationData} from '@utils/notification';
import {removeProtocol} from '@utils/url';

import type {DeepLinkWithData, LaunchProps} from '@typings/launch';

Expand Down Expand Up @@ -80,9 +81,17 @@ const launchApp = async (props: LaunchProps) => {
const existingServer = DatabaseManager.searchUrl(extra.data!.serverUrl);
serverUrl = existingServer;
props.serverUrl = serverUrl || extra.data?.serverUrl;
if (!serverUrl) {
if (!serverUrl && extra.type !== DeepLink.Server) {
props.launchError = true;
}
if (extra.type === DeepLink.Server) {
if (removeProtocol(serverUrl) === extra.data?.serverUrl) {
props.extra = undefined;
props.launchType = Launch.Normal;
} else {
serverUrl = await getActiveServerUrl();
}
}
}
break;
case Launch.Notification: {
Expand Down
2 changes: 1 addition & 1 deletion app/managers/global_event_handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ class GlobalEventHandler {
}

if (event.url) {
const {error} = await handleDeepLink(event.url);
const {error} = await handleDeepLink(event.url, undefined, undefined, true);
if (error) {
alertInvalidDeepLink(getIntlShape(DEFAULT_LOCALE));
}
Expand Down
6 changes: 3 additions & 3 deletions app/screens/home/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {enableFreeze, enableScreens} from 'react-native-screens';

import {autoUpdateTimezone} from '@actions/remote/user';
import ServerVersion from '@components/server_version';
import {Events, Screens} from '@constants';
import {Events, Launch, Screens} from '@constants';
import {useTheme} from '@context/theme';
import {useAppState} from '@hooks/device';
import {getAllServers} from '@queries/app/servers';
Expand Down Expand Up @@ -117,15 +117,15 @@ export default function HomeScreen(props: HomeProps) {
}, [appState]);

useEffect(() => {
if (props.launchType === 'deeplink') {
if (props.launchType === Launch.DeepLink) {
if (props.launchError) {
alertInvalidDeepLink(intl);
return;
}

const deepLink = props.extra as DeepLinkWithData;
if (deepLink?.url) {
handleDeepLink(deepLink.url).then((result) => {
handleDeepLink(deepLink.url, intl, props.componentId, true).then((result) => {
if (result.error) {
alertInvalidDeepLink(intl);
}
Expand Down
2 changes: 1 addition & 1 deletion app/screens/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ export function resetToHome(passProps: LaunchProps = {launchType: Launch.Normal}
const isDark = tinyColor(theme.sidebarBg).isDark();
StatusBar.setBarStyle(isDark ? 'light-content' : 'dark-content');

if (passProps.launchType === Launch.AddServer || passProps.launchType === Launch.AddServerFromDeepLink) {
if (!passProps.coldStart && (passProps.launchType === Launch.AddServer || passProps.launchType === Launch.AddServerFromDeepLink)) {
dismissModal({componentId: Screens.SERVER});
dismissModal({componentId: Screens.LOGIN});
dismissModal({componentId: Screens.SSO});
Expand Down
10 changes: 8 additions & 2 deletions app/screens/server/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {doPing} from '@actions/remote/general';
import {fetchConfigAndLicense} from '@actions/remote/systems';
import LocalConfig from '@assets/config.json';
import AppVersion from '@components/app_version';
import {Screens, Launch} from '@constants';
import {Screens, Launch, DeepLink} from '@constants';
import useNavButtonPressed from '@hooks/navigation_button_pressed';
import {t} from '@i18n';
import {getServerCredentials} from '@init/credentials';
Expand Down Expand Up @@ -136,7 +136,7 @@ const Server = ({
// If no other servers are allowed or the local config for AutoSelectServerUrl is set, attempt to connect
handleConnect(managedConfig?.serverUrl || LocalConfig.DefaultServerUrl);
}
}, [managedConfig?.allowOtherServers, managedConfig?.serverUrl, managedConfig?.serverName]);
}, [managedConfig?.allowOtherServers, managedConfig?.serverUrl, managedConfig?.serverName, defaultServerUrl]);

useEffect(() => {
if (url && displayName) {
Expand Down Expand Up @@ -210,6 +210,12 @@ const Server = ({
passProps.ssoType = enabledSSOs[0];
}

// if deeplink is of type server removing the deeplink info on new login
if (extra?.type === DeepLink.Server) {
passProps.extra = undefined;
passProps.launchType = Launch.Normal;
}

goToScreen(screen, '', passProps, loginAnimationOptions());
setConnecting(false);
setButtonDisabled(false);
Expand Down
63 changes: 61 additions & 2 deletions app/utils/deep_link/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,25 @@
// See LICENSE.txt for license information.

import {createIntl} from 'react-intl';
import {Navigation} from 'react-native-navigation';

import {makeDirectChannel, switchToChannelByName} from '@actions/remote/channel';
import {showPermalink} from '@actions/remote/permalink';
import {fetchUsersByUsernames} from '@actions/remote/user';
import {DeepLink, Launch, Preferences} from '@constants';
import {DeepLink, Launch, Preferences, Screens} from '@constants';
import DatabaseManager from '@database/manager';
import {t} from '@i18n';
import WebsocketManager from '@managers/websocket_manager';
import {getActiveServerUrl} from '@queries/app/servers';
import {queryUsersByUsername} from '@queries/servers/user';
import {dismissAllModalsAndPopToRoot} from '@screens/navigation';
import NavigationStore from '@store/navigation_store';
import {logError} from '@utils/log';
import {addNewServer} from '@utils/server';

import {alertErrorWithFallback, errorBadChannel, errorUnkownUser} from '../draft';

import {alertInvalidDeepLink, getLaunchPropsFromDeepLink, handleDeepLink} from '.';
import {alertInvalidDeepLink, extractServerUrl, getLaunchPropsFromDeepLink, handleDeepLink} from '.';

jest.mock('@actions/remote/user', () => ({
fetchUsersByUsernames: jest.fn(),
Expand Down Expand Up @@ -51,6 +53,7 @@ jest.mock('@store/navigation_store', () => ({
getVisibleScreen: jest.fn(() => 'HOME'),
hasModalsOpened: jest.fn(() => false),
waitUntilScreenHasLoaded: jest.fn(),
getScreensInStack: jest.fn().mockReturnValue([]),
}));

jest.mock('@utils/server', () => ({
Expand Down Expand Up @@ -78,6 +81,19 @@ jest.mock('@i18n', () => ({
t: jest.fn((id) => id),
}));

describe('extractServerUrl', () => {
it('should extract the sanitized server url', () => {
expect(extractServerUrl('example.com:8080//path/to///login')).toEqual('example.com:8080/path/to');
expect(extractServerUrl('localhost:3000/signup')).toEqual('localhost:3000');
expect(extractServerUrl('192.168.0.1/admin_console')).toEqual('192.168.0.1');
expect(extractServerUrl('example.com/path//to/resource')).toEqual('example.com/path/to/resource');
expect(extractServerUrl('my.local.network/.../resource/admin_console')).toEqual('my.local.network/resource');
expect(extractServerUrl('my.local.network//ad-1/channels/%252f%252e.town-square')).toEqual(null);
expect(extractServerUrl('example.com:8080')).toEqual('example.com:8080');
expect(extractServerUrl('example.com:8080/')).toEqual('example.com:8080');
});
});

describe('handleDeepLink', () => {
const intl = createIntl({locale: 'en', messages: {}});

Expand Down Expand Up @@ -115,6 +131,28 @@ describe('handleDeepLink', () => {
expect(result).toEqual({error: false});
});

it('should update the server url in the server url screen', async () => {
(getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://currentserver.com');
(DatabaseManager.searchUrl as jest.Mock).mockReturnValueOnce(null);

(NavigationStore.getVisibleScreen as jest.Mock).mockReturnValueOnce(Screens.SERVER);
const result = await handleDeepLink('https://currentserver.com/team/channels/town-square', undefined, undefined, true);
const spyOnUpdateProps = jest.spyOn(Navigation, 'updateProps');
expect(spyOnUpdateProps).toHaveBeenCalledWith(Screens.SERVER, {serverUrl: 'currentserver.com'});
expect(result).toEqual({error: false});
});

it('should not display the new server modal if the server screen is on the stack but not as the visible screen', async () => {
(getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://currentserver.com');
(DatabaseManager.searchUrl as jest.Mock).mockReturnValueOnce(null);

(NavigationStore.getVisibleScreen as jest.Mock).mockReturnValueOnce(Screens.LOGIN);
(NavigationStore.getScreensInStack as jest.Mock).mockReturnValueOnce([Screens.SERVER, Screens.LOGIN]);
const result = await handleDeepLink('https://currentserver.com/team/channels/town-square', undefined, undefined, true);
expect(addNewServer).not.toHaveBeenCalled();
expect(result).toEqual({error: false});
});

it('should switch to channel by name for Channel deep link', async () => {
(DatabaseManager.searchUrl as jest.Mock).mockReturnValueOnce('https://existingserver.com');
(getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://existingserver.com');
Expand Down Expand Up @@ -187,6 +225,10 @@ describe('getLaunchPropsFromDeepLink', () => {
launchType: Launch.DeepLink,
coldStart: false,
launchError: true,
extra: {
type: DeepLink.Invalid,
url: 'invalid-url',
},
});
});

Expand All @@ -208,6 +250,23 @@ describe('getLaunchPropsFromDeepLink', () => {
extra: extraData,
});
});

it('should return launch props with extra data to add a new server when opened from cold start', () => {
const extraData = {
type: DeepLink.Server,
data: {
serverUrl: 'existingserver.com',
},
url: 'https://existingserver.com/login',
};
const result = getLaunchPropsFromDeepLink('https://existingserver.com/login', true);

expect(result).toEqual({
launchType: Launch.DeepLink,
coldStart: true,
extra: extraData,
});
});
});

describe('alertInvalidDeepLink', () => {
Expand Down
65 changes: 60 additions & 5 deletions app/utils/deep_link/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import {match} from 'path-to-regexp';
import {createIntl, type IntlShape} from 'react-intl';
import {Navigation} from 'react-native-navigation';
import urlParse from 'url-parse';

import {makeDirectChannel, switchToChannelByName} from '@actions/remote/channel';
Expand Down Expand Up @@ -36,9 +37,9 @@ import type {AvailableScreens} from '@typings/screens/navigation';

const deepLinkScreens: AvailableScreens[] = [Screens.HOME, Screens.CHANNEL, Screens.GLOBAL_THREADS, Screens.THREAD];

export async function handleDeepLink(deepLinkUrl: string, intlShape?: IntlShape, location?: string) {
export async function handleDeepLink(deepLinkUrl: string, intlShape?: IntlShape, location?: string, asServer = false) {
try {
const parsed = parseDeepLink(deepLinkUrl);
const parsed = parseDeepLink(deepLinkUrl, asServer);
if (parsed.type === DeepLink.Invalid || !parsed.data || !parsed.data.serverUrl) {
return {error: true};
}
Expand All @@ -49,7 +50,11 @@ export async function handleDeepLink(deepLinkUrl: string, intlShape?: IntlShape,
// After checking the server for http & https then we add it
if (!existingServerUrl) {
const theme = EphemeralStore.theme || getDefaultThemeByAppearance();
addNewServer(theme, parsed.data.serverUrl, undefined, parsed);
if (NavigationStore.getVisibleScreen() === Screens.SERVER) {
Navigation.updateProps(Screens.SERVER, {serverUrl: parsed.data.serverUrl});
} else if (!NavigationStore.getScreensInStack().includes(Screens.SERVER)) {
addNewServer(theme, parsed.data.serverUrl, undefined, parsed);
}
return {error: false};
}

Expand Down Expand Up @@ -135,7 +140,49 @@ type PermalinkPathParams = {
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 {
type ServerPathParams = {
serverUrl: string;
path: string;
}

export const matchServerDeepLink = match<ServerPathParams>(':serverUrl(.*)/:path(.*)', {decode: decodeURIComponent});
const reservedWords = ['login', 'signup', 'admin_console'];

export function extractServerUrl(url: string) {
const deepLinkUrl = decodeURIComponent(url).replace(/\.{2,}/g, '').replace(/\/+/g, '/');

const pattern = new RegExp(

// Match the domain, IP address, or localhost
'^([a-zA-Z0-9.-]+|localhost|\\d{1,3}(?:\\.\\d{1,3}){3})' +

// Match optional port
'(?::(\\d+))?' +

// Match path segments
'(?:/([a-zA-Z0-9-/_]+))?/?$',
);

if (!pattern.test(deepLinkUrl)) {
return null;
}

const matched = matchServerDeepLink(deepLinkUrl);

if (matched) {
const {path} = matched.params;
const segments = path.split('/');

if (segments.length > 0 && reservedWords.includes(segments[segments.length - 1])) {
return matched.params.serverUrl;
}
return path ? `${matched.params.serverUrl}/${path}` : matched.params.serverUrl;
}

return deepLinkUrl;
}

export function parseDeepLink(deepLinkUrl: string, asServer = false): DeepLinkWithData {
try {
const url = removeProtocol(deepLinkUrl);

Expand All @@ -161,6 +208,13 @@ export function parseDeepLink(deepLinkUrl: string): DeepLinkWithData {
const {params: {serverUrl, teamName, postId}} = permalinkMatch;
return {type: DeepLink.Permalink, url: deepLinkUrl, data: {serverUrl, teamName, postId}};
}

if (asServer) {
const serverMatch = extractServerUrl(url);
if (serverMatch) {
return {type: DeepLink.Server, url: deepLinkUrl, data: {serverUrl: serverMatch}};
}
}
} catch (err) {
// do nothing just return invalid deeplink
}
Expand Down Expand Up @@ -196,7 +250,7 @@ export function matchDeepLink(url: string, serverURL?: string, siteURL?: string)
}

export const getLaunchPropsFromDeepLink = (deepLinkUrl: string, coldStart = false): LaunchProps => {
const parsed = parseDeepLink(deepLinkUrl);
const parsed = parseDeepLink(deepLinkUrl, coldStart);
const launchProps: LaunchProps = {
launchType: Launch.DeepLink,
coldStart,
Expand All @@ -205,6 +259,7 @@ export const getLaunchPropsFromDeepLink = (deepLinkUrl: string, coldStart = fals
switch (parsed.type) {
case DeepLink.Invalid:
launchProps.launchError = true;
launchProps.extra = parsed;
break;
default: {
launchProps.extra = parsed;
Expand Down
1 change: 1 addition & 0 deletions test/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,7 @@ jest.mock('react-native-navigation', () => {
mergeOptions: jest.fn(),
showOverlay: jest.fn(),
dismissOverlay: jest.fn(),
updateProps: jest.fn(),
},
};
});
Expand Down
6 changes: 5 additions & 1 deletion types/launch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ export interface DeepLink {
teamName: string;
}

export interface DeepLinkServer {
serverUrl: string;
}

export interface DeepLinkChannel extends DeepLink {
channelName: string;
}
Expand All @@ -34,7 +38,7 @@ export type DeepLinkType = typeof DeepLink[keyof typeof DeepLink];
export interface DeepLinkWithData {
type: DeepLinkType;
url: string;
data?: DeepLinkChannel | DeepLinkDM | DeepLinkGM | DeepLinkPermalink | DeepLinkPlugin;
data?: DeepLinkChannel | DeepLinkDM | DeepLinkGM | DeepLinkPermalink | DeepLinkPlugin | DeepLinkServer;
}

export type LaunchType = typeof Launch[keyof typeof Launch];
Expand Down

0 comments on commit 4048b70

Please sign in to comment.