Merge branch 'codex/feature/spam-blocker-challenge'

# Conflicts:
#	package.json
#	src/components/settings-modal/advanced-settings/__tests__/advanced-settings.test.tsx
#	src/components/settings-modal/advanced-settings/advanced-settings.tsx
#	src/stores/__tests__/publish-stores.test.ts
#	yarn.lock
This commit is contained in:
Tommaso Casaburi
2026-04-14 13:34:02 +07:00
9 changed files with 110 additions and 48 deletions
@@ -3,6 +3,7 @@ import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import ChallengeModal from '../challenge-modal';
import getShortAddress from '../../../lib/get-short-address';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
@@ -88,7 +89,7 @@ let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
let postMessageMock: ReturnType<typeof vi.fn>;
let root: Root;
const createPublication = () => ({
const createPublication = (): Record<string, any> => ({
author: { displayName: 'Alice' },
content: 'Publication content',
link: 'https://example.com/link',
@@ -285,6 +286,63 @@ describe('ChallengeModal', () => {
expect(testState.removeChallengeMock).toHaveBeenCalledOnce();
});
it('allows localhost http iframe challenges for local spam blocker testing', async () => {
const publication = createPublication();
testState.challenges = [
createStoredChallenge(
{
challenge: 'http://localhost:3000/api/v1/iframe/session-123?foo=bar',
type: 'url/iframe',
},
publication,
),
];
await renderModal();
expect(container.textContent).toContain('mu wants to open localhost:3000');
await clickButton('Open');
const iframe = container.querySelector('iframe');
expect(iframe).not.toBeNull();
expect(iframe?.getAttribute('src')).toContain('http://localhost:3000/api/v1/iframe/session-123?foo=bar&theme=dark');
await act(async () => {
iframe?.dispatchEvent(new Event('load', { bubbles: true }));
});
expect(postMessageMock).toHaveBeenCalledWith(
{
source: 'plebbit-5chan',
theme: 'dark',
type: 'plebbit-theme',
},
'http://localhost:3000',
);
});
it('uses the shortened community address when shortCommunityAddress is unavailable', async () => {
const longCommunityAddress = '12D3KooWS6yKc5N7o6JcAYHZpaQwAwyh1VddYatarU75Se3HXEeD';
const publication = {
...createPublication(),
shortCommunityAddress: undefined,
communityAddress: longCommunityAddress,
};
testState.challenges = [
createStoredChallenge(
{
challenge: 'http://localhost:3000/api/v1/iframe/session-123?foo=bar',
type: 'url/iframe',
},
publication,
),
];
await renderModal();
expect(container.textContent).toContain(`${getShortAddress(longCommunityAddress)} wants to open localhost:3000`);
expect(container.textContent).not.toContain(`${longCommunityAddress} wants to open localhost:3000`);
});
it('alerts when iframe challenges need a signer address and the account is missing one', async () => {
testState.account = { author: { address: '' } };
testState.challenges = [
@@ -9,6 +9,7 @@ import styles from './challenge-modal.module.css';
import capitalize from 'lodash/capitalize';
import { useSpring, animated } from '@react-spring/web';
import { useDrag } from '@use-gesture/react';
import getShortAddress from '../../lib/get-short-address';
const useParentAddress = (parentCid?: string) => {
const parentComment = useComment({ commentCid: parentCid, onlyIfCached: true });
@@ -25,6 +26,17 @@ const TextChallenge = ({ challenge }: { challenge: string }) => <div className={
const ImageChallenge = ({ challenge }: { challenge: string }) => <img alt='' className={styles.challengeMedia} src={`data:image/png;base64,${challenge}`} />;
const isLocalIframeHostname = (hostname: string) => hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname.endsWith('.localhost');
const getReadableIframeUrl = (challengeUrl: string) => {
try {
const url = new URL(challengeUrl);
return url.host || url.hostname;
} catch {
return '';
}
};
interface IframeChallengeProps {
challenge: string;
shortCommunityAddress?: string;
@@ -42,6 +54,7 @@ const IframeChallenge = ({ challenge, shortCommunityAddress, communityAddress, r
const [iframeUrlState, setIframeUrl] = useState('');
const [iframeOrigin, setIframeOrigin] = useState('');
const iframeRef = useRef<HTMLIFrameElement>(null);
const displayCommunityAddress = shortCommunityAddress || (communityAddress ? getShortAddress(communityAddress) : '') || communityAddress || 'unknown board';
const handleLoadIframe = useCallback(() => {
const iframeUrl = challenge;
@@ -60,8 +73,10 @@ const IframeChallenge = ({ challenge, shortCommunityAddress, communityAddress, r
try {
const validatedUrl = new URL(replacedUrl);
if (validatedUrl.protocol !== 'https:') {
throw new Error('Only HTTPS iframe challenges are supported');
const isHttps = validatedUrl.protocol === 'https:';
const isLocalHttp = validatedUrl.protocol === 'http:' && isLocalIframeHostname(validatedUrl.hostname);
if (!isHttps && !isLocalHttp) {
throw new Error('Only HTTPS iframe challenges or localhost HTTP challenges are supported');
}
validatedUrl.pathname = validatedUrl.pathname.replace(/\/{2,}/g, '/');
validatedUrl.searchParams.set('theme', theme);
@@ -101,7 +116,7 @@ const IframeChallenge = ({ challenge, shortCommunityAddress, communityAddress, r
{publicationDetails}
<div className={styles.challengeMediaWrapper}>
<div className={`${styles.challengeMedia} ${styles.iframeChallengeWarning}`}>
{shortCommunityAddress || communityAddress || 'unknown board'} wants to open {readableUrl || 'an external site'}
{displayCommunityAddress} wants to open {readableUrl || 'an external site'}
</div>
</div>
<div className={`${styles.challengeFooter} ${styles.iframeFooter}`}>
@@ -235,26 +250,11 @@ const Challenge = ({ challenge, closeModal, abandonModal }: ChallengeProps) => {
}, [abandonModal]);
const getChallengeUrl = useCallback(() => {
try {
const iframeUrl = currentChallenge?.challenge;
if (!iframeUrl) return '';
const url = new URL(iframeUrl);
if (url.hostname === 'mintpass.org') return url.hostname;
return url.href;
} catch {
return '';
}
const iframeUrl = currentChallenge?.challenge;
if (!iframeUrl) return '';
return getReadableIframeUrl(iframeUrl);
}, [currentChallenge]);
const readableUrl = (() => {
const url = getChallengeUrl();
if (!url) return '';
try {
return decodeURIComponent(url);
} catch {
return url;
}
})();
const readableUrl = getChallengeUrl();
if (!challenges?.length || !publication || !currentChallenge) {
return null;
@@ -9,11 +9,11 @@ const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise
const testState = vi.hoisted(() => ({
account: {
mediaIpfsGatewayUrl: 'https://media.old.example',
chainProviders: {
eth: { chainId: 1, urls: ['https://eth.old.example'] },
sol: { chainId: 101, urls: ['https://sol.old.example'] },
},
pkcOptions: {
chainProviders: {
eth: { chainId: 1, urls: ['https://eth.old.example'] },
sol: { chainId: 101, urls: ['https://sol.old.example'] },
},
httpRoutersOptions: ['https://router.old.example'],
ipfsGatewayUrls: ['https://ipfs.old.example'],
pkcRpcClientsOptions: ['ws://old.example/key'],
@@ -86,11 +86,11 @@ describe('AdvancedSettings', () => {
vi.clearAllMocks();
testState.account = {
mediaIpfsGatewayUrl: 'https://media.old.example',
chainProviders: {
eth: { chainId: 1, urls: ['https://eth.old.example'] },
sol: { chainId: 101, urls: ['https://sol.old.example'] },
},
pkcOptions: {
chainProviders: {
eth: { chainId: 1, urls: ['https://eth.old.example'] },
sol: { chainId: 101, urls: ['https://sol.old.example'] },
},
httpRoutersOptions: ['https://router.old.example'],
ipfsGatewayUrls: ['https://ipfs.old.example'],
pkcRpcClientsOptions: ['ws://old.example/key'],
@@ -154,11 +154,11 @@ describe('AdvancedSettings', () => {
expect(testState.setAccountMock).toHaveBeenCalledWith({
mediaIpfsGatewayUrl: 'https://media.new.example',
chainProviders: {
eth: { chainId: 1, urls: ['https://eth.one.example'] },
sol: { chainId: 101, urls: ['https://sol.one.example'] },
},
pkcOptions: {
chainProviders: {
eth: { chainId: 1, urls: ['https://eth.one.example'] },
sol: { chainId: 101, urls: ['https://sol.one.example'] },
},
dataPath: '/tmp/next-plebbit',
httpRoutersOptions: ['https://router.one.example'],
ipfsGatewayUrls: ['https://ipfs.one.example', 'https://ipfs.two.example'],
@@ -237,7 +237,7 @@ const AdvancedSettings = () => {
const mediaIpfsGatewayUrl = mediaIpfsGatewayUrlRef.current?.value.trim();
const pubsubHttpClientsOptions = pubsubProvidersRef.current?.value
const pubsubKuboRpcClientsOptions = pubsubProvidersRef.current?.value
.split('\n')
.map((url) => url.trim())
.filter((url) => url !== '');
@@ -272,11 +272,11 @@ const AdvancedSettings = () => {
await setAccount({
...account,
mediaIpfsGatewayUrl,
chainProviders,
pkcOptions: {
...protocolOptions,
ipfsGatewayUrls,
pubsubKuboRpcClientsOptions: pubsubHttpClientsOptions,
chainProviders,
pubsubKuboRpcClientsOptions,
httpRoutersOptions,
pkcRpcClientsOptions,
dataPath,
@@ -82,6 +82,7 @@ describe('usePublishPost', () => {
spoiler: true,
title: 'Hello world',
});
expect('subplebbitAddress' in latestValue.publishPostOptions).toBe(false);
expect(typeof latestValue.publishPostOptions.onChallengeVerification).toBe('function');
expect(typeof latestValue.publishPostOptions.onError).toBe('function');
});
@@ -112,6 +112,7 @@ describe('usePublishReply', () => {
quotedCids: ['quoted-cid'],
spoiler: true,
});
expect('subplebbitAddress' in (testState.lastPublishOptions || {})).toBe(false);
});
it('resolves same-board external quote references before triggering publish', async () => {
@@ -52,6 +52,7 @@ describe('publish stores', () => {
expect(state.author).toEqual({ address: '0x123', role: 'mod', displayName: 'Poster Alias' });
expect(state.publishCommentOptions.author).toEqual({ address: '0x123', role: 'mod', displayName: 'Poster Alias' });
expect(state.publishCommentOptions.communityAddress).toBe('music-posting.eth');
expect('subplebbitAddress' in state.publishCommentOptions).toBe(false);
expect(state.publishCommentOptions.title).toBe('Hello');
state.publishCommentOptions.onChallengeVerification?.({} as never, comment);
@@ -85,6 +86,7 @@ describe('publish stores', () => {
expect(state.publishCommentOptions['parent-1']?.communityAddress).toBe('music-posting.eth');
expect(state.publishCommentOptions['parent-1']?.parentCid).toBe('parent-1');
expect(state.publishCommentOptions['parent-1']?.postCid).toBe('parent-1');
expect('subplebbitAddress' in (state.publishCommentOptions['parent-1'] || {})).toBe(false);
state.publishCommentOptions['parent-1']?.onChallengeVerification?.({ token: 'challenge' } as never, comment);
expect(testState.alertChallengeVerificationFailedMock).toHaveBeenCalledWith({ token: 'challenge' }, comment);