import React from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import remarkBreaks from 'remark-breaks'; import { memo, useState } from 'react'; import { CheckIcon, CopyIcon } from 'lucide-react'; import { TooltipIconButton } from '@/components/ui/tooltip-icon-button'; // Helper functions for media validation (copied from MessageList to avoid circular imports) function isValidDataUri(src: string, expectedType?: 'image' | 'video' | 'audio'): boolean { const typePattern = expectedType ? `${expectedType}/` : '[a-z0-9.+-]+/'; const dataUriRegex = new RegExp( `^data:${typePattern}[a-z0-9.+-]+;base64,[A-Za-z0-9+/]+={0,2}$`, 'i' ); return dataUriRegex.test(src); } function isSafeHttpUrl(src: string): boolean { try { const url = new URL(src); const hostname = url.hostname.toLowerCase(); // Check protocol if (url.protocol !== 'https:' && url.protocol !== 'http:') { return false; } // Block localhost and common local names if (hostname === 'localhost' || hostname === '::1') { return false; } // Check for IPv4 addresses const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; const ipv4Match = hostname.match(ipv4Regex); if (ipv4Match) { const [, a, b, c, d] = ipv4Match.map(Number); // Validate IP range (0-255) if (a > 255 || b > 255 || c > 255 || d > 255) { return false; } // Block loopback (127.0.0.0/8) if (a === 127) { return false; } // Block private networks (RFC 1918) // 10.0.0.0/8 if (a === 10) { return false; } // 172.16.0.0/12 if (a === 172 && b >= 16 && b <= 31) { return false; } // 192.168.0.0/16 if (a === 192 && b === 168) { return false; } // Block link-local (169.254.0.0/16) if (a === 169 && b === 254) { return false; } // Block 0.0.0.0 if (a === 0 && b === 0 && c === 0 && d === 0) { return false; } } // Check for IPv6 addresses if (hostname.includes(':')) { // Block IPv6 loopback if (hostname === '::1' || hostname === '0:0:0:0:0:0:0:1') { return false; } // Block IPv6 unique-local (fc00::/7) if (hostname.startsWith('fc') || hostname.startsWith('fd')) { return false; } // Block IPv6 link-local (fe80::/10) if ( hostname.startsWith('fe8') || hostname.startsWith('fe9') || hostname.startsWith('fea') || hostname.startsWith('feb') ) { return false; } } return true; } catch { return false; } } function isSafeMediaUrl(src: string, expectedType?: 'image' | 'video' | 'audio'): boolean { if (src.startsWith('blob:') || isSafeHttpUrl(src)) return true; if (src.startsWith('data:')) { return expectedType ? isValidDataUri(src, expectedType) : isValidDataUri(src); } return false; } function isVideoUrl(url: string): boolean { // Check for video file extensions if (url.match(/\.(mp4|webm|mov|m4v|avi|mkv)(\?.*)?$/i)) { return true; } // Check for video MIME types in URL or common video hosting patterns if (url.includes('/video/') || url.includes('video_')) { return true; } return false; } function isAudioUrl(url: string): boolean { // Check for audio file extensions if (url.match(/\.(mp3|wav|ogg|m4a|aac|flac|wma)(\?.*)?$/i)) { return true; } // Check for audio patterns in URL if (url.includes('/audio/') || url.includes('audio_')) { return true; } return false; } // Auto-linkify plain text URLs function linkifyText(text: string): React.ReactNode { // URL regex that matches http(s) URLs const urlRegex = /(https?:\/\/[^\s<]+)/g; const parts = text.split(urlRegex); return parts.map((part, index) => { if (part.match(urlRegex)) { // Validate URL safety before rendering as link if (!isSafeHttpUrl(part)) { return ( {part} ); } return ( {part} ); } return part; }); } // Code block component with copy functionality const CodeBlock = ({ className, children, ...props }: { className?: string; children?: React.ReactNode; [key: string]: any; }) => { const [copied, setCopied] = useState(false); const text = String(children ?? '').replace(/\n$/, ''); const isInline = !className; if (isInline) { return ( {children} ); } return (
{ navigator.clipboard .writeText(text) .then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); }) .catch(() => {}); }} className="absolute right-2 top-2 z-10 opacity-70 hover:opacity-100 transition-opacity bg-background/80 hover:bg-background" > {copied ? : }
                {text}
            
); }; // Enhanced markdown component with proper emoji support and spacing const MarkdownTextImpl = ({ children }: { children: string }) => { const blobUrlsRef = React.useRef>(new Set()); React.useEffect(() => { // Capture the current ref value for cleanup const blobUrls = blobUrlsRef.current; return () => { // Clean up any created blob URLs on unmount blobUrls.forEach((url) => { try { URL.revokeObjectURL(url); } catch { // Silently fail if revoke fails } }); }; }, []); return (
{ const url = (href as string | undefined) ?? ''; const isHttp = /^https?:\/\//i.test(url); const isAllowed = isHttp; // extend if you want: || url.startsWith('mailto:') || url.startsWith('tel:') if (!isAllowed || !isSafeHttpUrl(url)) { return ( {children} ); } // Regular link rendering with better overflow handling (no truncation) return ( {children} ); }, img: ({ src, alt, ...props }) => { if (!src) { return ( No media source provided ); } // Handle Blob sources - validate and convert to string URL let srcString: string | null = null; if (typeof src === 'string') { srcString = src; } else if ((src as any) instanceof Blob || (src as any) instanceof File) { // Safe to convert Blob or File to object URL try { const objectUrl = URL.createObjectURL(src as Blob | File); srcString = objectUrl; // Track the URL for cleanup blobUrlsRef.current.add(objectUrl); } catch { // URL.createObjectURL failed, treat as invalid srcString = null; } } else if ( typeof src === 'object' && src !== null && (src as any) instanceof MediaSource ) { // MediaSource objects can also be used with createObjectURL try { const objectUrl = URL.createObjectURL( src as unknown as MediaSource ); srcString = objectUrl; // Track the URL for cleanup blobUrlsRef.current.add(objectUrl); } catch { // URL.createObjectURL failed, treat as invalid srcString = null; } } else { // Invalid or unsafe type - not a string, Blob, File, or MediaSource srcString = null; } // If we couldn't get a valid source string, show error if (!srcString) { return ( Invalid or unsafe media source ); } // Check if this is a video URL - render video player if (isVideoUrl(srcString) && isSafeMediaUrl(srcString, 'video')) { return (
{alt && (

{alt}

)}
); } // Check if this is an audio URL - render audio player if (isAudioUrl(srcString) && isSafeMediaUrl(srcString, 'audio')) { return (
{alt && (

{alt}

)}
); } // Default to image rendering if (!isSafeMediaUrl(srcString, 'image')) { return ( Invalid or unsafe media source ); } return ( {alt ); }, p: ({ children, ...props }) => { // Auto-linkify plain text URLs in paragraphs if (typeof children === 'string') { return

{linkifyText(children)}

; } return

{children}

; }, table: ({ className, children, ...props }) => (
{children}
), thead: ({ className, ...props }) => , tr: ({ className, ...props }) => ( td:first-child]:rounded-bl-lg [&:last-child>td:last-child]:rounded-br-lg', className, ] .filter(Boolean) .join(' ')} {...props} /> ), th: ({ className, ...props }) => ( ), td: ({ className, ...props }) => ( ), code: CodeBlock, }} > {children}
); }; export const MarkdownText = memo(MarkdownTextImpl);