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 ->
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(`${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 (
- {release.body}
-
+ {bodyHtml ? (
+
) : null}
);