一、简介

1、概述

文件上传是Web项目的一个基本功能,一般是通过上传文件的后缀名进行格式校验,但是由于文件的后缀是可以手动更改的,黑客可以通过修改后缀名入侵文件服务器,因此后缀名校验不是一种严格有效的文件校验方式。如果想要对上传文件进行严格的格式校验,则需要通过文件头进行校验,即魔数文件头是位于文件开头的一段承担一定任务的数据,一般都在开头的部分,其作用就是为了描述一个文件的一些重要的属性,其可以作为是一类特定文件的标识。

2、环境与技术介绍

SpringBoot2.5.6,AOP思想

使用切面编程,在文件上传之前,通过自定义注解首先进行自定义文件类型判断,若判断不通过,则通过全全局自定义异常返回,通过所有检查后才进行文件的上传,同时通过ConditionalOnProperty注解可以在application.yml中进行注解文件的打开或关闭,即校验文件功能的开启与关闭。

3、简单的文件上传

    @Value("${file.staticPath}")
    private String staticPath;

    @Value("${file.uploadFolder}")
    private String uploadFolder;
    /**
     * 上传文件,比较通用的方法,这里我写在这里可以进行参考修改
     * 其他方法
     */
    public String uploadFile(MultipartFile multipartFile, String dir) {
        try {
            //上传的文件:aaa.jpg
            String realFileName = multipartFile.getOriginalFilename();
            //2:藏图文件名的后级
            String imgSuffix = realFileName.substring(realFileName.lastIndexOf("."));
            //3:生成的唯一的文件名:能不能用中文名:不能因为统一用英文命名。
            String newFileName = UUID.randomUUID().toString() + imgSuffix;
            //4:日期目录
            SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy/MM/dd");
            String datePath = dateFormat.format(new Date());
            //5:服务路径
            String serverName = uploadFolder;
            //6:指定文件上传以后的目录
            File targetPath = new File(serverName + dir, datePath);
            if (!targetPath.exists()) {
                targetPath.mkdirs();
            }
            //6:指定文件上传以后的服务器的完整的文件名
            File targetFileName = new File(targetPath, newFileName);
            //7:文件上传到指定的目录
            multipartFile.transferTo(targetFileName);
            // 返回的自由选择,可以选择Map进行返回
            String fileName = dir + File.separator + datePath + File.separator + newFileName;
            return staticPath + File.separator + fileName;
        } catch (IOException e) {
            e.printStackTrace();
            return "fail";
        }
    }

yml中进行配置

file:
  staticPatternPath: /upload/**
  uploadFolder: /www/upload/
  staticPath: http://www.shawn22.xyz:8080

二、文件校验与上传实战

1、 前提准备

SpringBoot Log4j2日志

SpringBoot自定义全局异常

2、 文件枚举类

包含了每种文件的后缀名与头部魔数

/**
 * 文件类型
 * 文件魔数
 * @author Shawn
 * @date 2021/11/23
 */
