forked from baskbuddy/zhangshun
16 changed files with 778 additions and 66 deletions
@ -0,0 +1,216 @@ |
|||
package com.mydreamjob.profile.storage; |
|||
|
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import org.springframework.stereotype.Component; |
|||
import org.springframework.web.multipart.MultipartFile; |
|||
|
|||
import java.io.ByteArrayInputStream; |
|||
import java.io.IOException; |
|||
import java.util.Map; |
|||
import java.util.Set; |
|||
import java.util.zip.ZipEntry; |
|||
import java.util.zip.ZipException; |
|||
import java.util.zip.ZipInputStream; |
|||
|
|||
/** |
|||
* 简历上传白名单校验器。 |
|||
* |
|||
* <p>扩展名和客户端 Content-Type 只作为第一层校验;随后检查文件实际签名。 |
|||
* DOCX 还会验证必要的 Office Open XML 条目,并限制条目数、解压总量和压缩比, |
|||
* 避免将任意 ZIP、路径穿越条目或压缩炸弹当作简历保存。</p> |
|||
* |
|||
* <p>本校验不等同于恶意软件扫描。生产环境如需病毒查杀,应在独立安全组件中完成。</p> |
|||
*/ |
|||
@Component |
|||
public class ResumeFileValidator { |
|||
|
|||
static final long MAX_FILE_SIZE = 10L * 1024 * 1024; |
|||
static final int MAX_DOCX_ENTRIES = 1_024; |
|||
static final long MAX_DOCX_UNCOMPRESSED_BYTES = 40L * 1024 * 1024; |
|||
static final long MAX_DOCX_COMPRESSION_RATIO = 100L; |
|||
private static final int BUFFER_SIZE = 8 * 1024; |
|||
|
|||
private static final Set<String> ALLOWED_EXTENSIONS = Set.of(".pdf", ".doc", ".docx"); |
|||
private static final Map<String, String> EXT_TO_CONTENT_TYPE = Map.of( |
|||
".pdf", "application/pdf", |
|||
".doc", "application/msword", |
|||
".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document" |
|||
); |
|||
|
|||
private static final byte[] PDF_SIGNATURE = {'%', 'P', 'D', 'F', '-'}; |
|||
private static final byte[] OLE_SIGNATURE = { |
|||
(byte) 0xD0, (byte) 0xCF, 0x11, (byte) 0xE0, |
|||
(byte) 0xA1, (byte) 0xB1, 0x1A, (byte) 0xE1 |
|||
}; |
|||
private static final byte[] WORD_DOCUMENT_UTF16_LE = { |
|||
'W', 0, 'o', 0, 'r', 0, 'd', 0, 'D', 0, 'o', 0, 'c', 0, |
|||
'u', 0, 'm', 0, 'e', 0, 'n', 0, 't', 0 |
|||
}; |
|||
|
|||
public void validate(MultipartFile file) { |
|||
if (file == null || file.isEmpty() || file.getSize() <= 0 || file.getSize() > MAX_FILE_SIZE) { |
|||
reject(); |
|||
} |
|||
|
|||
String originalName = file.getOriginalFilename(); |
|||
ResumeFilenameSanitizer.validateUploadName(originalName); |
|||
String extension = extensionOf(originalName); |
|||
if (!ALLOWED_EXTENSIONS.contains(extension)) { |
|||
reject(); |
|||
} |
|||
|
|||
String contentType = file.getContentType(); |
|||
if (!EXT_TO_CONTENT_TYPE.get(extension).equals(contentType)) { |
|||
reject(); |
|||
} |
|||
|
|||
byte[] content; |
|||
try { |
|||
content = file.getBytes(); |
|||
} catch (IOException e) { |
|||
reject(); |
|||
return; |
|||
} |
|||
if (content.length == 0 || content.length > MAX_FILE_SIZE) { |
|||
reject(); |
|||
} |
|||
|
|||
switch (extension) { |
|||
case ".pdf" -> validatePdf(content); |
|||
case ".doc" -> validateLegacyDoc(content); |
|||
case ".docx" -> validateDocx(content); |
|||
default -> reject(); |
|||
} |
|||
} |
|||
|
|||
private void validatePdf(byte[] content) { |
|||
int offset = startsWith(content, new byte[]{(byte) 0xEF, (byte) 0xBB, (byte) 0xBF}) ? 3 : 0; |
|||
while (offset < content.length && offset < 16 && isAsciiWhitespace(content[offset])) { |
|||
offset++; |
|||
} |
|||
if (!startsWithAt(content, PDF_SIGNATURE, offset)) { |
|||
reject(); |
|||
} |
|||
int searchStart = Math.max(0, content.length - 2_048); |
|||
if (indexOf(content, new byte[]{'%', '%', 'E', 'O', 'F'}, searchStart) < 0) { |
|||
reject(); |
|||
} |
|||
} |
|||
|
|||
private void validateLegacyDoc(byte[] content) { |
|||
if (!startsWith(content, OLE_SIGNATURE) || indexOf(content, WORD_DOCUMENT_UTF16_LE, 0) < 0) { |
|||
reject(); |
|||
} |
|||
} |
|||
|
|||
private void validateDocx(byte[] content) { |
|||
if (!startsWith(content, new byte[]{'P', 'K'})) { |
|||
reject(); |
|||
} |
|||
|
|||
boolean contentTypesFound = false; |
|||
boolean documentFound = false; |
|||
int entryCount = 0; |
|||
long uncompressedBytes = 0; |
|||
byte[] buffer = new byte[BUFFER_SIZE]; |
|||
|
|||
try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(content))) { |
|||
ZipEntry entry; |
|||
while ((entry = zip.getNextEntry()) != null) { |
|||
entryCount++; |
|||
if (entryCount > MAX_DOCX_ENTRIES || unsafeEntryName(entry.getName())) { |
|||
reject(); |
|||
} |
|||
if ("[Content_Types].xml".equals(entry.getName())) { |
|||
contentTypesFound = true; |
|||
} else if ("word/document.xml".equals(entry.getName())) { |
|||
documentFound = true; |
|||
} |
|||
|
|||
int read; |
|||
while ((read = zip.read(buffer)) != -1) { |
|||
uncompressedBytes += read; |
|||
if (uncompressedBytes > MAX_DOCX_UNCOMPRESSED_BYTES |
|||
|| uncompressedBytes > safeMultiply(content.length, MAX_DOCX_COMPRESSION_RATIO)) { |
|||
reject(); |
|||
} |
|||
} |
|||
zip.closeEntry(); |
|||
} |
|||
} catch (ZipException e) { |
|||
reject(); |
|||
} catch (IOException e) { |
|||
reject(); |
|||
} |
|||
|
|||
if (!contentTypesFound || !documentFound) { |
|||
reject(); |
|||
} |
|||
} |
|||
|
|||
private boolean unsafeEntryName(String name) { |
|||
if (name == null || name.isBlank() || name.startsWith("/") || name.startsWith("\\") |
|||
|| name.contains("\\") || name.indexOf('\0') >= 0 || name.contains(":")) { |
|||
return true; |
|||
} |
|||
for (String segment : name.split("/")) { |
|||
if ("..".equals(segment)) { |
|||
return true; |
|||
} |
|||
} |
|||
return false; |
|||
} |
|||
|
|||
private long safeMultiply(long value, long multiplier) { |
|||
if (value > Long.MAX_VALUE / multiplier) { |
|||
return Long.MAX_VALUE; |
|||
} |
|||
return value * multiplier; |
|||
} |
|||
|
|||
private String extensionOf(String filename) { |
|||
int dot = filename.lastIndexOf('.'); |
|||
if (dot <= 0 || dot == filename.length() - 1) { |
|||
reject(); |
|||
} |
|||
return filename.substring(dot).toLowerCase(java.util.Locale.ROOT); |
|||
} |
|||
|
|||
private boolean startsWith(byte[] content, byte[] signature) { |
|||
return startsWithAt(content, signature, 0); |
|||
} |
|||
|
|||
private boolean startsWithAt(byte[] content, byte[] signature, int offset) { |
|||
if (offset < 0 || content.length - offset < signature.length) { |
|||
return false; |
|||
} |
|||
for (int i = 0; i < signature.length; i++) { |
|||
if (content[offset + i] != signature[i]) { |
|||
return false; |
|||
} |
|||
} |
|||
return true; |
|||
} |
|||
|
|||
private int indexOf(byte[] content, byte[] target, int start) { |
|||
outer: |
|||
for (int i = Math.max(0, start); i <= content.length - target.length; i++) { |
|||
for (int j = 0; j < target.length; j++) { |
|||
if (content[i + j] != target[j]) { |
|||
continue outer; |
|||
} |
|||
} |
|||
return i; |
|||
} |
|||
return -1; |
|||
} |
|||
|
|||
private boolean isAsciiWhitespace(byte value) { |
|||
return value == ' ' || value == '\t' || value == '\r' || value == '\n' || value == '\f'; |
|||
} |
|||
|
|||
private void reject() { |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST); |
|||
} |
|||
} |
|||
@ -0,0 +1,54 @@ |
|||
package com.mydreamjob.profile.storage; |
|||
|
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
|
|||
/** 文件名白名单处理:阻止路径字符、控制字符和响应头换行进入存储元数据或下载响应。 */ |
|||
public final class ResumeFilenameSanitizer { |
|||
|
|||
private static final int MAX_FILENAME_LENGTH = 255; |
|||
|
|||
private ResumeFilenameSanitizer() { |
|||
} |
|||
|
|||
public static void validateUploadName(String filename) { |
|||
if (filename == null || filename.isBlank() || filename.length() > MAX_FILENAME_LENGTH) { |
|||
reject(); |
|||
} |
|||
for (int i = 0; i < filename.length(); i++) { |
|||
char c = filename.charAt(i); |
|||
if (Character.isISOControl(c) || c == '/' || c == '\\' || c == ':') { |
|||
reject(); |
|||
} |
|||
} |
|||
if (".".equals(filename) || "..".equals(filename)) { |
|||
reject(); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 兼容历史数据的下载文件名净化。新上传文件已在入口处拒绝危险字符, |
|||
* 此方法为旧记录提供防御性兜底,不返回路径片段或换行。 |
|||
*/ |
|||
public static String forDownload(String filename, String fallback) { |
|||
String safeFallback = (fallback == null || fallback.isBlank()) ? "resume" : fallback; |
|||
if (filename == null || filename.isBlank()) { |
|||
return safeFallback; |
|||
} |
|||
StringBuilder safe = new StringBuilder(Math.min(filename.length(), MAX_FILENAME_LENGTH)); |
|||
for (int i = 0; i < filename.length() && safe.length() < MAX_FILENAME_LENGTH; i++) { |
|||
char c = filename.charAt(i); |
|||
if (Character.isISOControl(c) || c == '/' || c == '\\' || c == ':') { |
|||
safe.append('_'); |
|||
} else { |
|||
safe.append(c); |
|||
} |
|||
} |
|||
String result = safe.toString().trim(); |
|||
return result.isEmpty() || ".".equals(result) || "..".equals(result) ? safeFallback : result; |
|||
} |
|||
|
|||
private static void reject() { |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST); |
|||
} |
|||
} |
|||
@ -0,0 +1,153 @@ |
|||
package com.mydreamjob.profile; |
|||
|
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.profile.storage.ResumeStorageService; |
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.data.redis.core.ValueOperations; |
|||
import org.springframework.mock.web.MockMultipartFile; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.transaction.PlatformTransactionManager; |
|||
import org.springframework.transaction.support.TransactionTemplate; |
|||
|
|||
import java.nio.charset.StandardCharsets; |
|||
import java.util.ArrayList; |
|||
import java.util.HashSet; |
|||
import java.util.List; |
|||
import java.util.Set; |
|||
import java.util.concurrent.atomic.AtomicLong; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.assertArrayEquals; |
|||
import static org.junit.jupiter.api.Assertions.assertEquals; |
|||
import static org.junit.jupiter.api.Assertions.assertThrows; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.Mockito.mock; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
@SpringBootTest |
|||
@ActiveProfiles("integration-test") |
|||
class ResumeUploadConsistencyIntegrationTest { |
|||
|
|||
private static final AtomicLong PHONE_SEQUENCE = new AtomicLong(800_000); |
|||
|
|||
@Autowired private ProfileService profileService; |
|||
@Autowired private StudentProfileRepository profileRepository; |
|||
@Autowired private UserRepository userRepository; |
|||
@Autowired private ResumeStorageService storageService; |
|||
@Autowired private PlatformTransactionManager transactionManager; |
|||
|
|||
@MockBean private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
private final List<String> testStoragePaths = new ArrayList<>(); |
|||
private Long userId; |
|||
|
|||
@BeforeEach |
|||
@SuppressWarnings("unchecked") |
|||
void setUp() { |
|||
ValueOperations<String, Object> operations = mock(ValueOperations.class); |
|||
when(redisTemplate.opsForValue()).thenReturn(operations); |
|||
when(redisTemplate.hasKey(any())).thenReturn(false); |
|||
|
|||
User user = User.builder() |
|||
.phone("177" + String.format("%08d", PHONE_SEQUENCE.incrementAndGet())) |
|||
.nickname("上传一致性测试") |
|||
.role(User.Role.STUDENT) |
|||
.active(true) |
|||
.verified(true) |
|||
.build(); |
|||
user.setRoles(new HashSet<>(Set.of(User.Role.STUDENT))); |
|||
userId = userRepository.saveAndFlush(user).getId(); |
|||
} |
|||
|
|||
@AfterEach |
|||
void tearDown() { |
|||
for (String path : testStoragePaths) { |
|||
storageService.delete(path); |
|||
} |
|||
profileRepository.findByUserId(userId).ifPresent(profileRepository::delete); |
|||
userRepository.findById(userId).ifPresent(userRepository::delete); |
|||
} |
|||
|
|||
@Test |
|||
void successfulReplacementCommitsNewReferenceBeforeDeletingOldFile() { |
|||
String oldPath = store(pdf("old")); |
|||
saveProfile(oldPath, "old.pdf"); |
|||
|
|||
@SuppressWarnings("unchecked") |
|||
String newPath = (String) profileService.uploadResume(userId, multipart("new.pdf", pdf("new"))) |
|||
.get("resumeUrl"); |
|||
testStoragePaths.add(newPath); |
|||
|
|||
StudentProfile persisted = profileRepository.findByUserId(userId).orElseThrow(); |
|||
assertEquals(newPath, persisted.getResumeUrl()); |
|||
assertArrayEquals(pdf("new"), storageService.read(newPath)); |
|||
assertThrows(BusinessException.class, () -> storageService.read(oldPath)); |
|||
} |
|||
|
|||
@Test |
|||
void outerRollbackRestoresOldReferenceAndDeletesOnlyNewFile() { |
|||
String oldPath = store(pdf("old")); |
|||
saveProfile(oldPath, "old.pdf"); |
|||
TransactionTemplate transaction = new TransactionTemplate(transactionManager); |
|||
|
|||
String newPath = transaction.execute(status -> { |
|||
String stored = (String) profileService.uploadResume(userId, multipart("new.pdf", pdf("new"))) |
|||
.get("resumeUrl"); |
|||
testStoragePaths.add(stored); |
|||
status.setRollbackOnly(); |
|||
return stored; |
|||
}); |
|||
|
|||
StudentProfile persisted = profileRepository.findByUserId(userId).orElseThrow(); |
|||
assertEquals(oldPath, persisted.getResumeUrl()); |
|||
assertArrayEquals(pdf("old"), storageService.read(oldPath)); |
|||
assertThrows(BusinessException.class, () -> storageService.read(newPath)); |
|||
} |
|||
|
|||
@Test |
|||
void deleteRollbackKeepsDatabaseReferenceAndStoredFile() { |
|||
String oldPath = store(pdf("old")); |
|||
saveProfile(oldPath, "old.pdf"); |
|||
TransactionTemplate transaction = new TransactionTemplate(transactionManager); |
|||
|
|||
transaction.executeWithoutResult(status -> { |
|||
profileService.deleteResume(userId); |
|||
status.setRollbackOnly(); |
|||
}); |
|||
|
|||
StudentProfile persisted = profileRepository.findByUserId(userId).orElseThrow(); |
|||
assertEquals(oldPath, persisted.getResumeUrl()); |
|||
assertArrayEquals(pdf("old"), storageService.read(oldPath)); |
|||
} |
|||
|
|||
private String store(byte[] content) { |
|||
String path = storageService.store(userId, multipart("resume.pdf", content)); |
|||
testStoragePaths.add(path); |
|||
return path; |
|||
} |
|||
|
|||
private void saveProfile(String path, String filename) { |
|||
profileRepository.saveAndFlush(StudentProfile.builder() |
|||
.userId(userId) |
|||
.resumeUrl(path) |
|||
.resumeOriginalName(filename) |
|||
.resumeContentType("application/pdf") |
|||
.resumeSize((long) pdf("old").length) |
|||
.build()); |
|||
} |
|||
|
|||
private MockMultipartFile multipart(String filename, byte[] content) { |
|||
return new MockMultipartFile("file", filename, "application/pdf", content); |
|||
} |
|||
|
|||
private byte[] pdf(String body) { |
|||
return ("%PDF-1.7\n" + body + "\n%%EOF\n").getBytes(StandardCharsets.US_ASCII); |
|||
} |
|||
} |
|||
@ -0,0 +1,142 @@ |
|||
package com.mydreamjob.profile.storage; |
|||
|
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.mock.web.MockMultipartFile; |
|||
|
|||
import java.io.ByteArrayOutputStream; |
|||
import java.io.IOException; |
|||
import java.util.zip.ZipEntry; |
|||
import java.util.zip.ZipOutputStream; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; |
|||
import static org.junit.jupiter.api.Assertions.assertThrows; |
|||
|
|||
class ResumeFileValidatorTest { |
|||
|
|||
private final ResumeFileValidator validator = new ResumeFileValidator(); |
|||
|
|||
@Test |
|||
void validPdfDocAndDocxAreAccepted() throws Exception { |
|||
assertDoesNotThrow(() -> validator.validate(file("resume.pdf", "application/pdf", pdf("profile")))); |
|||
assertDoesNotThrow(() -> validator.validate(file("resume.doc", "application/msword", legacyDoc()))); |
|||
assertDoesNotThrow(() -> validator.validate(file("resume.docx", docxType(), docx("docProps/core.xml", "profile")))); |
|||
} |
|||
|
|||
@Test |
|||
void fakePdfAndFakeLegacyDocAreRejected() { |
|||
assertThrows(BusinessException.class, |
|||
() -> validator.validate(file("resume.pdf", "application/pdf", "not a pdf".getBytes()))); |
|||
assertThrows(BusinessException.class, |
|||
() -> validator.validate(file("resume.doc", "application/msword", "not a doc".getBytes()))); |
|||
} |
|||
|
|||
@Test |
|||
void extensionContentTypeMismatchIsRejectedEvenWhenSignatureLooksValid() { |
|||
assertThrows(BusinessException.class, |
|||
() -> validator.validate(file("resume.pdf", "application/msword", pdf("profile")))); |
|||
} |
|||
|
|||
@Test |
|||
void arbitraryZipAndMissingDocumentPartAreRejected() throws Exception { |
|||
assertThrows(BusinessException.class, |
|||
() -> validator.validate(file("resume.docx", docxType(), |
|||
zip(new Entry("payload.txt", "not office"))))); |
|||
assertThrows(BusinessException.class, |
|||
() -> validator.validate(file("resume.docx", docxType(), |
|||
zip(new Entry("[Content_Types].xml", "types"))))); |
|||
} |
|||
|
|||
@Test |
|||
void docxPathTraversalEntryIsRejected() throws Exception { |
|||
byte[] malicious = zip( |
|||
new Entry("[Content_Types].xml", "types"), |
|||
new Entry("word/document.xml", "document"), |
|||
new Entry("../outside.txt", "secret")); |
|||
|
|||
assertThrows(BusinessException.class, |
|||
() -> validator.validate(file("resume.docx", docxType(), malicious))); |
|||
} |
|||
|
|||
@Test |
|||
void highlyCompressedDocxPayloadIsRejected() throws Exception { |
|||
byte[] repeated = new byte[1024 * 1024]; |
|||
byte[] bomb = zip( |
|||
new Entry("[Content_Types].xml", "types"), |
|||
new Entry("word/document.xml", "document"), |
|||
new Entry("word/media/bomb.bin", repeated)); |
|||
|
|||
assertThrows(BusinessException.class, |
|||
() -> validator.validate(file("resume.docx", docxType(), bomb))); |
|||
} |
|||
|
|||
@Test |
|||
void unsafeOrOversizedFilenameIsRejected() { |
|||
assertThrows(BusinessException.class, |
|||
() -> validator.validate(file("..\\resume.pdf", "application/pdf", pdf("profile")))); |
|||
assertThrows(BusinessException.class, |
|||
() -> validator.validate(file("resume\r\nX-Test.pdf", "application/pdf", pdf("profile")))); |
|||
assertThrows(BusinessException.class, |
|||
() -> validator.validate(file("a".repeat(252) + ".pdf", "application/pdf", pdf("profile")))); |
|||
} |
|||
|
|||
@Test |
|||
void downloadFilenameSanitizerProtectsLegacyMetadata() { |
|||
String safe = ResumeFilenameSanitizer.forDownload("..\\old\r\nInjected: value.pdf", "resume.pdf"); |
|||
org.junit.jupiter.api.Assertions.assertFalse(safe.contains("\\")); |
|||
org.junit.jupiter.api.Assertions.assertFalse(safe.contains("\r")); |
|||
org.junit.jupiter.api.Assertions.assertFalse(safe.contains("\n")); |
|||
org.junit.jupiter.api.Assertions.assertFalse(safe.contains(":")); |
|||
} |
|||
|
|||
static byte[] pdf(String body) { |
|||
return ("%PDF-1.7\n" + body + "\n%%EOF\n").getBytes(java.nio.charset.StandardCharsets.US_ASCII); |
|||
} |
|||
|
|||
static byte[] legacyDoc() { |
|||
byte[] marker = "WordDocument".getBytes(java.nio.charset.StandardCharsets.UTF_16LE); |
|||
byte[] bytes = new byte[512 + marker.length]; |
|||
byte[] signature = { |
|||
(byte) 0xD0, (byte) 0xCF, 0x11, (byte) 0xE0, |
|||
(byte) 0xA1, (byte) 0xB1, 0x1A, (byte) 0xE1 |
|||
}; |
|||
System.arraycopy(signature, 0, bytes, 0, signature.length); |
|||
System.arraycopy(marker, 0, bytes, 512, marker.length); |
|||
return bytes; |
|||
} |
|||
|
|||
static byte[] docx(String extraName, String extraContent) throws IOException { |
|||
return zip( |
|||
new Entry("[Content_Types].xml", "<Types/>"), |
|||
new Entry("word/document.xml", "<w:document/>"), |
|||
new Entry(extraName, extraContent)); |
|||
} |
|||
|
|||
private static byte[] zip(Entry... entries) throws IOException { |
|||
ByteArrayOutputStream output = new ByteArrayOutputStream(); |
|||
try (ZipOutputStream zip = new ZipOutputStream(output)) { |
|||
for (Entry entry : entries) { |
|||
zip.putNextEntry(new ZipEntry(entry.name())); |
|||
if (entry.bytes() != null) { |
|||
zip.write(entry.bytes()); |
|||
} |
|||
zip.closeEntry(); |
|||
} |
|||
} |
|||
return output.toByteArray(); |
|||
} |
|||
|
|||
private static MockMultipartFile file(String name, String contentType, byte[] content) { |
|||
return new MockMultipartFile("file", name, contentType, content); |
|||
} |
|||
|
|||
private static String docxType() { |
|||
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; |
|||
} |
|||
|
|||
private record Entry(String name, byte[] bytes) { |
|||
Entry(String name, String content) { |
|||
this(name, content.getBytes(java.nio.charset.StandardCharsets.UTF_8)); |
|||
} |
|||
} |
|||
} |
|||
Loading…
Reference in new issue