Skip to content

Commit 0d59bd1

Browse files
jlobue10claude
andcommitted
Fetch ipfs:// NFT resources through an HTTPS gateway
Some NFTs are minted with bare ipfs://<CID>/<path> data, metadata, or license URIs instead of an HTTPS gateway URL. Every such URI failed validation in the GUI cache layer with "Invalid URL: ipfs://...": validator's isURL applies an FQDN check to the host, and a CID has no top-level domain, so listing 'ipfs' as an allowed protocol never actually accepted anything. Even when a caller ignored the validation error, Electron's net stack cannot request the ipfs scheme, so the media could never be fetched, verified, or cached. Translate ipfs:// URIs to their HTTPS gateway form (https://ipfs.io/ipfs/<CID>/<path>) in one shared helper and apply it - in the electron isValidURL, which now validates the gateway form (the URL that is actually requested), - at the outgoing request sites (downloadFile, fetchBuffer, fetchJSON) right where the URL reaches net.request, - at the single-NFT download handler, whose Chromium downloadURL cannot fetch the ipfs scheme either, - in the oversized-image direct-URL fallback of the dapp dialog, whose CSP only allows https: and data: images, - in the NFTHashStatus badge so ipfs URIs are no longer flagged as invalid in the renderer. The original on-chain URI remains the cache key everywhere, so existing cache entries, cache-info sidecars, and renderer lookups are unaffected. The redundant ipfs://ipfs/<CID> form produced by some minting tools is tolerated, and CID case is preserved (CIDv0 is case-sensitive base58). The gateway does not need to be trusted for integrity: everything the cache serves is verified against the NFT's on-chain hash. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vai5yNzPZwUSgid2nhQvys
1 parent 4d61e77 commit 0d59bd1

12 files changed

Lines changed: 159 additions & 9 deletions

File tree

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import React, { useMemo } from 'react';
88

99
import useNFT from '../../hooks/useNFT';
1010
import useNFTVerifyHash from '../../hooks/useNFTVerifyHash';
11+
import ipfsToGatewayUrl from '../../util/ipfs';
1112

1213
export type NFTHashStatusProps = {
1314
nftId: string;
@@ -39,7 +40,9 @@ export default function NFTHashStatus(props: NFTHashStatusProps) {
3940
}
4041

4142
if (nftPreview.uri) {
42-
return isValidURL(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));
4346
}
4447

4548
return false;

packages/gui/src/electron/CacheManager.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import path from 'node:path';
77
import { Readable } from 'node:stream';
88

99
import debug from 'debug';
10-
import isURL from 'validator/lib/isURL';
1110

