Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions packages/gui/src/components/nfts/NFTHashStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -27,23 +29,27 @@ 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;
const error = (errorNFT ?? preview) ? nftPreview?.error : data?.error;
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]);
Comment thread
cursor[bot] marked this conversation as resolved.

const icon = useMemo(() => {
if (hideIcon) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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();
}
});
}
});
Comment thread
cursor[bot] marked this conversation as resolved.
}, [ipfsGateway, invalidate /* immutable */, metadatasOnDemand /* immutable */]);

// immutable function
const subscribeToMetadataChanges = useCallback(
(id: string | undefined, callback: (nftState: MetadataState) => void) => {
Expand Down
26 changes: 26 additions & 0 deletions packages/gui/src/components/settings/SettingsNFT.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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();
Expand All @@ -53,6 +55,10 @@ export default function SettingsGeneral() {
setNFTVideoLoop(event.target.checked);
}

function handleChangeIpfsGateway(event: React.ChangeEvent<HTMLInputElement>) {
setIpfsGateway(event.target.checked);
}

function handleChangeAllowUnverifiedPreviews(event: React.ChangeEvent<HTMLInputElement>) {
setAllowUnverifiedPreviews(event.target.checked);
}
Expand Down Expand Up @@ -150,6 +156,26 @@ export default function SettingsGeneral() {
</Grid>
</Grid>

<Grid container>
<Grid item style={{ width: '400px' }}>
<SettingsTitle>
<Trans>Fetch IPFS content through a gateway</Trans>
</SettingsTitle>
</Grid>
<Grid item container xs justifyContent="flex-end" marginTop="-6px">
<FormControlLabel control={<Switch checked={ipfsGateway} onChange={handleChangeIpfsGateway} />} />
</Grid>
<Grid item style={{ width: '400px' }}>
<SettingsText>
<Trans>
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.
</Trans>
</SettingsText>
</Grid>
</Grid>

<Grid container>
<Grid item style={{ width: '400px' }}>
<SettingsTitle>
Expand Down
13 changes: 11 additions & 2 deletions packages/gui/src/electron/CacheManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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}`);
}

Expand Down Expand Up @@ -507,6 +507,15 @@ export default class CacheManager extends EventEmitter {

return await this.#downloadLimit<CacheInfo>(() => 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, {
Expand Down
53 changes: 53 additions & 0 deletions packages/gui/src/electron/api/nftGetMetadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ jest.mock('../utils/fetchBuffer', () => ({
jest.requireActual<typeof import('../utils/fetchBuffer')>('../utils/fetchBuffer').MaxSizeExceededError,
}));

const mockMaybeIpfsToGatewayUrl = jest.fn<string, [string]>();

jest.mock('../utils/ipfsGateway', () => ({
__esModule: true,
default: mockMaybeIpfsToGatewayUrl,
}));

const mockAllowUnverifiedNftPreviews = jest.fn<boolean, []>();

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

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

const ipfsToGatewayUrl = jest.requireActual<typeof import('../../util/ipfs')>('../../util/ipfs').default;

const { nftGetImageDataUrl, nftGetMetadata } =
jest.requireActual<typeof import('./nftGetMetadata')>('./nftGetMetadata');

Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -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(
Expand Down
8 changes: 7 additions & 1 deletion packages/gui/src/electron/api/nftGetMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion packages/gui/src/electron/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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 }[]) => {
Expand Down
8 changes: 7 additions & 1 deletion packages/gui/src/electron/utils/downloadFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion packages/gui/src/electron/utils/fetchBuffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
});

Expand Down
Loading