diff --git a/next.config.js b/next.config.js index b9c4ed8490..ad3b5f2a1b 100644 --- a/next.config.js +++ b/next.config.js @@ -27,6 +27,9 @@ const INFURA_IPFS_URLS = [ const ETH_SUCKS_URLS = [ 'https://ipfs.banny.eth.sucks', 'https://ipfs.banny.eth.sucks/*', + // The gateway serves the subdomain form (`.eth.sucks`), so the wildcard + // is what actually covers reads; the path form above is kept for old links. + 'https://*.eth.sucks', ] const V2EX_URLS = [ @@ -381,6 +384,13 @@ const nextConfig = removeImports({ port: '', pathname: '/ipfs/**', }, + { + // The subdomain form the gateway actually serves: `.eth.sucks/`. + protocol: 'https', + hostname: '*.eth.sucks', + port: '', + pathname: '/**', + }, { protocol: 'https', hostname: 'cid.v2ex.pro', diff --git a/src/packages/v4v5/contexts/V4V5NftRewards/__tests__/nftTierMedia.test.ts b/src/packages/v4v5/contexts/V4V5NftRewards/__tests__/nftTierMedia.test.ts new file mode 100644 index 0000000000..b35ba7b685 --- /dev/null +++ b/src/packages/v4v5/contexts/V4V5NftRewards/__tests__/nftTierMedia.test.ts @@ -0,0 +1,63 @@ +import axios from 'axios' +import { ethSucksGatewayUrl, pinataGatewayUrl } from 'utils/ipfs' + +import { fetchTierMetadata, liveMediaUrl } from '../tierMedia' + +jest.mock('axios') + +const mockedGet = axios.get as jest.Mock +const CID = 'QmNLei78zWmzUdbeRB3CiUfAizWUrbeeZh5K1rhAQKCh51' + +describe('NFT tier media', () => { + beforeEach(() => { + mockedGet.mockReset() + jest.spyOn(console, 'warn').mockImplementation(() => undefined) + jest.spyOn(console, 'error').mockImplementation(() => undefined) + }) + + afterEach(() => jest.restoreAllMocks()) + + it('walks to the next gateway when one fails', async () => { + mockedGet + .mockRejectedValueOnce(new Error('410')) + .mockResolvedValueOnce({ data: { name: 'Tier 1' } }) + + await expect(fetchTierMetadata(CID)).resolves.toEqual({ name: 'Tier 1' }) + expect(mockedGet).toHaveBeenCalledTimes(2) + expect(mockedGet.mock.calls[0][0]).toBe(ethSucksGatewayUrl(CID)) + }) + + it('falls all the way through to Pinata', async () => { + mockedGet + .mockRejectedValueOnce(new Error('down')) + .mockRejectedValueOnce(new Error('down')) + .mockResolvedValueOnce({ data: { name: 'Tier 1' } }) + + await fetchTierMetadata(CID) + expect(mockedGet.mock.calls[2][0]).toBe(pinataGatewayUrl(CID)) + }) + + it('throws once every gateway has been tried', async () => { + mockedGet.mockRejectedValue(new Error('down')) + await expect(fetchTierMetadata(CID)).rejects.toThrow('down') + expect(mockedGet).toHaveBeenCalledTimes(3) + }) + + it.each([ + `https://jbx.mypinata.cloud/ipfs/${CID}`, + `https://jbm.infura-ipfs.io/ipfs/${CID}`, + `https://cid.v2ex.pro/ipfs/${CID}`, + `https://ipfs.banny.eth.sucks/ipfs/${CID}`, + ])('re-points media stored on a retired gateway (%s)', image => { + expect(liveMediaUrl(image)).toBe(ethSucksGatewayUrl(CID)) + }) + + it('leaves media on a live host alone', () => { + expect(liveMediaUrl('https://example.com/tier.png')).toBe( + 'https://example.com/tier.png', + ) + expect(liveMediaUrl('data:image/svg+xml;base64,abc')).toBe( + 'data:image/svg+xml;base64,abc', + ) + }) +}) diff --git a/src/packages/v4v5/contexts/V4V5NftRewards/tierMedia.ts b/src/packages/v4v5/contexts/V4V5NftRewards/tierMedia.ts new file mode 100644 index 0000000000..09758a6a1e --- /dev/null +++ b/src/packages/v4v5/contexts/V4V5NftRewards/tierMedia.ts @@ -0,0 +1,45 @@ +import axios from 'axios' +import { IPFSNftRewardTier } from 'models/nftRewards' +import { + cidFromUrl, + ethSucksGatewayUrl, + ipfsGatewayUrl, + pinataGatewayUrl, +} from 'utils/ipfs' + +/** + * The gateways a tier's metadata and media are read from, in order. + * + * eth.sucks first (fast, and it serves the subdomain form), then the configured + * gateway, then Pinata's public one. This used to be three copy-pasted try/catch + * blocks that all preferred URL shapes which had stopped serving — the eth.sucks + * path form (410 Gone) and cid.v2ex.pro (no longer resolves). + */ +const TIER_GATEWAY_URLS = [ethSucksGatewayUrl, ipfsGatewayUrl, pinataGatewayUrl] + +/** Legacy hosts baked into old tier metadata, whose CIDs we re-point at a live gateway. */ +const RETIRED_MEDIA_HOSTS = + /^https?:\/\/(jbx\.mypinata\.cloud|jbm\.infura-ipfs\.io|cid\.v2ex\.pro|ipfs\.banny\.eth\.sucks|ipfs\.io)\// + +export async function fetchTierMetadata(tierCid: string): Promise { + let lastError: unknown + for (const gatewayUrl of TIER_GATEWAY_URLS) { + try { + const response = await axios.get(gatewayUrl(tierCid)) + return response.data as IPFSNftRewardTier + } catch (error) { + lastError = error + console.warn(`IPFS gateway failed for CID ${tierCid}`, gatewayUrl(tierCid)) + } + } + console.error(`All IPFS gateways failed for CID ${tierCid}`) + throw lastError +} + +/** Re-point media stored on a gateway that has stopped serving. */ +export function liveMediaUrl(image: string): string { + if (!RETIRED_MEDIA_HOSTS.test(image)) return image + const cid = cidFromUrl(image) + return cid ? ethSucksGatewayUrl(cid) : image +} + diff --git a/src/packages/v4v5/contexts/V4V5NftRewards/useNftRewards.ts b/src/packages/v4v5/contexts/V4V5NftRewards/useNftRewards.ts index 0a57cbe401..4750eccddb 100644 --- a/src/packages/v4v5/contexts/V4V5NftRewards/useNftRewards.ts +++ b/src/packages/v4v5/contexts/V4V5NftRewards/useNftRewards.ts @@ -2,14 +2,7 @@ import { UseQueryResult, useQuery } from '@tanstack/react-query' import { formatEther, jb721TiersHookStoreAbi } from 'juice-sdk-core' import { readContract } from 'wagmi/actions' import { IPFSNftRewardTier, NftRewardTier } from 'models/nftRewards' -import { - cidFromUrl, - decodeEncodedIpfsUri, - ethSucksGatewayUrl, - ipfsGatewayUrl, - pinataGatewayUrl, - v2exGatewayUrl -} from 'utils/ipfs' +import { decodeEncodedIpfsUri } from 'utils/ipfs' import axios from 'axios' import { JBChainId } from 'juice-sdk-react' @@ -17,112 +10,31 @@ import { withHttps } from 'utils/externalLink' import { zeroAddress } from 'viem' import { useConfig } from 'wagmi' import { JB721TierV4 } from './V4V5NftRewardsProvider' +import { fetchTierMetadata, liveMediaUrl } from './tierMedia' const NFT_PAGE_SIZE = 100n -// fetchRewardTierMetadata takes three steps to retrieve and process metadata: -// 1. Attempt to fetch metadata from the primary eth.sucks gateway. -// 2. If the primary gateway fails, fallback to the Infura (jbm.infura-ipfs) gateway, and then to the Pinata gateway. -// 3. Process the retrieved metadata (e.g., handle image URLs, format data) and return the reward tier details. -async function fetchRewardTierMetadata({ - tier, - perChainSupply -}: { +async function fetchRewardTierMetadata({ + tier, + perChainSupply, +}: { tier: JB721TierV4 perChainSupply?: { chainId: number; remainingSupply: number }[] }) { const tierCid = decodeEncodedIpfsUri(tier.encodedIPFSUri) - const primaryUrl = ethSucksGatewayUrl(tierCid) // Use eth.sucks as primary - - try { - // First try the eth.sucks gateway (primary) - const response = await axios.get(primaryUrl) - const tierMetadata: IPFSNftRewardTier = response.data - - const maxSupply = tier.initialSupply - - // Some projects have image links hard-coded to the old IPFS gateway. - const pinataRegex = /^(https?:\/\/jbx\.mypinata\.cloud)/ - const infuraRegex = /^(https?:\/\/jbm\.infura-ipfs\.io\/ipfs\/)/ - const ipfsIoRegex = /^(https?:\/\/ipfs\.io\/ipfs\/)/ - - if (tierMetadata?.image && pinataRegex.test(tierMetadata.image)) { - const imageUrlCid = cidFromUrl(tierMetadata.image) - tierMetadata.image = ethSucksGatewayUrl(imageUrlCid) // Use eth.sucks for images too - } else if (tierMetadata?.image && infuraRegex.test(tierMetadata.image)) { - const imageUrlCid = cidFromUrl(tierMetadata.image) - tierMetadata.image = ethSucksGatewayUrl(imageUrlCid) // Convert Infura URLs to eth.sucks - } else if (tierMetadata?.image && ipfsIoRegex.test(tierMetadata.image)) { - const imageUrlCid = cidFromUrl(tierMetadata.image) - tierMetadata.image = v2exGatewayUrl(imageUrlCid) // Convert ipfs.io URLs to v2ex.pro gateway - } - - const rawContributionFloor = tier.price - - return processMetadata(tier, tierMetadata, maxSupply, rawContributionFloor, perChainSupply) - } catch (error) { - console.warn(`eth.sucks gateway failed for CID ${tierCid}, trying Infura fallback`) - - try { - // Try the jbm-Infura gateway as first fallback - const infuraUrl = ipfsGatewayUrl(tierCid) - const response = await axios.get(infuraUrl) - const tierMetadata: IPFSNftRewardTier = response.data - const maxSupply = tier.initialSupply - const rawContributionFloor = tier.price - - // Handle Pinata and Infura image links - const pinataRegex = /^(https?:\/\/jbx\.mypinata\.cloud)/ - const infuraRegex = /^(https?:\/\/jbm\.infura-ipfs\.io\/ipfs\/)/ - const ipfsIoRegex = /^(https?:\/\/ipfs\.io\/ipfs\/)/ - - if (tierMetadata?.image && pinataRegex.test(tierMetadata.image)) { - const imageUrlCid = cidFromUrl(tierMetadata.image) - tierMetadata.image = ethSucksGatewayUrl(imageUrlCid) // Still prefer eth.sucks for images - } else if (tierMetadata?.image && infuraRegex.test(tierMetadata.image)) { - const imageUrlCid = cidFromUrl(tierMetadata.image) - tierMetadata.image = ethSucksGatewayUrl(imageUrlCid) // Convert Infura URLs to eth.sucks - } else if (tierMetadata?.image && ipfsIoRegex.test(tierMetadata.image)) { - const imageUrlCid = cidFromUrl(tierMetadata.image) - tierMetadata.image = v2exGatewayUrl(imageUrlCid) // Convert ipfs.io URLs to v2ex.pro gateway - } - - return processMetadata(tier, tierMetadata, maxSupply, rawContributionFloor, perChainSupply) - } catch (infuraError) { - console.warn(`Infura gateway failed for CID ${tierCid}, trying Pinata fallback`) - - try { - // Try the Pinata gateway as final fallback - const pinataUrl = pinataGatewayUrl(tierCid) - const response = await axios.get(pinataUrl) - const tierMetadata: IPFSNftRewardTier = response.data - const maxSupply = tier.initialSupply - const rawContributionFloor = tier.price + const tierMetadata = await fetchTierMetadata(tierCid) - // Handle Pinata and Infura image links - const pinataRegex = /^(https?:\/\/jbx\.mypinata\.cloud)/ - const infuraRegex = /^(https?:\/\/jbm\.infura-ipfs\.io\/ipfs\/)/ - const ipfsIoRegex = /^(https?:\/\/ipfs\.io\/ipfs\/)/ - - if (tierMetadata?.image && pinataRegex.test(tierMetadata.image)) { - const imageUrlCid = cidFromUrl(tierMetadata.image) - tierMetadata.image = ethSucksGatewayUrl(imageUrlCid) // Still prefer eth.sucks for images - } else if (tierMetadata?.image && infuraRegex.test(tierMetadata.image)) { - const imageUrlCid = cidFromUrl(tierMetadata.image) - tierMetadata.image = ethSucksGatewayUrl(imageUrlCid) // Convert Infura URLs to eth.sucks - } else if (tierMetadata?.image && ipfsIoRegex.test(tierMetadata.image)) { - const imageUrlCid = cidFromUrl(tierMetadata.image) - tierMetadata.image = v2exGatewayUrl(imageUrlCid) // Convert ipfs.io URLs to v2ex.pro gateway - } - - return processMetadata(tier, tierMetadata, maxSupply, rawContributionFloor, perChainSupply) - } catch (pinataError) { - // If all gateways fail, rethrow the original error - console.error(`All IPFS gateways failed for CID ${tierCid}`) - throw error - } - } + if (tierMetadata.image) { + tierMetadata.image = liveMediaUrl(tierMetadata.image) } + + return processMetadata( + tier, + tierMetadata, + tier.initialSupply, + tier.price, + perChainSupply, + ) } // Helper function to process metadata and return the reward tier diff --git a/src/utils/__tests__/ipfs.test.ts b/src/utils/__tests__/ipfs.test.ts index 5935f01a94..feed95a2ce 100644 --- a/src/utils/__tests__/ipfs.test.ts +++ b/src/utils/__tests__/ipfs.test.ts @@ -1,8 +1,8 @@ -import { OPEN_IPFS_GATEWAY_HOSTNAME } from 'constants/ipfs' - import { + convertToV1CID, decodeEncodedIpfsUri, encodeIpfsUri, + ethSucksGatewayUrl, ipfsUriToGatewayUrl, pinataToGatewayUrl, } from '../ipfs' @@ -10,18 +10,21 @@ import { const CID = 'QmNLei78zWmzUdbeRB3CiUfAizWUrbeeZh5K1rhAQKCh51' describe('ipfs gateway urls', () => { - // Both of these used to resolve to gateways that stopped serving: the - // dedicated Pinata one is NXDOMAIN and eth.sucks answers 410, so every image - // routed through them rendered broken. - it('routes ipfs:// uris at the read gateway', () => { - expect(ipfsUriToGatewayUrl(`ipfs://${CID}`)).toBe( - `https://${OPEN_IPFS_GATEWAY_HOSTNAME}/ipfs/${CID}`, - ) + // The gateway serves the subdomain form. Its path form + // (`ipfs.banny.eth.sucks/ipfs/`) answers 410 Gone, which is what left + // every project logo and NFT tier image broken. + it('addresses eth.sucks by subdomain with a v1 CID', () => { + expect(ethSucksGatewayUrl(CID)).toBe(`https://${convertToV1CID(CID)}.eth.sucks/`) + expect(ethSucksGatewayUrl(CID)).not.toContain('/ipfs/') + }) + + it('routes ipfs:// uris there', () => { + expect(ipfsUriToGatewayUrl(`ipfs://${CID}`)).toBe(ethSucksGatewayUrl(CID)) }) it('rewrites urls on the retired dedicated gateway', () => { expect(pinataToGatewayUrl(`https://jbx.mypinata.cloud/ipfs/${CID}`)).toBe( - `https://${OPEN_IPFS_GATEWAY_HOSTNAME}/ipfs/${CID}`, + ethSucksGatewayUrl(CID), ) }) diff --git a/src/utils/ipfs.ts b/src/utils/ipfs.ts index 2e22c76074..264be5e44d 100644 --- a/src/utils/ipfs.ts +++ b/src/utils/ipfs.ts @@ -18,13 +18,15 @@ export const ipfsGatewayUrl = ( } /** - * Return a URL to ETH Sucks gateway for the given CID. - * Uses the new ipfs.banny.eth.sucks format. + * Return a URL to the eth.sucks gateway for the given CID. + * + * Subdomain form with a v1 CID, which is what that gateway serves — the path form + * (`ipfs.banny.eth.sucks/ipfs/`) answers 410 Gone, so everything routed + * through it rendered broken. */ export const ethSucksGatewayUrl = (cid: string | undefined): string => { if (!cid) return '' - // this can use either v0 or v1 CID according to Livid - return `https://ipfs.banny.eth.sucks/ipfs/${cid}` + return `https://${convertToV1CID(cid)}.eth.sucks/` } /** @@ -86,14 +88,13 @@ export const cidFromIpfsUri = (ipfsUri: string) => ipfsUri.match(IPFS_URL_REGEX)?.[1] /** - * Returns a native IPFS link (`ipfs://`) as an https link on the read gateway. + * Returns a native IPFS link (`ipfs://`) as an https link on a read gateway. */ export function ipfsUriToGatewayUrl(ipfsUri: string): string { if (!isIpfsUri(ipfsUri)) return ipfsUri - // Not eth.sucks: that gateway answers 410 Gone, so every image routed through - // it renders broken. - return ipfsGatewayUrl(cidFromIpfsUri(ipfsUri)) + const cid = cidFromIpfsUri(ipfsUri) + return cid && isIpfsCID(cid) ? ethSucksGatewayUrl(cid) : ipfsGatewayUrl(cid) } /** @@ -112,7 +113,7 @@ export function pinataToGatewayUrl(url: string) { } if (cid && isIpfsCID(cid)) { - return ipfsGatewayUrl(cid) + return ethSucksGatewayUrl(cid) } if (url.startsWith('https://jbx.mypinata.cloud')) {