mirror of https://github.com/halo-dev/halo
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:  ```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
parent
39f6f09dcc
commit
e2fd9ba60b
|
@ -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;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue