Compare commits

6 Commits

Author SHA1 Message Date
Apcallover
49af03f4bb flat: 暂存 2025-12-19 17:43:23 +08:00
4ae11e31f4 地图优化 2025-12-19 16:45:21 +08:00
bca0d997c6 style 2025-12-19 16:33:15 +08:00
Apcallover
b43eb98a1c flat: ok 2025-12-19 16:13:09 +08:00
Apcallover
44c297aac2 flat: ok 2025-12-19 16:10:39 +08:00
Apcallover
ce597b182d flat 文件校验 2025-12-19 12:06:35 +08:00
9 changed files with 1748 additions and 2004 deletions

View File

@@ -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',
// 语音转文字 // 语音转文字

View File

@@ -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",

View File

@@ -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) => {

View File

@@ -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 {

View File

@@ -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

View File

@@ -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
View 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';
// });

View File

@@ -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);
} }

View File

@@ -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({