Support randomizing local attachment filename (#7301)

#### 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
支持上传附件至本地时总是随机命名文件名
```
pull/7313/head v2.20.18
John Niang 2025-03-22 23:37:27 +08:00 committed by GitHub
parent 39f6f09dcc
commit e2fd9ba60b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 350 additions and 9 deletions

View File

@ -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<Attachment> 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<String> 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;
}
}

View File

@ -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<String> 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;
}
}

View File

@ -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

View File

@ -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<Arguments> testUploadWithRenameStrategy() {
return Stream.of(arguments(
"Random file name with length 10",
"""
{
"alwaysRenameFilename": true,
"renameStrategy": {
"method": "RANDOM",
"randomLength": 10
}
}
""",
(Consumer<Attachment>) 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>) 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>) 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>) 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>) attachment -> {
var expect = clock.instant().toEpochMilli() + ".png";
assertEquals(expect, attachment.getSpec().getDisplayName());
}
),
arguments(
"Rename filename with timestamp",
"""
{
"alwaysRenameFilename": true,
"renameStrategy": {
"method": "TIMESTAMP"
}
}
""",
(Consumer<Attachment>) attachment -> {
var expect = "halo-" + clock.instant().toEpochMilli() + ".png";
assertEquals(expect, attachment.getSpec().getDisplayName());
}
)
);
}
@ParameterizedTest(name = "{0}")
@MethodSource
void testUploadWithRenameStrategy(String name, String config, Consumer<Attachment> assertion) {
assertNotNull(uploadHandler);
var dataBufferFactory = new DefaultDataBufferFactory();
var dataBuffer = dataBufferFactory.allocateBuffer(1024);
dataBuffer.write("fake content".getBytes(StandardCharsets.UTF_8));
var content = Flux.<DataBuffer>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();
}
}