import { unified } from 'unified'; import remarkParse from 'remark-parse'; import remarkRehype from 'remark-rehype'; import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'; import rehypeStringify from 'rehype-stringify'; import type { TextStyle } from '../types/text-formatting'; import { validateTextStyle } from '../types/text-formatting'; import { isValidBBCodeColor, isValidBBCodeFontSize, isValidBBCodeFont } from '../validation/bbcode'; import { highlightMentionsHtml } from './mention'; // Define allowed HTML tags and attributes for security const ALLOWED_SCHEMA = { ...defaultSchema, tagNames: [...(defaultSchema.tagNames || []), 'span', 'strong', 'em', 'u', 's', 'mark'], attributes: { ...defaultSchema.attributes, span: ['style', 'class', 'data-gradient'], strong: ['style', 'class'], em: ['style', 'class'], u: ['style', 'class'], s: ['style', 'class'], mark: ['style', 'class'] }, protocols: { ...defaultSchema.protocols, style: [] } }; // BBCode-style formatting patterns (simple ones with static replacement) const BB_CODE_SIMPLE_PATTERNS: Array<{ pattern: RegExp; replacement: string; }> = [ // Bold { pattern: /\[b\](.*?)\[\/b\]/gi, replacement: '$1' }, // Italic { pattern: /\[i\](.*?)\[\/i\]/gi, replacement: '$1' }, // Underline { pattern: /\[u\](.*?)\[\/u\]/gi, replacement: '$1' }, // Strikethrough { pattern: /\[s\](.*?)\[\/s\]/gi, replacement: '$1' } ]; // BBCode patterns that require validation (using Zod schemas) function processBBCodeWithValidation(text: string): string { let result = text; // Color - validate hex or named colors using Zod schema result = result.replace(/\[color=([#\w]+)\](.*?)\[\/color\]/gi, (match, color, content) => { if (isValidBBCodeColor(color)) { return `${content}`; } return content; // Strip invalid color, keep content }); // Size - validate range 8-24px using Zod schema result = result.replace(/\[size=(\d+)\](.*?)\[\/size\]/gi, (match, size, content) => { if (isValidBBCodeFontSize(size)) { return `${content}`; } return content; // Strip invalid size, keep content }); // Font - validate against allowed fonts using Zod schema result = result.replace(/\[font=([^[\]]+)\](.*?)\[\/font\]/gi, (match, font, content) => { if (isValidBBCodeFont(font)) { return `${content}`; } return content; // Strip invalid font, keep content }); return result; } // Markdown-style formatting patterns const MARKDOWN_PATTERNS: Array<{ pattern: RegExp; replacement: string; }> = [ // Bold (double asterisk or double underscore) { pattern: /\*\*(.*?)\*\*/g, replacement: '$1' }, { pattern: /__(.*?)__/g, replacement: '$1' }, // Italic (single asterisk or single underscore) { pattern: /\*(.*?)\*/g, replacement: '$1' }, { pattern: /_(.*?)_/g, replacement: '$1' }, // Strikethrough { pattern: /~~(.*?)~~/g, replacement: '$1' }, // Inline code { pattern: /`([^`]+)`/g, replacement: '$1' } ]; // Preprocess text to convert BBCode and Markdown to HTML function preprocessFormatting(text: string): string { let processedText = text; // Apply simple BBCode patterns first (bold, italic, underline, strikethrough) BB_CODE_SIMPLE_PATTERNS.forEach(({ pattern, replacement }) => { processedText = processedText.replace(pattern, replacement); }); // Apply BBCode patterns that require validation (color, size, font) processedText = processBBCodeWithValidation(processedText); // Apply Markdown patterns (but avoid conflicts with already processed BBCode) MARKDOWN_PATTERNS.forEach(({ pattern, replacement }) => { // Only apply if not already inside HTML tags processedText = processedText.replace(pattern, (match, content) => { // Simple check to avoid replacing inside existing HTML tags const beforeMatch = processedText.substring(0, processedText.indexOf(match)); const openTags = (beforeMatch.match(/<[^/][^>]*>/g) || []).length; const closeTags = (beforeMatch.match(/<\/[^>]*>/g) || []).length; // If we're inside an unclosed tag, don't apply markdown if (openTags > closeTags) { return match; } return replacement.replace('$1', content); }); }); // Note: Mention highlighting is applied after sanitization in formatText // to ensure it's not stripped by the sanitizer return processedText; } // Main text formatting function export async function formatText( text: string, userStyle?: Partial, allowRichFormatting: boolean = true ): Promise { try { // If text is empty, return empty string if (!text) return ''; // Validate user style (ensures safe values) validateTextStyle(userStyle || {}); // Preprocess the text for formatting if allowed let processedText = text; if (allowRichFormatting) { processedText = preprocessFormatting(text); } // If no rich formatting, just return escaped text if (!allowRichFormatting && !processedText.includes('<')) { return escapeHtml(text); } // Create the unified processor const processor = unified() .use(remarkParse, { fragment: true // Parse as fragment, not full document }) .use(remarkRehype, { allowDangerousHtml: true // Allow preprocessed HTML to pass through }) .use(rehypeSanitize, ALLOWED_SCHEMA) // Sanitize HTML .use(rehypeStringify); // Process the text const result = await processor.process(processedText); let formattedHtml = String(result).trim(); // If we got an empty result, return the escaped text if (!formattedHtml || formattedHtml === '

