From ce597b182db9d091b8d98bdda8e713c468929a2a Mon Sep 17 00:00:00 2001 From: Apcallover <1503963513@qq.com> Date: Fri, 19 Dec 2025 12:06:35 +0800 Subject: [PATCH] =?UTF-8?q?flat=20=E6=96=87=E4=BB=B6=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- utils/fileValidator.js | 318 +++++++++++++++++------------------------ 1 file changed, 128 insertions(+), 190 deletions(-) diff --git a/utils/fileValidator.js b/utils/fileValidator.js index 305f176..25fe856 100644 --- a/utils/fileValidator.js +++ b/utils/fileValidator.js @@ -1,272 +1,210 @@ -/** - * 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', + 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" }; - -// ========================================== -// 2. 核心类定义 -// ========================================== 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 = {}) { - // 配置大小 (默认 10MB) this.maxSizeMB = options.maxSizeMB || 10; - - // 配置允许的类型 - // 如果传入了 allowedExtensions,则只使用传入的;否则使用全部 KNOWN_SIGNATURES if (options.allowedExtensions && Array.isArray(options.allowedExtensions)) { this.allowedConfig = {}; - options.allowedExtensions.forEach((ext) => { + options.allowedExtensions.forEach(ext => { const key = ext.toLowerCase(); if (KNOWN_SIGNATURES[key]) { - this.allowedConfig[key] = KNOWN_SIGNATURES[key]; + this.allowedConfig[key] = KNOWN_SIGNATURES[key] } else { - console.warn(`[FileValidator] 未知的文件类型: .${key},已忽略`); + console.warn(`[FileValidator] 未知的文件类型: .${key},已忽略`) } - }); + }) } else { this.allowedConfig = { ...KNOWN_SIGNATURES - }; + } } } - - /** - * 改进版:检查是否为有效的 UTF-8 文本 - */ _isValidUTF8(buffer) { try { - // fatal: true 会在遇到无效编码时抛出错误,而不是用 替换 - const decoder = new TextDecoder('utf-8', { + const decoder = new TextDecoder("utf-8", { fatal: true }); decoder.decode(buffer); - return true; + return true } catch (e) { - return false; + return false } } - - /** - * 辅助:ArrayBuffer 转 Hex 字符串 - */ _bufferToHex(buffer) { - return Array.prototype.map - .call(new Uint8Array(buffer), (x) => ('00' + x.toString(16)).slice(-2)) - .join('') - .toUpperCase(); + 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 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++; + inQuote = !inQuote + } else if (char === "\n" && !inQuote) { + rowCount++ } } - - // 处理最后一行没有换行符的情况(且文件不为空) - if (len > 0 && text[len - 1] !== '\n') { - rowCount++; + if (len > 0 && text[len - 1] !== "\n") { + rowCount++ } - - return rowCount; + return rowCount } - - - /** - * 【核心】:校验纯文本内容 - * 1. 检查是否包含乱码 (非 UTF-8) - * 2. 针对特定格式 (JSON) 进行语法解析 - */ _validateTextContent(buffer, extension) { - // 1. 尝试解码为 UTF-8 - let contentStr = ''; + let contentStr = ""; try { - const decoder = new TextDecoder('utf-8', { + const decoder = new TextDecoder("utf-8", { fatal: true }); - contentStr = decoder.decode(buffer); + contentStr = decoder.decode(buffer) } catch (e) { - // 如果解码失败,说明包含非文本的二进制数据 - console.warn('UTF-8 解码失败', e); - return false; + console.warn("UTF-8 解码失败", e); + return false } - - // 2. 检查是否存在过多的空字符 (二进制文件特征) - // 某些二进制文件可能勉强通过 UTF-8 解码,但会包含大量 \0 - if (contentStr.includes('\u0000')) { - return false; + if (contentStr.includes("\0")) { + return false } - - // 3. 针对特定后缀进行语法校验 (可选,更严格) - if (extension === 'json') { + if (extension === "json") { try { - JSON.parse(contentStr); + JSON.parse(contentStr) } catch (e) { - console.warn('无效的 JSON 格式'); - return false; + console.warn("无效的 JSON 格式"); + return false } } - - // 如果是 CSV,可以简单检查行数(可选) - // if (extension === 'csv') { ... } - - return true; + return true } - - /** - * 执行校验 - * @param {File} file 文件对象 - * @returns {Promise} - */ validate(file) { return new Promise((resolve, reject) => { - // 1. 基础对象检查 - if (!file || !file.name) return reject('无效的文件对象'); - - // 2. 大小检查 + if (!file || !file.name) return reject("无效的文件对象"); if (file.size > this.maxSizeMB * 1024 * 1024) { - return reject(`文件大小超出限制 (最大 ${this.maxSizeMB}MB)`); + return reject(`文件大小超出限制 (最大 ${this.maxSizeMB}MB)`) } - - // 3. 后缀名检查 const fileName = file.name.toLowerCase(); - const extension = fileName.substring(fileName.lastIndexOf('.') + 1); - - // 检查是否在配置的白名单中 + const extension = fileName.substring(fileName.lastIndexOf(".") + 1); const expectedMagic = this.allowedConfig[extension]; if (!expectedMagic) { - return reject(`不支持的文件格式: .${extension}`); + return reject(`不支持的文件格式: .${extension}`) } - - // 4. 读取二进制头进行魔数校验 - const reader = new FileReader(); - - reader.onload = (e) => { + const reader = new FileReader; + reader.onload = e => { const buffer = e.target.result; let isSafe = false; - - // 分支处理:纯文本 vs 二进制 - if (expectedMagic === 'TYPE_TEXT') { + if (expectedMagic === "TYPE_TEXT") { if (this._validateTextContent(buffer, extension)) { - isSafe = true; + isSafe = true } else { - // 细化报错信息 - if (extension === 'json') { - return reject(`文件异常:不是有效的 JSON 文件`); + if (extension === "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); - // 注意:这里通常把表头也算作 1 行,如果不算表头可以将 limit + 1 if (rows > this.csvMaxRows) { - return reject(`CSV 行数超出限制 (当前 ${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; + isSafe = true } else { - return reject(`文件可能已被篡改 (真实类型与 .${extension} 不符)`); + return reject(`文件可能已被篡改 (真实类型与 .${extension} 不符)`) } } - - if (isSafe) resolve(true); + if (isSafe) resolve(true) }; - - reader.onerror = () => reject('文件读取失败,无法校验'); - - // 读取前 1KB 进行判断 - if (expectedMagic === 'TYPE_TEXT' && extension === 'json') { - // JSON 必须读全量才能 parse,建议限制 JSON 文件大小 - reader.readAsArrayBuffer(file); + reader.onerror = () => reject("文件读取失败,无法校验"); + if (expectedMagic === "TYPE_TEXT" && extension === "json") { + reader.readAsArrayBuffer(file) } else { - // 图片/普通文本 读取前 2KB 足够判断头部和编码特征 - reader.readAsArrayBuffer(file.slice(0, 2048)); + reader.readAsArrayBuffer(file.slice(0, 2048)) } - }); + }) } } -// 【demo】 -// 如果传入了 allowedExtensions,则只使用传入的;否则使用全部 KNOWN_SIGNATURES -// const imageValidator = new FileValidator({ -// maxSizeMB: 5, -// allowedExtensions: ['png', 'jpg', 'jpeg'], +// FileValidator 使用文档 +// FileValidator 是一个用于浏览器端的 JavaScript 文件校验类。它提供了比简单的后缀名检查更安全的文件验证机制。 +// 主要特性 +// 真实类型检测:通过读取文件二进制头部的“魔数”来验证文件类型,防止后缀名伪造。 +// 文本内容安全:检测文本文件是否为有效的 UTF-8 编码,防止乱码或二进制文件伪装。 +// JSON 语法校验:针对 JSON 文件,会自动尝试解析以确保格式正确。 +// CSV 行数限制:支持限制 CSV 文件的最大行数(需手动配置)。 +// 大小限制:内置文件大小检查(MB)。 +// 按需配置:支持自定义允许的文件扩展名列表。 +// 使用方法 + +// import { FileValidator } from './fileValidator.js'; + + +// 1. 初始化校验器 +// const validator = new FileValidator({ +// maxSizeMB: 5, // 限制最大 5MB +// allowedExtensions: ['jpg', 'png', 'pdf', 'docx'] // 仅允许这些格式 // }); -// 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'; -// }); \ No newline at end of file +// // 2. 获取文件对象 (通常来自 input[type="file"]) +// const fileInput = document.getElementById('file-upload'); + +// fileInput.addEventListener('change', async (event) => { +// const file = event.target.files[0]; + +// if (!file) return; + +// try { +// // 3. 执行校验 +// await validator.validate(file); +// console.log('✅ 文件校验通过,可以上传'); + +// // 在这里执行你的上传逻辑... + +// } catch (errorMessage) { +// console.error('❌ 校验失败:', errorMessage); +// alert(errorMessage); +// } +// }); + +// 配置项: +// maxSizeMB: 允许的最大文件大小,单位 MB。 +// allowedExtensions: 允许的文件后缀列表(不区分大小写)。如果不传,则允许所有内置支持的格式。 + +// // 允许所有支持的格式,限制 20MB +// const v1 = new FileValidator({ maxSizeMB: 20 }); + +// // 仅允许图片 +// const v2 = new FileValidator({ +// allowedExtensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'] +// }); + +// 设置 CSV 最大允许 1000 行 +// validator.csvMaxRows = 1000; + + +// 分类,扩展名,检测方式 +// 图片,"png, jpg, jpeg, gif, webp",二进制头签名 (Magic Number) +// 文档,"pdf, docx, xlsx, pptx, doc, xls, ppt",二进制头签名 (Magic Number) +// 文本,"txt, csv, md",UTF-8 编码检测 + 无 Null 字节检测 +// 数据,json,UTF-8 编码检测 + JSON.parse 语法校验 \ No newline at end of file