diff --git a/.env.example b/.env.example index 08e5133e05..38cefc49ca 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,11 @@ JWT_SECRET=your-secret-key-change-in-production # Other Configuration NEXT_PUBLIC_SIGNUP_URL=https://signup.steemit.com +# Image proxy prefix. Post-body images are proxied through this host as +# /p/:base58?width=&mode=fit&format=match (mirrors master's +# $STM_Config.img_proxy_prefix). Default is the production CDN. +# NEXT_PUBLIC_IMAGE_PROXY_PREFIX=https://steemitimages.com/ + # External Steemit wallet (Condenser only links out). Production default: https://steemitwallet.com # Unset in production build to use that default; set for test/staging, e.g.: # NEXT_PUBLIC_WALLET_URL=https://wallet.steemitdev.com diff --git a/components/elements/MarkdownViewer.tsx b/components/elements/MarkdownViewer.tsx index 86f45a8c92..76cdee302e 100644 --- a/components/elements/MarkdownViewer.tsx +++ b/components/elements/MarkdownViewer.tsx @@ -1,6 +1,10 @@ 'use client'; -import { useEffect, useRef } from 'react'; +import { useMemo } from 'react'; +import MarkdownIt from 'markdown-it'; +import sanitizeHtml from 'sanitize-html'; +import htmlReady from '@/lib/html-ready'; +import sanitizeConfig from '@/lib/sanitize-config'; interface MarkdownViewerProps { text: string; @@ -9,11 +13,28 @@ interface MarkdownViewerProps { hideImages?: boolean; } +// Markdown→HTML renderer. html:true so author HTML passes through to sanitize; +// breaks:true so single newlines render as
(Steemit's markdown dialect). +// Uses markdown-it (actively maintained, CommonMark-spec) — replaces the +// unmaintained remarkable, with an identical options surface. +const md = new MarkdownIt({ + html: true, + breaks: true, + linkify: false, + typographer: false, + quotes: '“”‘’', +}); + /** - * MarkdownViewer component - * Renders markdown content as HTML - * Simplified version migrated from legacy/src/app/components/cards/MarkdownViewer.jsx - * TODO: Add full markdown rendering with remarkable, HtmlReady, and sanitize-html + * MarkdownViewer — renders post-body markdown as sanitized, proxied HTML. + * + * Pipeline (order is security-critical): + * 1. markdown-it: markdown → HTML + * 2. HtmlReady: URL normalization + link/mention/#tag linkify + image proxy + * 3. sanitize-html (SanitizeConfig): XSS filter — allowedTags/Attributes, + * iframe whitelist, link/image hardening. + * + * Ported from master's MarkdownViewer.jsx, adapted to the next branch. */ export default function MarkdownViewer({ text, @@ -21,54 +42,56 @@ export default function MarkdownViewer({ large = false, hideImages = false, }: MarkdownViewerProps) { - const containerRef = useRef(null); - - useEffect(() => { - if (!containerRef.current || !text) return; + const html = useMemo(() => { + if (!text) return ''; - // Basic markdown to HTML conversion - // TODO: Replace with proper markdown renderer (remarkable) - let html = text - // Headers - .replace(/^### (.*$)/gim, '

$1

') - .replace(/^## (.*$)/gim, '

$1

') - .replace(/^# (.*$)/gim, '

$1

') - // Bold - .replace(/\*\*(.*?)\*\*/gim, '$1') - .replace(/__(.*?)__/gim, '$1') - // Italic - .replace(/\*(.*?)\*/gim, '$1') - .replace(/_(.*?)_/gim, '$1') - // Links - .replace(/\[([^\]]+)\]\(([^)]+)\)/gim, '$1') - // Line breaks - .replace(/\n\n/gim, '

') - .replace(/\n/gim, '
'); + let body = text; - // Wrap in paragraph if not already wrapped - if (!html.startsWith('<')) { - html = `

${html}

`; + // Detect raw-HTML posts (wrapped in … or a leading

). + let isHtml = false; + const m = body.match(/^([\S\s]*)<\/html>$/); + if (m && m.length === 2) { + isHtml = true; + body = m[1]; + } else if (/^

[\S\s]*<\/p>/.test(body)) { + isHtml = true; } - // Handle images - if (hideImages) { - html = html.replace(/]*>/gi, (match) => { - const srcMatch = match.match(/src="([^"]+)"/); - return srcMatch ? `[Image: ${srcMatch[1]}]` : match; - }); + // Strip HTML comments ("JS-DOS" mitigation). + body = body.replace(/|$)/g, '(html comment removed: $1)'); + + // 1. markdown → HTML (skip for raw-HTML posts). + let rendered = isHtml ? body : md.render(body); + + // 2. HtmlReady mutation (linkify, proxify images, wrap iframes, …). + rendered = htmlReady(rendered, { hideImages }).html; + + // 3. sanitize-html (XSS filter + iframe whitelist). + const sanitizeErrors: string[] = []; + const clean = sanitizeHtml( + rendered, + sanitizeConfig({ large, highQualityPost: true, sanitizeErrors }) + ); + + if (sanitizeErrors.length > 0) { + console.warn('MarkdownViewer sanitize errors:', sanitizeErrors); } - containerRef.current.innerHTML = html; - }, [text, hideImages]); + // Secondary trap: refuse to render any