mirror of
https://github.com/luxfi/explore.git
synced 2026-09-24 00:28:18 +00:00
Refactor token media-type API to be token-scoped and safer (#3190)
* refactor media-type next.js API resource to mitigate request forgery attack * fix typescript
This commit is contained in:
@@ -90,7 +90,7 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
|
||||
'/api/metrics': 'Regular page',
|
||||
'/api/monitoring/invalid-api-schema': 'Regular page',
|
||||
'/api/log': 'Regular page',
|
||||
'/api/media-type': 'Regular page',
|
||||
'/api/tokens/[hash]/instances/[id]/media-type': 'Regular page',
|
||||
'/api/proxy': 'Regular page',
|
||||
'/api/csrf': 'Regular page',
|
||||
'/api/healthz': 'Regular page',
|
||||
|
||||
@@ -93,7 +93,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
|
||||
'/api/metrics': DEFAULT_TEMPLATE,
|
||||
'/api/monitoring/invalid-api-schema': DEFAULT_TEMPLATE,
|
||||
'/api/log': DEFAULT_TEMPLATE,
|
||||
'/api/media-type': DEFAULT_TEMPLATE,
|
||||
'/api/tokens/[hash]/instances/[id]/media-type': DEFAULT_TEMPLATE,
|
||||
'/api/proxy': DEFAULT_TEMPLATE,
|
||||
'/api/csrf': DEFAULT_TEMPLATE,
|
||||
'/api/healthz': DEFAULT_TEMPLATE,
|
||||
|
||||
@@ -94,7 +94,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
|
||||
'/api/metrics': '%network_name% node API prometheus metrics',
|
||||
'/api/monitoring/invalid-api-schema': '%network_name% node API prometheus metrics',
|
||||
'/api/log': '%network_name% node API request log',
|
||||
'/api/media-type': '%network_name% node API media type',
|
||||
'/api/tokens/[hash]/instances/[id]/media-type': '%network_name% node API token instance media type',
|
||||
'/api/proxy': '%network_name% node API proxy',
|
||||
'/api/csrf': '%network_name% node API CSRF token',
|
||||
'/api/healthz': '%network_name% node API health check',
|
||||
|
||||
@@ -88,7 +88,7 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
|
||||
'/api/metrics': 'Node API: Prometheus metrics',
|
||||
'/api/monitoring/invalid-api-schema': 'Node API: Prometheus metrics',
|
||||
'/api/log': 'Node API: Request log',
|
||||
'/api/media-type': 'Node API: Media type',
|
||||
'/api/tokens/[hash]/instances/[id]/media-type': 'Node API: Token instance media type',
|
||||
'/api/proxy': 'Node API: Proxy',
|
||||
'/api/csrf': 'Node API: CSRF token',
|
||||
'/api/healthz': 'Node API: Health check',
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
import type { TokenInstance } from 'types/api/token';
|
||||
|
||||
import * as addressMock from '../address/address';
|
||||
import * as tokenInfoMock from './tokenInfo';
|
||||
|
||||
export const base: TokenInstance = {
|
||||
token: tokenInfoMock.tokenInfoERC721a,
|
||||
animation_url: null,
|
||||
external_app_url: 'https://duck.nft/get-your-duck-today',
|
||||
id: '32925298983216553915666621415831103694597106215670571463977478984525997408266',
|
||||
|
||||
Vendored
+1
-1
@@ -22,10 +22,10 @@ declare module "nextjs-routes" {
|
||||
| StaticRoute<"/api/csrf">
|
||||
| StaticRoute<"/api/healthz">
|
||||
| StaticRoute<"/api/log">
|
||||
| StaticRoute<"/api/media-type">
|
||||
| StaticRoute<"/api/metrics">
|
||||
| StaticRoute<"/api/monitoring/invalid-api-schema">
|
||||
| StaticRoute<"/api/proxy">
|
||||
| DynamicRoute<"/api/tokens/[hash]/instances/[id]/media-type", { "hash": string; "id": string }>
|
||||
| StaticRoute<"/api-docs">
|
||||
| DynamicRoute<"/apps/[id]", { "id": string }>
|
||||
| StaticRoute<"/apps">
|
||||
|
||||
@@ -4,7 +4,14 @@ import buildUrl from 'nextjs/utils/buildUrl';
|
||||
import fetchFactory from 'nextjs/utils/fetchProxy';
|
||||
import { httpLogger } from 'nextjs/utils/logger';
|
||||
|
||||
import isNeedProxy from 'lib/api/isNeedProxy';
|
||||
|
||||
export default async function csrfHandler(_req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!isNeedProxy()) {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
httpLogger(_req, res);
|
||||
|
||||
const url = buildUrl('general:csrf');
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import nodeFetch from 'node-fetch';
|
||||
|
||||
import { httpLogger } from 'nextjs/utils/logger';
|
||||
|
||||
import metrics from 'lib/monitoring/metrics';
|
||||
import getQueryParamString from 'lib/router/getQueryParamString';
|
||||
|
||||
export default async function mediaTypeHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
try {
|
||||
const url = getQueryParamString(req.query.url);
|
||||
|
||||
const end = metrics?.apiRequestDuration.startTimer();
|
||||
const response = await nodeFetch(url, { method: 'HEAD' });
|
||||
const duration = end?.({ route: '/media-type', code: response.status });
|
||||
|
||||
if (response.status !== 200) {
|
||||
httpLogger.logger.error({ message: 'API fetch', url, code: response.status, duration });
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
const mediaType = (() => {
|
||||
if (contentType?.startsWith('video')) {
|
||||
return 'video';
|
||||
}
|
||||
|
||||
if (contentType?.startsWith('image')) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
if (contentType?.startsWith('text/html')) {
|
||||
return 'html';
|
||||
}
|
||||
})();
|
||||
httpLogger.logger.info({ message: 'API fetch', url, code: response.status, duration });
|
||||
|
||||
res.status(200).json({ type: mediaType });
|
||||
} catch (error) {
|
||||
res.status(200).json({ type: undefined });
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,11 @@ const isEnabled = process.env.PROMETHEUS_METRICS_ENABLED === 'true';
|
||||
isEnabled && promClient.collectDefaultMetrics({ prefix: 'frontend_' });
|
||||
|
||||
export default async function metricsHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!isEnabled) {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const metrics = await promClient.register.metrics();
|
||||
res.setHeader('Content-type', promClient.register.contentType);
|
||||
res.send(metrics);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import nodeFetch from 'node-fetch';
|
||||
|
||||
import fetchApi from 'nextjs/utils/fetchApi';
|
||||
import { httpLogger } from 'nextjs/utils/logger';
|
||||
|
||||
import metrics from 'lib/monitoring/metrics';
|
||||
import getQueryParamString from 'lib/router/getQueryParamString';
|
||||
import { SECOND } from 'toolkit/utils/consts';
|
||||
|
||||
export default async function tokenInstanceMediaTypeHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => {
|
||||
controller.abort('Request to media asset timed out');
|
||||
}, 10 * SECOND);
|
||||
|
||||
try {
|
||||
const field = getQueryParamString(req.query.field) as 'animation_url' | 'image_url';
|
||||
if (![ 'animation_url', 'image_url' ].includes(field)) {
|
||||
throw new Error('Invalid field parameter');
|
||||
}
|
||||
|
||||
const apiData = await fetchApi({
|
||||
resource: 'general:token_instance',
|
||||
pathParams: { hash: getQueryParamString(req.query.hash), id: getQueryParamString(req.query.id) },
|
||||
timeout: SECOND,
|
||||
});
|
||||
if (!apiData) {
|
||||
throw new Error('Failed to fetch token instance');
|
||||
}
|
||||
|
||||
const mediaUrl = apiData[field];
|
||||
if (!mediaUrl) {
|
||||
throw new Error('No media URL found');
|
||||
}
|
||||
|
||||
const mediaRequestEndTime = metrics?.apiRequestDuration.startTimer();
|
||||
const mediaResponse = await nodeFetch(mediaUrl, { method: 'HEAD', signal: controller.signal });
|
||||
const mediaRequestDuration = mediaRequestEndTime?.({ route: '/media-type', code: mediaResponse.status });
|
||||
|
||||
if (mediaResponse.status === 200) {
|
||||
httpLogger.logger.info({ message: 'API fetch', url: mediaUrl, code: mediaResponse.status, duration: mediaRequestDuration });
|
||||
} else {
|
||||
httpLogger.logger.error({ message: 'API fetch', url: mediaUrl, code: mediaResponse.status, duration: mediaRequestDuration });
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
const contentType = mediaResponse.headers.get('content-type');
|
||||
const mediaType = (() => {
|
||||
if (contentType?.startsWith('video')) {
|
||||
return 'video';
|
||||
}
|
||||
|
||||
if (contentType?.startsWith('image')) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
if (contentType?.startsWith('text/html')) {
|
||||
return 'html';
|
||||
}
|
||||
})();
|
||||
|
||||
res.status(200).json({ type: mediaType });
|
||||
} catch (error) {
|
||||
res.status(200).json({ type: undefined });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -89,23 +89,23 @@ export const ADDRESS_TOKEN_BALANCE_ERC_20: AddressTokenBalance = {
|
||||
|
||||
export const ADDRESS_NFT_721: AddressNFT = {
|
||||
token_type: 'ERC-721',
|
||||
token: TOKEN_INFO_ERC_721,
|
||||
value: '1',
|
||||
...TOKEN_INSTANCE,
|
||||
token: TOKEN_INFO_ERC_721,
|
||||
};
|
||||
|
||||
export const ADDRESS_NFT_1155: AddressNFT = {
|
||||
token_type: 'ERC-1155',
|
||||
token: TOKEN_INFO_ERC_1155,
|
||||
value: '10',
|
||||
...TOKEN_INSTANCE,
|
||||
token: TOKEN_INFO_ERC_1155,
|
||||
};
|
||||
|
||||
export const ADDRESS_NFT_404: AddressNFT = {
|
||||
token_type: 'ERC-404',
|
||||
token: TOKEN_INFO_ERC_404,
|
||||
value: '10',
|
||||
...TOKEN_INSTANCE,
|
||||
token: TOKEN_INFO_ERC_404,
|
||||
};
|
||||
|
||||
export const ADDRESS_COLLECTION: AddressCollection = {
|
||||
|
||||
@@ -167,6 +167,7 @@ export const getTokenInstanceTransfersStub = (type?: TokenType, pagination: Toke
|
||||
};
|
||||
|
||||
export const TOKEN_INSTANCE: TokenInstance = {
|
||||
token: TOKEN_INFO_ERC_721,
|
||||
animation_url: null,
|
||||
external_app_url: 'https://vipsland.com/nft/collections/genesis/188882',
|
||||
id: '188882',
|
||||
|
||||
@@ -68,6 +68,7 @@ export interface TokenInstance {
|
||||
metadata: Record<string, unknown> | null;
|
||||
owner: AddressParam | null;
|
||||
thumbnails: ({ original: string } & Partial<Record<Exclude<ThumbnailSize, 'original'>, string>>) | null;
|
||||
token: TokenInfo;
|
||||
}
|
||||
|
||||
export interface TokenInstanceMetadataSocketMessage {
|
||||
|
||||
@@ -19,7 +19,8 @@ import NFTItemContainer from './NFTItemContainer';
|
||||
|
||||
type Props = AddressNFT & { isLoading: boolean; withTokenLink?: boolean; chain?: ClusterChainConfig };
|
||||
|
||||
const NFTItem = ({ token, value, isLoading, withTokenLink, chain, ...tokenInstance }: Props) => {
|
||||
const NFTItem = ({ value, isLoading, withTokenLink, chain, ...tokenInstance }: Props) => {
|
||||
const { token } = tokenInstance;
|
||||
const valueResult = token.decimals && value ? calculateUsdValue({ amount: value, decimals: token.decimals, accuracy: 2 }).valueStr : value;
|
||||
const tokenInstanceLink = tokenInstance.id ?
|
||||
route({ pathname: '/token/[hash]/instance/[id]', query: { hash: token.address_hash, id: tokenInstance.id } }, chain ? { chain } : undefined) :
|
||||
|
||||
@@ -3,16 +3,22 @@ import React from 'react';
|
||||
|
||||
import type { TokenInstance } from 'types/api/token';
|
||||
|
||||
import * as tokenInfoMock from 'mocks/tokens/tokenInfo';
|
||||
import { test, expect } from 'playwright/lib';
|
||||
|
||||
import NftMedia from './NftMedia';
|
||||
|
||||
const TOKEN_ID = '123';
|
||||
const TOKEN_HASH = tokenInfoMock.tokenInfoERC721a.address_hash;
|
||||
|
||||
test.describe('no url', () => {
|
||||
test.use({ viewport: { width: 250, height: 250 } });
|
||||
test('preview +@dark-mode', async({ render }) => {
|
||||
const data = {
|
||||
id: TOKEN_ID,
|
||||
image_url: null,
|
||||
animation_url: null,
|
||||
token: tokenInfoMock.tokenInfoERC721a,
|
||||
} as TokenInstance;
|
||||
const component = await render(<NftMedia data={ data }/>);
|
||||
await expect(component).toHaveScreenshot();
|
||||
@@ -21,8 +27,10 @@ test.describe('no url', () => {
|
||||
test('with fallback', async({ render, mockAssetResponse }) => {
|
||||
const IMAGE_URL = 'https://localhost:3000/my-image.jpg';
|
||||
const data = {
|
||||
id: TOKEN_ID,
|
||||
image_url: IMAGE_URL,
|
||||
animation_url: null,
|
||||
token: tokenInfoMock.tokenInfoERC721a,
|
||||
} as TokenInstance;
|
||||
|
||||
await mockAssetResponse(IMAGE_URL, './playwright/mocks/image_long.jpg');
|
||||
@@ -32,11 +40,13 @@ test.describe('no url', () => {
|
||||
|
||||
test('non-media url and fallback', async({ render, page, mockAssetResponse }) => {
|
||||
const ANIMATION_URL = 'https://localhost:3000/my-animation.m3u8';
|
||||
const ANIMATION_MEDIA_TYPE_API_URL = `/node-api/media-type?url=${ encodeURIComponent(ANIMATION_URL) }`;
|
||||
const ANIMATION_MEDIA_TYPE_API_URL = `/node-api/tokens/${ TOKEN_HASH }/instances/${ TOKEN_ID }/media-type?field=animation_url`;
|
||||
const IMAGE_URL = 'https://localhost:3000/my-image.jpg';
|
||||
const data = {
|
||||
id: TOKEN_ID,
|
||||
animation_url: ANIMATION_URL,
|
||||
image_url: IMAGE_URL,
|
||||
token: tokenInfoMock.tokenInfoERC721a,
|
||||
} as TokenInstance;
|
||||
|
||||
await page.route(ANIMATION_MEDIA_TYPE_API_URL, (route) => {
|
||||
@@ -61,8 +71,10 @@ test.describe('image', () => {
|
||||
|
||||
test('preview +@dark-mode', async({ render, page }) => {
|
||||
const data = {
|
||||
id: TOKEN_ID,
|
||||
animation_url: MEDIA_URL,
|
||||
image_url: null,
|
||||
token: tokenInfoMock.tokenInfoERC721a,
|
||||
} as TokenInstance;
|
||||
await render(
|
||||
<Box boxSize="250px">
|
||||
@@ -75,11 +87,13 @@ test.describe('image', () => {
|
||||
test('preview with thumbnails', async({ render, page, mockAssetResponse }) => {
|
||||
const THUMBNAIL_URL = 'https://localhost:3000/my-image-250.jpg';
|
||||
const data = {
|
||||
id: TOKEN_ID,
|
||||
animation_url: MEDIA_URL,
|
||||
image_url: null,
|
||||
thumbnails: {
|
||||
'500x500': THUMBNAIL_URL,
|
||||
},
|
||||
token: tokenInfoMock.tokenInfoERC721a,
|
||||
} as TokenInstance;
|
||||
await mockAssetResponse(THUMBNAIL_URL, './playwright/mocks/image_md.jpg');
|
||||
await render(
|
||||
@@ -92,8 +106,10 @@ test.describe('image', () => {
|
||||
|
||||
test('preview hover', async({ render, page }) => {
|
||||
const data = {
|
||||
id: TOKEN_ID,
|
||||
animation_url: MEDIA_URL,
|
||||
image_url: null,
|
||||
token: tokenInfoMock.tokenInfoERC721a,
|
||||
} as TokenInstance;
|
||||
const component = await render(<NftMedia data={ data } w="250px" size="md"/>);
|
||||
await component.getByRole('img', { name: 'Token instance image' }).hover();
|
||||
@@ -102,8 +118,10 @@ test.describe('image', () => {
|
||||
|
||||
test('fullscreen +@dark-mode +@mobile', async({ render, page }) => {
|
||||
const data = {
|
||||
id: TOKEN_ID,
|
||||
animation_url: MEDIA_URL,
|
||||
image_url: null,
|
||||
token: tokenInfoMock.tokenInfoERC721a,
|
||||
} as TokenInstance;
|
||||
const component = await render(<NftMedia data={ data } withFullscreen w="250px"/>);
|
||||
await component.getByRole('img', { name: 'Token instance image' }).click();
|
||||
@@ -115,7 +133,7 @@ test.describe('page', () => {
|
||||
test.use({ viewport: { width: 250, height: 250 } });
|
||||
|
||||
const MEDIA_URL = 'https://localhost:3000/page.html';
|
||||
const MEDIA_TYPE_API_URL = `/node-api/media-type?url=${ encodeURIComponent(MEDIA_URL) }`;
|
||||
const MEDIA_TYPE_API_URL = `/node-api/tokens/${ TOKEN_HASH }/instances/${ TOKEN_ID }/media-type?field=animation_url`;
|
||||
|
||||
test.beforeEach(async({ page, mockAssetResponse }) => {
|
||||
await mockAssetResponse(MEDIA_URL, './playwright/mocks/page.html');
|
||||
@@ -127,8 +145,10 @@ test.describe('page', () => {
|
||||
|
||||
test('preview +@dark-mode', async({ render }) => {
|
||||
const data = {
|
||||
id: TOKEN_ID,
|
||||
animation_url: MEDIA_URL,
|
||||
image_url: null,
|
||||
token: tokenInfoMock.tokenInfoERC721a,
|
||||
} as TokenInstance;
|
||||
|
||||
const component = await render(<NftMedia data={ data }/>);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import type { TokenInstance } from 'types/api/token';
|
||||
|
||||
import type { StaticRoute } from 'nextjs-routes';
|
||||
import type { DynamicRoute } from 'nextjs-routes';
|
||||
import { route } from 'nextjs-routes';
|
||||
|
||||
import config from 'configs/app';
|
||||
@@ -81,7 +81,15 @@ async function getMediaType(data: TokenInstance, field: Params['field']): Promis
|
||||
}
|
||||
|
||||
try {
|
||||
const mediaTypeResourceUrl = route({ pathname: '/node-api/media-type' as StaticRoute<'/api/media-type'>['pathname'], query: { url } });
|
||||
const mediaTypeResourceUrl = route({
|
||||
// eslint-disable-next-line max-len
|
||||
pathname: '/node-api/tokens/[hash]/instances/[id]/media-type' as DynamicRoute<'/api/tokens/[hash]/instances/[id]/media-type', { hash: string; id: string }>['pathname'],
|
||||
query: {
|
||||
hash: data.token.address_hash,
|
||||
id: data.id,
|
||||
field,
|
||||
},
|
||||
});
|
||||
const response = await fetch(mediaTypeResourceUrl);
|
||||
const payload = await response.json() as { type: MediaType | undefined };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user