import { useRef, useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Challenge as ChallengeType, useAccount, useComment } from '@bitsocial/bitsocial-react-hooks'; import { getPublicationPreview, getPublicationType, getVotePreview, type ChallengePublication } from '../../lib/utils/challenge-utils'; import useIsMobile from '../../hooks/use-is-mobile'; import useChallengesStore from '../../stores/use-challenges-store'; import useTheme from '../../hooks/use-theme'; 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 }); return parentComment?.author?.shortAddress; }; interface ChallengeProps { challenge: ChallengeType; challengeId: number; closeModal: () => void; abandonModal: () => void; } const MAX_IFRAME_CONFIRM_EXCERPT_LENGTH = 80; const getTrimmedExcerpt = (value: unknown) => (typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : ''); const shortenConfirmExcerpt = (excerpt: string) => excerpt.length > MAX_IFRAME_CONFIRM_EXCERPT_LENGTH ? `${excerpt.slice(0, MAX_IFRAME_CONFIRM_EXCERPT_LENGTH).trimEnd()}...` : excerpt; const getIframeConfirmExcerpt = (publication: ChallengePublication | undefined, publicationTarget: ChallengePublication | undefined, publicationType?: string) => { const title = getTrimmedExcerpt(publication?.title); const content = getTrimmedExcerpt(publication?.content); const link = getTrimmedExcerpt(publication?.link); const excerpt = publicationType === 'vote' ? getTrimmedExcerpt(getPublicationPreview(publicationTarget)) : publicationType === 'reply' ? content || link : title || content || link; return shortenConfirmExcerpt(excerpt || getTrimmedExcerpt(getPublicationPreview(publication))); }; const getDisplayCommunityAddress = (shortCommunityAddress?: string, communityAddress?: string) => shortCommunityAddress || (communityAddress ? getShortAddress(communityAddress) : '') || communityAddress || ''; const iframeChallengeConfirmDecisions = new Map(); const TextChallenge = ({ challenge }: { challenge: string }) =>
{challenge}
; const MAX_IMAGE_CHALLENGE_BASE64_LENGTH = 2_000_000; const isSafeBase64ImageChallenge = (challenge: string) => /^[A-Za-z0-9+/]*={0,2}$/.test(challenge) && challenge.length <= MAX_IMAGE_CHALLENGE_BASE64_LENGTH; const ImageChallenge = ({ challenge }: { challenge: string }) => isSafeBase64ImageChallenge(challenge) ? ( Challenge ) : (
Invalid image challenge
); const isLocalIframeHostname = (hostname: string) => hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname.endsWith('.localhost'); type ValidatedIframeUrl = { status: 'valid'; finalUrl: string; origin: string } | { status: 'invalid'; error: unknown } | { status: 'unsupported' }; const validateIframeChallengeUrl = (iframeUrl: string, theme: string): ValidatedIframeUrl => { let validatedUrl: URL; try { validatedUrl = new URL(iframeUrl); } catch (error) { return { status: 'invalid', error }; } const isHttps = validatedUrl.protocol === 'https:'; const isLocalHttp = validatedUrl.protocol === 'http:' && isLocalIframeHostname(validatedUrl.hostname); if (!isHttps && !isLocalHttp) { return { status: 'unsupported' }; } validatedUrl.pathname = validatedUrl.pathname.replace(/\/{2,}/g, '/'); validatedUrl.searchParams.set('theme', theme); return { status: 'valid', finalUrl: validatedUrl.toString(), origin: validatedUrl.origin }; }; const postThemeToIframe = (iframe: HTMLIFrameElement | null, iframeOrigin: string, theme: string) => { if (!iframe || !iframeOrigin) return; try { iframe.contentWindow?.postMessage({ type: 'plebbit-theme', theme, source: 'plebbit-5chan' }, iframeOrigin); } catch (error) { console.warn('Could not send theme to iframe:', error); } }; const getReadableIframeUrl = (challengeUrl: string) => { try { const url = new URL(challengeUrl); return url.host || url.hostname; } catch { return ''; } }; const getIframeSessionId = (challengeUrl: string) => { try { const url = new URL(challengeUrl); const match = url.pathname.match(/\/iframe\/([^/?#]+)/); return match?.[1] ?? ''; } catch { return ''; } }; interface IframeChallengeProps { challenge: string; confirmKey: string; confirmMessage: string; onCancel: () => void; onDone: () => void; onAutoComplete: (challengeAnswers: string[]) => void; onReady: () => void; } const IframeChallenge = ({ challenge, confirmKey, confirmMessage, onCancel, onDone, onAutoComplete, onReady }: IframeChallengeProps) => { const account = useAccount(); const [theme] = useTheme(); const [iframeUrlState, setIframeUrl] = useState(''); const [iframeOrigin, setIframeOrigin] = useState(''); const iframeRef = useRef(null); const attemptedLoadRef = useRef(false); const mountedRef = useRef(false); const handledAutoCompleteRef = useRef(false); const expectedSessionId = getIframeSessionId(challenge); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); const openValidatedIframe = useCallback( (validatedUrl: { finalUrl: string; origin: string }) => { queueMicrotask(() => { if (!mountedRef.current) return; setIframeUrl(validatedUrl.finalUrl); setIframeOrigin(validatedUrl.origin); onReady(); }); }, [onReady], ); const handleLoadIframe = useCallback(() => { const iframeUrl = challenge; if (!iframeUrl) return; const rawUserAddress = account?.author?.address?.trim(); const requiresUserAddress = iframeUrl.includes('{userAddress}'); if (requiresUserAddress && !rawUserAddress) { alert('Error: Unable to load challenge without your address. Please sign in and try again.'); iframeChallengeConfirmDecisions.set(confirmKey, 'rejected'); onCancel(); return; } const encodedAddress = rawUserAddress ? encodeURIComponent(rawUserAddress) : undefined; const replacedUrl = requiresUserAddress && encodedAddress ? iframeUrl.replace(/\{userAddress\}/g, encodedAddress) : iframeUrl; const validatedUrl = validateIframeChallengeUrl(replacedUrl, theme); if (validatedUrl.status === 'unsupported') { alert('Error: Only HTTPS iframe challenges or localhost HTTP challenges are supported'); iframeChallengeConfirmDecisions.set(confirmKey, 'rejected'); onCancel(); return; } if (validatedUrl.status === 'invalid') { console.error('Invalid iframe challenge URL', { error: validatedUrl.error }); alert('Error: Invalid URL for authentication challenge'); iframeChallengeConfirmDecisions.set(confirmKey, 'rejected'); onCancel(); return; } const decision = iframeChallengeConfirmDecisions.get(confirmKey); if (decision === 'rejected') { onCancel(); return; } if (decision !== 'accepted') { if (!window.confirm(confirmMessage)) { iframeChallengeConfirmDecisions.set(confirmKey, 'rejected'); onCancel(); return; } iframeChallengeConfirmDecisions.set(confirmKey, 'accepted'); } openValidatedIframe(validatedUrl); }, [account, challenge, confirmKey, confirmMessage, onCancel, openValidatedIframe, theme]); useEffect(() => { if (attemptedLoadRef.current) return; attemptedLoadRef.current = true; handleLoadIframe(); }, [handleLoadIframe]); const sendThemeToIframe = useCallback(() => { postThemeToIframe(iframeRef.current, iframeOrigin, theme); }, [iframeOrigin, theme]); const handleIframeLoad = () => { sendThemeToIframe(); }; useEffect(() => { if (iframeRef.current && iframeUrlState && iframeOrigin) { sendThemeToIframe(); } }, [iframeOrigin, iframeUrlState, sendThemeToIframe]); useEffect(() => { if (!iframeOrigin || !expectedSessionId) { handledAutoCompleteRef.current = false; return; } const handleMessage = (event: MessageEvent) => { if (event.origin !== iframeOrigin) return; if (handledAutoCompleteRef.current) return; const data = event.data; if (!data || typeof data !== 'object') return; if ((data as { type?: string }).type !== 'challengeAnswer') return; const challengeAnswers = (data as { challengeAnswers?: unknown }).challengeAnswers; if (!Array.isArray(challengeAnswers)) return; const sessionId = (data as { sessionId?: unknown }).sessionId; if (sessionId !== expectedSessionId) return; handledAutoCompleteRef.current = true; onAutoComplete(challengeAnswers.filter((answer): answer is string => typeof answer === 'string')); }; window.addEventListener('message', handleMessage); return () => window.removeEventListener('message', handleMessage); }, [expectedSessionId, iframeOrigin, onAutoComplete]); if (!iframeUrlState) { return null; } return ( <>