Skip to content

Commit ad529a6

Browse files
rogaldhngundotra
andauthored
feat: implement proxy service for metadata and images (#451)
This PR implements proxy service for metadata and images. Here is needed configuration: - `NEXT_PUBLIC_METADATA_ENABLED` - flag to turn service on/off; "true" to enable - `NEXT_PUBLIC_METADATA_TIMEOUT` - abort fetching metadata for too long; default: 10_000ms - `NEXT_PUBLIC_METADATA_MAX_CONTENT_SIZE` - maximum for content size; default: 100_000 bytes - `NEXT_PUBLIC_METADATA_USER_AGENT` - will be used as user-agent header to represent Explorer's request for other services; default: Solana Explorer --------- Co-authored-by: Noah Gundotra <noah@gundotra.org>
1 parent 47fef99 commit ad529a6

File tree

18 files changed

+781
-5
lines changed

18 files changed

+781
-5
lines changed

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,8 @@
22
NEXT_PUBLIC_MAINNET_RPC_URL=
33
NEXT_PUBLIC_DEVNET_RPC_URL=
44
NEXT_PUBLIC_TESTNET_RPC_URL=
5+
# Configuration for "metadata" service. set "ENABLED" to true to use it
6+
NEXT_PUBLIC_METADATA_ENABLED=false
7+
NEXT_PUBLIC_METADATA_TIMEOUT=
8+
NEXT_PUBLIC_METADATA_MAX_CONTENT_SIZE=
9+
NEXT_PUBLIC_METADATA_USER_AGENT="Solana Explorer"
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/**
2+
* @jest-environment node
3+
*/
4+
import _dns from 'dns';
5+
import fetch, { Headers } from 'node-fetch';
6+
7+
import { GET } from '../route';
8+
9+
const dns = _dns.promises;
10+
11+
function setEnvironment(key: string, value: string) {
12+
Object.assign(process.env, { ...process.env, [key]: value });
13+
}
14+
15+
jest.mock('node-fetch', () => {
16+
const originalFetch = jest.requireActual('node-fetch')
17+
const mockFn = jest.fn();
18+
19+
Object.assign(mockFn, originalFetch);
20+
21+
return mockFn
22+
});
23+
24+
jest.mock('dns', () => {
25+
const originalDns = jest.requireActual('dns');
26+
const lookupFn = jest.fn();
27+
return {
28+
...originalDns,
29+
promises: {
30+
...originalDns.promises,
31+
lookup: lookupFn,
32+
}
33+
};
34+
});
35+
36+
async function mockFileResponseOnce(data: any, headers: Headers){
37+
// @ts-expect-error unavailable mock method for fetch
38+
fetch.mockResolvedValueOnce({ headers, json: async () => data });
39+
}
40+
41+
const ORIGIN = 'http://explorer.solana.com';
42+
43+
function requestFactory(uri?: string) {
44+
const params = new URLSearchParams({ uri: uri ?? '' });
45+
const request = new Request(`${ORIGIN}/api/metadata/devnet?${params.toString()}`);
46+
const nextParams = { params: { network: 'devnet' } };
47+
48+
return { nextParams, request };
49+
}
50+
51+
describe('metadata/[network] endpoint', () => {
52+
const validUrl = encodeURIComponent('http://external.resource/file.json');
53+
const unsupportedUri = encodeURIComponent('ftp://unsupported.resource/file.json');
54+
55+
afterEach(() => {
56+
jest.clearAllMocks();
57+
})
58+
59+
it('should return status when disabled', async () => {
60+
setEnvironment('NEXT_PUBLIC_METADATA_ENABLED', 'false');
61+
62+
const { request, nextParams } = requestFactory();
63+
const response = await GET(request, nextParams);
64+
expect(response.status).toBe(404);
65+
});
66+
67+
it('should return 400 for URIs with unsupported protocols', async () => {
68+
setEnvironment('NEXT_PUBLIC_METADATA_ENABLED', 'true');
69+
70+
const request = requestFactory(unsupportedUri);
71+
const response = await GET(request.request, request.nextParams);
72+
expect(response.status).toBe(400);
73+
});
74+
75+
it('should return proper status upon processig data', async () => {
76+
setEnvironment('NEXT_PUBLIC_METADATA_ENABLED', 'true')
77+
78+
const { request, nextParams } = requestFactory();
79+
const response = await GET(request, nextParams);
80+
expect(response.status).toBe(400);
81+
82+
// fail on encoded incorrectly input
83+
const request2 = requestFactory('https://example.com/%E0%A4%A');
84+
expect((await GET(request2.request, request2.nextParams)).status).toBe(400);
85+
86+
// fail due to unexpected error
87+
const request3 = requestFactory(validUrl);
88+
const result = await GET(request3.request, request3.nextParams);
89+
expect(result.status).toBe(403);
90+
});
91+
92+
it('should handle valid response successfully', async () => {
93+
await mockFileResponseOnce({ attributes: [], name: "NFT" }, new Headers({
94+
'Cache-Control': 'no-cache',
95+
'Content-Length': '140',
96+
'Content-Type': 'application/json',
97+
'Etag': 'random-etag',
98+
}));
99+
// @ts-expect-error lookup does not have mocked fn
100+
dns.lookup.mockResolvedValueOnce([{ address: '8.8.8.8' }]);
101+
102+
const request = requestFactory(validUrl);
103+
expect((await GET(request.request, request.nextParams)).status).toBe(200);
104+
})
105+
});
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/**
2+
* @jest-environment node
3+
*/
4+
import fetch, { Headers } from 'node-fetch';
5+
6+
import { fetchResource } from '../feature';
7+
8+
jest.mock('node-fetch', () => {
9+
const originalFetch = jest.requireActual('node-fetch')
10+
const mockFn = jest.fn();
11+
12+
Object.assign(mockFn, originalFetch);
13+
14+
return mockFn
15+
});
16+
17+
/**
18+
* mock valid response
19+
*/
20+
function mockFetchOnce(data: any = {}, headers: Headers = new Headers()) {
21+
// @ts-expect-error fetch does not have mocked fn
22+
fetch.mockResolvedValueOnce({
23+
headers,
24+
json: async () => data
25+
});
26+
}
27+
28+
/**
29+
* mock error during process
30+
*/
31+
function mockRejectOnce<T extends Error>(error: T) {
32+
// @ts-expect-error fetch does not have mocked fn
33+
fetch.mockRejectedValueOnce(error);
34+
}
35+
36+
describe('fetchResource', () => {
37+
const uri = 'http://hello.world/data.json' ;
38+
const headers = new Headers({ 'Content-Type': 'application/json' });
39+
40+
afterEach(() => {
41+
jest.clearAllMocks();
42+
})
43+
44+
it('should be called with proper arguments', async () => {
45+
mockFetchOnce({}, new Headers({ 'Content-Type': 'application/json, charset=utf-8' }));
46+
47+
const resource = await fetchResource(uri, headers, 100, 100);
48+
49+
expect(fetch).toHaveBeenCalledWith(uri, expect.anything());
50+
expect(resource.data).toEqual({});
51+
})
52+
53+
it('should throw exception for unsupported media', async () => {
54+
mockFetchOnce();
55+
56+
expect(() => {
57+
return fetchResource(uri, headers, 100, 100);
58+
}).rejects.toThrowError('Unsupported Media Type');
59+
})
60+
61+
it('should throw exception upon exceeded size', async () => {
62+
mockRejectOnce(new Error('FetchError: content size at https://path/to/resour.ce over limit: 100'));
63+
64+
expect(() => {
65+
return fetchResource(uri, headers, 100, 100);
66+
}).rejects.toThrowError('Max Content Size Exceeded');
67+
})
68+
69+
it('should handle AbortSignal', async () => {
70+
class TimeoutError extends Error {
71+
constructor() {
72+
super()
73+
this.name = 'TimeoutError'
74+
}
75+
}
76+
mockRejectOnce(new TimeoutError());
77+
78+
expect(() => {
79+
return fetchResource(uri, headers, 100, 100);
80+
}).rejects.toThrowError('Gateway Timeout')
81+
})
82+
83+
it('should handle size overflow', async () => {
84+
mockRejectOnce(new Error('file is over limit: 100'));
85+
86+
expect(() => {
87+
return fetchResource(uri, headers, 100, 100);
88+
}).rejects.toThrowError('Max Content Size Exceeded')
89+
})
90+
91+
it('should handle unexpected result', async () => {
92+
// @ts-expect-error fetch does not have mocked fn
93+
fetch.mockRejectedValueOnce({ data: "unexpected exception" });
94+
95+
const fn = () => {
96+
return fetchResource(uri, headers, 100, 100);
97+
}
98+
99+
try {
100+
await fn();
101+
} catch(e: any) {
102+
expect(e.message).toEqual('General Error')
103+
expect(e.status).toEqual(500)
104+
}
105+
})
106+
107+
it('should handle malformed JSON response gracefully', async () => {
108+
// Mock fetch to return a response with invalid JSON
109+
// @ts-expect-error fetch does not have mocked fn
110+
fetch.mockResolvedValueOnce({
111+
headers: new Headers({ 'Content-Type': 'application/json' }),
112+
// Simulate malformed JSON by rejecting during json parsing
113+
json: async () => { throw new SyntaxError('Unexpected token < in JSON at position 0') }
114+
});
115+
116+
await expect(fetchResource(uri, headers, 100, 100)).rejects.toThrowError('Unsupported Media Type');
117+
});
118+
})
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* @jest-environment node
3+
*/
4+
import _dns from 'dns';
5+
6+
import { checkURLForPrivateIP } from '../feature/ip';
7+
8+
const dns = _dns.promises;
9+
10+
jest.mock('dns', () => {
11+
const originalDns = jest.requireActual('dns');
12+
const lookupFn = jest.fn();
13+
return {
14+
...originalDns,
15+
promises: {
16+
...originalDns.promises,
17+
lookup: lookupFn,
18+
}
19+
};
20+
});
21+
22+
/**
23+
* mock valid response
24+
*/
25+
function mockLookupOnce(addresses: { address: string }[]) {
26+
// @ts-expect-error lookup does not have mocked fn
27+
dns.lookup.mockResolvedValueOnce(addresses);
28+
}
29+
30+
describe('ip::checkURLForPrivateIP', () => {
31+
afterEach(() => {
32+
jest.clearAllMocks();
33+
});
34+
35+
// do not throw exceptions forinvalid input to not break the execution flow
36+
test('should handle invalid URL gracefully', async () => {
37+
await expect(checkURLForPrivateIP('not-a-valid-url')).resolves.toBe(true);
38+
});
39+
40+
test('should block unsupported protocols', async () => {
41+
await expect(checkURLForPrivateIP('ftp://example.com')).resolves.toBe(true);
42+
});
43+
44+
test('should allow valid public URL', async () => {
45+
mockLookupOnce([{ address: '8.8.8.8' }]);
46+
expect(await checkURLForPrivateIP('http://google.com')).toBe(false);
47+
});
48+
49+
test('should allow valid public IPv6', async () => {
50+
mockLookupOnce([{ address: '2606:4700:4700::1111' }]);
51+
await expect(checkURLForPrivateIP('https://[2606:4700:4700::1111]')).resolves.toBe(false);
52+
});
53+
54+
test('should block private IPv4', async () => {
55+
mockLookupOnce([{ address: '192.168.1.1' }]);
56+
await expect(checkURLForPrivateIP('http://192.168.1.1')).resolves.toBe(true);
57+
});
58+
59+
test('should block localhost', async () => {
60+
mockLookupOnce([{ address: '127.0.0.1' }]);
61+
await expect(checkURLForPrivateIP('http://localhost')).resolves.toBe(true);
62+
});
63+
64+
test('should block decimal-encoded private IP', async () => {
65+
mockLookupOnce([{ address: '192.168.1.1' }]);
66+
await expect(checkURLForPrivateIP('http://3232235777')).resolves.toBe(true);
67+
});
68+
69+
test('should block hex-encoded private IP', async () => {
70+
mockLookupOnce([{ address: '192.168.1.1' }]);
71+
await expect(checkURLForPrivateIP('http://0xC0A80101')).resolves.toBe(true);
72+
});
73+
74+
test('should block cloud metadata IP', async () => {
75+
mockLookupOnce([{ address: '169.254.169.254' }]);
76+
await expect(checkURLForPrivateIP('http://169.254.169.254')).resolves.toBe(true);
77+
});
78+
79+
test('should handle DNS resolution failure gracefully', async () => {
80+
// @ts-expect-error fetch does not have mocked fn
81+
dns.lookup.mockRejectedValueOnce(new Error('DNS resolution failed'));
82+
await expect(checkURLForPrivateIP('http://unknown.domain')).resolves.toBe(true);
83+
});
84+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
export class StatusError extends Error {
2+
status: number;
3+
constructor(message: string, options: ErrorOptions & { cause: number }) {
4+
super(message);
5+
this.status = options.cause;
6+
}
7+
}
8+
9+
export const invalidRequestError = new StatusError('Invalid Request', { cause: 400 });
10+
11+
export const accessDeniedError = new StatusError('Access Denied', { cause: 403 });
12+
13+
export const resourceNotFoundError = new StatusError('Resource Not Found', { cause: 404 });
14+
15+
export const maxSizeError = new StatusError('Max Content Size Exceeded', { cause: 413 });
16+
17+
export const unsupportedMediaError = new StatusError('Unsupported Media Type', { cause: 415 });
18+
19+
export const generalError = new StatusError('General Error', { cause: 500 });
20+
21+
export const gatewayTimeoutError = new StatusError('Gateway Timeout', { cause: 504 });
22+
23+
export const errors = {
24+
400: invalidRequestError,
25+
403: accessDeniedError,
26+
404: resourceNotFoundError,
27+
413: maxSizeError,
28+
415: unsupportedMediaError,
29+
500: generalError,
30+
504: gatewayTimeoutError,
31+
}
32+
33+
export function matchAbortError(error: unknown): error is Error {
34+
return Boolean(error instanceof Error && error.name === 'AbortError');
35+
}
36+
37+
export function matchMaxSizeError(error: unknown): error is Error {
38+
return Boolean(error instanceof Error && error.message.match(/over limit:/));
39+
}
40+
41+
export function matchTimeoutError(error: unknown): error is Error {
42+
return Boolean(error instanceof Error && error.name === 'TimeoutError');
43+
}

0 commit comments

Comments
 (0)