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:

![image](https://user-images.githubusercontent.com/16865714/218670068-87389289-1248-48e8-82a4-1bcf939e64e3.png)

#### Does this PR introduce a user-facing change?

```release-note
附件已存在时自动重命名
```
pull/3314/head^2
John Niang 2023-02-15 13:38:12 +08:00 committed by GitHub
parent e485acef66
commit 5f7ea18f7c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 139 additions and 47 deletions

View File

@ -1,6 +1,7 @@
package run.halo.app.core.extension.attachment.endpoint;
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 java.io.IOException;
@ -12,16 +13,20 @@ import java.util.ArrayList;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.util.UriComponentsBuilder;
import reactor.core.Exceptions;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
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.AttachmentSpec;
import run.halo.app.core.extension.attachment.Constant;
@ -76,18 +81,17 @@ class LocalAttachmentUploadHandler implements AttachmentHandler {
}
})
.subscribeOn(Schedulers.boundedElastic())
// save the attachment
.then(DataBufferUtils.write(file.content(), attachmentPath, CREATE_NEW))
.then(Mono.fromCallable(() -> {
log.info("Wrote attachment {} into {}", file.filename(), attachmentPath);
.then(writeContent(file.content(), attachmentPath, true))
.map(path -> {
log.info("Wrote attachment {} into {}", file.filename(), path);
// TODO check the file extension
var metadata = new Metadata();
metadata.setName(UUID.randomUUID().toString());
var relativePath = attachmentsRoot.relativize(attachmentPath).toString();
var relativePath = attachmentsRoot.relativize(path).toString();
var pathSegments = new ArrayList<String>();
pathSegments.add("upload");
for (Path p : uploadRoot.relativize(attachmentPath)) {
for (Path p : uploadRoot.relativize(path)) {
pathSegments.add(p.toString());
}
@ -100,17 +104,16 @@ class LocalAttachmentUploadHandler implements AttachmentHandler {
Constant.LOCAL_REL_PATH_ANNO_KEY, relativePath,
Constant.URI_ANNO_KEY, uri));
var spec = new AttachmentSpec();
spec.setSize(attachmentPath.toFile().length());
file.headers().getContentType();
spec.setSize(path.toFile().length());
spec.setMediaType(Optional.ofNullable(file.headers().getContentType())
.map(MediaType::toString)
.orElse(null));
spec.setDisplayName(file.filename());
spec.setDisplayName(path.getFileName().toString());
var attachment = new Attachment();
attachment.setMetadata(metadata);
attachment.setSpec(spec);
return attachment;
}))
})
.onErrorMap(FileAlreadyExistsException.class,
e -> new AttachmentAlreadyExistsException(e.getFile()));
});
@ -165,4 +168,37 @@ class LocalAttachmentUploadHandler implements AttachmentHandler {
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));
});
}
}

View File

@ -1,5 +1,9 @@
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 {
private FileNameUtils() {
@ -12,4 +16,29 @@ public final class FileNameUtils {
var extPattern = "(?<!^)[.]" + (removeAllExtensions ? ".*" : "[^.]*$");
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;
}
}

View File

@ -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.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;
class FileNameUtilsTest {
@Test
public void shouldNotRemoveExtIfNoExt() {
assertEquals("halo", FileNameUtils.removeFileExtension("halo", true));
assertEquals("halo", FileNameUtils.removeFileExtension("halo", false));
@Nested
class RemoveFileExtensionTest {
@Test
public void shouldNotRemoveExtIfNoExt() {
assertEquals("halo", removeFileExtension("halo", true));
assertEquals("halo", removeFileExtension("halo", false));
}
@Test
public void shouldRemoveExtIfHasOnlyOneExt() {
assertEquals("halo", removeFileExtension("halo.run", true));
assertEquals("halo", removeFileExtension("halo.run", false));
}
@Test
public void shouldNotRemoveExtIfDotfile() {
assertEquals(".halo", removeFileExtension(".halo", true));
assertEquals(".halo", removeFileExtension(".halo", false));
}
@Test
public void shouldRemoveExtIfDotfileHasOneExt() {
assertEquals(".halo", removeFileExtension(".halo.run", true));
assertEquals(".halo", removeFileExtension(".halo.run", false));
}
@Test
public void shouldRemoveExtIfHasTwoExt() {
assertEquals("halo", removeFileExtension("halo.tar.gz", true));
assertEquals("halo.tar", removeFileExtension("halo.tar.gz", false));
}
@Test
public void shouldRemoveExtIfDotfileHasTwoExt() {
assertEquals(".halo", removeFileExtension(".halo.tar.gz", true));
assertEquals(".halo.tar", removeFileExtension(".halo.tar.gz", false));
}
@Test
void shouldReturnNullIfFilenameIsNull() {
assertNull(removeFileExtension(null, true));
assertNull(removeFileExtension(null, false));
}
}
@Test
public void shouldRemoveExtIfHasOnlyOneExt() {
assertEquals("halo", FileNameUtils.removeFileExtension("halo.run", true));
assertEquals("halo", FileNameUtils.removeFileExtension("halo.run", 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"));
@Test
public void shouldNotRemoveExtIfDotfile() {
assertEquals(".halo", FileNameUtils.removeFileExtension(".halo", true));
assertEquals(".halo", FileNameUtils.removeFileExtension(".halo", false));
}
randomFileName = randomFileName(".run", 3);
assertEquals(7, randomFileName.length());
assertTrue(randomFileName.endsWith(".run"));
@Test
public void shouldRemoveExtIfDotfileHasOneExt() {
assertEquals(".halo", FileNameUtils.removeFileExtension(".halo.run", true));
assertEquals(".halo", FileNameUtils.removeFileExtension(".halo.run", false));
randomFileName = randomFileName("halo", 3);
assertEquals(8, randomFileName.length());
assertTrue(randomFileName.startsWith("halo-"));
}
}
@Test
public void shouldRemoveExtIfHasTwoExt() {
assertEquals("halo", FileNameUtils.removeFileExtension("halo.tar.gz", true));
assertEquals("halo.tar", FileNameUtils.removeFileExtension("halo.tar.gz", false));
}
@Test
public void shouldRemoveExtIfDotfileHasTwoExt() {
assertEquals(".halo", FileNameUtils.removeFileExtension(".halo.tar.gz", true));
assertEquals(".halo.tar", FileNameUtils.removeFileExtension(".halo.tar.gz", false));
}
@Test
void shouldReturnNullIfFilenameIsNull() {
assertNull(FileNameUtils.removeFileExtension(null, true));
assertNull(FileNameUtils.removeFileExtension(null, false));
}
}
}