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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user