diff --git a/src/pages/AdminUpdates.tsx b/src/pages/AdminUpdates.tsx index e598096..283831f 100644 --- a/src/pages/AdminUpdates.tsx +++ b/src/pages/AdminUpdates.tsx @@ -1,6 +1,8 @@ +import { useMemo } from 'react'; import { useNavigate } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; +import DOMPurify from 'dompurify'; import { adminUpdatesApi, ReleaseItem, ProjectReleasesInfo } from '../api/adminUpdates'; declare const __APP_VERSION__: string; @@ -107,6 +109,65 @@ function stripVPrefix(tag: string): string { return tag.replace(/^v/, ''); } +function renderMarkdown(md: string): string { + const html = md + // Headers: ### Title ->

Title

+ .replace(/^#### (.+)$/gm, '

$1

') + .replace(/^### (.+)$/gm, '

$1

') + .replace(/^## (.+)$/gm, '

$1

') + .replace(/^# (.+)$/gm, '

$1

') + // Bold: **text** -> text + .replace(/\*\*(.+?)\*\*/g, '$1') + // Inline code: `text` -> text + .replace(/`([^`]+)`/g, '$1') + // Links: [text](url) -> text + .replace( + /\[([^\]]+)\]\(([^)]+)\)/g, + '$1', + ); + + // Process lines into blocks + const lines = html.split('\n'); + const blocks: string[] = []; + let listItems: string[] = []; + + const flushList = () => { + if (listItems.length > 0) { + blocks.push(``); + listItems = []; + } + }; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) { + flushList(); + continue; + } + // List item: * text or - text + const listMatch = trimmed.match(/^[*-]\s+(.+)$/); + if (listMatch) { + listItems.push(`
  • ${listMatch[1]}
  • `); + continue; + } + // Already an HTML block element from header replacement + if (/^/.test(trimmed)) { + flushList(); + blocks.push(trimmed); + continue; + } + // Regular line + flushList(); + blocks.push(`

    ${trimmed}

    `); + } + flushList(); + + return DOMPurify.sanitize(blocks.join(''), { + ALLOWED_TAGS: ['h1', 'h2', 'h3', 'h4', 'p', 'ul', 'li', 'strong', 'em', 'code', 'a', 'br'], + ALLOWED_ATTR: ['href', 'target', 'rel'], + }); +} + // ============ Components ============ function VersionBadge({ hasUpdate }: { hasUpdate: boolean }) { @@ -131,6 +192,11 @@ function VersionBadge({ hasUpdate }: { hasUpdate: boolean }) { function ReleaseCard({ release }: { release: ReleaseItem }) { const { t } = useTranslation(); + // Content is sanitized via DOMPurify in renderMarkdown — safe for innerHTML + const bodyHtml = useMemo( + () => (release.body ? renderMarkdown(release.body) : ''), + [release.body], + ); return (
    @@ -152,10 +218,11 @@ function ReleaseCard({ release }: { release: ReleaseItem }) { {formatDate(release.published_at)}
    - {release.body ? ( -
    -          {release.body}
    -        
    + {bodyHtml ? ( +
    ) : null}
    );