merge: fix/audit-b4-frontend (audit batch 4: markdown XSS, unread rollback, excel preview cap)
This commit is contained in:
@@ -4126,6 +4126,7 @@ export default function App() {
|
||||
)}
|
||||
<div style={{ position: 'relative', flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<MessageStream
|
||||
sessionId={activeSessionId}
|
||||
dayStack={chatWindow.displayDayStack}
|
||||
isLoadingOlder={chatWindow.phase === 'loading_older'}
|
||||
hasMoreBefore={chatWindow.hasMoreBefore}
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface MessageStreamProps {
|
||||
onQuote?: (message: ChatMessage) => void;
|
||||
onNavigateToAnchor?: (messageId: number) => void;
|
||||
selectedCategory?: MessageCategory | 'all';
|
||||
/** 会话切换时重置滚动基线,避免旧会话消息数挡住新会话自动滚底 */
|
||||
sessionId?: string;
|
||||
/** P3 / Step 5: 未读消息 ID 集合 */
|
||||
unreadMessageIds?: number[];
|
||||
onSaveToPrivate?: (message: ChatMessage) => void;
|
||||
@@ -43,6 +45,7 @@ export const MessageStream: React.FC<MessageStreamProps> = ({
|
||||
onQuote,
|
||||
onNavigateToAnchor,
|
||||
selectedCategory = 'all',
|
||||
sessionId,
|
||||
unreadMessageIds = [],
|
||||
onSaveToPrivate,
|
||||
onExportDocx,
|
||||
@@ -154,6 +157,11 @@ export const MessageStream: React.FC<MessageStreamProps> = ({
|
||||
}
|
||||
}, [totalMessagesCount, handle]);
|
||||
|
||||
useEffect(() => {
|
||||
prevMsgCountRef.current = 0;
|
||||
isInitialMountRef.current = true;
|
||||
}, [sessionId]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
|
||||
@@ -12,8 +12,9 @@ export const ExcelViewer: React.FC<ExcelViewerProps> = ({ fileRaw, fileUrl }) =>
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sheetNames, setSheetNames] = useState<string[]>([]);
|
||||
const [activeSheetIndex, setActiveSheetIndex] = useState(0);
|
||||
const [sheetData, setSheetData] = useState<Record<string, (string | number)[][]>>({});
|
||||
const [activeSheetIndex, setActiveSheetIndex] = useState(0);
|
||||
const [truncatedSheets, setTruncatedSheets] = useState<Record<string, boolean>>({});
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
@@ -40,6 +41,8 @@ export const ExcelViewer: React.FC<ExcelViewerProps> = ({ fileRaw, fileUrl }) =>
|
||||
}
|
||||
|
||||
const parsedSheets: Record<string, (string | number)[][]> = {};
|
||||
const truncated: Record<string, boolean> = {};
|
||||
const previewRowLimit = 1000;
|
||||
for (const name of workbook.SheetNames) {
|
||||
const ws = workbook.Sheets[name];
|
||||
if (ws) {
|
||||
@@ -48,7 +51,8 @@ export const ExcelViewer: React.FC<ExcelViewerProps> = ({ fileRaw, fileUrl }) =>
|
||||
defval: '',
|
||||
blankrows: false,
|
||||
});
|
||||
parsedSheets[name] = rawRows;
|
||||
truncated[name] = rawRows.length > previewRowLimit;
|
||||
parsedSheets[name] = rawRows.slice(0, previewRowLimit);
|
||||
} else {
|
||||
parsedSheets[name] = [];
|
||||
}
|
||||
@@ -57,6 +61,7 @@ export const ExcelViewer: React.FC<ExcelViewerProps> = ({ fileRaw, fileUrl }) =>
|
||||
if (isMounted) {
|
||||
setSheetNames(workbook.SheetNames);
|
||||
setSheetData(parsedSheets);
|
||||
setTruncatedSheets(truncated);
|
||||
setActiveSheetIndex(0);
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -173,6 +178,9 @@ export const ExcelViewer: React.FC<ExcelViewerProps> = ({ fileRaw, fileUrl }) =>
|
||||
<span>工作表:<strong>{currentSheetName}</strong></span>
|
||||
<span>·</span>
|
||||
<span>{rows.length} 行 × {maxCols} 列</span>
|
||||
{truncatedSheets[currentSheetName] && (
|
||||
<span style={{ color: '#b45309' }}>· 仅预览前 1000 行</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: '#94a3b8' }}>
|
||||
共 {sheetNames.length} 个工作表
|
||||
|
||||
@@ -17,6 +17,10 @@ test('renderInlineMarkdown: escapes raw HTML tags to prevent XSS', () => {
|
||||
assert.ok(!scriptHtml.includes('<script'));
|
||||
assert.ok(scriptHtml.includes('<script>'));
|
||||
|
||||
const forged = renderInlineMarkdown('XMATHTOKENX0XENDX<script>alert(1)</script>');
|
||||
assert.ok(!forged.includes('<script'));
|
||||
assert.ok(forged.includes('XMATHTOKENX0XENDX'));
|
||||
|
||||
const imgHtml = renderInlineMarkdown('<img src=x onerror=alert(1)>');
|
||||
assert.ok(!/<img[\s>]/i.test(imgHtml), 'must not emit executable img tag');
|
||||
assert.ok(imgHtml.includes('<img'));
|
||||
|
||||
@@ -28,7 +28,9 @@ export function renderInlineMarkdown(content: string, options: RenderMarkdownOpt
|
||||
// 1. 占位保护预处理:优先解析并提取数学公式,避免公式内部的 * 或 _ 被 Markdown 解析器误伤
|
||||
// 注意:占位符严禁使用包含 _ 或 * 等 Markdown 特殊字符,采用纯大写字母标识符
|
||||
const mathPlaceholders: string[] = [];
|
||||
const placeholderPrefix = 'XMATHTOKENX';
|
||||
// 每次渲染换盐,避免用户文本伪造 XMATHTOKENX0XENDX 在 escapeHtml 之后被还原成 HTML
|
||||
const tokenSalt = Math.random().toString(36).slice(2, 10).toUpperCase();
|
||||
const placeholderPrefix = `XMATHTOKENX${tokenSalt}X`;
|
||||
|
||||
// 1.1 块级独立公式 $$ ... $$
|
||||
text = text.replace(/\$\$([\s\S]*?)\$\$/g, (_, math) => {
|
||||
@@ -42,7 +44,9 @@ export function renderInlineMarkdown(content: string, options: RenderMarkdownOpt
|
||||
return `${placeholderPrefix}${idx}XENDX`;
|
||||
} catch {
|
||||
const idx = mathPlaceholders.length;
|
||||
mathPlaceholders.push(`<span style="color:var(--danger)">$$${math}$$</span>`);
|
||||
mathPlaceholders.push(
|
||||
`<span style="color:var(--danger)">$$${escapeHtml(math)}$$</span>`,
|
||||
);
|
||||
return `${placeholderPrefix}${idx}XENDX`;
|
||||
}
|
||||
});
|
||||
@@ -59,14 +63,14 @@ export function renderInlineMarkdown(content: string, options: RenderMarkdownOpt
|
||||
return `${placeholderPrefix}${idx}XENDX`;
|
||||
} catch {
|
||||
const idx = mathPlaceholders.length;
|
||||
mathPlaceholders.push(`$${math}$`);
|
||||
mathPlaceholders.push(`$${escapeHtml(math)}$`);
|
||||
return `${placeholderPrefix}${idx}XENDX`;
|
||||
}
|
||||
});
|
||||
|
||||
// 2. 行内代码保护 `code`
|
||||
const codePlaceholders: string[] = [];
|
||||
const codePlaceholderPrefix = 'XCODETOKENX';
|
||||
const codePlaceholderPrefix = `XCODETOKENX${tokenSalt}X`;
|
||||
text = text.replace(/`([^`\n]+)`/g, (_, code) => {
|
||||
const rendered = `<code style="font-family:var(--font-mono);font-size:12px;background:var(--bg-subtle);color:var(--brand-500);padding:2px 5px;border-radius:4px;border:1px solid var(--border)">${escapeHtml(code)}</code>`;
|
||||
const idx = codePlaceholders.length;
|
||||
|
||||
@@ -185,6 +185,8 @@ export function useUnreadMarks(
|
||||
// 0. 本地持久化记录已读
|
||||
saveLocalReadId(sessionId, targetId);
|
||||
|
||||
const marksSnapshot = unreadMarks;
|
||||
|
||||
// 1. 乐观更新未读集合
|
||||
const remainingIds = unreadMessageIds.filter((id) => id !== targetId);
|
||||
setUnreadMessageIds(remainingIds);
|
||||
@@ -217,7 +219,7 @@ export function useUnreadMarks(
|
||||
|
||||
// 2. 异步上报后端已读位点
|
||||
try {
|
||||
await fetch('/api/v1/chat/read', {
|
||||
const res = await fetch('/api/v1/chat/read', {
|
||||
method: 'POST',
|
||||
headers: getBearerAuthHeaders('application/json'),
|
||||
body: JSON.stringify({
|
||||
@@ -226,12 +228,20 @@ export function useUnreadMarks(
|
||||
read_message_ids: [targetId],
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`mark read failed: ${res.status}`);
|
||||
}
|
||||
} catch {
|
||||
// 错误时不强行重滚,下次拉取自愈
|
||||
// 上报失败回滚乐观更新,避免前后端未读数长期分叉
|
||||
setUnreadMessageIds(unreadMessageIds);
|
||||
setNextMessageId(nextMessageId);
|
||||
setUnreadTotal(unreadTotal);
|
||||
setUnreadMarks(marksSnapshot);
|
||||
setIsAllReadCompleted(false);
|
||||
}
|
||||
|
||||
return targetId;
|
||||
}, [sessionId, userId, nextMessageId, unreadMessageIds, unreadTotal]);
|
||||
}, [sessionId, userId, nextMessageId, unreadMessageIds, unreadTotal, unreadMarks]);
|
||||
|
||||
// 批量标记已读 (如滚动进入视口阅读或一键已读)
|
||||
const markMessagesRead = useCallback(
|
||||
|
||||
Reference in New Issue
Block a user