Skip to content

Commit 7d3f7a9

Browse files
jlobue10claude
andcommitted
Make IPFS gateway fetching a user-selectable option
Review feedback on the gateway feature: the gateway URL is technically not the URI recorded on chain, so translating ipfs:// URIs to https://ipfs.io/ipfs/... should be something the user opts into rather than automatic behavior. - New 'Fetch IPFS content through a gateway' switch in Settings > NFT, off by default. While off, ipfs:// URIs behave as before the gateway feature: they fail URL validation and are never fetched, and the NFTHashStatus badge flags them again. - The preference is stored as nftIpfsGateway via the existing prefs.yaml round-trip; the main process reads the persisted value through electron/utils/ipfsGateway.ts (fail-closed when the store is unreadable) at every site that translated URLs: isValidURL, downloadFile, fetchBuffer, fetchJSON, the single-NFT download handler, and the dapp dialog's oversized-image fallback. The ipfs scheme check runs before the preference read so non-ipfs requests never touch the store. - The oversized-image fallback returns no preview (instead of a CSP-blocked raw ipfs URI) while the option is off. - Nothing is persisted for a rejected ipfs URL (the outer isValidURL guard throws before the cache-info error sidecar is written), so enabling the option retries previously failing NFTs cleanly. - util/ipfs.ts stays a pure translation helper shared with the renderer; the preference gate lives only in the electron layer and the useIpfsGateway hook. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LdfCqRSBWwMpCDh1SdE24e
1 parent 581682b commit 7d3f7a9

13 files changed

Lines changed: 238 additions & 32 deletions

File tree

packages/gui/src/components/nfts/NFTHashStatus.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Chip, Typography } from '@mui/material';
66
import CircularProgress from '@mui/material/CircularProgress';
77
import React, { useMemo } from 'react';
88

9+
import useIpfsGateway from '../../hooks/useIpfsGateway';
910
import useNFT from '../../hooks/useNFT';
1011
import useNFTVerifyHash from '../../hooks/useNFTVerifyHash';
1112
import ipfsToGatewayUrl from '../../util/ipfs';
@@ -28,6 +29,7 @@ export default function NFTHashStatus(props: NFTHashStatusProps) {
2829
});
2930

3031
const { nft, isLoading: isLoadingNFT, error: errorNFT } = useNFT(nftId);
32+
const [ipfsGateway] = useIpfsGateway();
3133

3234
const isLoading = isLoadingNFTVerifyHash || isLoadingNFT;
3335
const isVerified = preview ? nftPreview?.isVerified : data?.isVerified;
@@ -40,13 +42,15 @@ export default function NFTHashStatus(props: NFTHashStatusProps) {
4042
}
4143

4244
if (nftPreview.uri) {
43-
// ipfs:// URIs are served through an HTTPS gateway by the cache layer,
44-
// so validate the gateway form instead of flagging them as invalid.
45-
return isValidURL(ipfsToGatewayUrl(nftPreview.uri));
45+
// While the user has IPFS gateway fetching enabled, ipfs:// URIs are
46+
// served through an HTTPS gateway by the cache layer, so validate the
47+
// gateway form instead of flagging them as invalid. With the option
48+
// off they are not fetchable and stay flagged.
49+
return isValidURL(ipfsGateway ? ipfsToGatewayUrl(nftPreview.uri) : nftPreview.uri);
4650
}
4751

4852
return false;
49-
}, [nftPreview]);
53+
}, [nftPreview, ipfsGateway]);
5054

