mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-19 16:58:37 +00:00
Mitigate XSS vulnerabilities in the playground where AI-generated content is rendered without sanitization, allowing potential script injection via prompt injection attacks. MarkdownRenderer.jsx: - Replace dangerouslySetInnerHTML with a sandboxed iframe for HTML preview - Use sandbox="allow-same-origin" to block script execution while allowing CSS rendering and iframe height auto-sizing - Add SandboxedHtmlPreview component with automatic height adjustment CodeViewer.jsx: - Add escapeHtml() utility to encode HTML entities before rendering - Rewrite highlightJson() to process tokens iteratively, escaping each token and structural text before wrapping in syntax highlighting spans - Escape non-JSON and very-large content paths that previously bypassed sanitization - Update linkRegex to correctly match URLs containing & entities These changes only affect the playground (AI output rendering). Admin- configured content (home page, about page, footer, notices) remains unaffected as they use separate code paths and are within the trusted admin boundary.
402 lines
11 KiB
JavaScript
402 lines
11 KiB
JavaScript
/*
|
|
Copyright (C) 2025 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 React, { useState, useMemo, useCallback } from 'react';
|
|
import { Button, Tooltip, Toast } from '@douyinfe/semi-ui';
|
|
import { Copy, ChevronDown, ChevronUp } from 'lucide-react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { copy } from '../../helpers';
|
|
|
|
const PERFORMANCE_CONFIG = {
|
|
MAX_DISPLAY_LENGTH: 50000, // 最大显示字符数
|
|
PREVIEW_LENGTH: 5000, // 预览长度
|
|
VERY_LARGE_MULTIPLIER: 2, // 超大内容倍数
|
|
};
|
|
|
|
const codeThemeStyles = {
|
|
container: {
|
|
backgroundColor: '#1e1e1e',
|
|
color: '#d4d4d4',
|
|
fontFamily: 'Consolas, "Courier New", Monaco, "SF Mono", monospace',
|
|
fontSize: '13px',
|
|
lineHeight: '1.4',
|
|
borderRadius: '8px',
|
|
border: '1px solid #3c3c3c',
|
|
position: 'relative',
|
|
overflow: 'hidden',
|
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
|
},
|
|
content: {
|
|
height: '100%',
|
|
overflowY: 'auto',
|
|
overflowX: 'auto',
|
|
padding: '16px',
|
|
margin: 0,
|
|
whiteSpace: 'pre',
|
|
wordBreak: 'normal',
|
|
background: '#1e1e1e',
|
|
},
|
|
actionButton: {
|
|
position: 'absolute',
|
|
zIndex: 10,
|
|
backgroundColor: 'rgba(45, 45, 45, 0.9)',
|
|
border: '1px solid rgba(255, 255, 255, 0.1)',
|
|
color: '#d4d4d4',
|
|
borderRadius: '6px',
|
|
transition: 'all 0.2s ease',
|
|
},
|
|
actionButtonHover: {
|
|
backgroundColor: 'rgba(60, 60, 60, 0.95)',
|
|
borderColor: 'rgba(255, 255, 255, 0.2)',
|
|
transform: 'scale(1.05)',
|
|
},
|
|
noContent: {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
height: '100%',
|
|
color: '#666',
|
|
fontSize: '14px',
|
|
fontStyle: 'italic',
|
|
backgroundColor: 'var(--semi-color-fill-0)',
|
|
borderRadius: '8px',
|
|
},
|
|
performanceWarning: {
|
|
padding: '8px 12px',
|
|
backgroundColor: 'rgba(255, 193, 7, 0.1)',
|
|
border: '1px solid rgba(255, 193, 7, 0.3)',
|
|
borderRadius: '6px',
|
|
color: '#ffc107',
|
|
fontSize: '12px',
|
|
marginBottom: '8px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '8px',
|
|
},
|
|
};
|
|
|
|
const escapeHtml = (str) => {
|
|
return str
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
};
|
|
|
|
const highlightJson = (str) => {
|
|
const tokenRegex =
|
|
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g;
|
|
|
|
let result = '';
|
|
let lastIndex = 0;
|
|
let match;
|
|
|
|
while ((match = tokenRegex.exec(str)) !== null) {
|
|
// Escape non-token text (structural chars like {, }, [, ], :, comma, whitespace)
|
|
result += escapeHtml(str.slice(lastIndex, match.index));
|
|
|
|
const token = match[0];
|
|
let color = '#b5cea8';
|
|
if (/^"/.test(token)) {
|
|
color = /:$/.test(token) ? '#9cdcfe' : '#ce9178';
|
|
} else if (/true|false|null/.test(token)) {
|
|
color = '#569cd6';
|
|
}
|
|
// Escape token content before wrapping in span
|
|
result += `<span style="color: ${color}">${escapeHtml(token)}</span>`;
|
|
lastIndex = tokenRegex.lastIndex;
|
|
}
|
|
|
|
// Escape remaining text
|
|
result += escapeHtml(str.slice(lastIndex));
|
|
return result;
|
|
};
|
|
|
|
const linkRegex = /(https?:\/\/(?:[^\s<"'\]),;&}]|&)+)/g;
|
|
|
|
const linkifyHtml = (html) => {
|
|
const parts = html.split(/(<[^>]+>)/g);
|
|
return parts
|
|
.map((part) => {
|
|
if (part.startsWith('<')) return part;
|
|
return part.replace(
|
|
linkRegex,
|
|
(url) => `<a href="${url}" target="_blank" rel="noreferrer">${url}</a>`,
|
|
);
|
|
})
|
|
.join('');
|
|
};
|
|
|
|
const isJsonLike = (content, language) => {
|
|
if (language === 'json') return true;
|
|
const trimmed = content.trim();
|
|
return (
|
|
(trimmed.startsWith('{') && trimmed.endsWith('}')) ||
|
|
(trimmed.startsWith('[') && trimmed.endsWith(']'))
|
|
);
|
|
};
|
|
|
|
const formatContent = (content) => {
|
|
if (!content) return '';
|
|
|
|
if (typeof content === 'object') {
|
|
try {
|
|
return JSON.stringify(content, null, 2);
|
|
} catch (e) {
|
|
return String(content);
|
|
}
|
|
}
|
|
|
|
if (typeof content === 'string') {
|
|
try {
|
|
const parsed = JSON.parse(content);
|
|
return JSON.stringify(parsed, null, 2);
|
|
} catch (e) {
|
|
return content;
|
|
}
|
|
}
|
|
|
|
return String(content);
|
|
};
|
|
|
|
const CodeViewer = ({ content, title, language = 'json' }) => {
|
|
const { t } = useTranslation();
|
|
const [copied, setCopied] = useState(false);
|
|
const [isHoveringCopy, setIsHoveringCopy] = useState(false);
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
|
|
const formattedContent = useMemo(() => formatContent(content), [content]);
|
|
|
|
const contentMetrics = useMemo(() => {
|
|
const length = formattedContent.length;
|
|
const isLarge = length > PERFORMANCE_CONFIG.MAX_DISPLAY_LENGTH;
|
|
const isVeryLarge =
|
|
length >
|
|
PERFORMANCE_CONFIG.MAX_DISPLAY_LENGTH *
|
|
PERFORMANCE_CONFIG.VERY_LARGE_MULTIPLIER;
|
|
return { length, isLarge, isVeryLarge };
|
|
}, [formattedContent.length]);
|
|
|
|
const displayContent = useMemo(() => {
|
|
if (!contentMetrics.isLarge || isExpanded) {
|
|
return formattedContent;
|
|
}
|
|
return (
|
|
formattedContent.substring(0, PERFORMANCE_CONFIG.PREVIEW_LENGTH) +
|
|
'\n\n// ... 内容被截断以提升性能 ...'
|
|
);
|
|
}, [formattedContent, contentMetrics.isLarge, isExpanded]);
|
|
|
|
const highlightedContent = useMemo(() => {
|
|
if (contentMetrics.isVeryLarge && !isExpanded) {
|
|
return escapeHtml(displayContent);
|
|
}
|
|
|
|
if (isJsonLike(displayContent, language)) {
|
|
return highlightJson(displayContent);
|
|
}
|
|
|
|
return escapeHtml(displayContent);
|
|
}, [displayContent, language, contentMetrics.isVeryLarge, isExpanded]);
|
|
|
|
const renderedContent = useMemo(() => {
|
|
return linkifyHtml(highlightedContent);
|
|
}, [highlightedContent]);
|
|
|
|
const handleCopy = useCallback(async () => {
|
|
try {
|
|
const textToCopy =
|
|
typeof content === 'object' && content !== null
|
|
? JSON.stringify(content, null, 2)
|
|
: content;
|
|
|
|
const success = await copy(textToCopy);
|
|
setCopied(true);
|
|
Toast.success(t('已复制到剪贴板'));
|
|
setTimeout(() => setCopied(false), 2000);
|
|
|
|
if (!success) {
|
|
throw new Error('Copy operation failed');
|
|
}
|
|
} catch (err) {
|
|
Toast.error(t('复制失败'));
|
|
console.error('Copy failed:', err);
|
|
}
|
|
}, [content, t]);
|
|
|
|
const handleToggleExpand = useCallback(() => {
|
|
if (contentMetrics.isVeryLarge && !isExpanded) {
|
|
setIsProcessing(true);
|
|
setTimeout(() => {
|
|
setIsExpanded(true);
|
|
setIsProcessing(false);
|
|
}, 100);
|
|
} else {
|
|
setIsExpanded(!isExpanded);
|
|
}
|
|
}, [isExpanded, contentMetrics.isVeryLarge]);
|
|
|
|
if (!content) {
|
|
const placeholderText =
|
|
{
|
|
preview: t('正在构造请求体预览...'),
|
|
request: t('暂无请求数据'),
|
|
response: t('暂无响应数据'),
|
|
}[title] || t('暂无数据');
|
|
|
|
return (
|
|
<div style={codeThemeStyles.noContent}>
|
|
<span>{placeholderText}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const warningTop = contentMetrics.isLarge ? '52px' : '12px';
|
|
const contentPadding = contentMetrics.isLarge ? '52px' : '16px';
|
|
|
|
return (
|
|
<div style={codeThemeStyles.container} className='h-full'>
|
|
{/* 性能警告 */}
|
|
{contentMetrics.isLarge && (
|
|
<div style={codeThemeStyles.performanceWarning}>
|
|
<span>⚡</span>
|
|
<span>
|
|
{contentMetrics.isVeryLarge
|
|
? t('内容较大,已启用性能优化模式')
|
|
: t('内容较大,部分功能可能受限')}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* 复制按钮 */}
|
|
<div
|
|
style={{
|
|
...codeThemeStyles.actionButton,
|
|
...(isHoveringCopy ? codeThemeStyles.actionButtonHover : {}),
|
|
top: warningTop,
|
|
right: '12px',
|
|
}}
|
|
onMouseEnter={() => setIsHoveringCopy(true)}
|
|
onMouseLeave={() => setIsHoveringCopy(false)}
|
|
>
|
|
<Tooltip content={copied ? t('已复制') : t('复制代码')}>
|
|
<Button
|
|
icon={<Copy size={14} />}
|
|
onClick={handleCopy}
|
|
size='small'
|
|
theme='borderless'
|
|
style={{
|
|
backgroundColor: 'transparent',
|
|
border: 'none',
|
|
color: copied ? '#4ade80' : '#d4d4d4',
|
|
padding: '6px',
|
|
}}
|
|
/>
|
|
</Tooltip>
|
|
</div>
|
|
|
|
{/* 代码内容 */}
|
|
<div
|
|
style={{
|
|
...codeThemeStyles.content,
|
|
paddingTop: contentPadding,
|
|
whiteSpace: 'pre-wrap',
|
|
wordBreak: 'break-word',
|
|
}}
|
|
className='model-settings-scroll'
|
|
>
|
|
{isProcessing ? (
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
height: '200px',
|
|
color: '#888',
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: '20px',
|
|
height: '20px',
|
|
border: '2px solid #444',
|
|
borderTop: '2px solid #888',
|
|
borderRadius: '50%',
|
|
animation: 'spin 1s linear infinite',
|
|
marginRight: '8px',
|
|
}}
|
|
/>
|
|
{t('正在处理大内容...')}
|
|
</div>
|
|
) : (
|
|
<div dangerouslySetInnerHTML={{ __html: renderedContent }} />
|
|
)}
|
|
</div>
|
|
|
|
{/* 展开/收起按钮 */}
|
|
{contentMetrics.isLarge && !isProcessing && (
|
|
<div
|
|
style={{
|
|
...codeThemeStyles.actionButton,
|
|
bottom: '12px',
|
|
left: '50%',
|
|
transform: 'translateX(-50%)',
|
|
}}
|
|
>
|
|
<Tooltip content={isExpanded ? t('收起内容') : t('显示完整内容')}>
|
|
<Button
|
|
icon={
|
|
isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />
|
|
}
|
|
onClick={handleToggleExpand}
|
|
size='small'
|
|
theme='borderless'
|
|
style={{
|
|
backgroundColor: 'transparent',
|
|
border: 'none',
|
|
color: '#d4d4d4',
|
|
padding: '6px 12px',
|
|
}}
|
|
>
|
|
{isExpanded ? t('收起') : t('展开')}
|
|
{!isExpanded && (
|
|
<span
|
|
style={{ fontSize: '11px', opacity: 0.7, marginLeft: '4px' }}
|
|
>
|
|
(+
|
|
{Math.round(
|
|
(contentMetrics.length -
|
|
PERFORMANCE_CONFIG.PREVIEW_LENGTH) /
|
|
1000,
|
|
)}
|
|
K)
|
|
</span>
|
|
)}
|
|
</Button>
|
|
</Tooltip>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default CodeViewer;
|