修改上传html 加入文件校验

This commit is contained in:
2025-12-19 15:48:24 +08:00
parent 1ac524e1f1
commit 975835baa5
3 changed files with 517 additions and 84 deletions

301
static/fileValidator.js Normal file
View File

@@ -0,0 +1,301 @@
/**
* 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';
// });

View File

@@ -7,6 +7,7 @@
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<title>文件上传</title>
<style>
* {
margin: 0;
@@ -352,30 +353,60 @@
font-weight: 500;
}
/* 状态提示框容器 */
.status-messages-container {
pointer-events: none;
width: 100%;
}
/* 状态提示框 */
.status-message {
position: fixed;
top: 50%;
left: 20px;
right: 20px;
transform: translateY(-50%);
background: rgb(238, 238, 238);
border-radius: 16px;
padding: 30px 25px;
text-align: center;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
z-index: 1001;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
margin: 0 auto 20px;
display: none;
animation: popUp 0.3s ease;
max-width: 85%;
pointer-events: auto;
position: fixed;
top: 45%;
left: 0;
right: 0;
z-index: 1001;
transform: translateY(-50%);
}
/* 点击关闭按钮 */
.status-message .close-btn {
position: absolute;
top: 10px;
right: 10px;
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: #666;
width: 30px;
height: 30px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.status-message .close-btn:hover {
background: rgba(0, 0, 0, 0.1);
}
@keyframes popUp {
from {
opacity: 0;
transform: translateY(-40%) scale(0.9);
}
to {
opacity: 1;
transform: translateY(-50%) scale(1);
}
}
@@ -615,13 +646,8 @@
<div class="loading-text" id="loadingText">上传中,请稍候...</div>
</div>
<!-- 状态提示框 -->
<div class="status-message" id="statusMessage">
<span class="status-icon" id="statusIcon"></span>
<h3 class="status-title" id="statusTitle">上传成功!</h3>
<p class="status-desc" id="statusDesc">文件已成功上传到电脑端</p>
<button class="status-btn" id="statusBtn">完成</button>
</div>
<!-- 状态提示框容器 -->
<div class="status-messages-container" id="statusMessagesContainer"></div>
<!-- 图片预览模态框 -->
<div class="image-preview-modal" id="imagePreviewModal">
@@ -629,7 +655,11 @@
<button class="close-preview" id="closePreview">×</button>
</div>
<script>
<script type="module">
import { FileValidator } from './FileValidator.js'; //文件校验JS
// 创建文件校验器实例
const fileValidator = new FileValidator();
// 获取URL中的参数
const urlParams = new URLSearchParams(window.location.search);
const sessionId = urlParams.get('sessionId');
@@ -655,11 +685,7 @@
const uploadBtn = document.getElementById('uploadBtn');
const loadingOverlay = document.getElementById('loadingOverlay');
const loadingText = document.getElementById('loadingText');
const statusMessage = document.getElementById('statusMessage');
const statusIcon = document.getElementById('statusIcon');
const statusTitle = document.getElementById('statusTitle');
const statusDesc = document.getElementById('statusDesc');
const statusBtn = document.getElementById('statusBtn');
const statusMessagesContainer = document.getElementById('statusMessagesContainer');
const imagePreviewModal = document.getElementById('imagePreviewModal');
const previewImage = document.getElementById('previewImage');
const closePreview = document.getElementById('closePreview');
@@ -673,6 +699,8 @@
let isUploading = false;
let uploadedCount = 0; // 已上传的总文件数量
let statusMessageCount = 0; // 弹窗计数器
// 初始化
function init() {
// 更新限制提示文本
@@ -688,12 +716,28 @@
fileInput.addEventListener('change', handleFileSelect);
clearBtn.addEventListener('click', clearAllFiles);
uploadBtn.addEventListener('click', startUpload);
statusBtn.addEventListener('click', hideStatus);
closePreview.addEventListener('click', () => {
imagePreviewModal.classList.remove('show');
document.body.style.overflow = '';
});
// 添加事件委托来处理状态消息的关闭按钮
statusMessagesContainer.addEventListener('click', (e) => {
if (e.target.matches('[data-action="close"]')) {
const messageId = e.target.dataset.messageId;
closeStatusMessage(messageId);
}
});
// 添加事件委托来处理删除按钮
fileList.addEventListener('click', (e) => {
if (e.target.classList.contains('remove-btn')) {
const name = e.target.dataset.name;
const size = parseInt(e.target.dataset.size);
removeFile(name, size);
}
});
// 图片预览模态框点击外部关闭
imagePreviewModal.addEventListener('click', (e) => {
if (e.target === imagePreviewModal) {
@@ -795,7 +839,7 @@
}
// 处理文件
function handleFiles(files) {
async function handleFiles(files) {
// 检查是否已达到总文件数量上限
if (MAX_FILE_COUNT > 0 && uploadedCount >= MAX_FILE_COUNT) {
showError(`已超过文件上传总数限制(${MAX_FILE_COUNT}个)`);
@@ -816,6 +860,7 @@
}
for (let file of files) {
console.log(file);
// 检查是否已达到本次选择的文件数量上限
if (selectedFiles.length >= MAX_FILE_COUNT) {
showError(`最多只能选择 ${MAX_FILE_COUNT} 个文件,已忽略多余文件`);
@@ -839,12 +884,18 @@
continue;
}
// 添加到文件列表
selectedFiles.push(file);
addFileToList(file);
fileValidator
.validate(file)
.then(() => {
console.log(1111);
selectedFiles.push(file);
addFileToList(file);
updateUI();
})
.catch((err) => {
showError(file.name + err);
});
}
updateUI();
}
// 检查是否为图片文件
@@ -888,12 +939,26 @@
};
return icons[ext] || icons.default;
}
// 移除文件
function removeFile(name, size) {
selectedFiles = selectedFiles.filter((f) => !(f.name === name && f.size === size));
const fileItem = document.querySelector(`.file-item[data-name="${name}"]`);
if (fileItem) {
fileItem.style.animation = 'slideIn 0.3s ease reverse';
setTimeout(() => {
fileItem.remove();
updateUI();
}, 150);
}
}
// 添加文件到列表
function addFileToList(file) {
const li = document.createElement('li');
li.className = 'file-item';
li.dataset.name = file.name;
li.dataset.size = file.size; // 添加 size 到 dataset
// 检查是否为图片
const isImage = isImageType(file) && isImageFile(file.name);
@@ -901,24 +966,24 @@
// 获取文件图标
const fileIcon = getFileIcon(file.name);
// 创建HTML
// 创建HTML(移除 onclick改为 data 属性)
li.innerHTML = `
<div class="file-icon-container" id="icon-${escapeHtml(file.name)}">
${
isImage
? `<img class="thumbnail" src="#" alt="${escapeHtml(file.name)}" />`
: `<div class="file-icon">${fileIcon}</div>`
}
</div>
<div class="file-info">
<div class="file-name">${escapeHtml(file.name)}</div>
<div class="file-size">${formatFileSize(file.size)}</div>
<div class="file-progress">
<div class="file-progress-bar" id="progress-${escapeHtml(file.name)}"></div>
</div>
</div>
<button class="remove-btn" onclick="removeFile('${escapeHtml(file.name)}', ${file.size})">×</button>
`;
<div class="file-icon-container" id="icon-${escapeHtml(file.name)}">
${
isImage
? `<img class="thumbnail" src="#" alt="${escapeHtml(file.name)}" />`
: `<div class="file-icon">${fileIcon}</div>`
}
</div>
<div class="file-info">
<div class="file-name">${escapeHtml(file.name)}</div>
<div class="file-size">${formatFileSize(file.size)}</div>
<div class="file-progress">
<div class="file-progress-bar" id="progress-${escapeHtml(file.name)}"></div>
</div>
</div>
<button class="remove-btn" data-name="${escapeHtml(file.name)}" data-size="${file.size}">×</button>
`;
fileList.appendChild(li);
@@ -934,7 +999,6 @@
});
}
}
// 生成缩略图
function generateThumbnail(file, containerId) {
const reader = new FileReader();
@@ -977,20 +1041,6 @@
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
// 移除文件
function removeFile(name, size) {
selectedFiles = selectedFiles.filter((f) => !(f.name === name && f.size === size));
const fileItem = document.querySelector(`.file-item[data-name="${name}"]`);
if (fileItem) {
fileItem.style.animation = 'slideIn 0.3s ease reverse';
setTimeout(() => {
fileItem.remove();
updateUI();
}, 150);
}
}
// 清空所有文件
function clearAllFiles() {
selectedFiles = [];
@@ -1197,43 +1247,125 @@
document.body.style.overflow = '';
}
// 显示成功提示
function showSuccess(message) {
statusMessage.className = 'status-message status-success';
statusIcon.textContent = '✅';
statusTitle.textContent = '上传成功!';
statusDesc.textContent = message;
statusMessage.style.display = 'block';
document.body.style.overflow = 'hidden';
// 创建状态提示
function createStatusMessage(type, title, desc, autoClose = false, closeTime = 3000) {
// 创建唯一的ID
const messageId = 'status-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
// 计算当前弹窗的偏移量每个弹窗下移20px
const offset = statusMessageCount * 40;
// 创建弹窗HTML添加自定义的偏移样式
const messageHtml = `
<div class="status-message status-${type}" id="${messageId}"
data-message-id="${messageId}"
style="margin-top: ${offset}px;">
<button class="close-btn" data-action="close" data-message-id="${messageId}">×</button>
<span class="status-icon">${type === 'success' ? '✅' : '❌'}</span>
<h3 class="status-title">${escapeHtml(title)}</h3>
<p class="status-desc">${escapeHtml(desc)}</p>
<button class="status-btn" data-action="close" data-message-id="${messageId}">完成</button>
</div>
`;
// 添加到容器
statusMessagesContainer.innerHTML += messageHtml;
// 显示弹窗
const messageElement = document.getElementById(messageId);
messageElement.style.display = 'block';
// 增加弹窗计数
statusMessageCount++;
// 自动关闭
if (autoClose) {
setTimeout(() => {
closeStatusMessage(messageId);
}, closeTime);
}
// 限制最大显示数量最多显示5个
const messages = statusMessagesContainer.querySelectorAll('.status-message');
if (messages.length > 5) {
// 移除最早的消息
const oldestMessage = messages[0];
closeStatusMessage(oldestMessage.id);
}
return messageId;
}
// 显示错误提示
function showError(message) {
statusMessage.className = 'status-message status-error';
statusIcon.textContent = '❌';
statusTitle.textContent = '出错了';
statusDesc.textContent = message;
statusMessage.style.display = 'block';
document.body.style.overflow = 'hidden';
// 关闭状态提示
function closeStatusMessage(messageId) {
const messageElement = document.getElementById(messageId);
if (messageElement) {
// 添加淡出动画
messageElement.style.animation = 'popUp 0.3s ease reverse';
messageElement.style.opacity = '0';
setTimeout(() => {
messageElement.remove();
// 减少弹窗计数
statusMessageCount--;
// 重新计算并更新剩余弹窗的位置
updateRemainingStatusMessages();
// 检查是否还有消息显示
const remainingMessages = statusMessagesContainer.querySelectorAll('.status-message');
if (remainingMessages.length === 0) {
document.body.style.overflow = '';
}
}, 300);
}
}
// 隐藏状态提示
function hideStatus() {
statusMessage.style.display = 'none';
// 更新剩余弹窗的位置
function updateRemainingStatusMessages() {
const messages = statusMessagesContainer.querySelectorAll('.status-message');
messages.forEach((message, index) => {
const offset = index * 20; // 每个弹窗下移20px
message.style.marginTop = `${offset}px`;
});
}
// 隐藏所有状态提示
function hideAllStatus() {
const messages = statusMessagesContainer.querySelectorAll('.status-message');
messages.forEach((message) => {
closeStatusMessage(message.id);
});
document.body.style.overflow = '';
}
// 防止页面滚动
// 显示成功提示(创建新弹窗)
function showSuccess(message, options = {}) {
const { autoClose = true, closeTime = 3000 } = options;
return createStatusMessage('success', '上传成功!', message, autoClose, closeTime);
}
// 显示错误提示(创建新弹窗)
function showError(message, options = {}) {
const { autoClose = false, closeTime = 5000 } = options;
return createStatusMessage('error', '出错了', message, autoClose, closeTime);
}
// 修改防止页面滚动函数
function preventScroll(e) {
const messages = statusMessagesContainer.querySelectorAll('.status-message');
const hasMessages = messages.length > 0;
if (
loadingOverlay.classList.contains('show') ||
statusMessage.style.display === 'block' ||
hasMessages ||
imagePreviewModal.classList.contains('show')
) {
e.preventDefault();
}
}
// HTML转义
function escapeHtml(text) {
const div = document.createElement('div');

View File

@@ -42,7 +42,7 @@ export class FileValidator {
* 构造函数
* @param {Object} options 配置项
* @param {number} [options.maxSizeMB=10] 最大文件大小 (MB)
* @param {string[]} [options.allowedExtensions = ['png','jpg','jpeg','gif','webp','svg','bmp','ico','pdf','doc','docx','xls','xlsx','csv','ppt','pptx','txt','md']] 允许的扩展名列表 (如 ['jpg', 'png']),默认允许全部已知类型
* @param {string[]} [options.allowedExtensions = []] 允许的扩展名列表 (如 ['jpg', 'png']),默认允许全部已知类型
*/
version = '1.0.0';
constructor(options = {}) {