Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

v1.8.0 #4

Closed
wants to merge 23 commits into from
Closed
Changes from 1 commit
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
d034f64
tokens sorting
isstuev Jul 18, 2023
1bbdd6a
[skip ci] contribution guide and readme update
tom2drum Jul 26, 2023
b65d96a
[skip ci] Update README.md
tom2drum Jul 26, 2023
d69b809
ENVs integrity check (#1039)
tom2drum Jul 26, 2023
cbcd175
[skip ci] remove concurrency for release workflow
tom2drum Jul 27, 2023
a44bc35
Merge pull request #1027 from blockscout/token-sortings
isstuev Jul 28, 2023
b0593fc
[skip ci] add dev container config
tom2drum Jul 28, 2023
a65ef55
Merge branch 'main' of github.com:blockscout/frontend
tom2drum Jul 28, 2023
4930181
hide add to wallet button if no rpc url
isstuev Jul 31, 2023
38c2538
fix envs schems
tom2drum Jul 31, 2023
b763fa4
beacon network currency symbol
isstuev Aug 1, 2023
8078b93
404 if marketplace is not configured
isstuev Jul 31, 2023
6a2ec0e
Merge pull request #1048 from blockscout/no-rpc-no-add-network
isstuev Aug 1, 2023
db17f61
fix
isstuev Aug 1, 2023
c41fef8
Merge pull request #1051 from blockscout/withdrawals-curr
isstuev Aug 1, 2023
bbbbdfe
lower case
isstuev Aug 2, 2023
6655687
image build process improvements (#1045)
tom2drum Aug 2, 2023
7a6afac
remove fallback url for token icon to trust wallet assets (#1042)
tom2drum Aug 2, 2023
727e88a
disable focus state for buttons on mobile (#1043)
tom2drum Aug 2, 2023
35a1c90
[skip ci] fix typo
tom2drum Aug 2, 2023
e44179c
Merge pull request #1054 from blockscout/lower-case
isstuev Aug 2, 2023
b38b5a8
Moving blockscout deployment to new helm chart (#1059)
nzenchik Aug 2, 2023
908543b
support 429 "Too many requests" API error (#1004)
tom2drum Aug 2, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions configs/app/config.ts
Original file line number Diff line number Diff line change
@@ -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(),
1 change: 1 addition & 0 deletions docs/ENVS.md
Original file line number Diff line number Diff line change
@@ -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

2 changes: 1 addition & 1 deletion icons/email-sent.svg

Unable to render rich display

3 changes: 3 additions & 0 deletions icons/error-pages/429.svg

Unable to render rich display

3 changes: 3 additions & 0 deletions lib/api/isBodyAllowed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function isBodyAllowed(method: string | undefined | null) {
return method && ![ 'GET', 'HEAD' ].includes(method);
}
4 changes: 4 additions & 0 deletions lib/api/isNeedProxy.ts
Original file line number Diff line number Diff line change
@@ -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;
}
5 changes: 5 additions & 0 deletions lib/api/resources.ts
Original file line number Diff line number Diff line change
@@ -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',
26 changes: 19 additions & 7 deletions lib/api/useApiFetch.tsx
Original file line number Diff line number Diff line change
@@ -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<R extends ResourceName> {

export default function useApiFetch() {
const fetch = useFetch();
const queryClient = useQueryClient();
const { token: csrfToken } = queryClient.getQueryData<CsrfData>(getResourceKey('csrf')) || {};

return React.useCallback(<R extends ResourceName, SuccessType = unknown, ErrorType = unknown>(
resourceName: R,
{ pathParams, queryParams, fetchParams }: Params<R> = {},
) => {
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<SuccessType, ErrorType>(
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 ]);
}
19 changes: 6 additions & 13 deletions lib/hooks/useFetch.tsx
Original file line number Diff line number Diff line change
@@ -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<CsrfData>(getResourceKey('csrf')) || {};

return React.useCallback(<Success, Error>(path: string, params?: Params, meta?: Meta): Promise<Success | ResourceError<Error>> => {
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<Success>;
}
});
}, [ token ]);
}, [ ]);
}
43 changes: 30 additions & 13 deletions pages/_app.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
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';
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 <AppErrorTooManyRequests { ...styles }/>;
}

return (
<AppError
statusCode={ statusCode || 500 }
height="100vh"
display="flex"
flexDirection="column"
alignItems="flex-start"
justifyContent="center"
width="fit-content"
margin="0 auto"
{ ...styles }
/>
);
}, []);
20 changes: 12 additions & 8 deletions pages/api/proxy.ts
Original file line number Diff line number Diff line change
@@ -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;
21 changes: 21 additions & 0 deletions ui/shared/AppError/AppErrorTooManyRequests.pw.tsx
Original file line number Diff line number Diff line change
@@ -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(
<TestApp>
<AppErrorTooManyRequests/>
</TestApp>,
);
await page.waitForResponse('https://www.google.com/recaptcha/api2/**');

await expect(component).toHaveScreenshot({
mask: [ page.locator('.recaptcha') ],
maskColor: configs.maskColor,
});
});
74 changes: 74 additions & 0 deletions ui/shared/AppError/AppErrorTooManyRequests.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box
className={ className }
sx={{
'.recaptcha': {
mt: 8,
h: '78px', // otherwise content will jump after reCaptcha is loaded
},
}}
>
<Icon as={ icon429 } width="200px" height="auto"/>
<Heading mt={ 8 } size="2xl" fontFamily="body">Too many requests</Heading>
<Text variant="secondary" mt={ 3 }>
You have exceeded the request rate for a given time period. Please reduce the number of requests and try again soon.
</Text>
{ appConfig.reCaptcha.siteKey && (
<ReCaptcha
className="recaptcha"
sitekey={ appConfig.reCaptcha.siteKey }
onChange={ handleReCaptchaChange }
/>
) }
</Box>
);
};

export default chakra(AppErrorTooManyRequests);

Unable to render rich display

Unable to render rich display

2 changes: 1 addition & 1 deletion ui/shared/Page/Page.tsx
Original file line number Diff line number Diff line change
@@ -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) {
6 changes: 4 additions & 2 deletions ui/shared/nft/NftMedia.tsx
Original file line number Diff line number Diff line change
@@ -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<MediaType | undefined>(!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 (

Unchanged files with check annotations Beta

// was not able to reproduce in tests issue when Icon is used as trigger for tooltip
// https://github.com/chakra-ui/chakra-ui/issues/7107
test.skip('with icon', async({ mount, page }) => {

Check warning on line 25 in theme/components/Tooltip/Tooltip.pw.tsx

GitHub Actions / Code quality

Unexpected use of the `.skip()` annotation
const component = await mount(
<TestApp>
<Box m={ 10 }>