Skip to content

Commit

Permalink
feat: implement proxy service for metadata and images (#451)
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
rogaldh and ngundotra authored Feb 4, 2025
1 parent 47fef99 commit ad529a6
Show file tree
Hide file tree
Showing 18 changed files with 781 additions and 5 deletions.
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,8 @@
NEXT_PUBLIC_MAINNET_RPC_URL=
NEXT_PUBLIC_DEVNET_RPC_URL=
NEXT_PUBLIC_TESTNET_RPC_URL=
# Configuration for "metadata" service. set "ENABLED" to true to use it
NEXT_PUBLIC_METADATA_ENABLED=false
NEXT_PUBLIC_METADATA_TIMEOUT=
NEXT_PUBLIC_METADATA_MAX_CONTENT_SIZE=
NEXT_PUBLIC_METADATA_USER_AGENT="Solana Explorer"
105 changes: 105 additions & 0 deletions app/api/metadata/proxy/__tests__/endpoint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* @jest-environment node
*/
import _dns from 'dns';
import fetch, { Headers } from 'node-fetch';

import { GET } from '../route';

const dns = _dns.promises;

function setEnvironment(key: string, value: string) {
Object.assign(process.env, { ...process.env, [key]: value });
}

jest.mock('node-fetch', () => {
const originalFetch = jest.requireActual('node-fetch')
const mockFn = jest.fn();

Object.assign(mockFn, originalFetch);

return mockFn
});

jest.mock('dns', () => {
const originalDns = jest.requireActual('dns');
const lookupFn = jest.fn();
return {
...originalDns,
promises: {
...originalDns.promises,
lookup: lookupFn,
}
};
});

async function mockFileResponseOnce(data: any, headers: Headers){
// @ts-expect-error unavailable mock method for fetch
fetch.mockResolvedValueOnce({ headers, json: async () => data });
}

const ORIGIN = 'http://explorer.solana.com';

function requestFactory(uri?: string) {
const params = new URLSearchParams({ uri: uri ?? '' });
const request = new Request(`${ORIGIN}/api/metadata/devnet?${params.toString()}`);
const nextParams = { params: { network: 'devnet' } };

return { nextParams, request };
}

describe('metadata/[network] endpoint', () => {
const validUrl = encodeURIComponent('http://external.resource/file.json');
const unsupportedUri = encodeURIComponent('ftp://unsupported.resource/file.json');

afterEach(() => {
jest.clearAllMocks();
})

it('should return status when disabled', async () => {
setEnvironment('NEXT_PUBLIC_METADATA_ENABLED', 'false');

const { request, nextParams } = requestFactory();
const response = await GET(request, nextParams);
expect(response.status).toBe(404);
});

it('should return 400 for URIs with unsupported protocols', async () => {
setEnvironment('NEXT_PUBLIC_METADATA_ENABLED', 'true');

const request = requestFactory(unsupportedUri);
const response = await GET(request.request, request.nextParams);
expect(response.status).toBe(400);
});

it('should return proper status upon processig data', async () => {
setEnvironment('NEXT_PUBLIC_METADATA_ENABLED', 'true')

const { request, nextParams } = requestFactory();
const response = await GET(request, nextParams);
expect(response.status).toBe(400);

// fail on encoded incorrectly input
const request2 = requestFactory('https://example.com/%E0%A4%A');
expect((await GET(request2.request, request2.nextParams)).status).toBe(400);

// fail due to unexpected error
const request3 = requestFactory(validUrl);
const result = await GET(request3.request, request3.nextParams);
expect(result.status).toBe(403);
});

it('should handle valid response successfully', async () => {
await mockFileResponseOnce({ attributes: [], name: "NFT" }, new Headers({
'Cache-Control': 'no-cache',
'Content-Length': '140',
'Content-Type': 'application/json',
'Etag': 'random-etag',
}));
// @ts-expect-error lookup does not have mocked fn
dns.lookup.mockResolvedValueOnce([{ address: '8.8.8.8' }]);

const request = requestFactory(validUrl);
expect((await GET(request.request, request.nextParams)).status).toBe(200);
})
});
118 changes: 118 additions & 0 deletions app/api/metadata/proxy/__tests__/fetch-resource.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* @jest-environment node
*/
import fetch, { Headers } from 'node-fetch';

import { fetchResource } from '../feature';

jest.mock('node-fetch', () => {
const originalFetch = jest.requireActual('node-fetch')
const mockFn = jest.fn();

Object.assign(mockFn, originalFetch);

return mockFn
});

/**
* mock valid response
*/
function mockFetchOnce(data: any = {}, headers: Headers = new Headers()) {
// @ts-expect-error fetch does not have mocked fn
fetch.mockResolvedValueOnce({
headers,
json: async () => data
});
}

/**
* mock error during process
*/
function mockRejectOnce<T extends Error>(error: T) {
// @ts-expect-error fetch does not have mocked fn
fetch.mockRejectedValueOnce(error);
}

describe('fetchResource', () => {
const uri = 'http://hello.world/data.json' ;
const headers = new Headers({ 'Content-Type': 'application/json' });

afterEach(() => {
jest.clearAllMocks();
})

it('should be called with proper arguments', async () => {
mockFetchOnce({}, new Headers({ 'Content-Type': 'application/json, charset=utf-8' }));

const resource = await fetchResource(uri, headers, 100, 100);

expect(fetch).toHaveBeenCalledWith(uri, expect.anything());
expect(resource.data).toEqual({});
})

it('should throw exception for unsupported media', async () => {
mockFetchOnce();

expect(() => {
return fetchResource(uri, headers, 100, 100);
}).rejects.toThrowError('Unsupported Media Type');
})

it('should throw exception upon exceeded size', async () => {
mockRejectOnce(new Error('FetchError: content size at https://path/to/resour.ce over limit: 100'));

expect(() => {
return fetchResource(uri, headers, 100, 100);
}).rejects.toThrowError('Max Content Size Exceeded');
})

it('should handle AbortSignal', async () => {
class TimeoutError extends Error {
constructor() {
super()
this.name = 'TimeoutError'
}
}
mockRejectOnce(new TimeoutError());

expect(() => {
return fetchResource(uri, headers, 100, 100);
}).rejects.toThrowError('Gateway Timeout')
})

it('should handle size overflow', async () => {
mockRejectOnce(new Error('file is over limit: 100'));

expect(() => {
return fetchResource(uri, headers, 100, 100);
}).rejects.toThrowError('Max Content Size Exceeded')
})

it('should handle unexpected result', async () => {
// @ts-expect-error fetch does not have mocked fn
fetch.mockRejectedValueOnce({ data: "unexpected exception" });

const fn = () => {
return fetchResource(uri, headers, 100, 100);
}

try {
await fn();
} catch(e: any) {
expect(e.message).toEqual('General Error')
expect(e.status).toEqual(500)
}
})

it('should handle malformed JSON response gracefully', async () => {
// Mock fetch to return a response with invalid JSON
// @ts-expect-error fetch does not have mocked fn
fetch.mockResolvedValueOnce({
headers: new Headers({ 'Content-Type': 'application/json' }),
// Simulate malformed JSON by rejecting during json parsing
json: async () => { throw new SyntaxError('Unexpected token < in JSON at position 0') }
});

await expect(fetchResource(uri, headers, 100, 100)).rejects.toThrowError('Unsupported Media Type');
});
})
84 changes: 84 additions & 0 deletions app/api/metadata/proxy/__tests__/ip.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* @jest-environment node
*/
import _dns from 'dns';

import { checkURLForPrivateIP } from '../feature/ip';

const dns = _dns.promises;

jest.mock('dns', () => {
const originalDns = jest.requireActual('dns');
const lookupFn = jest.fn();
return {
...originalDns,
promises: {
...originalDns.promises,
lookup: lookupFn,
}
};
});

/**
* mock valid response
*/
function mockLookupOnce(addresses: { address: string }[]) {
// @ts-expect-error lookup does not have mocked fn
dns.lookup.mockResolvedValueOnce(addresses);
}

describe('ip::checkURLForPrivateIP', () => {
afterEach(() => {
jest.clearAllMocks();
});

// do not throw exceptions forinvalid input to not break the execution flow
test('should handle invalid URL gracefully', async () => {
await expect(checkURLForPrivateIP('not-a-valid-url')).resolves.toBe(true);
});

test('should block unsupported protocols', async () => {
await expect(checkURLForPrivateIP('ftp://example.com')).resolves.toBe(true);
});

test('should allow valid public URL', async () => {
mockLookupOnce([{ address: '8.8.8.8' }]);
expect(await checkURLForPrivateIP('http://google.com')).toBe(false);
});

test('should allow valid public IPv6', async () => {
mockLookupOnce([{ address: '2606:4700:4700::1111' }]);
await expect(checkURLForPrivateIP('https://[2606:4700:4700::1111]')).resolves.toBe(false);
});

test('should block private IPv4', async () => {
mockLookupOnce([{ address: '192.168.1.1' }]);
await expect(checkURLForPrivateIP('http://192.168.1.1')).resolves.toBe(true);
});

test('should block localhost', async () => {
mockLookupOnce([{ address: '127.0.0.1' }]);
await expect(checkURLForPrivateIP('http://localhost')).resolves.toBe(true);
});

test('should block decimal-encoded private IP', async () => {
mockLookupOnce([{ address: '192.168.1.1' }]);
await expect(checkURLForPrivateIP('http://3232235777')).resolves.toBe(true);
});

test('should block hex-encoded private IP', async () => {
mockLookupOnce([{ address: '192.168.1.1' }]);
await expect(checkURLForPrivateIP('http://0xC0A80101')).resolves.toBe(true);
});

test('should block cloud metadata IP', async () => {
mockLookupOnce([{ address: '169.254.169.254' }]);
await expect(checkURLForPrivateIP('http://169.254.169.254')).resolves.toBe(true);
});

test('should handle DNS resolution failure gracefully', async () => {
// @ts-expect-error fetch does not have mocked fn
dns.lookup.mockRejectedValueOnce(new Error('DNS resolution failed'));
await expect(checkURLForPrivateIP('http://unknown.domain')).resolves.toBe(true);
});
});
43 changes: 43 additions & 0 deletions app/api/metadata/proxy/feature/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
export class StatusError extends Error {
status: number;
constructor(message: string, options: ErrorOptions & { cause: number }) {
super(message);
this.status = options.cause;
}
}

export const invalidRequestError = new StatusError('Invalid Request', { cause: 400 });

export const accessDeniedError = new StatusError('Access Denied', { cause: 403 });

export const resourceNotFoundError = new StatusError('Resource Not Found', { cause: 404 });

export const maxSizeError = new StatusError('Max Content Size Exceeded', { cause: 413 });

export const unsupportedMediaError = new StatusError('Unsupported Media Type', { cause: 415 });

export const generalError = new StatusError('General Error', { cause: 500 });

export const gatewayTimeoutError = new StatusError('Gateway Timeout', { cause: 504 });

export const errors = {
400: invalidRequestError,
403: accessDeniedError,
404: resourceNotFoundError,
413: maxSizeError,
415: unsupportedMediaError,
500: generalError,
504: gatewayTimeoutError,
}

export function matchAbortError(error: unknown): error is Error {
return Boolean(error instanceof Error && error.name === 'AbortError');
}

export function matchMaxSizeError(error: unknown): error is Error {
return Boolean(error instanceof Error && error.message.match(/over limit:/));
}

export function matchTimeoutError(error: unknown): error is Error {
return Boolean(error instanceof Error && error.name === 'TimeoutError');
}
Loading

0 comments on commit ad529a6

Please sign in to comment.