/** * 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 = ['png','jpg','jpeg','gif','webp','svg','bmp','ico','pdf','doc','docx','xls','xlsx','csv','ppt','pptx','txt','md']] 允许的扩展名列表 (如 ['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} */ 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'; // });