From e2fd9ba60ba3640fb093430025f17fd12452505b Mon Sep 17 00:00:00 2001 From: John Niang Date: Sat, 22 Mar 2025 23:37:27 +0800 Subject: [PATCH] Support randomizing local attachment filename (#7301) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### What type of PR is this? /kind feature /area core /milestone 2.20.x #### What this PR does / why we need it: This PR allows users to upload local attachment always with a random filename to simply prevent resource leak. Please see the configuration and the uploaded result below: ![image](https://github.com/user-attachments/assets/a479842a-9c8f-41d0-aab7-17ed35ba772a) ```json { "spec": { "displayName": "halo.run-ykfswxmokpjopvkqwybghazloxeovgae.cer", "policyName": "attachment-policy-XVdDK", "ownerName": "admin", "mediaType": "application/pkix-cert", "size": 1803 }, "status": { "permalink": "/upload/random/halo.run-ykfswxmokpjopvkqwybghazloxeovgae.cer" }, "apiVersion": "storage.halo.run/v1alpha1", "kind": "Attachment", "metadata": { "finalizers": [ "attachment-manager" ], "name": "44b4c8de-0d3b-4bbb-acc2-4af50175a2b5", "annotations": { "storage.halo.run/local-relative-path": "upload/random/halo.run-ykfswxmokpjopvkqwybghazloxeovgae.cer", "storage.halo.run/uri": "/upload/random/halo.run-ykfswxmokpjopvkqwybghazloxeovgae.cer" }, "version": 2, "creationTimestamp": "2025-03-18T15:53:11.817541483Z" } } ``` #### Does this PR introduce a user-facing change? ```release-note 支持上传附件至本地时总是随机命名文件名 ``` --- .../LocalAttachmentUploadHandler.java | 100 ++++++++- .../halo/app/infra/utils/FileNameUtils.java | 24 ++- .../extensions/attachment-local-policy.yaml | 33 +++ .../LocalAttachmentUploadHandlerTest.java | 202 ++++++++++++++++++ 4 files changed, 350 insertions(+), 9 deletions(-) create mode 100644 application/src/test/java/run/halo/app/core/attachment/endpoint/LocalAttachmentUploadHandlerTest.java diff --git a/application/src/main/java/run/halo/app/core/attachment/endpoint/LocalAttachmentUploadHandler.java b/application/src/main/java/run/halo/app/core/attachment/endpoint/LocalAttachmentUploadHandler.java index 3c0896dfe..f7c8fd60c 100644 --- a/application/src/main/java/run/halo/app/core/attachment/endpoint/LocalAttachmentUploadHandler.java +++ b/application/src/main/java/run/halo/app/core/attachment/endpoint/LocalAttachmentUploadHandler.java @@ -12,6 +12,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.FileAlreadyExistsException; import java.nio.file.Files; import java.nio.file.Path; +import java.time.Clock; import java.time.Duration; import java.util.ArrayList; import java.util.Map; @@ -21,6 +22,7 @@ import java.util.UUID; import java.util.concurrent.atomic.AtomicReference; import lombok.Data; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.RandomStringUtils; import org.reactivestreams.Publisher; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBufferUtils; @@ -52,6 +54,7 @@ import run.halo.app.infra.FileCategoryMatcher; import run.halo.app.infra.exception.AttachmentAlreadyExistsException; import run.halo.app.infra.exception.FileSizeExceededException; import run.halo.app.infra.exception.FileTypeNotAllowedException; +import run.halo.app.infra.utils.FileNameUtils; import run.halo.app.infra.utils.FileTypeDetectUtils; import run.halo.app.infra.utils.JsonUtils; @@ -63,30 +66,45 @@ class LocalAttachmentUploadHandler implements AttachmentHandler { private final ExternalUrlSupplier externalUrl; + private Clock clock = Clock.systemUTC(); + public LocalAttachmentUploadHandler(AttachmentRootGetter attachmentDirGetter, ExternalUrlSupplier externalUrl) { this.attachmentDirGetter = attachmentDirGetter; this.externalUrl = externalUrl; } + /** + * Set clock for test. + * + * @param clock new clock + */ + void setClock(Clock clock) { + this.clock = clock; + } + @Override public Mono upload(UploadContext uploadOption) { return Mono.just(uploadOption) .filter(option -> this.shouldHandle(option.policy())) .flatMap(option -> { var configMap = option.configMap(); - var settingJson = configMap.getData().getOrDefault("default", "{}"); - var setting = JsonUtils.jsonToObject(settingJson, PolicySetting.class); + var setting = Optional.ofNullable(configMap) + .map(ConfigMap::getData) + .map(data -> data.get("default")) + .map(json -> JsonUtils.jsonToObject(json, PolicySetting.class)) + .orElseGet(PolicySetting::new); final var attachmentsRoot = attachmentDirGetter.get(); final var uploadRoot = attachmentsRoot.resolve("upload"); final var file = option.file(); final Path attachmentPath; + final String filename = getFilename(file.filename(), setting); if (StringUtils.hasText(setting.getLocation())) { attachmentPath = - uploadRoot.resolve(setting.getLocation()).resolve(file.filename()); + uploadRoot.resolve(setting.getLocation()).resolve(filename); } else { - attachmentPath = uploadRoot.resolve(file.filename()); + attachmentPath = uploadRoot.resolve(filename); } checkDirectoryTraversal(uploadRoot, attachmentPath); @@ -102,7 +120,7 @@ class LocalAttachmentUploadHandler implements AttachmentHandler { .subscribeOn(Schedulers.boundedElastic()) .then(writeContent(file.content(), attachmentPath, true)) .map(path -> { - log.info("Wrote attachment {} into {}", file.filename(), path); + log.info("Wrote attachment {} into {}", filename, path); // TODO check the file extension var metadata = new Metadata(); metadata.setName(UUID.randomUUID().toString()); @@ -305,6 +323,56 @@ class LocalAttachmentUploadHandler implements AttachmentHandler { }); } + private String getFilename(String filename, PolicySetting setting) { + if (!setting.isAlwaysRenameFilename()) { + return filename; + } + var renameStrategy = setting.getRenameStrategy(); + if (renameStrategy == null) { + return filename; + } + var renameMethod = renameStrategy.getMethod(); + if (renameMethod == null) { + renameMethod = RenameMethod.RANDOM; + } + var excludeOriginalFilename = renameStrategy.isExcludeOriginalFilename(); + switch (renameMethod) { + case TIMESTAMP -> { + return FileNameUtils.renameFilename( + filename, + () -> { + var now = clock.instant(); + return now.toEpochMilli() + ""; + }, + excludeOriginalFilename); + } + case UUID -> { + return FileNameUtils.renameFilename( + filename, + () -> UUID.randomUUID().toString(), + excludeOriginalFilename + ); + } + default -> { + return FileNameUtils.renameFilename( + filename, + () -> { + var length = renameStrategy.getRandomLength(); + if (length < 8) { + length = 8; + } else if (length > 64) { + // The max filename length is 256, so we limit the random length to 64 + // for most cases. + length = 64; + } + return RandomStringUtils.secure().nextAlphabetic(length); + }, + excludeOriginalFilename); + } + } + } + + @Data public static class PolicySetting { @@ -314,6 +382,10 @@ class LocalAttachmentUploadHandler implements AttachmentHandler { private Set allowedFileTypes; + private boolean alwaysRenameFilename; + + private RenameStrategy renameStrategy; + public void setMaxFileSize(String maxFileSize) { if (!StringUtils.hasText(maxFileSize)) { return; @@ -321,4 +393,22 @@ class LocalAttachmentUploadHandler implements AttachmentHandler { this.maxFileSize = DataSize.parse(maxFileSize); } } + + public enum RenameMethod { + RANDOM, + UUID, + TIMESTAMP + } + + @Data + public static class RenameStrategy { + + private RenameMethod method; + + private int randomLength = 32; + + private boolean excludeOriginalFilename; + + } + } diff --git a/application/src/main/java/run/halo/app/infra/utils/FileNameUtils.java b/application/src/main/java/run/halo/app/infra/utils/FileNameUtils.java index 1101121fd..d7b7691fe 100644 --- a/application/src/main/java/run/halo/app/infra/utils/FileNameUtils.java +++ b/application/src/main/java/run/halo/app/infra/utils/FileNameUtils.java @@ -1,6 +1,7 @@ package run.halo.app.infra.utils; import com.google.common.io.Files; +import java.util.function.Supplier; import java.util.regex.Pattern; import org.apache.commons.lang3.RandomStringUtils; import org.apache.commons.lang3.StringUtils; @@ -45,15 +46,30 @@ public final class FileNameUtils { * @return File name with random string. */ public static String randomFileName(String filename, int length) { + return renameFilename( + filename, () -> RandomStringUtils.secure().nextAlphabetic(length), false + ); + } + + public static String renameFilename( + String filename, + Supplier renameSupplier, + boolean excludeBasename) { var nameWithoutExt = Files.getNameWithoutExtension(filename); var ext = Files.getFileExtension(filename); - var random = RandomStringUtils.randomAlphabetic(length).toLowerCase(); + var rename = renameSupplier.get(); if (StringUtils.isBlank(nameWithoutExt)) { - return random + "." + ext; + return rename + "." + ext; } if (StringUtils.isBlank(ext)) { - return nameWithoutExt + "-" + random; + if (excludeBasename) { + return rename; + } + return nameWithoutExt + "-" + rename; } - return nameWithoutExt + "-" + random + "." + ext; + if (excludeBasename) { + return rename + "." + ext; + } + return nameWithoutExt + "-" + rename + "." + ext; } } diff --git a/application/src/main/resources/extensions/attachment-local-policy.yaml b/application/src/main/resources/extensions/attachment-local-policy.yaml index 645895d28..c4a6684b9 100644 --- a/application/src/main/resources/extensions/attachment-local-policy.yaml +++ b/application/src/main/resources/extensions/attachment-local-policy.yaml @@ -66,6 +66,39 @@ spec: value: DOCUMENT - label: 压缩包 value: ARCHIVE + - $formkit: checkbox + name: alwaysRenameFilename + label: 是否总是重命名文件名 + help: 勾选后上传后的文件名将被重命名 + - $formkit: group + if: $alwaysRenameFilename + name: renameStrategy + label: 重命名策略 + children: + - $formkit: radio + name: method + label: 重命名方法 + options: + - label: 随机字符串 + value: RANDOM + - label: UUID + value: UUID + - label: 时间戳(毫秒级) + value: TIMESTAMP + - $formkit: number + number: integer + if: $renameStrategy.renameMethod === RANDOM + name: randomLength + label: 随机文件名长度 + help: 默认值为 32。因为文件名的长度限制,随机文件名的长度范围为 [8, 64]。 + validation: "between:8,64" + validation-visibility: live + min: 8 + max: 64 + - $formkit: checkbox + name: excludeOriginalFilename + label: 是否排除原始文件名 + help: 勾选后重命名后的文件名将不包含原始文件名 --- apiVersion: storage.halo.run/v1alpha1 kind: Group diff --git a/application/src/test/java/run/halo/app/core/attachment/endpoint/LocalAttachmentUploadHandlerTest.java b/application/src/test/java/run/halo/app/core/attachment/endpoint/LocalAttachmentUploadHandlerTest.java new file mode 100644 index 000000000..cd3972028 --- /dev/null +++ b/application/src/test/java/run/halo/app/core/attachment/endpoint/LocalAttachmentUploadHandlerTest.java @@ -0,0 +1,202 @@ +package run.halo.app.core.attachment.endpoint; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.params.provider.Arguments.arguments; +import static org.mockito.Mockito.when; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import java.util.Map; +import java.util.function.Consumer; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.http.MediaType; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; +import run.halo.app.core.attachment.AttachmentRootGetter; +import run.halo.app.core.extension.attachment.Attachment; +import run.halo.app.core.extension.attachment.Policy; +import run.halo.app.core.extension.attachment.endpoint.UploadOption; +import run.halo.app.extension.ConfigMap; + +@ExtendWith(MockitoExtension.class) +class LocalAttachmentUploadHandlerTest { + + @InjectMocks + LocalAttachmentUploadHandler uploadHandler; + + @Mock + AttachmentRootGetter attachmentRootGetter; + + @TempDir + Path tempDir; + + static Clock clock = Clock.fixed(Instant.now(), ZoneId.systemDefault()); + + @BeforeEach + void setUp() { + uploadHandler.setClock(clock); + } + + public static Stream testUploadWithRenameStrategy() { + return Stream.of(arguments( + "Random file name with length 10", + """ + { + "alwaysRenameFilename": true, + "renameStrategy": { + "method": "RANDOM", + "randomLength": 10 + } + } + """, + (Consumer) attachment -> { + var displayName = attachment.getSpec().getDisplayName(); + assertTrue(displayName.startsWith("halo-")); + assertTrue(displayName.endsWith(".png")); + // halo-xxxxxx.png + assertEquals(4 + 10 + 5, displayName.length()); + // fake-content + assertEquals(12L, attachment.getSpec().getSize()); + }), + arguments( + "Random file name with length 10 but without original filename", + """ + { + "alwaysRenameFilename": true, + "renameStrategy": { + "method": "RANDOM", + "randomLength": 10, + "excludeOriginalFilename": true + } + } + """, + (Consumer) attachment -> { + var displayName = attachment.getSpec().getDisplayName(); + assertFalse(displayName.startsWith("halo-")); + assertTrue(displayName.endsWith(".png")); + // halo-xxxxxx.png + assertEquals(10 + 4, displayName.length()); + // fake-content + assertEquals(12L, attachment.getSpec().getSize()); + }), + arguments( + "Rename filename with UUID but exclude original filename", + """ + { + "alwaysRenameFilename": true, + "renameStrategy": { + "method": "UUID", + "excludeOriginalFilename": true + } + } + """, + (Consumer) attachment -> { + var displayName = attachment.getSpec().getDisplayName(); + assertFalse(displayName.startsWith("halo-")); + assertTrue(displayName.endsWith(".png")); + // xxxxxx.png + assertEquals(36 + 4, displayName.length()); + // fake-content + assertEquals(12L, attachment.getSpec().getSize()); + } + ), + arguments( + "Rename filename with UUID", + """ + { + "alwaysRenameFilename": true, + "renameStrategy": { + "method": "UUID", + "excludeOriginalFilename": false + } + } + """, + (Consumer) attachment -> { + var displayName = attachment.getSpec().getDisplayName(); + assertTrue(displayName.startsWith("halo-")); + assertTrue(displayName.endsWith(".png")); + // xxxxxx.png + assertEquals(5 + 36 + 4, displayName.length()); + // fake-content + assertEquals(12L, attachment.getSpec().getSize()); + } + ), + arguments( + "Rename filename with timestamp but without original filename", + """ + { + "alwaysRenameFilename": true, + "renameStrategy": { + "method": "TIMESTAMP", + "excludeOriginalFilename": true + } + } + """, + (Consumer) attachment -> { + var expect = clock.instant().toEpochMilli() + ".png"; + assertEquals(expect, attachment.getSpec().getDisplayName()); + } + ), + arguments( + "Rename filename with timestamp", + """ + { + "alwaysRenameFilename": true, + "renameStrategy": { + "method": "TIMESTAMP" + } + } + """, + (Consumer) attachment -> { + var expect = "halo-" + clock.instant().toEpochMilli() + ".png"; + assertEquals(expect, attachment.getSpec().getDisplayName()); + } + ) + ); + } + + @ParameterizedTest(name = "{0}") + @MethodSource + void testUploadWithRenameStrategy(String name, String config, Consumer assertion) { + assertNotNull(uploadHandler); + var dataBufferFactory = new DefaultDataBufferFactory(); + var dataBuffer = dataBufferFactory.allocateBuffer(1024); + dataBuffer.write("fake content".getBytes(StandardCharsets.UTF_8)); + var content = Flux.just(dataBuffer); + + var policy = new Policy(); + var policySpec = new Policy.PolicySpec(); + policy.setSpec(policySpec); + policySpec.setTemplateName("local"); + + var configMap = new ConfigMap(); + configMap.setData(Map.of("default", config)); + + var uploadOption = + UploadOption.from("halo.png", content, MediaType.IMAGE_PNG, policy, configMap); + + when(attachmentRootGetter.get()).thenReturn(tempDir); + uploadHandler.upload(uploadOption) + .as(StepVerifier::create) + .assertNext(assertion) + .verifyComplete(); + } + +} \ No newline at end of file