fix: 统一文档解析文件格式校验

- 统一知识库和工作流支持格式并增加前后端上传拦截

- 拒绝 XLS 与伪装 XLSX,避免空内容解析成功
This commit is contained in:
2026-09-02 19:15:01 +08:00
parent 36acf37976
commit c3673ece46
24 changed files with 658 additions and 101 deletions

View File

@@ -0,0 +1,40 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it, vi } from 'vitest';
import DragFileUpload from './DragFileUpload.vue';
vi.mock('@easyflow/hooks', () => ({
useAppConfig: () => ({ apiURL: '' }),
}));
vi.mock('@easyflow/locales', () => ({
$t: (key: string) => key,
}));
vi.mock('@easyflow/stores', () => ({
useAccessStore: () => ({ accessToken: 'test-token' }),
}));
vi.mock('#/locales', () => ({
$t: (key: string, params?: Record<string, string>) =>
key === 'message.upload.unsupportedFileType'
? `${params?.fileName}”格式不支持,仅支持 ${params?.types} 文件。`
: key,
}));
describe('drag file upload', () => {
it('uses the shared document formats and rejects XLS drops', async () => {
const wrapper = mount(DragFileUpload);
const upload = wrapper.getComponent({ name: 'ElUpload' });
expect(upload.props('accept')).toBe('.txt,.pdf,.docx,.md,.pptx,.xlsx,.csv');
const beforeUpload = upload.props('beforeUpload') as (
file: File,
) => Promise<boolean>;
await expect(beforeUpload(new File(['legacy'], 'test.xls'))).resolves.toBe(
false,
);
});
});

View File

@@ -10,6 +10,7 @@ import { useAccessStore } from '@easyflow/stores';
import { UploadFilled } from '@element-plus/icons-vue';
import { ElIcon, ElMessage, ElUpload } from 'element-plus';
import { DocumentParseFilePolicy } from '#/utils/document-parse-file-policy';
import {
normalizeUploadError,
resolveUploadPath,
@@ -28,7 +29,6 @@ const props = defineProps({
const emit = defineEmits(['success', 'error', 'onChange']);
const MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024;
const ACCEPTED_FILE_TYPES = '.txt,.pdf,.docx,.md,.pptx,.xlsx';
const accessStore = useAccessStore();
const headers = ref({
'easyflow-token': accessStore.accessToken,
@@ -54,12 +54,18 @@ const handleError: UploadProps['onError'] = (error) => {
emit('error', normalizedError);
};
const beforeUpload: UploadProps['beforeUpload'] = (rawFile) => {
if (rawFile.size <= MAX_FILE_SIZE_BYTES) {
return true;
const beforeUpload: UploadProps['beforeUpload'] = async (rawFile) => {
try {
await DocumentParseFilePolicy.validateFiles([rawFile]);
} catch (error: any) {
ElMessage.warning(error?.message || $t('message.notSupported'));
return false;
}
ElMessage.warning($t('message.upload.fileTooLarge'));
return false;
if (rawFile.size > MAX_FILE_SIZE_BYTES) {
ElMessage.warning($t('message.upload.fileTooLarge'));
return false;
}
return true;
};
// 文件状态变化回调
@@ -97,7 +103,7 @@ defineExpose({
drag
:headers="headers"
:action="`${apiURL}${props.action}`"
:accept="ACCEPTED_FILE_TYPES"
:accept="DocumentParseFilePolicy.accept"
:before-upload="beforeUpload"
:on-success="handleSuccess"
:on-error="handleError"
@@ -111,7 +117,9 @@ defineExpose({
<div class="flex flex-col gap-1">
<span class="text-base">{{ $t('message.upload.title') }}</span>
<span class="text-muted-foreground text-sm">{{
$t('message.upload.description')
$t('message.upload.description', {
types: DocumentParseFilePolicy.supportedTypeLabel,
})
}}</span>
</div>
</ElUpload>

View File

@@ -79,7 +79,7 @@
"progressUpload": "Progress of file upload",
"fileSize": "File size",
"batchUploadTitle": "Select files or drop them here",
"batchUploadDescription": "TXT, PDF, DOCX, MD, PPTX and XLSX. Up to 100MB per file and 1GB per folder.",
"batchUploadDescription": "Supported formats: {types}. Up to 100MB per file and 1GB per folder.",
"batchUploadTip": "When upload completes, choose manual or automatic import.",
"selectFolder": "Select Folder",
"manualImport": "Manual Import",
@@ -95,8 +95,8 @@
"singleFileLimit": "Each file must be no larger than 100MB",
"folderSizeLimit": "The folder must be no larger than 1GB",
"fileCountLimit": "A batch can contain up to 2000 files",
"noSupportedFiles": "No supported documents found",
"unsupportedSkipped": "Unsupported files were skipped",
"noSupportedFiles": "No supported documents found. Supported formats: {types}.",
"unsupportedSkipped": "Unsupported files were skipped. Supported formats: {types}.",
"createBatchFailed": "Failed to create upload batch",
"cancelBatchFailed": "Failed to cancel the upload batch. Please retry.",
"uploadFailed": "Upload failed. Please retry.",

View File

@@ -27,8 +27,13 @@
"copyFail": "Copy fail",
"upload": {
"title": "Click or drag and drop files here to upload",
"description": "TXT, PDF, DOCX, MD, PPTX, and XLSX files are supported, up to 100 MB each.",
"fileTooLarge": "Each file must not exceed 100 MB"
"description": "Supported formats: {types}. Up to 100 MB per file.",
"supportedTypes": "Supported formats: {types}.",
"singleFileLimit": "Up to 100 MB per file.",
"fileTooLarge": "Each file must not exceed 100 MB",
"unsupportedFileType": "“{fileName}” is not supported. Supported formats: {types}.",
"legacyExcelAsXlsx": "“{fileName}” is not a standard XLSX file. It may be a legacy XLS or an encrypted file. Remove protection and save it as XLSX in Excel/WPS; renaming the extension does not work.",
"invalidXlsx": "“{fileName}” is not a standard XLSX file. Its content does not match the extension or the file is damaged. Save it as XLSX in Excel/WPS and try again."
},
"uploadFileFirst": "Please upload the file first",
"deleteModelAlert": "This operation will delete the large model. Are you sure to delete it?",

View File

@@ -79,7 +79,7 @@
"progressUpload": "文件上传进度",
"fileSize": "文件大小",
"batchUploadTitle": "点击选择文件,或将文件拖到这里上传",
"batchUploadDescription": "支持 TXT、PDF、DOCX、MD、PPTX、XLSX;单个文件不超过 100MB文件夹总大小不超过 1GB。",
"batchUploadDescription": "支持 {types} 文件;单个文件不超过 100MB文件夹总大小不超过 1GB。",
"batchUploadTip": "上传完成后,可选择手动导入或自动导入。",
"selectFolder": "选择文件夹",
"manualImport": "手动导入",
@@ -95,8 +95,8 @@
"singleFileLimit": "单个文件不能超过 100MB",
"folderSizeLimit": "文件夹总大小不能超过 1GB",
"fileCountLimit": "单批次文件数不能超过 2000",
"noSupportedFiles": "未找到支持的文档",
"unsupportedSkipped": "已跳过不支持的文件",
"noSupportedFiles": "未找到支持的文档,仅支持 {types} 文件",
"unsupportedSkipped": "已跳过不支持的文件;仅支持 {types} 文件",
"createBatchFailed": "创建上传批次失败",
"cancelBatchFailed": "取消上传批次失败,请重试",
"uploadFailed": "文件上传失败,请重试",

View File

@@ -27,8 +27,13 @@
"copyFail": "复制失败",
"upload": {
"title": "点击或将文件拖拽到这里上传",
"description": "支持 TXT、PDF、DOCX、MD、PPTX、XLSX,单个文件不超过 100 MB。",
"fileTooLarge": "单个文件大小不能超过 100 MB"
"description": "支持 {types} 文件,单个文件不超过 100 MB。",
"supportedTypes": "支持 {types} 文件",
"singleFileLimit": "单个文件不超过 100 MB。",
"fileTooLarge": "单个文件大小不能超过 100 MB",
"unsupportedFileType": "“{fileName}”格式不支持,仅支持 {types} 文件。",
"legacyExcelAsXlsx": "“{fileName}”不是标准 XLSX可能是旧版 XLS 或已加密文件。请解除保护后用 Excel/WPS 另存为 XLSX修改文件后缀无效",
"invalidXlsx": "“{fileName}”不是标准 XLSX文件内容与扩展名不一致或文件已损坏。请用 Excel/WPS 另存为 XLSX 后重试"
},
"uploadFileFirst": "请先上传文件",
"deleteModelAlert": "该操作会删除大模型,确定删除吗?",

View File

@@ -0,0 +1,52 @@
import { describe, expect, it, vi } from 'vitest';
import { DocumentParseFilePolicy } from './document-parse-file-policy';
vi.mock('#/locales', () => ({
$t: (key: string, params?: Record<string, string>) => {
if (key === 'message.upload.unsupportedFileType') {
return `${params?.fileName}”格式不支持,仅支持 ${params?.types} 文件。`;
}
if (key === 'message.upload.legacyExcelAsXlsx') {
return `${params?.fileName}”不是标准 XLSX可能是旧版 XLS 或已加密文件。请解除保护后用 Excel/WPS 另存为 XLSX修改文件后缀无效`;
}
return key;
},
}));
describe('document parse file policy', () => {
it('provides one accept list for knowledge and workflow uploads', () => {
expect(DocumentParseFilePolicy.accept).toBe(
'.txt,.pdf,.docx,.md,.pptx,.xlsx,.csv',
);
expect(DocumentParseFilePolicy.supports('REPORT.XLSX')).toBe(true);
expect(DocumentParseFilePolicy.supports('legacy.xls')).toBe(false);
});
it('accepts a standard XLSX container signature', async () => {
const file = new File([new Uint8Array([80, 75, 3, 4, 0])], '清单.XLSX');
await expect(
DocumentParseFilePolicy.validateFiles([file]),
).resolves.toBeUndefined();
});
it('rejects unsupported extensions with the supported type list', async () => {
await expect(
DocumentParseFilePolicy.validateFiles([new File(['xls'], 'test.xls')]),
).rejects.toThrow(
'“test.xls”格式不支持仅支持 TXT、PDF、DOCX、MD、PPTX、XLSX、CSV 文件。',
);
});
it('rejects legacy XLS content disguised as XLSX', async () => {
const file = new File(
[new Uint8Array([208, 207, 17, 224, 161, 177, 26, 225])],
'整理发布清单.xlsx',
);
await expect(DocumentParseFilePolicy.validateFiles([file])).rejects.toThrow(
'“整理发布清单.xlsx”不是标准 XLSX可能是旧版 XLS 或已加密文件。请解除保护后用 Excel/WPS 另存为 XLSX修改文件后缀无效',
);
});
});

View File

@@ -0,0 +1,83 @@
import { $t } from '#/locales';
const OLE2_SIGNATURE = [208, 207, 17, 224, 161, 177, 26, 225];
const supportedExtensions = Object.freeze([
'txt',
'pdf',
'docx',
'md',
'pptx',
'xlsx',
'csv',
]);
const supportedExtensionSet = new Set(supportedExtensions);
const supportedTypeLabel = supportedExtensions
.map((extension) => extension.toUpperCase())
.join('、');
function extensionOf(fileName: string) {
const normalizedName = String(fileName || '')
.trim()
.toLowerCase();
const dotIndex = normalizedName.lastIndexOf('.');
return dotIndex === -1 ? '' : normalizedName.slice(dotIndex + 1);
}
function supports(fileName: string) {
return supportedExtensionSet.has(extensionOf(fileName));
}
async function validateFiles(files: File[]) {
for (const file of files) {
const extension = extensionOf(file.name);
if (!supportedExtensionSet.has(extension)) {
throw new Error(
$t('message.upload.unsupportedFileType', {
fileName: file.name,
types: supportedTypeLabel,
}),
);
}
if (extension !== 'xlsx') {
continue;
}
const prefix = new Uint8Array(await file.slice(0, 8).arrayBuffer());
if (hasZipSignature(prefix)) {
continue;
}
const messageKey = startsWith(prefix, OLE2_SIGNATURE)
? 'message.upload.legacyExcelAsXlsx'
: 'message.upload.invalidXlsx';
throw new Error($t(messageKey, { fileName: file.name }));
}
}
/**
* 知识库导入与工作流文档解析共用的文件格式策略。
*/
export const DocumentParseFilePolicy = Object.freeze({
accept: supportedExtensions.map((extension) => `.${extension}`).join(','),
supportedExtensions,
supportedTypeLabel,
supports,
validateFiles,
});
function hasZipSignature(prefix: Uint8Array) {
return (
prefix.length >= 4 &&
prefix[0] === 80 &&
prefix[1] === 75 &&
((prefix[2] === 3 && prefix[3] === 4) ||
(prefix[2] === 5 && prefix[3] === 6) ||
(prefix[2] === 7 && prefix[3] === 8))
);
}
function startsWith(prefix: Uint8Array, signature: number[]) {
return (
prefix.length >= signature.length &&
signature.every((value, index) => prefix[index] === value)
);
}

View File

@@ -0,0 +1,52 @@
import { shallowMount } from '@vue/test-utils';
import { describe, expect, it, vi } from 'vitest';
import ImportKnowledgeFileContainer from './ImportKnowledgeFileContainer.vue';
vi.mock('@easyflow/hooks', () => ({
useAppConfig: () => ({ apiURL: '' }),
}));
vi.mock('@easyflow/locales', () => ({
$t: (key: string, params?: Record<string, string>) =>
key === 'documentCollection.importDoc.batchUploadDescription'
? `支持 ${params?.types} 文件`
: key,
}));
vi.mock('@easyflow/stores', () => ({
useAccessStore: () => ({ accessToken: 'test-token' }),
}));
vi.mock('#/api/request', () => ({ api: {} }));
vi.mock('#/locales', () => ({
$t: (key: string) => key,
}));
vi.mock('element-plus/es/components/table-v2/index.mjs', () => ({
ElAutoResizer: { template: '<div><slot :height="300" :width="600" /></div>' },
ElTableV2: { template: '<div />' },
}));
vi.mock('element-plus/es/components/table-v2/style/css.mjs', () => ({}));
describe('import knowledge file container', () => {
it('uses the shared document formats for file and folder selection', () => {
const wrapper = shallowMount(ImportKnowledgeFileContainer, {
props: { batchMode: true },
});
const inputs = wrapper.findAll('input[type="file"]');
expect(inputs).toHaveLength(2);
for (const input of inputs) {
expect(input.attributes('accept')).toBe(
'.txt,.pdf,.docx,.md,.pptx,.xlsx,.csv',
);
}
expect(wrapper.text()).toContain(
'支持 TXT、PDF、DOCX、MD、PPTX、XLSX、CSV 文件',
);
});
});

View File

@@ -22,6 +22,7 @@ import {
import { formatFileSize } from '#/api/common/file';
import { api } from '#/api/request';
import DragFileUpload from '#/components/upload/DragFileUpload.vue';
import { DocumentParseFilePolicy } from '#/utils/document-parse-file-policy';
import { resolveDocumentUploadResponse } from './document-import-upload-response';
@@ -80,16 +81,6 @@ const emit = defineEmits<{
const MAX_FILE_COUNT = 2000;
const MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024;
const MAX_TOTAL_SIZE_BYTES = 1024 * 1024 * 1024;
const SUPPORTED_EXTENSIONS = new Set([
'csv',
'docx',
'md',
'pdf',
'pptx',
'txt',
'xlsx',
]);
const fileData = ref<LegacyFileInfo[]>([]);
const filesPath = ref<any[]>([]);
const dragUploadRef = ref<InstanceType<typeof DragFileUpload>>();
@@ -331,8 +322,7 @@ async function prepareBatch(files: File[]) {
ignoredCount++;
continue;
}
const extension = file.name.split('.').pop()?.toLowerCase() || '';
if (!SUPPORTED_EXTENSIONS.has(extension)) {
if (!DocumentParseFilePolicy.supports(file.name)) {
ignoredCount++;
continue;
}
@@ -344,7 +334,11 @@ async function prepareBatch(files: File[]) {
accepted.push(file);
}
if (accepted.length === 0) {
ElMessage.warning($t('documentCollection.importDoc.noSupportedFiles'));
ElMessage.warning(
$t('documentCollection.importDoc.noSupportedFiles', {
types: DocumentParseFilePolicy.supportedTypeLabel,
}),
);
return;
}
if (accepted.length > MAX_FILE_COUNT) {
@@ -356,7 +350,17 @@ async function prepareBatch(files: File[]) {
return;
}
if (ignoredCount > 0) {
ElMessage.info($t('documentCollection.importDoc.unsupportedSkipped'));
ElMessage.info(
$t('documentCollection.importDoc.unsupportedSkipped', {
types: DocumentParseFilePolicy.supportedTypeLabel,
}),
);
}
try {
await DocumentParseFilePolicy.validateFiles(accepted);
} catch (error: any) {
ElMessage.warning(error?.message || $t('message.notSupported'));
return;
}
batchFiles.value = await Promise.all(
@@ -538,7 +542,7 @@ async function createClientFileKey(relativePath: string) {
class="native-file-input"
type="file"
multiple
accept=".txt,.pdf,.docx,.md,.pptx,.xlsx,.csv"
:accept="DocumentParseFilePolicy.accept"
@change="handleNativeSelection"
/>
<input
@@ -546,6 +550,7 @@ async function createClientFileKey(relativePath: string) {
class="native-file-input"
type="file"
multiple
:accept="DocumentParseFilePolicy.accept"
webkitdirectory
@change="handleNativeSelection"
/>
@@ -564,7 +569,11 @@ async function createClientFileKey(relativePath: string) {
{{ $t('documentCollection.importDoc.batchUploadTitle') }}
</div>
<div class="batch-drop-zone__description">
{{ $t('documentCollection.importDoc.batchUploadDescription') }}
{{
$t('documentCollection.importDoc.batchUploadDescription', {
types: DocumentParseFilePolicy.supportedTypeLabel,
})
}}
</div>
<ElButton
:icon="FolderOpened"

View File

@@ -11,6 +11,7 @@ import { ElButton, ElIcon, ElLink, ElMessage } from 'element-plus';
import { api } from '#/api/request';
import { $t } from '#/locales';
import { DocumentParseFilePolicy } from '#/utils/document-parse-file-policy';
import {
appendWorkflowFileValues,
@@ -19,7 +20,6 @@ import {
normalizeWorkflowFileValues,
validateWorkflowFileSelection,
validateWorkflowFileValues,
WORKFLOW_FILE_LIMITS,
} from './workflowFileValue';
const props = defineProps({
@@ -50,9 +50,6 @@ const fileInputRef = ref<HTMLInputElement | null>(null);
const currentFiles = computed(() =>
normalizeWorkflowFileValues(props.modelValue),
);
const maxSingleFileSizeText = formatWorkflowFileSize(
WORKFLOW_FILE_LIMITS.maxSingleSize,
).replace('.0 ', ' ');
function triggerSelectFile() {
if (props.disabled || uploadLoading.value) {
@@ -69,6 +66,7 @@ async function uploadFiles(files: File[]) {
uploadLoading.value = true;
try {
validateWorkflowFileSelection(currentFiles.value, files);
await DocumentParseFilePolicy.validateFiles(files);
const uploadedFiles = [];
for (const file of files) {
const res = await api.upload(
@@ -134,6 +132,7 @@ function removeFile(filePath: string) {
ref="fileInputRef"
class="workflow-file-input__native"
type="file"
:accept="DocumentParseFilePolicy.accept"
:disabled="disabled"
multiple
@change="handleNativeFileChange"
@@ -167,7 +166,16 @@ function removeFile(filePath: string) {
: '拖入文件或点击上传'
}}
</span>
<small>单个文件不超过 {{ maxSingleFileSizeText }}</small>
<small class="workflow-file-input__hint">
<span>
{{
$t('message.upload.supportedTypes', {
types: DocumentParseFilePolicy.supportedTypeLabel,
})
}}
</span>
<span>{{ $t('message.upload.singleFileLimit') }}</span>
</small>
</span>
</button>
</div>
@@ -287,13 +295,15 @@ function removeFile(filePath: string) {
.workflow-file-input__dropzone-copy {
display: flex;
flex-flow: row wrap;
gap: var(--space-1) var(--space-2);
align-items: center;
flex-direction: column;
gap: var(--space-1);
align-items: flex-start;
line-height: 1.4;
}
.workflow-file-input__dropzone-copy small {
.workflow-file-input__hint {
display: flex;
flex-direction: column;
font-size: 11px;
color: var(--el-text-color-placeholder);
}

View File

@@ -1,11 +1,62 @@
import { mount } from '@vue/test-utils';
import { flushPromises, mount } from '@vue/test-utils';
import { defineComponent, nextTick, ref } from 'vue';
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import WorkflowFileInput from '../WorkflowFileInput.vue';
const requestMocks = vi.hoisted(() => ({
upload: vi.fn(),
}));
vi.mock('#/api/request', () => ({
api: {
upload: requestMocks.upload,
},
}));
vi.mock('#/locales', () => ({
$t: (key: string, params?: Record<string, string>) => {
if (key === 'message.upload.supportedTypes') {
return `支持 ${params?.types} 文件`;
}
if (key === 'message.upload.singleFileLimit') {
return '单个文件不超过 100 MB。';
}
if (key === 'message.upload.unsupportedFileType') {
return `${params?.fileName}”格式不支持,仅支持 ${params?.types} 文件。`;
}
if (key === 'message.upload.legacyExcelAsXlsx') {
return `${params?.fileName}”不是标准 XLSX`;
}
return key;
},
}));
describe('workflow file input', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('limits the file chooser and shows the supported formats', () => {
const wrapper = mount(WorkflowFileInput);
expect(wrapper.get('input[type="file"]').attributes('accept')).toBe(
'.txt,.pdf,.docx,.md,.pptx,.xlsx,.csv',
);
expect(wrapper.text()).toContain(
'支持 TXT、PDF、DOCX、MD、PPTX、XLSX、CSV 文件',
);
expect(
wrapper
.findAll('.workflow-file-input__hint > span')
.map((item) => item.text()),
).toEqual([
'支持 TXT、PDF、DOCX、MD、PPTX、XLSX、CSV 文件',
'单个文件不超过 100 MB。',
]);
});
it('shows the upload area again after the uploaded file is deleted', async () => {
const Host = defineComponent({
components: { WorkflowFileInput },
@@ -55,4 +106,39 @@ describe('workflow file input', () => {
wrapper.get('.workflow-file-input__upload-trigger').attributes(),
).toHaveProperty('disabled');
});
it('rejects legacy XLS content before starting upload', async () => {
requestMocks.upload.mockReset();
vi.spyOn(console, 'error').mockImplementation(() => {});
const wrapper = mount(WorkflowFileInput);
const input = wrapper.get('input[type="file"]');
const file = new File(
[new Uint8Array([208, 207, 17, 224, 161, 177, 26, 225])],
'legacy.xlsx',
);
Object.defineProperty(input.element, 'files', {
configurable: true,
value: [file],
});
await input.trigger('change');
await flushPromises();
expect(requestMocks.upload).not.toHaveBeenCalled();
});
it('rejects a dragged legacy XLS file before starting upload', async () => {
requestMocks.upload.mockReset();
vi.spyOn(console, 'error').mockImplementation(() => {});
const wrapper = mount(WorkflowFileInput);
await wrapper.get('.workflow-file-input__dropzone').trigger('drop', {
dataTransfer: {
files: [new File(['legacy'], 'test.xls')],
},
});
await flushPromises();
expect(requestMocks.upload).not.toHaveBeenCalled();
});
});