diff --git a/package.json b/package.json index 8e53d260d..ad87eb81c 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "rehype-external-links": "^3.0.0", "remark-parse": "^11.0.0", "roadmap-renderer": "^1.0.6", + "sanitize-html": "^2.13.1", "satori": "^0.10.14", "satori-html": "^0.3.2", "sharp": "^0.33.4", @@ -101,6 +102,7 @@ "@types/prismjs": "^1.26.4", "@types/react-calendar-heatmap": "^1.6.7", "@types/react-slick": "^0.23.13", + "@types/sanitize-html": "^2.13.0", "@types/sql.js": "^1.4.9", "@types/turndown": "^5.0.5", "csv-parser": "^3.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6ba3dd3b..091443a5e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -158,6 +158,9 @@ importers: roadmap-renderer: specifier: ^1.0.6 version: 1.0.6 + sanitize-html: + specifier: ^2.13.1 + version: 2.13.1 satori: specifier: ^0.10.14 version: 0.10.14 @@ -219,6 +222,9 @@ importers: '@types/react-slick': specifier: ^0.23.13 version: 0.23.13 + '@types/sanitize-html': + specifier: ^2.13.0 + version: 2.13.0 '@types/sql.js': specifier: ^1.4.9 version: 1.4.9 @@ -1554,6 +1560,9 @@ packages: '@types/react@18.3.8': resolution: {integrity: sha512-syBUrW3/XpnW4WJ41Pft+I+aPoDVbrBVQGEnbD7NijDGlVC+8gV/XKRY+7vMDlfPpbwYt0l1vd/Sj8bJGMbs9Q==} + '@types/sanitize-html@2.13.0': + resolution: {integrity: sha512-X31WxbvW9TjIhZZNyNBZ/p5ax4ti7qsNDBDEnH4zAgmEh35YnFD1UiS6z9Cd34kKm0LslFW0KPmTQzu/oGtsqQ==} + '@types/sax@1.2.7': resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==} @@ -1928,6 +1937,10 @@ packages: decode-named-character-reference@1.0.2: resolution: {integrity: sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==} + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -2279,6 +2292,9 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + htmlparser2@8.0.2: + resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + http-cache-semantics@4.1.1: resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==} @@ -2357,6 +2373,10 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + is-unicode-supported@1.3.0: resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} engines: {node: '>=12'} @@ -2820,6 +2840,9 @@ packages: parse-latin@7.0.0: resolution: {integrity: sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==} + parse-srcset@1.0.2: + resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==} + parse5@7.1.2: resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==} @@ -3223,6 +3246,9 @@ packages: s.color@0.0.15: resolution: {integrity: sha512-AUNrbEUHeKY8XsYr/DYpl+qk5+aM+DChopnWOPEzn8YKzOhv4l2zH6LzZms3tOZP3wwdOyc0RmTciyi46HLIuA==} + sanitize-html@2.13.1: + resolution: {integrity: sha512-ZXtKq89oue4RP7abL9wp/9URJcqQNABB5GGJ2acW1sdO8JTVl92f4ygD7Yc9Ze09VAZhnt2zegeU0tbNsdcLYg==} + sass-formatter@0.7.9: resolution: {integrity: sha512-CWZ8XiSim+fJVG0cFLStwDvft1VI7uvXdCNJYXhDvowiv+DsbD1nXLiQ4zrE5UBvj5DWZJ93cwN0NX5PMsr1Pw==} @@ -4955,6 +4981,10 @@ snapshots: '@types/prop-types': 15.7.13 csstype: 3.1.3 + '@types/sanitize-html@2.13.0': + dependencies: + htmlparser2: 8.0.2 + '@types/sax@1.2.7': dependencies: '@types/node': 17.0.45 @@ -5373,6 +5403,8 @@ snapshots: dependencies: character-entities: 2.0.2 + deepmerge@4.3.1: {} + delayed-stream@1.0.0: {} depd@2.0.0: {} @@ -5784,6 +5816,13 @@ snapshots: html-void-elements@3.0.0: {} + htmlparser2@8.0.2: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.1.0 + entities: 4.5.0 + http-cache-semantics@4.1.1: {} http-errors@2.0.0: @@ -5845,6 +5884,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-plain-object@5.0.0: {} + is-unicode-supported@1.3.0: {} is-unicode-supported@2.1.0: {} @@ -6462,6 +6503,8 @@ snapshots: unist-util-visit-children: 3.0.0 vfile: 6.0.3 + parse-srcset@1.0.2: {} + parse5@7.1.2: dependencies: entities: 4.5.0 @@ -6914,6 +6957,15 @@ snapshots: s.color@0.0.15: {} + sanitize-html@2.13.1: + dependencies: + deepmerge: 4.3.1 + escape-string-regexp: 4.0.0 + htmlparser2: 8.0.2 + is-plain-object: 5.0.0 + parse-srcset: 1.0.2 + postcss: 8.4.47 + sass-formatter@0.7.9: dependencies: suf-log: 2.5.3 diff --git a/src/components/CourseAI/CourseAIPopover.tsx b/src/components/CourseAI/CourseAIPopover.tsx index e55abe34e..cc9928c29 100644 --- a/src/components/CourseAI/CourseAIPopover.tsx +++ b/src/components/CourseAI/CourseAIPopover.tsx @@ -1,10 +1,16 @@ -import { useMemo, useRef, useState } from 'react'; -import { useListCourseNote } from '../../hooks/use-course-note'; +import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react'; import type { ChapterFileType } from '../../lib/course'; -import { Bot, Loader2 } from 'lucide-react'; +import { Bot, Send } from 'lucide-react'; import { useOutsideClick } from '../../hooks/use-outside-click'; import { cn } from '../../lib/classname'; import { markdownToHtml } from '../../lib/markdown'; +import { sanitizeHtml } from '../../lib/sanitize-html'; +import { + roadmapAIChatHistory, + type AllowedAIChatType, +} from '../../stores/course'; +import { useStore } from '@nanostores/react'; +import { flushSync } from 'react-dom'; type CourseAIPopoverProps = { courseId: string; @@ -26,9 +32,44 @@ export function CourseAIPopover(props: CourseAIPopoverProps) { } = props; const containerRef = useRef(null); + const scrollareaRef = useRef(null); + const [message, setMessage] = useState(''); + + const $roadmapAIChatHistory = useStore(roadmapAIChatHistory); useOutsideClick(containerRef, onOutsideClick); + const handleChatSubmit = (e: FormEvent) => { + e.preventDefault(); + if (!message) { + return; + } + + flushSync(() => { + roadmapAIChatHistory.set([ + ...$roadmapAIChatHistory, + { + type: 'user', + message, + }, + ]); + setMessage(''); + }); + + scrollToBottom(); + }; + + const scrollToBottom = () => { + scrollareaRef.current?.scrollTo({ + top: scrollareaRef.current.scrollHeight, + behavior: 'smooth', + }); + }; + + useEffect(() => { + scrollToBottom(); + }, []); + return (
Roadmap AI
-
+
-
- - - - - - - - +
+
+ {$roadmapAIChatHistory.map((chat, index) => { + return ( + + ); + })} +
- +
+ setMessage(e.target.value)} + /> + +
); } type AIChatCardProps = { - type: 'user' | 'system'; + type: AllowedAIChatType; message: string; }; @@ -106,9 +130,7 @@ function AIChatCard(props: AIChatCardProps) { const { type, message } = props; const html = useMemo(() => { - const html = markdownToHtml(message, false); - // FIXME: Sanitize HTML - return html; + return sanitizeHtml(markdownToHtml(message, false)); }, [message]); return ( @@ -129,7 +151,7 @@ function AIChatCard(props: AIChatCardProps) {
diff --git a/src/components/CourseNotes/CourseNoteCard.tsx b/src/components/CourseNotes/CourseNoteCard.tsx index 53389275e..dc7729ee2 100644 --- a/src/components/CourseNotes/CourseNoteCard.tsx +++ b/src/components/CourseNotes/CourseNoteCard.tsx @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { markdownToHtml } from '../../lib/markdown'; import { ArrowRight } from 'lucide-react'; +import { sanitizeHtml } from '../../lib/sanitize-html'; type CourseNoteCardProps = { courseId: string; @@ -27,10 +28,7 @@ export function CourseNoteCard(props: CourseNoteCardProps) { } = props; const markdownHTML = useMemo(() => { - const html = markdownToHtml(content, false); - // FIXME: Sanitize html before returning - - return html; + return sanitizeHtml(markdownToHtml(content, false)); }, [content]); return ( diff --git a/src/lib/sanitize-html.ts b/src/lib/sanitize-html.ts new file mode 100644 index 000000000..b6b99ff86 --- /dev/null +++ b/src/lib/sanitize-html.ts @@ -0,0 +1,45 @@ +import _sanitizeHtml from 'sanitize-html'; + +export function sanitizeHtml(html: string) { + return _sanitizeHtml(html, { + allowedTags: [ + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'blockquote', + 'p', + 'a', + 'ul', + 'ol', + 'nl', + 'li', + 'b', + 'i', + 'strong', + 'em', + 'strike', + 'code', + 'hr', + 'br', + 'div', + 'table', + 'thead', + 'caption', + 'tbody', + 'tr', + 'th', + 'td', + 'pre', + 'img', + ], + allowedAttributes: { + a: ['href', 'name', 'target'], + img: ['src'], + '*': ['class'], + }, + allowedSchemes: ['http', 'https', 'ftp', 'mailto'], + }); +} diff --git a/src/stores/course.ts b/src/stores/course.ts index 37f8c7a05..8686bdb7e 100644 --- a/src/stores/course.ts +++ b/src/stores/course.ts @@ -11,3 +11,16 @@ export type CurrentLessonType = { }; export const currentLesson = atom(null); + +export type AllowedAIChatType = 'user' | 'system'; +export type AIChatHistoryType = { + type: AllowedAIChatType; + message: string; +}; + +export const roadmapAIChatHistory = atom([ + { + type: 'system', + message: 'Hey, how can I help you today? 🤖', + }, +]);