ソースを参照

feat(编辑器): 添加文件下载和安全文件名处理功能,优化图片源验证

Zhang Yice 1 ヶ月 前
コミット
62e315b899

+ 2 - 2
.env

@@ -14,6 +14,6 @@ VITE_DEBUG=false
14 14
 
15 15
 # External AI services are called directly by the browser.
16 16
 VITE_AI_API_URL=http://114.242.25.27:3000/api/v1/chat/completions
17
-VITE_AI_API_KEY=
17
+VITE_AI_API_KEY=XAgent-eVVoqEO7WJYwzCc5wQ8meEtIuyIQgHhxYvpd6fSwa7BwJW8CaBom4
18 18
 VITE_WORKFLOW_API_URL=http://114.242.25.27:3000/api/v2/chat/completions
19
-VITE_WORKFLOW_API_KEY=
19
+VITE_WORKFLOW_API_KEY=XAgent-mWHBqQw06psUYRqx6PrHWiKdfY05ebt7I9drDBHzaG9QQesIkVEICj

+ 6 - 23
src/components/ChatPanel/MessageItem.tsx

@@ -17,6 +17,7 @@ import { formatDate } from '../../utils/formatDate';
17 17
 import type { ChatMessage } from '../../types/chat';
18 18
 import { useDocumentStore } from '../../stores/documentStore';
19 19
 import { useChatStore } from '../../stores/chatStore';
20
+import { downloadBlob, getFileNameFromContentDisposition } from '../../utils/download';
20 21
 
21 22
 const { Text } = Typography;
22 23
 
@@ -246,32 +247,14 @@ const MessageItem: React.FC<MessageItemProps> = memo(
246 247
           );
247 248
           
248 249
           // Get filename from export record or Content-Disposition header
249
-          let fileName = exportRecord.fileName || 'document.doc';
250
-          const contentDisposition = response.headers['content-disposition'];
251
-          if (contentDisposition) {
252
-            const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
253
-            if (match && match[1]) {
254
-              fileName = match[1].replace(/['"]/g, '');
255
-            }
256
-          }
250
+          const fileName = getFileNameFromContentDisposition(
251
+            response.headers['content-disposition'],
252
+            exportRecord.fileName || 'document.doc'
253
+          );
257 254
           
258 255
           // Create blob and download
259 256
           const blob = new Blob([response.data], { type: 'application/msword' });
260
-          const blobUrl = URL.createObjectURL(blob);
261
-          
262
-          const link = document.createElement('a');
263
-          link.href = blobUrl;
264
-          link.download = fileName;
265
-          link.style.display = 'none';
266
-          
267
-          document.body.appendChild(link);
268
-          link.click();
269
-          
270
-          // Clean up
271
-          setTimeout(() => {
272
-            document.body.removeChild(link);
273
-            URL.revokeObjectURL(blobUrl);
274
-          }, 100);
257
+          downloadBlob(blob, fileName);
275 258
         } catch (error) {
276 259
           antdMessage.error(
277 260
             `下载文档失败: ${error instanceof Error ? error.message : '未知错误'}`

+ 6 - 2
src/components/Editor/blocks/ImageBlock.tsx

@@ -16,7 +16,11 @@ import type { ImageBlock as ImageBlockType } from '../../../types/editor';
16 16
 import { useEditorStore } from '../../../stores/editorStore';
17 17
 import { BlockMenu } from './BlockMenu';
18 18
 import { ToolbarLauncher } from '../RichTextEditor/RichTextToolbar';
19
-import { validateImageDimensions, validateImageUpload } from '../../../utils/imageUpload';
19
+import {
20
+  isSafeImageSource,
21
+  validateImageDimensions,
22
+  validateImageUpload,
23
+} from '../../../utils/imageUpload';
20 24
 import './ImageBlock.css';
21 25
 
22 26
 export interface ImageBlockProps {
@@ -502,7 +506,7 @@ export const ImageBlock: React.FC<ImageBlockProps> = ({
502 506
 
503 507
       {/* 图片容器 */}
504 508
       <div className={`image-block align-${block.style.align}`}>
505
-        {block.content ? (
509
+        {block.content && isSafeImageSource(block.content) ? (
506 510
           <div
507 511
             className="image-wrapper"
508 512
             style={{

+ 4 - 2
src/services/aiChatService.ts

@@ -11,6 +11,8 @@
11 11
  * @module services/aiChatService
12 12
  */
13 13
 
14
+import { fetchWithTimeout } from '../utils/fetchWithTimeout';
15
+
14 16
 /**
15 17
  * Message content types
16 18
  */
@@ -99,7 +101,7 @@ export const sendChatCompletion = async (
99 101
       return mockChatCompletion(request);
100 102
     }
101 103
 
102
-    const response = await fetch(config.apiUrl, {
104
+    const response = await fetchWithTimeout(config.apiUrl, {
103 105
       method: 'POST',
104 106
       headers: {
105 107
         'Content-Type': 'application/json',
@@ -161,7 +163,7 @@ export const sendStreamingChatCompletion = async (
161 163
       return;
162 164
     }
163 165
 
164
-    const response = await fetch(config.apiUrl, {
166
+    const response = await fetchWithTimeout(config.apiUrl, {
165 167
       method: 'POST',
166 168
       headers: {
167 169
         'Content-Type': 'application/json',

+ 1 - 13
src/services/clientExportService.ts

@@ -1,4 +1,5 @@
1 1
 import type { DocumentBlock, RichText, TableBlock } from '../types/editor';
2
+import { downloadBlob, safeFileName } from '../utils/download';
2 3
 
3 4
 function toPlainText(content: string | RichText[]): string {
4 5
   return typeof content === 'string'
@@ -47,19 +48,6 @@ function blockToMarkdown(block: DocumentBlock): string {
47 48
   }
48 49
 }
49 50
 
50
-function downloadBlob(blob: Blob, fileName: string): void {
51
-  const url = URL.createObjectURL(blob);
52
-  const anchor = document.createElement('a');
53
-  anchor.href = url;
54
-  anchor.download = fileName;
55
-  anchor.click();
56
-  URL.revokeObjectURL(url);
57
-}
58
-
59
-function safeFileName(title: string): string {
60
-  return (title || '未命名文档').replace(/[\\/:*?"<>|]/g, '_');
61
-}
62
-
63 51
 export function exportBlocksToMarkdown(blocks: DocumentBlock[], title: string): void {
64 52
   downloadBlob(
65 53
     new Blob([blocksToMarkdown(blocks)], { type: 'text/markdown;charset=utf-8' }),

+ 16 - 21
src/services/documentContentService.ts

@@ -7,6 +7,9 @@
7 7
  * @module services/documentContentService
8 8
  */
9 9
 
10
+import apiClient from './api';
11
+import { getFileNameFromContentDisposition } from '../utils/download';
12
+
10 13
 /**
11 14
  * Text run with formatting information
12 15
  */
@@ -102,29 +105,21 @@ export const fetchDocumentStructure = async (
102 105
   userId: string
103 106
 ): Promise<DocumentStructure> => {
104 107
   try {
105
-    // Construct the download URL using the API base URL
106
-    const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://192.168.0.195:8000';
107
-    const downloadUrl = `${baseUrl}/api/v1/export/records/${recordId}/download?userId=${encodeURIComponent(userId)}`;
108
-    
109
-    // Download the Word file as a blob
110
-    const response = await fetch(downloadUrl);
111
-    
112
-    if (!response.ok) {
113
-      throw new Error(`HTTP error! status: ${response.status}`);
114
-    }
115
-    
116
-    const blob = await response.blob();
117
-    const arrayBuffer = await blob.arrayBuffer();
108
+    const response = await apiClient.get<ArrayBuffer>(
109
+      `/api/v1/export/records/${encodeURIComponent(recordId)}/download`,
110
+      {
111
+        params: { userId },
112
+        responseType: 'arraybuffer',
113
+      }
114
+    );
115
+
116
+    const arrayBuffer = response.data;
118 117
     
119 118
     // Extract filename from Content-Disposition header if available
120
-    let fileName = '文档预览.docx';
121
-    const contentDisposition = response.headers.get('Content-Disposition');
122
-    if (contentDisposition) {
123
-      const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
124
-      if (match && match[1]) {
125
-        fileName = match[1].replace(/['"]/g, '');
126
-      }
127
-    }
119
+    const fileName = getFileNameFromContentDisposition(
120
+      response.headers['content-disposition'],
121
+      '文档预览.docx'
122
+    );
128 123
     
129 124
     // Parse the Word document using mammoth
130 125
     // We'll use mammoth's convertToHtml which preserves structure better

+ 6 - 23
src/services/exportRecordService.ts

@@ -19,6 +19,7 @@ import type {
19 19
   ExportRecordListFilters,
20 20
   StorageInfo,
21 21
 } from '../types/export';
22
+import { downloadBlob, getFileNameFromContentDisposition } from '../utils/download';
22 23
 
23 24
 /**
24 25
  * List export records with pagination
@@ -101,32 +102,14 @@ export const downloadExportRecord = async (
101 102
     );
102 103
     
103 104
     // Get filename from Content-Disposition header
104
-    let fileName = `export-${recordId}.doc`;
105
-    const contentDisposition = response.headers['content-disposition'];
106
-    if (contentDisposition) {
107
-      const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
108
-      if (match && match[1]) {
109
-        fileName = match[1].replace(/['"]/g, '');
110
-      }
111
-    }
105
+    const fileName = getFileNameFromContentDisposition(
106
+      response.headers['content-disposition'],
107
+      `export-${recordId}.doc`
108
+    );
112 109
     
113 110
     // Create blob and download
114 111
     const blob = new Blob([response.data], { type: 'application/msword' });
115
-    const blobUrl = URL.createObjectURL(blob);
116
-    
117
-    const link = document.createElement('a');
118
-    link.href = blobUrl;
119
-    link.download = fileName;
120
-    link.style.display = 'none';
121
-    
122
-    document.body.appendChild(link);
123
-    link.click();
124
-    
125
-    // Clean up
126
-    setTimeout(() => {
127
-      document.body.removeChild(link);
128
-      URL.revokeObjectURL(blobUrl);
129
-    }, 100);
112
+    downloadBlob(blob, fileName);
130 113
   } catch (error) {
131 114
     const message = getErrorMessage(error);
132 115
     throw new Error(`下载文件失败: ${message}`, { cause: error });

+ 6 - 10
src/services/sessionService.ts

@@ -4,9 +4,7 @@
4 4
  * 提供会话历史的增删改查功能
5 5
  */
6 6
 
7
-import axios from 'axios';
8
-
9
-const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
7
+import apiClient from './api';
10 8
 
11 9
 // ══════════════════════════════════════════════════════════════════════════════
12 10
 // 类型定义
@@ -58,9 +56,7 @@ export interface ListDocumentsResponse {
58 56
 export async function deleteSessionHistory(
59 57
   sessionId: string
60 58
 ): Promise<DeleteSessionResponse> {
61
-  const response = await axios.delete<DeleteSessionResponse>(
62
-    `${API_BASE_URL}/api/v1/documents/${sessionId}`
63
-  );
59
+  const response = await apiClient.delete<DeleteSessionResponse>(`/api/v1/documents/${sessionId}`);
64 60
   return response.data;
65 61
 }
66 62
 
@@ -79,8 +75,8 @@ export async function getSessionDocuments(
79 75
   page: number = 1,
80 76
   pageSize: number = 20
81 77
 ): Promise<ListDocumentsResponse> {
82
-  const response = await axios.get<ListDocumentsResponse>(
83
-    `${API_BASE_URL}/api/v1/documents`,
78
+  const response = await apiClient.get<ListDocumentsResponse>(
79
+    '/api/v1/documents',
84 80
     {
85 81
       params: {
86 82
         userId,
@@ -107,8 +103,8 @@ export async function getSessionList(userId: string): Promise<{
107 103
   }>;
108 104
 }> {
109 105
   // 获取所有文档
110
-  const response = await axios.get<ListDocumentsResponse>(
111
-      `${API_BASE_URL}/api/v1/documents`,
106
+    const response = await apiClient.get<ListDocumentsResponse>(
107
+      '/api/v1/documents',
112 108
       {
113 109
         params: {
114 110
           userId,

+ 2 - 1
src/services/workflowService.ts

@@ -11,6 +11,7 @@
11 11
  */
12 12
 
13 13
 import type { ExportRecordInfo } from '../types/chat';
14
+import { fetchWithTimeout } from '../utils/fetchWithTimeout';
14 15
 
15 16
 /**
16 17
  * Workflow chat message
@@ -165,7 +166,7 @@ export const triggerDocumentWorkflow = async (
165 166
     // 1. chatId: 带时间戳的唯一ID,确保每次请求都被视为新请求
166 167
     // 2. sessionId: 原始会话ID,传递给工作流,工作流会将其传递给后端 /api/v1/export/records
167 168
     // 3. 后端在创建文档时会使用这个sessionId,确保同一会话的多个文档共用相同的session_id
168
-    const response = await fetch(config.apiUrl, {
169
+    const response = await fetchWithTimeout(config.apiUrl, {
169 170
       method: 'POST',
170 171
       headers: {
171 172
         'Content-Type': 'application/json',

+ 41 - 3
src/stores/chatStore.ts

@@ -32,18 +32,52 @@ import { shouldTriggerWorkflow, triggerDocumentWorkflow } from '../services/work
32 32
 
33 33
 const STORAGE_KEY = 'ax-chat-sessions';
34 34
 const MAX_SESSIONS = 50; // Maximum number of sessions to keep
35
+const MAX_MESSAGES_PER_SESSION = 200;
35 36
 
36 37
 // ── Utility Functions ──────────────────────────────────────────────────────
37 38
 
38 39
 /**
39 40
  * Load sessions from localStorage
40 41
  */
42
+const isChatMessage = (value: unknown): value is ChatMessage => {
43
+  if (!value || typeof value !== 'object') return false;
44
+  const message = value as Partial<ChatMessage>;
45
+  return (
46
+    typeof message.id === 'string' &&
47
+    (message.role === 'user' || message.role === 'assistant') &&
48
+    typeof message.content === 'string' &&
49
+    typeof message.timestamp === 'number'
50
+  );
51
+};
52
+
53
+const isChatSession = (value: unknown): value is ChatSession => {
54
+  if (!value || typeof value !== 'object') return false;
55
+  const session = value as Partial<ChatSession>;
56
+  return (
57
+    typeof session.id === 'string' &&
58
+    typeof session.title === 'string' &&
59
+    Array.isArray(session.messages) &&
60
+    session.messages.every(isChatMessage) &&
61
+    typeof session.createdAt === 'number' &&
62
+    typeof session.updatedAt === 'number' &&
63
+    Array.isArray(session.exportRecords)
64
+  );
65
+};
66
+
41 67
 const loadSessionsFromStorage = (): ChatSession[] => {
42 68
   try {
43 69
     const stored = localStorage.getItem(STORAGE_KEY);
44 70
     if (!stored) return [];
45 71
     
46
-    const sessions = JSON.parse(stored) as ChatSession[];
72
+    const parsed: unknown = JSON.parse(stored);
73
+    if (!Array.isArray(parsed)) return [];
74
+
75
+    const sessions = parsed
76
+      .filter(isChatSession)
77
+      .map((session) => ({
78
+        ...session,
79
+        messages: session.messages.slice(-MAX_MESSAGES_PER_SESSION),
80
+      }));
47 81
     // Sort by updatedAt descending (most recent first)
48 82
     return sessions.sort((a, b) => b.updatedAt - a.updatedAt);
49 83
   } catch (error) {
@@ -58,9 +92,13 @@ const loadSessionsFromStorage = (): ChatSession[] => {
58 92
 const saveSessionsToStorage = (sessions: ChatSession[]): void => {
59 93
   try {
60 94
     // Keep only the most recent MAX_SESSIONS
61
-    const sessionsToSave = sessions
95
+    const sessionsToSave = [...sessions]
62 96
       .sort((a, b) => b.updatedAt - a.updatedAt)
63
-      .slice(0, MAX_SESSIONS);
97
+      .slice(0, MAX_SESSIONS)
98
+      .map((session) => ({
99
+        ...session,
100
+        messages: session.messages.slice(-MAX_MESSAGES_PER_SESSION),
101
+      }));
64 102
     
65 103
     localStorage.setItem(STORAGE_KEY, JSON.stringify(sessionsToSave));
66 104
   } catch (error) {

+ 45 - 0
src/utils/download.ts

@@ -0,0 +1,45 @@
1
+const UNSAFE_FILE_NAME_CHARS = /[\\/:*?"<>|]/g;
2
+
3
+export function safeFileName(fileName: string, fallback = 'download'): string {
4
+  const normalized = Array.from(fileName, (character) =>
5
+    character.charCodeAt(0) < 32 ? '_' : character
6
+  )
7
+    .join('')
8
+    .replace(UNSAFE_FILE_NAME_CHARS, '_')
9
+    .replace(/[. ]+$/g, '')
10
+    .trim();
11
+
12
+  return normalized || fallback;
13
+}
14
+
15
+export function getFileNameFromContentDisposition(
16
+  header: string | null | undefined,
17
+  fallback: string
18
+): string {
19
+  if (!header) return fallback;
20
+
21
+  const utf8Match = header.match(/filename\*=UTF-8''([^;]+)/i);
22
+  if (utf8Match?.[1]) {
23
+    try {
24
+      return safeFileName(decodeURIComponent(utf8Match[1]), fallback);
25
+    } catch {
26
+      return fallback;
27
+    }
28
+  }
29
+
30
+  const match = header.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/i);
31
+  const value = match?.[1]?.replace(/^['"]|['"]$/g, '').trim();
32
+  return value ? safeFileName(value, fallback) : fallback;
33
+}
34
+
35
+export function downloadBlob(blob: Blob, fileName: string): void {
36
+  const url = URL.createObjectURL(blob);
37
+  const anchor = document.createElement('a');
38
+  anchor.href = url;
39
+  anchor.download = safeFileName(fileName);
40
+  anchor.style.display = 'none';
41
+  document.body.appendChild(anchor);
42
+  anchor.click();
43
+  anchor.remove();
44
+  URL.revokeObjectURL(url);
45
+}

+ 14 - 0
src/utils/fetchWithTimeout.ts

@@ -0,0 +1,14 @@
1
+const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
2
+
3
+export function fetchWithTimeout(
4
+  input: RequestInfo | URL,
5
+  init: RequestInit = {},
6
+  timeoutMs = DEFAULT_FETCH_TIMEOUT_MS
7
+): Promise<Response> {
8
+  const timeoutSignal = AbortSignal.timeout(timeoutMs);
9
+  const signal = init.signal
10
+    ? AbortSignal.any([init.signal, timeoutSignal])
11
+    : timeoutSignal;
12
+
13
+  return fetch(input, { ...init, signal });
14
+}

+ 10 - 0
src/utils/imageUpload.ts

@@ -10,6 +10,16 @@ const ALLOWED_IMAGE_TYPES = new Set([
10 10
   'image/bmp',
11 11
 ]);
12 12
 
13
+const ALLOWED_DATA_IMAGE_PREFIX = /^data:image\/(?:jpeg|png|gif|webp|bmp);base64,/i;
14
+
15
+export function isSafeImageSource(source: string): boolean {
16
+  const value = source.trim();
17
+  if (!value) return false;
18
+  if (ALLOWED_DATA_IMAGE_PREFIX.test(value)) return true;
19
+  if (/^https?:\/\//i.test(value)) return true;
20
+  return value.startsWith('/') || value.startsWith('./');
21
+}
22
+
13 23
 export function validateImageUpload(file: Pick<File, 'size' | 'type'>): string | null {
14 24
   if (!Number.isFinite(file.size) || file.size <= 0 || file.size > MAX_IMAGE_SIZE_BYTES) {
15 25
     return '图片大小必须大于0且不能超过10MB';

+ 2 - 0
src/utils/index.ts

@@ -4,7 +4,9 @@
4 4
  */
5 5
 
6 6
 export * from './debounce';
7
+export * from './download';
7 8
 export * from './formatDate';
9
+export * from './fetchWithTimeout';
8 10
 export * from './notification';
9 11
 export * from './storage';
10 12
 export * from './validation';