feat(file): 上传准入白名单 + 素材媒体大类筛选

素材库要从「只传图片」放开到文档与音视频,后端必须先有一道类型防线:
/api/file/upload 此前对文件类型零校验,任意文件都能落到公开可读目录。

- UploadRules:image/* 按探测 MIME 放行(历史头像/图标格式多,不做后缀白名单);
  其余按后缀白名单(pdf/doc/docx/xls/xlsx/ppt/pptx/txt/md/csv、
  mp3/wav/m4a/aac、mp4/mov/webm),再用危险 MIME 黑名单兜住 exe 改名。
  不用「扩展名必须等于探测类型」的严格比对:docx/xlsx/pptx 经 Tika 常得到
  application/zip,md/csv 得到 text/plain,严格比对会误杀正常办公文档。
- FileStorageService.store 落盘后校验,本地分支失败删除刚写的文件,不留孤儿。
- FileController 单独捕获 UploadRejectedException,把原因原样回给前端。
- FileRecordParam.mediaCategory + Mapper 展开成 image/video/audio/doc 四类条件:
  「文档」在 MIME 里没有统一前缀,只能落到 application/% | text/%。

设计取舍见 docs/adr/0004(本仓库外,tuanwei-home)。
This commit is contained in:
2026-11-01 21:03:56 +08:00
parent 9e88c53052
commit 76c9edd4be
6 changed files with 177 additions and 1 deletions
@@ -88,8 +88,16 @@ public class FileStorageService {
if (!isRemote()) {
File out = FileServerUtil.upload(file, getUploadDir().getAbsolutePath() + "/", uuidName);
String contentType = FileServerUtil.getContentType(out);
try {
UploadRules.check(originalName, contentType);
} catch (RuntimeException e) {
// 已经落盘才判定失败,把刚写下的文件删掉,不留孤儿
FileUtil.del(out);
throw e;
}
String path = "/" + dateDir + "/" + out.getName();
return new StoredFile(path, out.length(), FileServerUtil.getContentType(out),
return new StoredFile(path, out.length(), contentType,
StrUtil.isBlank(originalName) ? out.getName() : originalName);
}
@@ -108,6 +116,8 @@ public class FileStorageService {
file.transferTo(tmp);
try {
String contentType = FileServerUtil.getContentType(tmp);
// 临时文件由 finally 清理,失败直接抛出即可
UploadRules.check(originalName, contentType);
putObject(KEY_PREFIX + normalize(path), tmp, contentType);
return new StoredFile(path, tmp.length(), contentType,
StrUtil.isBlank(originalName) ? tmp.getName() : originalName);
@@ -0,0 +1,18 @@
package com.gxwebsoft.common.core.storage;
/**
* 上传被准入规则拒绝。
* <p>
* 单独的异常类型是为了让控制层能把它和真正的系统异常区分开:
* 前者把原因原样回给用户("不支持的文件格式"),后者一律回"上传失败"。
*
* @author WebSoft
*/
public class UploadRejectedException extends RuntimeException {
private static final long serialVersionUID = 1L;
public UploadRejectedException(String message) {
super(message);
}
}
@@ -0,0 +1,124 @@
package com.gxwebsoft.common.core.storage;
import cn.hutool.core.util.StrUtil;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/**
* 上传文件的格式准入规则(后端唯一防线)。
* <p>
* 规则分两层,故意不对"扩展名"做严格的一一绑定:
* <ol>
* <li>探测出的 MIME 是 {@code image/*} 一律放行——头像、图标、编辑器插图的历史格式很多,
* 用后缀白名单会误伤;</li>
* <li>其余按<strong>扩展名白名单</strong>放行(文档 / 音频 / 视频),
* 再用一份"危险 MIME"黑名单兜住"把 exe 改名成 .pdf"这类情况。</li>
* </ol>
* 之所以不用"扩展名必须等于探测类型"的严格校验:OOXML(docx/xlsx/pptx)经 Tika 探测
* 可能得到 {@code application/zip},md/csv 会得到 {@code text/plain},严格比对会把
* 正常文件判死。黑名单 + 后缀白名单的组合能挡住可执行文件,又不会误杀办公文档。
* <p>
* 前端 {@code src/utils/media.ts} 的 {@code MEDIA_ACCEPT} 是这份规则的镜像,两处要一起改。
*
* @author WebSoft
*/
public final class UploadRules {
public static final String IMAGE = "image";
public static final String VIDEO = "video";
public static final String AUDIO = "audio";
public static final String DOC = "doc";
/**
* 扩展名 -> 媒体大类,只登记非图片类型(图片走 MIME 判断)
*/
private static final Map<String, String> SUFFIX_CATEGORY;
/**
* 探测失败(octet-stream)时按后缀放行的图片格式
*/
private static final Set<String> IMAGE_SUFFIXES = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
"jpg", "jpeg", "png", "gif", "bmp", "webp", "svg", "ico", "tif", "tiff", "heic", "avif"
)));
/**
* 危险 MIME 前缀:可执行文件、脚本、快捷方式,改名换后缀也不放行
*/
private static final List<String> DENIED_MIME_PREFIXES = Arrays.asList(
"application/x-msdownload", "application/x-dosexec", "application/x-executable",
"application/x-sharedlib", "application/x-sh", "application/x-msi",
"application/x-ms-shortcut", "application/x-bat", "application/x-perl",
"application/x-python", "application/x-httpd-php", "application/x-javascript",
"text/x-php", "text/x-shellscript", "text/x-python"
);
static {
Map<String, String> m = new HashMap<>();
for (String suffix : Arrays.asList("mp4", "mov", "webm")) {
m.put(suffix, VIDEO);
}
for (String suffix : Arrays.asList("mp3", "wav", "m4a", "aac")) {
m.put(suffix, AUDIO);
}
for (String suffix : Arrays.asList("pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "md", "csv")) {
m.put(suffix, DOC);
}
SUFFIX_CATEGORY = Collections.unmodifiableMap(m);
}
private UploadRules() {
}
/**
* 校验一个待入库的文件,不通过时抛出 {@link UploadRejectedException}
*
* @param fileName 原始文件名,用于取后缀
* @param contentType 服务端探测出的 MIME,可为空
*/
public static void check(String fileName, String contentType) {
String mime = StrUtil.nullToEmpty(contentType).toLowerCase(Locale.ROOT);
String suffix = suffixOf(fileName);
if (mime.startsWith("image/")) {
return;
}
for (String denied : DENIED_MIME_PREFIXES) {
if (mime.startsWith(denied)) {
throw new UploadRejectedException("不支持的文件格式" + describe(suffix) + ",仅支持图片、文档、音频、视频");
}
}
if (IMAGE_SUFFIXES.contains(suffix) && (mime.isEmpty() || "application/octet-stream".equals(mime))) {
// 探测不出来但后缀是图片,按图片放行
return;
}
if (SUFFIX_CATEGORY.containsKey(suffix)) {
return;
}
throw new UploadRejectedException("不支持的文件格式" + describe(suffix) + ",仅支持图片、文档、音频、视频");
}
/**
* 文件名对应的媒体大类,用于服务端需要分类时,未知返回 null
*/
public static String categoryOf(String fileName) {
return SUFFIX_CATEGORY.get(suffixOf(fileName));
}
private static String suffixOf(String fileName) {
if (StrUtil.isBlank(fileName) || fileName.lastIndexOf('.') < 0) {
return "";
}
return fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT);
}
private static String describe(String suffix) {
return StrUtil.isBlank(suffix) ? "" : ":" + suffix;
}
}
@@ -5,6 +5,7 @@ import com.gxwebsoft.common.core.annotation.OperationLog;
import com.gxwebsoft.common.core.config.ConfigProperties;
import com.gxwebsoft.common.core.storage.FileStorageService;
import com.gxwebsoft.common.core.storage.StoredFile;
import com.gxwebsoft.common.core.storage.UploadRejectedException;
import com.gxwebsoft.common.core.utils.FileServerUtil;
import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.BaseController;
@@ -56,6 +57,9 @@ public class FileController extends BaseController {
result = buildRecord(stored);
fileRecordService.save(result);
return success(result);
} catch (UploadRejectedException e) {
// 格式准入失败:把原因原样回给前端,不要吞成"上传失败"
return fail(e.getMessage(), result);
} catch (Exception e) {
e.printStackTrace();
return fail("上传失败", result).setError(e.toString());
@@ -50,6 +50,17 @@
<if test="param.contentType != null">
AND a.content_type LIKE CONCAT('%', #{param.contentType}, '%')
</if>
<!-- 媒体大类:'文档' 在 MIME 里没有统一前缀,用 application/text 整体圈定;
content_type 由服务端 Tika 探测写入,前端传入的值不会落库 -->
<if test="param.mediaCategory != null and param.mediaCategory != ''">
AND
<choose>
<when test="param.mediaCategory == 'image'">a.content_type LIKE 'image/%'</when>
<when test="param.mediaCategory == 'video'">a.content_type LIKE 'video/%'</when>
<when test="param.mediaCategory == 'audio'">a.content_type LIKE 'audio/%'</when>
<otherwise>(a.content_type LIKE 'application/%' OR a.content_type LIKE 'text/%')</otherwise>
</choose>
</if>
<if test="param.merchantCode != null">
AND a.merchant_code LIKE CONCAT('%', #{param.merchantCode}, '%')
</if>
@@ -48,6 +48,15 @@ public class FileRecordParam extends BaseParam {
@ApiModelProperty("文件类型")
private String contentType;
/**
* 媒体大类:image / video / audio / doc。
* <p>
* 区别于 contentType 的 MIME 字符串——"文档"这个大类在 MIME 里没有可用的前缀
* (pdf、docx、zip 都是 application/…),只能由 SQL 侧展开成具体类型匹配。
*/
@ApiModelProperty("媒体大类, image/video/audio/doc")
private String mediaCategory;
@ApiModelProperty("是否删除, 0否, 1是")
@TableLogic
private Integer deleted;