-
Notifications
You must be signed in to change notification settings - Fork 9.6k
fix(web): render custom HTML and Markdown content consistently #5760
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| /* | ||
| Copyright (C) 2023-2026 QuantumNous | ||
|
|
||
| This program is free software: you can redistribute it and/or modify | ||
| it under the terms of the GNU Affero General Public License as | ||
| published by the Free Software Foundation, either version 3 of the | ||
| License, or (at your option) any later version. | ||
|
|
||
| This program is distributed in the hope that it will be useful, | ||
| but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
| GNU Affero General Public License for more details. | ||
|
|
||
| You should have received a copy of the GNU Affero General Public License | ||
| along with this program. If not, see <https://www.gnu.org/licenses/>. | ||
|
|
||
| For commercial licensing, please contact support@quantumnous.com | ||
| */ | ||
| import { cn } from '@/lib/utils' | ||
|
|
||
| interface HtmlContentProps { | ||
| content: string | ||
| className?: string | ||
| } | ||
|
|
||
| export function HtmlContent(props: HtmlContentProps) { | ||
| return ( | ||
| <div | ||
| className={cn('prose prose-neutral dark:prose-invert max-w-none', props.className)} | ||
| dangerouslySetInnerHTML={{ __html: props.content }} | ||
| /> | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com | |||||||||||||||||||||||||||||||||||||||
| import type { TFunction } from 'i18next' | ||||||||||||||||||||||||||||||||||||||||
| import { Bell, Megaphone } from 'lucide-react' | ||||||||||||||||||||||||||||||||||||||||
| import { useTranslation } from 'react-i18next' | ||||||||||||||||||||||||||||||||||||||||
| import { RichContent } from '@/components/rich-content' | ||||||||||||||||||||||||||||||||||||||||
| import { getAnnouncementColorClass } from '@/lib/colors' | ||||||||||||||||||||||||||||||||||||||||
| import { formatDateTimeObject } from '@/lib/time' | ||||||||||||||||||||||||||||||||||||||||
| import { cn } from '@/lib/utils' | ||||||||||||||||||||||||||||||||||||||||
|
|
@@ -31,7 +32,6 @@ import { | |||||||||||||||||||||||||||||||||||||||
| EmptyMedia, | ||||||||||||||||||||||||||||||||||||||||
| EmptyTitle, | ||||||||||||||||||||||||||||||||||||||||
| } from '@/components/ui/empty' | ||||||||||||||||||||||||||||||||||||||||
| import { Markdown } from '@/components/ui/markdown' | ||||||||||||||||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||||||||||||||||
| Popover, | ||||||||||||||||||||||||||||||||||||||||
| PopoverContent, | ||||||||||||||||||||||||||||||||||||||||
|
|
@@ -44,6 +44,7 @@ import { Separator } from '@/components/ui/separator' | |||||||||||||||||||||||||||||||||||||||
| import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| interface AnnouncementItem { | ||||||||||||||||||||||||||||||||||||||||
| id?: number | string | ||||||||||||||||||||||||||||||||||||||||
| type?: string | ||||||||||||||||||||||||||||||||||||||||
| content?: string | ||||||||||||||||||||||||||||||||||||||||
| extra?: string | ||||||||||||||||||||||||||||||||||||||||
|
|
@@ -72,8 +73,9 @@ function getRelativeTime(publishDate: string | Date, t: TFunction): string { | |||||||||||||||||||||||||||||||||||||||
| const pubDate = new Date(publishDate) | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| // If invalid date, return original string | ||||||||||||||||||||||||||||||||||||||||
| if (isNaN(pubDate.getTime())) | ||||||||||||||||||||||||||||||||||||||||
| if (Number.isNaN(pubDate.getTime())) { | ||||||||||||||||||||||||||||||||||||||||
| return typeof publishDate === 'string' ? publishDate : '' | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| const diffMs = now.getTime() - pubDate.getTime() | ||||||||||||||||||||||||||||||||||||||||
| const diffSeconds = Math.floor(diffMs / 1000) | ||||||||||||||||||||||||||||||||||||||||
|
|
@@ -89,26 +91,31 @@ function getRelativeTime(publishDate: string | Date, t: TFunction): string { | |||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| // Return relative time based on difference | ||||||||||||||||||||||||||||||||||||||||
| if (diffSeconds < 60) return t('Just now') | ||||||||||||||||||||||||||||||||||||||||
| if (diffMinutes < 60) | ||||||||||||||||||||||||||||||||||||||||
| if (diffMinutes < 60) { | ||||||||||||||||||||||||||||||||||||||||
| return diffMinutes === 1 | ||||||||||||||||||||||||||||||||||||||||
| ? t('1 minute ago') | ||||||||||||||||||||||||||||||||||||||||
| : t('{{count}} minutes ago', { count: diffMinutes }) | ||||||||||||||||||||||||||||||||||||||||
| if (diffHours < 24) | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| if (diffHours < 24) { | ||||||||||||||||||||||||||||||||||||||||
| return diffHours === 1 | ||||||||||||||||||||||||||||||||||||||||
| ? t('1 hour ago') | ||||||||||||||||||||||||||||||||||||||||
| : t('{{count}} hours ago', { count: diffHours }) | ||||||||||||||||||||||||||||||||||||||||
| if (diffDays < 7) | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| if (diffDays < 7) { | ||||||||||||||||||||||||||||||||||||||||
| return diffDays === 1 | ||||||||||||||||||||||||||||||||||||||||
| ? t('1 day ago') | ||||||||||||||||||||||||||||||||||||||||
| : t('{{count}} days ago', { count: diffDays }) | ||||||||||||||||||||||||||||||||||||||||
| if (diffWeeks < 4) | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| if (diffWeeks < 4) { | ||||||||||||||||||||||||||||||||||||||||
| return diffWeeks === 1 | ||||||||||||||||||||||||||||||||||||||||
| ? t('1 week ago') | ||||||||||||||||||||||||||||||||||||||||
| : t('{{count}} weeks ago', { count: diffWeeks }) | ||||||||||||||||||||||||||||||||||||||||
| if (diffMonths < 12) | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| if (diffMonths < 12) { | ||||||||||||||||||||||||||||||||||||||||
| return diffMonths === 1 | ||||||||||||||||||||||||||||||||||||||||
| ? t('1 month ago') | ||||||||||||||||||||||||||||||||||||||||
| : t('{{count}} months ago', { count: diffMonths }) | ||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+109
to
117
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 28–29 day timestamps now render as When 🐛 Proposed fix- if (diffWeeks < 4) {
+ if (diffDays < 30) {
return diffWeeks === 1
? t('1 week ago')
: t('{{count}} weeks ago', { count: diffWeeks })
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| if (diffYears < 2) return t('1 year ago') | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| // Over 2 years, show specific date | ||||||||||||||||||||||||||||||||||||||||
|
|
@@ -129,6 +136,19 @@ function AnnouncementDot({ type }: { type?: string }) { | |||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| function getAnnouncementRenderKey(announcement: AnnouncementItem): string { | ||||||||||||||||||||||||||||||||||||||||
| if (announcement.id !== undefined && announcement.id !== null) { | ||||||||||||||||||||||||||||||||||||||||
| return `id:${announcement.id}` | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| return JSON.stringify({ | ||||||||||||||||||||||||||||||||||||||||
| content: announcement.content ?? '', | ||||||||||||||||||||||||||||||||||||||||
| extra: announcement.extra ?? '', | ||||||||||||||||||||||||||||||||||||||||
| publishDate: announcement.publishDate ?? '', | ||||||||||||||||||||||||||||||||||||||||
| type: announcement.type ?? '', | ||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||
| * Empty state component | ||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||
|
|
@@ -184,7 +204,7 @@ function NoticeContent({ | |||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||||||||||||
| <ScrollArea className='h-[min(52vh,28rem)] pr-3'> | ||||||||||||||||||||||||||||||||||||||||
| <Markdown>{notice}</Markdown> | ||||||||||||||||||||||||||||||||||||||||
| <RichContent breaks content={notice} /> | ||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win Don't render notice/announcement HTML through These fields used to stay on the Markdown renderer. With As per coding guidelines, "Do not rely on client-side checks alone for auth, permissions, validation, or security-sensitive data; avoid hardcoded secrets and minimize Also applies to: 262-267 🤖 Prompt for AI AgentsSource: Coding guidelines |
||||||||||||||||||||||||||||||||||||||||
| </ScrollArea> | ||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
@@ -221,6 +241,7 @@ function AnnouncementsContent({ | |||||||||||||||||||||||||||||||||||||||
| <ScrollArea className='h-[min(52vh,28rem)] pr-3'> | ||||||||||||||||||||||||||||||||||||||||
| <div className='flex flex-col'> | ||||||||||||||||||||||||||||||||||||||||
| {announcements.map((item, idx) => { | ||||||||||||||||||||||||||||||||||||||||
| const announcementKey = getAnnouncementRenderKey(item) | ||||||||||||||||||||||||||||||||||||||||
| const publishDate = item.publishDate | ||||||||||||||||||||||||||||||||||||||||
| ? new Date(item.publishDate) | ||||||||||||||||||||||||||||||||||||||||
| : null | ||||||||||||||||||||||||||||||||||||||||
|
|
@@ -232,18 +253,18 @@ function AnnouncementsContent({ | |||||||||||||||||||||||||||||||||||||||
| : '' | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||||||||||||
| <div key={idx}> | ||||||||||||||||||||||||||||||||||||||||
| <div key={announcementKey}> | ||||||||||||||||||||||||||||||||||||||||
| <div className='py-3'> | ||||||||||||||||||||||||||||||||||||||||
| <div className='flex items-start gap-3'> | ||||||||||||||||||||||||||||||||||||||||
| <AnnouncementDot type={item.type} /> | ||||||||||||||||||||||||||||||||||||||||
| <div className='flex min-w-0 flex-1 flex-col gap-2'> | ||||||||||||||||||||||||||||||||||||||||
| <div className='text-sm'> | ||||||||||||||||||||||||||||||||||||||||
| <Markdown>{item.content || ''}</Markdown> | ||||||||||||||||||||||||||||||||||||||||
| <RichContent breaks content={item.content || ''} /> | ||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| {item.extra ? ( | ||||||||||||||||||||||||||||||||||||||||
| <div className='text-muted-foreground text-xs'> | ||||||||||||||||||||||||||||||||||||||||
| <Markdown>{item.extra}</Markdown> | ||||||||||||||||||||||||||||||||||||||||
| <RichContent breaks content={item.extra} /> | ||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||
| ) : null} | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| /* | ||
| Copyright (C) 2023-2026 QuantumNous | ||
|
|
||
| This program is free software: you can redistribute it and/or modify | ||
| it under the terms of the GNU Affero General Public License as | ||
| published by the Free Software Foundation, either version 3 of the | ||
| License, or (at your option) any later version. | ||
|
|
||
| This program is distributed in the hope that it will be useful, | ||
| but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
| GNU Affero General Public License for more details. | ||
|
|
||
| You should have received a copy of the GNU Affero General Public License | ||
| along with this program. If not, see <https://www.gnu.org/licenses/>. | ||
|
|
||
| For commercial licensing, please contact support@quantumnous.com | ||
| */ | ||
| import { isLikelyHtml } from '@/lib/content-format' | ||
| import { HtmlContent } from '@/components/html-content' | ||
| import { Markdown } from '@/components/ui/markdown' | ||
|
|
||
| interface RichContentProps { | ||
| content: string | ||
| breaks?: boolean | ||
| className?: string | ||
| } | ||
|
|
||
| export function RichContent(props: RichContentProps) { | ||
| if (isLikelyHtml(props.content)) { | ||
| return <HtmlContent content={props.content} className={props.className} /> | ||
| } | ||
|
|
||
| return ( | ||
| <Markdown breaks={props.breaks} className={props.className}> | ||
| {props.content} | ||
| </Markdown> | ||
| ) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win
Sanitize
props.contentbefore injecting it into the DOM.This branch now renders backend-provided page content verbatim on public surfaces. A saved
<script>tag or inline event handler here becomes stored XSS for every visitor who hits the HTML path.Suggested change
As per coding guidelines, "Do not rely on client-side checks alone for auth, permissions, validation, or security-sensitive data; avoid hardcoded secrets and minimize dangerouslySetInnerHTML usage."
📝 Committable suggestion
🧰 Tools
🪛 ast-grep (0.44.0)
[warning] 29-29: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation
(react-unsafe-html-injection)
🤖 Prompt for AI Agents
Sources: Coding guidelines, Linters/SAST tools