Java 统一文件上传业务组件

文件上传在 Spring 框架中已有良好集成,无需依赖第三方库。虽然仅需几行代码即可实现一个简单的文件上传接口,但由于业务需求,往往需要对上传文件进行各类检查与识别。若非法文件(如包含木马的脚本或病毒)被成功上传,将给服务端带来灾难性的安全风险。

本组件旨在不仅提供多种识别合法文件的手段,还具备良好的封装性和易用性,为您的文件上传接口提供坚实的安全保障。

该组件已在 Spring Boot 2.7 环境下测试通过。

安装组件

xml 复制代码
<dependency>
     <groupId>com.ajaxjs</groupId>
     <artifactId>aj-filupload</artifactId>
     <version>1.0</version>
 </dependency>

除依赖一个自研的工具库外,本组件无其他外部依赖,代码短小精悍。

源码在:gitee.com/lightweight...

此组件属于 aj-framework 框架的一部分,欢迎查阅了解。

用法

前期准备

建议使用 Postman 或其他 HTTP 测试工具进行调试。

若无测试工具,也可直接使用以下 HTML 表单:

html 复制代码
<!DOCTYPE html>
<html>
<head lang="en">
    <meta charset="UTF-8" />
    <title>文件上传demo</title>
</head>
<body>
<h1>单文件上传页面</h1>
<form method="post" action="/fileUpload" enctype="multipart/form-data">
    文件:<input type="file" name="file"><br>
    前缀路径:<input type="text" name="prefixName"><br>
    <hr>
    <input type="submit" value="提交">
</form>
</body>
</html>

用法简介

简单用法

支持通过注解定义配置,以声明式调用组件,避免写复杂逻辑。

最简用法只需两步:声明注解 + 调用静态方法。其中UploadUtils.doUpload(getClass(), "uploadAudio", file);只需要修改第二个字符串参数,为当前控制器方法的名称(因为通过反射方法获取注解配置)。另外@RequestParam("file")中的file一般不用修改,需与表单中的name属性一致。

java 复制代码
import com.ajaxjs.framework.fileupload.FileUploadAction;
import com.ajaxjs.framework.fileupload.UploadUtils;
import com.ajaxjs.framework.fileupload.UploadedResult;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

@RestController
@RequestMapping("/upload")
public class FileUploadController {
    @FileUploadAction(uploadDir = "audio")
    @PostMapping(value = "/audio", consumes = "multipart/form-data")
    public UploadedResult uploadAudio(@RequestParam("file") MultipartFile file) {
        return UploadUtils.doUpload(getClass(), "uploadAudio", file);
    }
}

返回结果:

可自定义配置

若多个方法重复使用相同配置,可将其提取至配置文件,并通过 Spring 的@Value注入。此时需在调用时传入自定义的 FileUploadConfig。

例如配置 URL 前缀的(为了可以让外界访问刚上传的资源):

java 复制代码
@RestController
@RequestMapping("/upload")
public class FileUploadController {
    @Value("${file-upload.urlRoot:http://foo.com}")
    private String urlRoot;

    @Value("${server.servlet.context-path:/}")
    private String contextPath;

    @FileUploadAction(uploadDir = "audio")
    @PostMapping(value = "/audio", consumes = "multipart/form-data")
    public UploadedResult uploadAudio(@RequestParam("file") MultipartFile file) {
        return UploadUtils.doUpload(getClass(), "uploadAudio", file, (FileUploadConfig config) -> {
            config.setUrlPrefix(urlRoot + contextPath + "/audio/");
        });
    }
}

setUrlPrefix用于设置 Web 访问前缀,最终会与文件路径拼接,形成完整的访问 URL(如上图 JSON 所示),供前端或其他系统使用。

纯粹的调用方式

上述方式依赖注解与反射。其实也可以采用更直接的方式------手动构造配置并调用:

java 复制代码
@RestController
@RequestMapping("/upload")
public class FileUploadController {
    @Value("${file-upload.urlRoot:http://foo.com}")
    private String urlRoot;

    @Value("${server.servlet.context-path:/}")
    private String contextPath;

    @PostMapping(value = "/audio", consumes = "multipart/form-data")
    public UploadedResult uploadAudio(@RequestParam("file") MultipartFile file) {
        FileUploadConfig config = new FileUploadConfig();
        config.setBaseUploadDir("c:/temp/uploads");
        config.setUploadDir("audio");
        config.setUrlPrefix(urlRoot + contextPath + "/audio/");

        return new FileUpload(file, config).save();
    }
}

文件存储方式

本组件支持多种文件的存储方式,通过FileUploadConfig.storageType配置,枚举值如下。

方式 说明
LOCAL_DISK 保存到本地磁盘(默认)
DATABASE 保存到数据库
FILE_SERVICE 保存到 S3 存储服务,例如 OSS、MinIO 等
FILE_SERVICE_API 通过第三方 HTTP API 上传(本组件不执行实际上传)

