Commit d5721c30ba69cc941e9ec549de28f93bf71d8879

Authored by YunaiV
1 parent 5db1c511

fix(mp): 修复公众号素材上传临时文件覆盖问题

yudao-module-mp/src/main/java/cn/iocoder/yudao/module/mp/service/material/MpMaterialServiceImpl.java
... ... @@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.mp.service.material;
3 3 import cn.hutool.core.io.FileTypeUtil;
4 4 import cn.hutool.core.io.FileUtil;
5 5 import cn.hutool.core.util.ObjUtil;
  6 +import cn.hutool.core.util.ReUtil;
6 7 import cn.hutool.core.util.StrUtil;
7 8 import cn.iocoder.yudao.framework.common.pojo.PageResult;
8 9 import cn.iocoder.yudao.module.infra.api.file.FileApi;
... ... @@ -16,6 +17,7 @@ import cn.iocoder.yudao.module.mp.dal.dataobject.material.MpMaterialDO;
16 17 import cn.iocoder.yudao.module.mp.dal.mysql.material.MpMaterialMapper;
17 18 import cn.iocoder.yudao.module.mp.framework.mp.core.MpServiceFactory;
18 19 import cn.iocoder.yudao.module.mp.service.account.MpAccountService;
  20 +import com.google.common.annotations.VisibleForTesting;
19 21 import lombok.extern.slf4j.Slf4j;
20 22 import me.chanjar.weixin.common.bean.result.WxMediaUploadResult;
21 23 import me.chanjar.weixin.common.error.WxErrorException;
... ... @@ -24,12 +26,15 @@ import me.chanjar.weixin.mp.bean.material.WxMpMaterialUploadResult;
24 26 import org.springframework.context.annotation.Lazy;
25 27 import org.springframework.stereotype.Service;
26 28 import org.springframework.validation.annotation.Validated;
  29 +import org.springframework.web.multipart.MultipartFile;
27 30  
28 31 import jakarta.annotation.Resource;
29 32 import java.io.File;
30 33 import java.io.IOException;
  34 +import java.nio.file.Files;
31 35 import java.util.Collection;
32 36 import java.util.List;
  37 +import java.util.regex.Pattern;
