diff --git a/packages/gui/src/components/nfts/NFTHashStatus.tsx b/packages/gui/src/components/nfts/NFTHashStatus.tsx index 884d8ecfc8..abed5e7aaa 100644 --- a/packages/gui/src/components/nfts/NFTHashStatus.tsx +++ b/packages/gui/src/components/nfts/NFTHashStatus.tsx @@ -6,8 +6,10 @@ import { Chip, Typography } from '@mui/material'; import CircularProgress from '@mui/material/CircularProgress'; import React, { useMemo } from 'react'; +import useIpfsGateway from '../../hooks/useIpfsGateway'; import useNFT from '../../hooks/useNFT'; import useNFTVerifyHash from '../../hooks/useNFTVerifyHash'; +import ipfsToGatewayUrl from '../../util/ipfs'; export type NFTHashStatusProps = { nftId: string; @@ -27,6 +29,7 @@ export default function NFTHashStatus(props: NFTHashStatusProps) { }); const { nft, isLoading: isLoadingNFT, error: errorNFT } = useNFT(nftId); + const [ipfsGateway] = useIpfsGateway(); const isLoading = isLoadingNFTVerifyHash || isLoadingNFT; const isVerified = preview ? nftPreview?.isVerified : data?.isVerified; @@ -34,16 +37,19 @@ export default function NFTHashStatus(props: NFTHashStatusProps) { const failedFetch = preview ? nftPreview?.failedFetch : data?.failedFetch; const isValidURI = useMemo(() => { - if (!nftPreview || !('originalUri' in nftPreview)) { + const uri = nftPreview?.uri; + if (!uri) { + // nothing to validate — other branches cover the missing-preview cases return true; } - if (nftPreview.uri) { - return isValidURL(nftPreview.uri); - } - - return false; - }, [nftPreview]); + // While the user has IPFS gateway fetching enabled, ipfs:// URIs are + // served through an HTTPS gateway by the cache layer, so validate the + // gateway form instead of flagging them as invalid. With the option off + // they are not fetchable and stay flagged — unless the file already + // verified from the cache, which the message branches above this check. + return isValidURL(ipfsGateway ? ipfsToGatewayUrl(uri) : uri); + }, [nftPreview, ipfsGateway]); const icon = useMemo(() => { if (hideIcon) { diff --git a/packages/gui/src/components/nfts/provider/hooks/useMetadataData.ts b/packages/gui/src/components/nfts/provider/hooks/useMetadataData.ts index c25ade0370..73b8e71b6a 100644 --- a/packages/gui/src/components/nfts/provider/hooks/useMetadataData.ts +++ b/packages/gui/src/components/nfts/provider/hooks/useMetadataData.ts @@ -2,12 +2,13 @@ import { EventEmitter } from 'events'; import { type NFTInfo } from '@chia-network/api'; import debug from 'debug'; -import { useState, useCallback, useMemo } from 'react'; +import { useState, useCallback, useEffect, useMemo, useRef } from 'react'; import type Metadata from '../../../../@types/Metadata'; import type MetadataOnDemand from '../../../../@types/MetadataOnDemand'; import type MetadataState from '../../../../@types/MetadataState'; import useFetchAndProcessMetadata from '../../../../hooks/useFetchAndProcessMetadata'; +import useIpfsGateway from '../../../../hooks/useIpfsGateway'; import getNFTId from '../../../../util/getNFTId'; const log = debug('chia-gui:NFTProvider:useMetadataData'); @@ -159,6 +160,52 @@ export default function useMetadataData(props: UseMetadataDataProps) { [getMetadata /* immutable */, metadatasOnDemand /* immutable */], ); + const [ipfsGateway] = useIpfsGateway(); + const lastIpfsGatewayRef = useRef(ipfsGateway); + + useEffect(() => { + if (lastIpfsGatewayRef.current === ipfsGateway) { + return; + } + lastIpfsGatewayRef.current = ipfsGateway; + + // Flipping the gateway option changes which URIs the main process will + // fetch, so cached failures are stale — without this, a failed ipfs + // metadata fetch stayed cached here and its NFT kept looking broken + // after enabling the option, until a full app reload. Only failures are + // retried: successfully fetched metadata is hash-verified content and + // unaffected by how it was fetched. A fetch that is still in flight + // started under the old preference and may fail because of it — after + // this effect has run, nothing else would retry that failure — so it is + // retried on rejection; a result that arrives successfully is kept. + // + // Iterate a snapshot: retrying an errored entry re-inserts its key with a + // fresh in-flight promise synchronously, and Map.forEach revisits keys + // re-added during the pass — the live map would attach a rejection retry + // to the very fetch this effect just started, double-fetching a failure. + Array.from(metadatasOnDemand.entries()).forEach(([nftId, metadataOnDemand]) => { + const retry = () => + invalidate(nftId).catch((e) => { + log(`Error retrying metadata for nftId: ${nftId}`, e); + }); + + if (metadataOnDemand.error) { + retry(); + } else if (metadataOnDemand.promise) { + metadataOnDemand.promise.catch((e) => { + // Retry only the failure this handler saw. The fetch's own catch + // stores its rejection as the entry's error, so anything else here + // means the entry has moved on — a stacked handler from another + // toggle already retried it, or a newer fetch succeeded — and a + // retry would discard that state and fetch again for nothing. + if (metadatasOnDemand.get(nftId)?.error === e) { + retry(); + } + }); + } + }); + }, [ipfsGateway, invalidate /* immutable */, metadatasOnDemand /* immutable */]); + // immutable function const subscribeToMetadataChanges = useCallback( (id: string | undefined, callback: (nftState: MetadataState) => void) => { diff --git a/packages/gui/src/components/settings/SettingsNFT.tsx b/packages/gui/src/components/settings/SettingsNFT.tsx index db3e58185a..f793e9bfa8 100644 --- a/packages/gui/src/components/settings/SettingsNFT.tsx +++ b/packages/gui/src/components/settings/SettingsNFT.tsx @@ -17,6 +17,7 @@ import React from 'react'; import useAllowUnverifiedNFTPreviews from '../../hooks/useAllowUnverifiedNFTPreviews'; import useCache from '../../hooks/useCache'; import useHideObjectionableContent from '../../hooks/useHideObjectionableContent'; +import useIpfsGateway from '../../hooks/useIpfsGateway'; import useNFTImageFittingMode from '../../hooks/useNFTImageFittingMode'; import { useNFTVideoLoopGlobal } from '../../hooks/useNFTVideoLoop'; @@ -41,6 +42,7 @@ export default function SettingsGeneral() { const { cacheSize, clearCache, cacheDirectory, setCacheDirectory } = useCache(); const [nftImageFittingMode, setNFTImageFittingMode] = useNFTImageFittingMode(); const [nftVideoLoop, setNFTVideoLoop] = useNFTVideoLoopGlobal(); + const [ipfsGateway, setIpfsGateway] = useIpfsGateway(); const [allowUnverifiedPreviews, setAllowUnverifiedPreviews] = useAllowUnverifiedNFTPreviews(); // const [, setCacheFolder] = usePrefs('cacheFolder', ''); const openDialog = useOpenDialog(); @@ -53,6 +55,10 @@ export default function SettingsGeneral() { setNFTVideoLoop(event.target.checked); } + function handleChangeIpfsGateway(event: React.ChangeEvent) { + setIpfsGateway(event.target.checked); + } + function handleChangeAllowUnverifiedPreviews(event: React.ChangeEvent) { setAllowUnverifiedPreviews(event.target.checked); } @@ -150,6 +156,26 @@ export default function SettingsGeneral() { + + + + Fetch IPFS content through a gateway + + + + } /> + + + + + NFT files published with ipfs:// addresses will be downloaded through the public ipfs.io HTTPS gateway. + The requested URL differs from the address recorded on chain, but downloaded content is still verified + against the NFT's on-chain hash. When disabled, ipfs:// files are not fetched. + + + + + diff --git a/packages/gui/src/electron/CacheManager.ts b/packages/gui/src/electron/CacheManager.ts index 745cd4899d..4e5e595720 100644 --- a/packages/gui/src/electron/CacheManager.ts +++ b/packages/gui/src/electron/CacheManager.ts @@ -7,7 +7,6 @@ import path from 'node:path'; import { Readable } from 'node:stream'; import debug from 'debug'; -import isURL from 'validator/lib/isURL'; import type CacheInfo from '../@types/CacheInfo'; import type CacheInfoBase from '../@types/CacheInfoBase'; @@ -20,6 +19,7 @@ import downloadFile, { MAX_FILE_SIZE_EXCEEDED_ERROR } from './utils/downloadFile import ensureDirectoryExists from './utils/ensureDirectoryExists'; import getChecksum from './utils/getChecksum'; import ipcMainHandle from './utils/ipcMainHandle'; +import { IpfsGatewayDisabledError } from './utils/ipfsGateway'; import isValidURL from './utils/isValidURL'; import sanitizeFilename from './utils/sanitizeFilename'; import sanitizeNumber from './utils/sanitizeNumber'; @@ -435,7 +435,7 @@ export default class CacheManager extends EventEmitter { const normalizedURL = decodeURI(url) === url ? encodeURI(url) : url; - if (!isURL(normalizedURL)) { + if (!isValidURL(normalizedURL)) { throw new Error(`Invalid URL: ${normalizedURL}`); } @@ -507,6 +507,15 @@ export default class CacheManager extends EventEmitter { return await this.#downloadLimit(() => limitedRemoteFileDownload()); } catch (error) { + // Not a property of the URL, just of the current preference: while + // the IPFS gateway option is off the fetch is refused before it + // starts. Persisting that as a cache error would keep the entry + // poisoned after the user turns the option on, so it propagates + // instead — already-cached content was served above regardless. + if (error instanceof IpfsGatewayDisabledError) { + throw error; + } + const currentError = (error as Error) ?? new Error('Unknown fetchRemoteContent error'); return await this.setCacheInfo(url, { diff --git a/packages/gui/src/electron/api/nftGetMetadata.test.ts b/packages/gui/src/electron/api/nftGetMetadata.test.ts index 10f560d3fc..4c481ef17d 100644 --- a/packages/gui/src/electron/api/nftGetMetadata.test.ts +++ b/packages/gui/src/electron/api/nftGetMetadata.test.ts @@ -11,6 +11,13 @@ jest.mock('../utils/fetchBuffer', () => ({ jest.requireActual('../utils/fetchBuffer').MaxSizeExceededError, })); +const mockMaybeIpfsToGatewayUrl = jest.fn(); + +jest.mock('../utils/ipfsGateway', () => ({ + __esModule: true, + default: mockMaybeIpfsToGatewayUrl, +})); + const mockAllowUnverifiedNftPreviews = jest.fn(); jest.mock('../utils/allowUnverifiedNftPreviews', () => ({ @@ -20,6 +27,8 @@ jest.mock('../utils/allowUnverifiedNftPreviews', () => ({ const { MaxSizeExceededError } = jest.requireActual('../utils/fetchBuffer'); +const ipfsToGatewayUrl = jest.requireActual('../../util/ipfs').default; + const { nftGetImageDataUrl, nftGetMetadata } = jest.requireActual('./nftGetMetadata'); @@ -66,6 +75,9 @@ describe('nftGetMetadata', () => { describe('nftGetImageDataUrl', () => { beforeEach(() => { mockFetchBuffer.mockReset(); + mockMaybeIpfsToGatewayUrl.mockReset(); + // gateway option off: URLs pass through untranslated + mockMaybeIpfsToGatewayUrl.mockImplementation((url) => url); mockAllowUnverifiedNftPreviews.mockReset(); mockAllowUnverifiedNftPreviews.mockReturnValue(false); }); @@ -124,6 +136,47 @@ describe('nftGetImageDataUrl', () => { ); }); + it('falls back to the gateway URL for an oversized ipfs image when both options are on', async () => { + mockAllowUnverifiedNftPreviews.mockReturnValue(true); + mockMaybeIpfsToGatewayUrl.mockImplementation(ipfsToGatewayUrl); + mockFetchBuffer.mockRejectedValue( + new MaxSizeExceededError({ + 'content-type': 'image/gif', + }), + ); + + // the dialog CSP only allows https: and data: images, so the raw ipfs + // URI would render as a broken image + await expect(nftGetImageDataUrl('ipfs://bafybeigdyrztest/large.gif', '00')).resolves.toBe( + 'https://ipfs.io/ipfs/bafybeigdyrztest/large.gif', + ); + }); + + it('omits the preview for an oversized ipfs image while the gateway option is off', async () => { + mockAllowUnverifiedNftPreviews.mockReturnValue(true); + mockFetchBuffer.mockRejectedValue( + new MaxSizeExceededError({ + 'content-type': 'image/gif', + }), + ); + + // an untranslated ipfs URI would be blocked by the dialog CSP, so no + // preview is returned at all even though unverified previews are allowed + await expect(nftGetImageDataUrl('ipfs://bafybeigdyrztest/large.gif', '00')).resolves.toBeUndefined(); + }); + + it('omits the preview for an oversized ipfs image while unverified previews are off', async () => { + mockMaybeIpfsToGatewayUrl.mockImplementation(ipfsToGatewayUrl); + mockFetchBuffer.mockRejectedValue( + new MaxSizeExceededError({ + 'content-type': 'image/gif', + }), + ); + + // the gateway option alone does not opt into unverified fallbacks + await expect(nftGetImageDataUrl('ipfs://bafybeigdyrztest/large.gif', '00')).resolves.toBeUndefined(); + }); + it('rejects an oversized response that is not an image even when unverified previews are enabled', async () => { mockAllowUnverifiedNftPreviews.mockReturnValue(true); mockFetchBuffer.mockRejectedValue( diff --git a/packages/gui/src/electron/api/nftGetMetadata.ts b/packages/gui/src/electron/api/nftGetMetadata.ts index f1f1d4a481..f6c4cf38fb 100644 --- a/packages/gui/src/electron/api/nftGetMetadata.ts +++ b/packages/gui/src/electron/api/nftGetMetadata.ts @@ -2,8 +2,10 @@ import crypto from 'node:crypto'; import type Headers from '../../@types/Headers'; import compareChecksums from '../../util/compareChecksums'; +import { isIpfsUrl } from '../../util/ipfs'; import allowUnverifiedNftPreviews from '../utils/allowUnverifiedNftPreviews'; import fetchBuffer, { MaxSizeExceededError } from '../utils/fetchBuffer'; +import maybeIpfsToGatewayUrl from '../utils/ipfsGateway'; const METADATA_TIMEOUT = 10_000; const METADATA_MAX_SIZE = 5 * 1024 * 1024; @@ -103,8 +105,12 @@ export async function nftGetImageDataUrl( // behavior for these files. Off by default: the response's size and type // claims are attacker-controlled, so the fallback can be triggered // deliberately to place unverified content in a confirmation dialog. + // The CSP does not allow the ipfs: scheme either, so ipfs URIs fall back + // to their gateway form, and only when the user has also enabled the + // gateway — otherwise they get no preview rather than a CSP-blocked URL. if (error instanceof MaxSizeExceededError && getImageContentType(error.headers) && allowUnverifiedNftPreviews()) { - return imageUri; + const directUrl = maybeIpfsToGatewayUrl(imageUri); + return isIpfsUrl(directUrl) ? undefined : directUrl; } // image previews are best effort — the confirmation dialog has a fallback diff --git a/packages/gui/src/electron/main.tsx b/packages/gui/src/electron/main.tsx index 80e442814b..c48ca8dad9 100644 --- a/packages/gui/src/electron/main.tsx +++ b/packages/gui/src/electron/main.tsx @@ -29,6 +29,7 @@ import type { PermissionsNotificationPayload } from '../@types/PermissionsServic import { WcError, WcErrorCode, encodeWcErrorForIpc } from '../@types/WcError'; import AppIcon from '../assets/img/chia64x64.png'; import { i18n } from '../config/locales'; +import { isIpfsUrl } from '../util/ipfs'; import CacheManager, { CACHE_PROTOCOL } from './CacheManager'; import { checkNFTOwnership } from './api/checkNFTOwnership'; @@ -61,6 +62,7 @@ import { dispatchPairRequest } from './utils/dispatchPairRequest'; import downloadFile from './utils/downloadFile'; import fetchJSON from './utils/fetchJSON'; import ipcMainHandle from './utils/ipcMainHandle'; +import maybeIpfsToGatewayUrl from './utils/ipfsGateway'; import isValidURL from './utils/isValidURL'; import { loadConfig, checkConfigFileExists } from './utils/loadConfig'; import { getDefaultLogPath, LogPathValidationError, resolveTrustedLogPath } from './utils/logPath'; @@ -575,7 +577,17 @@ if (ensureSingleInstance() && ensureCorrectEnvironment()) { return; } - mainWindow.webContents.downloadURL(urlLocal); + // Chromium's downloader cannot fetch the ipfs: scheme; when the user + // has enabled the gateway, download ipfs URIs through it like every + // other network path. With the option off there is nothing the + // downloader could fetch, so the request is dropped instead of handing + // Chromium a URL it silently fails on. + const downloadUrl = maybeIpfsToGatewayUrl(urlLocal); + if (isIpfsUrl(downloadUrl)) { + return; + } + + mainWindow.webContents.downloadURL(downloadUrl); }); ipcMainHandle(AppAPI.START_MULTIPLE_DOWNLOAD, async (tasks: { url: string; filename: string }[]) => { diff --git a/packages/gui/src/electron/utils/downloadFile.ts b/packages/gui/src/electron/utils/downloadFile.ts index 7520fdb5ac..d69d5b679f 100644 --- a/packages/gui/src/electron/utils/downloadFile.ts +++ b/packages/gui/src/electron/utils/downloadFile.ts @@ -6,6 +6,7 @@ import debug from 'debug'; import type Headers from '../../@types/Headers'; import fileExists from './fileExists'; +import { toFetchableUrl } from './ipfsGateway'; import isValidURL from './isValidURL'; const log = debug('chia-gui:downloadFile'); @@ -100,7 +101,12 @@ export default async function downloadFile( } const tempFilePath = `${localPath}.tmp`; - const request = net.request(url); + // ipfs:// URIs are fetched through an HTTPS gateway when the user has + // enabled it — Electron's net stack cannot request the ipfs scheme, and + // with the option off toFetchableUrl refuses the fetch outright. Only + // this outgoing request uses the translated URL; callers keep the original + // URI as the cache key. + const request = net.request(toFetchableUrl(url)); const outputStream = new WriteStreamPromise(tempFilePath, overrideFile); // set when we abort the request ourselves, so abort events can be reported diff --git a/packages/gui/src/electron/utils/fetchBuffer.ts b/packages/gui/src/electron/utils/fetchBuffer.ts index cad731bac0..0efef32118 100644 --- a/packages/gui/src/electron/utils/fetchBuffer.ts +++ b/packages/gui/src/electron/utils/fetchBuffer.ts @@ -2,6 +2,7 @@ import { net, type IncomingMessage } from 'electron'; import type Headers from '../../@types/Headers'; +import { toFetchableUrl } from './ipfsGateway'; import isValidURL from './isValidURL'; const DEFAULT_TIMEOUT = 10 * 60 * 1000; // 10 minutes @@ -41,7 +42,10 @@ export default async function fetchBuffer( const request = net.request({ method: 'GET', - url, + // ipfs:// URIs are fetched through an HTTPS gateway when the user has + // enabled it — Electron's net stack cannot request the ipfs scheme, and + // with the option off toFetchableUrl refuses the fetch outright. + url: toFetchableUrl(url), headers, }); diff --git a/packages/gui/src/electron/utils/fetchJSON.ts b/packages/gui/src/electron/utils/fetchJSON.ts index 982c010fdb..df5465e527 100644 --- a/packages/gui/src/electron/utils/fetchJSON.ts +++ b/packages/gui/src/electron/utils/fetchJSON.ts @@ -1,5 +1,6 @@ import { net, IncomingMessage } from 'electron'; +import { toFetchableUrl } from './ipfsGateway'; import isValidURL from './isValidURL'; const DEFAULT_TIMEOUT = 10 * 60 * 1000; // 10 minutes @@ -17,7 +18,10 @@ export default async function fetchJSON( const request = net.request({ method, - url, + // ipfs:// URIs are fetched through an HTTPS gateway when the user has + // enabled it — Electron's net stack cannot request the ipfs scheme, and + // with the option off toFetchableUrl refuses the fetch outright. + url: toFetchableUrl(url), headers, }); diff --git a/packages/gui/src/electron/utils/ipfsGateway.test.ts b/packages/gui/src/electron/utils/ipfsGateway.test.ts new file mode 100644 index 0000000000..bcdf0b1cf4 --- /dev/null +++ b/packages/gui/src/electron/utils/ipfsGateway.test.ts @@ -0,0 +1,95 @@ +const mockReadPrefs = jest.fn, []>(); + +jest.mock('../prefs', () => ({ + readPrefs: mockReadPrefs, +})); + +const { + default: maybeIpfsToGatewayUrl, + ipfsGatewayEnabled, + toFetchableUrl, + IpfsGatewayDisabledError, + NFT_IPFS_GATEWAY_PREF, +} = jest.requireActual('./ipfsGateway'); + +describe('ipfsGatewayEnabled', () => { + beforeEach(() => { + mockReadPrefs.mockReset(); + }); + + it('is disabled when the preference has never been set', () => { + mockReadPrefs.mockReturnValue({}); + + expect(ipfsGatewayEnabled()).toBe(false); + }); + + it('is enabled only by an explicit boolean true', () => { + mockReadPrefs.mockReturnValue({ [NFT_IPFS_GATEWAY_PREF]: true }); + expect(ipfsGatewayEnabled()).toBe(true); + + mockReadPrefs.mockReturnValue({ [NFT_IPFS_GATEWAY_PREF]: 'true' }); + expect(ipfsGatewayEnabled()).toBe(false); + }); + + it('fails closed when the preferences store cannot be read', () => { + mockReadPrefs.mockImplementation(() => { + throw new Error('userDataDir needs to be initialized'); + }); + + expect(ipfsGatewayEnabled()).toBe(false); + }); +}); + +describe('maybeIpfsToGatewayUrl', () => { + beforeEach(() => { + mockReadPrefs.mockReset(); + }); + + it('never consults the preferences store for non-ipfs URLs', () => { + expect(maybeIpfsToGatewayUrl('https://example.com/image.png')).toBe('https://example.com/image.png'); + expect(mockReadPrefs).not.toHaveBeenCalled(); + }); + + it('leaves ipfs URIs untranslated while the gateway option is off', () => { + mockReadPrefs.mockReturnValue({}); + + expect(maybeIpfsToGatewayUrl('ipfs://QmPK1s3pNYLi9ERiq3BDxKa4XosgWwFRQUydHUtz4YgpqB')).toBe( + 'ipfs://QmPK1s3pNYLi9ERiq3BDxKa4XosgWwFRQUydHUtz4YgpqB', + ); + }); + + it('translates ipfs URIs when the gateway option is on', () => { + mockReadPrefs.mockReturnValue({ [NFT_IPFS_GATEWAY_PREF]: true }); + + expect(maybeIpfsToGatewayUrl('ipfs://QmPK1s3pNYLi9ERiq3BDxKa4XosgWwFRQUydHUtz4YgpqB/img.png')).toBe( + 'https://ipfs.io/ipfs/QmPK1s3pNYLi9ERiq3BDxKa4XosgWwFRQUydHUtz4YgpqB/img.png', + ); + }); +}); + +describe('toFetchableUrl', () => { + beforeEach(() => { + mockReadPrefs.mockReset(); + }); + + it('passes non-ipfs URLs through without consulting the preferences store', () => { + expect(toFetchableUrl('https://example.com/image.png')).toBe('https://example.com/image.png'); + expect(mockReadPrefs).not.toHaveBeenCalled(); + }); + + it('translates ipfs URIs when the gateway option is on', () => { + mockReadPrefs.mockReturnValue({ [NFT_IPFS_GATEWAY_PREF]: true }); + + expect(toFetchableUrl('ipfs://QmPK1s3pNYLi9ERiq3BDxKa4XosgWwFRQUydHUtz4YgpqB/img.png')).toBe( + 'https://ipfs.io/ipfs/QmPK1s3pNYLi9ERiq3BDxKa4XosgWwFRQUydHUtz4YgpqB/img.png', + ); + }); + + it('refuses ipfs URIs while the gateway option is off', () => { + mockReadPrefs.mockReturnValue({}); + + expect(() => toFetchableUrl('ipfs://QmPK1s3pNYLi9ERiq3BDxKa4XosgWwFRQUydHUtz4YgpqB')).toThrow( + IpfsGatewayDisabledError, + ); + }); +}); diff --git a/packages/gui/src/electron/utils/ipfsGateway.ts b/packages/gui/src/electron/utils/ipfsGateway.ts new file mode 100644 index 0000000000..8c06be7269 --- /dev/null +++ b/packages/gui/src/electron/utils/ipfsGateway.ts @@ -0,0 +1,56 @@ +import ipfsToGatewayUrl, { isIpfsUrl } from '../../util/ipfs'; +import { readPrefs } from '../prefs'; + +// Preference key shared with the renderer's useIpfsGateway hook. The renderer +// persists it through PreferencesAPI.SAVE into prefs.yaml, which is the copy +// consulted here in the main process. +export const NFT_IPFS_GATEWAY_PREF = 'nftIpfsGateway'; + +// Whether ipfs:// NFT resources may be fetched through the public HTTPS +// gateway. Off by default: the gateway URL is not the URI recorded on chain, +// so the translation is a user-selectable opt-in. Fails closed when the +// preferences store is unreadable (e.g. before userData is initialized). +export function ipfsGatewayEnabled(): boolean { + try { + return readPrefs()[NFT_IPFS_GATEWAY_PREF] === true; + } catch { + return false; + } +} + +// Translates an ipfs:// URI to its HTTPS gateway equivalent only when the +// user has enabled gateway fetching; every other URL — and every ipfs URI +// while the option is off — is returned unchanged, so this can wrap any URL +// right where it reaches the network layer. The ipfs check runs first so the +// hot non-ipfs paths never touch the preferences store. +export default function maybeIpfsToGatewayUrl(url: string): string { + if (!isIpfsUrl(url) || !ipfsGatewayEnabled()) { + return url; + } + + return ipfsToGatewayUrl(url); +} + +// Thrown instead of attempting a fetch that cannot happen: with the gateway +// option off there is no URL Electron's net stack could request for an +// ipfs:// URI. CacheManager treats this error as non-persistent — flipping +// the option on must retry cleanly, so it never poisons a cache entry. +export class IpfsGatewayDisabledError extends Error { + constructor() { + super('IPFS gateway fetching is disabled'); + this.name = 'IpfsGatewayDisabledError'; + } +} + +// The URL the network layer may actually request. The gateway option gates +// only fetching: structural URL validation and serving already-cached content +// stay independent of it, so every network call site funnels through here +// instead of checking the option itself. +export function toFetchableUrl(url: string): string { + const requestUrl = maybeIpfsToGatewayUrl(url); + if (isIpfsUrl(requestUrl)) { + throw new IpfsGatewayDisabledError(); + } + + return requestUrl; +} diff --git a/packages/gui/src/electron/utils/isValidURL.test.ts b/packages/gui/src/electron/utils/isValidURL.test.ts new file mode 100644 index 0000000000..d9e38a0259 --- /dev/null +++ b/packages/gui/src/electron/utils/isValidURL.test.ts @@ -0,0 +1,40 @@ +const mockReadPrefs = jest.fn, []>(); + +jest.mock('../prefs', () => ({ + readPrefs: mockReadPrefs, +})); + +const isValidURL = jest.requireActual('./isValidURL').default; + +describe('isValidURL', () => { + beforeEach(() => { + mockReadPrefs.mockReset(); + mockReadPrefs.mockReturnValue({}); + }); + + it('accepts https URLs', () => { + expect(isValidURL('https://example.com/image.png')).toBe(true); + }); + + it('requires the protocol and rejects non-https schemes', () => { + expect(isValidURL('example.com/image.png')).toBe(false); + expect(isValidURL('http://example.com/image.png')).toBe(false); + expect(isValidURL('ftp://example.com/image.png')).toBe(false); + }); + + it('accepts ipfs:// URIs with a CID host regardless of the gateway option', () => { + // validator's isURL rejects CID hosts (no TLD), so these pass only via + // the gateway-form translation. The check is structural on purpose: the + // gateway option gates fetching (toFetchableUrl), not validity — cache + // lookups for already-downloaded ipfs content must keep working while + // the option is off. + expect(isValidURL('ipfs://bafybeiceg2gltyhlkukwetn26k7t2zdvthg4u4c6uj23rpni2adzgvo5si/020.png')).toBe(true); + expect(isValidURL('ipfs://ipfs/QmPK1s3pNYLi9ERiq3BDxKa4XosgWwFRQUydHUtz4YgpqB')).toBe(true); + expect(mockReadPrefs).not.toHaveBeenCalled(); + }); + + it('rejects a bare ipfs scheme and non-strings', () => { + expect(isValidURL('ipfs://')).toBe(false); + expect(isValidURL(undefined as unknown as string)).toBe(false); + }); +}); diff --git a/packages/gui/src/electron/utils/isValidURL.ts b/packages/gui/src/electron/utils/isValidURL.ts index 53153a77e4..85ba088331 100644 --- a/packages/gui/src/electron/utils/isValidURL.ts +++ b/packages/gui/src/electron/utils/isValidURL.ts @@ -1,9 +1,22 @@ import isURL from 'validator/lib/isURL'; +import ipfsToGatewayUrl, { isIpfsUrl } from '../../util/ipfs'; + +// Structural validation only — deliberately independent of the IPFS gateway +// preference. CacheManager consults this check before every cache path +// lookup, so tying it to the preference would strand content that was +// downloaded and hash-verified while the option was on: the cached bytes +// could no longer be served, checksummed, or evicted after switching it off, +// even though serving a local file involves no gateway request. Whether an +// ipfs URI may actually be FETCHED is decided at the network call sites via +// toFetchableUrl (electron/utils/ipfsGateway.ts). export default function isValidURL(url: string) { if (typeof url !== 'string') { return false; } - return isURL(url, { protocols: ['https', 'ipfs'], require_protocol: true }); + // isURL applies an FQDN check to the host, which every ipfs:// URI + // fails (a CID has no top-level domain), so listing 'ipfs' as an allowed + // protocol is not enough — validate the HTTPS gateway form instead. + return isURL(isIpfsUrl(url) ? ipfsToGatewayUrl(url) : url, { protocols: ['https'], require_protocol: true }); } diff --git a/packages/gui/src/hooks/useIpfsGateway.ts b/packages/gui/src/hooks/useIpfsGateway.ts new file mode 100644 index 0000000000..fcdbaed063 --- /dev/null +++ b/packages/gui/src/hooks/useIpfsGateway.ts @@ -0,0 +1,13 @@ +import { usePrefs } from '@chia-network/api-react'; + +// When enabled, NFT resources published with ipfs:// URIs are fetched through +// the public HTTPS gateway; the requested URL then differs from the URI +// recorded on chain, which is why this is a user-selectable opt-in (off by +// default — ipfs:// resources are simply not fetched). Downloaded content is +// still verified against the NFT's on-chain hash either way. The main process +// reads the persisted copy of this preference at every network call site +// (electron/utils/ipfsGateway.ts); keep the key in sync with +// NFT_IPFS_GATEWAY_PREF there. +export default function useIpfsGateway() { + return usePrefs('nftIpfsGateway', false); +} diff --git a/packages/gui/src/hooks/useNFTVerifyHash.ts b/packages/gui/src/hooks/useNFTVerifyHash.ts index 4d54602a1b..4d2790a6dc 100644 --- a/packages/gui/src/hooks/useNFTVerifyHash.ts +++ b/packages/gui/src/hooks/useNFTVerifyHash.ts @@ -7,6 +7,7 @@ import compareChecksums from '../util/compareChecksums'; import selectNFTPreviewState, { type NFTPreviewState } from './selectNFTPreviewState'; import useCache from './useCache'; +import useIpfsGateway from './useIpfsGateway'; import useNFT from './useNFT'; import useNFTMetadata from './useNFTMetadata'; @@ -21,6 +22,11 @@ export default function useNFTVerifyHash(nftId?: string, options: UseNFTVerifyHa const { preview = false, ignoreSizeLimit = false } = options; const { getChecksum } = useCache(); + // Not read directly: the value changes which URIs the main process will + // fetch at all, so both verification effects list it as a dependency and + // re-run when the user flips the option — without this, NFTs already on + // screen would keep their failed state until a remount. + const [ipfsGateway] = useIpfsGateway(); const { nft, isLoading: isLoadingNFT, error: errorNFT } = useNFT(nftId); const { isLoading: isLoadingMetadata, metadata, error: errorMetadata } = useNFTMetadata(nftId); @@ -201,7 +207,7 @@ export default function useNFTVerifyHash(nftId?: string, options: UseNFTVerifyHa dataGeneration.current += 1; } }; - }, [nft, isLoadingNFT, validateData]); + }, [nft, isLoadingNFT, validateData, ipfsGateway]); useEffect(() => { const generation = previewGeneration.current + 1; @@ -227,7 +233,7 @@ export default function useNFTVerifyHash(nftId?: string, options: UseNFTVerifyHa previewGeneration.current += 1; } }; - }, [preview, nft, metadata, isLoadingNFT, isLoadingMetadata, validatePreview]); + }, [preview, nft, metadata, isLoadingNFT, isLoadingMetadata, validatePreview, ipfsGateway]); const previewState = useMemo( () => diff --git a/packages/gui/src/util/ipfs.test.ts b/packages/gui/src/util/ipfs.test.ts new file mode 100644 index 0000000000..a802405544 --- /dev/null +++ b/packages/gui/src/util/ipfs.test.ts @@ -0,0 +1,53 @@ +import ipfsToGatewayUrl, { IPFS_GATEWAY_BASE, getIpfsPath, isIpfsUrl } from './ipfs'; + +// CID taken from a real mainnet NFT whose on-chain data URI is ipfs:// +const CID_V1 = 'bafybeiceg2gltyhlkukwetn26k7t2zdvthg4u4c6uj23rpni2adzgvo5si'; +const CID_V0 = 'QmPK1s3pNYLi9ERiq3BDxKa4XosgWwFRQUydHUtz4YgpqB'; + +describe('isIpfsUrl', () => { + it('matches the ipfs scheme case-insensitively', () => { + expect(isIpfsUrl(`ipfs://${CID_V1}/020.png`)).toBe(true); + expect(isIpfsUrl(`IPFS://${CID_V1}`)).toBe(true); + }); + + it('does not match other schemes', () => { + expect(isIpfsUrl(`https://ipfs.io/ipfs/${CID_V1}`)).toBe(false); + expect(isIpfsUrl('')).toBe(false); + }); +}); + +describe('getIpfsPath', () => { + it('returns the CID and path', () => { + expect(getIpfsPath(`ipfs://${CID_V1}/020.png`)).toBe(`${CID_V1}/020.png`); + expect(getIpfsPath(`ipfs://${CID_V1}`)).toBe(CID_V1); + }); + + it('strips the redundant ipfs/ prefix some minting tools produce', () => { + expect(getIpfsPath(`ipfs://ipfs/${CID_V1}/020.png`)).toBe(`${CID_V1}/020.png`); + }); + + it('preserves the case of CIDv0 base58 hashes', () => { + expect(getIpfsPath(`ipfs://${CID_V0}/image.png`)).toBe(`${CID_V0}/image.png`); + }); + + it('returns undefined for non-ipfs URLs and a bare scheme', () => { + expect(getIpfsPath(`https://example.com/${CID_V1}`)).toBeUndefined(); + expect(getIpfsPath('ipfs://')).toBeUndefined(); + }); +}); + +describe('ipfsToGatewayUrl', () => { + it('translates ipfs:// URIs to the HTTPS gateway', () => { + expect(ipfsToGatewayUrl(`ipfs://${CID_V1}/020.png`)).toBe(`${IPFS_GATEWAY_BASE}${CID_V1}/020.png`); + expect(ipfsToGatewayUrl(`ipfs://ipfs/${CID_V1}`)).toBe(`${IPFS_GATEWAY_BASE}${CID_V1}`); + }); + + it('returns non-ipfs URLs unchanged', () => { + const url = 'https://example.com/image.png?size=large'; + expect(ipfsToGatewayUrl(url)).toBe(url); + }); + + it('returns an unusable bare scheme unchanged so validation rejects it', () => { + expect(ipfsToGatewayUrl('ipfs://')).toBe('ipfs://'); + }); +}); diff --git a/packages/gui/src/util/ipfs.ts b/packages/gui/src/util/ipfs.ts new file mode 100644 index 0000000000..e58a5e1304 --- /dev/null +++ b/packages/gui/src/util/ipfs.ts @@ -0,0 +1,33 @@ +// The public HTTPS gateway used to serve ipfs:// resources. Electron's net +// stack has no IPFS support, so ipfs:// URIs are fetched through a gateway. +// The gateway does not need to be trusted for integrity: everything the cache +// serves is checked against the NFT's on-chain hash before it is shown. +export const IPFS_GATEWAY_BASE = 'https://ipfs.io/ipfs/'; + +const IPFS_SCHEME = /^ipfs:\/\//i; + +export function isIpfsUrl(url: string): boolean { + return typeof url === 'string' && IPFS_SCHEME.test(url); +} + +// Returns the `[/path]` part of an ipfs:// URI, tolerating the redundant +// `ipfs://ipfs/` form produced by some minting tools. CIDv0 hashes are +// case-sensitive base58, so the value is never case-normalized. +export function getIpfsPath(url: string): string | undefined { + if (!isIpfsUrl(url)) { + return undefined; + } + + const ipfsPath = url.replace(IPFS_SCHEME, '').replace(/^ipfs\//i, ''); + + return ipfsPath.length > 0 ? ipfsPath : undefined; +} + +// Translates an ipfs:// URI to its HTTPS gateway equivalent. Anything else +// (including an unusable bare `ipfs://`) is returned unchanged, so this can +// wrap any URL right where it reaches the network layer. +export default function ipfsToGatewayUrl(url: string): string { + const ipfsPath = getIpfsPath(url); + + return ipfsPath === undefined ? url : `${IPFS_GATEWAY_BASE}${ipfsPath}`; +}