diff --git a/__tests__/components/waves/memes/MemesArtSubmissionFile.test.tsx b/__tests__/components/waves/memes/MemesArtSubmissionFile.test.tsx
index f68d3f9f34..54961fa52c 100644
--- a/__tests__/components/waves/memes/MemesArtSubmissionFile.test.tsx
+++ b/__tests__/components/waves/memes/MemesArtSubmissionFile.test.tsx
@@ -2,6 +2,8 @@ import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import MemesArtSubmissionFile from '@/components/waves/memes/MemesArtSubmissionFile';
import { AuthContext } from '@/components/auth/Auth';
+import type { MemesArtSubmissionFileProps } from '@/components/waves/memes/file-upload/reducers/types';
+import type { InteractiveMediaMimeType } from '@/components/waves/memes/submission/constants/media';
// Mock framer-motion to avoid AbortSignal compatibility issues
jest.mock('framer-motion', () => {
@@ -62,7 +64,10 @@ jest.mock('@/components/waves/memes/file-upload/components/FilePreview', () => {
));
FilePreviewMock.displayName = 'FilePreviewMock';
- return FilePreviewMock;
+ return {
+ __esModule: true,
+ default: FilePreviewMock,
+ };
});
jest.mock('@/components/waves/memes/file-upload/components/UploadArea', () => {
@@ -73,7 +78,10 @@ jest.mock('@/components/waves/memes/file-upload/components/UploadArea', () => {
));
UploadAreaMock.displayName = 'UploadAreaMock';
- return UploadAreaMock;
+ return {
+ __esModule: true,
+ default: UploadAreaMock,
+ };
});
jest.mock('@/components/waves/memes/file-upload/components/BrowserWarning', () => {
@@ -84,7 +92,10 @@ jest.mock('@/components/waves/memes/file-upload/components/BrowserWarning', () =
return
{reason}
;
});
BrowserWarningMock.displayName = 'BrowserWarningMock';
- return BrowserWarningMock;
+ return {
+ __esModule: true,
+ default: BrowserWarningMock,
+ };
});
// Mock useFileUploader hook with more realistic state management
@@ -152,6 +163,42 @@ describe('MemesArtSubmissionFile', () => {
const mockSetToast = jest.fn();
const mockSetArtworkUploaded = jest.fn();
const mockHandleFileSelect = jest.fn();
+ const mockSetMediaSource = jest.fn();
+ const mockOnExternalHashChange = jest.fn();
+ const mockOnExternalProviderChange = jest.fn();
+const mockOnExternalMimeTypeChange =
+ jest.fn<(value: InteractiveMediaMimeType) => void>();
+const mockOnClearExternalMedia = jest.fn();
+
+const VALID_IPFS_CID = 'bafybeigdyrztobg3tv6zj5n6xvztf4k5p3xf7r6xkqfq5jz3o5quftdjum';
+const VALID_ARWEAVE_TX_ID = 'QW_ArkGRZa0uSmLkH2ZAzU9xOQFfGqVsRyCrND3eOo8';
+const VALID_ARWEAVE_SUBDOMAIN = 'ifx4blsbsfs22lskmlsb6zsazvhxcoibl4nkk3checvtipo6hkhq';
+
+ const baseProps: MemesArtSubmissionFileProps = {
+ artworkUploaded: false,
+ artworkUrl: 'url',
+ setArtworkUploaded: mockSetArtworkUploaded,
+ handleFileSelect: mockHandleFileSelect,
+ mediaSource: 'upload',
+ setMediaSource: mockSetMediaSource,
+ externalHash: '',
+ externalProvider: 'ipfs',
+ externalConstructedUrl: '',
+ externalPreviewUrl: '',
+ externalMimeType: 'text/html',
+ externalError: null,
+ externalValidationStatus: 'idle',
+ isExternalMediaValid: false,
+ onExternalHashChange: mockOnExternalHashChange,
+ onExternalProviderChange: mockOnExternalProviderChange,
+ onExternalMimeTypeChange: mockOnExternalMimeTypeChange,
+ onClearExternalMedia: mockOnClearExternalMedia,
+ };
+
+ const renderComponent = (
+ overrideProps: Partial = {}
+ ) =>
+ render();
beforeEach(() => {
jest.clearAllMocks();
@@ -162,19 +209,17 @@ describe('MemesArtSubmissionFile', () => {
mockProcessFile.mockClear();
mockHandleRetry.mockClear();
mockHandleRemoveFile.mockClear();
+ mockSetMediaSource.mockClear();
+ mockOnExternalHashChange.mockClear();
+ mockOnExternalProviderChange.mockClear();
+ mockOnExternalMimeTypeChange.mockClear();
+ mockOnClearExternalMedia.mockClear();
});
describe('Error Handling - Fail Fast', () => {
it('renders with default AuthContext when provider is missing', () => {
// Component should render with default context values when no provider is present
- const { container } = render(
-
- );
+ const { container } = renderComponent();
// Should render the main container without errors
expect(container.firstChild).toBeInTheDocument();
@@ -188,6 +233,7 @@ describe('MemesArtSubmissionFile', () => {
const { container } = render(
{
it('shows warning when browser unsupported and calls setToast', async () => {
render(
-
+
);
@@ -236,12 +277,7 @@ describe('MemesArtSubmissionFile', () => {
render(
-
+
);
@@ -255,12 +291,7 @@ describe('MemesArtSubmissionFile', () => {
it('renders upload area when artwork not uploaded', () => {
render(
-
+
);
@@ -272,10 +303,9 @@ describe('MemesArtSubmissionFile', () => {
render(
);
@@ -288,10 +318,9 @@ describe('MemesArtSubmissionFile', () => {
const { rerender } = render(
);
@@ -303,10 +332,9 @@ describe('MemesArtSubmissionFile', () => {
rerender(
);
@@ -321,12 +349,7 @@ describe('MemesArtSubmissionFile', () => {
it('renders file input with correct attributes', () => {
render(
-
+
);
@@ -340,12 +363,7 @@ describe('MemesArtSubmissionFile', () => {
it('has proper accessibility attributes on upload area', () => {
render(
-
+
);
@@ -359,10 +377,9 @@ describe('MemesArtSubmissionFile', () => {
render(
);
@@ -380,10 +397,8 @@ describe('MemesArtSubmissionFile', () => {
render(
);
@@ -395,12 +410,7 @@ describe('MemesArtSubmissionFile', () => {
it('revokes object URLs on cleanup', () => {
const { unmount } = render(
-
+
);
@@ -410,4 +420,103 @@ describe('MemesArtSubmissionFile', () => {
expect(true).toBe(true); // No errors during unmount
});
});
+
+ describe('Interactive media preview security', () => {
+ const renderInteractivePreview = (
+ overrideProps: Partial = {}
+ ) =>
+ render(
+
+
+
+ );
+
+ it('renders sandboxed iframe for approved ipfs.io URLs', () => {
+ renderInteractivePreview();
+
+ const iframe = screen.getByTitle('Interactive artwork preview');
+ expect(iframe).toBeInTheDocument();
+ expect(iframe).toHaveAttribute('src', `https://ipfs.io/ipfs/${VALID_IPFS_CID}`);
+ expect(iframe).toHaveAttribute('sandbox');
+ expect(iframe.getAttribute('sandbox')).toContain('allow-scripts');
+ });
+
+ it('renders sandboxed iframe for approved arweave.net URLs', () => {
+ renderInteractivePreview({
+ externalProvider: 'arweave',
+ externalHash: VALID_ARWEAVE_TX_ID,
+ externalPreviewUrl: `https://arweave.net/${VALID_ARWEAVE_TX_ID}`,
+ externalConstructedUrl: `https://arweave.net/${VALID_ARWEAVE_TX_ID}`,
+ externalValidationStatus: 'valid',
+ });
+
+ const iframe = screen.getByTitle('Interactive artwork preview');
+ expect(iframe).toBeInTheDocument();
+ expect(iframe).toHaveAttribute('src', `https://arweave.net/${VALID_ARWEAVE_TX_ID}`);
+ });
+
+ it('renders sandboxed iframe for approved arweave subdomains', () => {
+ renderInteractivePreview({
+ externalProvider: 'arweave',
+ externalHash: VALID_ARWEAVE_TX_ID,
+ externalPreviewUrl: `https://${VALID_ARWEAVE_SUBDOMAIN}.arweave.net/`,
+ externalConstructedUrl: `https://arweave.net/${VALID_ARWEAVE_TX_ID}`,
+ externalValidationStatus: 'valid',
+ });
+
+ const iframe = screen.getByTitle('Interactive artwork preview');
+ expect(iframe).toBeInTheDocument();
+ expect(iframe).toHaveAttribute(
+ 'src',
+ `https://${VALID_ARWEAVE_SUBDOMAIN}.arweave.net/`
+ );
+ });
+
+ it('blocks previews from unapproved domains', () => {
+ renderInteractivePreview({
+ externalPreviewUrl: 'https://example.com/bad',
+ externalValidationStatus: 'valid',
+ isExternalMediaValid: true,
+ });
+
+ expect(
+ screen.getByText('Preview unavailable for unapproved domains or file types.')
+ ).toBeInTheDocument();
+ expect(screen.queryByTitle('Interactive artwork preview')).not.toBeInTheDocument();
+ });
+
+ it('blocks previews from allowed hosts when extension is unsafe', () => {
+ renderInteractivePreview({
+ externalPreviewUrl: 'https://ipfs.io/ipfs/bad/file.exe',
+ externalValidationStatus: 'valid',
+ isExternalMediaValid: true,
+ });
+
+ expect(
+ screen.getByText('Preview unavailable for unapproved domains or file types.')
+ ).toBeInTheDocument();
+ expect(screen.queryByTitle('Interactive artwork preview')).not.toBeInTheDocument();
+ });
+ it('shows validation pending message before iframe renders', () => {
+ renderInteractivePreview({
+ isExternalMediaValid: false,
+ externalValidationStatus: 'pending',
+ externalPreviewUrl: 'https://ipfs.io/ipfs/bafyHash',
+ });
+
+ expect(
+ screen.getByText("Validating preview...")
+ ).toBeInTheDocument();
+ expect(screen.queryByTitle('Interactive artwork preview')).not.toBeInTheDocument();
+ });
+ });
});
diff --git a/__tests__/components/waves/memes/submission/MemesArtSubmissionContainer.test.tsx b/__tests__/components/waves/memes/submission/MemesArtSubmissionContainer.test.tsx
index 906fbeb132..9e4857f8d4 100644
--- a/__tests__/components/waves/memes/submission/MemesArtSubmissionContainer.test.tsx
+++ b/__tests__/components/waves/memes/submission/MemesArtSubmissionContainer.test.tsx
@@ -6,6 +6,7 @@ import { SubmissionStep } from '@/components/waves/memes/submission/types/Steps'
import { useArtworkSubmissionForm } from '@/components/waves/memes/submission/hooks/useArtworkSubmissionForm';
import { useArtworkSubmissionMutation } from '@/components/waves/memes/submission/hooks/useArtworkSubmissionMutation';
import { useSeizeConnectContext } from '@/components/auth/SeizeConnectContext';
+import type { InteractiveMediaMimeType } from '@/components/waves/memes/submission/constants/media';
jest.mock('@/components/waves/memes/submission/hooks/useArtworkSubmissionForm');
jest.mock('@/components/waves/memes/submission/hooks/useArtworkSubmissionMutation');
@@ -27,7 +28,7 @@ describe('MemesArtSubmissionContainer', () => {
beforeEach(() => {
artworkProps = undefined;
- mockForm.mockReturnValue({
+ const formState: any = {
currentStep: SubmissionStep.ARTWORK,
agreements: false,
setAgreements: jest.fn(),
@@ -37,10 +38,88 @@ describe('MemesArtSubmissionContainer', () => {
updateTraitField: jest.fn(),
artworkUploaded: false,
artworkUrl: '',
- setArtworkUploaded: jest.fn(),
- handleFileSelect: jest.fn(),
- getSubmissionData: () => ({ traits: { title: 't' } }),
- } as any);
+ selectedFile: null,
+ mediaSource: 'upload',
+ externalMediaUrl: '',
+ externalMediaPreviewUrl: '',
+ externalMediaHashInput: '',
+ externalMediaProvider: 'ipfs',
+ externalMediaMimeType: 'text/html',
+ externalMediaError: null,
+ externalMediaValidationStatus: 'idle',
+ isExternalMediaValid: false,
+ };
+
+ formState.setArtworkUploaded = jest.fn((value: boolean) => {
+ formState.artworkUploaded = value;
+ if (!value) {
+ formState.selectedFile = null;
+ }
+ });
+
+ formState.handleFileSelect = jest.fn((file: File) => {
+ formState.selectedFile = file;
+ formState.artworkUploaded = true;
+ formState.artworkUrl = 'object-url';
+ });
+
+ formState.setMediaSource = jest.fn((mode: 'upload' | 'url') => {
+ formState.mediaSource = mode;
+ });
+
+ formState.setExternalMediaHash = jest.fn((hash: string) => {
+ formState.externalMediaHashInput = hash;
+ if (hash) {
+ formState.externalMediaUrl = `${formState.externalMediaProvider === 'arweave' ? 'https://arweave.net/' : 'ipfs://'}${hash}`;
+ formState.externalMediaPreviewUrl =
+ formState.externalMediaProvider === 'arweave'
+ ? `https://arweave.net/${hash}`
+ : `https://ipfs.io/ipfs/${hash}`;
+ formState.isExternalMediaValid = true;
+ formState.externalMediaValidationStatus = 'valid';
+ formState.externalMediaError = null;
+ } else {
+ formState.externalMediaUrl = '';
+ formState.externalMediaPreviewUrl = '';
+ formState.isExternalMediaValid = false;
+ formState.externalMediaValidationStatus = 'idle';
+ formState.externalMediaError = null;
+ }
+ });
+
+ formState.setExternalMediaProvider = jest.fn((provider: 'ipfs' | 'arweave') => {
+ formState.externalMediaProvider = provider;
+ formState.setExternalMediaHash(formState.externalMediaHashInput);
+ });
+
+ formState.setExternalMediaMimeType = jest.fn(
+ (mimeType: InteractiveMediaMimeType) => {
+ formState.externalMediaMimeType = mimeType;
+ }
+ );
+
+ formState.clearExternalMedia = jest.fn(() => {
+ formState.externalMediaHashInput = '';
+ formState.externalMediaUrl = '';
+ formState.externalMediaPreviewUrl = '';
+ formState.isExternalMediaValid = false;
+ formState.externalMediaValidationStatus = 'idle';
+ formState.externalMediaError = null;
+ });
+
+ formState.getSubmissionData = () => ({ traits: { title: 't' } });
+ formState.getMediaSelection = jest.fn(() => ({
+ mediaSource: formState.mediaSource,
+ selectedFile: formState.selectedFile,
+ externalUrl: formState.externalMediaUrl,
+ externalPreviewUrl: formState.externalMediaPreviewUrl,
+ externalProvider: formState.externalMediaProvider,
+ externalHash: formState.externalMediaHashInput,
+ externalMimeType: formState.externalMediaMimeType,
+ isExternalValid: formState.isExternalMediaValid,
+ }));
+
+ mockForm.mockReturnValue(formState);
mockMutation.mockReturnValue({
submitArtwork: jest.fn(async () => 'ok'),
uploadProgress: 0,
diff --git a/__tests__/components/waves/memes/submission/actions/validateInteractivePreview.test.ts b/__tests__/components/waves/memes/submission/actions/validateInteractivePreview.test.ts
new file mode 100644
index 0000000000..456b302995
--- /dev/null
+++ b/__tests__/components/waves/memes/submission/actions/validateInteractivePreview.test.ts
@@ -0,0 +1,264 @@
+import { validateInteractivePreview } from '@/components/waves/memes/submission/actions/validateInteractivePreview';
+import { INTERACTIVE_MEDIA_GATEWAY_BASE_URL } from '@/components/waves/memes/submission/constants/security';
+
+const CID_V1 = 'bafybeigdyrztobg3tv6zj5n6xvztf4k5p3xf7r6xkqfq5jz3o5quftdjum';
+const ARWEAVE_TX_ID = 'QW_ArkGRZa0uSmLkH2ZAzU9xOQFfGqVsRyCrND3eOo8';
+
+const originalFetch = global.fetch;
+
+type MockResponse = {
+ status: number;
+ ok: boolean;
+ url: string;
+ headers: {
+ get: (name: string) => string | null;
+ };
+ body: {
+ cancel: () => Promise;
+ } | null;
+};
+
+const createResponse = (
+ status: number,
+ headers: Record,
+ url = `https://ipfs.io/ipfs/${CID_V1}`
+): MockResponse => {
+ const headerStore = new Map(
+ Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value])
+ );
+
+ const includeBody = status === 206;
+
+ return {
+ status,
+ ok: status >= 200 && status < 300,
+ headers: {
+ get: (name: string) => headerStore.get(name.toLowerCase()) ?? null,
+ },
+ url,
+ body: includeBody
+ ? {
+ cancel: async () => {},
+ }
+ : null,
+ };
+};
+
+describe('validateInteractivePreview', () => {
+ beforeEach(() => {
+ global.fetch = jest.fn();
+ });
+
+ afterEach(() => {
+ global.fetch = originalFetch;
+ jest.clearAllMocks();
+ });
+
+ it('accepts HTML content served over an approved gateway', async () => {
+ (global.fetch as jest.Mock).mockResolvedValueOnce(
+ createResponse(200, { 'content-type': 'text/html; charset=utf-8' })
+ );
+
+ const result = await validateInteractivePreview({
+ provider: 'ipfs',
+ path: CID_V1,
+ });
+
+ expect(result.ok).toBe(true);
+ expect(result.contentType).toBe('text/html; charset=utf-8');
+ expect(global.fetch).toHaveBeenCalledTimes(1);
+ expect(global.fetch).toHaveBeenLastCalledWith(
+ expect.stringContaining(`https://ipfs.io/ipfs/${CID_V1}`),
+ expect.objectContaining({ method: 'HEAD' })
+ );
+ });
+
+ it('falls back to a ranged GET when HEAD is unsupported', async () => {
+ (global.fetch as jest.Mock)
+ .mockResolvedValueOnce(createResponse(405, { 'content-type': 'text/plain' }))
+ .mockResolvedValueOnce(
+ createResponse(206, { 'content-type': 'text/html' }, `https://arweave.net/${ARWEAVE_TX_ID}`)
+ );
+
+ const result = await validateInteractivePreview({
+ provider: 'arweave',
+ path: ARWEAVE_TX_ID,
+ });
+
+ expect(result.ok).toBe(true);
+ expect(global.fetch).toHaveBeenCalledTimes(2);
+ expect(global.fetch).toHaveBeenNthCalledWith(
+ 2,
+ expect.stringContaining(`https://arweave.net/${ARWEAVE_TX_ID}`),
+ expect.objectContaining({ method: 'GET', headers: { Range: 'bytes=0-1024' } })
+ );
+ });
+
+ it('accepts HTML content served from an arweave subdomain', async () => {
+ (global.fetch as jest.Mock).mockResolvedValueOnce(
+ createResponse(
+ 200,
+ { 'content-type': 'text/html' },
+ `https://${ARWEAVE_TX_ID.toLowerCase()}.arweave.net/`
+ )
+ );
+
+ const result = await validateInteractivePreview({
+ provider: 'arweave',
+ path: ARWEAVE_TX_ID,
+ });
+
+ expect(result.ok).toBe(true);
+ expect(global.fetch).toHaveBeenCalledTimes(1);
+ });
+
+ it('rejects responses with disallowed content types', async () => {
+ (global.fetch as jest.Mock).mockResolvedValueOnce(
+ createResponse(200, { 'content-type': 'application/octet-stream' })
+ );
+
+ const result = await validateInteractivePreview({
+ provider: 'ipfs',
+ path: CID_V1,
+ });
+
+ expect(result.ok).toBe(false);
+ expect(result.reason).toBe('Media must respond with an HTML document.');
+ });
+
+ it('fails when the gateway redirects to an unapproved host', async () => {
+ (global.fetch as jest.Mock).mockResolvedValueOnce(
+ createResponse(200, { 'content-type': 'text/html' }, 'https://example.com/bad')
+ );
+
+ const result = await validateInteractivePreview({
+ provider: 'ipfs',
+ path: CID_V1,
+ });
+
+ expect(result.ok).toBe(false);
+ expect(result.reason).toBe('Gateway redirected to an unapproved host.');
+ });
+
+ it('propagates network failures as validation errors', async () => {
+ (global.fetch as jest.Mock).mockRejectedValueOnce(new Error('offline'));
+
+ const result = await validateInteractivePreview({
+ provider: 'ipfs',
+ path: CID_V1,
+ });
+
+ expect(result.ok).toBe(false);
+ expect(result.reason).toBe('Unable to reach the content gateway.');
+ });
+
+ it('fails fast when HEAD request returns 400 without retrying GET', async () => {
+ (global.fetch as jest.Mock).mockResolvedValueOnce(
+ createResponse(400, { 'content-type': 'text/plain' })
+ );
+
+ const result = await validateInteractivePreview({
+ provider: 'ipfs',
+ path: CID_V1,
+ });
+
+ expect(result.ok).toBe(false);
+ expect(result.reason).toBe('Gateway returned 400.');
+ expect(global.fetch).toHaveBeenCalledTimes(1);
+ });
+
+ it('accepts responses from trailing-dot hosts after canonicalization', async () => {
+ (global.fetch as jest.Mock).mockResolvedValueOnce(
+ createResponse(
+ 200,
+ { 'content-type': 'text/html' },
+ `https://ipfs.io./ipfs/${CID_V1}`
+ )
+ );
+
+ const result = await validateInteractivePreview({
+ provider: 'ipfs',
+ path: CID_V1,
+ });
+
+ expect(result.ok).toBe(true);
+ });
+
+ it('rejects responses that include credentials', async () => {
+ (global.fetch as jest.Mock).mockResolvedValueOnce(
+ createResponse(
+ 200,
+ { 'content-type': 'text/html' },
+ `https://user:pass@ipfs.io/ipfs/${CID_V1}`
+ )
+ );
+
+ const result = await validateInteractivePreview({
+ provider: 'ipfs',
+ path: CID_V1,
+ });
+
+ expect(result.ok).toBe(false);
+ expect(result.reason).toBe('Gateway redirected to an unapproved host.');
+ });
+
+ it('rejects responses that use a non-default https port', async () => {
+ (global.fetch as jest.Mock).mockResolvedValueOnce(
+ createResponse(
+ 200,
+ { 'content-type': 'text/html' },
+ `https://ipfs.io:444/ipfs/${CID_V1}`
+ )
+ );
+
+ const result = await validateInteractivePreview({
+ provider: 'ipfs',
+ path: CID_V1,
+ });
+
+ expect(result.ok).toBe(false);
+ expect(result.reason).toBe('Gateway redirected to an unapproved host.');
+ });
+
+ it('rejects identifiers containing path separators before requesting the gateway', async () => {
+ const result = await validateInteractivePreview({
+ provider: 'ipfs',
+ path: `${CID_V1}/index.html`,
+ });
+
+ expect(result.ok).toBe(false);
+ expect(result.reason).toBe('Invalid path: only relative paths under the gateway origin are allowed.');
+ expect(global.fetch).not.toHaveBeenCalled();
+ });
+
+ it('rejects identifiers that do not match provider expectations', async () => {
+ const result = await validateInteractivePreview({
+ provider: 'ipfs',
+ path: 'not-a-valid-cid',
+ });
+
+ expect(result.ok).toBe(false);
+ expect(result.reason).toBe('Invalid path: expected a CIDv0 or CIDv1 root hash.');
+ expect(global.fetch).not.toHaveBeenCalled();
+ });
+
+ it('rejects requests whose resolved target host is unapproved before making a network call', async () => {
+ const originalBase = INTERACTIVE_MEDIA_GATEWAY_BASE_URL.ipfs;
+ INTERACTIVE_MEDIA_GATEWAY_BASE_URL.ipfs = 'https://example.com/';
+
+ try {
+ const result = await validateInteractivePreview({
+ provider: 'ipfs',
+ path: CID_V1,
+ });
+
+ expect(result.ok).toBe(false);
+ expect(result.reason).toBe(
+ 'Invalid path: resolved target is not permitted under allowed gateway hosts.'
+ );
+ expect(global.fetch).not.toHaveBeenCalled();
+ } finally {
+ INTERACTIVE_MEDIA_GATEWAY_BASE_URL.ipfs = originalBase;
+ }
+ });
+});
diff --git a/__tests__/components/waves/memes/submission/hooks/useArtworkSubmissionForm.test.ts b/__tests__/components/waves/memes/submission/hooks/useArtworkSubmissionForm.test.ts
index cd163c45b5..1899d62a14 100644
--- a/__tests__/components/waves/memes/submission/hooks/useArtworkSubmissionForm.test.ts
+++ b/__tests__/components/waves/memes/submission/hooks/useArtworkSubmissionForm.test.ts
@@ -1,6 +1,8 @@
-import { renderHook, act } from '@testing-library/react';
+import { renderHook, act, waitFor } from '@testing-library/react';
import { useArtworkSubmissionForm } from '@/components/waves/memes/submission/hooks/useArtworkSubmissionForm';
+const CID_V1 = 'bafybeigdyrztobg3tv6zj5n6xvztf4k5p3xf7r6xkqfq5jz3o5quftdjum';
+
jest.mock('@/components/waves/memes/traits/schema', () => ({
getInitialTraitsValues: () => ({
title: '',
@@ -10,12 +12,21 @@ jest.mock('@/components/waves/memes/traits/schema', () => ({
}),
}));
jest.mock('@/components/auth/Auth', () => ({ useAuth: jest.fn() }));
+jest.mock('@/components/waves/memes/submission/actions/validateInteractivePreview', () => ({
+ validateInteractivePreview: jest.fn(),
+}));
const { useAuth } = require('@/components/auth/Auth');
+const { validateInteractivePreview } = require('@/components/waves/memes/submission/actions/validateInteractivePreview');
describe('useArtworkSubmissionForm', () => {
beforeEach(() => {
(useAuth as jest.Mock).mockReturnValue({ connectedProfile: { handle: 'alice' } });
+ (validateInteractivePreview as jest.Mock).mockResolvedValue({ ok: true, contentType: 'text/html' });
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
});
it('initializes traits from profile and updates fields', () => {
@@ -38,4 +49,94 @@ describe('useArtworkSubmissionForm', () => {
expect(result.current.artworkUploaded).toBe(true);
expect(result.current.artworkUrl).toBe('url');
});
+
+ it('rejects external previews that are not HTML files', () => {
+ const { result } = renderHook(() => useArtworkSubmissionForm());
+
+ act(() => { result.current.setMediaSource('url'); });
+ act(() => { result.current.setExternalMediaHash('bafyBad/binary.exe'); });
+
+ expect(result.current.isExternalMediaValid).toBe(false);
+ expect(result.current.externalMediaError).toBe('IPFS embeds must reference the root CID without subpaths.');
+ expect(result.current.externalMediaPreviewUrl).toBe('');
+ expect(result.current.externalMediaValidationStatus).toBe('invalid');
+ expect(validateInteractivePreview).not.toHaveBeenCalled();
+ });
+
+ it('accepts external previews that resolve to HTML documents', async () => {
+ const { result } = renderHook(() => useArtworkSubmissionForm());
+
+ act(() => { result.current.setMediaSource('url'); });
+ act(() => { result.current.setExternalMediaHash(CID_V1); });
+
+ await waitFor(() => {
+ expect(result.current.isExternalMediaValid).toBe(true);
+ });
+
+ expect(validateInteractivePreview).toHaveBeenCalledWith({
+ provider: 'ipfs',
+ path: CID_V1,
+ });
+ expect(result.current.externalMediaError).toBeNull();
+ expect(result.current.externalMediaPreviewUrl).toBe(`https://ipfs.io/ipfs/${CID_V1}`);
+ expect(result.current.externalMediaValidationStatus).toBe('valid');
+ });
+
+ it('surfaces gateway validation errors from server action', async () => {
+ (validateInteractivePreview as jest.Mock).mockResolvedValue({
+ ok: false,
+ reason: 'Gateway returned 404.',
+ });
+
+ const { result } = renderHook(() => useArtworkSubmissionForm());
+
+ act(() => { result.current.setMediaSource('url'); });
+ act(() => { result.current.setExternalMediaHash(CID_V1); });
+
+ await waitFor(() => {
+ expect(result.current.externalMediaError).toBe('Gateway returned 404.');
+ });
+
+ expect(result.current.isExternalMediaValid).toBe(false);
+ expect(result.current.externalMediaPreviewUrl).toBe('');
+ expect(result.current.externalMediaValidationStatus).toBe('invalid');
+ });
+
+ it('restores uploaded artwork URL when switching back from external media', async () => {
+ const { result } = renderHook(() => useArtworkSubmissionForm());
+ class MockFileReader {
+ onloadend: (() => void) | null = null;
+ result = 'data-upload';
+ readAsDataURL() {
+ this.onloadend?.();
+ }
+ }
+ global.FileReader = MockFileReader as any;
+
+ act(() => {
+ result.current.handleFileSelect(new File(['x'], 'a.png'));
+ });
+
+ expect(result.current.mediaSource).toBe('upload');
+ expect(result.current.artworkUrl).toBe('data-upload');
+
+ act(() => {
+ result.current.setMediaSource('url');
+ });
+ act(() => {
+ result.current.setExternalMediaHash(CID_V1);
+ });
+
+ await waitFor(() => {
+ expect(result.current.isExternalMediaValid).toBe(true);
+ });
+ expect(result.current.artworkUrl).toContain('ipfs://');
+
+ act(() => {
+ result.current.setMediaSource('upload');
+ });
+
+ expect(result.current.artworkUrl).toBe('data-upload');
+ expect(result.current.artworkUploaded).toBe(true);
+ });
});
diff --git a/__tests__/components/waves/memes/submission/steps/ArtworkStep.test.tsx b/__tests__/components/waves/memes/submission/steps/ArtworkStep.test.tsx
index 7143c89077..338011b7a6 100644
--- a/__tests__/components/waves/memes/submission/steps/ArtworkStep.test.tsx
+++ b/__tests__/components/waves/memes/submission/steps/ArtworkStep.test.tsx
@@ -1,7 +1,8 @@
-import React from 'react';
+import React, { ComponentProps } from 'react';
import { render, screen } from '@testing-library/react';
import ArtworkStep from '@/components/waves/memes/submission/steps/ArtworkStep';
import { TraitsData } from '@/components/waves/memes/submission/types/TraitsData';
+import type { InteractiveMediaMimeType } from '@/components/waves/memes/submission/constants/media';
jest.mock('@/components/waves/memes/MemesArtSubmissionFile', () => () => );
jest.mock('@/components/waves/memes/submission/details/ArtworkDetails', () => (props: any) => (
@@ -67,20 +68,36 @@ function createTraits(): TraitsData {
};
}
+const createProps = (
+ override: Partial> = {}
+) => ({
+ traits: createTraits(),
+ artworkUploaded: false,
+ artworkUrl: '',
+ setArtworkUploaded: () => {},
+ handleFileSelect: () => {},
+ mediaSource: 'upload' as const,
+ setMediaSource: () => {},
+ externalHash: '',
+ externalProvider: 'ipfs',
+ externalConstructedUrl: '',
+ externalPreviewUrl: '',
+ externalMimeType: 'text/html' as InteractiveMediaMimeType,
+ externalError: null as string | null,
+ isExternalMediaValid: false,
+ onExternalHashChange: () => {},
+ onExternalProviderChange: () => {},
+ onExternalMimeTypeChange: (_value: InteractiveMediaMimeType) => {},
+ onClearExternalMedia: () => {},
+ onSubmit: () => {},
+ updateTraitField: () => {},
+ setTraits: () => {},
+ ...override,
+});
+
describe('ArtworkStep', () => {
it('shows upload tooltip when artwork missing', () => {
- render(
- {}}
- handleFileSelect={() => {}}
- onSubmit={() => {}}
- updateTraitField={() => {}}
- setTraits={() => {}}
- />
- );
+ render();
expect(screen.getByTestId('submit')).toBeDisabled();
expect(screen.getByTestId('submit').getAttribute('title')).toMatch('Please upload artwork');
});
@@ -108,14 +125,7 @@ describe('ArtworkStep', () => {
traits.boost = 'bo';
render(
{}}
- handleFileSelect={() => {}}
- onSubmit={() => {}}
- updateTraitField={() => {}}
- setTraits={() => {}}
+ {...createProps({ traits, artworkUploaded: true, artworkUrl: 'url' })}
/>
);
expect(screen.getByTestId('submit')).not.toBeDisabled();
@@ -127,14 +137,12 @@ describe('ArtworkStep', () => {
const onSubmit = jest.fn();
render(
{}}
- handleFileSelect={() => {}}
- onSubmit={onSubmit}
- updateTraitField={() => {}}
- setTraits={() => {}}
+ {...createProps({
+ traits: { ...traits, title: 't', description: 'd' },
+ artworkUploaded: true,
+ artworkUrl: 'u',
+ onSubmit,
+ })}
/>
);
screen.getByTestId('submit').click();
@@ -148,16 +156,13 @@ describe('ArtworkStep', () => {
const onCancel = jest.fn();
render(
{}}
- handleFileSelect={() => {}}
- onSubmit={() => {}}
- onCancel={onCancel}
- updateTraitField={() => {}}
- setTraits={() => {}}
- submissionPhase="uploading"
+ {...createProps({
+ traits,
+ artworkUploaded: true,
+ artworkUrl: 'url',
+ onCancel,
+ submissionPhase: 'uploading',
+ })}
/>
);
const btn = screen.getByRole('button', { name: /cancel/i });
diff --git a/components/common/SandboxedExternalIframe.tsx b/components/common/SandboxedExternalIframe.tsx
new file mode 100644
index 0000000000..7026315670
--- /dev/null
+++ b/components/common/SandboxedExternalIframe.tsx
@@ -0,0 +1,169 @@
+"use client";
+
+import React, { useEffect, useMemo, useRef, useState } from "react";
+import { canonicalizeInteractiveMediaUrl } from "@/components/waves/memes/submission/constants/security";
+
+// Sandbox policy for external interactive media:
+// - allow-scripts: Required for interactive HTML content.
+// - intentionally omits allow-pointer-lock, allow-same-origin and allow-popups to preserve isolation and block window spawning.
+const DEFAULT_SANDBOX = "allow-scripts";
+
+export interface SandboxedExternalIframeProps {
+ readonly src: string;
+ readonly title: string;
+ readonly className?: string;
+ readonly fallback?: React.ReactNode;
+ readonly containerClassName?: string;
+}
+
+/**
+ * Render untrusted interactive media inside a strongly sandboxed iframe.
+ *
+ * Security notes:
+ * - keep allow-same-origin disabled to force an opaque origin boundary.
+ * - deny all Permission Policy features via an explicit empty `allow` attribute.
+ * - enforce HTTPS and strip referrers; credentialless removes ambient cookies where supported.
+ * - the sandbox isolates the frame even if it redirects after the initial load.
+ */
+const SandboxedExternalIframe: React.FC = ({
+ src,
+ title,
+ className,
+ fallback = null,
+ containerClassName,
+}) => {
+ const containerRef = useRef(null);
+ const [isVisible, setIsVisible] = useState(false);
+
+ const canonicalSrc = useMemo(
+ () => canonicalizeInteractiveMediaUrl(src),
+ [src]
+ );
+
+ const frameClassName = useMemo(() => {
+ const classes = ["tw-h-full", "tw-w-full", className].filter(
+ (value): value is string => Boolean(value)
+ );
+ return classes.join(" ");
+ }, [className]);
+
+ useEffect(() => {
+ if (!canonicalSrc) {
+ return;
+ }
+
+ if (isVisible) {
+ return;
+ }
+
+ const element = containerRef.current;
+ if (!element) {
+ return;
+ }
+
+ if (typeof window === "undefined" || !("IntersectionObserver" in window)) {
+ setIsVisible(true);
+ return;
+ }
+
+ const observer = new IntersectionObserver(
+ (entries, observerInstance) => {
+ const isIntersecting = entries.some((entry) => entry.isIntersecting);
+ if (isIntersecting) {
+ setIsVisible(true);
+ observerInstance.disconnect();
+ }
+ },
+ { root: null, threshold: 0.1 }
+ );
+
+ observer.observe(element);
+
+ return () => {
+ observer.disconnect();
+ };
+ }, [canonicalSrc, isVisible]);
+
+ const parsedCanonicalUrl = useMemo(() => {
+ if (!canonicalSrc) {
+ return null;
+ }
+
+ try {
+ return new URL(canonicalSrc);
+ } catch {
+ return null;
+ }
+ }, [canonicalSrc]);
+
+ const iframeProps = useMemo(() => {
+ if (!canonicalSrc) {
+ return null;
+ }
+
+ const baseProps = {
+ src: canonicalSrc,
+ title,
+ sandbox: DEFAULT_SANDBOX,
+ // `allow=""` intentionally denies all Permission Policy features beyond the sandbox defaults.
+ allow: "",
+ referrerPolicy: "no-referrer",
+ loading: "lazy",
+ } as React.IframeHTMLAttributes & {
+ fetchPriority?: "high" | "low" | "auto";
+ credentialless?: string;
+ };
+
+ baseProps.fetchPriority = "low";
+ baseProps.credentialless = "";
+ baseProps.className = frameClassName;
+
+ return baseProps;
+ }, [canonicalSrc, frameClassName, title]);
+
+ if (!canonicalSrc || !iframeProps) {
+ return fallback ? <>{fallback}> : null;
+ }
+
+ const placeholder = (
+
+ );
+
+ const banner = (
+
+ );
+
+ const containerClasses = ["tw-flex", "tw-flex-col", "tw-h-full", containerClassName]
+ .filter((value): value is string => Boolean(value))
+ .join(" ");
+
+ return (
+
+ {banner}
+
+ {isVisible ? : placeholder}
+
+
+ );
+};
+
+export default SandboxedExternalIframe;
diff --git a/components/drops/view/item/content/media/DropListItemContentMedia.tsx b/components/drops/view/item/content/media/DropListItemContentMedia.tsx
index 878117f57f..62dd89eb04 100644
--- a/components/drops/view/item/content/media/DropListItemContentMedia.tsx
+++ b/components/drops/view/item/content/media/DropListItemContentMedia.tsx
@@ -5,12 +5,14 @@ import { ImageScale } from "@/helpers/image.helpers";
import DropListItemContentMediaImage from "./DropListItemContentMediaImage";
import DropListItemContentMediaVideo from "./DropListItemContentMediaVideo";
+import SandboxedExternalIframe from "@/components/common/SandboxedExternalIframe";
enum MediaType {
IMAGE = "IMAGE",
VIDEO = "VIDEO",
AUDIO = "AUDIO",
GLB = "GLB",
+ HTML = "HTML",
UNKNOWN = "UNKNOWN",
}
@@ -50,6 +52,9 @@ export default function DropListItemContentMedia({
media_url.endsWith(".gltf")) {
return MediaType.GLB;
}
+ if (media_mime_type === "text/html") {
+ return MediaType.HTML;
+ }
return MediaType.UNKNOWN;
};
@@ -72,6 +77,8 @@ export default function DropListItemContentMedia({
return ;
case MediaType.GLB:
return ;
+ case MediaType.HTML:
+ return ;
case MediaType.UNKNOWN:
return <>>;
default:
diff --git a/components/drops/view/item/content/media/MediaDisplay.tsx b/components/drops/view/item/content/media/MediaDisplay.tsx
index 239c74754f..21fa593b5a 100644
--- a/components/drops/view/item/content/media/MediaDisplay.tsx
+++ b/components/drops/view/item/content/media/MediaDisplay.tsx
@@ -6,12 +6,14 @@ import MediaDisplayImage from "./MediaDisplayImage";
import { ImageScale } from "@/helpers/image.helpers";
import MediaDisplayVideo from "./MediaDisplayVideo";
import MediaDisplayAudio from "./MediaDisplayAudio";
+import SandboxedExternalIframe from "@/components/common/SandboxedExternalIframe";
enum MediaType {
IMAGE = "IMAGE",
VIDEO = "VIDEO",
AUDIO = "AUDIO",
GLB = "GLB",
+ HTML = "HTML",
UNKNOWN = "UNKNOWN",
}
@@ -45,12 +47,15 @@ export default function MediaDisplay({
if (media_mime_type.includes("audio")) {
return MediaType.AUDIO;
}
- if (media_mime_type === "model/gltf-binary" ||
- media_mime_type === "model/gltf+json" ||
- media_url.endsWith(".glb") ||
- media_url.endsWith(".gltf")) {
+ if (media_mime_type === "model/gltf-binary" ||
+ media_mime_type === "model/gltf+json" ||
+ media_url.endsWith(".glb") ||
+ media_url.endsWith(".gltf")) {
return MediaType.GLB;
}
+ if (media_mime_type === "text/html") {
+ return MediaType.HTML;
+ }
return MediaType.UNKNOWN;
};
@@ -60,15 +65,17 @@ export default function MediaDisplay({
case MediaType.IMAGE:
return ;
case MediaType.VIDEO:
- return ;
case MediaType.AUDIO:
return ;
case MediaType.GLB:
return ;
+ case MediaType.HTML:
+ return ;
case MediaType.UNKNOWN:
return <>>;
default:
diff --git a/components/waves/memes/MemesArtSubmissionFile.tsx b/components/waves/memes/MemesArtSubmissionFile.tsx
index 53a6f714fa..69783eb271 100644
--- a/components/waves/memes/MemesArtSubmissionFile.tsx
+++ b/components/waves/memes/MemesArtSubmissionFile.tsx
@@ -4,11 +4,14 @@ import React, {
useCallback,
useContext,
useEffect,
+ useMemo,
useRef,
useState,
} from "react";
import { motion } from "framer-motion";
import { AuthContext } from "@/components/auth/Auth";
+import { TabToggle } from "@/components/common/TabToggle";
+import SandboxedExternalIframe from "@/components/common/SandboxedExternalIframe";
import FilePreview from "./file-upload/components/FilePreview";
import UploadArea from "./file-upload/components/UploadArea";
@@ -21,6 +24,18 @@ import useAccessibility from "./file-upload/hooks/useAccessibility";
import type { MemesArtSubmissionFileProps } from "./file-upload/reducers/types";
import { isBrowserSupported } from "./file-upload/utils/browserDetection";
import { FILE_INPUT_ACCEPT } from "./file-upload/utils/constants";
+import {
+ ALLOWED_INTERACTIVE_MEDIA_MIME_TYPES,
+ DEFAULT_INTERACTIVE_MEDIA_MIME_TYPE,
+ INTERACTIVE_MEDIA_PROVIDERS,
+} from "./submission/constants/media";
+
+const renderPreviewMessage = (primary: string, secondary: string) => (
+
+ {primary}
+ {secondary}
+
+);
/**
* Memes Art Submission File Component
@@ -36,6 +51,20 @@ const MemesArtSubmissionFile: React.FC = ({
artworkUrl,
setArtworkUploaded,
handleFileSelect,
+ mediaSource,
+ setMediaSource,
+ externalHash,
+ externalProvider,
+ externalConstructedUrl,
+ externalPreviewUrl,
+ externalMimeType,
+ externalError,
+ externalValidationStatus,
+ isExternalMediaValid,
+ onExternalHashChange,
+ onExternalProviderChange,
+ onExternalMimeTypeChange,
+ onClearExternalMedia,
}) => {
const { setToast } = useContext(AuthContext);
@@ -70,8 +99,11 @@ const MemesArtSubmissionFile: React.FC = ({
// Refs, event handlers
const fileInputRef = useRef(null);
const handleAreaClick = useCallback(() => {
+ if (mediaSource !== "upload") {
+ return;
+ }
fileInputRef.current?.click();
- }, []);
+ }, [mediaSource]);
const {
dropAreaRef,
@@ -80,13 +112,13 @@ const MemesArtSubmissionFile: React.FC = ({
handleDragLeave,
handleDrop,
} = useDragAndDrop({
- enabled: !artworkUploaded,
+ enabled: mediaSource === "upload" && !artworkUploaded,
onFileDrop: processFile,
setVisualState,
});
const { handleKeyDown } = useAccessibility({
- isActive: !artworkUploaded,
+ isActive: mediaSource === "upload" && !artworkUploaded,
onAreaClick: handleAreaClick,
prefersReducedMotion,
});
@@ -95,11 +127,64 @@ const MemesArtSubmissionFile: React.FC = ({
(e: React.ChangeEvent) => {
const file = e.target.files?.[0];
if (file) {
- processFile(file);
+ if (mediaSource === "upload") {
+ processFile(file);
+ }
e.target.value = "";
}
},
- [processFile]
+ [processFile, mediaSource]
+ );
+
+ const tabOptions = [
+ {
+ key: "upload",
+ label: "Upload File",
+ panelId: "memes-art-submission-upload-panel",
+ },
+ {
+ key: "url",
+ label: "Interactive HTML",
+ panelId: "memes-art-submission-interactive-panel",
+ },
+ ] as const;
+
+ const handleTabSelect = useCallback(
+ (key: string) => {
+ if (key === "upload") {
+ setMediaSource("upload");
+ return;
+ }
+
+ if (key === "url") {
+ onExternalMimeTypeChange(DEFAULT_INTERACTIVE_MEDIA_MIME_TYPE);
+ setMediaSource("url");
+ }
+ },
+ [setMediaSource, onExternalMimeTypeChange],
+ );
+
+ const providerOptions = useMemo(
+ () =>
+ INTERACTIVE_MEDIA_PROVIDERS.map((provider) => ({
+ key: provider.key,
+ label: provider.label,
+ panelId: `memes-art-submission-provider-${provider.key}`,
+ })),
+ [],
+ );
+
+ const handleProviderSelect = useCallback(
+ (key: string) => {
+ if (key === externalProvider) {
+ return;
+ }
+
+ if (key === "ipfs" || key === "arweave") {
+ onExternalProviderChange(key);
+ }
+ },
+ [externalProvider, onExternalProviderChange],
);
// Check browser compatibility on mount
@@ -145,80 +230,206 @@ const MemesArtSubmissionFile: React.FC = ({
setPreviewUrl(objectUrl ?? artworkUrl);
}, [currentFile, objectUrl, artworkUrl]);
- return (
-
- {/* Hidden for selecting the file */}
-
-
- {/* Show a warning overlay if the user's browser lacks necessary features */}
- {!browserSupport.supported && browserSupport.reason && (
-
- )}
+ const showUploadUi = mediaSource === "upload";
+
+ const previewFallback = useMemo(() => {
+ if (externalValidationStatus === "pending") {
+ return renderPreviewMessage(
+ "Validating preview...",
+ "We're verifying the gateway serves an HTML document.",
+ );
+ }
+
+ if (externalValidationStatus === "invalid" && externalError) {
+ return renderPreviewMessage(
+ externalError,
+ "Only ipfs.io or arweave.net HTML documents can be embedded.",
+ );
+ }
+
+ return renderPreviewMessage(
+ "Provide a valid hash or CID to enable the preview.",
+ "The final artwork is rendered securely inside a sandboxed iframe.",
+ );
+ }, [externalValidationStatus, externalError]);
+
+ const mediaTypeLabel = useMemo(() => {
+ const match = ALLOWED_INTERACTIVE_MEDIA_MIME_TYPES.find(
+ (type) => type.value === externalMimeType,
+ );
+ return match?.label ?? externalMimeType;
+ }, [externalMimeType]);
- {/* If not uploaded yet, show the upload area UI, else show the preview */}
- {!artworkUploaded ? (
-
+
+
+
+
+ {showUploadUi ? (
+
+
+
+ {!browserSupport.supported && browserSupport.reason && (
+
+ )}
+
+ {!artworkUploaded ? (
+
+ ) : (
+
+ )}
+
) : (
-
+
+
+
+ Hosting Network
+
+
+
+
+
+
+
+
+
onExternalHashChange(event.target.value)}
+ aria-invalid={Boolean(externalError)}
+ />
+ {externalError && (
+
{externalError}
+ )}
+ {externalConstructedUrl && !externalError && (
+
+ Resulting URL{" "}
+ {externalConstructedUrl}
+
+ )}
+
+
+
+
+ Media Type
+
+
+ {mediaTypeLabel}
+
+ Fixed to interactive HTML (text/html)
+
+
+
+
+
+
+
+
+
+ {isExternalMediaValid ? (
+
+ ) : (
+
{previewFallback}
+ )}
+
+
)}
-
+
);
};
-export default React.memo(
- MemesArtSubmissionFile,
- (prev, next) =>
- prev.artworkUploaded === next.artworkUploaded &&
- prev.artworkUrl === next.artworkUrl
-);
+export default React.memo(MemesArtSubmissionFile);
diff --git a/components/waves/memes/file-upload/reducers/types.ts b/components/waves/memes/file-upload/reducers/types.ts
index a98b6de5c0..25401a4902 100644
--- a/components/waves/memes/file-upload/reducers/types.ts
+++ b/components/waves/memes/file-upload/reducers/types.ts
@@ -1,3 +1,8 @@
+import type {
+ InteractiveMediaMimeType,
+ InteractiveMediaProvider,
+} from "../../submission/constants/media";
+
/**
* Type definitions for the file upload system
*
@@ -72,6 +77,38 @@ export interface MemesArtSubmissionFileProps {
readonly setArtworkUploaded: (uploaded: boolean) => void;
/** Callback for handling file selection */
readonly handleFileSelect: (file: File) => void;
+ /** Current media source mode */
+ readonly mediaSource: "upload" | "url";
+ /** Update media source mode */
+ readonly setMediaSource: (mode: "upload" | "url") => void;
+ /** Raw hash or CID when using hosted interactive content */
+ readonly externalHash: string;
+ /** Selected decentralized hosting provider */
+ readonly externalProvider: InteractiveMediaProvider;
+ /** Fully constructed URL from the hash */
+ readonly externalConstructedUrl: string;
+ /** URL used for previewing inside the modal */
+ readonly externalPreviewUrl: string;
+ /** External media MIME type */
+ readonly externalMimeType: InteractiveMediaMimeType;
+ /** Validation error for external media */
+ readonly externalError: string | null;
+ /** Current validation status for external media */
+ readonly externalValidationStatus: "idle" | "pending" | "valid" | "invalid";
+ /** Whether external media input is valid */
+ readonly isExternalMediaValid: boolean;
+ /** Handler for changing the interactive hash input */
+ readonly onExternalHashChange: (value: string) => void;
+ /** Handler for changing the hosting provider */
+ readonly onExternalProviderChange: (
+ value: InteractiveMediaProvider
+ ) => void;
+ /** Handler for changing the external media MIME type */
+ readonly onExternalMimeTypeChange: (
+ value: InteractiveMediaMimeType
+ ) => void;
+ /** Reset external media selection */
+ readonly onClearExternalMedia: () => void;
}
/**
@@ -150,4 +187,4 @@ export interface ErrorMessageProps {
readonly showRetry: boolean;
/** Handler for retry action */
readonly onRetry: (e: React.MouseEvent) => void;
-}
\ No newline at end of file
+}
diff --git a/components/waves/memes/submission/MemesArtSubmissionContainer.tsx b/components/waves/memes/submission/MemesArtSubmissionContainer.tsx
index f286b023ed..f8dee24e77 100644
--- a/components/waves/memes/submission/MemesArtSubmissionContainer.tsx
+++ b/components/waves/memes/submission/MemesArtSubmissionContainer.tsx
@@ -1,6 +1,6 @@
"use client";
-import React, { useState, useCallback, useEffect } from "react";
+import React, { useCallback, useEffect } from "react";
import { motion } from "framer-motion";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faXmark } from "@fortawesome/free-solid-svg-icons";
@@ -45,13 +45,6 @@ const MemesArtSubmissionContainer: React.FC<
isSubmitting,
} = useArtworkSubmissionMutation();
- // Keep track of the selected file
- const [selectedFile, setSelectedFile] = useState(null);
- const [fileInfo, setFileInfo] = useState<{
- name: string;
- size: number;
- } | null>(null);
-
// Auto-close on successful submission after a short delay
useEffect(() => {
if (submissionPhase === "success") {
@@ -65,14 +58,6 @@ const MemesArtSubmissionContainer: React.FC<
// Handle file selection
const handleFileSelect = (file: File) => {
- // Store the file for later submission
- setSelectedFile(file);
- setFileInfo({
- name: file.name,
- size: file.size,
- });
-
- // Also pass to the form hook for preview
form.handleFileSelect(file);
};
@@ -84,17 +69,40 @@ const MemesArtSubmissionContainer: React.FC<
// Handle final submission
const handleSubmit = async () => {
- if (!selectedFile) {
- return null;
- }
-
// Get submission data including all traits
const { traits } = form.getSubmissionData();
+ const media = form.getMediaSelection();
+
+ if (media.mediaSource === "upload") {
+ if (!media.selectedFile) {
+ return null;
+ }
+
+ return submitArtwork(
+ {
+ imageFile: media.selectedFile,
+ traits,
+ waveId: wave.id,
+ termsOfService: wave.participation.terms,
+ },
+ address ?? "",
+ isSafeWallet,
+ {
+ onPhaseChange: handlePhaseChange,
+ }
+ );
+ }
+
+ if (!media.isExternalValid) {
+ return null;
+ }
- // Submit the artwork with the wave ID and selected file
- const result = await submitArtwork(
+ return submitArtwork(
{
- imageFile: selectedFile,
+ externalMedia: {
+ url: media.externalUrl,
+ mimeType: media.externalMimeType,
+ },
traits,
waveId: wave.id,
termsOfService: wave.participation.terms,
@@ -105,10 +113,15 @@ const MemesArtSubmissionContainer: React.FC<
onPhaseChange: handlePhaseChange,
}
);
-
- return result;
};
+ const fileInfo = form.selectedFile
+ ? {
+ name: form.selectedFile.name,
+ size: form.selectedFile.size,
+ }
+ : null;
+
// Map of steps to their corresponding components
const stepComponents = {
[SubmissionStep.AGREEMENT]: (
@@ -126,6 +139,20 @@ const MemesArtSubmissionContainer: React.FC<
artworkUrl={form.artworkUrl}
setArtworkUploaded={form.setArtworkUploaded}
handleFileSelect={handleFileSelect}
+ mediaSource={form.mediaSource}
+ setMediaSource={form.setMediaSource}
+ externalHash={form.externalMediaHashInput}
+ externalProvider={form.externalMediaProvider}
+ externalConstructedUrl={form.externalMediaUrl}
+ externalPreviewUrl={form.externalMediaPreviewUrl}
+ externalMimeType={form.externalMediaMimeType}
+ externalError={form.externalMediaError}
+ externalValidationStatus={form.externalMediaValidationStatus}
+ isExternalMediaValid={form.isExternalMediaValid}
+ onExternalHashChange={form.setExternalMediaHash}
+ onExternalProviderChange={form.setExternalMediaProvider}
+ onExternalMimeTypeChange={form.setExternalMediaMimeType}
+ onClearExternalMedia={form.clearExternalMedia}
onSubmit={handleSubmit}
onCancel={onClose}
updateTraitField={form.updateTraitField}
diff --git a/components/waves/memes/submission/actions/validateInteractivePreview.ts b/components/waves/memes/submission/actions/validateInteractivePreview.ts
new file mode 100644
index 0000000000..f254cee2d0
--- /dev/null
+++ b/components/waves/memes/submission/actions/validateInteractivePreview.ts
@@ -0,0 +1,243 @@
+'use server';
+
+import {
+ INTERACTIVE_MEDIA_ALLOWED_CONTENT_TYPES,
+ INTERACTIVE_MEDIA_GATEWAY_BASE_URL,
+ InteractiveMediaValidationResult,
+ canonicalizeInteractiveMediaHostname,
+ isInteractiveMediaAllowedHost,
+ isInteractiveMediaContentIdentifier,
+ isInteractiveMediaContentPathAllowed,
+} from "../constants/security";
+import { InteractiveMediaProvider } from "../constants/media";
+
+type GatewayRequestMethod = "HEAD" | "GET";
+
+interface ValidateInteractivePreviewArgs {
+ readonly provider: InteractiveMediaProvider;
+ readonly path: string;
+}
+
+const MAX_BYTES_TO_PEEK = 1024;
+
+const isAllowedContentType = (contentType: string | null): boolean => {
+ if (!contentType) {
+ return false;
+ }
+
+ const normalized = contentType.toLowerCase().split(";")[0]?.trim() ?? "";
+ return INTERACTIVE_MEDIA_ALLOWED_CONTENT_TYPES.some((allowed) =>
+ normalized.startsWith(allowed)
+ );
+};
+
+const ensureAllowedHost = (url: string): boolean => {
+ try {
+ const parsed = new URL(url);
+ if (parsed.protocol !== "https:") {
+ return false;
+ }
+ if (parsed.username || parsed.password) {
+ return false;
+ }
+ if (parsed.port) {
+ if (parsed.port === "443") {
+ parsed.port = "";
+ } else {
+ return false;
+ }
+ }
+
+ const normalizedHostname = canonicalizeInteractiveMediaHostname(
+ parsed.hostname
+ );
+ if (!normalizedHostname) {
+ return false;
+ }
+ if (normalizedHostname !== parsed.hostname) {
+ parsed.hostname = normalizedHostname;
+ }
+
+ return (
+ isInteractiveMediaAllowedHost(parsed.hostname) &&
+ isInteractiveMediaContentPathAllowed(parsed.hostname, parsed.pathname)
+ );
+ } catch {
+ return false;
+ }
+};
+
+const performGatewayRequest = async (
+ url: string,
+ method: GatewayRequestMethod
+): Promise => {
+ const headers: Record = {};
+ if (method === "GET") {
+ headers.Range = `bytes=0-${MAX_BYTES_TO_PEEK}`;
+ }
+
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 10000);
+
+ try {
+ return await fetch(url, {
+ method,
+ cache: "no-store",
+ redirect: "follow",
+ headers,
+ signal: controller.signal,
+ });
+ } finally {
+ clearTimeout(timeoutId);
+ }
+};
+
+const buildGatewayUrl = (
+ provider: InteractiveMediaProvider,
+ path: string
+): string => {
+ const base = INTERACTIVE_MEDIA_GATEWAY_BASE_URL[provider];
+ // URL constructor handles duplicate slashes gracefully.
+ return new URL(path, base).toString();
+};
+
+export async function validateInteractivePreview({
+ provider,
+ path,
+}: ValidateInteractivePreviewArgs): Promise {
+ if (!path) {
+ return { ok: false, reason: "Hash or path is required." };
+ }
+
+ const trimmedPath = path.trim();
+ if (!trimmedPath || trimmedPath !== path) {
+ return {
+ ok: false,
+ reason: "Invalid path: only relative paths under the gateway origin are allowed.",
+ };
+ }
+
+ if (
+ trimmedPath.includes("/") ||
+ trimmedPath.includes("\\") ||
+ trimmedPath.includes("//") ||
+ trimmedPath.includes("\\\\") ||
+ trimmedPath.includes("..") ||
+ trimmedPath.includes("\n") ||
+ trimmedPath.includes("\r") ||
+ trimmedPath.includes("\t") ||
+ trimmedPath.includes("://") ||
+ trimmedPath.includes("?") ||
+ trimmedPath.includes("#")
+ ) {
+ return {
+ ok: false,
+ reason: "Invalid path: only relative paths under the gateway origin are allowed.",
+ };
+ }
+
+ if (!isInteractiveMediaContentIdentifier(provider, trimmedPath)) {
+ return {
+ ok: false,
+ reason:
+ provider === "ipfs"
+ ? "Invalid path: expected a CIDv0 or CIDv1 root hash."
+ : "Invalid path: expected an Arweave transaction ID.",
+ };
+ }
+
+ const targetUrl = buildGatewayUrl(provider, trimmedPath);
+
+ // Block requests whose resolved host is not part of the trusted gateways.
+ if (!ensureAllowedHost(targetUrl)) {
+ return {
+ ok: false,
+ reason: "Invalid path: resolved target is not permitted under allowed gateway hosts.",
+ };
+ }
+
+ let response: Response;
+ try {
+ response = await performGatewayRequest(targetUrl, "HEAD");
+ } catch (error) {
+ console.error(
+ "[validateInteractivePreview] HEAD request failed",
+ {
+ provider,
+ path,
+ targetUrl,
+ error,
+ }
+ );
+ return {
+ ok: false,
+ reason: "Unable to reach the content gateway.",
+ };
+ }
+
+ if (
+ response.status === 405 ||
+ response.status === 403 ||
+ response.status === 501
+ ) {
+ try {
+ response = await performGatewayRequest(targetUrl, "GET");
+ } catch (error) {
+ console.error(
+ "[validateInteractivePreview] Fallback GET request failed",
+ {
+ provider,
+ path,
+ targetUrl,
+ error,
+ }
+ );
+ return {
+ ok: false,
+ reason: "Unable to reach the content gateway.",
+ };
+ }
+ }
+
+ if (!response.ok && response.status !== 206) {
+ return {
+ ok: false,
+ reason: `Gateway returned ${response.status}.`,
+ };
+ }
+
+ // Ensure the final resolved URL is still within the trusted hosts.
+ if (!ensureAllowedHost(response.url)) {
+ console.warn(
+ "[validateInteractivePreview] Blocking redirect to unapproved host",
+ {
+ resolvedUrl: response.url,
+ }
+ );
+ return {
+ ok: false,
+ reason: "Gateway redirected to an unapproved host.",
+ };
+ }
+
+ const contentType = response.headers.get("content-type");
+ if (!isAllowedContentType(contentType)) {
+ return {
+ ok: false,
+ reason: "Media must respond with an HTML document.",
+ };
+ }
+
+ // Best-effort cancellation of the body stream for GET validations.
+ try {
+ await response.body?.cancel();
+ } catch {
+ // ignore cancellation errors
+ }
+
+ return {
+ ok: true,
+ finalUrl: response.url,
+ contentType,
+ };
+}
diff --git a/components/waves/memes/submission/constants/media.ts b/components/waves/memes/submission/constants/media.ts
new file mode 100644
index 0000000000..1367973f8f
--- /dev/null
+++ b/components/waves/memes/submission/constants/media.ts
@@ -0,0 +1,32 @@
+// Supported MIME types when creators embed hosted interactive experiences.
+export const ALLOWED_INTERACTIVE_MEDIA_MIME_TYPES = [
+ { value: "text/html", label: "Interactive HTML" },
+ { value: "image/png", label: "Image (PNG)" },
+ { value: "image/jpeg", label: "Image (JPG)" },
+ { value: "video/mp4", label: "Video (MP4)" },
+ { value: "video/webm", label: "Video (WebM)" },
+] as const;
+
+export const DEFAULT_INTERACTIVE_MEDIA_MIME_TYPE =
+ ALLOWED_INTERACTIVE_MEDIA_MIME_TYPES[0].value;
+
+export type InteractiveMediaMimeType =
+ (typeof ALLOWED_INTERACTIVE_MEDIA_MIME_TYPES)[number]["value"];
+
+export const ALLOWED_INTERACTIVE_MEDIA_MIME_TYPE_SET =
+ new Set(
+ ALLOWED_INTERACTIVE_MEDIA_MIME_TYPES.map((item) => item.value)
+ );
+
+export const isAllowedInteractiveMediaMimeType = (
+ value: string
+): value is InteractiveMediaMimeType =>
+ ALLOWED_INTERACTIVE_MEDIA_MIME_TYPES.some((item) => item.value === value);
+
+export const INTERACTIVE_MEDIA_PROVIDERS = [
+ { key: "ipfs", label: "IPFS" },
+ { key: "arweave", label: "Arweave" },
+] as const;
+
+export type InteractiveMediaProvider =
+ (typeof INTERACTIVE_MEDIA_PROVIDERS)[number]["key"];
diff --git a/components/waves/memes/submission/constants/security.ts b/components/waves/memes/submission/constants/security.ts
new file mode 100644
index 0000000000..b05bb3a48f
--- /dev/null
+++ b/components/waves/memes/submission/constants/security.ts
@@ -0,0 +1,237 @@
+import { InteractiveMediaProvider } from "./media";
+
+const INTERACTIVE_MEDIA_IPFS_HOSTS = new Set([
+ "ipfs.io",
+ "www.ipfs.io",
+]);
+
+const ARWEAVE_ROOT_HOSTS = new Set(["arweave.net", "www.arweave.net"]);
+const ARWEAVE_SUBDOMAIN_PATTERN = /^([a-z0-9_-]{43,87})\.arweave\.net$/;
+
+const CIDV0_PATTERN = /^Qm[1-9A-HJ-NP-Za-km-z]{44}$/;
+const CIDV1_PATTERN = /^b[a-z2-7]{52,}$/;
+
+const ARWEAVE_TX_ID_PATTERN = /^[a-zA-Z0-9_-]{43,87}$/;
+
+const IPFS_PATH_PATTERN = /^\/ipfs\/([^/]+)$/;
+const ARWEAVE_PATH_PATTERN = /^\/([^/]+)$/;
+
+export const canonicalizeInteractiveMediaHostname = (
+ hostname: string
+): string => {
+ let normalized = hostname.toLowerCase();
+ while (normalized.endsWith(".")) {
+ normalized = normalized.slice(0, -1);
+ }
+ return normalized;
+};
+
+const isIpfsHost = (hostname: string): boolean =>
+ INTERACTIVE_MEDIA_IPFS_HOSTS.has(canonicalizeInteractiveMediaHostname(hostname));
+
+const getArweaveTransactionIdFromSubdomain = (
+ hostname: string
+): string | null => {
+ const normalized = canonicalizeInteractiveMediaHostname(hostname);
+ const match = ARWEAVE_SUBDOMAIN_PATTERN.exec(normalized);
+ return match ? match[1] : null;
+};
+
+const isArweaveHost = (hostname: string): boolean => {
+ const normalized = canonicalizeInteractiveMediaHostname(hostname);
+ if (ARWEAVE_ROOT_HOSTS.has(normalized)) {
+ return true;
+ }
+
+ return getArweaveTransactionIdFromSubdomain(hostname) !== null;
+};
+
+export const getInteractiveMediaProviderForHost = (
+ hostname: string
+): InteractiveMediaProvider | null => {
+ if (isIpfsHost(hostname)) {
+ return "ipfs";
+ }
+
+ if (isArweaveHost(hostname)) {
+ return "arweave";
+ }
+
+ return null;
+};
+
+export const isInteractiveMediaAllowedHost = (hostname: string): boolean =>
+ getInteractiveMediaProviderForHost(hostname) !== null;
+
+const isValidIpfsCid = (cid: string): boolean =>
+ CIDV0_PATTERN.test(cid) || CIDV1_PATTERN.test(cid);
+
+const isValidArweaveTransactionId = (txId: string): boolean =>
+ ARWEAVE_TX_ID_PATTERN.test(txId);
+
+export const isInteractiveMediaContentIdentifier = (
+ provider: InteractiveMediaProvider,
+ identifier: string
+): boolean => {
+ if (!identifier) {
+ return false;
+ }
+
+ const trimmed = identifier.trim();
+ if (!trimmed || trimmed !== identifier) {
+ return false;
+ }
+
+ if (provider === "ipfs") {
+ return isValidIpfsCid(trimmed);
+ }
+
+ if (provider === "arweave") {
+ return isValidArweaveTransactionId(trimmed);
+ }
+
+ return false;
+};
+
+export const isInteractiveMediaContentPathAllowed = (
+ hostname: string,
+ pathname: string
+): boolean => {
+ const provider = getInteractiveMediaProviderForHost(hostname);
+ if (!provider) {
+ return false;
+ }
+
+ if (provider === "ipfs") {
+ let normalizedPath = pathname;
+ while (normalizedPath.endsWith("/") && normalizedPath !== "/") {
+ normalizedPath = normalizedPath.slice(0, -1);
+ }
+
+ const match = IPFS_PATH_PATTERN.exec(normalizedPath);
+ if (!match) {
+ return false;
+ }
+
+ if (!isInteractiveMediaContentIdentifier(provider, match[1])) {
+ return false;
+ }
+
+ if (normalizedPath === pathname) {
+ return true;
+ }
+
+ return pathname === `${normalizedPath}/`;
+ }
+
+ if (provider === "arweave") {
+ const subdomainIdentifier = getArweaveTransactionIdFromSubdomain(hostname);
+ if (subdomainIdentifier) {
+ if (
+ !isInteractiveMediaContentIdentifier(provider, subdomainIdentifier)
+ ) {
+ return false;
+ }
+
+ if (pathname === "/") {
+ return true;
+ }
+
+ const subdomainMatch = ARWEAVE_PATH_PATTERN.exec(pathname);
+ if (!subdomainMatch) {
+ return false;
+ }
+
+ return isInteractiveMediaContentIdentifier(provider, subdomainMatch[1]);
+ }
+
+ const match = ARWEAVE_PATH_PATTERN.exec(pathname);
+ if (!match) {
+ return false;
+ }
+
+ return isInteractiveMediaContentIdentifier(provider, match[1]);
+ }
+
+ return false;
+};
+
+export const canonicalizeInteractiveMediaUrl = (src: string): string | null => {
+ let parsedUrl: URL;
+ try {
+ parsedUrl = new URL(src);
+ } catch {
+ return null;
+ }
+
+ if (parsedUrl.protocol !== "https:") {
+ return null;
+ }
+
+ if (parsedUrl.username || parsedUrl.password) {
+ return null;
+ }
+
+ if (parsedUrl.search || parsedUrl.hash) {
+ return null;
+ }
+
+ if (parsedUrl.port) {
+ if (parsedUrl.port === "443") {
+ parsedUrl.port = "";
+ } else {
+ return null;
+ }
+ }
+
+ const normalizedHostname = canonicalizeInteractiveMediaHostname(
+ parsedUrl.hostname
+ );
+ if (!normalizedHostname) {
+ return null;
+ }
+
+ if (normalizedHostname !== parsedUrl.hostname) {
+ parsedUrl.hostname = normalizedHostname;
+ }
+
+ if (!isInteractiveMediaAllowedHost(parsedUrl.hostname)) {
+ return null;
+ }
+
+ if (
+ !isInteractiveMediaContentPathAllowed(
+ parsedUrl.hostname,
+ parsedUrl.pathname
+ )
+ ) {
+ return null;
+ }
+
+ parsedUrl.username = "";
+ parsedUrl.password = "";
+ parsedUrl.hash = "";
+ parsedUrl.search = "";
+
+ return parsedUrl.toString();
+};
+
+export const INTERACTIVE_MEDIA_GATEWAY_BASE_URL: Record<
+ InteractiveMediaProvider,
+ string
+> = {
+ ipfs: "https://ipfs.io/ipfs/",
+ arweave: "https://arweave.net/",
+};
+
+export const INTERACTIVE_MEDIA_ALLOWED_CONTENT_TYPES = [
+ "text/html",
+ "application/xhtml+xml",
+];
+
+export interface InteractiveMediaValidationResult {
+ readonly ok: boolean;
+ readonly reason?: string;
+ readonly contentType?: string | null;
+ readonly finalUrl?: string;
+}
diff --git a/components/waves/memes/submission/hooks/useArtworkSubmissionForm.ts b/components/waves/memes/submission/hooks/useArtworkSubmissionForm.ts
index 1a95da40a1..0d1f2ea984 100644
--- a/components/waves/memes/submission/hooks/useArtworkSubmissionForm.ts
+++ b/components/waves/memes/submission/hooks/useArtworkSubmissionForm.ts
@@ -1,39 +1,203 @@
"use client";
-import { useReducer, useEffect, useCallback } from "react";
+import { useReducer, useEffect, useCallback, useRef } from "react";
import { TraitsData } from "../types/TraitsData";
import { SubmissionStep } from "../types/Steps";
import { useAuth } from "@/components/auth/Auth";
import { getInitialTraitsValues } from "@/components/waves/memes/traits/schema";
+import {
+ DEFAULT_INTERACTIVE_MEDIA_MIME_TYPE,
+ InteractiveMediaMimeType,
+ InteractiveMediaProvider,
+} from "../constants/media";
+import {
+ INTERACTIVE_MEDIA_GATEWAY_BASE_URL,
+ isInteractiveMediaContentIdentifier,
+} from "../constants/security";
+import { validateInteractivePreview } from "../actions/validateInteractivePreview";
+
+type MediaSource = "upload" | "url";
+
+interface ExternalMediaState {
+ input: string;
+ sanitizedHash: string;
+ provider: InteractiveMediaProvider;
+ url: string;
+ previewUrl: string;
+ mimeType: InteractiveMediaMimeType;
+ error: string | null;
+ status: "idle" | "pending" | "valid" | "invalid";
+ isValid: boolean;
+}
-/**
- * Action types for the form reducer - drastically simplified
- */
type FormAction =
| { type: "SET_STEP"; payload: SubmissionStep }
| { type: "SET_AGREEMENTS"; payload: boolean }
- | { type: "SET_ARTWORK_UPLOADED"; payload: boolean }
- | { type: "SET_ARTWORK_URL"; payload: string }
| {
type: "SET_TRAIT_FIELD";
payload: { field: keyof TraitsData; value: any };
}
- | { type: "SET_MULTIPLE_TRAITS"; payload: Partial };
+ | { type: "SET_MULTIPLE_TRAITS"; payload: Partial }
+ | { type: "SET_MEDIA_SOURCE"; payload: MediaSource }
+ | { type: "SET_EXTERNAL_MEDIA"; payload: ExternalMediaState }
+ | {
+ type: "SET_EXTERNAL_MEDIA_VALIDATION";
+ payload: {
+ status: ExternalMediaState["status"];
+ error: string | null;
+ finalUrl?: string;
+ };
+ }
+ | {
+ type: "SET_UPLOAD_MEDIA";
+ payload: { file: File; artworkUrl: string };
+ }
+ | { type: "RESET_UPLOAD_MEDIA" };
-/**
- * State interface for the form reducer
- */
interface FormState {
currentStep: SubmissionStep;
agreements: boolean;
artworkUploaded: boolean;
artworkUrl: string;
+ uploadArtworkUrl: string;
traits: TraitsData;
+ mediaSource: MediaSource;
+ selectedFile: File | null;
+ externalMedia: ExternalMediaState;
}
-/**
- * Ultra-simplified reducer function for the artwork submission form
- */
+const sanitizeInteractiveHash = (
+ input: string,
+ provider: InteractiveMediaProvider
+): string => {
+ if (!input) {
+ return "";
+ }
+
+ let value = input.trim();
+
+ if (provider === "ipfs") {
+ value = value.replace(/^ipfs:\/\//i, "");
+ value = value.replace(/^https?:\/\/[^/]+\/ipfs\//i, "");
+ value = value.replace(/^ipfs\//i, "");
+ } else if (provider === "arweave") {
+ value = value.replace(/^https?:\/\/(?:www\.)?arweave\.net\//i, "");
+ }
+
+ value = value.replace(/^\/+/, "");
+ return value;
+};
+
+const isSafeRelativeGatewayPath = (path: string): boolean => {
+ if (!path) {
+ return false;
+ }
+
+ const trimmed = path.trim();
+ if (trimmed !== path) {
+ return false;
+ }
+
+ const lower = trimmed.toLowerCase();
+
+ if (
+ trimmed.startsWith("/") ||
+ trimmed.startsWith("\\") ||
+ trimmed.startsWith("//") ||
+ trimmed.startsWith("\\\\") ||
+ lower.startsWith("http:") ||
+ lower.startsWith("https:") ||
+ lower.includes("://") ||
+ trimmed.includes("\n") ||
+ trimmed.includes("\r")
+ ) {
+ return false;
+ }
+
+ return true;
+};
+
+const buildExternalMediaState = (
+ input: string,
+ provider: InteractiveMediaProvider,
+ mimeType: InteractiveMediaMimeType
+): ExternalMediaState => {
+ const trimmedInput = input.trim();
+ let sanitizedHash = sanitizeInteractiveHash(trimmedInput, provider);
+ const hasHash = sanitizedHash.length > 0;
+
+ let error: string | null = null;
+ if (trimmedInput && !hasHash) {
+ error = "Enter a valid hash or CID.";
+ } else if (/\s/.test(sanitizedHash)) {
+ error = "Hashes cannot contain whitespace.";
+ }
+
+ // Drop query/fragment markers without regex backtracking risk.
+ if (!error && !isSafeRelativeGatewayPath(sanitizedHash)) {
+ error = "Only relative paths under the gateway origin are allowed.";
+ }
+
+ if (!error) {
+ const hashWithoutQuery = sanitizedHash.split(/[?#]/)[0] ?? sanitizedHash;
+ if (hashWithoutQuery !== sanitizedHash) {
+ error = "Remove query strings or fragments from the hash.";
+ }
+ sanitizedHash = hashWithoutQuery;
+ }
+
+ if (!error && sanitizedHash.includes("/")) {
+ error =
+ provider === "ipfs"
+ ? "IPFS embeds must reference the root CID without subpaths."
+ : "Arweave embeds must reference the transaction ID without subpaths.";
+ }
+
+ if (!error && sanitizedHash.includes("..")) {
+ error = "Remove path traversal segments from the hash.";
+ }
+
+ if (!error && sanitizedHash) {
+ const isValidIdentifier = isInteractiveMediaContentIdentifier(
+ provider,
+ sanitizedHash
+ );
+ if (!isValidIdentifier) {
+ error =
+ provider === "ipfs"
+ ? "Enter a valid IPFS CID (CIDv0 or CIDv1)."
+ : "Enter a valid Arweave transaction ID.";
+ }
+ }
+
+ let status: ExternalMediaState["status"] = "idle";
+ if (hasHash) {
+ status = error ? "invalid" : "pending";
+ }
+
+ const previewUrl =
+ status !== "idle" && !error
+ ? `${INTERACTIVE_MEDIA_GATEWAY_BASE_URL[provider]}${sanitizedHash}`
+ : "";
+
+ let url = "";
+ if (status !== "idle" && !error) {
+ url = provider === "arweave" ? previewUrl : `ipfs://${sanitizedHash}`;
+ }
+
+ return {
+ input,
+ sanitizedHash,
+ provider,
+ url,
+ previewUrl,
+ mimeType,
+ error,
+ status,
+ isValid: false,
+ };
+};
+
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case "SET_STEP":
@@ -42,14 +206,7 @@ function formReducer(state: FormState, action: FormAction): FormState {
case "SET_AGREEMENTS":
return { ...state, agreements: action.payload };
- case "SET_ARTWORK_UPLOADED":
- return { ...state, artworkUploaded: action.payload };
-
- case "SET_ARTWORK_URL":
- return { ...state, artworkUrl: action.payload };
-
- case "SET_TRAIT_FIELD": {
- // Simple direct update - no special handling
+ case "SET_TRAIT_FIELD":
return {
...state,
traits: {
@@ -57,10 +214,8 @@ function formReducer(state: FormState, action: FormAction): FormState {
[action.payload.field]: action.payload.value,
},
};
- }
- case "SET_MULTIPLE_TRAITS": {
- // Simple merge - no special handling
+ case "SET_MULTIPLE_TRAITS":
return {
...state,
traits: {
@@ -68,6 +223,93 @@ function formReducer(state: FormState, action: FormAction): FormState {
...action.payload,
},
};
+
+ case "SET_MEDIA_SOURCE": {
+ const nextSource = action.payload;
+ if (nextSource === "upload") {
+ const hasFile = state.selectedFile !== null;
+ return {
+ ...state,
+ mediaSource: nextSource,
+ artworkUploaded: hasFile,
+ artworkUrl: hasFile ? state.uploadArtworkUrl : "",
+ };
+ }
+
+ return {
+ ...state,
+ mediaSource: nextSource,
+ artworkUploaded: state.externalMedia.isValid,
+ artworkUrl: state.externalMedia.isValid
+ ? state.externalMedia.url
+ : "",
+ };
+ }
+
+ case "SET_EXTERNAL_MEDIA": {
+ const externalMedia = action.payload;
+ const shouldApply = state.mediaSource === "url";
+ let artworkUrl = state.artworkUrl;
+ if (shouldApply) {
+ artworkUrl = externalMedia.isValid ? externalMedia.url : "";
+ }
+ return {
+ ...state,
+ externalMedia,
+ artworkUploaded: shouldApply
+ ? externalMedia.isValid
+ : state.artworkUploaded,
+ artworkUrl,
+ };
+ }
+
+ case "SET_EXTERNAL_MEDIA_VALIDATION": {
+ const { status, error, finalUrl } = action.payload;
+ const isValid = status === "valid";
+ const externalMedia = {
+ ...state.externalMedia,
+ status,
+ isValid,
+ error,
+ previewUrl: isValid
+ ? finalUrl ?? state.externalMedia.previewUrl
+ : "",
+ };
+
+ const shouldApply = state.mediaSource === "url";
+ let artworkUrl = state.artworkUrl;
+ if (shouldApply) {
+ artworkUrl = isValid ? externalMedia.url : "";
+ }
+
+ return {
+ ...state,
+ externalMedia,
+ artworkUploaded: shouldApply ? isValid : state.artworkUploaded,
+ artworkUrl,
+ };
+ }
+
+ case "SET_UPLOAD_MEDIA":
+ return {
+ ...state,
+ selectedFile: action.payload.file,
+ artworkUrl: action.payload.artworkUrl,
+ uploadArtworkUrl: action.payload.artworkUrl,
+ artworkUploaded: true,
+ mediaSource: "upload",
+ };
+
+ case "RESET_UPLOAD_MEDIA": {
+ const shouldFallbackToExternal =
+ state.mediaSource === "url" && state.externalMedia.isValid;
+ return {
+ ...state,
+ selectedFile: null,
+ artworkUrl: shouldFallbackToExternal ? state.externalMedia.url : "",
+ uploadArtworkUrl: "",
+ artworkUploaded: shouldFallbackToExternal,
+ };
}
default:
@@ -75,64 +317,189 @@ function formReducer(state: FormState, action: FormAction): FormState {
}
}
-/**
- * Extremely simplified hook to manage artwork submission form state
- * Uses uncontrolled inputs for maximum typing performance
- */
export function useArtworkSubmissionForm() {
const { connectedProfile } = useAuth();
-
- // Pre-compute initial values for traits without triggering circular dependencies
const initialTraits = getInitialTraitsValues();
- // Create the initial state
const initialState: FormState = {
currentStep: SubmissionStep.AGREEMENT,
agreements: false,
artworkUploaded: false,
artworkUrl: "",
+ uploadArtworkUrl: "",
traits: initialTraits,
+ mediaSource: "upload",
+ selectedFile: null,
+ externalMedia: {
+ input: "",
+ sanitizedHash: "",
+ provider: "ipfs",
+ url: "",
+ previewUrl: "",
+ mimeType: DEFAULT_INTERACTIVE_MEDIA_MIME_TYPE,
+ error: null,
+ status: "idle",
+ isValid: false,
+ },
};
- // Use reducer for state management
const [state, dispatch] = useReducer(formReducer, initialState);
+ const validationRequestKeyRef = useRef(null);
- // Extract values for convenience
- const { currentStep, agreements, artworkUploaded, artworkUrl, traits } =
- state;
+ const setMediaSource = useCallback(
+ (mode: MediaSource) => {
+ dispatch({ type: "SET_MEDIA_SOURCE", payload: mode });
+ },
+ [dispatch]
+ );
- // Extremely simple and direct update function
- const updateTraitField = useCallback(
- (field: K, value: TraitsData[K]) => {
- dispatch({
- type: "SET_TRAIT_FIELD",
- payload: { field, value },
- });
+ const updateExternalMediaState = useCallback(
+ (input: string, provider: InteractiveMediaProvider) => {
+ const nextExternalMedia = buildExternalMediaState(
+ input,
+ provider,
+ state.externalMedia.mimeType
+ );
+ dispatch({ type: "SET_EXTERNAL_MEDIA", payload: nextExternalMedia });
},
- []
+ [dispatch, state.externalMedia.mimeType]
);
- // Multiple traits update function
- const setTraits = useCallback((traitsUpdate: Partial) => {
- dispatch({ type: "SET_MULTIPLE_TRAITS", payload: traitsUpdate });
- }, []);
+ const setExternalMediaHash = useCallback(
+ (hash: string) => {
+ updateExternalMediaState(hash, state.externalMedia.provider);
+ },
+ [updateExternalMediaState, state.externalMedia.provider]
+ );
- // Handle file selection
- const handleFileSelect = useCallback((file: File) => {
- const reader = new FileReader();
- reader.onloadend = () => {
- dispatch({ type: "SET_ARTWORK_URL", payload: reader.result as string });
- dispatch({ type: "SET_ARTWORK_UPLOADED", payload: true });
- };
- reader.readAsDataURL(file);
- }, []);
+ const setExternalMediaProvider = useCallback(
+ (provider: InteractiveMediaProvider) => {
+ updateExternalMediaState(state.externalMedia.input, provider);
+ },
+ [updateExternalMediaState, state.externalMedia.input]
+ );
+
+ const setExternalMediaMimeType = useCallback(
+ (mimeType: InteractiveMediaMimeType) => {
+ const nextExternalMedia = buildExternalMediaState(
+ state.externalMedia.input,
+ state.externalMedia.provider,
+ mimeType
+ );
+ dispatch({ type: "SET_EXTERNAL_MEDIA", payload: nextExternalMedia });
+ },
+ [dispatch, state.externalMedia.input, state.externalMedia.provider]
+ );
+
+ const clearExternalMedia = useCallback(() => {
+ updateExternalMediaState("", state.externalMedia.provider);
+ }, [updateExternalMediaState, state.externalMedia.provider]);
+
+ const handleFileSelect = useCallback(
+ (file: File) => {
+ const reader = new FileReader();
+ reader.onloadend = () => {
+ dispatch({
+ type: "SET_UPLOAD_MEDIA",
+ payload: { file, artworkUrl: reader.result as string },
+ });
+ };
+ reader.readAsDataURL(file);
+ },
+ [dispatch]
+ );
- // Handle continuing from terms
const handleContinueFromTerms = useCallback(() => {
dispatch({ type: "SET_STEP", payload: SubmissionStep.ARTWORK });
}, []);
- // Initialize traits with profile info
+ useEffect(() => {
+ if (state.mediaSource !== "url") {
+ validationRequestKeyRef.current = null;
+ return;
+ }
+
+ const { status, sanitizedHash, provider } = state.externalMedia;
+
+ if (status !== "pending" || !sanitizedHash) {
+ return;
+ }
+
+ const validationKey = `${provider}:${sanitizedHash}`;
+ validationRequestKeyRef.current = validationKey;
+
+ let cancelled = false;
+
+ const runValidation = async () => {
+ try {
+ const result = await validateInteractivePreview({
+ provider,
+ path: sanitizedHash,
+ });
+
+ if (cancelled || validationRequestKeyRef.current !== validationKey) {
+ return;
+ }
+
+ if (result.ok) {
+ dispatch({
+ type: "SET_EXTERNAL_MEDIA_VALIDATION",
+ payload: {
+ status: "valid",
+ error: null,
+ finalUrl: result.finalUrl,
+ },
+ });
+ validationRequestKeyRef.current = null;
+ } else {
+ dispatch({
+ type: "SET_EXTERNAL_MEDIA_VALIDATION",
+ payload: {
+ status: "invalid",
+ error:
+ result.reason ??
+ "Interactive media must respond with an HTML document.",
+ },
+ });
+ validationRequestKeyRef.current = null;
+ }
+ } catch (error) {
+ console.error(
+ "[useArtworkSubmissionForm] validateInteractivePreview failed",
+ {
+ provider,
+ sanitizedHash,
+ error,
+ }
+ );
+ if (cancelled || validationRequestKeyRef.current !== validationKey) {
+ return;
+ }
+
+ dispatch({
+ type: "SET_EXTERNAL_MEDIA_VALIDATION",
+ payload: {
+ status: "invalid",
+ error: "Unable to verify media URL. Try again later.",
+ },
+ });
+ validationRequestKeyRef.current = null;
+ }
+ };
+
+ runValidation();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [
+ state.mediaSource,
+ state.externalMedia.status,
+ state.externalMedia.sanitizedHash,
+ state.externalMedia.provider,
+ dispatch,
+ ]);
+
useEffect(() => {
const userProfile = connectedProfile?.handle ?? "";
if (userProfile) {
@@ -146,43 +513,97 @@ export function useArtworkSubmissionForm() {
}
}, [connectedProfile]);
- // Prepare submission data
const getSubmissionData = useCallback(() => {
+ const { traits, artworkUrl } = state;
return {
imageUrl: artworkUrl,
traits: {
...traits,
- // Ensure these fields have values
title: traits.title ?? "Artwork Title",
- description: traits.description ?? "Artwork for The Memes collection.",
+ description:
+ traits.description ?? "Artwork for The Memes collection.",
},
};
- }, [artworkUrl, traits]);
+ }, [state]);
- // Return the API for the form
- return {
- // Current step
- currentStep,
+ const getMediaSelection = useCallback(
+ () => ({
+ mediaSource: state.mediaSource,
+ selectedFile: state.selectedFile,
+ externalUrl: state.externalMedia.url,
+ externalPreviewUrl: state.externalMedia.previewUrl,
+ externalProvider: state.externalMedia.provider,
+ externalHash: state.externalMedia.sanitizedHash,
+ externalMimeType: state.externalMedia.mimeType,
+ isExternalValid: state.externalMedia.isValid,
+ }),
+ [state]
+ );
+
+ const setArtworkUploaded = useCallback(
+ (uploaded: boolean) => {
+ if (!uploaded) {
+ if (state.mediaSource === "url") {
+ updateExternalMediaState("", state.externalMedia.provider);
+ } else {
+ dispatch({ type: "RESET_UPLOAD_MEDIA" });
+ }
+ }
+ },
+ [
+ state.mediaSource,
+ state.externalMedia.provider,
+ updateExternalMediaState,
+ dispatch,
+ ]
+ );
- // Agreement step
- agreements,
+ const updateTraitField = useCallback(
+ (field: K, value: TraitsData[K]) => {
+ dispatch({
+ type: "SET_TRAIT_FIELD",
+ payload: { field, value },
+ });
+ },
+ []
+ );
+
+ const setTraits = useCallback((traitsUpdate: Partial) => {
+ dispatch({ type: "SET_MULTIPLE_TRAITS", payload: traitsUpdate });
+ }, []);
+
+ return {
+ currentStep: state.currentStep,
+ agreements: state.agreements,
setAgreements: (value: boolean) =>
dispatch({ type: "SET_AGREEMENTS", payload: value }),
handleContinueFromTerms,
- // Artwork step
- artworkUploaded,
- artworkUrl,
- setArtworkUploaded: (value: boolean) =>
- dispatch({ type: "SET_ARTWORK_UPLOADED", payload: value }),
+ artworkUploaded: state.artworkUploaded,
+ artworkUrl: state.artworkUrl,
+ selectedFile: state.selectedFile,
+ mediaSource: state.mediaSource,
+ externalMediaUrl: state.externalMedia.url,
+ externalMediaPreviewUrl: state.externalMedia.previewUrl,
+ externalMediaHashInput: state.externalMedia.input,
+ externalMediaProvider: state.externalMedia.provider,
+ externalMediaMimeType: state.externalMedia.mimeType,
+ externalMediaError: state.externalMedia.error,
+ externalMediaValidationStatus: state.externalMedia.status,
+ isExternalMediaValid: state.externalMedia.isValid,
+ setArtworkUploaded,
+ setMediaSource,
+ setExternalMediaHash,
+ setExternalMediaProvider,
+ setExternalMediaMimeType,
+ clearExternalMedia,
handleFileSelect,
- // Traits
- traits,
+ traits: state.traits,
setTraits,
updateTraitField,
- // Submission
getSubmissionData,
+ getMediaSelection,
};
}
diff --git a/components/waves/memes/submission/hooks/useArtworkSubmissionMutation.ts b/components/waves/memes/submission/hooks/useArtworkSubmissionMutation.ts
index 66cb5c7a29..834543a512 100644
--- a/components/waves/memes/submission/hooks/useArtworkSubmissionMutation.ts
+++ b/components/waves/memes/submission/hooks/useArtworkSubmissionMutation.ts
@@ -13,12 +13,17 @@ import { TraitsData } from "../types/TraitsData";
import { SubmissionPhase } from "../ui/SubmissionProgress";
import { useDropSignature } from "@/hooks/drops/useDropSignature";
import { multiPartUpload } from "@/components/waves/create-wave/services/multiPartUpload";
+import type { InteractiveMediaMimeType } from "../constants/media";
/**
* Interface for the artwork submission data
*/
interface ArtworkSubmissionData {
- imageFile: File;
+ imageFile?: File;
+ externalMedia?: {
+ url: string;
+ mimeType: InteractiveMediaMimeType;
+ };
traits: TraitsData;
waveId: string;
termsOfService: string | null;
@@ -208,20 +213,34 @@ export function useArtworkSubmissionMutation() {
setSubmissionError(undefined);
// Validate required fields
- if (!data.imageFile) {
+ const hasFile = Boolean(data.imageFile);
+ const externalUrl = data.externalMedia?.url?.trim() ?? "";
+ const hasExternal = externalUrl.length > 0;
+
+ if (!hasFile && !hasExternal) {
setToast({
- message: "Please upload an artwork file",
+ message: "Please upload a file or provide a valid media URL",
+ type: "error",
+ });
+ return null;
+ }
+
+ if (hasExternal && !data.externalMedia?.mimeType) {
+ setToast({
+ message: "Please select the media type for your URL",
type: "error",
});
return null;
}
// Debug logging for file info
- console.log("Uploading file:", {
- name: data.imageFile.name,
- type: data.imageFile.type,
- size: data.imageFile.size
- });
+ if (hasFile && data.imageFile) {
+ console.log("Uploading file:", {
+ name: data.imageFile.name,
+ type: data.imageFile.type,
+ size: data.imageFile.size,
+ });
+ }
if (!data.traits.title) {
setToast({
@@ -234,11 +253,22 @@ export function useArtworkSubmissionMutation() {
// Create callbacks object
const callbacks = { onPhaseChange: options?.onPhaseChange };
- // Step 1: Upload the media file
- const media = await uploadMutation.mutateAsync({
- file: data.imageFile,
- callbacks,
- });
+ // Step 1: Resolve the media payload (upload when necessary)
+ let media: ApiDropMedia;
+
+ if (hasFile && data.imageFile) {
+ media = await uploadMutation.mutateAsync({
+ file: data.imageFile,
+ callbacks,
+ });
+ } else if (hasExternal && data.externalMedia) {
+ media = {
+ url: externalUrl,
+ mime_type: data.externalMedia.mimeType,
+ };
+ } else {
+ return null;
+ }
// Step 2: Transform data to API format
const transformedRequest = transformToApiRequest({
diff --git a/components/waves/memes/submission/steps/ArtworkStep.tsx b/components/waves/memes/submission/steps/ArtworkStep.tsx
index 1f4f0072d5..c43caade0a 100644
--- a/components/waves/memes/submission/steps/ArtworkStep.tsx
+++ b/components/waves/memes/submission/steps/ArtworkStep.tsx
@@ -8,6 +8,10 @@ import ArtworkDetails from "../details/ArtworkDetails";
import MemesArtSubmissionTraits from "@/components/waves/memes/MemesArtSubmissionTraits";
import SubmissionProgress, { SubmissionPhase } from "../ui/SubmissionProgress";
import { useTraitsValidation } from "../validation";
+import type {
+ InteractiveMediaMimeType,
+ InteractiveMediaProvider,
+} from "../constants/media";
/**
* Required fields for submission
@@ -126,6 +130,20 @@ interface ArtworkStepProps {
readonly artworkUrl: string;
readonly setArtworkUploaded: (uploaded: boolean) => void;
readonly handleFileSelect: (file: File) => void;
+ readonly mediaSource: "upload" | "url";
+ readonly setMediaSource: (mode: "upload" | "url") => void;
+ readonly externalHash: string;
+ readonly externalProvider: InteractiveMediaProvider;
+ readonly externalConstructedUrl: string;
+ readonly externalPreviewUrl: string;
+ readonly externalMimeType: InteractiveMediaMimeType;
+ readonly externalError: string | null;
+ readonly externalValidationStatus: "idle" | "pending" | "valid" | "invalid";
+ readonly isExternalMediaValid: boolean;
+ readonly onExternalHashChange: (value: string) => void;
+ readonly onExternalProviderChange: (value: InteractiveMediaProvider) => void;
+ readonly onExternalMimeTypeChange: (value: InteractiveMediaMimeType) => void;
+ readonly onClearExternalMedia: () => void;
readonly onSubmit: () => void;
readonly onCancel?: () => void; // Added cancel handler prop
readonly updateTraitField: (
@@ -156,6 +174,20 @@ const ArtworkStep: React.FC = ({
artworkUrl,
setArtworkUploaded,
handleFileSelect,
+ mediaSource,
+ setMediaSource,
+ externalHash,
+ externalProvider,
+ externalConstructedUrl,
+ externalPreviewUrl,
+ externalMimeType,
+ externalError,
+ externalValidationStatus,
+ isExternalMediaValid,
+ onExternalHashChange,
+ onExternalProviderChange,
+ onExternalMimeTypeChange,
+ onClearExternalMedia,
onSubmit,
onCancel,
updateTraitField,
@@ -278,6 +310,20 @@ const ArtworkStep: React.FC = ({
artworkUrl={artworkUrl}
setArtworkUploaded={setArtworkUploaded}
handleFileSelect={handleFileSelect}
+ mediaSource={mediaSource}
+ setMediaSource={setMediaSource}
+ externalHash={externalHash}
+ externalProvider={externalProvider}
+ externalConstructedUrl={externalConstructedUrl}
+ externalPreviewUrl={externalPreviewUrl}
+ externalMimeType={externalMimeType}
+ externalError={externalError}
+ externalValidationStatus={externalValidationStatus}
+ isExternalMediaValid={isExternalMediaValid}
+ onExternalHashChange={onExternalHashChange}
+ onExternalProviderChange={onExternalProviderChange}
+ onExternalMimeTypeChange={onExternalMimeTypeChange}
+ onClearExternalMedia={onClearExternalMedia}
/>
diff --git a/next.config.mjs b/next.config.mjs
index 3a5c8efc16..90fe42f9b2 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -25,12 +25,42 @@ function createSecurityHeaders(apiEndpoint = "") {
},
{
key: "Content-Security-Policy",
- value: `default-src 'none'; script-src 'self' 'unsafe-inline' https://dnclu2fna0b2b.cloudfront.net https://www.google-analytics.com https://www.googletagmanager.com/ https://dataplane.rum.us-east-1.amazonaws.com 'unsafe-eval'; connect-src * 'self' blob: ${apiEndpoint} https://registry.walletconnect.com/api/v2/wallets wss://*.bridge.walletconnect.org wss://*.walletconnect.com wss://www.walletlink.org/rpc https://explorer-api.walletconnect.com/v3/wallets https://www.googletagmanager.com https://*.google-analytics.com https://cloudflare-eth.com/ https://arweave.net/* https://rpc.walletconnect.com/v1/ https://sts.us-east-1.amazonaws.com https://sts.us-west-2.amazonaws.com; font-src 'self' data: https://fonts.gstatic.com https://fonts.reown.com https://dnclu2fna0b2b.cloudfront.net https://cdnjs.cloudflare.com; img-src 'self' data: blob: ipfs: https://artblocks.io https://*.artblocks.io *; media-src 'self' blob: https://*.cloudfront.net https://videos.files.wordpress.com https://arweave.net https://*.arweave.net https://ipfs.io/ipfs/* https://cf-ipfs.com/ipfs/* https://*.twimg.com https://artblocks.io https://*.artblocks.io; frame-src 'self' https://media.generator.seize.io https://media.generator.6529.io https://generator.seize.io https://arweave.net https://*.arweave.net https://ipfs.io/ipfs/* https://cf-ipfs.com/ipfs/* https://nftstorage.link https://*.ipfs.nftstorage.link https://verify.walletconnect.com https://verify.walletconnect.org https://secure.walletconnect.com https://d3lqz0a4bldqgf.cloudfront.net https://www.youtube.com https://www.youtube-nocookie.com https://*.youtube.com https://artblocks.io https://*.artblocks.io https://docs.google.com https://drive.google.com https://*.google.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com/css2 https://dnclu2fna0b2b.cloudfront.net https://cdnjs.cloudflare.com http://cdnjs.cloudflare.com https://cdn.jsdelivr.net; object-src data:;`,
+ value: `default-src 'none'; script-src 'self' 'unsafe-inline' https://dnclu2fna0b2b.cloudfront.net https://www.google-analytics.com https://www.googletagmanager.com/ https://dataplane.rum.us-east-1.amazonaws.com 'unsafe-eval'; connect-src * 'self' blob: ${apiEndpoint} https://registry.walletconnect.com/api/v2/wallets wss://*.bridge.walletconnect.org wss://*.walletconnect.com wss://www.walletlink.org/rpc https://explorer-api.walletconnect.com/v3/wallets https://www.googletagmanager.com https://*.google-analytics.com https://cloudflare-eth.com/ https://arweave.net/* https://rpc.walletconnect.com/v1/ https://sts.us-east-1.amazonaws.com https://sts.us-west-2.amazonaws.com; font-src 'self' data: https://fonts.gstatic.com https://fonts.reown.com https://dnclu2fna0b2b.cloudfront.net https://cdnjs.cloudflare.com; img-src 'self' data: blob: ipfs: https://artblocks.io https://*.artblocks.io *; media-src 'self' blob: https://*.cloudfront.net https://videos.files.wordpress.com https://arweave.net https://*.arweave.net https://cf-ipfs.com/ipfs/* https://*.twimg.com https://artblocks.io https://*.artblocks.io; frame-src 'self' https://ipfs.io https://ipfs.io/ipfs/ https://cf-ipfs.com https://cf-ipfs.com/ipfs/ https://media.generator.seize.io https://media.generator.6529.io https://generator.seize.io https://arweave.net https://*.arweave.net https://nftstorage.link https://*.ipfs.nftstorage.link https://verify.walletconnect.com https://verify.walletconnect.org https://secure.walletconnect.com https://d3lqz0a4bldqgf.cloudfront.net https://www.youtube.com https://www.youtube-nocookie.com https://*.youtube.com https://artblocks.io https://*.artblocks.io https://docs.google.com https://drive.google.com https://*.google.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com/css2 https://dnclu2fna0b2b.cloudfront.net https://cdnjs.cloudflare.com http://cdnjs.cloudflare.com https://cdn.jsdelivr.net; object-src data:;`,
},
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "same-origin" },
- { key: "Permissions-Policy", value: "geolocation=()" },
+ {
+ key: "Permissions-Policy",
+ value: [
+ "accelerometer=()",
+ "ambient-light-sensor=()",
+ "autoplay=()",
+ "battery=()",
+ "camera=()",
+ "cross-origin-isolated=()",
+ "display-capture=()",
+ "document-domain=()",
+ "encrypted-media=()",
+ "execution-while-not-rendered=()",
+ "execution-while-out-of-viewport=()",
+ "fullscreen=()",
+ "geolocation=()",
+ "gyroscope=()",
+ "keyboard-map=()",
+ "magnetometer=()",
+ "microphone=()",
+ "midi=()",
+ "payment=()",
+ "picture-in-picture=()",
+ "publickey-credentials-get=()",
+ "screen-wake-lock=()",
+ "sync-xhr=()",
+ "usb=()",
+ "web-share=()",
+ "xr-spatial-tracking=()",
+ ].join(", "),
+ },
];
}