diff --git a/src/main/java/com/gxwebsoft/common/core/storage/FileStorageService.java b/src/main/java/com/gxwebsoft/common/core/storage/FileStorageService.java index 8e007d8..93e1260 100644 --- a/src/main/java/com/gxwebsoft/common/core/storage/FileStorageService.java +++ b/src/main/java/com/gxwebsoft/common/core/storage/FileStorageService.java @@ -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); diff --git a/src/main/java/com/gxwebsoft/common/core/storage/UploadRejectedException.java b/src/main/java/com/gxwebsoft/common/core/storage/UploadRejectedException.java new file mode 100644 index 0000000..4e4db90 --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/core/storage/UploadRejectedException.java @@ -0,0 +1,18 @@ +package com.gxwebsoft.common.core.storage; + +/** + * 上传被准入规则拒绝。 + *

+ * 单独的异常类型是为了让控制层能把它和真正的系统异常区分开: + * 前者把原因原样回给用户("不支持的文件格式"),后者一律回"上传失败"。 + * + * @author WebSoft + */ +public class UploadRejectedException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public UploadRejectedException(String message) { + super(message); + } +} diff --git a/src/main/java/com/gxwebsoft/common/core/storage/UploadRules.java b/src/main/java/com/gxwebsoft/common/core/storage/UploadRules.java new file mode 100644 index 0000000..8c5f26c --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/core/storage/UploadRules.java @@ -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; + +/** + * 上传文件的格式准入规则(后端唯一防线)。 + *

+ * 规则分两层,故意不对"扩展名"做严格的一一绑定: + *

    + *
  1. 探测出的 MIME 是 {@code image/*} 一律放行——头像、图标、编辑器插图的历史格式很多, + * 用后缀白名单会误伤;
  2. + *
  3. 其余按扩展名白名单放行(文档 / 音频 / 视频), + * 再用一份"危险 MIME"黑名单兜住"把 exe 改名成 .pdf"这类情况。
  4. + *
+ * 之所以不用"扩展名必须等于探测类型"的严格校验:OOXML(docx/xlsx/pptx)经 Tika 探测 + * 可能得到 {@code application/zip},md/csv 会得到 {@code text/plain},严格比对会把 + * 正常文件判死。黑名单 + 后缀白名单的组合能挡住可执行文件,又不会误杀办公文档。 + *

+ * 前端 {@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 SUFFIX_CATEGORY; + + /** + * 探测失败(octet-stream)时按后缀放行的图片格式 + */ + private static final Set 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 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 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; + } +} diff --git a/src/main/java/com/gxwebsoft/common/system/controller/FileController.java b/src/main/java/com/gxwebsoft/common/system/controller/FileController.java index 6995edc..45660e1 100644 --- a/src/main/java/com/gxwebsoft/common/system/controller/FileController.java +++ b/src/main/java/com/gxwebsoft/common/system/controller/FileController.java @@ -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()); diff --git a/src/main/java/com/gxwebsoft/common/system/mapper/xml/FileRecordMapper.xml b/src/main/java/com/gxwebsoft/common/system/mapper/xml/FileRecordMapper.xml index 468c19b..a4ad0a4 100644 --- a/src/main/java/com/gxwebsoft/common/system/mapper/xml/FileRecordMapper.xml +++ b/src/main/java/com/gxwebsoft/common/system/mapper/xml/FileRecordMapper.xml @@ -50,6 +50,17 @@ AND a.content_type LIKE CONCAT('%', #{param.contentType}, '%') + + + AND + + a.content_type LIKE 'image/%' + a.content_type LIKE 'video/%' + a.content_type LIKE 'audio/%' + (a.content_type LIKE 'application/%' OR a.content_type LIKE 'text/%') + + AND a.merchant_code LIKE CONCAT('%', #{param.merchantCode}, '%') diff --git a/src/main/java/com/gxwebsoft/common/system/param/FileRecordParam.java b/src/main/java/com/gxwebsoft/common/system/param/FileRecordParam.java index 97dc845..6f833e0 100644 --- a/src/main/java/com/gxwebsoft/common/system/param/FileRecordParam.java +++ b/src/main/java/com/gxwebsoft/common/system/param/FileRecordParam.java @@ -48,6 +48,15 @@ public class FileRecordParam extends BaseParam { @ApiModelProperty("文件类型") private String contentType; + /** + * 媒体大类:image / video / audio / doc。 + *

+ * 区别于 contentType 的 MIME 字符串——"文档"这个大类在 MIME 里没有可用的前缀 + * (pdf、docx、zip 都是 application/…),只能由 SQL 侧展开成具体类型匹配。 + */ + @ApiModelProperty("媒体大类, image/video/audio/doc") + private String mediaCategory; + @ApiModelProperty("是否删除, 0否, 1是") @TableLogic private Integer deleted;