Compare commits
6 Commits
f64c9e5dae
...
shihezi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49af03f4bb | ||
| 4ae11e31f4 | |||
| bca0d997c6 | |||
|
|
b43eb98a1c | ||
|
|
44c297aac2 | ||
|
|
ce597b182d |
@@ -1,6 +1,6 @@
|
|||||||
export default {
|
export default {
|
||||||
// baseUrl: 'https://fw.rc.qingdao.gov.cn/rgpp-api/api', // 内网
|
baseUrl: 'http://36.105.163.21:30081/rgpp/api', // 内网
|
||||||
baseUrl: 'https://qd.zhaopinzao8dian.com/api', // 测试
|
// baseUrl: 'https://qd.zhaopinzao8dian.com/api', // 测试
|
||||||
// baseUrl: 'http://192.168.3.29:8081',
|
// baseUrl: 'http://192.168.3.29:8081',
|
||||||
// baseUrl: 'http://10.213.6.207:19010/api',
|
// baseUrl: 'http://10.213.6.207:19010/api',
|
||||||
// 语音转文字
|
// 语音转文字
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name" : "qingdao-employment-service",
|
"name" : "qingdao-employment-service",
|
||||||
"appid" : "__UNI__2496162",
|
"appid" : "__UNI__C939371",
|
||||||
"description" : "招聘",
|
"description" : "招聘",
|
||||||
"versionName" : "1.0.0",
|
"versionName" : "1.0.0",
|
||||||
"versionCode" : "100",
|
"versionCode" : "100",
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ const { userInfo } = storeToRefs(useUserStore());
|
|||||||
const { getUserResume } = useUserStore();
|
const { getUserResume } = useUserStore();
|
||||||
const { dictLabel, oneDictData } = useDictStore();
|
const { dictLabel, oneDictData } = useDictStore();
|
||||||
const openSelectPopup = inject('openSelectPopup');
|
const openSelectPopup = inject('openSelectPopup');
|
||||||
import {FileValidator} from "@/utils/fileValidator" //文件校验
|
import { FileValidator } from '@/utils/fileValidator.js'; //文件校验
|
||||||
|
|
||||||
const percent = ref('0%');
|
const percent = ref('0%');
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
@@ -279,14 +279,14 @@ function selectAvatar() {
|
|||||||
sizeType: ['original', 'compressed'],
|
sizeType: ['original', 'compressed'],
|
||||||
sourceType: ['album', 'camera'],
|
sourceType: ['album', 'camera'],
|
||||||
count: 1,
|
count: 1,
|
||||||
success: async(res) => {
|
success: async (res) => {
|
||||||
const tempFilePaths = res.tempFilePaths;
|
const tempFilePaths = res.tempFilePaths;
|
||||||
const file = res.tempFiles[0];
|
const file = res.tempFiles[0];
|
||||||
|
|
||||||
const imageValidator = new FileValidator()
|
const imageValidator = new FileValidator();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await imageValidator.validate(file)
|
await imageValidator.validate(file);
|
||||||
|
|
||||||
$api.uploadFile(tempFilePaths[0], true)
|
$api.uploadFile(tempFilePaths[0], true)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ import successIcon from '@/static/icon/success.png';
|
|||||||
import useUserStore from '@/stores/useUserStore';
|
import useUserStore from '@/stores/useUserStore';
|
||||||
const { isMachineEnv } = storeToRefs(useUserStore());
|
const { isMachineEnv } = storeToRefs(useUserStore());
|
||||||
|
|
||||||
import {FileValidator} from "@/utils/fileValidator" //文件校验
|
import { FileValidator } from '@/utils/fileValidator.js'; //文件校验
|
||||||
// hook
|
// hook
|
||||||
// 语音识别
|
// 语音识别
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
<scroll-view :scroll-y="true" class="nearby-scroll" @scrolltolower="scrollBottom">
|
<scroll-view :scroll-y="true" class="nearby-scroll" @scrolltolower="scrollBottom">
|
||||||
<view class="nearby-map" @touchmove.stop.prevent>
|
<view class="nearby-map" @touchmove.stop.prevent>
|
||||||
<map
|
<map
|
||||||
style="width: 100%; height: 410rpx"
|
style="width: 100%; height: 690rpx"
|
||||||
:latitude="latitudeVal"
|
:latitude="latitudeVal"
|
||||||
:longitude="longitudeVal"
|
:longitude="longitudeVal"
|
||||||
:markers="mapCovers"
|
:markers="mapCovers"
|
||||||
:circles="mapCircles"
|
:circles="mapCircles"
|
||||||
:controls="mapControls"
|
:controls="mapControls"
|
||||||
@controltap="handleControl"
|
@controltap="handleControl"
|
||||||
|
:scale="mapScale"
|
||||||
></map>
|
></map>
|
||||||
<view class="nearby-select">
|
<view class="nearby-select">
|
||||||
<view class="select-view" @click="changeRangeShow">
|
<view class="select-view" @click="changeRangeShow">
|
||||||
@@ -106,16 +107,18 @@ const tMap = ref();
|
|||||||
const progress = ref();
|
const progress = ref();
|
||||||
const mapCovers = ref([]);
|
const mapCovers = ref([]);
|
||||||
const mapCircles = ref([]);
|
const mapCircles = ref([]);
|
||||||
|
const mapScale = ref(14.5)
|
||||||
const mapControls = ref([
|
const mapControls = ref([
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
position: {
|
position: {
|
||||||
// 控件位置
|
// 控件位置
|
||||||
left: customSystem.systemInfo.screenWidth - 48 - 14,
|
left: customSystem.systemInfo.screenWidth - uni.upx2px(75 + 30),
|
||||||
top: 320,
|
top: uni.upx2px(655 - 75 - 30),
|
||||||
width: 48,
|
width: uni.upx2px(75),
|
||||||
height: 48,
|
height: uni.upx2px(75),
|
||||||
},
|
},
|
||||||
|
width:100,
|
||||||
iconPath: LocationPng, // 控件图标
|
iconPath: LocationPng, // 控件图标
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -148,6 +151,8 @@ function changeRangeShow() {
|
|||||||
|
|
||||||
function changeRadius(item) {
|
function changeRadius(item) {
|
||||||
console.log(item);
|
console.log(item);
|
||||||
|
if(item > 1) mapScale.value = 14.5 - item * 0.3
|
||||||
|
else mapScale.value = 14.5
|
||||||
pageState.search.radius = item;
|
pageState.search.radius = item;
|
||||||
rangeShow.value = false;
|
rangeShow.value = false;
|
||||||
progressChange(item);
|
progressChange(item);
|
||||||
@@ -221,27 +226,23 @@ onMounted(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function getInit() {
|
function getInit() {
|
||||||
useLocationStore()
|
|
||||||
.getLocation()
|
|
||||||
.then((res) => {
|
|
||||||
mapCovers.value = [
|
mapCovers.value = [
|
||||||
{
|
{
|
||||||
latitude: res.latitude,
|
latitude: latitudeVal.value,
|
||||||
longitude: res.longitude,
|
longitude: longitudeVal.value,
|
||||||
iconPath: point2,
|
iconPath: point2,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
mapCircles.value = [
|
mapCircles.value = [
|
||||||
{
|
{
|
||||||
latitude: res.latitude,
|
latitude: latitudeVal.value,
|
||||||
longitude: res.longitude,
|
longitude:longitudeVal.value,
|
||||||
radius: 1000,
|
radius: 1000,
|
||||||
fillColor: '#1c52fa25',
|
fillColor: '#1c52fa25',
|
||||||
color: '#256BFA',
|
color: '#256BFA',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
getJobList('refresh');
|
getJobList('refresh');
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function progressChange(value) {
|
function progressChange(value) {
|
||||||
@@ -363,7 +364,7 @@ defineExpose({ loadData, handleFilterConfirm });
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
background: #f4f4f4;
|
background: #f4f4f4;
|
||||||
.nearby-map
|
.nearby-map
|
||||||
height: 400rpx;
|
height: 655rpx;
|
||||||
background: #e8e8e8;
|
background: #e8e8e8;
|
||||||
overflow: hidden
|
overflow: hidden
|
||||||
.nearby-list
|
.nearby-list
|
||||||
|
|||||||
@@ -1,301 +0,0 @@
|
|||||||
/**
|
|
||||||
* FileValidator.js
|
|
||||||
* 封装好的文件安全校验类
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// 1. 预定义:已知文件类型的魔数 (Signature Database)
|
|
||||||
// ==========================================
|
|
||||||
const KNOWN_SIGNATURES = {
|
|
||||||
// === 图片 ===
|
|
||||||
png: '89504E470D0A1A0A',
|
|
||||||
jpg: 'FFD8FF',
|
|
||||||
jpeg: 'FFD8FF',
|
|
||||||
gif: '47494638',
|
|
||||||
webp: '52494646', // RIFF Header
|
|
||||||
|
|
||||||
// === 文档 (Office 新版 - ZIP 格式) ===
|
|
||||||
docx: '504B0304',
|
|
||||||
xlsx: '504B0304',
|
|
||||||
pptx: '504B0304',
|
|
||||||
|
|
||||||
// === 文档 (Office 旧版 - OLECF 格式) ===
|
|
||||||
doc: 'D0CF11E0',
|
|
||||||
xls: 'D0CF11E0',
|
|
||||||
ppt: 'D0CF11E0',
|
|
||||||
|
|
||||||
// === 其他 ===
|
|
||||||
pdf: '25504446',
|
|
||||||
|
|
||||||
// === 纯文本 (无固定魔数,需特殊算法检测) ===
|
|
||||||
txt: 'TYPE_TEXT',
|
|
||||||
csv: 'TYPE_TEXT',
|
|
||||||
md: 'TYPE_TEXT',
|
|
||||||
json: 'TYPE_TEXT',
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// 2. 核心类定义
|
|
||||||
// ==========================================
|
|
||||||
export class FileValidator {
|
|
||||||
/**
|
|
||||||
* 构造函数
|
|
||||||
* @param {Object} options 配置项
|
|
||||||
* @param {number} [options.maxSizeMB=10] 最大文件大小 (MB)
|
|
||||||
* @param {string[]} [options.allowedExtensions = []] 允许的扩展名列表 (如 ['jpg', 'png']),默认允许全部已知类型
|
|
||||||
*/
|
|
||||||
version = '1.0.0';
|
|
||||||
constructor(options = {}) {
|
|
||||||
// 配置大小 (默认 10MB)
|
|
||||||
this.maxSizeMB = options.maxSizeMB || 10;
|
|
||||||
|
|
||||||
// 扩展名到 MIME 的映射(用于反向查找)
|
|
||||||
this.extToMime = {
|
|
||||||
png: 'image/png',
|
|
||||||
jpg: 'image/jpeg',
|
|
||||||
jpeg: 'image/jpeg',
|
|
||||||
gif: 'image/gif',
|
|
||||||
webp: 'image/webp',
|
|
||||||
pdf: 'application/pdf',
|
|
||||||
txt: 'text/plain',
|
|
||||||
md: 'text/markdown',
|
|
||||||
json: 'application/json',
|
|
||||||
csv: 'text/csv',
|
|
||||||
doc: 'application/msword',
|
|
||||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
||||||
xls: 'application/vnd.ms-excel',
|
|
||||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
||||||
ppt: 'application/vnd.ms-powerpoint',
|
|
||||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
||||||
};
|
|
||||||
|
|
||||||
// 如果传入的是 MIME 类型,转换为扩展名
|
|
||||||
let allowedExtensions = options.allowedExtensions || Object.keys(KNOWN_SIGNATURES);
|
|
||||||
|
|
||||||
// 配置允许的类型
|
|
||||||
this.allowedConfig = {};
|
|
||||||
allowedExtensions.forEach((extOrMime) => {
|
|
||||||
const key = extOrMime.toLowerCase();
|
|
||||||
|
|
||||||
// 如果是 MIME 类型,尝试转换为扩展名
|
|
||||||
let ext = key;
|
|
||||||
if (key.includes('/')) {
|
|
||||||
// 查找对应的扩展名
|
|
||||||
for (const [e, mime] of Object.entries(this.extToMime)) {
|
|
||||||
if (mime === key) {
|
|
||||||
ext = e;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (KNOWN_SIGNATURES[ext]) {
|
|
||||||
this.allowedConfig[ext] = KNOWN_SIGNATURES[ext];
|
|
||||||
} else {
|
|
||||||
console.warn(`[FileValidator] 未知的文件类型: ${key},已忽略`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 改进版:检查是否为有效的 UTF-8 文本
|
|
||||||
*/
|
|
||||||
_isValidUTF8(buffer) {
|
|
||||||
try {
|
|
||||||
// fatal: true 会在遇到无效编码时抛出错误,而不是用 替换
|
|
||||||
const decoder = new TextDecoder('utf-8', {
|
|
||||||
fatal: true,
|
|
||||||
});
|
|
||||||
decoder.decode(buffer);
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 辅助:ArrayBuffer 转 Hex 字符串
|
|
||||||
*/
|
|
||||||
_bufferToHex(buffer) {
|
|
||||||
return Array.prototype.map
|
|
||||||
.call(new Uint8Array(buffer), (x) => ('00' + x.toString(16)).slice(-2))
|
|
||||||
.join('')
|
|
||||||
.toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 【新增】统计 CSV 行数(严谨版:忽略引号内的换行符)
|
|
||||||
* 性能:对于 10MB 文件,现代浏览器处理通常在 100ms 以内
|
|
||||||
*/
|
|
||||||
_countCSVRows(buffer) {
|
|
||||||
const decoder = new TextDecoder('utf-8');
|
|
||||||
const text = decoder.decode(buffer);
|
|
||||||
|
|
||||||
let rowCount = 0;
|
|
||||||
let inQuote = false;
|
|
||||||
let len = text.length;
|
|
||||||
|
|
||||||
// 遍历每一个字符
|
|
||||||
for (let i = 0; i < len; i++) {
|
|
||||||
const char = text[i];
|
|
||||||
|
|
||||||
// 切换引号状态
|
|
||||||
if (char === '"') {
|
|
||||||
inQuote = !inQuote;
|
|
||||||
}
|
|
||||||
// 只有在非引号状态下的换行符,才算作一行结束
|
|
||||||
else if (char === '\n' && !inQuote) {
|
|
||||||
rowCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理最后一行没有换行符的情况(且文件不为空)
|
|
||||||
if (len > 0 && text[len - 1] !== '\n') {
|
|
||||||
rowCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return rowCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 【核心】:校验纯文本内容
|
|
||||||
* 1. 检查是否包含乱码 (非 UTF-8)
|
|
||||||
* 2. 针对特定格式 (JSON) 进行语法解析
|
|
||||||
*/
|
|
||||||
_validateTextContent(buffer, extension) {
|
|
||||||
// 1. 尝试解码为 UTF-8
|
|
||||||
let contentStr = '';
|
|
||||||
try {
|
|
||||||
const decoder = new TextDecoder('utf-8', {
|
|
||||||
fatal: true,
|
|
||||||
});
|
|
||||||
contentStr = decoder.decode(buffer);
|
|
||||||
} catch (e) {
|
|
||||||
// 如果解码失败,说明包含非文本的二进制数据
|
|
||||||
console.warn('UTF-8 解码失败', e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 检查是否存在过多的空字符 (二进制文件特征)
|
|
||||||
// 某些二进制文件可能勉强通过 UTF-8 解码,但会包含大量 \0
|
|
||||||
if (contentStr.includes('\u0000')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 针对特定后缀进行语法校验 (可选,更严格)
|
|
||||||
if (extension === 'json') {
|
|
||||||
try {
|
|
||||||
JSON.parse(contentStr);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('无效的 JSON 格式');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果是 CSV,可以简单检查行数(可选)
|
|
||||||
// if (extension === 'csv') { ... }
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 执行校验
|
|
||||||
* @param {File} file 文件对象
|
|
||||||
* @returns {Promise<boolean>}
|
|
||||||
*/
|
|
||||||
validate(file) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
console.log('开始校验文件');
|
|
||||||
// 1. 基础对象检查
|
|
||||||
if (!file || !file.name) return reject('无效的文件对象');
|
|
||||||
|
|
||||||
// 2. 大小检查
|
|
||||||
if (file.size > this.maxSizeMB * 1024 * 1024) {
|
|
||||||
return reject(`文件大小超出限制 (最大 ${this.maxSizeMB}MB)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 后缀名检查
|
|
||||||
const fileName = file.name.toLowerCase();
|
|
||||||
const extension = fileName.substring(fileName.lastIndexOf('.') + 1);
|
|
||||||
|
|
||||||
// 检查是否在配置的白名单中
|
|
||||||
const expectedMagic = this.allowedConfig[extension];
|
|
||||||
if (!expectedMagic) {
|
|
||||||
return reject(`不支持的文件格式: .${extension}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 读取二进制头进行魔数校验
|
|
||||||
const reader = new FileReader();
|
|
||||||
|
|
||||||
reader.onload = (e) => {
|
|
||||||
const buffer = e.target.result;
|
|
||||||
let isSafe = false;
|
|
||||||
|
|
||||||
// 分支处理:纯文本 vs 二进制
|
|
||||||
if (expectedMagic === 'TYPE_TEXT') {
|
|
||||||
if (this._validateTextContent(buffer, extension)) {
|
|
||||||
isSafe = true;
|
|
||||||
} else {
|
|
||||||
// 细化报错信息
|
|
||||||
if (extension === 'json') {
|
|
||||||
return reject(`文件异常:不是有效的 JSON 文件`);
|
|
||||||
}
|
|
||||||
return reject(`文件异常:.${extension} 包含非法二进制内容或编码错误`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 【新增】专门针对 CSV 的行数检查
|
|
||||||
if (extension === 'csv' && this.csvMaxRows > 0) {
|
|
||||||
const rows = this._countCSVRows(buffer);
|
|
||||||
// 注意:这里通常把表头也算作 1 行,如果不算表头可以将 limit + 1
|
|
||||||
if (rows > this.csvMaxRows) {
|
|
||||||
return reject(`CSV 行数超出限制 (当前 ${rows} 行,最大允许 ${this.csvMaxRows} 行)`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 获取文件头 Hex (读取足够长的字节以覆盖最长的魔数,PNG需8字节)
|
|
||||||
const fileHeader = this._bufferToHex(buffer.slice(0, 8));
|
|
||||||
|
|
||||||
// 使用 startsWith 匹配
|
|
||||||
if (fileHeader.startsWith(expectedMagic)) {
|
|
||||||
isSafe = true;
|
|
||||||
} else {
|
|
||||||
return reject(`文件可能已被篡改 (真实类型与 .${extension} 不符)`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSafe) resolve(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
reader.onerror = () => reject('文件读取失败,无法校验');
|
|
||||||
|
|
||||||
// 读取前 1KB 进行判断
|
|
||||||
if (expectedMagic === 'TYPE_TEXT' && extension === 'json') {
|
|
||||||
// JSON 必须读全量才能 parse,建议限制 JSON 文件大小
|
|
||||||
reader.readAsArrayBuffer(file);
|
|
||||||
} else {
|
|
||||||
// 图片/普通文本 读取前 2KB 足够判断头部和编码特征
|
|
||||||
reader.readAsArrayBuffer(file.slice(0, 2048));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 【demo】
|
|
||||||
// 如果传入了 allowedExtensions,则只使用传入的;否则使用全部 KNOWN_SIGNATURES
|
|
||||||
// const imageValidator = new FileValidator({
|
|
||||||
// maxSizeMB: 5,
|
|
||||||
// allowedExtensions: ['png', 'jpg', 'jpeg'],
|
|
||||||
// });
|
|
||||||
|
|
||||||
// imageValidator
|
|
||||||
// .validate(file)
|
|
||||||
// .then(() => {
|
|
||||||
// statusDiv.textContent = `检测通过: ${file.name}`;
|
|
||||||
// statusDiv.style.color = 'green';
|
|
||||||
// console.log('图片校验通过,开始上传...');
|
|
||||||
// // upload(file)...
|
|
||||||
// })
|
|
||||||
// .catch((err) => {
|
|
||||||
// statusDiv.textContent = `检测失败: ${err}`;
|
|
||||||
// statusDiv.style.color = 'red';
|
|
||||||
// });
|
|
||||||
170
static/js/fileValidator.js
Normal file
170
static/js/fileValidator.js
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
const KNOWN_SIGNATURES = {
|
||||||
|
png: '89504E470D0A1A0A',
|
||||||
|
jpg: 'FFD8FF',
|
||||||
|
jpeg: 'FFD8FF',
|
||||||
|
gif: '47494638',
|
||||||
|
webp: '52494646',
|
||||||
|
docx: '504B0304',
|
||||||
|
xlsx: '504B0304',
|
||||||
|
pptx: '504B0304',
|
||||||
|
doc: 'D0CF11E0',
|
||||||
|
xls: 'D0CF11E0',
|
||||||
|
ppt: 'D0CF11E0',
|
||||||
|
pdf: '25504446',
|
||||||
|
txt: 'TYPE_TEXT',
|
||||||
|
csv: 'TYPE_TEXT',
|
||||||
|
md: 'TYPE_TEXT',
|
||||||
|
json: 'TYPE_TEXT',
|
||||||
|
};
|
||||||
|
export class FileValidator {
|
||||||
|
version = '1.0.0';
|
||||||
|
signs = Object.keys(KNOWN_SIGNATURES);
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.maxSizeMB = options.maxSizeMB || 10;
|
||||||
|
if (options.allowedExtensions && Array.isArray(options.allowedExtensions)) {
|
||||||
|
this.allowedConfig = {};
|
||||||
|
options.allowedExtensions.forEach((ext) => {
|
||||||
|
const key = ext.toLowerCase();
|
||||||
|
if (KNOWN_SIGNATURES[key]) {
|
||||||
|
this.allowedConfig[key] = KNOWN_SIGNATURES[key];
|
||||||
|
} else {
|
||||||
|
console.warn(`[FileValidator] 未知的文件类型: .${key},已忽略`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.allowedConfig = {
|
||||||
|
...KNOWN_SIGNATURES,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_isValidUTF8(buffer) {
|
||||||
|
try {
|
||||||
|
const decoder = new TextDecoder('utf-8', {
|
||||||
|
fatal: true,
|
||||||
|
});
|
||||||
|
decoder.decode(buffer);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_bufferToHex(buffer) {
|
||||||
|
return Array.prototype.map
|
||||||
|
.call(new Uint8Array(buffer), (x) => ('00' + x.toString(16)).slice(-2))
|
||||||
|
.join('')
|
||||||
|
.toUpperCase();
|
||||||
|
}
|
||||||
|
_countCSVRows(buffer) {
|
||||||
|
const decoder = new TextDecoder('utf-8');
|
||||||
|
const text = decoder.decode(buffer);
|
||||||
|
let rowCount = 0;
|
||||||
|
let inQuote = false;
|
||||||
|
let len = text.length;
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
const char = text[i];
|
||||||
|
if (char === '"') {
|
||||||
|
inQuote = !inQuote;
|
||||||
|
} else if (char === '\n' && !inQuote) {
|
||||||
|
rowCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (len > 0 && text[len - 1] !== '\n') {
|
||||||
|
rowCount++;
|
||||||
|
}
|
||||||
|
return rowCount;
|
||||||
|
}
|
||||||
|
_validateTextContent(buffer, extension) {
|
||||||
|
let contentStr = '';
|
||||||
|
try {
|
||||||
|
const decoder = new TextDecoder('utf-8', {
|
||||||
|
fatal: true,
|
||||||
|
});
|
||||||
|
contentStr = decoder.decode(buffer);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('UTF-8 解码失败', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (contentStr.includes('\0')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (extension === 'json') {
|
||||||
|
try {
|
||||||
|
JSON.parse(contentStr);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('无效的 JSON 格式');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
validate(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!file || !file.name) return reject('无效的文件对象');
|
||||||
|
if (file.size > this.maxSizeMB * 1024 * 1024) {
|
||||||
|
return reject(`文件大小超出限制 (最大 ${this.maxSizeMB}MB)`);
|
||||||
|
}
|
||||||
|
const fileName = file.name.toLowerCase();
|
||||||
|
const extension = fileName.substring(fileName.lastIndexOf('.') + 1);
|
||||||
|
const expectedMagic = this.allowedConfig[extension];
|
||||||
|
if (!expectedMagic) {
|
||||||
|
return reject(`不支持的文件格式: .${extension}`);
|
||||||
|
}
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const buffer = e.target.result;
|
||||||
|
let isSafe = false;
|
||||||
|
if (expectedMagic === 'TYPE_TEXT') {
|
||||||
|
if (this._validateTextContent(buffer, extension)) {
|
||||||
|
isSafe = true;
|
||||||
|
} else {
|
||||||
|
if (extension === 'json') {
|
||||||
|
return reject(`文件异常:不是有效的 JSON 文件`);
|
||||||
|
}
|
||||||
|
return reject(`文件异常:.${extension} 包含非法二进制内容或编码错误`);
|
||||||
|
}
|
||||||
|
if (extension === 'csv' && this.csvMaxRows > 0) {
|
||||||
|
const rows = this._countCSVRows(buffer);
|
||||||
|
if (rows > this.csvMaxRows) {
|
||||||
|
return reject(`CSV 行数超出限制 (当前 ${rows} 行,最大允许 ${this.csvMaxRows} 行)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const fileHeader = this._bufferToHex(buffer.slice(0, 8));
|
||||||
|
if (fileHeader.startsWith(expectedMagic)) {
|
||||||
|
isSafe = true;
|
||||||
|
} else {
|
||||||
|
return reject(`文件可能已被篡改 (真实类型与 .${extension} 不符)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isSafe) resolve(true);
|
||||||
|
};
|
||||||
|
reader.onerror = () => reject('文件读取失败,无法校验');
|
||||||
|
if (expectedMagic === 'TYPE_TEXT' && extension === 'json') {
|
||||||
|
reader.readAsArrayBuffer(file);
|
||||||
|
} else {
|
||||||
|
reader.readAsArrayBuffer(file.slice(0, 2048));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 【demo】
|
||||||
|
// 如果传入了 allowedExtensions,则只使用传入的;否则使用全部 KNOWN_SIGNATURES
|
||||||
|
// const imageValidator = new FileValidator({
|
||||||
|
// maxSizeMB: 5,
|
||||||
|
// allowedExtensions: ['png', 'jpg', 'jpeg'],
|
||||||
|
// });
|
||||||
|
|
||||||
|
// imageValidator
|
||||||
|
// .validate(file)
|
||||||
|
// .then(() => {
|
||||||
|
// statusDiv.textContent = `检测通过: ${file.name}`;
|
||||||
|
// statusDiv.style.color = 'green';
|
||||||
|
// console.log('图片校验通过,开始上传...');
|
||||||
|
// // upload(file)...
|
||||||
|
// })
|
||||||
|
// .catch((err) => {
|
||||||
|
// statusDiv.textContent = `检测失败: ${err}`;
|
||||||
|
// statusDiv.style.color = 'red';
|
||||||
|
// });
|
||||||
@@ -2,10 +2,8 @@
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta
|
<meta name="viewport"
|
||||||
name="viewport"
|
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
|
|
||||||
/>
|
|
||||||
<title>文件上传</title>
|
<title>文件上传</title>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -173,6 +171,7 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(10px);
|
transform: translateY(10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
@@ -342,6 +341,7 @@
|
|||||||
0% {
|
0% {
|
||||||
transform: rotate(0deg);
|
transform: rotate(0deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
@@ -405,6 +405,7 @@
|
|||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
@@ -614,13 +615,8 @@
|
|||||||
<div class="upload-icon">📁</div>
|
<div class="upload-icon">📁</div>
|
||||||
<div class="upload-text" id="uploadText">点击选择文件</div>
|
<div class="upload-text" id="uploadText">点击选择文件</div>
|
||||||
<div class="upload-hint" id="uploadHint">支持图片、文档、文本等格式</div>
|
<div class="upload-hint" id="uploadHint">支持图片、文档、文本等格式</div>
|
||||||
<input
|
<input type="file" id="fileInput" class="file-input" multiple
|
||||||
type="file"
|
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.csv,.ppt,.pptx,.txt,.md" />
|
||||||
id="fileInput"
|
|
||||||
class="file-input"
|
|
||||||
multiple
|
|
||||||
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.csv,.ppt,.pptx,.txt,.md"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -656,7 +652,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import { FileValidator } from './FileValidator.js'; //文件校验JS
|
import {
|
||||||
|
FileValidator
|
||||||
|
} from './js/FileValidator.js'; //文件校验JS
|
||||||
// 创建文件校验器实例
|
// 创建文件校验器实例
|
||||||
const fileValidator = new FileValidator();
|
const fileValidator = new FileValidator();
|
||||||
|
|
||||||
@@ -750,7 +748,9 @@
|
|||||||
setupDragAndDrop();
|
setupDragAndDrop();
|
||||||
|
|
||||||
// 防止页面滚动
|
// 防止页面滚动
|
||||||
document.body.addEventListener('touchmove', preventScroll, { passive: false });
|
document.body.addEventListener('touchmove', preventScroll, {
|
||||||
|
passive: false
|
||||||
|
});
|
||||||
|
|
||||||
// 更新UI
|
// 更新UI
|
||||||
updateUI();
|
updateUI();
|
||||||
@@ -1002,14 +1002,14 @@
|
|||||||
// 生成缩略图
|
// 生成缩略图
|
||||||
function generateThumbnail(file, containerId) {
|
function generateThumbnail(file, containerId) {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = function (e) {
|
reader.onload = function(e) {
|
||||||
const container = document.getElementById(containerId);
|
const container = document.getElementById(containerId);
|
||||||
if (container) {
|
if (container) {
|
||||||
const img = container.querySelector('.thumbnail');
|
const img = container.querySelector('.thumbnail');
|
||||||
img.src = e.target.result;
|
img.src = e.target.result;
|
||||||
|
|
||||||
// 添加加载完成后的淡入效果
|
// 添加加载完成后的淡入效果
|
||||||
img.onload = function () {
|
img.onload = function() {
|
||||||
img.style.opacity = '0';
|
img.style.opacity = '0';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
img.style.transition = 'opacity 0.3s ease';
|
img.style.transition = 'opacity 0.3s ease';
|
||||||
@@ -1024,7 +1024,7 @@
|
|||||||
// 预览完整图片
|
// 预览完整图片
|
||||||
function previewFullImage(file) {
|
function previewFullImage(file) {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = function (e) {
|
reader.onload = function(e) {
|
||||||
previewImage.src = e.target.result;
|
previewImage.src = e.target.result;
|
||||||
imagePreviewModal.classList.add('show');
|
imagePreviewModal.classList.add('show');
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
@@ -1087,7 +1087,8 @@
|
|||||||
fileInput.disabled = true;
|
fileInput.disabled = true;
|
||||||
} else {
|
} else {
|
||||||
uploadText.textContent = '点击选择文件';
|
uploadText.textContent = '点击选择文件';
|
||||||
uploadHint.innerHTML = `支持图片、文档、文本等格式<br />最多${MAX_FILE_COUNT}个文件,每个不超过10MB<br />已上传: ${uploadedCount}/${MAX_FILE_COUNT}`;
|
uploadHint.innerHTML =
|
||||||
|
`支持图片、文档、文本等格式<br />最多${MAX_FILE_COUNT}个文件,每个不超过10MB<br />已上传: ${uploadedCount}/${MAX_FILE_COUNT}`;
|
||||||
fileInput.disabled = false;
|
fileInput.disabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1343,13 +1344,17 @@
|
|||||||
|
|
||||||
// 显示成功提示(创建新弹窗)
|
// 显示成功提示(创建新弹窗)
|
||||||
function showSuccess(message, options = {}) {
|
function showSuccess(message, options = {}) {
|
||||||
const { autoClose = true, closeTime = 3000 } = options;
|
const {
|
||||||
|
autoClose = true, closeTime = 3000
|
||||||
|
} = options;
|
||||||
return createStatusMessage('success', '上传成功!', message, autoClose, closeTime);
|
return createStatusMessage('success', '上传成功!', message, autoClose, closeTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显示错误提示(创建新弹窗)
|
// 显示错误提示(创建新弹窗)
|
||||||
function showError(message, options = {}) {
|
function showError(message, options = {}) {
|
||||||
const { autoClose = false, closeTime = 5000 } = options;
|
const {
|
||||||
|
autoClose = false, closeTime = 5000
|
||||||
|
} = options;
|
||||||
return createStatusMessage('error', '出错了', message, autoClose, closeTime);
|
return createStatusMessage('error', '出错了', message, autoClose, closeTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,108 +1,44 @@
|
|||||||
/**
|
|
||||||
* FileValidator.js
|
|
||||||
* 封装好的文件安全校验类
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// 1. 预定义:已知文件类型的魔数 (Signature Database)
|
|
||||||
// ==========================================
|
|
||||||
const KNOWN_SIGNATURES = {
|
const KNOWN_SIGNATURES = {
|
||||||
// === 图片 ===
|
|
||||||
png: '89504E470D0A1A0A',
|
png: '89504E470D0A1A0A',
|
||||||
jpg: 'FFD8FF',
|
jpg: 'FFD8FF',
|
||||||
jpeg: 'FFD8FF',
|
jpeg: 'FFD8FF',
|
||||||
gif: '47494638',
|
gif: '47494638',
|
||||||
webp: '52494646', // RIFF Header
|
webp: '52494646',
|
||||||
|
|
||||||
// === 文档 (Office 新版 - ZIP 格式) ===
|
|
||||||
docx: '504B0304',
|
docx: '504B0304',
|
||||||
xlsx: '504B0304',
|
xlsx: '504B0304',
|
||||||
pptx: '504B0304',
|
pptx: '504B0304',
|
||||||
|
|
||||||
// === 文档 (Office 旧版 - OLECF 格式) ===
|
|
||||||
doc: 'D0CF11E0',
|
doc: 'D0CF11E0',
|
||||||
xls: 'D0CF11E0',
|
xls: 'D0CF11E0',
|
||||||
ppt: 'D0CF11E0',
|
ppt: 'D0CF11E0',
|
||||||
|
|
||||||
// === 其他 ===
|
|
||||||
pdf: '25504446',
|
pdf: '25504446',
|
||||||
|
|
||||||
// === 纯文本 (无固定魔数,需特殊算法检测) ===
|
|
||||||
txt: 'TYPE_TEXT',
|
txt: 'TYPE_TEXT',
|
||||||
csv: 'TYPE_TEXT',
|
csv: 'TYPE_TEXT',
|
||||||
md: 'TYPE_TEXT',
|
md: 'TYPE_TEXT',
|
||||||
json: 'TYPE_TEXT',
|
json: 'TYPE_TEXT',
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// 2. 核心类定义
|
|
||||||
// ==========================================
|
|
||||||
export class FileValidator {
|
export class FileValidator {
|
||||||
/**
|
|
||||||
* 构造函数
|
|
||||||
* @param {Object} options 配置项
|
|
||||||
* @param {number} [options.maxSizeMB=10] 最大文件大小 (MB)
|
|
||||||
* @param {string[]} [options.allowedExtensions = []] 允许的扩展名列表 (如 ['jpg', 'png']),默认允许全部已知类型
|
|
||||||
*/
|
|
||||||
version = '1.0.0';
|
version = '1.0.0';
|
||||||
|
signs = Object.keys(KNOWN_SIGNATURES);
|
||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
// 配置大小 (默认 10MB)
|
|
||||||
this.maxSizeMB = options.maxSizeMB || 10;
|
this.maxSizeMB = options.maxSizeMB || 10;
|
||||||
|
if (options.allowedExtensions && Array.isArray(options.allowedExtensions)) {
|
||||||
// 扩展名到 MIME 的映射(用于反向查找)
|
|
||||||
this.extToMime = {
|
|
||||||
png: 'image/png',
|
|
||||||
jpg: 'image/jpeg',
|
|
||||||
jpeg: 'image/jpeg',
|
|
||||||
gif: 'image/gif',
|
|
||||||
webp: 'image/webp',
|
|
||||||
pdf: 'application/pdf',
|
|
||||||
txt: 'text/plain',
|
|
||||||
md: 'text/markdown',
|
|
||||||
json: 'application/json',
|
|
||||||
csv: 'text/csv',
|
|
||||||
doc: 'application/msword',
|
|
||||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
||||||
xls: 'application/vnd.ms-excel',
|
|
||||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
||||||
ppt: 'application/vnd.ms-powerpoint',
|
|
||||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
||||||
};
|
|
||||||
|
|
||||||
// 如果传入的是 MIME 类型,转换为扩展名
|
|
||||||
let allowedExtensions = options.allowedExtensions || Object.keys(KNOWN_SIGNATURES);
|
|
||||||
|
|
||||||
// 配置允许的类型
|
|
||||||
this.allowedConfig = {};
|
this.allowedConfig = {};
|
||||||
allowedExtensions.forEach((extOrMime) => {
|
options.allowedExtensions.forEach((ext) => {
|
||||||
const key = extOrMime.toLowerCase();
|
const key = ext.toLowerCase();
|
||||||
|
if (KNOWN_SIGNATURES[key]) {
|
||||||
// 如果是 MIME 类型,尝试转换为扩展名
|
this.allowedConfig[key] = KNOWN_SIGNATURES[key];
|
||||||
let ext = key;
|
|
||||||
if (key.includes('/')) {
|
|
||||||
// 查找对应的扩展名
|
|
||||||
for (const [e, mime] of Object.entries(this.extToMime)) {
|
|
||||||
if (mime === key) {
|
|
||||||
ext = e;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (KNOWN_SIGNATURES[ext]) {
|
|
||||||
this.allowedConfig[ext] = KNOWN_SIGNATURES[ext];
|
|
||||||
} else {
|
} else {
|
||||||
console.warn(`[FileValidator] 未知的文件类型: ${key},已忽略`);
|
console.warn(`[FileValidator] 未知的文件类型: .${key},已忽略`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
this.allowedConfig = {
|
||||||
|
...KNOWN_SIGNATURES,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 改进版:检查是否为有效的 UTF-8 文本
|
|
||||||
*/
|
|
||||||
_isValidUTF8(buffer) {
|
_isValidUTF8(buffer) {
|
||||||
try {
|
try {
|
||||||
// fatal: true 会在遇到无效编码时抛出错误,而不是用 替换
|
|
||||||
const decoder = new TextDecoder('utf-8', {
|
const decoder = new TextDecoder('utf-8', {
|
||||||
fatal: true,
|
fatal: true,
|
||||||
});
|
});
|
||||||
@@ -112,58 +48,32 @@ export class FileValidator {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 辅助:ArrayBuffer 转 Hex 字符串
|
|
||||||
*/
|
|
||||||
_bufferToHex(buffer) {
|
_bufferToHex(buffer) {
|
||||||
return Array.prototype.map
|
return Array.prototype.map
|
||||||
.call(new Uint8Array(buffer), (x) => ('00' + x.toString(16)).slice(-2))
|
.call(new Uint8Array(buffer), (x) => ('00' + x.toString(16)).slice(-2))
|
||||||
.join('')
|
.join('')
|
||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 【新增】统计 CSV 行数(严谨版:忽略引号内的换行符)
|
|
||||||
* 性能:对于 10MB 文件,现代浏览器处理通常在 100ms 以内
|
|
||||||
*/
|
|
||||||
_countCSVRows(buffer) {
|
_countCSVRows(buffer) {
|
||||||
const decoder = new TextDecoder('utf-8');
|
const decoder = new TextDecoder('utf-8');
|
||||||
const text = decoder.decode(buffer);
|
const text = decoder.decode(buffer);
|
||||||
|
|
||||||
let rowCount = 0;
|
let rowCount = 0;
|
||||||
let inQuote = false;
|
let inQuote = false;
|
||||||
let len = text.length;
|
let len = text.length;
|
||||||
|
|
||||||
// 遍历每一个字符
|
|
||||||
for (let i = 0; i < len; i++) {
|
for (let i = 0; i < len; i++) {
|
||||||
const char = text[i];
|
const char = text[i];
|
||||||
|
|
||||||
// 切换引号状态
|
|
||||||
if (char === '"') {
|
if (char === '"') {
|
||||||
inQuote = !inQuote;
|
inQuote = !inQuote;
|
||||||
}
|
} else if (char === '\n' && !inQuote) {
|
||||||
// 只有在非引号状态下的换行符,才算作一行结束
|
|
||||||
else if (char === '\n' && !inQuote) {
|
|
||||||
rowCount++;
|
rowCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理最后一行没有换行符的情况(且文件不为空)
|
|
||||||
if (len > 0 && text[len - 1] !== '\n') {
|
if (len > 0 && text[len - 1] !== '\n') {
|
||||||
rowCount++;
|
rowCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return rowCount;
|
return rowCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 【核心】:校验纯文本内容
|
|
||||||
* 1. 检查是否包含乱码 (非 UTF-8)
|
|
||||||
* 2. 针对特定格式 (JSON) 进行语法解析
|
|
||||||
*/
|
|
||||||
_validateTextContent(buffer, extension) {
|
_validateTextContent(buffer, extension) {
|
||||||
// 1. 尝试解码为 UTF-8
|
|
||||||
let contentStr = '';
|
let contentStr = '';
|
||||||
try {
|
try {
|
||||||
const decoder = new TextDecoder('utf-8', {
|
const decoder = new TextDecoder('utf-8', {
|
||||||
@@ -171,18 +81,12 @@ export class FileValidator {
|
|||||||
});
|
});
|
||||||
contentStr = decoder.decode(buffer);
|
contentStr = decoder.decode(buffer);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 如果解码失败,说明包含非文本的二进制数据
|
|
||||||
console.warn('UTF-8 解码失败', e);
|
console.warn('UTF-8 解码失败', e);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (contentStr.includes('\0')) {
|
||||||
// 2. 检查是否存在过多的空字符 (二进制文件特征)
|
|
||||||
// 某些二进制文件可能勉强通过 UTF-8 解码,但会包含大量 \0
|
|
||||||
if (contentStr.includes('\u0000')) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 针对特定后缀进行语法校验 (可选,更严格)
|
|
||||||
if (extension === 'json') {
|
if (extension === 'json') {
|
||||||
try {
|
try {
|
||||||
JSON.parse(contentStr);
|
JSON.parse(contentStr);
|
||||||
@@ -191,95 +95,60 @@ export class FileValidator {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果是 CSV,可以简单检查行数(可选)
|
|
||||||
// if (extension === 'csv') { ... }
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 执行校验
|
|
||||||
* @param {File} file 文件对象
|
|
||||||
* @returns {Promise<boolean>}
|
|
||||||
*/
|
|
||||||
validate(file) {
|
validate(file) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
console.log('开始校验文件');
|
|
||||||
// 1. 基础对象检查
|
|
||||||
if (!file || !file.name) return reject('无效的文件对象');
|
if (!file || !file.name) return reject('无效的文件对象');
|
||||||
|
|
||||||
// 2. 大小检查
|
|
||||||
if (file.size > this.maxSizeMB * 1024 * 1024) {
|
if (file.size > this.maxSizeMB * 1024 * 1024) {
|
||||||
return reject(`文件大小超出限制 (最大 ${this.maxSizeMB}MB)`);
|
return reject(`文件大小超出限制 (最大 ${this.maxSizeMB}MB)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 后缀名检查
|
|
||||||
const fileName = file.name.toLowerCase();
|
const fileName = file.name.toLowerCase();
|
||||||
const extension = fileName.substring(fileName.lastIndexOf('.') + 1);
|
const extension = fileName.substring(fileName.lastIndexOf('.') + 1);
|
||||||
|
|
||||||
// 检查是否在配置的白名单中
|
|
||||||
const expectedMagic = this.allowedConfig[extension];
|
const expectedMagic = this.allowedConfig[extension];
|
||||||
if (!expectedMagic) {
|
if (!expectedMagic) {
|
||||||
return reject(`不支持的文件格式: .${extension}`);
|
return reject(`不支持的文件格式: .${extension}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 读取二进制头进行魔数校验
|
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
|
|
||||||
reader.onload = (e) => {
|
reader.onload = (e) => {
|
||||||
const buffer = e.target.result;
|
const buffer = e.target.result;
|
||||||
let isSafe = false;
|
let isSafe = false;
|
||||||
|
|
||||||
// 分支处理:纯文本 vs 二进制
|
|
||||||
if (expectedMagic === 'TYPE_TEXT') {
|
if (expectedMagic === 'TYPE_TEXT') {
|
||||||
if (this._validateTextContent(buffer, extension)) {
|
if (this._validateTextContent(buffer, extension)) {
|
||||||
isSafe = true;
|
isSafe = true;
|
||||||
} else {
|
} else {
|
||||||
// 细化报错信息
|
|
||||||
if (extension === 'json') {
|
if (extension === 'json') {
|
||||||
return reject(`文件异常:不是有效的 JSON 文件`);
|
return reject(`文件异常:不是有效的 JSON 文件`);
|
||||||
}
|
}
|
||||||
return reject(`文件异常:.${extension} 包含非法二进制内容或编码错误`);
|
return reject(`文件异常:.${extension} 包含非法二进制内容或编码错误`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 【新增】专门针对 CSV 的行数检查
|
|
||||||
if (extension === 'csv' && this.csvMaxRows > 0) {
|
if (extension === 'csv' && this.csvMaxRows > 0) {
|
||||||
const rows = this._countCSVRows(buffer);
|
const rows = this._countCSVRows(buffer);
|
||||||
// 注意:这里通常把表头也算作 1 行,如果不算表头可以将 limit + 1
|
|
||||||
if (rows > this.csvMaxRows) {
|
if (rows > this.csvMaxRows) {
|
||||||
return reject(`CSV 行数超出限制 (当前 ${rows} 行,最大允许 ${this.csvMaxRows} 行)`);
|
return reject(`CSV 行数超出限制 (当前 ${rows} 行,最大允许 ${this.csvMaxRows} 行)`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 获取文件头 Hex (读取足够长的字节以覆盖最长的魔数,PNG需8字节)
|
|
||||||
const fileHeader = this._bufferToHex(buffer.slice(0, 8));
|
const fileHeader = this._bufferToHex(buffer.slice(0, 8));
|
||||||
|
|
||||||
// 使用 startsWith 匹配
|
|
||||||
if (fileHeader.startsWith(expectedMagic)) {
|
if (fileHeader.startsWith(expectedMagic)) {
|
||||||
isSafe = true;
|
isSafe = true;
|
||||||
} else {
|
} else {
|
||||||
return reject(`文件可能已被篡改 (真实类型与 .${extension} 不符)`);
|
return reject(`文件可能已被篡改 (真实类型与 .${extension} 不符)`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSafe) resolve(true);
|
if (isSafe) resolve(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
reader.onerror = () => reject('文件读取失败,无法校验');
|
reader.onerror = () => reject('文件读取失败,无法校验');
|
||||||
|
|
||||||
// 读取前 1KB 进行判断
|
|
||||||
if (expectedMagic === 'TYPE_TEXT' && extension === 'json') {
|
if (expectedMagic === 'TYPE_TEXT' && extension === 'json') {
|
||||||
// JSON 必须读全量才能 parse,建议限制 JSON 文件大小
|
|
||||||
reader.readAsArrayBuffer(file);
|
reader.readAsArrayBuffer(file);
|
||||||
} else {
|
} else {
|
||||||
// 图片/普通文本 读取前 2KB 足够判断头部和编码特征
|
|
||||||
reader.readAsArrayBuffer(file.slice(0, 2048));
|
reader.readAsArrayBuffer(file.slice(0, 2048));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 【demo】
|
// 【demo】
|
||||||
// 如果传入了 allowedExtensions,则只使用传入的;否则使用全部 KNOWN_SIGNATURES
|
// 如果传入了 allowedExtensions,则只使用传入的;否则使用全部 KNOWN_SIGNATURES
|
||||||
// const imageValidator = new FileValidator({
|
// const imageValidator = new FileValidator({
|
||||||
|
|||||||
Reference in New Issue
Block a user