33 38  
34 39 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
35 40 import static cn.iocoder.yudao.module.mp.enums.ErrorCodeConstants.*;
... ... @@ -44,6 +49,15 @@ import static cn.iocoder.yudao.module.mp.enums.ErrorCodeConstants.*;
44 49 @Slf4j
45 50 public class MpMaterialServiceImpl implements MpMaterialService {
46 51  
  52 + /**
  53 + * 临时文件的前缀
  54 + */
  55 + private static final String TEMP_FILE_PREFIX = "mp-material-";
  56 + /**
  57 + * 临时文件允许保留的扩展名格式
  58 + */
  59 + private static final Pattern TEMP_FILE_EXTENSION_PATTERN = Pattern.compile("^[a-zA-Z0-9]{1,16}$");
  60 +
47 61 @Resource
48 62 private MpMaterialMapper mpMaterialMapper;
49 63  
... ... @@ -90,7 +104,7 @@ public class MpMaterialServiceImpl implements MpMaterialService {
90 104 String url;
91 105 try {
92 106 // 写入到临时文件
93   - file = FileUtil.newFile(FileUtil.getTmpDirPath() + reqVO.getFile().getOriginalFilename());
  107 + file = createTempFile(reqVO.getFile());
94 108 reqVO.getFile().transferTo(file);
95 109 // 上传到公众号
96 110 result = mpService.getMaterialService().mediaUpload(reqVO.getType(), file);
... ... @@ -122,7 +136,7 @@ public class MpMaterialServiceImpl implements MpMaterialService {
122 136 String url;
123 137 try {
124 138 // 写入到临时文件
125   - file = FileUtil.newFile(FileUtil.getTmpDirPath() + reqVO.getFile().getOriginalFilename());
  139 + file = createTempFile(reqVO.getFile());
126 140 reqVO.getFile().transferTo(file);
127 141 // 上传到公众号
128 142 result = mpService.getMaterialService().materialFileUpload(reqVO.getType(),
... ... @@ -150,7 +164,7 @@ public class MpMaterialServiceImpl implements MpMaterialService {
150 164 File file = null;
151 165 try {
152 166 // 写入到临时文件
153   - file = FileUtil.newFile(FileUtil.getTmpDirPath() + reqVO.getFile().getOriginalFilename());
  167 + file = createTempFile(reqVO.getFile());
154 168 reqVO.getFile().transferTo(file);
155 169 // 上传到公众号
156 170 return mpService.getMaterialService().mediaImgUpload(file).getUrl();
... ... @@ -221,4 +235,20 @@ public class MpMaterialServiceImpl implements MpMaterialService {
221 235 return fileApi.createFile(FileUtil.readBytes(file), path);
222 236 }
223 237  
  238 + /**
  239 + * 创建上传到公众号的临时文件
  240 + *
  241 + * 使用随机的文件名独占创建,不拼接原始文件名,避免目录穿越覆盖、删除其它文件,以及并发同名互相影响;
  242 + * 仅保留合法的扩展名,用于公众号识别文件类型
  243 + *
  244 + * @param file 上传的文件
  245 + * @return 临时文件
  246 + */
  247 + @VisibleForTesting
  248 + static File createTempFile(MultipartFile file) throws IOException {
  249 + String extension = FileUtil.extName(file.getOriginalFilename());
  250 + String suffix = ReUtil.isMatch(TEMP_FILE_EXTENSION_PATTERN, extension) ? StrUtil.DOT + extension : null;
  251 + return Files.createTempFile(TEMP_FILE_PREFIX, suffix).toFile();
  252 + }
  253 +
224 254 }
... ...
yudao-module-mp/src/test/java/cn/iocoder/yudao/module/mp/service/material/MpMaterialServiceImplTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.mp.service.material;
  2 +
  3 +import cn.hutool.core.io.FileUtil;
  4 +import cn.hutool.core.util.IdUtil;
  5 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  6 +import cn.iocoder.yudao.module.infra.api.file.FileApi;
  7 +import cn.iocoder.yudao.module.mp.controller.admin.material.vo.MpMaterialUploadNewsImageReqVO;
  8 +import cn.iocoder.yudao.module.mp.controller.admin.material.vo.MpMaterialUploadPermanentReqVO;
  9 +import cn.iocoder.yudao.module.mp.controller.admin.material.vo.MpMaterialUploadTemporaryReqVO;
  10 +import cn.iocoder.yudao.module.mp.dal.dataobject.account.MpAccountDO;
  11 +import cn.iocoder.yudao.module.mp.dal.mysql.material.MpMaterialMapper;
  12 +import cn.iocoder.yudao.module.mp.framework.mp.core.MpServiceFactory;
  13 +import cn.iocoder.yudao.module.mp.service.account.MpAccountService;
  14 +import me.chanjar.weixin.common.bean.result.WxMediaUploadResult;
  15 +import me.chanjar.weixin.common.error.WxError;
  16 +import me.chanjar.weixin.common.error.WxErrorException;
  17 +import me.chanjar.weixin.mp.api.WxMpMaterialService;
  18 +import me.chanjar.weixin.mp.api.WxMpService;
  19 +import me.chanjar.weixin.mp.bean.material.WxMediaImgUploadResult;
  20 +import me.chanjar.weixin.mp.bean.material.WxMpMaterial;
  21 +import me.chanjar.weixin.mp.bean.material.WxMpMaterialUploadResult;
  22 +import org.junit.jupiter.api.AfterEach;
  23 +import org.junit.jupiter.api.BeforeEach;
  24 +import org.junit.jupiter.api.Test;
  25 +import org.mockito.InjectMocks;
  26 +import org.mockito.Mock;
  27 +import org.springframework.mock.web.MockMultipartFile;
  28 +
  29 +import java.io.File;
  30 +import java.nio.charset.StandardCharsets;
  31 +import java.util.ArrayList;
  32 +import java.util.List;
  33 +
  34 +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
  35 +import static cn.iocoder.yudao.module.mp.enums.ErrorCodeConstants.MATERIAL_UPLOAD_FAIL;
  36 +import static org.junit.jupiter.api.Assertions.*;
  37 +import static org.mockito.ArgumentMatchers.any;
  38 +import static org.mockito.ArgumentMatchers.anyString;
  39 +import static org.mockito.ArgumentMatchers.eq;
  40 +import static org.mockito.Mockito.when;
  41 +
  42 +/**
  43 + * {@link MpMaterialServiceImpl} 的单元测试
  44 + *
  45 + * @author 芋道源码
  46 + */
  47 +public class MpMaterialServiceImplTest extends BaseMockitoUnitTest {
  48 +
  49 + @InjectMocks
  50 + private MpMaterialServiceImpl materialService;
  51 +
  52 + @Mock
  53 + private MpMaterialMapper mpMaterialMapper;
  54 + @Mock
  55 + private FileApi fileApi;
  56 + @Mock
  57 + private MpAccountService mpAccountService;
  58 + @Mock
  59 + private MpServiceFactory mpServiceFactory;
  60 +
  61 + @Mock
  62 + private WxMpService mpService;
  63 + @Mock
  64 + private WxMpMaterialService mpMaterialService;
  65 +
  66 + /**
  67 + * 模拟系统临时目录中,与上传原始文件名相同的其它文件
  68 + */
  69 + private File victimFile;
  70 +
  71 + @BeforeEach
  72 + public void setUp() {
  73 + victimFile = FileUtil.writeUtf8String("victim", new File(FileUtil.getTmpDir(), IdUtil.fastSimpleUUID() + ".jpg"));
  74 + }
  75 +
  76 + @AfterEach
  77 + public void tearDown() {
  78 + FileUtil.del(victimFile);
  79 + }
  80 +
  81 + @Test
  82 + public void testUploadTemporaryMaterial_originalFilenameNotUsed() throws Exception {
  83 + // 准备参数:原始文件名与临时目录中的其它文件同名
  84 + MpMaterialUploadTemporaryReqVO reqVO = new MpMaterialUploadTemporaryReqVO();
  85 + reqVO.setAccountId(1L);
  86 + reqVO.setType("image");
  87 + reqVO.setFile(buildMultipartFile(victimFile.getName()));
  88 + // mock 方法
  89 + mockMpService();
  90 + mockUploadFile();
  91 + List<File> uploadFiles = new ArrayList<>();
  92 + when(mpMaterialService.mediaUpload(eq("image"), any(File.class))).thenAnswer(invocation -> {
  93 + File file = invocation.getArgument(1);
  94 + assertTempFile(file, ".jpg");
  95 + uploadFiles.add(file);
  96 + return buildMediaUploadResult();
  97 + });
  98 +
  99 + // 调用
  100 + materialService.uploadTemporaryMaterial(reqVO);
  101 + // 断言:同名文件未被覆盖、删除;临时文件已清理
  102 + assertEquals(1, uploadFiles.size());
  103 + assertTrue(victimFile.exists());
  104 + assertEquals("victim", FileUtil.readUtf8String(victimFile));
  105 + assertFalse(uploadFiles.get(0).exists());
  106 + }
  107 +
  108 + @Test
  109 + public void testUploadTemporaryMaterial_traversalFilename() throws Exception {
  110 + // 准备参数:原始文件名包含目录穿越与绝对路径
  111 + MpMaterialUploadTemporaryReqVO reqVO = new MpMaterialUploadTemporaryReqVO();
  112 + reqVO.setAccountId(1L);
  113 + reqVO.setType("image");
  114 + reqVO.setFile(buildMultipartFile("../../" + victimFile.getAbsolutePath()));
  115 + // mock 方法
  116 + mockMpService();
  117 + mockUploadFile();
  118 + List<File> uploadFiles = new ArrayList<>();
  119 + when(mpMaterialService.mediaUpload(eq("image"), any(File.class))).thenAnswer(invocation -> {
  120 + File file = invocation.getArgument(1);
  121 + assertTempFile(file, ".jpg");
  122 + uploadFiles.add(file);
  123 + return buildMediaUploadResult();
  124 + });
  125 +
  126 + // 调用
  127 + materialService.uploadTemporaryMaterial(reqVO);
  128 + // 断言
  129 + assertEquals("victim", FileUtil.readUtf8String(victimFile));
  130 + assertFalse(uploadFiles.get(0).exists());
  131 + }
  132 +
  133 + @Test
  134 + public void testUploadTemporaryMaterial_sameFilenameConcurrent() throws Exception {
  135 + // 准备参数:两次上传相同的原始文件名
  136 + MpMaterialUploadTemporaryReqVO reqVO = new MpMaterialUploadTemporaryReqVO();
  137 + reqVO.setAccountId(1L);
  138 + reqVO.setType("image");
  139 + reqVO.setFile(buildMultipartFile("same.jpg"));
  140 + // mock 方法
  141 + mockMpService();
  142 + mockUploadFile();
  143 + List<File> uploadFiles = new ArrayList<>();
  144 + when(mpMaterialService.mediaUpload(eq("image"), any(File.class))).thenAnswer(invocation -> {
  145 + File file = invocation.getArgument(1);
  146 + assertTempFile(file, ".jpg");
  147 + uploadFiles.add(file);
  148 + return buildMediaUploadResult();
  149 + });
  150 +
  151 + // 调用
  152 + materialService.uploadTemporaryMaterial(reqVO);
  153 + materialService.uploadTemporaryMaterial(reqVO);
  154 + // 断言:每次都是独立的临时文件
  155 + assertEquals(2, uploadFiles.size());
  156 + assertNotEquals(uploadFiles.get(0).getAbsolutePath(), uploadFiles.get(1).getAbsolutePath());
  157 + }
  158 +
  159 + @Test
  160 + public void testUploadTemporaryMaterial_wxError() throws Exception {
  161 + // 准备参数
  162 + MpMaterialUploadTemporaryReqVO reqVO = new MpMaterialUploadTemporaryReqVO();
  163 + reqVO.setAccountId(1L);
  164 + reqVO.setType("image");
  165 + reqVO.setFile(buildMultipartFile(victimFile.getName()));
  166 + // mock 方法
  167 + mockMpService();
  168 + List<File> uploadFiles = new ArrayList<>();
  169 + when(mpMaterialService.mediaUpload(eq("image"), any(File.class))).thenAnswer(invocation -> {
  170 + uploadFiles.add(invocation.getArgument(1));
  171 + throw new WxErrorException(WxError.builder().errorCode(40001).errorMsg("invalid").build());
  172 + });
  173 +
  174 + // 调用,并断言异常
  175 + assertServiceException(() -> materialService.uploadTemporaryMaterial(reqVO), MATERIAL_UPLOAD_FAIL, "invalid");
  176 + // 断言:失败时,也只清理自己创建的临时文件
  177 + assertFalse(uploadFiles.get(0).exists());
  178 + assertEquals("victim", FileUtil.readUtf8String(victimFile));
  179 + }
  180 +
  181 + @Test
  182 + public void testUploadPermanentMaterial_originalFilenameNotUsed() throws Exception {
  183 + // 准备参数
  184 + MpMaterialUploadPermanentReqVO reqVO = new MpMaterialUploadPermanentReqVO();
  185 + reqVO.setAccountId(1L);
  186 + reqVO.setType("image");
  187 + reqVO.setName("永久素材");
  188 + reqVO.setFile(buildMultipartFile(victimFile.getName()));
  189 + // mock 方法
  190 + mockMpService();
  191 + mockUploadFile();
  192 + List<File> uploadFiles = new ArrayList<>();
  193 + when(mpMaterialService.materialFileUpload(eq("image"), any(WxMpMaterial.class))).thenAnswer(invocation -> {
  194 + WxMpMaterial material = invocation.getArgument(1);
  195 + assertTempFile(material.getFile(), ".jpg");
  196 + uploadFiles.add(material.getFile());
  197 + WxMpMaterialUploadResult result = new WxMpMaterialUploadResult();
  198 + result.setMediaId("media_02");
  199 + return result;
  200 + });
  201 +
  202 + // 调用
  203 + materialService.uploadPermanentMaterial(reqVO);
  204 + // 断言
  205 + assertEquals("victim", FileUtil.readUtf8String(victimFile));
  206 + assertFalse(uploadFiles.get(0).exists());
  207 + }
  208 +
  209 + @Test
  210 + public void testUploadNewsImage_originalFilenameNotUsed() throws Exception {
  211 + // 准备参数:扩展名不合法时,不保留扩展名
  212 + MpMaterialUploadNewsImageReqVO reqVO = new MpMaterialUploadNewsImageReqVO();
  213 + reqVO.setAccountId(1L);
  214 + reqVO.setFile(buildMultipartFile(victimFile.getName() + ".p%ng"));
  215 + // mock 方法
  216 + when(mpServiceFactory.getRequiredMpService(eq(1L))).thenReturn(mpService);
  217 + when(mpService.getMaterialService()).thenReturn(mpMaterialService);
  218 + List<File> uploadFiles = new ArrayList<>();
  219 + when(mpMaterialService.mediaImgUpload(any(File.class))).thenAnswer(invocation -> {
  220 + File file = invocation.getArgument(0);
  221 + assertTempFile(file, ".tmp");
  222 + uploadFiles.add(file);
  223 + WxMediaImgUploadResult result = new WxMediaImgUploadResult();
  224 + result.setUrl("https://mmbiz.qpic.cn/test.jpg");
  225 + return result;
  226 + });
  227 +
  228 + // 调用
  229 + String url = materialService.uploadNewsImage(reqVO);
  230 + // 断言
  231 + assertEquals("https://mmbiz.qpic.cn/test.jpg", url);
  232 + assertEquals("victim", FileUtil.readUtf8String(victimFile));
  233 + assertFalse(uploadFiles.get(0).exists());
  234 + }
  235 +
  236 + private void mockMpService() {
  237 + when(mpServiceFactory.getRequiredMpService(eq(1L))).thenReturn(mpService);
  238 + when(mpService.getMaterialService()).thenReturn(mpMaterialService);
  239 + }
  240 +
  241 + private void mockUploadFile() {
  242 + when(fileApi.createFile(any(byte[].class), anyString())).thenReturn("https://www.iocoder.cn/media.jpg");
  243 + when(mpAccountService.getRequiredAccount(eq(1L))).thenReturn(new MpAccountDO().setId(1L).setAppId("wx_app"));
  244 + }
  245 +
  246 + private static WxMediaUploadResult buildMediaUploadResult() {
  247 + WxMediaUploadResult result = new WxMediaUploadResult();
  248 + result.setMediaId("media_01");
  249 + return result;
  250 + }
  251 +
  252 + private static MockMultipartFile buildMultipartFile(String originalFilename) {
  253 + return new MockMultipartFile("file", originalFilename, "image/jpeg",
  254 + "content".getBytes(StandardCharsets.UTF_8));
  255 + }
  256 +
  257 + private static void assertTempFile(File file, String suffix) throws Exception {
  258 + assertTrue(file.exists());
  259 + assertEquals(FileUtil.getTmpDir().getCanonicalFile(), file.getParentFile().getCanonicalFile());
  260 + assertTrue(file.getName().startsWith("mp-material-"));
  261 + assertTrue(file.getName().endsWith(suffix));
  262 + assertEquals("content", FileUtil.readUtf8String(file));
  263 + }
  264 +
  265 +}
... ...