@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"),

    /**
     * CAD  (dwg)
     */
    DWG("DWG", "41433130"),

    /**
     * Adobe photoshop  (psd)
     */
    PSD("PSD", "38425053"),

    /**
     * Rich Text Format  (rtf)
     */
    RTF("RTF", "7B5C727466"),

    /**
     * XML
     */
    XML("XML", "3C3F786D6C"),

    /**
     * HTML (html)
     */
    HTML("HTML", "68746D6C3E"),

    /**
     * Email [thorough only] (eml)
     */
    EML("EML", "44656C69766572792D646174653A"),

    /**
     * Outlook Express (dbx)
     */
    DBX("DBX", "CFAD12FEC5FD746F "),

    /**
     * Outlook (pst)
     */
    PST("", "2142444E"),

    /**
     * doc;xls;dot;ppt;xla;ppa;pps;pot;msi;sdw;db
     */
    OLE2("OLE2", "0xD0CF11E0A1B11AE1"),

    /**
     * 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"),

    /**
     * Microsoft Access (mdb)
     */
    MDB("MDB", "5374616E64617264204A"),

    /**
     * Adobe Acrobat (pdf) 255044462D312E
     */
    PDF("PDF", "25504446"),

    /**
     * Windows Password  (pwl)
     */
    PWL("PWL", "E3828596"),

    /**
     * WAVE (wav)
     */
    WAV("WAV", "57415645"),

    /**
     * AVI
     */
    AVI("AVI", "41564920"),

    /**
     * Real Audio (ram)
     */
    RAM("RAM", "2E7261FD"),

    /**
     * Real Media (rm) rmvb/rm相同
     */
    RM("RM", "2E524D46"),

    /**
     * Real Media (rm) rmvb/rm相同
     */
    RMVB("RMVB", "2E524D46000000120001"),

    /**
     * MPEG (mpg)
     */
    MPG("MPG", "000001BA"),

    /**
     * Quicktime  (mov)
     */
    MOV("MOV", "6D6F6F76"),

    /**
     * MIDI (mid)
     */
    MID("MID", "4D546864"),

    /**
     * MP4
     */
    MP4("MP4", "00000020667479706D70"),

    /**
     * MP3
     */
    MP3("MP3", "49443303000000002176"),

    /**
     * FLV
     */
    FLV("FLV", "464C5601050000000900"),

    /**
     * torrent
     */
    TORRENT("TORRENT", "6431303A637265617465"),

    /**
     * JSP Archive
     */
    JSP("JSP", "3C2540207061676520"),

    /**
     * JAVA Archive
     */
    JAVA("JAVA", "7061636B61676520"),

    /**
     * CLASS Archive
     */
    CLASS("CLASS", "CAFEBABE0000002E00"),

    /**
     * JAR Archive
     */
    JAR("JAR", "504B03040A000000"),

    /**
     * MF Archive
     */
    MF("MF", "4D616E69666573742D56"),

    /**
     * EXE Archive
     */
    EXE("EXE", "4D5A9000030000000400"),

    /**
     * ELF Executable
     */
    ELF("ELF", "7F454C4601010100"),

    /**
     * Lotus 123 v1
     */
    WK1("WK1", "2000604060"),

    /**
     * Lotus 123 v3
     */
    WK3("WK3", "00001A0000100400"),

    /**
     * Lotus 123 v5
     */
    WK4("WK4", "00001A0002100400"),

    /**
     * Lotus WordPro v9
     */
    LWP("LWP", "576F726450726F"),

    /**
     * Sage(sly.or.srt.or.slt;sly;srt;slt)
     */
    SLY("SLY", "53520100");

    /**
     * 后缀 大写字母
     */
    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) throws FileUploadException {
        for (FileType fileType : FileType.values()) {
            if (fileType.getSuffix().equals(suffix.toUpperCase())) {
                return fileType;
            }
        }
        throw new FileUploadException("不支持的文件后缀 : " + suffix);
    }
}

3、 自定义文件校验注解

/**
 * 文件检查
 *
 * @author Shawn
 * @date 2021/11/23
 */
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Target(ElementType.METHOD)
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
    }
}

4、 文件校验切面

/**
 * @author Shawn
 * @date 2021年11月23日9:32
 * prefix为配置文件中的前缀,
 * name为配置的名字
 * havingValue是与配置的值对比值,当两个值相同返回true,配置类生效
 * 需要在yml中进行配置:前缀+名字,值为true,表示该配置文件生效
 **/
@Aspect
@Slf4j
@Component
@ConditionalOnProperty(prefix = "file-check", name = "enabled", havingValue = "true")
public class FileCheckAspect {

    /**
     * 目标方法:被@FileCheck注解的方法即为目标方法
     * 其中@annotation中的值,需要和target方法中参数名称相同(必须相同,但是名称任意)
     *
     * @param joinPoint  连接点
     * @param annotation 文件检查
     */
    @Before("@annotation(annotation)")
    public void before(JoinPoint joinPoint, FileCheck annotation) throws FileUploadException {
        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) throws FileUploadException {
        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) throws FileUploadException {
        String magicNumber = readMagicNumber(file);
        String fileName = file.getOriginalFilename();
        String fileSuffix = fileName.substring(fileName.lastIndexOf(".") + 1).toUpperCase();
        for (FileType fileType : fileTypeSet) {
            if (magicNumber.startsWith(fileType.getMagicNumber()) && fileType.getSuffix().toUpperCase().equalsIgnoreCase(fileSuffix)) {
                return;
            }
        }
        log.error("文件头格式错误:{}", magicNumber);
        throw new FileUploadException(message);
    }

    /**
     * 验证文件后缀
     */
    private void doCheckSuffix(MultipartFile file, Set<String> suffixSet, String message) throws FileUploadException {
        String fileName = file.getOriginalFilename();
        String fileSuffix = fileName.substring(fileName.lastIndexOf(".") + 1).toUpperCase();
        for (String suffix : suffixSet) {
            if (suffix.toUpperCase().equalsIgnoreCase(fileSuffix)) {
                return;
            }
        }
        log.error("文件后缀格式错误:{}", message);
        throw new FileUploadException(message);
    }

