Skip to content

Commit 0aabeae

Browse files
authored
[CHIA-4324] NFT media pipeline 3/3: preview verification & hardening (#3010)
1 parent 4923512 commit 0aabeae

16 files changed

Lines changed: 1299 additions & 79 deletions

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export default function NFTHashStatus(props: NFTHashStatusProps) {
3131
const isLoading = isLoadingNFTVerifyHash || isLoadingNFT;
3232
const isVerified = preview ? nftPreview?.isVerified : data?.isVerified;
3333
const error = (errorNFT ?? preview) ? nftPreview?.error : data?.error;
34+
const failedFetch = preview ? nftPreview?.failedFetch : data?.failedFetch;
3435

3536
const isValidURI = useMemo(() => {
3637
if (!nftPreview || !('originalUri' in nftPreview)) {
@@ -77,8 +78,12 @@ export default function NFTHashStatus(props: NFTHashStatusProps) {
7778
return <Trans>URL is not valid</Trans>;
7879
}
7980

81+
if (failedFetch) {
82+
return <Trans>File is not available</Trans>;
83+
}
84+
8085
return <Trans>Invalid hash</Trans>;
81-
}, [isLoading, isVerified, nft, isValidURI]);
86+
}, [isLoading, isVerified, nft, isValidURI, failedFetch]);
8287

8388
const color = useMemo(() => {
8489
if (isLoading) {

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

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import VideoSmallIcon from '../../assets/img/video-small.svg';
2323
import VideoPngIcon from '../../assets/img/video.png';
2424
import VideoPngDarkIcon from '../../assets/img/video_dark.png';
2525
import FileType from '../../constants/FileType';
26+
import { isSettledHashMismatch } from '../../hooks/selectNFTPreviewState';
2627
import useCache from '../../hooks/useCache';
2728
import useFileType from '../../hooks/useFileType';
2829
import useHideObjectionableContent from '../../hooks/useHideObjectionableContent';
@@ -141,7 +142,7 @@ export default function NFTPreview(props: NFTPreviewProps) {
141142
const nftId = useMemo(() => getNFTId(id), [id]);
142143
const iframeRef = useRef<any>(null);
143144
const { isDarkMode } = useDarkMode();
144-
const [, setError] = useStateAbort<Error | undefined>(undefined);
145+
const [prepareError, setPrepareError] = useStateAbort<Error | undefined>(undefined);
145146
const [previewContent, setPreviewContent] = useStateAbort<ReactNode | undefined>(undefined);
146147
const abortControllerRef = useRef(new AbortController());
147148
const [hideObjectionableContent] = useHideObjectionableContent();
@@ -172,15 +173,20 @@ export default function NFTPreview(props: NFTPreviewProps) {
172173

173174
const { isLoading: isLoadingNFT } = useNFT(nftId);
174175
const { metadata, isLoading: isLoadingMetadata } = useNFTMetadata(nftId);
175-
const isLoading = isLoadingVerifyHash || isLoadingMetadata || isLoadingNFT || isLoadingFileType;
176+
// hash verification downloads the full data file, which can take a long
177+
// time for large media, and the metadata host can be slow or dead — either
178+
// one only blocks the tile while there is no verified preview uri to show
179+
const isLoading = isLoadingNFT || isLoadingFileType || ((isLoadingVerifyHash || isLoadingMetadata) && !preview);
176180

177181
const blurPreview = useMemo(() => {
178182
if (!hideObjectionableContent) {
179183
return false;
180184
}
181185

182-
if (isLoading) {
183-
return false;
186+
// a verified preview can render before the metadata fetch settles — keep
187+
// it covered until the sensitive-content flag can actually be read
188+
if (isLoadingMetadata) {
189+
return true;
184190
}
185191

186192
if (!metadata) {
@@ -192,16 +198,22 @@ export default function NFTPreview(props: NFTPreviewProps) {
192198
}
193199

194200
return false;
195-
}, [hideObjectionableContent, isLoading, metadata]);
201+
}, [hideObjectionableContent, isLoadingMetadata, metadata]);
196202

197203
const previewExtension = useMemo(() => getFileExtension(preview?.uri), [preview]);
198204

205+
// The cached bytes of a settled mismatch must never reach the iframe, even
206+
// though the state still carries the uri so the hash badge can report it.
207+
const isHashMismatch = isSettledHashMismatch(preview);
208+
209+
const previewUri = isHashMismatch ? undefined : preview?.uri;
210+
199211
const preparePreview = useCallback(
200212
async (signal: AbortSignal) => {
201213
try {
202-
setError(undefined, signal);
214+
setPrepareError(undefined, signal);
203215

204-
if (!preview?.uri) {
216+
if (!previewUri) {
205217
setPreviewContent(undefined, signal);
206218
return;
207219
}
@@ -232,9 +244,10 @@ export default function NFTPreview(props: NFTPreviewProps) {
232244
}
233245
`;
234246

235-
const cachedURI = await getURI(preview.uri, { maxSize: ignoreSizeLimit ? -1 : undefined });
247+
const cachedURI = await getURI(previewUri, { maxSize: ignoreSizeLimit ? -1 : undefined });
236248
if (!cachedURI || !cachedURI.startsWith('cache://')) {
237249
setPreviewContent(undefined, signal);
250+
setPrepareError(new Error(t`File is not available`), signal);
238251
return;
239252
}
240253

@@ -276,10 +289,21 @@ export default function NFTPreview(props: NFTPreviewProps) {
276289
signal,
277290
);
278291
} catch (e) {
279-
setError(e as Error, signal);
292+
setPreviewContent(undefined, signal);
293+
setPrepareError(e as Error, signal);
280294
}
281295
},
282-
[preview, fit, getURI, ignoreSizeLimit, previewFileType, loopVideo, isDarkMode, setPreviewContent, setError],
296+
[
297+
previewUri,
298+
fit,
299+
getURI,
300+
ignoreSizeLimit,
301+
previewFileType,
302+
loopVideo,
303+
isDarkMode,
304+
setPreviewContent,
305+
setPrepareError,
306+
],
283307
);
284308

285309
useEffect(() => {
@@ -482,6 +506,13 @@ export default function NFTPreview(props: NFTPreviewProps) {
482506

483507
const hasFile = !!preview;
484508

509+
// icon, model, document and compact non-image previews do not render the
510+
// iframe, so they never wait for the preview file to download
511+
const usesIframe =
512+
!(isCompact && previewFileType !== FileType.IMAGE) &&
513+
!icon &&
514+
![FileType.MODEL, FileType.DOCUMENT].includes(previewFileType);
515+
485516
return (
486517
<StyledCardPreview width={width} height={height} sx={{ aspectRatio: ratio.toString() }}>
487518
{isLoading ? (
@@ -494,6 +525,22 @@ export default function NFTPreview(props: NFTPreviewProps) {
494525
<Trans>No file available</Trans>
495526
</IconMessage>
496527
</Background>
528+
) : isHashMismatch ? (
529+
<Background>
530+
<IconMessage icon={<NotInterested fontSize="large" />}>
531+
<Trans>File does not match the expected hash</Trans>
532+
</IconMessage>
533+
</Background>
534+
) : usesIframe && prepareError ? (
535+
<Background>
536+
<IconMessage icon={<NotInterested fontSize="large" />}>
537+
<Trans>Preview is not available</Trans>
538+
</IconMessage>
539+
</Background>
540+
) : usesIframe && !previewContent ? (
541+
<Flex position="absolute" left="0" top="0" bottom="0" right="0" justifyContent="center" alignItems="center">
542+
<Loading center>{!isCompact && t`Loading preview...`}</Loading>
543+
</Flex>
497544
) : (
498545
previewIframe
499546
)}

packages/gui/src/components/settings/SettingsNFT.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { Trans } from '@lingui/macro';
1414
import { Grid, Button, Switch, FormControlLabel, Typography } from '@mui/material';
1515
import React from 'react';
1616

17+
import useAllowUnverifiedNFTPreviews from '../../hooks/useAllowUnverifiedNFTPreviews';
1718
import useCache from '../../hooks/useCache';
1819
import useHideObjectionableContent from '../../hooks/useHideObjectionableContent';
1920
import useNFTImageFittingMode from '../../hooks/useNFTImageFittingMode';
@@ -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 [allowUnverifiedPreviews, setAllowUnverifiedPreviews] = useAllowUnverifiedNFTPreviews();
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 handleChangeAllowUnverifiedPreviews(event: React.ChangeEvent<HTMLInputElement>) {
57+
setAllowUnverifiedPreviews(event.target.checked);
58+
}
59+
5460
async function clearNFTCache() {
5561
openDialog(
5662
<ConfirmDialog
@@ -144,6 +150,28 @@ export default function SettingsGeneral() {
144150
</Grid>
145151
</Grid>
146152

153+
<Grid container>
154+
<Grid item style={{ width: '400px' }}>
155+
<SettingsTitle>
156+
<Trans>Show unverified previews</Trans>
157+
</SettingsTitle>
158+
</Grid>
159+
<Grid item container xs justifyContent="flex-end" marginTop="-6px">
160+
<FormControlLabel
161+
control={<Switch checked={allowUnverifiedPreviews} onChange={handleChangeAllowUnverifiedPreviews} />}
162+
/>
163+
</Grid>
164+
<Grid item style={{ width: '400px' }}>
165+
<SettingsText>
166+
<Trans>
167+
When an NFT image is too large to verify against its on-chain hash, load the transaction confirmation
168+
preview directly from its source URL without verification. When disabled, no preview is shown for these
169+
NFTs.
170+
</Trans>
171+
</SettingsText>
172+
</Grid>
173+
</Grid>
174+
147175
<Grid item style={{ maxWidth: '400px' }}>
148176
<Flex flexDirection="column" gap={1}>
149177
<SettingsSection>
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import crypto from 'node:crypto';
2+
3+
type FetchBuffer = typeof import('../utils/fetchBuffer').default;
4+
5+
const mockFetchBuffer = jest.fn<ReturnType<FetchBuffer>, Parameters<FetchBuffer>>();
6+
7+
jest.mock('../utils/fetchBuffer', () => ({
8+
__esModule: true,
9+
default: mockFetchBuffer,
10+
MaxSizeExceededError:
11+
jest.requireActual<typeof import('../utils/fetchBuffer')>('../utils/fetchBuffer').MaxSizeExceededError,
12+
}));
13+
14+
const mockAllowUnverifiedNftPreviews = jest.fn<boolean, []>();
15+
16+
jest.mock('../utils/allowUnverifiedNftPreviews', () => ({
17+
__esModule: true,
18+
default: mockAllowUnverifiedNftPreviews,
19+
}));
20+
21+
const { MaxSizeExceededError } = jest.requireActual<typeof import('../utils/fetchBuffer')>('../utils/fetchBuffer');
22+
23+
const { nftGetImageDataUrl, nftGetMetadata } =
24+
jest.requireActual<typeof import('./nftGetMetadata')>('./nftGetMetadata');
25+
26+
function sha256(data: Buffer): string {
27+
return crypto.createHash('sha256').update(data.toString('latin1'), 'latin1').digest('hex');
28+
}
29+
30+
describe('nftGetMetadata', () => {
31+
beforeEach(() => {
32+
mockFetchBuffer.mockReset();
33+
});
34+
35+
it('parses metadata only after its raw bytes match the expected hash', async () => {
36+
const data = Buffer.from('{"name":"Verified NFT","preview_image_uris":["https://example.com/preview.png"]}');
37+
mockFetchBuffer.mockResolvedValue({
38+
data,
39+
headers: {
40+
'content-type': 'application/json',
41+
},
42+
});
43+
44+
await expect(nftGetMetadata('https://example.com/metadata.json', `0x${sha256(data)}`)).resolves.toEqual({
45+
name: 'Verified NFT',
46+
preview_image_uris: ['https://example.com/preview.png'],
47+
});
48+
});
49+
50+
it('rejects metadata whose bytes do not match the on-chain hash', async () => {
51+
const data = Buffer.from('{"name":"Tampered NFT"}');
52+
mockFetchBuffer.mockResolvedValue({
53+
data,
54+
headers: {},
55+
});
56+
57+
await expect(nftGetMetadata('https://example.com/metadata.json', '00')).resolves.toBeUndefined();
58+
});
59+
60+
it('does not fetch metadata without an expected hash', async () => {
61+
await expect(nftGetMetadata('https://example.com/metadata.json', undefined)).resolves.toBeUndefined();
62+
expect(mockFetchBuffer).not.toHaveBeenCalled();
63+
});
64+
});
65+
66+
describe('nftGetImageDataUrl', () => {
67+
beforeEach(() => {
68+
mockFetchBuffer.mockReset();
69+
mockAllowUnverifiedNftPreviews.mockReset();
70+
mockAllowUnverifiedNftPreviews.mockReturnValue(false);
71+
});
72+
73+
it('returns an immutable data URL for a verified image response', async () => {
74+
const data = Buffer.from('verified image bytes');
75+
mockFetchBuffer.mockResolvedValue({
76+
data,
77+
headers: {
78+
'content-type': 'image/png; charset=binary',
79+
},
80+
});
81+
82+
await expect(nftGetImageDataUrl('https://example.com/preview.png', sha256(data))).resolves.toBe(
83+
`data:image/png;base64,${data.toString('base64')}`,
84+
);
85+
});
86+
87+
it('rejects a hash-matched response that is not an image', async () => {
88+
const data = Buffer.from('<html>not an image</html>');
89+
mockFetchBuffer.mockResolvedValue({
90+
data,
91+
headers: {
92+
'content-type': 'text/html',
93+
},
94+
});
95+
96+
await expect(nftGetImageDataUrl('https://example.com/preview.png', sha256(data))).resolves.toBeUndefined();
97+
});
98+
99+
it('does not fetch an image without an expected hash', async () => {
100+
await expect(nftGetImageDataUrl('https://example.com/preview.png', undefined)).resolves.toBeUndefined();
101+
expect(mockFetchBuffer).not.toHaveBeenCalled();
102+
});
103+
104+
it('omits the preview for an oversized image response by default', async () => {
105+
mockFetchBuffer.mockRejectedValue(
106+
new MaxSizeExceededError({
107+
'content-type': 'image/gif',
108+
}),
109+
);
110+
111+
await expect(nftGetImageDataUrl('https://example.com/large.gif', '00')).resolves.toBeUndefined();
112+
});
113+
114+
it('falls back to the direct URL for an oversized image when unverified previews are enabled', async () => {
115+
mockAllowUnverifiedNftPreviews.mockReturnValue(true);
116+
mockFetchBuffer.mockRejectedValue(
117+
new MaxSizeExceededError({
118+
'content-type': 'image/gif',
119+
}),
120+
);
121+
122+
await expect(nftGetImageDataUrl('https://example.com/large.gif', '00')).resolves.toBe(
123+
'https://example.com/large.gif',
124+
);
125+
});
126+
127+
it('rejects an oversized response that is not an image even when unverified previews are enabled', async () => {
128+
mockAllowUnverifiedNftPreviews.mockReturnValue(true);
129+
mockFetchBuffer.mockRejectedValue(
130+
new MaxSizeExceededError({
131+
'content-type': 'video/mp4',
132+
}),
133+
);
134+
135+
await expect(nftGetImageDataUrl('https://example.com/large.mp4', '00')).resolves.toBeUndefined();
136+
});
137+
138+
it('rejects on any other download failure', async () => {
139+
mockFetchBuffer.mockRejectedValue(new Error('Request timeout after 10000ms'));
140+
141+
await expect(nftGetImageDataUrl('https://example.com/preview.png', '00')).resolves.toBeUndefined();
142+
});
143+
});

0 commit comments

Comments
 (0)