5155
const icon = useMemo(() => {
5256
if (hideIcon) {

packages/gui/src/components/settings/SettingsNFT.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import React from 'react';
1717
import useAllowUnverifiedNFTPreviews from '../../hooks/useAllowUnverifiedNFTPreviews';
1818
import useCache from '../../hooks/useCache';
1919
import useHideObjectionableContent from '../../hooks/useHideObjectionableContent';
20+
import useIpfsGateway from '../../hooks/useIpfsGateway';
2021
import useNFTImageFittingMode from '../../hooks/useNFTImageFittingMode';
2122
import { useNFTVideoLoopGlobal } from '../../hooks/useNFTVideoLoop';
2223

@@ -41,6 +42,7 @@ export default function SettingsGeneral() {
4142
const { cacheSize, clearCache, cacheDirectory, setCacheDirectory } = useCache();
4243
const [nftImageFittingMode, setNFTImageFittingMode] = useNFTImageFittingMode();
4344
const [nftVideoLoop, setNFTVideoLoop] = useNFTVideoLoopGlobal();
45+
const [ipfsGateway, setIpfsGateway] = useIpfsGateway();
4446
const [allowUnverifiedPreviews, setAllowUnverifiedPreviews] = useAllowUnverifiedNFTPreviews();
4547
// const [, setCacheFolder] = usePrefs('cacheFolder', '');
4648
const openDialog = useOpenDialog();
@@ -53,6 +55,10 @@ export default function SettingsGeneral() {
5355
setNFTVideoLoop(event.target.checked);
5456
}
5557

58+
function handleChangeIpfsGateway(event: React.ChangeEvent<HTMLInputElement>) {
59+
setIpfsGateway(event.target.checked);
60+
}
61+
5662
function handleChangeAllowUnverifiedPreviews(event: React.ChangeEvent<HTMLInputElement>) {
5763
setAllowUnverifiedPreviews(event.target.checked);
5864
}
@@ -150,6 +156,26 @@ export default function SettingsGeneral() {
150156
</Grid>
151157
</Grid>
152158

159+
<Grid container>
160+
<Grid item style={{ width: '400px' }}>
161+
<SettingsTitle>
162+
<Trans>Fetch IPFS content through a gateway</Trans>
163+
</SettingsTitle>
164+
</Grid>
165+
<Grid item container xs justifyContent="flex-end" marginTop="-6px">
166+
<FormControlLabel control={<Switch checked={ipfsGateway} onChange={handleChangeIpfsGateway} />} />
167+
</Grid>
168+
<Grid item style={{ width: '400px' }}>
169+
<SettingsText>
170+
<Trans>
171+
NFT files published with ipfs:// addresses will be downloaded through the public ipfs.io HTTPS gateway.
172+
The requested URL differs from the address recorded on chain, but downloaded content is still verified
173+
against the NFT's on-chain hash. When disabled, ipfs:// files are not fetched.
174+
</Trans>
175+
</SettingsText>
176+
</Grid>
177+
</Grid>
178+
153179
<Grid container>
154180
<Grid item style={{ width: '400px' }}>
155181
<SettingsTitle>

packages/gui/src/electron/api/nftGetMetadata.test.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ jest.mock('../utils/fetchBuffer', () => ({
1111
jest.requireActual<typeof import('../utils/fetchBuffer')>('../utils/fetchBuffer').MaxSizeExceededError,
1212
}));
1313

14+
const mockMaybeIpfsToGatewayUrl = jest.fn<string, [string]>();
15+
16+
jest.mock('../utils/ipfsGateway', () => ({
17+
__esModule: true,
18+
default: mockMaybeIpfsToGatewayUrl,
19+
}));
20+
1421
const mockAllowUnverifiedNftPreviews = jest.fn<boolean, []>();
1522

1623
jest.mock('../utils/allowUnverifiedNftPreviews', () => ({
@@ -20,6 +27,8 @@ jest.mock('../utils/allowUnverifiedNftPreviews', () => ({
2027

2128
const { MaxSizeExceededError } = jest.requireActual<typeof import('../utils/fetchBuffer')>('../utils/fetchBuffer');
2229

30+
const ipfsToGatewayUrl = jest.requireActual<typeof import('../../util/ipfs')>('../../util/ipfs').default;
31+
2332
const { nftGetImageDataUrl, nftGetMetadata } =
2433
jest.requireActual<typeof import('./nftGetMetadata')>('./nftGetMetadata');
2534

@@ -66,6 +75,9 @@ describe('nftGetMetadata', () => {
6675
describe('nftGetImageDataUrl', () => {
6776
beforeEach(() => {
6877
mockFetchBuffer.mockReset();
78+
mockMaybeIpfsToGatewayUrl.mockReset();
79+
// gateway option off: URLs pass through untranslated
80+
mockMaybeIpfsToGatewayUrl.mockImplementation((url) => url);
6981
mockAllowUnverifiedNftPreviews.mockReset();
7082
mockAllowUnverifiedNftPreviews.mockReturnValue(false);
7183
});
@@ -124,8 +136,9 @@ describe('nftGetImageDataUrl', () => {
124136
);
125137
});
126138

127-
it('falls back to the gateway URL for an oversized ipfs image when unverified previews are enabled', async () => {
139+
it('falls back to the gateway URL for an oversized ipfs image when both options are on', async () => {
128140
mockAllowUnverifiedNftPreviews.mockReturnValue(true);
141+
mockMaybeIpfsToGatewayUrl.mockImplementation(ipfsToGatewayUrl);
129142
mockFetchBuffer.mockRejectedValue(
130143
new MaxSizeExceededError({
131144
'content-type': 'image/gif',
@@ -139,6 +152,31 @@ describe('nftGetImageDataUrl', () => {
139152
);
140153
});
141154

155+
it('omits the preview for an oversized ipfs image while the gateway option is off', async () => {
156+
mockAllowUnverifiedNftPreviews.mockReturnValue(true);
157+
mockFetchBuffer.mockRejectedValue(
158+
new MaxSizeExceededError({
159+
'content-type': 'image/gif',
160+
}),
161+
);
162+
163+
// an untranslated ipfs URI would be blocked by the dialog CSP, so no
164+
// preview is returned at all even though unverified previews are allowed
165+
await expect(nftGetImageDataUrl('ipfs://bafybeigdyrztest/large.gif', '00')).resolves.toBeUndefined();
166+
});
167+
168+
it('omits the preview for an oversized ipfs image while unverified previews are off', async () => {
169+
mockMaybeIpfsToGatewayUrl.mockImplementation(ipfsToGatewayUrl);
170+
mockFetchBuffer.mockRejectedValue(
171+
new MaxSizeExceededError({
172+
'content-type': 'image/gif',
173+
}),
174+
);
175+
176+
// the gateway option alone does not opt into unverified fallbacks
177+
await expect(nftGetImageDataUrl('ipfs://bafybeigdyrztest/large.gif', '00')).resolves.toBeUndefined();
178+
});
179+
142180
it('rejects an oversized response that is not an image even when unverified previews are enabled', async () => {
143181
mockAllowUnverifiedNftPreviews.mockReturnValue(true);
144182
mockFetchBuffer.mockRejectedValue(

packages/gui/src/electron/api/nftGetMetadata.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import crypto from 'node:crypto';
22

33
import type Headers from '../../@types/Headers';
44
import compareChecksums from '../../util/compareChecksums';
5-
import ipfsToGatewayUrl from '../../util/ipfs';
5+
import { isIpfsUrl } from '../../util/ipfs';
66
import allowUnverifiedNftPreviews from '../utils/allowUnverifiedNftPreviews';
77
import fetchBuffer, { MaxSizeExceededError } from '../utils/fetchBuffer';
8+
import maybeIpfsToGatewayUrl from '../utils/ipfsGateway';
89

910
const METADATA_TIMEOUT = 10_000;
1011
const METADATA_MAX_SIZE = 5 * 1024 * 1024;
@@ -105,9 +106,11 @@ export async function nftGetImageDataUrl(
105106
// claims are attacker-controlled, so the fallback can be triggered
106107
// deliberately to place unverified content in a confirmation dialog.
107108
// The CSP does not allow the ipfs: scheme either, so ipfs URIs fall back
108-
// to their gateway form.
109+
// to their gateway form, and only when the user has also enabled the
110+
// gateway — otherwise they get no preview rather than a CSP-blocked URL.
109111
if (error instanceof MaxSizeExceededError && getImageContentType(error.headers) && allowUnverifiedNftPreviews()) {
110-
return ipfsToGatewayUrl(imageUri);
112+
const directUrl = maybeIpfsToGatewayUrl(imageUri);
113+
return isIpfsUrl(directUrl) ? undefined : directUrl;
111114
}
112115

113116
// image previews are best effort — the confirmation dialog has a fallback

packages/gui/src/electron/main.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ import type { PermissionsNotificationPayload } from '../@types/PermissionsServic
2929
import { WcError, WcErrorCode, encodeWcErrorForIpc } from '../@types/WcError';
3030
import AppIcon from '../assets/img/chia64x64.png';
3131
import { i18n } from '../config/locales';
32-
import ipfsToGatewayUrl from '../util/ipfs';
3332

3433
import CacheManager, { CACHE_PROTOCOL } from './CacheManager';
3534
import { checkNFTOwnership } from './api/checkNFTOwnership';
@@ -62,6 +61,7 @@ import { dispatchPairRequest } from './utils/dispatchPairRequest';
6261
import downloadFile from './utils/downloadFile';
6362
import fetchJSON from './utils/fetchJSON';
6463
import ipcMainHandle from './utils/ipcMainHandle';
64+
import maybeIpfsToGatewayUrl from './utils/ipfsGateway';
6565
import isValidURL from './utils/isValidURL';
6666
import { loadConfig, checkConfigFileExists } from './utils/loadConfig';
6767
import { getDefaultLogPath, LogPathValidationError, resolveTrustedLogPath } from './utils/logPath';
@@ -576,9 +576,10 @@ if (ensureSingleInstance() && ensureCorrectEnvironment()) {
576576
return;
577577
}
578578

579-
// Chromium's downloader cannot fetch the ipfs: scheme; download ipfs
580-
// URIs through the HTTPS gateway like every other network path.
581-
mainWindow.webContents.downloadURL(ipfsToGatewayUrl(urlLocal));
579+
// Chromium's downloader cannot fetch the ipfs: scheme; when the user
580+
// has enabled the gateway, download ipfs URIs through it like every
581+
// other network path.
582+
mainWindow.webContents.downloadURL(maybeIpfsToGatewayUrl(urlLocal));
582583
});
583584

584585
ipcMainHandle(AppAPI.START_MULTIPLE_DOWNLOAD, async (tasks: { url: string; filename: string }[]) => {

packages/gui/src/electron/utils/downloadFile.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import { promises as fs, createWriteStream, type WriteStream } from 'node:fs';
44
import debug from 'debug';
55

66
import type Headers from '../../@types/Headers';
7-
import ipfsToGatewayUrl from '../../util/ipfs';
87

98
import fileExists from './fileExists';
9+
import maybeIpfsToGatewayUrl from './ipfsGateway';
1010
import isValidURL from './isValidURL';
1111

1212
const log = debug('chia-gui:downloadFile');
@@ -101,10 +101,11 @@ export default async function downloadFile(
101101
}
102102

103103
const tempFilePath = `${localPath}.tmp`;
104-
// ipfs:// URIs are fetched through an HTTPS gateway — Electron's net stack
105-
// cannot request the ipfs scheme. Only this outgoing request uses the
106-
// translated URL; callers keep the original URI as the cache key.
107-
const request = net.request(ipfsToGatewayUrl(url));
104+
// ipfs:// URIs are fetched through an HTTPS gateway when the user has
105+
// enabled it — Electron's net stack cannot request the ipfs scheme. Only
106+
// this outgoing request uses the translated URL; callers keep the original
107+
// URI as the cache key.
108+
const request = net.request(maybeIpfsToGatewayUrl(url));
108109
const outputStream = new WriteStreamPromise(tempFilePath, overrideFile);
109110

110111
// set when we abort the request ourselves, so abort events can be reported

packages/gui/src/electron/utils/fetchBuffer.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { net, type IncomingMessage } from 'electron';
22

33
import type Headers from '../../@types/Headers';
4-
import ipfsToGatewayUrl from '../../util/ipfs';
54

5+
import maybeIpfsToGatewayUrl from './ipfsGateway';
66
import isValidURL from './isValidURL';
77

88
const DEFAULT_TIMEOUT = 10 * 60 * 1000; // 10 minutes
@@ -42,9 +42,9 @@ export default async function fetchBuffer(
4242

4343
const request = net.request({
4444
method: 'GET',
45-
// ipfs:// URIs are fetched through an HTTPS gateway — Electron's net
46-
// stack cannot request the ipfs scheme.
47-
url: ipfsToGatewayUrl(url),
45+
// ipfs:// URIs are fetched through an HTTPS gateway when the user has
46+
// enabled it — Electron's net stack cannot request the ipfs scheme.
47+
url: maybeIpfsToGatewayUrl(url),
4848
headers,
4949
});
5050

packages/gui/src/electron/utils/fetchJSON.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { net, IncomingMessage } from 'electron';
22

3-
import ipfsToGatewayUrl from '../../util/ipfs';
4-
3+
import maybeIpfsToGatewayUrl from './ipfsGateway';
54
import isValidURL from './isValidURL';
65

76
const DEFAULT_TIMEOUT = 10 * 60 * 1000; // 10 minutes
@@ -19,9 +18,9 @@ export default async function fetchJSON<TData>(
1918

2019
const request = net.request({
2120
method,
22-
// ipfs:// URIs are fetched through an HTTPS gateway — Electron's net
23-
// stack cannot request the ipfs scheme.
24-
url: ipfsToGatewayUrl(url),
21+
// ipfs:// URIs are fetched through an HTTPS gateway when the user has
22+
// enabled it — Electron's net stack cannot request the ipfs scheme.
23+
url: maybeIpfsToGatewayUrl(url),
2524
headers,
2625
});
2726

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
const mockReadPrefs = jest.fn<Record<string, any>, []>();
2+
3+
jest.mock('../prefs', () => ({
4+
readPrefs: mockReadPrefs,
5+
}));
6+
7+
const {
8+
default: maybeIpfsToGatewayUrl,
9+
ipfsGatewayEnabled,
10+
NFT_IPFS_GATEWAY_PREF,
11+
} = jest.requireActual<typeof import('./ipfsGateway')>('./ipfsGateway');
12+
13+
describe('ipfsGatewayEnabled', () => {
14+
beforeEach(() => {
15+
mockReadPrefs.mockReset();
16+
});
17+
18+
it('is disabled when the preference has never been set', () => {
19+
mockReadPrefs.mockReturnValue({});
20+
21+
expect(ipfsGatewayEnabled()).toBe(false);
22+
});
23+
24+
it('is enabled only by an explicit boolean true', () => {
25+
mockReadPrefs.mockReturnValue({ [NFT_IPFS_GATEWAY_PREF]: true });
26+
expect(ipfsGatewayEnabled()).toBe(true);
27+
28+
mockReadPrefs.mockReturnValue({ [NFT_IPFS_GATEWAY_PREF]: 'true' });
29+
expect(ipfsGatewayEnabled()).toBe(false);
30+
});
31+
32+
it('fails closed when the preferences store cannot be read', () => {
33+
mockReadPrefs.mockImplementation(() => {
34+
throw new Error('userDataDir needs to be initialized');
35+
});
36+
37+
expect(ipfsGatewayEnabled()).toBe(false);
38+
});
39+
});
40+
41+
describe('maybeIpfsToGatewayUrl', () => {
42+
beforeEach(() => {
43+
mockReadPrefs.mockReset();
44+
});
45+
46+
it('never consults the preferences store for non-ipfs URLs', () => {
47+
expect(maybeIpfsToGatewayUrl('https://example.com/image.png')).toBe('https://example.com/image.png');
48+
expect(mockReadPrefs).not.toHaveBeenCalled();
49+
});
50+
51+
it('leaves ipfs URIs untranslated while the gateway option is off', () => {
52+
mockReadPrefs.mockReturnValue({});
53+
54+
expect(maybeIpfsToGatewayUrl('ipfs://QmPK1s3pNYLi9ERiq3BDxKa4XosgWwFRQUydHUtz4YgpqB')).toBe(
55+
'ipfs://QmPK1s3pNYLi9ERiq3BDxKa4XosgWwFRQUydHUtz4YgpqB',
56+
);
57+
});
58+
59+
it('translates ipfs URIs when the gateway option is on', () => {
60+
mockReadPrefs.mockReturnValue({ [NFT_IPFS_GATEWAY_PREF]: true });
61+
62+
expect(maybeIpfsToGatewayUrl('ipfs://QmPK1s3pNYLi9ERiq3BDxKa4XosgWwFRQUydHUtz4YgpqB/img.png')).toBe(
63+
'https://ipfs.io/ipfs/QmPK1s3pNYLi9ERiq3BDxKa4XosgWwFRQUydHUtz4YgpqB/img.png',
64+
);
65+
});
66+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import ipfsToGatewayUrl, { isIpfsUrl } from '../../util/ipfs';
2+
import { readPrefs } from '../prefs';
3+
4+
// Preference key shared with the renderer's useIpfsGateway hook. The renderer
5+
// persists it through PreferencesAPI.SAVE into prefs.yaml, which is the copy
6+
// consulted here in the main process.
7+
export const NFT_IPFS_GATEWAY_PREF = 'nftIpfsGateway';
8+
9+
// Whether ipfs:// NFT resources may be fetched through the public HTTPS
10+
// gateway. Off by default: the gateway URL is not the URI recorded on chain,
11+
// so the translation is a user-selectable opt-in. Fails closed when the
12+
// preferences store is unreadable (e.g. before userData is initialized).
13+
export function ipfsGatewayEnabled(): boolean {
14+
try {
15+
return readPrefs()[NFT_IPFS_GATEWAY_PREF] === true;
16+
} catch {
17+
return false;
18+
}
19+
}
20+
21+
// Translates an ipfs:// URI to its HTTPS gateway equivalent only when the
22+
// user has enabled gateway fetching; every other URL — and every ipfs URI
23+
// while the option is off — is returned unchanged, so this can wrap any URL
24+
// right where it reaches the network layer. The ipfs check runs first so the
25+
// hot non-ipfs paths never touch the preferences store.
26+
export default function maybeIpfsToGatewayUrl(url: string): string {
27+
if (!isIpfsUrl(url) || !ipfsGatewayEnabled()) {
28+
return url;
29+
}
30+
31+
return ipfsToGatewayUrl(url);
32+
}

0 commit comments

Comments
 (0)