diff --git a/lib/actions/link-actions.js b/lib/actions/link-actions.js index 17445c1be..317e0e4c0 100644 --- a/lib/actions/link-actions.js +++ b/lib/actions/link-actions.js @@ -1,191 +1,193 @@ // @flow import * as React from 'react'; import { extractKeyserverIDFromID } from '../keyserver-conn/keyserver-call-utils.js'; import type { CallKeyserverEndpoint } from '../keyserver-conn/keyserver-conn-types.js'; import type { FetchInviteLinksResponse, InviteLinkVerificationRequest, InviteLinkVerificationResponse, CreateOrUpdatePublicLinkRequest, InviteLink, DisableInviteLinkRequest, DisableInviteLinkPayload, } from '../types/link-types.js'; import type { CallSingleKeyserverEndpoint } from '../utils/call-single-keyserver-endpoint.js'; import { useKeyserverCall } from '../utils/keyserver-call.js'; import { useSelector } from '../utils/redux-utils.js'; import { ashoatKeyserverID } from '../utils/validation-utils.js'; const verifyInviteLinkActionTypes = Object.freeze({ started: 'VERIFY_INVITE_LINK_STARTED', success: 'VERIFY_INVITE_LINK_SUCCESS', failed: 'VERIFY_INVITE_LINK_FAILED', }); const verifyInviteLink = ( callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, ): ((input: { +request: InviteLinkVerificationRequest, +keyserverID: string, }) => Promise) => async input => { const { request, keyserverID } = input; const requests = { [keyserverID]: request, }; const responses = await callSingleKeyserverEndpoint( 'verify_invite_link', requests, ); const response = responses[keyserverID]; if (response.status === 'valid' || response.status === 'already_joined') { return { status: response.status, community: response.community, }; } return { status: response.status, }; }; -function useVerifyInviteLink(keyserverOverride?: { - +keyserverID: string, - +keyserverURL: string, -}): ( +function useVerifyInviteLink( + keyserverOverride?: ?{ + +keyserverID: string, + +keyserverURL: string, + }, +): ( request: InviteLinkVerificationRequest, ) => Promise { const keyserverID = keyserverOverride?.keyserverID ?? ashoatKeyserverID; const isKeyserverKnown = useSelector( state => !!state.keyserverStore.keyserverInfos[keyserverID], ); let paramOverride = null; if (keyserverOverride && !isKeyserverKnown) { paramOverride = { keyserverInfos: { [keyserverOverride.keyserverID]: { urlPrefix: keyserverOverride.keyserverURL, }, }, }; } const callVerifyInviteLink = useKeyserverCall( verifyInviteLink, paramOverride, ); return React.useCallback( (request: InviteLinkVerificationRequest) => callVerifyInviteLink({ request, keyserverID }), [callVerifyInviteLink, keyserverID], ); } const fetchPrimaryInviteLinkActionTypes = Object.freeze({ started: 'FETCH_PRIMARY_INVITE_LINKS_STARTED', success: 'FETCH_PRIMARY_INVITE_LINKS_SUCCESS', failed: 'FETCH_PRIMARY_INVITE_LINKS_FAILED', }); const fetchPrimaryInviteLinks = ( callKeyserverEndpoint: CallKeyserverEndpoint, allKeyserverIDs: $ReadOnlyArray, ): (() => Promise) => async () => { const requests: { [string]: void } = {}; for (const keyserverID of allKeyserverIDs) { requests[keyserverID] = undefined; } const responses = await callKeyserverEndpoint( 'fetch_primary_invite_links', requests, ); let links: $ReadOnlyArray = []; for (const keyserverID in responses) { links = links.concat(responses[keyserverID].links); } return { links, }; }; function useFetchPrimaryInviteLinks(): () => Promise { return useKeyserverCall(fetchPrimaryInviteLinks); } const createOrUpdatePublicLinkActionTypes = Object.freeze({ started: 'CREATE_OR_UPDATE_PUBLIC_LINK_STARTED', success: 'CREATE_OR_UPDATE_PUBLIC_LINK_SUCCESS', failed: 'CREATE_OR_UPDATE_PUBLIC_LINK_FAILED', }); const createOrUpdatePublicLink = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: CreateOrUpdatePublicLinkRequest) => Promise) => async input => { const keyserverID = extractKeyserverIDFromID(input.communityID); const requests = { [keyserverID]: { name: input.name, communityID: input.communityID, }, }; const responses = await callKeyserverEndpoint( 'create_or_update_public_link', requests, ); const response = responses[keyserverID]; return { name: response.name, primary: response.primary, role: response.role, communityID: response.communityID, expirationTime: response.expirationTime, limitOfUses: response.limitOfUses, numberOfUses: response.numberOfUses, }; }; function useCreateOrUpdatePublicLink(): ( input: CreateOrUpdatePublicLinkRequest, ) => Promise { return useKeyserverCall(createOrUpdatePublicLink); } const disableInviteLinkLinkActionTypes = Object.freeze({ started: 'DISABLE_INVITE_LINK_STARTED', success: 'DISABLE_INVITE_LINK_SUCCESS', failed: 'DISABLE_INVITE_LINK_FAILED', }); const disableInviteLink = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: DisableInviteLinkRequest) => Promise) => async input => { const keyserverID = extractKeyserverIDFromID(input.communityID); const requests = { [keyserverID]: input }; await callKeyserverEndpoint('disable_invite_link', requests); return input; }; function useDisableInviteLink(): ( input: DisableInviteLinkRequest, ) => Promise { return useKeyserverCall(disableInviteLink); } export { verifyInviteLinkActionTypes, useVerifyInviteLink, fetchPrimaryInviteLinkActionTypes, useFetchPrimaryInviteLinks, createOrUpdatePublicLinkActionTypes, useCreateOrUpdatePublicLink, disableInviteLinkLinkActionTypes, useDisableInviteLink, }; diff --git a/lib/shared/invite-links.js b/lib/shared/invite-links.js index 4f9747e53..707ce5972 100644 --- a/lib/shared/invite-links.js +++ b/lib/shared/invite-links.js @@ -1,18 +1,53 @@ // @flow +import blobService from '../facts/blob-service.js'; +import { getBlobFetchableURL } from '../utils/blob-service.js'; + const inviteLinkErrorMessages: { +[string]: string } = { invalid_characters: 'Link cannot contain any spaces or special characters.', offensive_words: 'No offensive or abusive words allowed.', already_in_use: 'Public link URL already in use.', link_reserved: 'This public link is currently reserved. Please contact support@' + 'comm.app if you would like to claim this link.', }; const defaultErrorMessage = 'Unknown error.'; function inviteLinkBlobHash(secret: string): string { return `invite_${secret}`; } -export { inviteLinkErrorMessages, defaultErrorMessage, inviteLinkBlobHash }; +export type KeyserverOverride = { + +keyserverID: string, + +keyserverURL: string, +}; + +async function getKeyserverOverrideForAnInviteLink( + secret: string, +): Promise { + const blobURL = getBlobFetchableURL(inviteLinkBlobHash(secret)); + const result = await fetch(blobURL, { + method: blobService.httpEndpoints.GET_BLOB.method, + }); + if (result.status !== 200) { + return null; + } + const resultText = await result.text(); + const resultObject = JSON.parse(resultText); + if (resultObject.keyserverID && resultObject.keyserverURL) { + const keyserverURL: string = resultObject.keyserverURL; + return { + keyserverID: resultObject.keyserverID, + keyserverURL: keyserverURL.replace(/\/$/, ''), + }; + } + return null; +} + +export { + inviteLinkErrorMessages, + defaultErrorMessage, + inviteLinkBlobHash, + getKeyserverOverrideForAnInviteLink, +}; diff --git a/native/navigation/deep-links-context-provider.react.js b/native/navigation/deep-links-context-provider.react.js index 36f392070..e983851df 100644 --- a/native/navigation/deep-links-context-provider.react.js +++ b/native/navigation/deep-links-context-provider.react.js @@ -1,139 +1,183 @@ // @flow import { useNavigation } from '@react-navigation/native'; import * as Application from 'expo-application'; import * as React from 'react'; import { Linking, Platform } from 'react-native'; import { useVerifyInviteLink, verifyInviteLinkActionTypes, } from 'lib/actions/link-actions.js'; import { parseInstallReferrerFromInviteLinkURL, parseDataFromDeepLink, type ParsedDeepLinkData, } from 'lib/facts/links.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; +import { getKeyserverOverrideForAnInviteLink } from 'lib/shared/invite-links.js'; +import type { KeyserverOverride } from 'lib/shared/invite-links.js'; import type { SetState } from 'lib/types/hook-types.js'; import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; import { InviteLinkModalRouteName, SecondaryDeviceQRCodeScannerRouteName, } from './route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useOnFirstLaunchEffect } from '../utils/hooks.js'; type DeepLinksContextType = { +setCurrentLinkUrl: SetState, }; const defaultContext = { setCurrentLinkUrl: () => {}, }; const DeepLinksContext: React.Context = React.createContext(defaultContext); type Props = { +children: React.Node, }; function DeepLinksContextProvider(props: Props): React.Node { const { children } = props; const [currentLink, setCurrentLink] = React.useState(null); React.useEffect(() => { // This listener listens for an event where a user clicked a link when the // app was running const subscription = Linking.addEventListener('url', ({ url }) => setCurrentLink(url), ); // We're also checking if the app was opened by using a link. // In that case the listener won't be called and we're instead checking // if the initial URL is set. void (async () => { const initialURL = await Linking.getInitialURL(); if (initialURL) { setCurrentLink(initialURL); } })(); return () => { subscription.remove(); }; }, []); const checkInstallReferrer = React.useCallback(async () => { if (Platform.OS !== 'android') { return; } const installReferrer = await Application.getInstallReferrerAsync(); if (!installReferrer) { return; } const linkSecret = parseInstallReferrerFromInviteLinkURL(installReferrer); if (linkSecret) { setCurrentLink(linkSecret); } }, []); useOnFirstLaunchEffect('ANDROID_REFERRER', checkInstallReferrer); + const [keyserverOverride, setKeyserverOverride] = + React.useState(undefined); + const inviteLinkSecret = React.useRef(null); + const loggedIn = useSelector(isLoggedIn); const dispatchActionPromise = useDispatchActionPromise(); - const validateLink = useVerifyInviteLink(); + const validateLink = useVerifyInviteLink(keyserverOverride); const navigation = useNavigation(); React.useEffect(() => { void (async () => { if (!loggedIn || !currentLink) { return; } // We're setting this to null so that we ensure that each link click // results in at most one validation and navigation. setCurrentLink(null); + setKeyserverOverride(undefined); + inviteLinkSecret.current = null; const parsedData: ParsedDeepLinkData = parseDataFromDeepLink(currentLink); if (!parsedData) { return; } if (parsedData.type === 'invite-link') { const { secret } = parsedData.data; + inviteLinkSecret.current = secret; + try { + const newKeyserverOverride = + await getKeyserverOverrideForAnInviteLink(secret); + setKeyserverOverride(newKeyserverOverride); + } catch (e) { + console.log('Error while downloading an invite link blob', e); + navigation.navigate<'InviteLinkModal'>({ + name: InviteLinkModalRouteName, + params: { + invitationDetails: { + status: 'invalid', + }, + secret, + }, + }); + } + } else if (parsedData.type === 'qr-code') { + navigation.navigate(SecondaryDeviceQRCodeScannerRouteName); + } + })(); + }, [currentLink, loggedIn, navigation]); + + React.useEffect(() => { + const secret = inviteLinkSecret.current; + if (keyserverOverride === undefined || !secret) { + return; + } + setKeyserverOverride(undefined); + + void (async () => { + let result; + try { const validateLinkPromise = validateLink({ secret }); void dispatchActionPromise( verifyInviteLinkActionTypes, validateLinkPromise, ); - const result = await validateLinkPromise; + result = await validateLinkPromise; if (result.status === 'already_joined') { return; } - - navigation.navigate<'InviteLinkModal'>({ - name: InviteLinkModalRouteName, - params: { - invitationDetails: result, - secret, - }, - }); - } else if (parsedData.type === 'qr-code') { - navigation.navigate(SecondaryDeviceQRCodeScannerRouteName); + } catch (e) { + console.log(e); + result = { + status: 'invalid', + }; } + + navigation.navigate<'InviteLinkModal'>({ + name: InviteLinkModalRouteName, + params: { + invitationDetails: result, + secret, + }, + }); })(); - }, [currentLink, dispatchActionPromise, loggedIn, navigation, validateLink]); + }, [dispatchActionPromise, keyserverOverride, navigation, validateLink]); const contextValue = React.useMemo( () => ({ setCurrentLinkUrl: setCurrentLink, }), [], ); return ( {children} ); } export { DeepLinksContext, DeepLinksContextProvider }; diff --git a/web/invite-links/invite-link-handler.react.js b/web/invite-links/invite-link-handler.react.js index d75f564c2..01cfb59de 100644 --- a/web/invite-links/invite-link-handler.react.js +++ b/web/invite-links/invite-link-handler.react.js @@ -1,63 +1,100 @@ // @flow import * as React from 'react'; import { useVerifyInviteLink, verifyInviteLinkActionTypes, } from 'lib/actions/link-actions.js'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; +import { getKeyserverOverrideForAnInviteLink } from 'lib/shared/invite-links.js'; +import type { KeyserverOverride } from 'lib/shared/invite-links.js'; import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import AcceptInviteModal from './accept-invite-modal.react.js'; import { updateNavInfoActionType } from '../redux/action-types.js'; import { useSelector } from '../redux/redux-utils.js'; function InviteLinkHandler(): null { const inviteSecret = useSelector(state => state.navInfo.inviteSecret); + const inviteLinkSecret = React.useRef(null); + const [keyserverOverride, setKeyserverOverride] = + React.useState(undefined); const loggedIn = useSelector(isLoggedIn); const dispatchActionPromise = useDispatchActionPromise(); const dispatch = useDispatch(); - const validateLink = useVerifyInviteLink(); const { pushModal } = useModalContext(); React.useEffect(() => { - if (!inviteSecret || !loggedIn) { + void (async () => { + if (!inviteSecret || !loggedIn) { + return; + } + dispatch({ + type: updateNavInfoActionType, + payload: { inviteSecret: null }, + }); + setKeyserverOverride(undefined); + inviteLinkSecret.current = inviteSecret; + + try { + const newKeyserverOverride = + await getKeyserverOverrideForAnInviteLink(inviteSecret); + setKeyserverOverride(newKeyserverOverride); + } catch (e) { + console.error('Error while downloading an invite link blob', e); + pushModal( + , + ); + } + })(); + }, [dispatch, inviteSecret, loggedIn, pushModal]); + + const validateLink = useVerifyInviteLink(keyserverOverride); + React.useEffect(() => { + const secret = inviteLinkSecret.current; + if (keyserverOverride === undefined || !secret) { return; } - dispatch({ - type: updateNavInfoActionType, - payload: { inviteSecret: null }, - }); - const validateLinkPromise = validateLink({ secret: inviteSecret }); - void dispatchActionPromise( - verifyInviteLinkActionTypes, - validateLinkPromise, - ); + setKeyserverOverride(undefined); + void (async () => { - const result = await validateLinkPromise; - if (result.status === 'already_joined') { - return; + let result; + try { + const validateLinkPromise = validateLink({ secret }); + void dispatchActionPromise( + verifyInviteLinkActionTypes, + validateLinkPromise, + ); + + result = await validateLinkPromise; + if (result.status === 'already_joined') { + return; + } + } catch (e) { + console.error('Error while verifying an invite link', e); + result = { + status: 'invalid', + }; } + pushModal( , ); })(); - }, [ - dispatch, - dispatchActionPromise, - inviteSecret, - loggedIn, - pushModal, - validateLink, - ]); + }, [dispatchActionPromise, keyserverOverride, pushModal, validateLink]); return null; } export default InviteLinkHandler;