diff --git a/app/features/metadata/__tests__/utils.spec.ts b/app/features/metadata/__tests__/utils.spec.ts index 5450eb1d2..c5475c6bf 100644 --- a/app/features/metadata/__tests__/utils.spec.ts +++ b/app/features/metadata/__tests__/utils.spec.ts @@ -2,6 +2,11 @@ import { vi } from 'vitest'; import { getProxiedUri } from '../utils'; +// A well-known valid CIDv0 (contains Hello World) +const VALID_CID_V0 = 'QmWATWQ7fVPP2EFGu71UkfnqhYXDYH566qy47CnJDgvs8u'; +// A well-known valid CIDv1 (contains Hello World) +const VALID_CID_V1 = 'bafybeigdyrzt5sfp7udm7hu76uh7y26nf3ek5bfx73d7h4x7bgd35y2nuq'; + describe('getProxiedUri', () => { const originalEnv = process.env; @@ -38,6 +43,47 @@ describe('getProxiedUri', () => { expect(getProxiedUri(uri)).toBe('/api/metadata/proxy?uri=https%3A%2F%2Fexample.com'); }); + it('returns the rewritten HTTP gateway URI when proxy is not enabled and protocol is ipfs (CIDv0)', () => { + process.env.NEXT_PUBLIC_METADATA_ENABLED = 'false'; + const uri = `ipfs://${VALID_CID_V0}`; + expect(getProxiedUri(uri)).toBe(`https://ipfs.io/ipfs/${VALID_CID_V0}`); + }); + + it('returns the rewritten HTTP gateway URI when proxy is not enabled and protocol is ipfs (CIDv1)', () => { + process.env.NEXT_PUBLIC_METADATA_ENABLED = 'false'; + const uri = `ipfs://${VALID_CID_V1}`; + expect(getProxiedUri(uri)).toBe(`https://ipfs.io/ipfs/${VALID_CID_V1}`); + }); + + it('returns proxied HTTP gateway URI when proxy is enabled and protocol is ipfs (CIDv0)', () => { + process.env.NEXT_PUBLIC_METADATA_ENABLED = 'true'; + const uri = `ipfs://${VALID_CID_V0}`; + expect(getProxiedUri(uri)).toBe(`/api/metadata/proxy?uri=${encodeURIComponent(`https://ipfs.io/ipfs/${VALID_CID_V0}`)}`); + }); + + it('returns proxied HTTP gateway URI when proxy is enabled and protocol is ipfs (CIDv1)', () => { + process.env.NEXT_PUBLIC_METADATA_ENABLED = 'true'; + const uri = `ipfs://${VALID_CID_V1}`; + expect(getProxiedUri(uri)).toBe(`/api/metadata/proxy?uri=${encodeURIComponent(`https://ipfs.io/ipfs/${VALID_CID_V1}`)}`); + }); + + it('returns proxied HTTP gateway URI handling ipfs/ prefix when proxy is enabled and protocol is ipfs (CIDv0)', () => { + process.env.NEXT_PUBLIC_METADATA_ENABLED = 'true'; + const uri = `ipfs://ipfs/${VALID_CID_V0}`; + expect(getProxiedUri(uri)).toBe(`/api/metadata/proxy?uri=${encodeURIComponent(`https://ipfs.io/ipfs/${VALID_CID_V0}`)}`); + }); + + it('returns proxied HTTP gateway URI handling ipfs/ prefix when proxy is enabled and protocol is ipfs (CIDv1)', () => { + process.env.NEXT_PUBLIC_METADATA_ENABLED = 'true'; + const uri = `ipfs://ipfs/${VALID_CID_V1}`; + expect(getProxiedUri(uri)).toBe(`/api/metadata/proxy?uri=${encodeURIComponent(`https://ipfs.io/ipfs/${VALID_CID_V1}`)}`); + }); + + it('returns empty string for malformed IPFS CIDs', () => { + process.env.NEXT_PUBLIC_METADATA_ENABLED = 'true'; + expect(getProxiedUri('ipfs://not-a-valid-cid')).toBe(''); + }); + it('returns empty string when empty string is passed', () => { process.env.NEXT_PUBLIC_METADATA_ENABLED = 'true'; expect(getProxiedUri('')).toBe(''); diff --git a/app/features/metadata/utils.ts b/app/features/metadata/utils.ts index 39ce3f39b..cc72ed3ea 100644 --- a/app/features/metadata/utils.ts +++ b/app/features/metadata/utils.ts @@ -1,8 +1,9 @@ -export const getProxiedUri = (uri: string): string | '' => { - const isProxyEnabled = process.env.NEXT_PUBLIC_METADATA_ENABLED === 'true'; +import Logger from '@utils/logger'; +import { CID } from 'multiformats/cid'; - if (!isProxyEnabled) return uri; +const IPFS_GATEWAY = 'https://ipfs.io/ipfs'; +export const getProxiedUri = (uri: string): string | '' => { // handle empty addresses as that is likely the case for metadata if (uri === '') return ''; @@ -13,7 +14,38 @@ export const getProxiedUri = (uri: string): string | '' => { throw new Error(`Could not construct URL for "${uri}"`); } + const isProxyEnabled = process.env.NEXT_PUBLIC_METADATA_ENABLED === 'true'; + + if (url.protocol === 'ipfs:') { + const gatewayUri = resolveIpfsUri(url); + if (gatewayUri === '') return ''; + return isProxyEnabled ? proxyUri(gatewayUri) : gatewayUri; + } + + if (!isProxyEnabled) return uri; + if (!['http:', 'https:'].includes(url.protocol)) return uri; - return `/api/metadata/proxy?uri=${encodeURIComponent(uri)}`; + return proxyUri(uri); }; + +const resolveIpfsUri = (url: URL): string => { + // eslint-disable-next-line no-restricted-syntax -- Strips redundant "ipfs/" prefix from the path for a clean gateway URL. + const path = (url.host + url.pathname).replace(/^ipfs\//, ''); + if (!verifyCID(path)) { + Logger.warn(`[metadata] Cannot fetch a malformed CID: ${path}`); + return ''; + } + return `${IPFS_GATEWAY}/${path}${url.search}`; +}; + +const proxyUri = (uri: string): string => `/api/metadata/proxy?uri=${encodeURIComponent(uri)}`; + +const verifyCID = (cid: string): boolean => { + try { + CID.parse(cid); + return true; + } catch { + return false; + } +}; \ No newline at end of file diff --git a/package.json b/package.json index 885f75ba3..ac3e4f26f 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "lighthouse-sdk": "2.0.1", "micromatch": "4.0.8", "moment": "2.29.4", + "multiformats": "13.4.2", "next": "14.2.35", "node-fetch": "3.3.2", "p-limit": "3.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66a4c70d5..2d611c262 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -255,6 +255,9 @@ importers: moment: specifier: 2.29.4 version: 2.29.4 + multiformats: + specifier: 13.4.2 + version: 13.4.2 next: specifier: 14.2.35 version: 14.2.35(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.53.0) @@ -8166,6 +8169,9 @@ packages: typescript: optional: true + multiformats@13.4.2: + resolution: {integrity: sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==} + multistream@4.1.0: resolution: {integrity: sha512-J1XDiAmmNpRCBfIWJv+n0ymC4ABcf/Pl+5YvC5B/D2f/2+8PtHvCNxMPKiQcZyi922Hq69J2YOpb1pTywfifyw==} @@ -20600,6 +20606,8 @@ snapshots: - '@types/node' optional: true + multiformats@13.4.2: {} + multistream@4.1.0: dependencies: once: 1.4.0