170 lines
6.0 KiB
JavaScript
170 lines
6.0 KiB
JavaScript
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';
|
||
// });
|