    /**
     * 读取文件,获取文件头
     */
    private String readMagicNumber(MultipartFile file) throws FileUploadException {
        try (InputStream is = file.getInputStream()) {
            byte[] fileHeader = new byte[4];
            is.read(fileHeader, 0, 4);
            return byteArray2Hex(fileHeader);
        } catch (IOException e) {
            log.error("文件读取错误:{0}", e);
            throw new FileUploadException("读取文件失败!");
        }
    }

    /**
     * 字节数组转十六进制
     */
    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);
        }
        return stringBuilder.toString();
    }

}

5、 文件上传工具类

/**
 * 文件上传工具类
 * @author Shawn
 * @date 2021年11月22日19:45
 **/
public class FileUtils {

    private static final Logger logger = LoggerFactory.getLogger(FileUtils.class);


    /**
     * 文件上传
     *
     * @param file 文件
     * @return {@link String}
     * @throws Exception 异常
     */
    public static String fileUpload(Integer type, Integer userId,MultipartFile file) throws FileUploadException {
        // 获取文件名,带后缀
        String originalFilename = file.getOriginalFilename();
        // 获取文件的后缀格式
        String fileSuffix = originalFilename.substring(originalFilename.lastIndexOf(".") + 1).toLowerCase();

        String filePrefix = String.valueOf(System.currentTimeMillis())
                .concat(String.valueOf(type))
                .concat(String.valueOf(userId));
        String newFileName = filePrefix.concat(".").concat(fileSuffix);

        String dirPath;
        // 判断上传类型
        if(type == 0 ){
            dirPath = FileLocationEnum.LocalVideoLocation.getLocation();

        }else{
            dirPath = FileLocationEnum.LocalPicLocation.getLocation();
        }

        String path = dirPath + newFileName;

        File destFile = new File(dirPath + newFileName);
        if (!destFile.getParentFile().exists()) {
            destFile.getParentFile().mkdirs();
        }
        try {
            file.transferTo(destFile);
            logger.info("单次上传文件成功");
            // 将相对路径返回给前端
            return path;
        } catch (IOException e) {
            logger.error("upload pic error");
            throw new FileUploadException("上传文件错误");
        }
    }


    /**
     * 文件上传的图片
     *
     * @param type   类型,图片为1,视频为0
     * @param userId 用户id
     * @param files   文件
     * @return {@link List<String>}
     */
    public static List<String> fileUploadWithPics(int type, Integer userId, MultipartFile[] files) throws FileUploadException {
        List<String> picList = new ArrayList<>();
        for (MultipartFile file:files) {
            picList.add(fileUpload(type,userId,file));
        }
        logger.info("多图片文件上传成功");
        return picList;
    }
    
  
}

6、 控制类

这里提供了一个视频上传接口和多图片上传接口

/**
 * @author Shawn
 * @date 2021年11月22日21:09
 **/
@RestController
@RequestMapping("/file")
public class FileUploadController {


    /**
     * 文件上传的图片
     * 同时校验后缀和文件头
     * @param userId 用户id
     * @param file   文件
     * @return {@link ResultVO<?>}
     * @throws Exception 异常
     */
    @PostMapping("/fileuploadwithpics")
    @FileCheck(message = "不支持的图片格式",
            supportedSuffixes = {"png", "jpg",  "jpeg"},
            type = FileCheck.CheckType.SUFFIX_MAGIC_NUMBER,
            supportedFileTypes = {FileType.PNG, FileType.JPG, FileType.JPEG})
    public ResultVO<?> fileUploadWithPics(Integer userId, @RequestParam("pics") MultipartFile[] MultipartFile) throws Exception {
        if(userId==null){
            return new ResultVO<>(400,"缺少userId参数");
        }
        // 1表示图片,0 表示视频
        List<String> result = FileUtils.fileUploadWithPics(1, userId, MultipartFile);
        Map<String, List<String>> map = new HashMap<>(4);
        map.put("picUrl",result);
        return new ResultVO<>(map);
    }


    /**
     * 文件上传视频
     * 仅校验后缀
     * @param userId 用户id
     * @param file   文件
     * @return {@link ResultVO<?>}
     * @throws Exception 异常
     */
    @PostMapping("/fileuploadwithvideo")
    @FileCheck(message = "不支持的视频格式",
            type = FileCheck.CheckType.SUFFIX,
            supportedSuffixes = {"mp4","gif"})
    public ResultVO<?> fileUploadWithVideo(Integer userId, @RequestParam("video") MultipartFile file) throws Exception {
        if(userId==null){
            return new ResultVO<>(400,"缺少userId参数");
        }
        String s = FileUtils.fileUpload(0, userId, file);
        Map<String, String> map = new HashMap<>(4);
        map.put("videoUrl",s);
        return new ResultVO<>(map);
    }

}

7、 配置文件