保存到本地磁盘

上述的几个例子都是保存到本地磁盘的,这里不作赘述。配置的时候要设置FileUploadConfig.baseUploadDir指定保存根目录,uploadDir指定子目录(可选的)。

保存到数据库

文件保存到数据库适合一些比较小的文件,例如用户头像。

java 复制代码
@FileUploadAction(storageType = StorageType.DATABASE, detectType = DetectType.IMAGE, maxFileSize = 3)
public UploadedResult avatar(MultipartFile file) {
    Long userId = SecurityManager.getUser().getId();

    return UploadUtils.doUpload(getClass(), "avatar", file, null, (_file, config) -> {
        try {
            if (!Sql.instance().input("UPDATE user SET avatar_blob = ? WHERE id = ?", _file.getBytes(), userId).update().isOk())
                throw new BusinessException("更新用户头像失败");
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }

        String filename = file.getOriginalFilename();
        UploadedResult result = new UploadedResult();
        result.setFileName(filename);
        result.setOriginalFileName(filename);
        result.setFileSize(file.getSize());

        return result;
    });
}

由于涉及数据库的操作,这不是本组件具体负责的事情,故所以提供了一个回调函数来指定BiFunction<MultipartFile, FileUploadConfig, UploadedResult> saveToDatabase,施加在主类FileUpload上。

保存到 S3 存储服务

当前内置一个阿里云 OSS 的上传组件,可直接使用。

其他更多 S3 的集成,TODO。

云存储 API

这个其实与我们的组件无关了。因为可以直接保存到对方的 API 中。不过我们可以做的是 302 重定向一下。这个还未做(TODO)。

文件检测

文件上传往往是被攻击的入口和漏洞的温床,于是怎么保证这个接口的安全成为了重中之重------不然被恶意上传了文件(木马 / 病毒)服务器要遭殃!有多种的手段加固接口,有些集成在配置身上,可以方便增强安全性,有些是需要用户自己去修改加固的(例如针对 Linux 系统的权限修改等)。

文件体积大小检测

这是最基础的检测。通过设置FileUploadConfig.maxFileSize即可,这是单个文件的设置,单位是 MB。默认是 0 则不限制。

此外还需注意以下两处全局限制。

  • Servlet 配置:max-file-sizemax-request-size。注意在 yaml 配置中不是server节点下,而是spring下。
  • Nginx 配置:client_max_body_size 10M;
yaml 复制代码
spring:
  servlet:
    multipart:
      max-file-size: 10MB
      max-request-size: 10MB

验证文件扩展名

通过对上传文件的扩展名进行检查。这只是一个简单的检查,因为攻击者可将virus.exe重命名为virus.png进行绕过,因此仅靠扩展名并不安全。

实际上这是一种"白名单"的机制,分别有以下两种具体的策略检测:

  • 默认内置的检测。设置FileUploadConfig.detectType为特定的文件类型,例如图片、办公文件等,具体如下表所示。
