diff --git a/configs/app/config.ts b/configs/app/config.ts index 89d5b768d2..6892ec07b0 100644 --- a/configs/app/config.ts +++ b/configs/app/config.ts @@ -136,6 +136,7 @@ const config = Object.freeze({ host: appHost, port: appPort, baseUrl, + useNextJsProxy: getEnvValue(process.env.NEXT_PUBLIC_USE_NEXT_JS_PROXY) === 'true', }, ad: { adBannerProvider: getAdBannerProvider(), diff --git a/docs/ENVS.md b/docs/ENVS.md index e16891ca40..392167ce29 100644 --- a/docs/ENVS.md +++ b/docs/ENVS.md @@ -128,6 +128,7 @@ In order to enable "My Account" feature you have to configure following set of v | NEXT_PUBLIC_APP_HOST | `string` | App host | yes | - | `blockscout.com` | | NEXT_PUBLIC_APP_PORT | `number` | Port where app is running | - | `3000` | `3001` | | NEXT_PUBLIC_APP_ENV | `string` | Current app env (e.g development, review or production). Used for Sentry.io configuration | - | equals to `process.env.NODE_ENV` | `production` | +| NEXT_PUBLIC_USE_NEXT_JS_PROXY | `boolean` | Tells the app to proxy all APIs request through the NextJs app. **We strongly advise not to use it in the production environment** | - | `false` | `true` | ## API configuration diff --git a/icons/email-sent.svg b/icons/email-sent.svg index 9fb03dd81a..d31e30f244 100644 --- a/icons/email-sent.svg +++ b/icons/email-sent.svg @@ -1,3 +1,3 @@ - + diff --git a/icons/error-pages/429.svg b/icons/error-pages/429.svg new file mode 100644 index 0000000000..9ae110e192 --- /dev/null +++ b/icons/error-pages/429.svg @@ -0,0 +1,3 @@ + + + diff --git a/lib/api/isBodyAllowed.ts b/lib/api/isBodyAllowed.ts new file mode 100644 index 0000000000..aad52fedb7 --- /dev/null +++ b/lib/api/isBodyAllowed.ts @@ -0,0 +1,3 @@ +export default function isBodyAllowed(method: string | undefined | null) { + return method && ![ 'GET', 'HEAD' ].includes(method); +} diff --git a/lib/api/isNeedProxy.ts b/lib/api/isNeedProxy.ts index e2ae76fdb8..a348a196f7 100644 --- a/lib/api/isNeedProxy.ts +++ b/lib/api/isNeedProxy.ts @@ -5,5 +5,9 @@ import appConfig from 'configs/app/config'; // unsuccessfully tried different ways, even custom local dev domain // so for local development we have to use next.js api as proxy server export default function isNeedProxy() { + if (appConfig.app.useNextJsProxy) { + return true; + } + return appConfig.app.host === 'localhost' && appConfig.app.host !== appConfig.api.host; } diff --git a/lib/api/resources.ts b/lib/api/resources.ts index ffa751f06c..7a9ed2ce7c 100644 --- a/lib/api/resources.ts +++ b/lib/api/resources.ts @@ -474,6 +474,11 @@ export const RESOURCES = { path: '/api/v2/config/backend-version', }, + // OTHER + api_v2_key: { + path: '/api/v2/key', + }, + // DEPRECATED old_api: { path: '/api', diff --git a/lib/api/useApiFetch.tsx b/lib/api/useApiFetch.tsx index f879b982e9..71a0cf93a0 100644 --- a/lib/api/useApiFetch.tsx +++ b/lib/api/useApiFetch.tsx @@ -1,6 +1,12 @@ +import { useQueryClient } from '@tanstack/react-query'; +import _pickBy from 'lodash/pickBy'; import React from 'react'; +import type { CsrfData } from 'types/client/account'; + +import isBodyAllowed from 'lib/api/isBodyAllowed'; import isNeedProxy from 'lib/api/isNeedProxy'; +import { getResourceKey } from 'lib/api/useApiQuery'; import * as cookies from 'lib/cookies'; import type { Params as FetchParams } from 'lib/hooks/useFetch'; import useFetch from 'lib/hooks/useFetch'; @@ -17,28 +23,34 @@ export interface Params { export default function useApiFetch() { const fetch = useFetch(); + const queryClient = useQueryClient(); + const { token: csrfToken } = queryClient.getQueryData(getResourceKey('csrf')) || {}; return React.useCallback(( resourceName: R, { pathParams, queryParams, fetchParams }: Params = {}, ) => { + const apiToken = cookies.get(cookies.NAMES.API_TOKEN); + const resource: ApiResource = RESOURCES[resourceName]; const url = buildUrl(resourceName, pathParams, queryParams); + const withBody = isBodyAllowed(fetchParams?.method); + const headers = _pickBy({ + 'x-endpoint': resource.endpoint && isNeedProxy() ? resource.endpoint : undefined, + Authorization: resource.endpoint && resource.needAuth ? apiToken : undefined, + 'x-csrf-token': withBody && csrfToken ? csrfToken : undefined, + }, Boolean) as HeadersInit; + return fetch( url, { credentials: 'include', - ...(resource.endpoint ? { - headers: { - ...(isNeedProxy() ? { 'x-endpoint': resource.endpoint } : {}), - ...(resource.needAuth ? { Authorization: cookies.get(cookies.NAMES.API_TOKEN) } : {}), - }, - } : {}), + headers, ...fetchParams, }, { resource: resource.path, }, ); - }, [ fetch ]); + }, [ fetch, csrfToken ]); } diff --git a/lib/hooks/useFetch.tsx b/lib/hooks/useFetch.tsx index 5f77ec3a1a..b399b36992 100644 --- a/lib/hooks/useFetch.tsx +++ b/lib/hooks/useFetch.tsx @@ -1,11 +1,8 @@ import * as Sentry from '@sentry/react'; -import { useQueryClient } from '@tanstack/react-query'; import React from 'react'; -import type { CsrfData } from 'types/client/account'; - +import isBodyAllowed from 'lib/api/isBodyAllowed'; import type { ResourceError, ResourcePath } from 'lib/api/resources'; -import { getResourceKey } from 'lib/api/useApiQuery'; export interface Params { method?: RequestInit['method']; @@ -20,16 +17,13 @@ interface Meta { } export default function useFetch() { - const queryClient = useQueryClient(); - const { token } = queryClient.getQueryData(getResourceKey('csrf')) || {}; - return React.useCallback((path: string, params?: Params, meta?: Meta): Promise> => { const _body = params?.body; const isFormData = _body instanceof FormData; - const isBodyAllowed = params?.method && ![ 'GET', 'HEAD' ].includes(params.method); + const withBody = isBodyAllowed(params?.method); const body: FormData | string | undefined = (() => { - if (!isBodyAllowed) { + if (!withBody) { return; } @@ -44,8 +38,7 @@ export default function useFetch() { ...params, body, headers: { - ...(isBodyAllowed && !isFormData ? { 'Content-type': 'application/json' } : undefined), - ...(isBodyAllowed && token ? { 'x-csrf-token': token } : undefined), + ...(withBody && !isFormData ? { 'Content-type': 'application/json' } : undefined), ...params?.headers, }, }; @@ -56,7 +49,7 @@ export default function useFetch() { status: response.status, statusText: response.statusText, }; - Sentry.captureException(new Error('Client fetch failed'), { extra: { ...error, ...meta }, tags: { source: 'api_fetch' } }); + Sentry.captureException(new Error('Client fetch failed'), { extra: { ...error, ...meta }, tags: { source: 'fetch' } }); return response.json().then( (jsonError) => Promise.reject({ @@ -73,5 +66,5 @@ export default function useFetch() { return response.json() as Promise; } }); - }, [ token ]); + }, [ ]); } diff --git a/pages/_app.tsx b/pages/_app.tsx index 4eebabb0ac..79269f938a 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -1,3 +1,4 @@ +import type { ChakraProps } from '@chakra-ui/react'; import * as Sentry from '@sentry/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; @@ -5,15 +6,17 @@ import type { AppProps } from 'next/app'; import React, { useState } from 'react'; import appConfig from 'configs/app/config'; -import type { ResourceError } from 'lib/api/resources'; import { AppContextProvider } from 'lib/contexts/app'; import { ChakraProvider } from 'lib/contexts/chakra'; import { ScrollDirectionProvider } from 'lib/contexts/scrollDirection'; import getErrorCauseStatusCode from 'lib/errors/getErrorCauseStatusCode'; +import getErrorObjPayload from 'lib/errors/getErrorObjPayload'; +import getErrorObjStatusCode from 'lib/errors/getErrorObjStatusCode'; import useConfigSentry from 'lib/hooks/useConfigSentry'; import { SocketProvider } from 'lib/socket/context'; import theme from 'theme'; import AppError from 'ui/shared/AppError/AppError'; +import AppErrorTooManyRequests from 'ui/shared/AppError/AppErrorTooManyRequests'; import ErrorBoundary from 'ui/shared/ErrorBoundary'; import GoogleAnalytics from 'ui/shared/GoogleAnalytics'; @@ -27,33 +30,47 @@ function MyApp({ Component, pageProps }: AppProps) { defaultOptions: { queries: { refetchOnWindowFocus: false, - retry: (failureCount, _error) => { - const error = _error as ResourceError<{ status: number }>; - const status = error?.payload?.status || error?.status; + retry: (failureCount, error) => { + const errorPayload = getErrorObjPayload<{ status: number }>(error); + const status = errorPayload?.status || getErrorObjStatusCode(error); if (status && status >= 400 && status < 500) { // don't do retry for client error responses return false; } - return failureCount < 2; }, + useErrorBoundary: (error) => { + const status = getErrorObjStatusCode(error); + // don't catch error for "Too many requests" response + return status === 429; + }, }, }, })); const renderErrorScreen = React.useCallback((error?: Error) => { - const statusCode = getErrorCauseStatusCode(error); + const statusCode = getErrorCauseStatusCode(error) || getErrorObjStatusCode(error); + + const styles: ChakraProps = { + h: '100vh', + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + justifyContent: 'center', + width: 'fit-content', + maxW: '800px', + margin: '0 auto', + p: { base: 4, lg: 0 }, + }; + + if (statusCode === 429) { + return ; + } return ( ); }, []); diff --git a/pages/api/proxy.ts b/pages/api/proxy.ts index f40439ffa6..3e30983f10 100644 --- a/pages/api/proxy.ts +++ b/pages/api/proxy.ts @@ -5,22 +5,26 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import appConfig from 'configs/app/config'; import fetchFactory from 'lib/api/nodeFetch'; -const handler = async(_req: NextApiRequest, res: NextApiResponse) => { - if (!_req.url) { - res.status(500).json({ error: 'no url provided' }); +const handler = async(nextReq: NextApiRequest, nextRes: NextApiResponse) => { + if (!nextReq.url) { + nextRes.status(500).json({ error: 'no url provided' }); return; } const url = new URL( - _req.url.replace(/^\/node-api\/proxy/, ''), - _req.headers['x-endpoint']?.toString() || appConfig.api.endpoint, + nextReq.url.replace(/^\/node-api\/proxy/, ''), + nextReq.headers['x-endpoint']?.toString() || appConfig.api.endpoint, ); - const response = await fetchFactory(_req)( + const apiRes = await fetchFactory(nextReq)( url.toString(), - _pickBy(_pick(_req, [ 'body', 'method' ]), Boolean), + _pickBy(_pick(nextReq, [ 'body', 'method' ]), Boolean), ); - res.status(response.status).send(response.body); + // proxy some headers from API + nextRes.setHeader('x-request-id', apiRes.headers.get('x-request-id') || ''); + nextRes.setHeader('set-cookie', apiRes.headers.get('set-cookie') || ''); + + nextRes.status(apiRes.status).send(apiRes.body); }; export default handler; diff --git a/ui/shared/AppError/AppErrorTooManyRequests.pw.tsx b/ui/shared/AppError/AppErrorTooManyRequests.pw.tsx new file mode 100644 index 0000000000..5647b281d2 --- /dev/null +++ b/ui/shared/AppError/AppErrorTooManyRequests.pw.tsx @@ -0,0 +1,21 @@ +import { test, expect } from '@playwright/experimental-ct-react'; +import React from 'react'; + +import TestApp from 'playwright/TestApp'; +import * as configs from 'playwright/utils/configs'; + +import AppErrorTooManyRequests from './AppErrorTooManyRequests'; + +test('default view +@mobile', async({ mount, page }) => { + const component = await mount( + + + , + ); + await page.waitForResponse('https://www.google.com/recaptcha/api2/**'); + + await expect(component).toHaveScreenshot({ + mask: [ page.locator('.recaptcha') ], + maskColor: configs.maskColor, + }); +}); diff --git a/ui/shared/AppError/AppErrorTooManyRequests.tsx b/ui/shared/AppError/AppErrorTooManyRequests.tsx new file mode 100644 index 0000000000..bc51becaf7 --- /dev/null +++ b/ui/shared/AppError/AppErrorTooManyRequests.tsx @@ -0,0 +1,74 @@ +import { Box, Heading, Icon, Text, chakra } from '@chakra-ui/react'; +import React from 'react'; +import ReCaptcha from 'react-google-recaptcha'; + +import appConfig from 'configs/app/config'; +import icon429 from 'icons/error-pages/429.svg'; +import buildUrl from 'lib/api/buildUrl'; +import useFetch from 'lib/hooks/useFetch'; +import useToast from 'lib/hooks/useToast'; + +interface Props { + className?: string; +} + +const AppErrorTooManyRequests = ({ className }: Props) => { + const toast = useToast(); + const fetch = useFetch(); + + const handleReCaptchaChange = React.useCallback(async(token: string | null) => { + + if (token) { + try { + const url = buildUrl('api_v2_key'); + + await fetch(url, { + method: 'POST', + body: { recaptcha_response: token }, + credentials: 'include', + }, { + resource: 'api_v2_key', + }); + + window.location.reload(); + + } catch (error) { + toast({ + position: 'top-right', + title: 'Error', + description: 'Unable to get client key.', + status: 'error', + variant: 'subtle', + isClosable: true, + }); + } + } + }, [ toast, fetch ]); + + return ( + + + Too many requests + + You have exceeded the request rate for a given time period. Please reduce the number of requests and try again soon. + + { appConfig.reCaptcha.siteKey && ( + + ) } + + ); +}; + +export default chakra(AppErrorTooManyRequests); diff --git a/ui/shared/AppError/__screenshots__/AppErrorTooManyRequests.pw.tsx_default_default-view-mobile-1.png b/ui/shared/AppError/__screenshots__/AppErrorTooManyRequests.pw.tsx_default_default-view-mobile-1.png new file mode 100644 index 0000000000..337f3d44ea Binary files /dev/null and b/ui/shared/AppError/__screenshots__/AppErrorTooManyRequests.pw.tsx_default_default-view-mobile-1.png differ diff --git a/ui/shared/AppError/__screenshots__/AppErrorTooManyRequests.pw.tsx_mobile_default-view-mobile-1.png b/ui/shared/AppError/__screenshots__/AppErrorTooManyRequests.pw.tsx_mobile_default-view-mobile-1.png new file mode 100644 index 0000000000..df9ec1f57a Binary files /dev/null and b/ui/shared/AppError/__screenshots__/AppErrorTooManyRequests.pw.tsx_mobile_default-view-mobile-1.png differ diff --git a/ui/shared/Page/Page.tsx b/ui/shared/Page/Page.tsx index ff41c56cdf..b76888575a 100644 --- a/ui/shared/Page/Page.tsx +++ b/ui/shared/Page/Page.tsx @@ -43,7 +43,7 @@ const Page = ({ resourceErrorPayload.message : undefined; - const isInvalidTxHash = error?.message.includes('Invalid tx hash'); + const isInvalidTxHash = error?.message?.includes('Invalid tx hash'); const isBlockConsensus = messageInPayload?.includes('Block lost consensus'); if (isInvalidTxHash) { diff --git a/ui/shared/nft/NftMedia.tsx b/ui/shared/nft/NftMedia.tsx index 782020676a..0381aeb55d 100644 --- a/ui/shared/nft/NftMedia.tsx +++ b/ui/shared/nft/NftMedia.tsx @@ -3,6 +3,8 @@ import type { StaticRoute } from 'nextjs-routes'; import { route } from 'nextjs-routes'; import React from 'react'; +import useFetch from 'lib/hooks/useFetch'; + import NftImage from './NftImage'; import NftVideo from './NftVideo'; import type { MediaType } from './utils'; @@ -17,6 +19,7 @@ interface Props { const NftMedia = ({ imageUrl, animationUrl, className, isLoading }: Props) => { const [ type, setType ] = React.useState(!animationUrl ? 'image' : undefined); + const fetch = useFetch(); React.useEffect(() => { if (!animationUrl || isLoading) { @@ -38,7 +41,6 @@ const NftMedia = ({ imageUrl, animationUrl, className, isLoading }: Props) => { const url = route({ pathname: '/node-api/media-type' as StaticRoute<'/api/media-type'>['pathname'], query: { url: animationUrl } }); fetch(url) - .then((response) => response.json()) .then((_data) => { const data = _data as { type: MediaType | undefined }; setType(data.type || 'image'); @@ -47,7 +49,7 @@ const NftMedia = ({ imageUrl, animationUrl, className, isLoading }: Props) => { setType('image'); }); - }, [ animationUrl, isLoading ]); + }, [ animationUrl, isLoading, fetch ]); if (!type || isLoading) { return (