Skip to content

Commit 20da0e6

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 a50015a commit 20da0e6

13 files changed

Lines changed: 225 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
@@ -16,6 +16,7 @@ import React from 'react';
1616

1717
import useCache from '../../hooks/useCache';
1818
import useHideObjectionableContent from '../../hooks/useHideObjectionableContent';
19+
import useIpfsGateway from '../../hooks/useIpfsGateway';
1920
import useNFTImageFittingMode from '../../hooks/useNFTImageFittingMode';
2021
import { useNFTVideoLoopGlobal } from '../../hooks/useNFTVideoLoop';
2122

@@ -40,6 +41,7 @@ export default function SettingsGeneral() {
4041
const { cacheSize, clearCache, cacheDirectory, setCacheDirectory } = useCache();
4142
const [nftImageFittingMode, setNFTImageFittingMode] = useNFTImageFittingMode();
4243
const [nftVideoLoop, setNFTVideoLoop] = useNFTVideoLoopGlobal();
44+
const [ipfsGateway, setIpfsGateway] = useIpfsGateway();
4345
// const [, setCacheFolder] = usePrefs('cacheFolder', '');
4446
const openDialog = useOpenDialog();
4547

@@ -51,6 +53,10 @@ export default function SettingsGeneral() {
5153
setNFTVideoLoop(event.target.checked);
5254
}
5355

56+
function handleChangeIpfsGateway(event: React.ChangeEvent<HTMLInputElement>) {
57+
setIpfsGateway(event.target.checked);
58+
}
59+
5460
async function clearNFTCache() {
5561
openDialog(
5662
<ConfirmDialog
@@ -144,6 +150,26 @@ export default function SettingsGeneral() {
144150
</Grid>
145151
</Grid>
146152

153+
<Grid container>
154+
<Grid item style={{ width: '400px' }}>
155+
<SettingsTitle>
156+
<Trans>Fetch IPFS content through a gateway</Trans>
157+
</SettingsTitle>
158+
</Grid>
159+
<Grid item container xs justifyContent="flex-end" marginTop="-6px">
160+
<FormControlLabel control={<Switch checked={ipfsGateway} onChange={handleChangeIpfsGateway} />} />
161+
</Grid>
162+
<Grid item style={{ width: '400px' }}>
163+
<SettingsText>
164+
<Trans>
165+
NFT files published with ipfs:// addresses will be downloaded through the public ipfs.io HTTPS gateway.
166+
The requested URL differs from the address recorded on chain, but downloaded content is still verified
167+
against the NFT's on-chain hash. When disabled, ipfs:// files are not fetched.
168+
</Trans>
169+
</SettingsText>
170+
</Grid>
171+
</Grid>
172+
147173
<Grid item style={{ maxWidth: '400px' }}>
148174
<Flex flexDirection="column" gap={1}>
149175
<SettingsSection>

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

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,17 @@ 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 { MaxSizeExceededError } = jest.requireActual<typeof import('../utils/fetchBuffer')>('../utils/fetchBuffer');
1522

23+
const ipfsToGatewayUrl = jest.requireActual<typeof import('../../util/ipfs')>('../../util/ipfs').default;
24+
1625
const { nftGetImageDataUrl, nftGetMetadata } =
1726
jest.requireActual<typeof import('./nftGetMetadata')>('./nftGetMetadata');
1827

@@ -59,6 +68,9 @@ describe('nftGetMetadata', () => {
5968
describe('nftGetImageDataUrl', () => {
6069
beforeEach(() => {
6170
mockFetchBuffer.mockReset();
71+
mockMaybeIpfsToGatewayUrl.mockReset();
72+
// gateway option off: URLs pass through untranslated
73+
mockMaybeIpfsToGatewayUrl.mockImplementation((url) => url);
6274
});
6375

6476
it('returns an immutable data URL for a verified image response', async () => {
@@ -104,7 +116,8 @@ describe('nftGetImageDataUrl', () => {
104116
);
105117
});
106118

107-
it('falls back to the gateway URL when an oversized image has an ipfs URI', async () => {
119+
it('falls back to the gateway URL for an oversized ipfs image when the gateway option is on', async () => {
120+
mockMaybeIpfsToGatewayUrl.mockImplementation(ipfsToGatewayUrl);
108121
mockFetchBuffer.mockRejectedValue(
109122
new MaxSizeExceededError({
110123
'content-type': 'image/gif',
@@ -118,6 +131,18 @@ describe('nftGetImageDataUrl', () => {
118131
);
119132
});
120133

134+
it('omits the preview for an oversized ipfs image while the gateway option is off', async () => {
135+
mockFetchBuffer.mockRejectedValue(
136+
new MaxSizeExceededError({
137+
'content-type': 'image/gif',
138+
}),
139+
);
140+
141+
// an untranslated ipfs URI would be blocked by the dialog CSP, so no
142+
// preview is returned at all
143+
await expect(nftGetImageDataUrl('ipfs://bafybeigdyrztest/large.gif', '00')).resolves.toBeUndefined();
144+
});
145+
121146
it('rejects an oversized response that is not an image', async () => {
122147
mockFetchBuffer.mockRejectedValue(
123148
new MaxSizeExceededError({

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ 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 fetchBuffer, { MaxSizeExceededError } from '../utils/fetchBuffer';
7+
import maybeIpfsToGatewayUrl from '../utils/ipfsGateway';
78

89
const METADATA_TIMEOUT = 10_000;
910
const METADATA_MAX_SIZE = 5 * 1024 * 1024;
@@ -101,9 +102,11 @@ export async function nftGetImageDataUrl(
101102
// buffering. Fall back to the direct URL — the dialog CSP still allows
102103
// https: images, matching the pre-verification behavior for these files.
103104
// The CSP does not allow the ipfs: scheme, so ipfs URIs fall back to
104-
// their gateway form.
105+
// their gateway form, and only when the user has enabled the gateway —
106+
// otherwise they get no preview rather than a CSP-blocked URL.
105107
if (error instanceof MaxSizeExceededError && getImageContentType(error.headers)) {
106-
return ipfsToGatewayUrl(imageUri);
108+
const directUrl = maybeIpfsToGatewayUrl(imageUri);
109+
return isIpfsUrl(directUrl) ? undefined : directUrl;
107110
}
108111

109112
// 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)