' || formattedHtml === '') { return escapeHtml(text); } // Remove wrapping

tags if it's just a single paragraph if (formattedHtml.startsWith('

') && formattedHtml.endsWith('

')) { const pCount = (formattedHtml.match(/

/g) || []).length; if (pCount === 1) { formattedHtml = formattedHtml.slice(3, -4).trim(); } } // Final check - if we ended up with empty content, return escaped text if (!formattedHtml) { return escapeHtml(text); } // Apply mention highlighting after sanitization formattedHtml = highlightMentionsHtml(formattedHtml); return formattedHtml; } catch (error) { console.error('Text formatting error:', error); // Return escaped plain text as fallback return escapeHtml(text); } } // Utility function to escape HTML (isomorphic - works in SSR and browser) export function escapeHtml(text: string): string { return text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // Generate CSS style string excluding font-family (which should be handled by classes) function generateCSSStyleWithoutFont(style: TextStyle): string { const styles: string[] = []; // Font size styles.push(`font-size: ${style.fontSize}px`); // Color (not for gradients) if (style.color && !style.gradient) { styles.push(`color: ${style.color}`); } // Font weight if (style.bold) { styles.push(`font-weight: bold`); } // Font style if (style.italic) { styles.push(`font-style: italic`); } // Text decoration const decorations: string[] = []; if (style.underline) decorations.push('underline'); if (style.strikethrough) decorations.push('line-through'); if (decorations.length > 0) { styles.push(`text-decoration: ${decorations.join(' ')}`); } return styles.join('; '); } // Create gradient text effect export function createGradientText( text: string, colors: string[], className: string = 'gradient-text', style?: TextStyle ): string { if (colors.length < 2) { const fontClass = style?.fontFamily ? ` retro-font-${style.fontFamily}` : ''; const inlineStyle = style ? generateCSSStyleWithoutFont({ ...style, gradient: undefined }) : ''; const result = `${escapeHtml(text)}`; return result; } const chars = Array.from(text); const totalChars = chars.length; if (totalChars === 0) return ''; // Create spans for each character with interpolated colors const gradientSpans = chars.map((char, index) => { if (char === ' ') { return ' '; // Use non-breaking space to preserve spacing } // Calculate color position (0 to 1) const position = totalChars === 1 ? 0 : index / (totalChars - 1); // Find the two colors to interpolate between const colorIndex = position * (colors.length - 1); const lowerIndex = Math.floor(colorIndex); const upperIndex = Math.min(lowerIndex + 1, colors.length - 1); const t = colorIndex - lowerIndex; // Interpolate between colors const color1 = hexToRgb(colors[lowerIndex]); const color2 = hexToRgb(colors[upperIndex]); if (!color1 || !color2) { const fallbackStyle = style ? generateCSSStyleWithoutFont({ ...style, gradient: undefined }) : ''; return `${escapeHtml(char)}`; } const r = Math.round(color1.r + t * (color2.r - color1.r)); const g = Math.round(color1.g + t * (color2.g - color1.g)); const b = Math.round(color1.b + t * (color2.b - color1.b)); const interpolatedColor = `rgb(${r}, ${g}, ${b})`; // Combine interpolated color with other styles let inlineStyle = `color: ${interpolatedColor}`; if (style) { // Get styles without font-family and color if (style.fontSize !== 14) inlineStyle += `; font-size: ${style.fontSize}px`; if (style.bold) inlineStyle += `; font-weight: bold`; if (style.italic) inlineStyle += `; font-style: italic`; const decorations: string[] = []; if (style.underline) decorations.push('underline'); if (style.strikethrough) decorations.push('line-through'); if (decorations.length > 0) { inlineStyle += `; text-decoration: ${decorations.join(' ')}`; } } return `${escapeHtml(char)}`; }); // Wrap all spans in a container - use inline display to match text baseline const fontClass = style?.fontFamily ? ` retro-font-${style.fontFamily}` : ''; const containerStyle = style ? generateCSSStyleWithoutFont({ ...style, gradient: undefined, color: undefined }) : ''; const result = `${gradientSpans.join('')}`; return result; } // Helper function to convert hex to RGB function hexToRgb(hex: string): { r: number; g: number; b: number } | null { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); return result ? { r: parseInt(result[1], 16), g: parseInt(result[2], 16), b: parseInt(result[3], 16) } : null; } // Validate message content for security export function validateMessageContent(content: string): { isValid: boolean; sanitizedContent: string; errors: string[]; } { const errors: string[] = []; let sanitizedContent = content; // Check length if (content.length > 2000) { errors.push('Message too long (max 2000 characters)'); sanitizedContent = content.substring(0, 2000); } // Check for potential XSS patterns const dangerousPatterns = [ /)<[^<]*)*<\/script>/gi, /javascript:/gi, /vbscript:/gi, /onload\s*=/gi, /onerror\s*=/gi, /onclick\s*=/gi, /onmouseover\s*=/gi ]; dangerousPatterns.forEach((pattern) => { if (pattern.test(content)) { errors.push('Potentially dangerous content detected'); sanitizedContent = sanitizedContent.replace(pattern, ''); } }); return { isValid: errors.length === 0, sanitizedContent, errors }; }