mirror of https://github.com/halo-dev/halo
Auto rename attachment if it exists (#3305)
#### What type of PR is this? /kind improvement /area core /milestone 2.3.x #### What this PR does / why we need it: Before this PR, halo will throw an FileAlreadyExists exception if users upload file which already exists. But now, we will rename the attachment automatically on filename conflict. e.g.: ```bash halo.run -> halo-xyz.run .run -> xyz.run halo -> halo-xyz ``` #### Which issue(s) this PR fixes: Fixes https://github.com/halo-dev/halo/issues/3218 #### Special notes for your reviewer:  #### Does this PR introduce a user-facing change? ```release-note 附件已存在时自动重命名 ```pull/3314/head^2
parent
e485acef66
commit
5f7ea18f7c
|
@ -1,6 +1,7 @@
|
||||||
package run.halo.app.core.extension.attachment.endpoint;
|
package run.halo.app.core.extension.attachment.endpoint;
|
||||||
|
|
||||||
import static java.nio.file.StandardOpenOption.CREATE_NEW;
|
import static java.nio.file.StandardOpenOption.CREATE_NEW;
|
||||||
|
import static run.halo.app.infra.utils.FileNameUtils.randomFileName;
|
||||||
import static run.halo.app.infra.utils.FileUtils.checkDirectoryTraversal;
|
import static run.halo.app.infra.utils.FileUtils.checkDirectoryTraversal;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
@ -12,16 +13,20 @@ import java.util.ArrayList;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.core.io.buffer.DataBuffer;
|
||||||
import org.springframework.core.io.buffer.DataBufferUtils;
|
import org.springframework.core.io.buffer.DataBufferUtils;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
import org.springframework.web.util.UriComponentsBuilder;
|
import org.springframework.web.util.UriComponentsBuilder;
|
||||||
import reactor.core.Exceptions;
|
import reactor.core.Exceptions;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
import reactor.core.scheduler.Schedulers;
|
import reactor.core.scheduler.Schedulers;
|
||||||
|
import reactor.util.retry.Retry;
|
||||||
import run.halo.app.core.extension.attachment.Attachment;
|
import run.halo.app.core.extension.attachment.Attachment;
|
||||||
import run.halo.app.core.extension.attachment.Attachment.AttachmentSpec;
|
import run.halo.app.core.extension.attachment.Attachment.AttachmentSpec;
|
||||||
import run.halo.app.core.extension.attachment.Constant;
|
import run.halo.app.core.extension.attachment.Constant;
|
||||||
|
@ -76,18 +81,17 @@ class LocalAttachmentUploadHandler implements AttachmentHandler {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.subscribeOn(Schedulers.boundedElastic())
|
.subscribeOn(Schedulers.boundedElastic())
|
||||||
// save the attachment
|
.then(writeContent(file.content(), attachmentPath, true))
|
||||||
.then(DataBufferUtils.write(file.content(), attachmentPath, CREATE_NEW))
|
.map(path -> {
|
||||||
.then(Mono.fromCallable(() -> {
|
log.info("Wrote attachment {} into {}", file.filename(), path);
|
||||||
log.info("Wrote attachment {} into {}", file.filename(), attachmentPath);
|
|
||||||
// TODO check the file extension
|
// TODO check the file extension
|
||||||
var metadata = new Metadata();
|
var metadata = new Metadata();
|
||||||
metadata.setName(UUID.randomUUID().toString());
|
metadata.setName(UUID.randomUUID().toString());
|
||||||
var relativePath = attachmentsRoot.relativize(attachmentPath).toString();
|
var relativePath = attachmentsRoot.relativize(path).toString();
|
||||||
|
|
||||||
var pathSegments = new ArrayList<String>();
|
var pathSegments = new ArrayList<String>();
|
||||||
pathSegments.add("upload");
|
pathSegments.add("upload");
|
||||||
for (Path p : uploadRoot.relativize(attachmentPath)) {
|
for (Path p : uploadRoot.relativize(path)) {
|
||||||
pathSegments.add(p.toString());
|
pathSegments.add(p.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -100,17 +104,16 @@ class LocalAttachmentUploadHandler implements AttachmentHandler {
|
||||||
Constant.LOCAL_REL_PATH_ANNO_KEY, relativePath,
|
Constant.LOCAL_REL_PATH_ANNO_KEY, relativePath,
|
||||||
Constant.URI_ANNO_KEY, uri));
|
Constant.URI_ANNO_KEY, uri));
|
||||||
var spec = new AttachmentSpec();
|
var spec = new AttachmentSpec();
|
||||||
spec.setSize(attachmentPath.toFile().length());
|
spec.setSize(path.toFile().length());
|
||||||
file.headers().getContentType();
|
|
||||||
spec.setMediaType(Optional.ofNullable(file.headers().getContentType())
|
spec.setMediaType(Optional.ofNullable(file.headers().getContentType())
|
||||||
.map(MediaType::toString)
|
.map(MediaType::toString)
|
||||||
.orElse(null));
|
.orElse(null));
|
||||||
spec.setDisplayName(file.filename());
|
spec.setDisplayName(path.getFileName().toString());
|
||||||
var attachment = new Attachment();
|
var attachment = new Attachment();
|
||||||
attachment.setMetadata(metadata);
|
attachment.setMetadata(metadata);
|
||||||
attachment.setSpec(spec);
|
attachment.setSpec(spec);
|
||||||
return attachment;
|
return attachment;
|
||||||
}))
|
})
|
||||||
.onErrorMap(FileAlreadyExistsException.class,
|
.onErrorMap(FileAlreadyExistsException.class,
|
||||||
e -> new AttachmentAlreadyExistsException(e.getFile()));
|
e -> new AttachmentAlreadyExistsException(e.getFile()));
|
||||||
});
|
});
|
||||||
|
@ -165,4 +168,37 @@ class LocalAttachmentUploadHandler implements AttachmentHandler {
|
||||||
private String location;
|
private String location;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write content into file. We will detect duplicate filename and auto-rename it with 3 times
|
||||||
|
* retry.
|
||||||
|
*
|
||||||
|
* @param content is file content
|
||||||
|
* @param targetPath is target path
|
||||||
|
* @return file path
|
||||||
|
*/
|
||||||
|
private Mono<Path> writeContent(Flux<DataBuffer> content,
|
||||||
|
Path targetPath,
|
||||||
|
boolean renameIfExists) {
|
||||||
|
return Mono.defer(() -> {
|
||||||
|
final var pathRef = new AtomicReference<>(targetPath);
|
||||||
|
return Mono.defer(
|
||||||
|
// we have to use defer method to obtain a fresh path
|
||||||
|
() -> DataBufferUtils.write(content, pathRef.get(), CREATE_NEW))
|
||||||
|
.retryWhen(Retry.max(3)
|
||||||
|
.filter(t -> {
|
||||||
|
if (renameIfExists) {
|
||||||
|
return t instanceof FileAlreadyExistsException;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
.doAfterRetry(signal -> {
|
||||||
|
// rename the path
|
||||||
|
var oldPath = pathRef.get();
|
||||||
|
var fileName = randomFileName(oldPath.toString(), 4);
|
||||||
|
pathRef.set(oldPath.resolveSibling(fileName));
|
||||||
|
}))
|
||||||
|
.then(Mono.fromSupplier(pathRef::get));
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,9 @@
|
||||||
package run.halo.app.infra.utils;
|
package run.halo.app.infra.utils;
|
||||||
|
|
||||||
|
import com.google.common.io.Files;
|
||||||
|
import org.apache.commons.lang3.RandomStringUtils;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
|
||||||
public final class FileNameUtils {
|
public final class FileNameUtils {
|
||||||
|
|
||||||
private FileNameUtils() {
|
private FileNameUtils() {
|
||||||
|
@ -12,4 +16,29 @@ public final class FileNameUtils {
|
||||||
var extPattern = "(?<!^)[.]" + (removeAllExtensions ? ".*" : "[^.]*$");
|
var extPattern = "(?<!^)[.]" + (removeAllExtensions ? ".*" : "[^.]*$");
|
||||||
return filename.replaceAll(extPattern, "");
|
return filename.replaceAll(extPattern, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append random string after file name.
|
||||||
|
* <pre>
|
||||||
|
* Case 1: halo.run -> halo-xyz.run
|
||||||
|
* Case 2: .run -> xyz.run
|
||||||
|
* Case 3: halo -> halo-xyz
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* @param filename is name of file.
|
||||||
|
* @param length is for generating random string with specific length.
|
||||||
|
* @return File name with random string.
|
||||||
|
*/
|
||||||
|
public static String randomFileName(String filename, int length) {
|
||||||
|
var nameWithoutExt = Files.getNameWithoutExtension(filename);
|
||||||
|
var ext = Files.getFileExtension(filename);
|
||||||
|
var random = RandomStringUtils.randomAlphabetic(length).toLowerCase();
|
||||||
|
if (StringUtils.isBlank(nameWithoutExt)) {
|
||||||
|
return random + "." + ext;
|
||||||
|
}
|
||||||
|
if (StringUtils.isBlank(ext)) {
|
||||||
|
return nameWithoutExt + "-" + random;
|
||||||
|
}
|
||||||
|
return nameWithoutExt + "-" + random + "." + ext;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,50 +2,77 @@ package run.halo.app.infra.utils;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static run.halo.app.infra.utils.FileNameUtils.randomFileName;
|
||||||
|
import static run.halo.app.infra.utils.FileNameUtils.removeFileExtension;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Nested;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
class FileNameUtilsTest {
|
class FileNameUtilsTest {
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
class RemoveFileExtensionTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void shouldNotRemoveExtIfNoExt() {
|
public void shouldNotRemoveExtIfNoExt() {
|
||||||
assertEquals("halo", FileNameUtils.removeFileExtension("halo", true));
|
assertEquals("halo", removeFileExtension("halo", true));
|
||||||
assertEquals("halo", FileNameUtils.removeFileExtension("halo", false));
|
assertEquals("halo", removeFileExtension("halo", false));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void shouldRemoveExtIfHasOnlyOneExt() {
|
public void shouldRemoveExtIfHasOnlyOneExt() {
|
||||||
assertEquals("halo", FileNameUtils.removeFileExtension("halo.run", true));
|
assertEquals("halo", removeFileExtension("halo.run", true));
|
||||||
assertEquals("halo", FileNameUtils.removeFileExtension("halo.run", false));
|
assertEquals("halo", removeFileExtension("halo.run", false));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void shouldNotRemoveExtIfDotfile() {
|
public void shouldNotRemoveExtIfDotfile() {
|
||||||
assertEquals(".halo", FileNameUtils.removeFileExtension(".halo", true));
|
assertEquals(".halo", removeFileExtension(".halo", true));
|
||||||
assertEquals(".halo", FileNameUtils.removeFileExtension(".halo", false));
|
assertEquals(".halo", removeFileExtension(".halo", false));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void shouldRemoveExtIfDotfileHasOneExt() {
|
public void shouldRemoveExtIfDotfileHasOneExt() {
|
||||||
assertEquals(".halo", FileNameUtils.removeFileExtension(".halo.run", true));
|
assertEquals(".halo", removeFileExtension(".halo.run", true));
|
||||||
assertEquals(".halo", FileNameUtils.removeFileExtension(".halo.run", false));
|
assertEquals(".halo", removeFileExtension(".halo.run", false));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void shouldRemoveExtIfHasTwoExt() {
|
public void shouldRemoveExtIfHasTwoExt() {
|
||||||
assertEquals("halo", FileNameUtils.removeFileExtension("halo.tar.gz", true));
|
assertEquals("halo", removeFileExtension("halo.tar.gz", true));
|
||||||
assertEquals("halo.tar", FileNameUtils.removeFileExtension("halo.tar.gz", false));
|
assertEquals("halo.tar", removeFileExtension("halo.tar.gz", false));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void shouldRemoveExtIfDotfileHasTwoExt() {
|
public void shouldRemoveExtIfDotfileHasTwoExt() {
|
||||||
assertEquals(".halo", FileNameUtils.removeFileExtension(".halo.tar.gz", true));
|
assertEquals(".halo", removeFileExtension(".halo.tar.gz", true));
|
||||||
assertEquals(".halo.tar", FileNameUtils.removeFileExtension(".halo.tar.gz", false));
|
assertEquals(".halo.tar", removeFileExtension(".halo.tar.gz", false));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldReturnNullIfFilenameIsNull() {
|
void shouldReturnNullIfFilenameIsNull() {
|
||||||
assertNull(FileNameUtils.removeFileExtension(null, true));
|
assertNull(removeFileExtension(null, true));
|
||||||
assertNull(FileNameUtils.removeFileExtension(null, false));
|
assertNull(removeFileExtension(null, false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
class AppendRandomFileNameTest {
|
||||||
|
@Test
|
||||||
|
void normalFileName() {
|
||||||
|
String randomFileName = randomFileName("halo.run", 3);
|
||||||
|
assertEquals(12, randomFileName.length());
|
||||||
|
assertTrue(randomFileName.startsWith("halo-"));
|
||||||
|
assertTrue(randomFileName.endsWith(".run"));
|
||||||
|
|
||||||
|
randomFileName = randomFileName(".run", 3);
|
||||||
|
assertEquals(7, randomFileName.length());
|
||||||
|
assertTrue(randomFileName.endsWith(".run"));
|
||||||
|
|
||||||
|
randomFileName = randomFileName("halo", 3);
|
||||||
|
assertEquals(8, randomFileName.length());
|
||||||
|
assertTrue(randomFileName.startsWith("halo-"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
Loading…
Reference in New Issue