JavaScript验证文件格式

一、利用唯一文件类型说明符限制上传文件类型

唯一文件类型说明符,表示在 file 类型的 input 元素中用户可以选择的文件类型。每个唯一文件类型说明符可以采用下列形式之一:

  • 一个以英文句号(“.”)开头的合法的不区分大小写的文件名扩展名。例如:.jpg.pdf.doc
  • 一个不带扩展名的 MIME 类型字符串。
  • 字符串 audio/*,表示“任何音频文件”。
  • 字符串 video/*,表示“任何视频文件”。
  • 字符串 image/*,表示“任何图片文件”。

accept 属性的值是包含一个或多个(用逗号分隔)唯一文件类型说明符的字符串。例如,一个文件选择器需要能被表示成一张图片的内容,包括标准的图片格式和 PDF 文件,大概是这样的:

1
<input type="file" accept="image/*,.pdf" />

这种方式,在 Mac OS 系统中表现为:不被允许的文件类型在上传文件框中呈现为灰色不可选状态,无法选中上传。在 Windows 系统中表现为:上传文件框中只展示可被上传的文件类型。而这个限制在 Windows 系统中可以被轻松绕过(选择上传文件框右下角的 All Files 选项)。

input-windows.png

二、利用 MIME 类型验证文件类型

媒体类型(通常称为 Multipurpose Internet Mail ExtensionsMIME 类型)是一种标准,用来表示文档、文件或字节流的性质和格式。

通用结构(语法):

1
type/subtype

常见 MIME 类型列表

我们可以在用户上传文件后,从 File 对象的 type 字段拿到文件的 MIME 类型,来验证文件类型是否合法,这种方式可以规避掉唯一文件类型说明符限制上传文件类型被 Windows 系统绕过的缺点。

例如,我们来验证用户上传 .png 类型的图片:

1
2
3
4
5
6
7
8
const input = document.querySelector('input')
input.addEventListener('change', function() {
const [file] = input.files
if (file.type !== 'image/jpeg') {
alert('请上传 .png 文件')
input.value = ''
}
})

这种方式同样也有缺点。用户可以修改文件的后缀名来实现修改文件 MIME 类型的方法,绕开检查,这种方式相对来说安全性也不是很高。

三、利用文件 Magic number 验证文件类型

Magic number ,又称之为 魔术数字 。在文件中,魔术数字指:

在特定文件格式中加入固定数值和固定字符串,然后便可以通过检查文件是否包含这些数据来快速地识别文件格式。

例如:GIF文件开头会包含GIF89a(47 49 46 38 39 61)或GIF87a(47 49 46 38 37 61)这两种字符串。

我们可以利用这个特性,通过读取并比对文件头部的 Macic number 来验证文件的格式,大多数文件类型都能做到在同一种文件类型下的不同文件,它的 Magic number 一致,但是也有例外如:.csv.json 格式的文件。

并非所有类型的文件都有 Magic number ,所以这也不是 100%可靠的方式。

还是上传文件的场景,我们在拿到用户上传的文件后,可以利用 FileReader 接口提供的 readAsArrayBuffer 方法来读取文件的内容,这会返回一个 ArrayBuffer 对象以表示所读取文件的数据,我们将这个 ArrayBuffer 对象转换成16进制的字符串,去和对应类型的 Magic number 做比对,即可分辨出用户上传的文件是否为目标类型。

各文件类型的 Magic number 示例可以在这里查看到。

下面我们拆解流程来一步步看。首先定义一个 ArrayBuffer 对象转16进制的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* ArrayBuffer对象转16进制字符串
* @param {ArrayBuffer} arrayBuffer
* @return {String} 转换后的16进制字符串
*/
function arrayBufferToHex(arrayBuffer) {
return Array.prototype.map.call(
new Uint8Array(arrayBuffer),
function (bit) {
return ('00' + bit.toString(16)).slice(-2)
}
).join('')
}

然后,我们模拟一个 <input type="file" /> 上传标签拿到用户上传文件后并获取文件类型 Magic number 的过程:

1
2
3
4
5
6
7
8
9
10
11
const input = document.querySelector('input')
input.addEventListener('change', function() {
const [file] = input.files
const reader = new FileReader()
reader.onloadend = function() {
const result = arrayBufferToHex(reader.result)
console.log(result) // 最终拿到的文件 Magic number: 89504e470d0a1a0a
}
// 这里我们假设用户上传的是 .png 格式的文件,只取文件前8字节的数据
reader.readAsArrayBuffer(file.slice(0, 8))
})

这里需要注意,通常文件类型 Magic number 会标识在文件的最前面(具体类型查看维基百科示例),例如 .png 格式的文件,示例里描述说该类型的文件以8字节的签名文件开始(89 50 4E 47 0D 0A 1A 0A),以此标识为 .png 格式。所以,如果要验证这种类型的文件,我们只需要利用 File 接口的 slice 方法提取文件前8字节的数据做转换判断即可,而无需将整个文件都参与到验证中来,这样在处理较大文件时会节省很多性能消耗。

最后,我们只需要将最后转换好的16进制 Magic number 与 .png 格式文件对应的 Magic number 比对即可。

在实际使用中,我们可以把验证具体类型文件的需求封装成具体的方法,应用在项目中即可。我们来封装一个 isPdf 方法,来验证用户上传的文件是否为 pdf 格式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/**
* ArrayBuffer对象转16进制字符串
* @param {ArrayBuffer} arrayBuffer
* @return {String} 转换后的16进制字符串
*/
function arrayBufferToHex(arrayBuffer) {
return Array.prototype.map.call(
new Uint8Array(arrayBuffer),
function (bit) {
return ('00' + bit.toString(16)).slice(-2)
}
).join('')
}

async function isPdf(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onloadend = function() {
const fileMagicNumber = arrayBufferToHex(reader.result)
resolve(fileMagicNumber === '25504446')
}
reader.onerror = reject
reader.readAsArrayBuffer(file.slice(0, 4)) // pdf类型的文件只需要4个字节内容即可判断
})
}

const input = document.querySelector('input')
input.addEventListener('change', async function() {
const [file] = input.files
const result = await isPdf(file)
if (!result) {
alert('请上传 pdf 文件')
input.value = ''
}
})
0%