Skip to content
Open
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]);

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();
}
});
}
});
}, [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
28 changes: 28 additions & 0 deletions packages/gui/src/electron/CacheManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ jest.mock('./utils/downloadFile', () => ({
__esModule: true,
default: mockDownloadFile,
MAX_FILE_SIZE_EXCEEDED_ERROR: 'Maximum file size exceeded',
isDownloadTimeoutError: jest.requireActual('./utils/downloadFile').isDownloadTimeoutError,
}));

jest.mock('./utils/ipcMainHandle', () => ({
Expand Down Expand Up @@ -125,6 +126,33 @@ describe('CacheManager eviction', () => {
expect(mockDownloadFile).toHaveBeenCalledTimes(1);
});

it('retries a timeout persisted by a previous session', async () => {
const payload = Buffer.from('cached payload');
mockDownloadFile.mockRejectedValue(new Error('Request timed out after 30000ms of inactivity'));

const firstSession = new CacheManager({
cacheDirectory,
maxCacheSize: 1024,
});
await firstSession.init();
await expect(firstSession.getContent('https://example.com/nft.png')).rejects.toThrow('Request timed out');

mockDownloadFile.mockReset();
mockDownloadFile.mockImplementation(async (_url, localPath) => {
await fs.writeFile(localPath, payload);
return {
'content-type': 'image/png',
};
});

const secondSession = new CacheManager({
cacheDirectory,
maxCacheSize: 1024,
});
await secondSession.init();
await expect(secondSession.getContent('https://example.com/nft.png')).resolves.toEqual(payload);
});

it('retries an aborted download on the next access', async () => {
const payload = Buffer.from('cached payload');
mockDownloadFile.mockRejectedValueOnce(new Error('Request aborted')).mockImplementation(async (_url, localPath) => {
Expand Down
30 changes: 26 additions & 4 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 @@ -16,10 +15,11 @@ import CacheState from '../constants/CacheState';
import limit from '../util/limit';

import CacheAPI from './constants/CacheAPI';
import downloadFile, { MAX_FILE_SIZE_EXCEEDED_ERROR } from './utils/downloadFile';
import downloadFile, { MAX_FILE_SIZE_EXCEEDED_ERROR, isDownloadTimeoutError } 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 @@ -114,6 +114,11 @@ export default class CacheManager extends EventEmitter {
}
> = new Map();

// URLs whose download timed out during this session. A persisted timeout is
// retried once per session — the set keeps a stalled host from being retried
// (and holding a download slot) on every access within the same session.
private timedOutUrls: Set<string> = new Set();

constructor(
options: {
cacheDirectory?: string;
Expand Down Expand Up @@ -435,7 +440,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 All @@ -449,10 +454,14 @@ export default class CacheManager extends EventEmitter {
log(`Url already downloaded with error: ${cacheInfo.error}`, url);

const isTransientError = ['Response aborted', 'Request aborted'].includes(cacheInfo.error);
// A persisted timeout settles for the rest of the session, but is
// retried in later sessions — a one-off network problem must not
// disable the preview until the whole cache is cleared.
const isRetriableTimeout = isDownloadTimeoutError(cacheInfo.error) && !this.timedOutUrls.has(url);
// A persisted size-limit error is only retried when the caller lifts
// the limit, so oversized files are not re-downloaded on every visit.
const isSizeLimitLifted = cacheInfo.error === MAX_FILE_SIZE_EXCEEDED_ERROR && maxSize <= 0;
if (!isTransientError && !isSizeLimitLifted) {
if (!isTransientError && !isRetriableTimeout && !isSizeLimitLifted) {
return cacheInfo;
}

Expand Down Expand Up @@ -507,8 +516,21 @@ 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');

if (isDownloadTimeoutError(currentError.message)) {
this.timedOutUrls.add(url);
}

return await this.setCacheInfo(url, {
state: CacheState.ERROR,
error: currentError.message,
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
Loading