Docs: sanitize bot user input

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/10920
GitOrigin-RevId: c02782fb63ff2f2f46c1bba532b0539d2d217b8c
This commit is contained in:
Sean Park-Ross 2024-07-05 13:48:16 +01:00 committed by hasura-bot
parent 69219958d6
commit 122abb5f46
3 changed files with 179 additions and 108 deletions

View File

@ -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",

View File

@ -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<boolean>(false);
// Manage the bot responding state
const [isResponding, setIsResponding] = useState<boolean>(false)
const [isResponding, setIsResponding] = useState<boolean>(false);
// Manage the text input
const [input, setInput] = useState<string>('');
// Manage the message thread ID
const [messageThreadId, setMessageThreadId] = useLocalStorage<String>(`hasuraV${customFields.hasuraVersion}ThreadId`, uuidv4())
const [messageThreadId, setMessageThreadId] = useLocalStorage<String>(
`hasuraV${customFields.hasuraVersion}ThreadId`,
uuidv4()
);
// Manage the historical messages
const [messages, setMessages] = useLocalStorage<Message[]>(`hasuraV${customFields.hasuraVersion}BotMessages`, initialMessages);
const [messages, setMessages] = useLocalStorage<Message[]>(
`hasuraV${customFields.hasuraVersion}BotMessages`,
initialMessages
);
// Manage the current message
const [currentMessage, setCurrentMessage] = useState<Message>({ userMessage: '', botResponse: '' });
// Manage scrolling to the end
@ -69,23 +75,41 @@ export function AiChatBot() {
// Enables scrolling to the end
const scrollDiv = useRef<HTMLDivElement>(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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
};
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 (
<Markdown
options={{
overrides: {
a: {
props: {
target: '_blank',
rel: 'noopener noreferrer'
},
},
},
}}
>
{DOMPurify.sanitize(content)}
</Markdown>
);
};
const isOnOverviewOrIndex =
window.location.href.endsWith('/index') ||
window.location.href.endsWith('/overview') ||
window.location.href.endsWith('/overview/');
return (
<div className="chat-popup">
{isOpen ? (
<button className="close-chat-button" onClick={() => setIsOpen(!isOpen)}>
{CloseIcon} Close Chat
</button>
) : (
<button className="open-chat-button" onClick={() => setIsOpen(!isOpen)}>
{SparklesIcon} Hasura Docs AI Chat
</button>
)}
{isOpen && (
<div className="chat-window">
<div className="info-bar">
<div className={"bot-name-pic-container"}>
<div className="bot-name">DocsBot</div>
<img src={profilePic} height={30} width={30} className="bot-pic"/>
</div>
<button className="clear-button" onClick={() => {
setMessages(initialMessages)
setCurrentMessage({ userMessage: '', botResponse: '' });
setMessageThreadId(uuidv4());
}}>Clear</button>
</div>
<div className="messages-container" onScroll={handleScroll} ref={scrollDiv}>
{messages.map((msg, index) => (
<div key={index}>
{msg.userMessage && (
<div className="user-message-container">
<div className="formatted-text message user-message">
<Markdown>{msg.userMessage}</Markdown>
</div>
</div>
)}
{msg.botResponse && (
<div className="bot-message-container">
<div className="formatted-text message bot-message">
<Markdown>{msg.botResponse}</Markdown>
</div>
</div>
)}
</div>
))}
<div className="user-message-container">
{currentMessage.userMessage && (
<div className="formatted-text message user-message">
<Markdown>{currentMessage.userMessage}</Markdown>
<div className={'chat-popup'}>
<div className={isOnOverviewOrIndex ? 'chat-popup-index-and-overviews' : 'chat-popup-other-pages'}>
{isOpen ? (
<></>
) : (
<button className="open-chat-button" onClick={() => setIsOpen(!isOpen)}>
{SparklesIcon} Hasura Docs AI Chat
</button>
)}
{isOpen && (
<div className={isOnOverviewOrIndex ? '' : 'absolute -bottom-11 w-full min-w-[500px] right-[10px]'}>
{isOpen && (
<button className="close-chat-button" onClick={() => setIsOpen(!isOpen)}>
{CloseIcon} Close Chat
</button>
)}
<div className="chat-window">
<div className="info-bar">
<div className={'bot-name-pic-container'}>
<div className="bot-name">DocsBot</div>
<img src={profilePic} height={30} width={30} className="bot-pic" />
</div>
)}
</div>
<div>
<div className="bot-message-container">
{currentMessage.botResponse && (
<div className="formatted-text message bot-message">
<Markdown>{currentMessage.botResponse}</Markdown>
<button
className="clear-button"
onClick={() => {
setMessages(initialMessages);
setCurrentMessage({ userMessage: '', botResponse: '' });
setMessageThreadId(uuidv4());
}}
>
Clear
</button>
</div>
<div className="messages-container" onScroll={handleScroll} ref={scrollDiv}>
{messages.map((msg, index) => (
<div key={index}>
{msg.userMessage && (
<div className="user-message-container">
<div className="formatted-text message user-message">
{renderMessage(msg.userMessage)}
</div>
</div>
)}
{msg.botResponse && (
<div className="bot-message-container">
<div className="formatted-text message bot-message">
{renderMessage(msg.botResponse)}
</div>
</div>
)}
</div>
)}
</div>
<div className="responding-div">
{isResponding ?
RespondingIconGray : null}
))}
<div className="user-message-container">
{currentMessage.userMessage && (
<div className="formatted-text message user-message">
{renderMessage(currentMessage.userMessage)}
</div>
)}
</div>
<div>
<div className="bot-message-container">
{currentMessage.botResponse && (
<div className="formatted-text message bot-message">
{renderMessage(currentMessage.botResponse)}
</div>
)}
</div>
<div className="responding-div">{isResponding ? RespondingIconGray : null}</div>
</div>
</div>
{/* Handles scrolling to the end */}
{/*<div ref={messagesEndRef} />*/}
<form
className="input-container"
onSubmit={e => {
e.preventDefault();
handleSubmit();
}}
>
<input
disabled={isResponding || isConnecting}
className="input-text"
value={input}
onChange={e => setInput(e.target.value)}
maxLength={1000}
/>
<button disabled={isResponding || isConnecting} className="input-button" type="submit">
{isConnecting ? 'Connecting...' : isResponding ? 'Responding...' : 'Send'}
</button>
</form>
</div>
</div>
{/* Handles scrolling to the end */}
{/*<div ref={messagesEndRef} />*/}
<form
className="input-container"
onSubmit={e => {
e.preventDefault();
handleSubmit();
}}
>
<input disabled={isResponding || isConnecting} className="input-text" value={input} onChange={e => setInput(e.target.value)} />
<button disabled={isResponding || isConnecting} className="input-button" type="submit">
{isConnecting ? "Connecting..." : isResponding ? "Responding..." : "Send"}
</button>
</form>
</div>
)}
)}
</div>
</div>
);
}
}

View File

@ -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"