1211
import type CacheInfo from '../@types/CacheInfo';
1312
import type CacheInfoBase from '../@types/CacheInfoBase';
@@ -435,7 +434,7 @@ export default class CacheManager extends EventEmitter {
435434

436435
const normalizedURL = decodeURI(url) === url ? encodeURI(url) : url;
437436

438-
if (!isURL(normalizedURL)) {
437+
if (!isValidURL(normalizedURL)) {
439438
throw new Error(`Invalid URL: ${normalizedURL}`);
440439
}
441440

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,20 @@ describe('nftGetImageDataUrl', () => {
104104
);
105105
});
106106

107+
it('falls back to the gateway URL when an oversized image has an ipfs URI', async () => {
108+
mockFetchBuffer.mockRejectedValue(
109+
new MaxSizeExceededError({
110+
'content-type': 'image/gif',
111+
}),
112+
);
113+
114+
// the dialog CSP only allows https: and data: images, so the raw ipfs
115+
// URI would render as a broken image
116+
await expect(nftGetImageDataUrl('ipfs://bafybeigdyrztest/large.gif', '00')).resolves.toBe(
117+
'https://ipfs.io/ipfs/bafybeigdyrztest/large.gif',
118+
);
119+
});
120+
107121
it('rejects an oversized response that is not an image', async () => {
108122
mockFetchBuffer.mockRejectedValue(
109123
new MaxSizeExceededError({

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ 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';
56
import fetchBuffer, { MaxSizeExceededError } from '../utils/fetchBuffer';
67

78
const METADATA_TIMEOUT = 10_000;
@@ -99,8 +100,10 @@ export async function nftGetImageDataUrl(
99100
// An image too large to inline cannot be hash-verified without unbounded
100101
// buffering. Fall back to the direct URL — the dialog CSP still allows
101102
// https: images, matching the pre-verification behavior for these files.
103+
// The CSP does not allow the ipfs: scheme, so ipfs URIs fall back to
104+
// their gateway form.
102105
if (error instanceof MaxSizeExceededError && getImageContentType(error.headers)) {
103-
return imageUri;
106+
return ipfsToGatewayUrl(imageUri);
104107
}
105108

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

packages/gui/src/electron/main.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ 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';
3233

3334
import CacheManager, { CACHE_PROTOCOL } from './CacheManager';
3435
import { checkNFTOwnership } from './api/checkNFTOwnership';
@@ -575,7 +576,9 @@ if (ensureSingleInstance() && ensureCorrectEnvironment()) {
575576
return;
576577
}
577578

578-
mainWindow.webContents.downloadURL(urlLocal);
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));
579582
});
580583

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

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ 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';
78

89
import fileExists from './fileExists';
910
import isValidURL from './isValidURL';
@@ -100,7 +101,10 @@ export default async function downloadFile(
100101
}
101102

102103
const tempFilePath = `${localPath}.tmp`;
103-
const request = net.request(url);
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));
104108
const outputStream = new WriteStreamPromise(tempFilePath, overrideFile);
105109

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

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

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

33
import type Headers from '../../@types/Headers';
4+
import ipfsToGatewayUrl from '../../util/ipfs';
45

56
import isValidURL from './isValidURL';
67

@@ -41,7 +42,9 @@ export default async function fetchBuffer(
4142

4243
const request = net.request({
4344
method: 'GET',
44-
url,
45+
// ipfs:// URIs are fetched through an HTTPS gateway — Electron's net
46+
// stack cannot request the ipfs scheme.
47+
url: ipfsToGatewayUrl(url),
4548
headers,
4649
});
4750

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

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

3+
import ipfsToGatewayUrl from '../../util/ipfs';
4+
35
import isValidURL from './isValidURL';
46

57
const DEFAULT_TIMEOUT = 10 * 60 * 1000; // 10 minutes
@@ -17,7 +19,9 @@ export default async function fetchJSON<TData>(
1719

1820
const request = net.request({
1921
method,
20-
url,
22+
// ipfs:// URIs are fetched through an HTTPS gateway — Electron's net
23+
// stack cannot request the ipfs scheme.
24+
url: ipfsToGatewayUrl(url),
2125
headers,
2226
});
2327

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import isValidURL from './isValidURL';
2+
3+
describe('isValidURL', () => {
4+
it('accepts https URLs', () => {
5+
expect(isValidURL('https://example.com/image.png')).toBe(true);
6+
});
7+
8+
it('requires the protocol and rejects non-https schemes', () => {
9+
expect(isValidURL('example.com/image.png')).toBe(false);
10+
expect(isValidURL('http://example.com/image.png')).toBe(false);
11+
expect(isValidURL('ftp://example.com/image.png')).toBe(false);
12+
});
13+
14+
it('accepts ipfs:// URIs with a CID host', () => {
15+
// validator's isURL rejects CID hosts (no TLD), so these pass only via
16+
// the gateway translation
17+
expect(isValidURL('ipfs://bafybeiceg2gltyhlkukwetn26k7t2zdvthg4u4c6uj23rpni2adzgvo5si/020.png')).toBe(true);
18+
expect(isValidURL('ipfs://ipfs/QmPK1s3pNYLi9ERiq3BDxKa4XosgWwFRQUydHUtz4YgpqB')).toBe(true);
19+
});
20+
21+
it('rejects a bare ipfs scheme and non-strings', () => {
22+
expect(isValidURL('ipfs://')).toBe(false);
23+
expect(isValidURL(undefined as unknown as string)).toBe(false);
24+
});
25+
});
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
import isURL from 'validator/lib/isURL';
22

3+
import ipfsToGatewayUrl from '../../util/ipfs';
4+
35
export default function isValidURL(url: string) {
46
if (typeof url !== 'string') {
57
return false;
68
}
79

8-
return isURL(url, { protocols: ['https', 'ipfs'], require_protocol: true });
10+
// isURL applies an FQDN check to the host, which every ipfs://<CID> URI
11+
// fails (a CID has no top-level domain), so listing 'ipfs' as an allowed
12+
// protocol is not enough. Validate the HTTPS gateway form instead — it is
13+
// also the URL the network layer will actually request.
14+
return isURL(ipfsToGatewayUrl(url), { protocols: ['https'], require_protocol: true });
915
}

0 commit comments

Comments
 (0)