From f15ec6a0660464a8c5a843986e221abd9fddf4b3 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Mon, 12 Oct 2026 15:53:02 +0200 Subject: [PATCH 1/2] fix: try the production ipfs proxy first for token icons --- app/src/pages/ipfs/[...path].test.ts | 20 ++++++++++---------- app/src/pages/ipfs/[...path].ts | 13 +++++++------ 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/app/src/pages/ipfs/[...path].test.ts b/app/src/pages/ipfs/[...path].test.ts index ee67b14..5ef37d8 100644 --- a/app/src/pages/ipfs/[...path].test.ts +++ b/app/src/pages/ipfs/[...path].test.ts @@ -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`, ); }); @@ -37,7 +37,7 @@ 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)[0]).toBe(`https://bridge.mintlayer.org/ipfs/${CID_V0}`); expect(gatewaysForPath(CID_V0).length).toBeGreaterThanOrEqual(3); }); }); @@ -65,7 +65,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).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. @@ -92,13 +92,13 @@ 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('follows same-site redirects but never foreign ones', async () => { - const subdomain = `https://${CID_V0.toLowerCase()}.ipfs.gateway.pinata.cloud/ipfs/${CID_V0}`; + const subdomain = `https://${CID_V0.toLowerCase()}.ipfs.ipfs.io/ipfs/${CID_V0}`; const calls: string[] = []; vi.stubGlobal( 'fetch', @@ -106,7 +106,7 @@ describe('GET /ipfs/[...path]', () => { 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, @@ -115,7 +115,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, diff --git a/app/src/pages/ipfs/[...path].ts b/app/src/pages/ipfs/[...path].ts index a9db6be..261fd24 100644 --- a/app/src/pages/ipfs/[...path].ts +++ b/app/src/pages/ipfs/[...path].ts @@ -34,16 +34,17 @@ const MAX_BYTES = 2 * 1024 * 1024; const GATEWAY_TIMEOUT_MS = 12_000; const DEFAULT_GATEWAYS = [ + // First: the project's own production proxy (R2-backed, same + // content-addressed paths, measured 200 in <1s vs multi-second hangs + // on public gateways). Same org — no new third party. + 'https://bridge.mintlayer.org/ipfs', + // Fallbacks: public gateways 429 "service worker only" from many + // residential networks, so they only ever serve when the R2 cache + // misses and the origin is unreachable. '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. - 'https://bridge.mintlayer.org/ipfs', ]; // SVG is excluded: same-origin SVG can carry script (self-XSS via a From a9f4ee299359b5e23277160b968fa77433772183 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Mon, 12 Oct 2026 15:56:00 +0200 Subject: [PATCH 2/2] fix: default ipfs lookups to the production proxy only --- app/src/pages/ipfs/[...path].test.ts | 25 ++++++++++++++++++++++--- app/src/pages/ipfs/[...path].ts | 16 ++++++---------- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/app/src/pages/ipfs/[...path].test.ts b/app/src/pages/ipfs/[...path].test.ts index 5ef37d8..380739e 100644 --- a/app/src/pages/ipfs/[...path].test.ts +++ b/app/src/pages/ipfs/[...path].test.ts @@ -36,12 +36,19 @@ describe('upstreamForPath', () => { ]); }); - it('builds one candidate per configured gateway', () => { - expect(gatewaysForPath(CID_V0)[0]).toBe(`https://bridge.mintlayer.org/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')); @@ -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', @@ -97,7 +105,18 @@ describe('GET /ipfs/[...path]', () => { 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 () => { + stubTwoGateways(); const subdomain = `https://${CID_V0.toLowerCase()}.ipfs.ipfs.io/ipfs/${CID_V0}`; const calls: string[] = []; vi.stubGlobal( diff --git a/app/src/pages/ipfs/[...path].ts b/app/src/pages/ipfs/[...path].ts index 261fd24..504720d 100644 --- a/app/src/pages/ipfs/[...path].ts +++ b/app/src/pages/ipfs/[...path].ts @@ -34,17 +34,13 @@ const MAX_BYTES = 2 * 1024 * 1024; const GATEWAY_TIMEOUT_MS = 12_000; const DEFAULT_GATEWAYS = [ - // First: the project's own production proxy (R2-backed, same - // content-addressed paths, measured 200 in <1s vs multi-second hangs - // on public gateways). Same org — no new third party. + // 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', - // Fallbacks: public gateways 429 "service worker only" from many - // residential networks, so they only ever serve when the R2 cache - // misses and the origin is unreachable. - 'https://ipfs.io/ipfs', - 'https://gateway.pinata.cloud/ipfs', - 'https://dweb.link/ipfs', - 'https://4everland.io/ipfs', ]; // SVG is excluded: same-origin SVG can carry script (self-XSS via a