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 (