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 (
{text}
{alt}
)}{alt}
)}{linkifyText(children)}
; } return{children}
; }, table: ({ className, children, ...props }) => (