Commit 54a35a82 authored by tom goriunov's avatar tom goriunov Committed by GitHub

Changes in ReCaptcha behavior (#2829)

* migrate to v2 API URLs for CSV exports

* don't ask user to solve recaptcha for first response; pass token in header as well

* hide "Try again" button if no bypass allowed

* fix tests
parent ad61f745
......@@ -9,6 +9,8 @@ NEXT_PUBLIC_APP_PORT=3000
NEXT_PUBLIC_APP_ENV=development
NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL=ws
NEXT_PUBLIC_HAS_CONTRACT_AUDIT_REPORTS=true
# Instance ENVs
NEXT_PUBLIC_AD_ADBUTLER_CONFIG_DESKTOP={ "id": "632019", "width": "728", "height": "90" }
NEXT_PUBLIC_AD_ADBUTLER_CONFIG_MOBILE={ "id": "632018", "width": "320", "height": "100" }
......
......@@ -125,6 +125,28 @@ export const GENERAL_API_ADDRESS_RESOURCES = {
path: '/api/v2/proxy/3dparty/xname/addresses/:hash',
pathParams: [ 'hash' as const ],
},
// CSV EXPORTS
address_csv_export_txs: {
path: '/api/v2/addresses/:hash/transactions/csv',
pathParams: [ 'hash' as const ],
},
address_csv_export_internal_txs: {
path: '/api/v2/addresses/:hash/internal-transactions/csv',
pathParams: [ 'hash' as const ],
},
address_csv_export_token_transfers: {
path: '/api/v2/addresses/:hash/token-transfers/csv',
pathParams: [ 'hash' as const ],
},
address_csv_export_logs: {
path: '/api/v2/addresses/:hash/logs/csv',
pathParams: [ 'hash' as const ],
},
address_csv_export_celo_election_rewards: {
path: '/api/v2/addresses/:hash/celo/election-rewards/csv',
pathParams: [ 'hash' as const ],
},
} satisfies Record<string, ApiResource>;
export type GeneralApiAddressResourceName = `general:${ keyof typeof GENERAL_API_ADDRESS_RESOURCES }`;
......
......@@ -246,12 +246,6 @@ export const GENERAL_API_MISC_RESOURCES = {
path: '/api/v2/config/celo',
},
// CSV EXPORT
csv_export_token_holders: {
path: '/api/v2/tokens/:hash/holders/csv',
pathParams: [ 'hash' as const ],
},
// OTHER
api_v2_key: {
path: '/api/v2/key',
......
......@@ -49,6 +49,10 @@ export const GENERAL_API_TOKEN_RESOURCES = {
filterFields: [ 'q' as const, 'chain_ids' as const ],
paginated: true,
},
token_csv_export_holders: {
path: '/api/v2/tokens/:hash/holders/csv',
pathParams: [ 'hash' as const ],
},
// TOKEN INSTANCE
token_instance: {
......
......@@ -2,21 +2,6 @@ import type { ApiResource } from '../../types';
import type { BlockCountdownResponse } from 'types/api/block';
export const GENERAL_API_V1_RESOURCES = {
csv_export_txs: {
path: '/api/v1/transactions-csv',
},
csv_export_internal_txs: {
path: '/api/v1/internal-transactions-csv',
},
csv_export_token_transfers: {
path: '/api/v1/token-transfers-csv',
},
csv_export_logs: {
path: '/api/v1/logs-csv',
},
csv_export_epoch_rewards: {
path: '/api/v1/celo-election-rewards-csv',
},
graphql: {
path: '/api/v1/graphql',
},
......
......@@ -54,6 +54,9 @@ export default function useFetch() {
const error = {
status: response.status,
statusText: response.statusText,
rateLimits: {
bypassOptions: response.headers.get('bypass-429-option'),
},
};
if (meta?.logError && rollbar) {
......@@ -67,18 +70,16 @@ export default function useFetch() {
if (!isJson) {
return response.text().then(
(textError) => Promise.reject({
...error,
payload: textError,
status: response.status,
statusText: response.statusText,
}),
);
}
return response.json().then(
(jsonError) => Promise.reject({
...error,
payload: jsonError as Error,
status: response.status,
statusText: response.statusText,
}),
() => {
return Promise.reject(error);
......
......@@ -23,6 +23,8 @@ export default function fetchFactory(
cookie,
...pick(_req.headers, [
'x-csrf-token',
'recaptcha-v2-response',
'user-agent',
'Authorization', // the old value, just in case
'authorization', // Node.js automatically lowercases headers
// feature flags
......
......@@ -21,14 +21,24 @@ const handler = async(nextReq: NextApiRequest, nextRes: NextApiResponse) => {
);
// proxy some headers from API
const requestId = apiRes.headers.get('x-request-id');
requestId && nextRes.setHeader('x-request-id', requestId);
const HEADERS_TO_PROXY = [
'x-request-id',
'content-type',
'bypass-429-option',
'x-ratelimit-limit',
'x-ratelimit-remaining',
'x-ratelimit-reset',
];
HEADERS_TO_PROXY.forEach((header) => {
const value = apiRes.headers.get(header);
value && nextRes.setHeader(header, value);
});
const setCookie = apiRes.headers.raw()['set-cookie'];
setCookie?.forEach((value) => {
nextRes.appendHeader('set-cookie', value);
});
nextRes.setHeader('content-type', apiRes.headers.get('content-type') || '');
nextRes.status(apiRes.status).send(apiRes.body);
};
......
......@@ -2,6 +2,10 @@ const useReCaptcha = () => {
return {
ref: { current: null },
executeAsync: () => Promise.resolve('recaptcha_token'),
fetchProtectedResource: async(fetcher) => {
const result = await fetcher();
return result;
},
};
};
......
......@@ -20,30 +20,36 @@ const ExportCSV = ({ filters }: Props) => {
const recaptcha = useReCaptcha();
const [ isLoading, setIsLoading ] = React.useState(false);
const handleExportCSV = React.useCallback(async() => {
try {
setIsLoading(true);
const token = await recaptcha.executeAsync();
if (!token) {
throw new Error('ReCaptcha is not solved');
}
const apiFetchFactory = React.useCallback(async(recaptchaToken?: string) => {
const url = buildUrl('general:advanced_filter_csv', undefined, {
...filters,
recaptcha_response: token,
recaptcha_response: recaptchaToken,
});
const response = await fetch(url, {
headers: {
'content-type': 'application/octet-stream',
...(recaptchaToken && { 'recaptcha-v2-response': recaptchaToken }),
},
});
if (!response.ok) {
throw new Error();
throw new Error(response.statusText, {
cause: {
status: response.status,
},
});
}
return response;
}, [ filters ]);
const handleExportCSV = React.useCallback(async() => {
try {
setIsLoading(true);
const response = await recaptcha.fetchProtectedResource(apiFetchFactory);
const blob = await response.blob();
const fileName = `export-filtered-txs-${ dayjs().format('YYYY-MM-DD-HH-mm-ss') }.csv`;
downloadBlob(blob, fileName);
......@@ -56,7 +62,7 @@ const ExportCSV = ({ filters }: Props) => {
} finally {
setIsLoading(false);
}
}, [ filters, recaptcha ]);
}, [ apiFetchFactory, recaptcha ]);
if (!config.services.reCaptchaV2.siteKey) {
return null;
......
......@@ -39,33 +39,40 @@ const CsvExportForm = ({ hash, resource, filterType, filterValue, fileNameTempla
const { handleSubmit, formState } = formApi;
const recaptcha = useReCaptcha();
const onFormSubmit: SubmitHandler<FormFields> = React.useCallback(async(data) => {
try {
const token = await recaptcha.executeAsync();
if (!token) {
throw new Error('ReCaptcha is not solved');
}
const apiFetchFactory = React.useCallback((data: FormFields) => {
return async(recaptchaToken?: string) => {
const url = buildUrl(resource, { hash } as never, {
address_id: hash,
from_period: exportType !== 'holders' ? dayjs(data.from).toISOString() : null,
to_period: exportType !== 'holders' ? dayjs(data.to).toISOString() : null,
filter_type: filterType,
filter_value: filterValue,
recaptcha_response: token,
recaptcha_response: recaptchaToken,
});
const response = await fetch(url, {
headers: {
'content-type': 'application/octet-stream',
...(recaptchaToken && { 'recaptcha-v2-response': recaptchaToken }),
},
});
if (!response.ok) {
throw new Error();
throw new Error(response.statusText, {
cause: {
status: response.status,
},
});
}
return response;
};
}, [ resource, hash, exportType, filterType, filterValue ]);
const onFormSubmit: SubmitHandler<FormFields> = React.useCallback(async(data) => {
try {
const response = await recaptcha.fetchProtectedResource<Response>(apiFetchFactory(data));
const blob = await response.blob();
const fileName = exportType === 'holders' ?
`${ fileNameTemplate }_${ hash }.csv` :
......@@ -80,7 +87,7 @@ const CsvExportForm = ({ hash, resource, filterType, filterValue, fileNameTempla
});
}
}, [ recaptcha, resource, hash, exportType, filterType, filterValue, fileNameTemplate ]);
}, [ recaptcha, apiFetchFactory, exportType, fileNameTemplate, hash, filterType, filterValue ]);
if (!config.services.reCaptchaV2.siteKey) {
return (
......
......@@ -46,19 +46,21 @@ const MyProfileEmail = ({ profileQuery }: Props) => {
},
});
const onFormSubmit: SubmitHandler<FormFields> = React.useCallback(async(formData) => {
try {
const token = await recaptcha.executeAsync();
await apiFetch('general:auth_send_otp', {
const authFetchFactory = React.useCallback((email: string) => (recaptchaToken?: string) => {
return apiFetch('general:auth_send_otp', {
fetchParams: {
method: 'POST',
body: {
email: formData.email,
recaptcha_response: token,
body: { email, recaptcha_response: recaptchaToken },
headers: {
...(recaptchaToken && { 'recaptcha-v2-response': recaptchaToken }),
},
},
});
}, [ apiFetch ]);
const onFormSubmit: SubmitHandler<FormFields> = React.useCallback(async(formData) => {
try {
await recaptcha.fetchProtectedResource(authFetchFactory(formData.email));
mixpanel.logEvent(mixpanel.EventTypes.ACCOUNT_LINK_INFO, {
Source: 'Profile',
Status: 'OTP sent',
......@@ -72,7 +74,7 @@ const MyProfileEmail = ({ profileQuery }: Props) => {
description: apiError?.message || getErrorMessage(error) || 'Something went wrong',
});
}
}, [ apiFetch, authModal, recaptcha ]);
}, [ authFetchFactory, authModal, recaptcha ]);
const hasDirtyFields = Object.keys(formApi.formState.dirtyFields).length > 0;
......
......@@ -29,39 +29,39 @@ interface ExportTypeEntity {
const EXPORT_TYPES: Record<CsvExportParams['type'], ExportTypeEntity> = {
transactions: {
text: 'transactions',
resource: 'general:csv_export_txs',
resource: 'general:address_csv_export_txs',
fileNameTemplate: 'transactions',
filterType: 'address',
filterValues: AddressFromToFilterValues,
},
'internal-transactions': {
text: 'internal transactions',
resource: 'general:csv_export_internal_txs',
resource: 'general:address_csv_export_internal_txs',
fileNameTemplate: 'internal_transactions',
filterType: 'address',
filterValues: AddressFromToFilterValues,
},
'token-transfers': {
text: 'token transfers',
resource: 'general:csv_export_token_transfers',
resource: 'general:address_csv_export_token_transfers',
fileNameTemplate: 'token_transfers',
filterType: 'address',
filterValues: AddressFromToFilterValues,
},
logs: {
text: 'logs',
resource: 'general:csv_export_logs',
resource: 'general:address_csv_export_logs',
fileNameTemplate: 'logs',
filterType: 'topic',
},
holders: {
text: 'holders',
resource: 'general:csv_export_token_holders',
resource: 'general:token_csv_export_holders',
fileNameTemplate: 'holders',
},
'epoch-rewards': {
text: 'epoch rewards',
resource: 'general:csv_export_epoch_rewards',
resource: 'general:address_csv_export_celo_election_rewards',
fileNameTemplate: 'epoch_rewards',
},
};
......
......@@ -7,6 +7,7 @@ import config from 'configs/app';
import getErrorCause from 'lib/errors/getErrorCause';
import getErrorCauseStatusCode from 'lib/errors/getErrorCauseStatusCode';
import getErrorObjStatusCode from 'lib/errors/getErrorObjStatusCode';
import getErrorProp from 'lib/errors/getErrorProp';
import getResourceErrorPayload from 'lib/errors/getResourceErrorPayload';
import { Button } from 'toolkit/chakra/button';
import { Link } from 'toolkit/chakra/link';
......@@ -77,7 +78,9 @@ const AppError = ({ error, className }: Props) => {
switch (statusCode) {
case 429: {
return <AppErrorTooManyRequests/>;
const rateLimits = getErrorProp(error, 'rateLimits');
const bypassOptions = typeof rateLimits === 'object' && rateLimits && 'bypassOptions' in rateLimits ? rateLimits.bypassOptions : undefined;
return <AppErrorTooManyRequests bypassOptions={ typeof bypassOptions === 'string' ? bypassOptions : undefined }/>;
}
default: {
......
......@@ -12,18 +12,30 @@ import useReCaptcha from 'ui/shared/reCaptcha/useReCaptcha';
import AppErrorIcon from '../AppErrorIcon';
import AppErrorTitle from '../AppErrorTitle';
const AppErrorTooManyRequests = () => {
interface Props {
bypassOptions?: string;
}
const AppErrorTooManyRequests = ({ bypassOptions }: Props) => {
const fetch = useFetch();
const recaptcha = useReCaptcha();
const handleSubmit = React.useCallback(async() => {
try {
const token = await recaptcha.executeAsync();
if (!token) {
throw new Error('ReCaptcha is not solved');
}
const url = buildUrl('general:api_v2_key');
await fetch(url, {
method: 'POST',
body: { recaptcha_response: token },
headers: {
'recaptcha-v2-response': token,
},
credentials: 'include',
}, {
resource: 'general:api_v2_key',
......@@ -52,7 +64,7 @@ const AppErrorTooManyRequests = () => {
You have exceeded the request rate for a given time period. Please reduce the number of requests and try again soon.
</Text>
<ReCaptcha { ...recaptcha }/>
<Button onClick={ handleSubmit } disabled={ recaptcha.isInitError } mt={ 8 }>Try again</Button>
{ bypassOptions !== 'no_bypass' && <Button onClick={ handleSubmit } disabled={ recaptcha.isInitError } mt={ 8 }>Try again</Button> }
</>
);
};
......
import React from 'react';
import type ReCAPTCHA from 'react-google-recaptcha';
import getErrorCauseStatusCode from 'lib/errors/getErrorCauseStatusCode';
import getErrorObjStatusCode from 'lib/errors/getErrorObjStatusCode';
export default function useReCaptcha() {
const ref = React.useRef<ReCAPTCHA>(null);
const rejectCb = React.useRef<((error: Error) => void) | null>(null);
......@@ -40,5 +43,31 @@ export default function useReCaptcha() {
};
}, [ isOpen, handleContainerClick ]);
return React.useMemo(() => ({ ref, executeAsync, isInitError, onInitError: handleInitError }), [ ref, executeAsync, isInitError, handleInitError ]);
const fetchProtectedResource: <T>(fetcher: (token?: string) => Promise<T>, token?: string) => Promise<T> = React.useCallback(async(fetcher, token) => {
try {
const result = await fetcher(token);
return result;
} catch (error) {
const statusCode = error instanceof Error ? getErrorCauseStatusCode(error) : getErrorObjStatusCode(error);
if (statusCode === 429) {
const token = await executeAsync();
if (!token) {
throw new Error('ReCaptcha is not solved');
}
return fetchProtectedResource(fetcher, token);
}
throw error;
}
}, [ executeAsync ]);
return React.useMemo(() => ({
ref,
executeAsync,
isInitError,
onInitError: handleInitError,
fetchProtectedResource,
}), [ ref, executeAsync, isInitError, handleInitError, fetchProtectedResource ]);
}
......@@ -35,7 +35,7 @@ const AuthModalScreenConnectWallet = ({ onSuccess, onError, isAuth, source, logi
onError: handleSignInError,
source,
isAuth,
executeRecaptchaAsync: recaptcha.executeAsync,
fetchProtectedResource: recaptcha.fetchProtectedResource,
loginToRewards,
});
......
......@@ -37,19 +37,22 @@ const AuthModalScreenEmail = ({ onSubmit, isAuth, mixpanelConfig }: Props) => {
},
});
const onFormSubmit: SubmitHandler<EmailFormFields> = React.useCallback(async(formData) => {
try {
const token = await recaptcha.executeAsync();
await apiFetch('general:auth_send_otp', {
const sendCodeFetchFactory = React.useCallback((email: string) => (recaptchaToken?: string) => {
return apiFetch('general:auth_send_otp', {
fetchParams: {
method: 'POST',
body: {
email: formData.email,
recaptcha_response: token,
body: { email, recaptcha_response: recaptchaToken },
headers: {
...(recaptchaToken && { 'recaptcha-v2-response': recaptchaToken }),
},
},
});
}, [ apiFetch ]);
const onFormSubmit: SubmitHandler<EmailFormFields> = React.useCallback(async(formData) => {
try {
await recaptcha.fetchProtectedResource(sendCodeFetchFactory(formData.email));
if (isAuth) {
mixpanelConfig?.account_link_info.source !== 'Profile' && mixpanel.logEvent(mixpanel.EventTypes.ACCOUNT_LINK_INFO, {
Source: mixpanelConfig?.account_link_info.source ?? 'Profile dropdown',
......@@ -69,7 +72,7 @@ const AuthModalScreenEmail = ({ onSubmit, isAuth, mixpanelConfig }: Props) => {
description: getErrorObjPayload<{ message: string }>(error)?.message || getErrorMessage(error) || 'Something went wrong',
});
}
}, [ recaptcha, apiFetch, isAuth, onSubmit, mixpanelConfig?.account_link_info.source ]);
}, [ recaptcha, sendCodeFetchFactory, isAuth, onSubmit, mixpanelConfig?.account_link_info.source ]);
return (
<FormProvider { ...formApi }>
......
......@@ -68,17 +68,23 @@ const AuthModalScreenOtpCode = ({ email, onSuccess, isAuth }: Props) => {
});
}, [ apiFetch, email, onSuccess, isAuth, formApi ]);
const handleResendCodeClick = React.useCallback(async() => {
try {
formApi.clearErrors('code');
setIsCodeSending(true);
const token = await recaptcha.executeAsync();
await apiFetch('general:auth_send_otp', {
const resendCodeFetchFactory = React.useCallback((recaptchaToken?: string) => {
return apiFetch('general:auth_send_otp', {
fetchParams: {
method: 'POST',
body: { email, recaptcha_response: token },
body: { email, recaptcha_response: recaptchaToken },
headers: {
...(recaptchaToken && { 'recaptcha-v2-response': recaptchaToken }),
},
},
});
}, [ apiFetch, email ]);
const handleResendCodeClick = React.useCallback(async() => {
try {
formApi.clearErrors('code');
setIsCodeSending(true);
await recaptcha.fetchProtectedResource(resendCodeFetchFactory);
toaster.success({
title: 'Success',
......@@ -94,7 +100,7 @@ const AuthModalScreenOtpCode = ({ email, onSuccess, isAuth }: Props) => {
} finally {
setIsCodeSending(false);
}
}, [ apiFetch, email, formApi, recaptcha ]);
}, [ formApi, recaptcha, resendCodeFetchFactory ]);
return (
<FormProvider { ...formApi }>
......
......@@ -41,10 +41,10 @@ interface Props {
source?: mixpanel.EventPayload<mixpanel.EventTypes.WALLET_CONNECT>['Source'];
isAuth?: boolean;
loginToRewards?: boolean;
executeRecaptchaAsync: () => Promise<string | null>;
fetchProtectedResource: <T>(fetcher: (token?: string) => Promise<T>, token?: string) => Promise<T>;
}
function useSignInWithWallet({ onSuccess, onError, source = 'Login', isAuth, loginToRewards, executeRecaptchaAsync }: Props) {
function useSignInWithWallet({ onSuccess, onError, source = 'Login', isAuth, loginToRewards, fetchProtectedResource }: Props) {
const [ isPending, setIsPending ] = React.useState(false);
const isConnectingWalletRef = React.useRef(false);
......@@ -96,24 +96,26 @@ function useSignInWithWallet({ onSuccess, onError, source = 'Login', isAuth, log
}
}, [ apiFetch, loginToRewards ]);
const authFetchFactory = React.useCallback((message: string, signature: string) => (recaptchaToken?: string) => {
const authResource = isAuth ? 'general:auth_link_address' : 'general:auth_siwe_verify';
return apiFetch<typeof authResource, UserInfo, unknown>(authResource, {
fetchParams: {
method: 'POST',
body: { message, signature, recaptcha_response: recaptchaToken },
headers: {
...(recaptchaToken && { 'recaptcha-v2-response': recaptchaToken }),
},
},
});
}, [ apiFetch, isAuth ]);
const proceedToAuth = React.useCallback(async(address: string) => {
try {
await switchChainAsync({ chainId: Number(config.chain.id) });
const siweMessage = await getSiweMessage(address);
const signature = await signMessageAsync({ message: siweMessage.message });
const recaptchaToken = await executeRecaptchaAsync();
if (!recaptchaToken) {
throw new Error('ReCaptcha is not solved');
}
const authResource = isAuth ? 'general:auth_link_address' : 'general:auth_siwe_verify';
const authResponse = await apiFetch<typeof authResource, UserInfo, unknown>(authResource, {
fetchParams: {
method: 'POST',
body: { message: siweMessage.message, signature, recaptcha_response: recaptchaToken },
},
});
const authResponse = await fetchProtectedResource(authFetchFactory(siweMessage.message, signature));
const rewardsLoginResponse = siweMessage.type === 'shared' ?
await apiFetch('rewards:login', {
......@@ -143,7 +145,7 @@ function useSignInWithWallet({ onSuccess, onError, source = 'Login', isAuth, log
} finally {
setIsPending(false);
}
}, [ getSiweMessage, switchChainAsync, signMessageAsync, executeRecaptchaAsync, isAuth, apiFetch, onSuccess, onError ]);
}, [ switchChainAsync, getSiweMessage, signMessageAsync, fetchProtectedResource, authFetchFactory, apiFetch, onSuccess, onError ]);
const start = React.useCallback(() => {
setIsPending(true);
......
......@@ -45,16 +45,22 @@ const TokenInstanceMetadataFetcher = ({ hash, id }: Props) => {
});
}, [ setStatus ]);
const initializeUpdate = React.useCallback(async(tokenProp?: string) => {
try {
const token = tokenProp || await recaptcha.executeAsync();
await apiFetch<'general:token_instance_refresh_metadata', unknown, unknown>('general:token_instance_refresh_metadata', {
const apiFetchFactory = React.useCallback(async(recaptchaToken?: string) => {
return apiFetch<'general:token_instance_refresh_metadata', unknown, unknown>('general:token_instance_refresh_metadata', {
pathParams: { hash, id },
fetchParams: {
method: 'PATCH',
body: { recaptcha_response: token },
body: { recaptcha_response: recaptchaToken },
headers: {
...(recaptchaToken && { 'recaptcha-v2-response': recaptchaToken }),
},
},
});
}, [ apiFetch, hash, id ]);
const initializeUpdate = React.useCallback(async() => {
try {
await recaptcha.fetchProtectedResource(apiFetchFactory);
setStatus?.('WAITING_FOR_RESPONSE');
toaster.loading({
id: TOAST_ID,
......@@ -72,7 +78,7 @@ const TokenInstanceMetadataFetcher = ({ hash, id }: Props) => {
setStatus?.('ERROR');
}
}, [ apiFetch, handleRefreshError, hash, id, recaptcha, setStatus ]);
}, [ apiFetchFactory, handleRefreshError, recaptcha, setStatus ]);
const handleModalClose = React.useCallback(({ open }: { open: boolean }) => {
if (!open) {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment