1.前言
对于文件上传,一般是对上传文件的后缀名进行格式校验,但是由于文件的后缀可以手动更改,后缀名校验不是一种严格有效的文件校验方式。
如果想要对上传文件进行严格的格式校验,则需要通过文件头进行校验,文件头是位于文件开头的一段承担一定任务的数据,其作用就是为了描述一个文件的一些重要的属性,其可以作为是一类特定文件的标识。
2.实战演练
本文基于AOP实现文件上传格式校验,同时支持文件后缀校验和文件头校验两种方式。
2.1文件类型枚举类
下面列举了常用的文件类型和文件头信息:
package com.zxh.common.enums; import lombok.Getter; import org.springframework.lang.NonNull; /** * @description 文件类型 */ @Getter public enum FileType { /** * JPEG (jpg) */ JPEG("JPEG", "FFD8FF"), JPG("JPG", "FFD8FF"), /** * PNG */ PNG("PNG", "89504E47"), /** * GIF */ GIF("GIF", "47494638"), /** * TIFF (tif) */ TIFF("TIF", "49492A00"), /** * Windows bitmap (bmp) */ BMP("BMP", "424D"), /** * 16色位图(bmp) */ BMP_16("BMP", "424D228C010000000000"), /** * 24位位图(bmp) */ BMP_24("BMP", "424D8240090000000000"), /** * 256色位图(bmp) */ BMP_256("BMP", "424D8E1B030000000000"), /** * XML */ XML("XML", "3C3F786D6C"), /** * HTML (html) */ HTML("HTML", "68746D6C3E"), /** * Microsoft Word/Excel 注意:word 和 excel的文件头一样 */ XLS("XLS", "D0CF11E0"), /** * Microsoft Word/Excel 注意:word 和 excel的文件头一样 */ DOC("DOC", "D0CF11E0"), /** * Microsoft Word/Excel 2007以上版本文件 注意:word 和 excel的文件头一样 */ DOCX("DOCX", "504B0304"), /** * Microsoft Word/Excel 2007以上版本文件 注意:word 和 excel的文件头一样 504B030414000600080000002100 */ XLSX("XLSX", "504B0304"), /** * Adobe Acrobat (pdf) 255044462D312E */ PDF("PDF", "25504446"); /** * 后缀 大写字母 */ private final String suffix; /** * 魔数 */ private final String magicNumber; FileType(String suffix, String magicNumber) { this.suffix = suffix; this.magicNumber = magicNumber; } @NonNull public static FileType getBySuffix(String suffix) { for (FileType fileType : values()) { if (fileType.getSuffix().equals(suffix.toUpperCase())) { return fileType; } } throw new IllegalArgumentException("不支持的文件后缀 : " + suffix); } }
getBySuffix()
方法是根据后缀名获取文件的枚举类型。
2.2自定义文件校验注解
package com.zxh.common.annotation; import com.zxh.common.enums.FileType; import java.lang.annotation.*; /** * @description 文件校验 */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) @Documented public @interface FileCheck { /** * 校验不通过提示信息 * * @return */ String message() default "不支持的文件格式"; /** * 校验方式 */ CheckType type() default CheckType.SUFFIX; /** * 支持的文件后缀 * * @return */ String[] supportedSuffixes() default {}; /** * 支持的文件类型 * * @return */ FileType[] supportedFileTypes() default {}; enum CheckType { /** * 仅校验后缀 */ SUFFIX, /** * 校验文件头(魔数) */ MAGIC_NUMBER, /** * 同时校验后缀和文件头 */ SUFFIX_MAGIC_NUMBER } }
可通过supportedSuffixes
或者supportedFileTypes
指定支持的上传文件格式,如果同时指定了这两个参数,则最终支持的格式是两者的合集。文件格式校验支持文件后缀名校验和文件头校验,两者也可同时支持,默认采用文件后缀名进行校验。
2.3切面校验
package com.zxh.common.aspect; import cn.hutool.core.io.FileUtil; import com.zxh.common.annotation.FileCheck; import com.zxh.common.enums.FileType; import lombok.extern.slf4j.Slf4j; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.ArrayUtils; import org.aspectj.lang.JoinPoint; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Before; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.io.InputStream; import java.util.Arrays; import java.util.HashSet; import java.util.Set; /** * @description 文件校验切面 */ @Component @Slf4j @Aspect @ConditionalOnProperty(prefix = "file-check", name = "enabled", havingValue = "true") public class FileCheckAspect { @Before("@annotation(annotation)") public void before(JoinPoint joinPoint, FileCheck annotation) { final String[] suffixes = annotation.supportedSuffixes(); final FileCheck.CheckType type = annotation.type(); final FileType[] fileTypes = annotation.supportedFileTypes(); final String message = annotation.message(); if (ArrayUtils.isEmpty(suffixes) && ArrayUtils.isEmpty(fileTypes)) { return; } Object[] args = joinPoint.getArgs(); //文件后缀转成set集合 Set<String> suffixSet = new HashSet<>(Arrays.asList(suffixes)); for (FileType fileType : fileTypes) { suffixSet.add(fileType.getSuffix()); } //文件类型转成set集合 Set<FileType> fileTypeSet = new HashSet<>(Arrays.asList(fileTypes)); for (String suffix : suffixes) { fileTypeSet.add(FileType.getBySuffix(suffix)); } //对参数是文件的进行校验 for (Object arg : args) { if (arg instanceof MultipartFile) { doCheck((MultipartFile) arg, type, suffixSet, fileTypeSet, message); } else if (arg instanceof MultipartFile[]) { for (MultipartFile file : (MultipartFile[]) arg) { doCheck(file, type, suffixSet, fileTypeSet, message); } } } } //根据指定的检查类型对文件进行校验 private void doCheck(MultipartFile file, FileCheck.CheckType type, Set<String> suffixSet, Set<FileType> fileTypeSet, String message) { if (type == FileCheck.CheckType.SUFFIX) { doCheckSuffix(file, suffixSet, message); } else if (type == FileCheck.CheckType.MAGIC_NUMBER) { doCheckMagicNumber(file, fileTypeSet, message); } else { doCheckSuffix(file, suffixSet, message); doCheckMagicNumber(file, fileTypeSet, message); } } //验证文件头信息 private void doCheckMagicNumber(MultipartFile file, Set<FileType> fileTypeSet, String message) { String magicNumber = readMagicNumber(file); String fileName = file.getOriginalFilename(); String fileSuffix = FileUtil.extName(fileName); for (FileType fileType : fileTypeSet) { if (magicNumber.startWith(fileType.getMagicNumber()) && fileType.getSuffix().toUpperCase().equalsIgnoreCase(fileSuffix)) { return; } } log.error("文件头格式错误:{}",magicNumber); throw new RuntimeException( message); } //验证文件后缀 private void doCheckSuffix(MultipartFile file, Set<String> suffixSet, String message) { String fileName = file.getOriginalFilename(); String fileSuffix = FileUtil.extName(fileName); for (String suffix : suffixSet) { if (suffix.toUpperCase().equalsIgnoreCase(fileSuffix)) { return; } } log.error("文件后缀格式错误:{}", message); throw new RuntimeException( message); } //读取文件,获取文件头 private String readMagicNumber(MultipartFile file) { try (InputStream is = file.getInputStream()) { byte[] fileHeader = new byte[4]; is.read(fileHeader); return byteArray2Hex(fileHeader); } catch (IOException e) { log.error("文件读取错误:{}", e); throw new RuntimeException( "读取文件失败!"); } finally { IOUtils.closeQuietly(); } } private String byteArray2Hex(byte[] data) { StringBuilder stringBuilder = new StringBuilder(); if (ArrayUtils.isEmpty(data)) { return null; } for (byte datum : data) { int v = datum & 0xFF; String hv = Integer.toHexString(v).toUpperCase(); if (hv.length() < 2) { stringBuilder.append(0); } stringBuilder.append(hv); } String result = stringBuilder.toString(); return result; } }
这里文件头的获取方式是取前4个字节然后转成十六进制小写转大写,然后判断与对应格式枚举类的文件头开头是否一致,如果一致就认为格式是正确的。
2.4使用注解进行验证
在controller中文件上传的方法上使用注解
ackage com.zxh.controller; import com.zxh.Result; import com.zxh.annotation.FileCheck; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; @RestController public class FileController { //只校验后缀 @PostMapping("/uploadFile") @FileCheck(message = "不支持的文件格式", supportedSuffixes = {"png", "jpg", "jpeg"}) public ApiResult uploadFile(@RequestParam("file") MultipartFile file) throws IOException { return Result.success(); } //只校验文件头 @PostMapping("/uploadFile2") @FileCheck(message = "不支持的文件格式",supportedFileTypes = {FileType.PNG, FileType.JPG, FileType.JPEG}), type = FileCheck.CheckType.MAGIC_NUMBER) public ApiResult uploadFile(@RequestParam("file") MultipartFile file) throws IOException { return Result.success(); } //同时校验后缀和文件头 @PostMapping("/uploadFile3") @FileCheck(message = "不支持的文件格式", supportedSuffixes = {"png", "jpg", "jpeg"}, type = FileCheck.CheckType.SUFFIX_MAGIC_NUMBER), supportedFileTypes = {FileType.PNG, FileType.JPG, FileType.JPEG}) public ApiResult uploadFile(@RequestParam("file") MultipartFile file) throws IOException { return Result.success(); } }
上面同时列出了三个方式,根据需要进行选择,一般使用第三种进行完整的校验。