Commit 03ce4fad5766bd85d318b9d6fb4cf8e43dad92d1
1 parent
223a4d81
feat(infra): 增加文件上传类型白名单校验
Showing
5 changed files
with
279 additions
and
10 deletions
yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/app/file/AppFileController.java
| @@ -12,7 +12,6 @@ import io.swagger.v3.oas.annotations.Parameters; | @@ -12,7 +12,6 @@ import io.swagger.v3.oas.annotations.Parameters; | ||
| 12 | import io.swagger.v3.oas.annotations.media.Schema; | 12 | import io.swagger.v3.oas.annotations.media.Schema; |
| 13 | import io.swagger.v3.oas.annotations.tags.Tag; | 13 | import io.swagger.v3.oas.annotations.tags.Tag; |
| 14 | import jakarta.annotation.Resource; | 14 | import jakarta.annotation.Resource; |
| 15 | -import jakarta.annotation.security.PermitAll; | ||
| 16 | import jakarta.validation.Valid; | 15 | import jakarta.validation.Valid; |
| 17 | import lombok.extern.slf4j.Slf4j; | 16 | import lombok.extern.slf4j.Slf4j; |
| 18 | import org.springframework.validation.annotation.Validated; | 17 | import org.springframework.validation.annotation.Validated; |
yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/enums/ErrorCodeConstants.java
| @@ -34,6 +34,7 @@ public interface ErrorCodeConstants { | @@ -34,6 +34,7 @@ public interface ErrorCodeConstants { | ||
| 34 | ErrorCode FILE_NOT_EXISTS = new ErrorCode(1_001_003_001, "文件不存在"); | 34 | ErrorCode FILE_NOT_EXISTS = new ErrorCode(1_001_003_001, "文件不存在"); |
| 35 | ErrorCode FILE_IS_EMPTY = new ErrorCode(1_001_003_002, "文件为空"); | 35 | ErrorCode FILE_IS_EMPTY = new ErrorCode(1_001_003_002, "文件为空"); |
| 36 | ErrorCode FILE_PATH_INVALID = new ErrorCode(1_001_003_003, "文件路径不正确"); | 36 | ErrorCode FILE_PATH_INVALID = new ErrorCode(1_001_003_003, "文件路径不正确"); |
| 37 | + ErrorCode FILE_TYPE_NOT_ALLOWED = new ErrorCode(1_001_003_004, "不支持的文件类型"); | ||
| 37 | 38 | ||
| 38 | // ========== 代码生成器 1-001-004-000 ========== | 39 | // ========== 代码生成器 1-001-004-000 ========== |
| 39 | ErrorCode CODEGEN_TABLE_EXISTS = new ErrorCode(1_001_004_002, "表定义已经存在"); | 40 | ErrorCode CODEGEN_TABLE_EXISTS = new ErrorCode(1_001_004_002, "表定义已经存在"); |
yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java
| @@ -3,6 +3,8 @@ package cn.iocoder.yudao.module.infra.service.file; | @@ -3,6 +3,8 @@ package cn.iocoder.yudao.module.infra.service.file; | ||
| 3 | import cn.hutool.core.date.LocalDateTimeUtil; | 3 | import cn.hutool.core.date.LocalDateTimeUtil; |
| 4 | import cn.hutool.core.io.FileUtil; | 4 | import cn.hutool.core.io.FileUtil; |
| 5 | import cn.hutool.core.lang.Assert; | 5 | import cn.hutool.core.lang.Assert; |
| 6 | +import cn.hutool.core.util.ArrayUtil; | ||
| 7 | +import cn.hutool.core.util.ObjectUtil; | ||
| 6 | import cn.hutool.core.util.RandomUtil; | 8 | import cn.hutool.core.util.RandomUtil; |
| 7 | import cn.hutool.core.util.StrUtil; | 9 | import cn.hutool.core.util.StrUtil; |
| 8 | import cn.hutool.crypto.digest.DigestUtil; | 10 | import cn.hutool.crypto.digest.DigestUtil; |
| @@ -23,10 +25,11 @@ import lombok.SneakyThrows; | @@ -23,10 +25,11 @@ import lombok.SneakyThrows; | ||
| 23 | import org.springframework.stereotype.Service; | 25 | import org.springframework.stereotype.Service; |
| 24 | 26 | ||
| 25 | import java.util.List; | 27 | import java.util.List; |
| 28 | +import java.util.Set; | ||
| 26 | 29 | ||
| 27 | import static cn.hutool.core.date.DatePattern.PURE_DATE_PATTERN; | 30 | import static cn.hutool.core.date.DatePattern.PURE_DATE_PATTERN; |
| 28 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | 31 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; |
| 29 | -import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.FILE_NOT_EXISTS; | 32 | +import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.*; |
| 30 | 33 | ||
| 31 | /** | 34 | /** |
| 32 | * 文件 Service 实现类 | 35 | * 文件 Service 实现类 |
| @@ -37,6 +40,29 @@ import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.FILE_NOT_EX | @@ -37,6 +40,29 @@ import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.FILE_NOT_EX | ||
| 37 | public class FileServiceImpl implements FileService { | 40 | public class FileServiceImpl implements FileService { |
| 38 | 41 | ||
| 39 | /** | 42 | /** |
| 43 | + * 允许上传的文件类型,可按业务需要调整 | ||
| 44 | + */ | ||
| 45 | + private static final Set<String> ALLOWED_FILE_TYPES = Set.of( | ||
| 46 | + // 图片 | ||
| 47 | + "image/jpeg", "image/png", "image/gif", "image/webp", "image/bmp", | ||
| 48 | + "image/vnd.microsoft.icon", "image/tiff", "image/avif", "image/heic", "image/heif", "image/svg+xml", | ||
| 49 | + // 文档 | ||
| 50 | + "application/pdf", "text/plain", "text/csv", "text/markdown", "text/html", "application/xhtml+xml", | ||
| 51 | + "application/xml", "application/json", "application/epub+zip", "message/rfc822", "application/vnd.ms-outlook", | ||
| 52 | + "application/msword", "application/vnd.ms-excel", "application/vnd.ms-powerpoint", | ||
| 53 | + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", | ||
| 54 | + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", | ||
| 55 | + "application/vnd.openxmlformats-officedocument.presentationml.presentation", | ||
| 56 | + // 压缩包 | ||
| 57 | + "application/zip", "application/x-rar-compressed", "application/x-7z-compressed", "application/gzip", | ||
| 58 | + // 音视频 | ||
| 59 | + "audio/mpeg", "audio/vnd.wave", "audio/vorbis", "audio/ogg", "audio/opus", "audio/mp4", "audio/webm", | ||
| 60 | + "audio/x-aac", "audio/x-flac", "audio/amr", "audio/x-ms-wma", | ||
| 61 | + "video/mp4", "video/quicktime", "video/x-msvideo", "video/webm", "video/x-ms-wmv", "video/x-flv", | ||
| 62 | + "application/x-matroska", "video/x-matroska" | ||
| 63 | + ); | ||
| 64 | + | ||
| 65 | + /** | ||
| 40 | * 上传文件的前缀,是否包含日期(yyyyMMdd) | 66 | * 上传文件的前缀,是否包含日期(yyyyMMdd) |
| 41 | * | 67 | * |
| 42 | * 目的:按照日期,进行分目录 | 68 | * 目的:按照日期,进行分目录 |
| @@ -74,9 +100,15 @@ public class FileServiceImpl implements FileService { | @@ -74,9 +100,15 @@ public class FileServiceImpl implements FileService { | ||
| 74 | // 1.1 处理 name 的合法性,禁止携带目录路径 | 100 | // 1.1 处理 name 的合法性,禁止携带目录路径 |
| 75 | name = FilePathUtils.validateFileName(name); | 101 | name = FilePathUtils.validateFileName(name); |
| 76 | 102 | ||
| 77 | - // 1.2.1 处理 type 为空的情况 | ||
| 78 | - if (StrUtil.isEmpty(type)) { | ||
| 79 | - type = FileTypeUtils.getMineType(content, name); | 103 | + // 1.2.1 校验文件内容和类型,不信任客户端传入的 type |
| 104 | + if (ArrayUtil.isEmpty(content)) { | ||
| 105 | + throw exception(FILE_IS_EMPTY); | ||
| 106 | + } | ||
| 107 | + type = FileTypeUtils.getMineType(content, name); | ||
| 108 | + String nameType = StrUtil.isNotEmpty(FileUtil.extName(name)) ? validateFileType(name) : null; | ||
| 109 | + if (!ALLOWED_FILE_TYPES.contains(type) | ||
| 110 | + || (FileTypeUtils.isImage(nameType) && ObjectUtil.notEqual(type, nameType))) { | ||
| 111 | + throw exception(FILE_TYPE_NOT_ALLOWED); | ||
| 80 | } | 112 | } |
| 81 | // 1.2.2 处理 name 为空的情况 | 113 | // 1.2.2 处理 name 为空的情况 |
| 82 | if (StrUtil.isEmpty(name)) { | 114 | if (StrUtil.isEmpty(name)) { |
| @@ -148,8 +180,10 @@ public class FileServiceImpl implements FileService { | @@ -148,8 +180,10 @@ public class FileServiceImpl implements FileService { | ||
| 148 | @Override | 180 | @Override |
| 149 | @SneakyThrows | 181 | @SneakyThrows |
| 150 | public FilePresignedUrlRespVO presignPutUrl(String name, String directory) { | 182 | public FilePresignedUrlRespVO presignPutUrl(String name, String directory) { |
| 151 | - // 1. 生成上传的 path,需要保证唯一 | 183 | + // 1.1 生成上传的 path,需要保证唯一 |
| 152 | String path = generateUploadPath(name, directory); | 184 | String path = generateUploadPath(name, directory); |
| 185 | + // 1.2 前端直传无法读取文件内容,此处仅校验文件名类型 | ||
| 186 | + validateFileType(name); | ||
| 153 | 187 | ||
| 154 | // 2. 获取文件预签名地址 | 188 | // 2. 获取文件预签名地址 |
| 155 | FileClient fileClient = fileConfigService.getMasterFileClient(); | 189 | FileClient fileClient = fileConfigService.getMasterFileClient(); |
| @@ -170,7 +204,13 @@ public class FileServiceImpl implements FileService { | @@ -170,7 +204,13 @@ public class FileServiceImpl implements FileService { | ||
| 170 | // 1.1 校验参数的合法性 | 204 | // 1.1 校验参数的合法性 |
| 171 | FilePathUtils.validatePath(createReqVO.getPath()); | 205 | FilePathUtils.validatePath(createReqVO.getPath()); |
| 172 | createReqVO.setName(FilePathUtils.validateFileName(createReqVO.getName())); | 206 | createReqVO.setName(FilePathUtils.validateFileName(createReqVO.getName())); |
| 173 | - // 1.2 处理 URL 的合法性,移除 URL 中的查询参数(例如签名参数),保证 URL 的唯一性 | 207 | + // 1.2 校验原文件名和存储路径的类型,前端直传不校验文件内容 |
| 208 | + String type = validateFileType(createReqVO.getName()); | ||
| 209 | + if (ObjectUtil.notEqual(type, validateFileType(FileUtil.getName(createReqVO.getPath())))) { | ||
| 210 | + throw exception(FILE_TYPE_NOT_ALLOWED); | ||
| 211 | + } | ||
| 212 | + createReqVO.setType(type); | ||
| 213 | + // 1.3 处理 URL 的合法性,移除 URL 中的查询参数(例如签名参数),保证 URL 的唯一性 | ||
| 174 | createReqVO.setUrl(HttpUtils.removeUrlQuery(createReqVO.getUrl())); // 目的:移除私有桶情况下,URL 的签名参数 | 214 | createReqVO.setUrl(HttpUtils.removeUrlQuery(createReqVO.getUrl())); // 目的:移除私有桶情况下,URL 的签名参数 |
| 175 | 215 | ||
| 176 | // 2. 保存到数据库 | 216 | // 2. 保存到数据库 |
| @@ -179,6 +219,14 @@ public class FileServiceImpl implements FileService { | @@ -179,6 +219,14 @@ public class FileServiceImpl implements FileService { | ||
| 179 | return file.getId(); | 219 | return file.getId(); |
| 180 | } | 220 | } |
| 181 | 221 | ||
| 222 | + private String validateFileType(String name) { | ||
| 223 | + String type = FileTypeUtils.getMineType(name); | ||
| 224 | + if (!ALLOWED_FILE_TYPES.contains(type)) { | ||
| 225 | + throw exception(FILE_TYPE_NOT_ALLOWED); | ||
| 226 | + } | ||
| 227 | + return type; | ||
| 228 | + } | ||
| 229 | + | ||
| 182 | @Override | 230 | @Override |
| 183 | public FileDO getFile(Long id) { | 231 | public FileDO getFile(Long id) { |
| 184 | return validateFileExists(id); | 232 | return validateFileExists(id); |
yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImplTest.java
| @@ -7,23 +7,31 @@ import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; | @@ -7,23 +7,31 @@ import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; | ||
| 7 | import cn.iocoder.yudao.framework.test.core.util.AssertUtils; | 7 | import cn.iocoder.yudao.framework.test.core.util.AssertUtils; |
| 8 | import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FileCreateReqVO; | 8 | import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FileCreateReqVO; |
| 9 | import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePageReqVO; | 9 | import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePageReqVO; |
| 10 | +import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePresignedUrlRespVO; | ||
| 10 | import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileDO; | 11 | import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileDO; |
| 11 | import cn.iocoder.yudao.module.infra.dal.mysql.file.FileMapper; | 12 | import cn.iocoder.yudao.module.infra.dal.mysql.file.FileMapper; |
| 12 | import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClient; | 13 | import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClient; |
| 13 | import jakarta.annotation.Resource; | 14 | import jakarta.annotation.Resource; |
| 15 | +import org.apache.poi.hssf.usermodel.HSSFWorkbook; | ||
| 16 | +import org.apache.poi.ss.usermodel.Workbook; | ||
| 17 | +import org.apache.poi.xssf.usermodel.XSSFWorkbook; | ||
| 14 | import org.junit.jupiter.api.BeforeEach; | 18 | import org.junit.jupiter.api.BeforeEach; |
| 15 | import org.junit.jupiter.api.Test; | 19 | import org.junit.jupiter.api.Test; |
| 16 | import org.springframework.context.annotation.Import; | 20 | import org.springframework.context.annotation.Import; |
| 17 | import org.springframework.test.context.bean.override.mockito.MockitoBean; | 21 | import org.springframework.test.context.bean.override.mockito.MockitoBean; |
| 18 | 22 | ||
| 23 | +import javax.imageio.ImageIO; | ||
| 24 | +import java.awt.image.BufferedImage; | ||
| 25 | +import java.io.ByteArrayOutputStream; | ||
| 26 | +import java.nio.charset.StandardCharsets; | ||
| 19 | import java.time.LocalDateTime; | 27 | import java.time.LocalDateTime; |
| 28 | +import java.util.Locale; | ||
| 20 | import java.util.concurrent.atomic.AtomicReference; | 29 | import java.util.concurrent.atomic.AtomicReference; |
| 21 | 30 | ||
| 22 | import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.buildTime; | 31 | import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.buildTime; |
| 23 | import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; | 32 | import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; |
| 24 | import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.*; | 33 | import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.*; |
| 25 | -import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.FILE_NOT_EXISTS; | ||
| 26 | -import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.FILE_PATH_INVALID; | 34 | +import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.*; |
| 27 | import static org.junit.jupiter.api.Assertions.*; | 35 | import static org.junit.jupiter.api.Assertions.*; |
| 28 | import static org.mockito.ArgumentMatchers.same; | 36 | import static org.mockito.ArgumentMatchers.same; |
| 29 | import static org.mockito.Mockito.*; | 37 | import static org.mockito.Mockito.*; |
| @@ -147,6 +155,198 @@ public class FileServiceImplTest extends BaseDbUnitTest { | @@ -147,6 +155,198 @@ public class FileServiceImplTest extends BaseDbUnitTest { | ||
| 147 | } | 155 | } |
| 148 | 156 | ||
| 149 | @Test | 157 | @Test |
| 158 | + public void testCreateFile_success_image() throws Exception { | ||
| 159 | + for (String extension : new String[]{"jpg", "png", "gif", "bmp"}) { | ||
| 160 | + // 准备参数 | ||
| 161 | + String type = "jpg".equals(extension) ? "image/jpeg" : "image/" + extension; | ||
| 162 | + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); | ||
| 163 | + assertTrue(ImageIO.write(new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB), extension, outputStream)); | ||
| 164 | + byte[] content = outputStream.toByteArray(); | ||
| 165 | + String name = "头像." + extension.toUpperCase(Locale.ROOT); | ||
| 166 | + // mock 方法 | ||
| 167 | + FileClient client = mock(FileClient.class); | ||
| 168 | + when(fileConfigService.getMasterFileClient()).thenReturn(client); | ||
| 169 | + String url = randomString(); | ||
| 170 | + when(client.upload(same(content), anyString(), eq(type))).thenReturn(url); | ||
| 171 | + | ||
| 172 | + // 调用 | ||
| 173 | + String result = fileService.createFile(content, name, null, "application/octet-stream"); | ||
| 174 | + // 断言 | ||
| 175 | + assertEquals(url, result); | ||
| 176 | + FileDO file = fileMapper.selectOne(FileDO::getUrl, url); | ||
| 177 | + assertEquals(name, file.getName()); | ||
| 178 | + assertEquals(type, file.getType()); | ||
| 179 | + } | ||
| 180 | + } | ||
| 181 | + | ||
| 182 | + @Test | ||
| 183 | + public void testCreateFile_success_excel() throws Exception { | ||
| 184 | + for (String extension : new String[]{"xls", "xlsx"}) { | ||
| 185 | + // 准备参数 | ||
| 186 | + String type = "xls".equals(extension) ? "application/vnd.ms-excel" | ||
| 187 | + : "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; | ||
| 188 | + byte[] content; | ||
| 189 | + try (Workbook workbook = "xls".equals(extension) ? new HSSFWorkbook() : new XSSFWorkbook(); | ||
| 190 | + ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { | ||
| 191 | + workbook.createSheet("用户").createRow(0).createCell(0).setCellValue("芋道源码"); | ||
| 192 | + workbook.write(outputStream); | ||
| 193 | + content = outputStream.toByteArray(); | ||
| 194 | + } | ||
| 195 | + // mock 方法 | ||
| 196 | + FileClient client = mock(FileClient.class); | ||
| 197 | + when(fileConfigService.getMasterFileClient()).thenReturn(client); | ||
| 198 | + String url = randomString(); | ||
| 199 | + when(client.upload(same(content), anyString(), eq(type))).thenReturn(url); | ||
| 200 | + | ||
| 201 | + // 调用 | ||
| 202 | + String result = fileService.createFile(content, "用户." + extension, null, null); | ||
| 203 | + // 断言 | ||
| 204 | + assertEquals(url, result); | ||
| 205 | + assertEquals(type, fileMapper.selectOne(FileDO::getUrl, url).getType()); | ||
| 206 | + } | ||
| 207 | + } | ||
| 208 | + | ||
| 209 | + @Test | ||
| 210 | + public void testCreateFile_success_detectType() throws Exception { | ||
| 211 | + for (String declaredType : new String[]{null, "", "image/jpg", "text/html", "application/octet-stream"}) { | ||
| 212 | + // 准备参数 | ||
| 213 | + byte[] content = ResourceUtil.readBytes("file/erweima.jpg"); | ||
| 214 | + // mock 方法 | ||
| 215 | + FileClient client = mock(FileClient.class); | ||
| 216 | + when(fileConfigService.getMasterFileClient()).thenReturn(client); | ||
| 217 | + String url = randomString(); | ||
| 218 | + when(client.upload(same(content), anyString(), eq("image/jpeg"))).thenReturn(url); | ||
| 219 | + | ||
| 220 | + // 调用 | ||
| 221 | + String result = fileService.createFile(content, "头像.jpg", null, declaredType); | ||
| 222 | + // 断言 | ||
| 223 | + assertEquals(url, result); | ||
| 224 | + assertEquals("image/jpeg", fileMapper.selectOne(FileDO::getUrl, url).getType()); | ||
| 225 | + } | ||
| 226 | + } | ||
| 227 | + | ||
| 228 | + @Test | ||
| 229 | + public void testCreateFile_success_document() throws Exception { | ||
| 230 | + for (String[] document : new String[][]{ | ||
| 231 | + {"test.svg", "<svg xmlns=\"http://www.w3.org/2000/svg\"><script>alert(1)</script></svg>", "image/svg+xml"}, | ||
| 232 | + {"test.html", "<html><script>alert(1)</script></html>", "text/html"}, | ||
| 233 | + {"test.xml", "<?xml version=\"1.0\"?><root>test</root>", "application/xml"}, | ||
| 234 | + {"test.markdown", "# test\n\nhello", "text/markdown"}, | ||
| 235 | + {"test.eml", "From: a@example.com\r\nTo: b@example.com\r\nSubject: test\r\n\r\nhello", "message/rfc822"}}) { | ||
| 236 | + // 准备参数 | ||
| 237 | + String name = document[0]; | ||
| 238 | + byte[] content = document[1].getBytes(StandardCharsets.UTF_8); | ||
| 239 | + String type = document[2]; | ||
| 240 | + // mock 方法 | ||
| 241 | + FileClient client = mock(FileClient.class); | ||
| 242 | + when(fileConfigService.getMasterFileClient()).thenReturn(client); | ||
| 243 | + String url = randomString(); | ||
| 244 | + when(client.upload(same(content), anyString(), eq(type))).thenReturn(url); | ||
| 245 | + | ||
| 246 | + // 调用 | ||
| 247 | + String result = fileService.createFile(content, name, null, "application/octet-stream"); | ||
| 248 | + // 断言 | ||
| 249 | + assertEquals(url, result); | ||
| 250 | + assertEquals(type, fileMapper.selectOne(FileDO::getUrl, url).getType()); | ||
| 251 | + } | ||
| 252 | + } | ||
| 253 | + | ||
| 254 | + @Test | ||
| 255 | + public void testCreateFile_success_opus() throws Exception { | ||
| 256 | + // 准备参数 | ||
| 257 | + byte[] content = new byte[64]; | ||
| 258 | + System.arraycopy("OggS".getBytes(StandardCharsets.US_ASCII), 0, content, 0, 4); | ||
| 259 | + System.arraycopy("OpusHead".getBytes(StandardCharsets.US_ASCII), 0, content, 28, 8); | ||
| 260 | + // mock 方法 | ||
| 261 | + FileClient client = mock(FileClient.class); | ||
| 262 | + when(fileConfigService.getMasterFileClient()).thenReturn(client); | ||
| 263 | + String url = randomString(); | ||
| 264 | + when(client.upload(same(content), anyString(), eq("audio/opus"))).thenReturn(url); | ||
| 265 | + | ||
| 266 | + // 调用 | ||
| 267 | + String result = fileService.createFile(content, "voice.ogg", null, "audio/ogg;codecs=opus"); | ||
| 268 | + // 断言 | ||
| 269 | + assertEquals(url, result); | ||
| 270 | + assertEquals("audio/opus", fileMapper.selectOne(FileDO::getUrl, url).getType()); | ||
| 271 | + } | ||
| 272 | + | ||
| 273 | + @Test | ||
| 274 | + public void testCreateFile_typeNotAllowed_fakeImage() { | ||
| 275 | + for (String text : new String[]{"<html><script>alert(1)</script></html>", | ||
| 276 | + "<svg xmlns=\"http://www.w3.org/2000/svg\"><script>alert(1)</script></svg>", "普通文本"}) { | ||
| 277 | + // 准备参数 | ||
| 278 | + byte[] content = text.getBytes(StandardCharsets.UTF_8); | ||
| 279 | + | ||
| 280 | + // 调用,并断言异常 | ||
| 281 | + assertServiceException(() -> fileService.createFile(content, "头像.jpg", null, "image/jpeg"), | ||
| 282 | + FILE_TYPE_NOT_ALLOWED); | ||
| 283 | + // 断言未上传文件、未保存记录 | ||
| 284 | + verifyNoInteractions(fileConfigService); | ||
| 285 | + assertEquals(0L, fileMapper.selectCount()); | ||
| 286 | + } | ||
| 287 | + } | ||
| 288 | + | ||
| 289 | + @Test | ||
| 290 | + public void testCreateFile_typeNotAllowed_extension() { | ||
| 291 | + for (String name : new String[]{"头像.svg", "头像.js", "头像.exe", "头像.unknown", "头像.png"}) { | ||
| 292 | + // 准备参数 | ||
| 293 | + byte[] content = ResourceUtil.readBytes("file/erweima.jpg"); | ||
| 294 | + | ||
| 295 | + // 调用,并断言异常 | ||
| 296 | + assertServiceException(() -> fileService.createFile(content, name, null, "image/jpeg"), FILE_TYPE_NOT_ALLOWED); | ||
| 297 | + // 校验调用 | ||
| 298 | + verifyNoInteractions(fileConfigService); | ||
| 299 | + } | ||
| 300 | + } | ||
| 301 | + | ||
| 302 | + @Test | ||
| 303 | + public void testCreateFile_fileIsEmpty() { | ||
| 304 | + // 准备参数 | ||
| 305 | + byte[] content = new byte[0]; | ||
| 306 | + | ||
| 307 | + // 调用,并断言异常 | ||
| 308 | + assertServiceException(() -> fileService.createFile(content, "头像.jpg", null, null), FILE_IS_EMPTY); | ||
| 309 | + // 校验调用 | ||
| 310 | + verifyNoInteractions(fileConfigService); | ||
| 311 | + } | ||
| 312 | + | ||
| 313 | + @Test | ||
| 314 | + public void testPresignPutUrl_success() { | ||
| 315 | + for (String extension : new String[]{"jpg", "JPEG", "jfif", "png", "gif", "webp", "bmp", "ico", "tiff", | ||
| 316 | + "avif", "heic", "heif", "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "csv", "md", | ||
| 317 | + "zip", "rar", "mp3", "mp4", "svg", "html", "htm", "xml", "markdown", "eml", "msg", "epub", | ||
| 318 | + "json", "opus", "ogg", "m4a", "webm", "amr", "wma", "wmv", "flv", "mkv"}) { | ||
| 319 | + // 准备参数 | ||
| 320 | + String name = "测试文件." + extension; | ||
| 321 | + // mock 方法 | ||
| 322 | + FileClient client = mock(FileClient.class); | ||
| 323 | + when(fileConfigService.getMasterFileClient()).thenReturn(client); | ||
| 324 | + when(client.getId()).thenReturn(10L); | ||
| 325 | + when(client.presignPutUrl(anyString())).thenReturn("upload-url"); | ||
| 326 | + when(client.presignGetUrl(anyString(), isNull())).thenReturn("visit-url"); | ||
| 327 | + | ||
| 328 | + // 调用 | ||
| 329 | + FilePresignedUrlRespVO result = fileService.presignPutUrl(name, "avatar"); | ||
| 330 | + // 断言 | ||
| 331 | + assertEquals(10L, result.getConfigId()); | ||
| 332 | + assertTrue(result.getPath().endsWith("/" + name)); | ||
| 333 | + assertEquals("upload-url", result.getUploadUrl()); | ||
| 334 | + assertEquals("visit-url", result.getUrl()); | ||
| 335 | + } | ||
| 336 | + } | ||
| 337 | + | ||
| 338 | + @Test | ||
| 339 | + public void testPresignPutUrl_typeNotAllowed() { | ||
| 340 | + // 准备参数 | ||
| 341 | + for (String name : new String[]{"test.php", "test.jsp", "test.js", "test.exe", "test.unknown", "test"}) { | ||
| 342 | + // 调用,并断言异常 | ||
| 343 | + assertServiceException(() -> fileService.presignPutUrl(name, null), FILE_TYPE_NOT_ALLOWED); | ||
| 344 | + // 校验调用 | ||
| 345 | + verifyNoInteractions(fileConfigService); | ||
| 346 | + } | ||
| 347 | + } | ||
| 348 | + | ||
| 349 | + @Test | ||
| 150 | public void testDeleteFile_success() throws Exception { | 350 | public void testDeleteFile_success() throws Exception { |
| 151 | // mock 数据 | 351 | // mock 数据 |
| 152 | FileDO dbFile = randomPojo(FileDO.class, o -> o.setConfigId(10L).setPath("tudou.jpg")); | 352 | FileDO dbFile = randomPojo(FileDO.class, o -> o.setConfigId(10L).setPath("tudou.jpg")); |
| @@ -245,6 +445,27 @@ public class FileServiceImplTest extends BaseDbUnitTest { | @@ -245,6 +445,27 @@ public class FileServiceImplTest extends BaseDbUnitTest { | ||
| 245 | assertEquals("avatar/test.jpg", file.getPath()); | 445 | assertEquals("avatar/test.jpg", file.getPath()); |
| 246 | assertEquals("test.jpg", file.getName()); | 446 | assertEquals("test.jpg", file.getName()); |
| 247 | assertEquals("https://www.iocoder.cn/test.jpg", file.getUrl()); | 447 | assertEquals("https://www.iocoder.cn/test.jpg", file.getUrl()); |
| 448 | + assertEquals("image/jpeg", file.getType()); | ||
| 449 | + } | ||
| 450 | + | ||
| 451 | + @Test | ||
| 452 | + public void testCreateFileByPresignedPath_typeNotAllowed() { | ||
| 453 | + for (String[] file : new String[][]{{"test.js", "avatar/test.js"}, {"test.jpg", "avatar/test.html"}, | ||
| 454 | + {"test.html", "avatar/test.jpg"}, {"test.jpg", "avatar/test.png"}}) { | ||
| 455 | + // 准备参数 | ||
| 456 | + String name = file[0]; | ||
| 457 | + String path = file[1]; | ||
| 458 | + FileCreateReqVO reqVO = randomPojo(FileCreateReqVO.class, o -> { | ||
| 459 | + o.setName(name); | ||
| 460 | + o.setPath(path); | ||
| 461 | + o.setType("image/jpeg"); | ||
| 462 | + }); | ||
| 463 | + | ||
| 464 | + // 调用,并断言异常 | ||
| 465 | + assertServiceException(() -> fileService.createFile(reqVO), FILE_TYPE_NOT_ALLOWED); | ||
| 466 | + // 断言未保存记录 | ||
| 467 | + assertEquals(0L, fileMapper.selectCount()); | ||
| 468 | + } | ||
| 248 | } | 469 | } |
| 249 | 470 | ||
| 250 | @Test | 471 | @Test |
yudao-module-infra/src/test/resources/sql/create_tables.sql
| @@ -37,7 +37,7 @@ CREATE TABLE IF NOT EXISTS "infra_file" ( | @@ -37,7 +37,7 @@ CREATE TABLE IF NOT EXISTS "infra_file" ( | ||
| 37 | "name" varchar(256), | 37 | "name" varchar(256), |
| 38 | "path" varchar(512), | 38 | "path" varchar(512), |
| 39 | "url" varchar(1024), | 39 | "url" varchar(1024), |
| 40 | - "type" varchar(63) DEFAULT NULL, | 40 | + "type" varchar(128) DEFAULT NULL, |
| 41 | "size" bigint NOT NULL, | 41 | "size" bigint NOT NULL, |
| 42 | "creator" varchar(64) DEFAULT '', | 42 | "creator" varchar(64) DEFAULT '', |
| 43 | "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, | 43 | "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, |