application.yml进行配置

spring:
  servlet:
    multipart:
      enabled: true
      # 单个文件大小,m默认1M
      max-file-size: 10MB
      # 总上传文件大小,默认10M
      max-request-size: 30MB
      # 文件多少时写入磁盘,默认为0,有文件就写入
      # file-size-threshold: 10MB

8、 文件的前端显示

一种是Nginx进行映射,这种方式比较常见;另一种是SpringBoot自带的映射穿透,需要在application配置好映射关系,或者在java里配置好映射关系。

若视频放在D:\social\文件夹下,最终资源访问路径http://ip:port/social/xxxx

Yml配置文件方式

spring:
  mvc:
    static-path-pattern: /social/**
  web:
    resources:
      static-locations: file:D:\social\

javaBean配置方式

/**
    #application.yml中的配置
    file:
      staticPatternPath: /social/**
      uploadFolder: file:D:\social\
*/

//这个注解必须加,将该bean交给Spring管理,否则无法解析@Value
@Component
public class WebMvcConfig implements WebMvcConfigurer {
    
    @Value("${file.staticPatternPath}")
    private String staticPatternPath;

    @Value("${file.uploadFolder}")
    private String uploadFolder;

    // 这个方法是springboot中springMvc让程序开发者去配置文件上传的额外的静态资源服务的配置
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        // staticPatternPath 是访问路径,后面的是上传的资源路径
        // uploadFolder 是文件存储位置,而文件保存在uploadFolder 目录下
        registry.addResourceHandler(staticPatternPath).addResourceLocations(uploadFolder);
    }
}


三、阿里云OSS文件上传

1、 阿里云oss配置

首先开通阿里云oss,选择公共读,这样别人才可以读到我们的文件,但这样可能会导致上行流量剧增
请添加图片描述

创建玩Bucket后,需要配置一下ssl证书和已备案自定义域名,否则浏览器只能下载,不能读
请添加图片描述

最后获取AccessKey和SecretKey。进入 AccessKey管理 ,进入之后选择开始使用子用户AccessKey(推荐,这样安全),创建子用户,选择openAPI访问,创建完成后,添加AliyunOSSFullAccess权限

2、 Java整合oss

官方教程:https://help.aliyun.com/document_detail/84778.html

下面简单说一下配置,首先配置maven

<dependency>
    <groupId>com.aliyun.oss</groupId>
    <artifactId>aliyun-sdk-oss</artifactId>
    <version>3.10.2</version>
</dependency>

创建上传方法

public static String uploadFile(MultipartFile multipartFile) {
        // yourEndpoint填写Bucket所在地域对应的Endpoint。以华东1(杭州)为例,Endpoint填写为https://oss-cn-hangzhou.aliyuncs.com。
        String endpoint = "oss-cn-hangzhou.aliyuncs.com";
        // 阿里云账号AccessKey拥有所有API的访问权限,风险很高。强烈建议您创建并使用RAM用户进行API访问或日常运维,请登录RAM控制台创建RAM用户。
        String accessKeyId = "";
        String accessKeySecret = "";
        // 你的桶名字
        String bucketName = "";
        // 你的自定义域名,需要备案和配好ssl证书
        String domainName = "";
        // 桶里面你的根目录
        String rootPath = "lamp";

        OSS ossClient = null;

        try {
            // 创建OSSClient实例。
            ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
            // 获取文件上传的流
            InputStream inputStream = multipartFile.getInputStream();
            // 构建指定目录,按日期分类
            SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy/MM/dd");
            String datePath = dateFormat.format(new Date());
            // 获取文件名
            String originName = multipartFile.getOriginalFilename();
            String filename = UUID.randomUUID().toString();
            String suffix = originName.substring(originName.lastIndexOf("."));
            String newName = filename + suffix;
            String fileUrl = rootPath + "/" + datePath + "/" + newName;

            // 上传文件
            ossClient.putObject(bucketName, fileUrl, inputStream);
            return "https://" + domainName + "/" + fileUrl;
        } catch (IOException e) {
            e.printStackTrace();
            return "fail";
        } finally {
            // 关闭OSSClient。
            ossClient.shutdown();
        }
    }

3、 注意事项

使用 OSS 默认域名访问 html、图片资源,会有以附件形式下载的情况。若需要浏览器直接访问,需使用自定义域名进行访问,同时保证已经配置好ssl证书;同时oss桶还可以用来做图床

其他请参考官方文档


参考文献:

https://www.jianshu.com/p/be3f4c26c39a

https://www.cnblogs.com/zys2019/p/15394599.html

https://www.bilibili.com/video/BV1C3411b7wt?p=15&spm_id_from=pageDriver

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