From 4048b70713ee7cbe7e3d2b095c6e0aba1e247f9d Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Sat, 27 Jul 2024 03:37:57 +0800 Subject: [PATCH] fix cannot read data when opening a deeplink (#8109) --- app/constants/deep_linking.ts | 1 + app/init/launch.ts | 11 ++++- app/managers/global_event_handler.ts | 2 +- app/screens/home/index.tsx | 6 +-- app/screens/navigation.ts | 2 +- app/screens/server/index.tsx | 10 ++++- app/utils/deep_link/index.test.ts | 63 ++++++++++++++++++++++++++- app/utils/deep_link/index.ts | 65 +++++++++++++++++++++++++--- test/setup.ts | 1 + types/launch/index.ts | 6 ++- 10 files changed, 151 insertions(+), 16 deletions(-) diff --git a/app/constants/deep_linking.ts b/app/constants/deep_linking.ts index 4e96eb89e..b934312e1 100644 --- a/app/constants/deep_linking.ts +++ b/app/constants/deep_linking.ts @@ -8,6 +8,7 @@ const DeepLinkType = { Invalid: 'invalid', Permalink: 'permalink', Redirect: '_redirect', + Server: 'server', } as const; export default DeepLinkType; diff --git a/app/init/launch.ts b/app/init/launch.ts index c9cfb365c..9db93c37a 100644 --- a/app/init/launch.ts +++ b/app/init/launch.ts @@ -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'; @@ -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: { diff --git a/app/managers/global_event_handler.ts b/app/managers/global_event_handler.ts index f9daa8dec..5a69d5b72 100644 --- a/app/managers/global_event_handler.ts +++ b/app/managers/global_event_handler.ts @@ -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)); } diff --git a/app/screens/home/index.tsx b/app/screens/home/index.tsx index d5fbb6099..c0bee4ece 100644 --- a/app/screens/home/index.tsx +++ b/app/screens/home/index.tsx @@ -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'; @@ -117,7 +117,7 @@ export default function HomeScreen(props: HomeProps) { }, [appState]); useEffect(() => { - if (props.launchType === 'deeplink') { + if (props.launchType === Launch.DeepLink) { if (props.launchError) { alertInvalidDeepLink(intl); return; @@ -125,7 +125,7 @@ export default function HomeScreen(props: HomeProps) { 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); } diff --git a/app/screens/navigation.ts b/app/screens/navigation.ts index b4111e6eb..8484109ff 100644 --- a/app/screens/navigation.ts +++ b/app/screens/navigation.ts @@ -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}); diff --git a/app/screens/server/index.tsx b/app/screens/server/index.tsx index c6700abd2..1ff20c262 100644 --- a/app/screens/server/index.tsx +++ b/app/screens/server/index.tsx @@ -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'; @@ -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) { @@ -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); diff --git a/app/utils/deep_link/index.test.ts b/app/utils/deep_link/index.test.ts index 57539ef5f..ec18e833e 100644 --- a/app/utils/deep_link/index.test.ts +++ b/app/utils/deep_link/index.test.ts @@ -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(), @@ -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', () => ({ @@ -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: {}}); @@ -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'); @@ -187,6 +225,10 @@ describe('getLaunchPropsFromDeepLink', () => { launchType: Launch.DeepLink, coldStart: false, launchError: true, + extra: { + type: DeepLink.Invalid, + url: 'invalid-url', + }, }); }); @@ -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', () => { diff --git a/app/utils/deep_link/index.ts b/app/utils/deep_link/index.ts index 4422d7aa3..ee1db6498 100644 --- a/app/utils/deep_link/index.ts +++ b/app/utils/deep_link/index.ts @@ -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'; @@ -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}; } @@ -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}; } @@ -135,7 +140,49 @@ type PermalinkPathParams = { 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 { +type ServerPathParams = { + serverUrl: string; + path: string; +} + +export const matchServerDeepLink = match(':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); @@ -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 } @@ -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, @@ -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; diff --git a/test/setup.ts b/test/setup.ts index 55f86f577..0ff7995a2 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -289,6 +289,7 @@ jest.mock('react-native-navigation', () => { mergeOptions: jest.fn(), showOverlay: jest.fn(), dismissOverlay: jest.fn(), + updateProps: jest.fn(), }, }; }); diff --git a/types/launch/index.ts b/types/launch/index.ts index 4dffb4d71..b813e4369 100644 --- a/types/launch/index.ts +++ b/types/launch/index.ts @@ -8,6 +8,10 @@ export interface DeepLink { teamName: string; } +export interface DeepLinkServer { + serverUrl: string; +} + export interface DeepLinkChannel extends DeepLink { channelName: string; } @@ -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];