Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
43 changes: 31 additions & 12 deletions app/src/pages/ipfs/[...path].test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ afterEach(() => {

describe('upstreamForPath', () => {
it('accepts a CID with an optional subpath', () => {
expect(upstreamForPath(CID_V0)).toBe(`${'https://ipfs.io/ipfs'}/${CID_V0}`);
expect(upstreamForPath(CID_V0)).toBe(`${'https://bridge.mintlayer.org/ipfs'}/${CID_V0}`);
expect(upstreamForPath(`${CID_V1}/metadata.json`)).toBe(
`https://ipfs.io/ipfs/${CID_V1}/metadata.json`,
`https://bridge.mintlayer.org/ipfs/${CID_V1}/metadata.json`,
);
});

Expand All @@ -36,12 +36,19 @@ describe('upstreamForPath', () => {
]);
});

it('builds one candidate per configured gateway', () => {
expect(gatewaysForPath(CID_V0)[0]).toBe(`https://ipfs.io/ipfs/${CID_V0}`);
expect(gatewaysForPath(CID_V0).length).toBeGreaterThanOrEqual(3);
it('defaults to the production proxy only', () => {
expect(gatewaysForPath(CID_V0)).toEqual([`https://bridge.mintlayer.org/ipfs/${CID_V0}`]);
});
});

/** Two-gateway env for the failover/redirect behaviour tests. */
function stubTwoGateways() {
vi.stubEnv(
'IPFS_GATEWAY_URLS',
'https://bridge.mintlayer.org/ipfs, https://ipfs.io/ipfs',
);
}

describe('GET /ipfs/[...path]', () => {
it('404s without a valid CID', async () => {
const res = await GET(ctx('not-a-cid'));
Expand All @@ -65,7 +72,7 @@ describe('GET /ipfs/[...path]', () => {
expect(res.headers.get('content-security-policy')).toContain("sandbox");
expect(await res.text()).toBe('{"name":"mlPEPE"}');
expect(vi.mocked(fetch as unknown as ReturnType<typeof vi.fn>).mock.calls[0]?.[0]).toBe(
`https://ipfs.io/ipfs/${CID_V0}`,
`https://bridge.mintlayer.org/ipfs/${CID_V0}`,
);
// Hop 1 is manual — same-site redirects (CIDv1 → subdomain
// gateway) are followed, foreign ones are not.
Expand All @@ -76,6 +83,7 @@ describe('GET /ipfs/[...path]', () => {
});

it('fails over to the next gateway when the first one rejects', async () => {
stubTwoGateways();
const calls: string[] = [];
vi.stubGlobal(
'fetch',
Expand All @@ -92,21 +100,32 @@ describe('GET /ipfs/[...path]', () => {
);
const res = await GET(ctx(CID_V0));
expect(res.status).toBe(200);
expect(calls[0]).toBe(`https://ipfs.io/ipfs/${CID_V0}`);
expect(calls[1]).toBe(`https://gateway.pinata.cloud/ipfs/${CID_V0}`);
expect(res.headers.get('x-ipfs-gateway')).toBe('gateway.pinata.cloud');
expect(calls[0]).toBe(`https://bridge.mintlayer.org/ipfs/${CID_V0}`);
expect(calls[1]).toBe(`https://ipfs.io/ipfs/${CID_V0}`);
expect(res.headers.get('x-ipfs-gateway')).toBe('ipfs.io');
});

it('gives up honestly when the only configured proxy fails', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('down', { status: 503 })) as unknown as typeof fetch,
);
const res = await GET(ctx(CID_V0));
expect(res.status).toBe(502);
expect(res.headers.get('cache-control')).toBe('no-store');
});

it('follows same-site redirects but never foreign ones', async () => {
const subdomain = `https://${CID_V0.toLowerCase()}.ipfs.gateway.pinata.cloud/ipfs/${CID_V0}`;
stubTwoGateways();
const subdomain = `https://${CID_V0.toLowerCase()}.ipfs.ipfs.io/ipfs/${CID_V0}`;
const calls: string[] = [];
vi.stubGlobal(
'fetch',
vi.fn(async (url: string | URL, init?: RequestInit) => {
calls.push(String(url));
if (calls.length === 1) {
// First gateway bounces to a FOREIGN host — must not be followed.
expect(String(url)).toBe(`https://ipfs.io/ipfs/${CID_V0}`);
expect(String(url)).toBe(`https://bridge.mintlayer.org/ipfs/${CID_V0}`);
expect(init?.redirect).toBe('manual');
return new Response(null, {
status: 302,
Expand All @@ -115,7 +134,7 @@ describe('GET /ipfs/[...path]', () => {
}
if (calls.length === 2) {
// Second gateway bounces to its OWN subdomain — followed.
expect(String(url)).toBe(`https://gateway.pinata.cloud/ipfs/${CID_V0}`);
expect(String(url)).toBe(`https://ipfs.io/ipfs/${CID_V0}`);
expect(init?.redirect).toBe('manual');
return new Response(null, {
status: 302,
Expand Down
15 changes: 6 additions & 9 deletions app/src/pages/ipfs/[...path].ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,12 @@ const MAX_BYTES = 2 * 1024 * 1024;
const GATEWAY_TIMEOUT_MS = 12_000;

const DEFAULT_GATEWAYS = [
'https://ipfs.io/ipfs',
'https://gateway.pinata.cloud/ipfs',
'https://dweb.link/ipfs',
'https://4everland.io/ipfs',
// Last resort: the project's own production proxy (R2-backed, same
// content-addressed paths). Public gateways now 429 "service worker
// only" from many residential networks, so dev environments would
// otherwise never see token icons. Same org — no new third party
// sees anything, and cached CIDs are immutable.
// The project's own production proxy only (R2-backed, same
// content-addressed paths, measured 200 in <1s). Public IPFS gateways
// are unreliable from residential networks (429 "service worker only",
// multi-second hangs) and we don't fall back to them by default. The
// list stays env-overridable (IPFS_GATEWAY_URLS) and the failover loop
// stays in place for operator-added mirrors.
'https://bridge.mintlayer.org/ipfs',
];

Expand Down
Loading