Skip to content

Commit 886d5f5

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, - 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 a5f45e4 commit 886d5f5

9 files changed

Lines changed: 137 additions & 7 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';
@@ -417,7 +416,7 @@ export default class CacheManager extends EventEmitter {
417416

418417
const normalizedURL = decodeURI(url) === url ? encodeURI(url) : url;
419418

420-
if (!isURL(normalizedURL)) {
419+
if (!isValidURL(normalizedURL)) {
421420
throw new Error(`Invalid URL: ${normalizedURL}`);
422421
}
423422

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';
@@ -91,7 +92,10 @@ export default async function downloadFile(
9192
}
9293

9394
const tempFilePath = `${localPath}.tmp`;
94-
const request = net.request(url);
95+
// ipfs:// URIs are fetched through an HTTPS gateway — Electron's net stack
96+
// cannot request the ipfs scheme. Only this outgoing request uses the
97+
// translated URL; callers keep the original URI as the cache key.
98+
const request = net.request(ipfsToGatewayUrl(url));
9599
const outputStream = new WriteStreamPromise(tempFilePath, overrideFile);
96100

97101
// 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
}

packages/gui/src/util/ipfs.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import ipfsToGatewayUrl, { IPFS_GATEWAY_BASE, getIpfsPath, isIpfsUrl } from './ipfs';
2+
3+
// CID taken from a real mainnet NFT whose on-chain data URI is ipfs://
4+
const CID_V1 = 'bafybeiceg2gltyhlkukwetn26k7t2zdvthg4u4c6uj23rpni2adzgvo5si';
5+
const CID_V0 = 'QmPK1s3pNYLi9ERiq3BDxKa4XosgWwFRQUydHUtz4YgpqB';
6+
7+
describe('isIpfsUrl', () => {
8+
it('matches the ipfs scheme case-insensitively', () => {
9+
expect(isIpfsUrl(`ipfs://${CID_V1}/020.png`)).toBe(true);
10+
expect(isIpfsUrl(`IPFS://${CID_V1}`)).toBe(true);
11+
});
12+
13+
it('does not match other schemes', () => {
14+
expect(isIpfsUrl(`https://ipfs.io/ipfs/${CID_V1}`)).toBe(false);
15+
expect(isIpfsUrl('')).toBe(false);
16+
});
17+
});
18+
19+
describe('getIpfsPath', () => {
20+
it('returns the CID and path', () => {
21+
expect(getIpfsPath(`ipfs://${CID_V1}/020.png`)).toBe(`${CID_V1}/020.png`);
22+
expect(getIpfsPath(`ipfs://${CID_V1}`)).toBe(CID_V1);
23+
});
24+
25+
it('strips the redundant ipfs/ prefix some minting tools produce', () => {
26+
expect(getIpfsPath(`ipfs://ipfs/${CID_V1}/020.png`)).toBe(`${CID_V1}/020.png`);
27+
});
28+
29+
it('preserves the case of CIDv0 base58 hashes', () => {
30+
expect(getIpfsPath(`ipfs://${CID_V0}/image.png`)).toBe(`${CID_V0}/image.png`);
31+
});
32+
33+
it('returns undefined for non-ipfs URLs and a bare scheme', () => {
34+
expect(getIpfsPath(`https://example.com/${CID_V1}`)).toBeUndefined();
35+
expect(getIpfsPath('ipfs://')).toBeUndefined();
36+
});
37+
});
38+
39+
describe('ipfsToGatewayUrl', () => {
40+
it('translates ipfs:// URIs to the HTTPS gateway', () => {
41+
expect(ipfsToGatewayUrl(`ipfs://${CID_V1}/020.png`)).toBe(`${IPFS_GATEWAY_BASE}${CID_V1}/020.png`);
42+
expect(ipfsToGatewayUrl(`ipfs://ipfs/${CID_V1}`)).toBe(`${IPFS_GATEWAY_BASE}${CID_V1}`);
43+
});
44+
45+
it('returns non-ipfs URLs unchanged', () => {
46+
const url = 'https://example.com/image.png?size=large';
47+
expect(ipfsToGatewayUrl(url)).toBe(url);
48+
});
49+
50+
it('returns an unusable bare scheme unchanged so validation rejects it', () => {
51+
expect(ipfsToGatewayUrl('ipfs://')).toBe('ipfs://');
52+
});
53+
});

packages/gui/src/util/ipfs.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// The public HTTPS gateway used to serve ipfs:// resources. Electron's net
2+
// stack has no IPFS support, so ipfs:// URIs are fetched through a gateway.
3+
// The gateway does not need to be trusted for integrity: everything the cache
4+
// serves is checked against the NFT's on-chain hash before it is shown.
5+
export const IPFS_GATEWAY_BASE = 'https://ipfs.io/ipfs/';
6+
7+
const IPFS_SCHEME = /^ipfs:\/\//i;
8+
9+
export function isIpfsUrl(url: string): boolean {
10+
return typeof url === 'string' && IPFS_SCHEME.test(url);
11+
}
12+
13+
// Returns the `<CID>[/path]` part of an ipfs:// URI, tolerating the redundant
14+
// `ipfs://ipfs/<CID>` form produced by some minting tools. CIDv0 hashes are
15+
// case-sensitive base58, so the value is never case-normalized.
16+
export function getIpfsPath(url: string): string | undefined {
17+
if (!isIpfsUrl(url)) {
18+
return undefined;
19+
}
20+
21+
const ipfsPath = url.replace(IPFS_SCHEME, '').replace(/^ipfs\//i, '');
22+
23+
return ipfsPath.length > 0 ? ipfsPath : undefined;
24+
}
25+
26+
// Translates an ipfs:// URI to its HTTPS gateway equivalent. Anything else
27+
// (including an unusable bare `ipfs://`) is returned unchanged, so this can
28+
// wrap any URL right where it reaches the network layer.
29+
export default function ipfsToGatewayUrl(url: string): string {
30+
const ipfsPath = getIpfsPath(url);
31+
32+
return ipfsPath === undefined ? url : `${IPFS_GATEWAY_BASE}${ipfsPath}`;
33+
}

0 commit comments

Comments
 (0)