diff --git a/docs/package.json b/docs/package.json index b284fbdb8b9..640ccb66176 100644 --- a/docs/package.json +++ b/docs/package.json @@ -32,6 +32,7 @@ "clsx": "^1.2.1", "cspell": "^6.18.1", "docusaurus-plugin-sass": "^0.2.2", + "dompurify": "^3.1.5", "graphiql": "^1.5.1", "graphql": "^15.7.2", "graphql-ws": "^5.11.2", diff --git a/docs/src/components/AiChatBot/AiChatBot.tsx b/docs/src/components/AiChatBot/AiChatBot.tsx index f1fbc14cf4b..1725ff95e05 100644 --- a/docs/src/components/AiChatBot/AiChatBot.tsx +++ b/docs/src/components/AiChatBot/AiChatBot.tsx @@ -1,9 +1,10 @@ import React, { useEffect, useRef, useState } from 'react'; -import Markdown from 'markdown-to-jsx' +import Markdown from 'markdown-to-jsx'; +import DOMPurify from 'dompurify'; import './styles.css'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import { CloseIcon, RespondingIconGray, SparklesIcon } from '@site/src/components/AiChatBot/icons'; -import { useLocalStorage } from 'usehooks-ts' +import { useLocalStorage } from 'usehooks-ts'; import profilePic from '@site/static/img/docs-bot-profile-pic.webp'; import { v4 as uuidv4 } from 'uuid'; @@ -38,8 +39,7 @@ const initialMessages: Message[] = [ }, ]; - -export function AiChatBot() { +export function AiChatBot({ style }) { // Get the docsBotEndpointURL and hasuraVersion from the siteConfig const { siteConfig: { customFields }, @@ -47,13 +47,19 @@ export function AiChatBot() { // Manage the open state of the popup const [isOpen, setIsOpen] = useState(false); // Manage the bot responding state - const [isResponding, setIsResponding] = useState(false) + const [isResponding, setIsResponding] = useState(false); // Manage the text input const [input, setInput] = useState(''); // Manage the message thread ID - const [messageThreadId, setMessageThreadId] = useLocalStorage(`hasuraV${customFields.hasuraVersion}ThreadId`, uuidv4()) + const [messageThreadId, setMessageThreadId] = useLocalStorage( + `hasuraV${customFields.hasuraVersion}ThreadId`, + uuidv4() + ); // Manage the historical messages - const [messages, setMessages] = useLocalStorage(`hasuraV${customFields.hasuraVersion}BotMessages`, initialMessages); + const [messages, setMessages] = useLocalStorage( + `hasuraV${customFields.hasuraVersion}BotMessages`, + initialMessages + ); // Manage the current message const [currentMessage, setCurrentMessage] = useState({ userMessage: '', botResponse: '' }); // Manage scrolling to the end @@ -69,23 +75,41 @@ export function AiChatBot() { // Enables scrolling to the end const scrollDiv = useRef(null); - const { docsBotEndpointURL, hasuraVersion, DEV_TOKEN } = customFields as { docsBotEndpointURL: string; hasuraVersion: number; DEV_TOKEN: string }; + const { docsBotEndpointURL, hasuraVersion, DEV_TOKEN } = customFields as { + docsBotEndpointURL: string; + hasuraVersion: number; + DEV_TOKEN: string; + }; - const storedUserID = localStorage.getItem('hasuraDocsUserID') as string | "null"; + const sanitizeInput = (input: string): string => { + const sanitized = DOMPurify.sanitize(input.trim()); + return sanitized.replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + }; + + const validateInput = (input: string): boolean => { + return input.length > 0 && input.length <= 1000; + }; + + const storedUserID = localStorage.getItem('hasuraDocsUserID') as string | 'null'; // Effect to auto-scroll to the bottom if autoScroll is true useEffect(() => { if (isAutoScroll) { scrollDiv.current?.scrollTo({ top: scrollDiv.current.scrollHeight, - behavior: 'smooth' + behavior: 'smooth', }); } }, [currentMessage.botResponse]); // Detect if user scrolls up and disable auto-scrolling - const handleScroll = (e) => { - const atBottom = Math.abs(scrollDiv.current?.scrollHeight - Math.floor(e.target.scrollTop + e.target.clientHeight)) < 2; + const handleScroll = e => { + const atBottom = + Math.abs(scrollDiv.current?.scrollHeight - Math.floor(e.target.scrollTop + e.target.clientHeight)) < 2; setIsAutoScroll(atBottom); }; @@ -99,13 +123,14 @@ export function AiChatBot() { let websocket; let reconnectInterval; - const queryDevToken = process.env.NODE_ENV === "development" && DEV_TOKEN ? `&devToken=${DEV_TOKEN}` : ""; + const queryDevToken = process.env.NODE_ENV === 'development' && DEV_TOKEN ? `&devToken=${DEV_TOKEN}` : ''; - - console.log("process.env.NODE_ENV", process.env.NODE_ENV); + console.log('process.env.NODE_ENV', process.env.NODE_ENV); const connectWebSocket = () => { - websocket = new WebSocket(encodeURI(`${docsBotEndpointURL}?version=${hasuraVersion}&userId=${storedUserID}${queryDevToken}`)); + websocket = new WebSocket( + encodeURI(`${docsBotEndpointURL}?version=${hasuraVersion}&userId=${storedUserID}${queryDevToken}`) + ); websocket.onopen = () => { console.log('Connected to the websocket'); @@ -113,41 +138,40 @@ export function AiChatBot() { clearTimeout(reconnectInterval); }; - websocket.onmessage = (event) => { - - let response = { type: "", message: "" }; + websocket.onmessage = event => { + let response = { type: '', message: '' }; try { - response = JSON.parse(event.data) as {"type": string, "message": string} + response = JSON.parse(event.data) as { type: string; message: string }; } catch (e) { - console.error("error parsing websocket message", e); + console.error('error parsing websocket message', e); } switch (response.type) { - case "endOfStream": { + case 'endOfStream': { console.log('end of stream'); setMessages((prevMessages: Message[]) => [...prevMessages, currentMessageRef.current]); setCurrentMessage({ userMessage: '', botResponse: '' }); setIsResponding(false); break; } - case "responsePart": { + case 'responsePart': { setIsResponding(true); setCurrentMessage(prevState => { return { ...prevState, botResponse: prevState?.botResponse + response.message }; }); break; } - case "error": { - console.error("error", response.message); + case 'error': { + console.error('error', response.message); break; } - case "loading": { - console.log("loading", response.message); + case 'loading': { + console.log('loading', response.message); break; } default: { - console.error("unknown response type", response.type); + console.error('unknown response type', response.type); break; } } @@ -181,103 +205,141 @@ export function AiChatBot() { // Send the query to the websocket when the user submits the form const handleSubmit = async () => { - // if the input is empty, do nothing - if (!input) { + const sanitizedInput = sanitizeInput(input); + + if (!validateInput(sanitizedInput)) { + console.error('Invalid input'); return; } if (ws) { const toSend = JSON.stringify({ previousMessages: messages, currentUserInput: input, messageThreadId }); - setCurrentMessage({ userMessage: input, botResponse: '' }); + setCurrentMessage({ userMessage: sanitizedInput, botResponse: '' }); setInput(''); ws.send(toSend); setIsResponding(true); } - }; - const baseUrl = useDocusaurusContext().siteConfig.baseUrl; + const renderMessage = (content: string) => { + return ( + + {DOMPurify.sanitize(content)} + + ); + }; + + const isOnOverviewOrIndex = + window.location.href.endsWith('/index') || + window.location.href.endsWith('/overview') || + window.location.href.endsWith('/overview/'); return ( -
- {isOpen ? ( - - ) : ( - - )} - {isOpen && ( -
-
-
-
DocsBot
- -
- -
-
- {messages.map((msg, index) => ( -
- {msg.userMessage && ( -
-
- {msg.userMessage} -
-
- )} - {msg.botResponse && ( -
-
- {msg.botResponse} -
-
- )} -
- ))} -
- {currentMessage.userMessage && ( -
- {currentMessage.userMessage} +
+
+ {isOpen ? ( + <> + ) : ( + + )} + {isOpen && ( +
+ {isOpen && ( + + )} +
+
+
+
DocsBot
+
- )} -
-
-
- {currentMessage.botResponse && ( -
- {currentMessage.botResponse} + +
+
+ {messages.map((msg, index) => ( +
+ {msg.userMessage && ( +
+
+ {renderMessage(msg.userMessage)} +
+
+ )} + {msg.botResponse && ( +
+
+ {renderMessage(msg.botResponse)} +
+
+ )}
- )} -
-
- {isResponding ? - RespondingIconGray : null} + ))} +
+ {currentMessage.userMessage && ( +
+ {renderMessage(currentMessage.userMessage)} +
+ )} +
+
+
+ {currentMessage.botResponse && ( +
+ {renderMessage(currentMessage.botResponse)} +
+ )} +
+
{isResponding ? RespondingIconGray : null}
+
+ {/* Handles scrolling to the end */} + {/*
*/} +
{ + e.preventDefault(); + handleSubmit(); + }} + > + setInput(e.target.value)} + maxLength={1000} + /> + +
- {/* Handles scrolling to the end */} - {/*
*/} -
{ - e.preventDefault(); - handleSubmit(); - }} - > - setInput(e.target.value)} /> - -
-
- )} + )} +
); -} \ No newline at end of file +} diff --git a/docs/yarn.lock b/docs/yarn.lock index 65232a9fdc5..5522319ae2d 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -5510,6 +5510,7 @@ __metadata: clsx: ^1.2.1 cspell: ^6.18.1 docusaurus-plugin-sass: ^0.2.2 + dompurify: ^3.1.5 graphiql: ^1.5.1 graphql: ^15.7.2 graphql-ws: ^5.11.2 @@ -5605,6 +5606,13 @@ __metadata: languageName: node linkType: hard +"dompurify@npm:^3.1.5": + version: 3.1.5 + resolution: "dompurify@npm:3.1.5" + checksum: 18ae2930cba3c260889b99e312c382c344d219bd113bc39fbb665a61987d25849021768f490395e6954aab94448a24b3c3721c160b53550547110c37cebe9feb + languageName: node + linkType: hard + "domutils@npm:^2.5.2, domutils@npm:^2.8.0": version: 2.8.0 resolution: "domutils@npm:2.8.0"