mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-14 08:02:15 +03:00
Docs: sanitize bot user input
PR-URL: https://github.com/hasura/graphql-engine-mono/pull/10920 GitOrigin-RevId: c02782fb63ff2f2f46c1bba532b0539d2d217b8c
This commit is contained in:
parent
69219958d6
commit
122abb5f46
@ -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",
|
||||
|
@ -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, '&')
|
||||
.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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user