枚举常量 说明 例子
IMAGE 图片 *.jpg/*.png/*.gif
OFFICE_FILE 办公文件 *.doc/*.xls/*.pdf/*.docx
AUDIO 音频 *.mp3/*.wav/*.aac
VIDEO 视频 *.mp4/*.mov/*.avi
NONE 不检测 - - -

然后获取文件的扩展名看是否在这已知的集合中。如果没有找到则抛出异常,说明是非法的文件上传。

  • 另外一种是自定义的文件名,如果允许的扩展名不在上述的特定类型中,还可以用户指定自己的扩展名。设置FileUploadConfig.allowExtFilenames数组即可,例如".jpg", ".png"。若为空则跳过检测 。

Content-Type 验证

Content-Type 验证同样也是一个简单的检查,因为客户端可随意修改 Content-Type 头,极易伪造,不可完全信赖。

Content-Type 同样也是一种"白名单"的机制,分别有以下两种具体的策略检测:

  • 默认内置的检测。设置FileUploadConfig.detectType为特定的文件类型,例如图片、办公文件等,具体如下表所示。
枚举常量 说明 例子
IMAGE 图片 *.jpg/*.png/*.gif
OFFICE_FILE 办公文件 *.doc/*.xls/*.pdf/*.docx
AUDIO 音频 *.mp3/*.wav/*.aac
VIDEO 视频 *.mp4/*.mov/*.avi
NONE 不检测 - - -

然后获取请求的Content-Type看是否在这已知的集合中。一般来说这个Content-Type是由前端所指定的。如果没有找到则抛出异常,说明是非法的文件上传。

  • 另外一种是根据文件扩展来判断,根据文件扩展名推断预期 Content-Type,并与请求头中的值比对,不一致则视为非法。

我们可以通过FileUploadConfig.contentTypePolicy设置检测策略,具体如下表:

枚举常量 说明
WHITELIST 根据指定内容检测是否在白名单中
MAPPING 根据文件扩展名的映射看是否匹配
ALL 同时启用上述两种校验
NO_CHECK 不检测

文件内容检测

文件魔数判断

魔数(Magic Number)指的是文件头中一般会包含该文件类型的二进制数据,我们读取头部前15、20 字节即可了解是否那种文件。

文件类型 文件头(HEX)
JPEG FF D8 FF E0
PNG 89 50 4E 47 0D 0A
PDF 25 50 44 46

示例代码:

本组件已经集成常见的魔数检测,开启方式:

java 复制代码
config.setCheckMagicNumber(true);
config.setDetectType(DetectType.IMAGE); // 必须指定类型

通过设置FileUploadConfig.checkMagicNumber = true打开检测,同时还要设置detectType枚举常量为特定的文件类型进行检测。

枚举常量 说明 例子
IMAGE 图片 *.jpg/*.png/*.gif
OFFICE_FILE 办公文件 *.doc/*.xls/*.pdf/*.docx
AUDIO 音频 *.mp3/*.wav/*.aac
VIDEO 视频 *.mp4/*.mov/*.avi
NONE 不检测 - - -

Apache Tika 深度检测

若内置魔数检测不足,可集成 Apache Tika 进行更深度的内容分析(需用户自行引入)。

强制解析文件

另外某些文件还可以通过强制解析来判断是否合法文件,例如图片,通过 Java Image API 读取一次:

该方法优点明显就是判断准确率高,缺点是仅限部分文件支持,而且耗费资源也大。

其他增强的安全手段

文件隔离存储

将上传文件存放在非 Web 根目录。本组件一般不会设置在 Web 目录一起(通过设置 FileUploadConfig.setBaseUploadDir()指定存储的目录)。如下指定了返回结果 url 前缀,与后面的文件路径构成完整的访问 url:

上传文件存放在专门的存储目录,通过 Nginx 等的静态服务器实现 Web 公开访问,或者简单一点通过 Spring MVC 读取文件流也是可以的。下面是一个控制器的例子。

java 复制代码
@GetMapping("/files/{type}/{filename:.+}")
public ResponseEntity<Resource> getFile(@PathVariable String type, @PathVariable String filename) {
    Path file = Paths.get(baseUploadDir).resolve(type + "/" + filename).normalize();
    log.info("下载文件:file:{}", file);

    try {
        Resource resource = new UrlResource(file.toUri());

        if (!resource.exists() || !resource.isReadable()) {
            log.warn("文件不存在:{}", filename);
            return ResponseEntity.notFound().build();
        }

        String contentType = Files.probeContentType(file);

        if (contentType == null)
            contentType = "application/octet-stream"; // fallback

        return ResponseEntity.ok().header(HttpHeaders.CONTENT_TYPE, contentType).body(resource);
    } catch (IOException e) {
        log.warn("文件读取异常:{}", filename);
        return ResponseEntity.badRequest().build();
    }
}

文件重命名

强制使用随机文件名+白名单扩展名。本组件使用NamePolicy.PolicyORIGINAL_RANDOMRANDOM即可。完整的文件命名策略如下表。

枚举常量 说明
ORIGINAL 保持原文件名不变(不推荐,易冲突)
ORIGINAL_RANDOM 原文件名+随机 UUID 的组合,保留原扩展名
RANDOM 完全使用随机文件名,保留原扩展名

限制执行的权限

设置存储目录不可执行权限。本组件在首次上传的时候自动检测,请注意观察日志有否警告。 执行以下 shell 脚本,对目录设置非执行的权限(包括所有的子目录)。这个对于 Windows 系统无效,仅对 Linux 系统有效。

shell 复制代码
find /path/to/dir -type d -exec chmod -x {} +

用工具扫文件内容

如集成 ClamAV 等杀毒引擎扫描上传文件。此功能需调用外部工具,本文不展开。

上传超大文件

当前组件尚未考虑这一块。我们通过 MinIO 提供的 API 可完成大文件的切片上传。

参考:12

相关推荐
10x104 小时前
# Docker 使用笔记:重新理解镜像、容器与数据持久化
后端
Rover.x4 小时前
Spring国际化语言切换不生效
java·后端·spring
IT_陈寒4 小时前
Redis 7个性能优化技巧,让我们的QPS从5k提升到20k+
前端·人工智能·后端
百锦再4 小时前
金仓数据库提出“三低一平”的迁移理念
开发语言·数据库·后端·python·rust·eclipse·pygame
ZHE|张恒5 小时前
深入理解 Spring 原理:IOC、AOP 与事务管理
java·后端·spring
expect7g5 小时前
Flink-To-Paimon 读取机制
大数据·后端·flink
kida_yuan5 小时前
【从零开始】18. 持续优化模型微调
后端·llm
倚栏听风雨5 小时前
Agent 认知+ReAct模式
后端