Skip to content

Commit

Permalink
refactor: local thumbnail fallback
Browse files Browse the repository at this point in the history
  • Loading branch information
guqing committed Aug 20, 2024
1 parent d4e7539 commit 549a4cf
Show file tree
Hide file tree
Showing 5 changed files with 67 additions and 100 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,8 @@
import java.net.URI;
import java.net.URL;
import lombok.experimental.UtilityClass;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.lang.NonNull;
import org.springframework.util.Assert;
import org.springframework.web.util.UriComponentsBuilder;
import run.halo.app.core.extension.attachment.Attachment;

@UtilityClass
Expand Down Expand Up @@ -41,22 +38,4 @@ public static URL toUrl(@NonNull URI uri) {
throw new IllegalArgumentException(e);
}
}

/**
* <p>Convert URL to resource.</p>
* <p>It will encode the URL before converting it to a resource.</p>
* {@link UrlResource} will throw an exception if the URL is not properly encoded when
* {@link UrlResource#getInputStream()} ()} is called.
*/
public static Resource toUrlResource(String url) {
try {
var encodedUri = UriComponentsBuilder.fromHttpUrl(url)
.encode()
.build()
.toUri();
return new UrlResource(encodedUri);
} catch (MalformedURLException e) {
throw new IllegalArgumentException("Invalid URL: " + url, e);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,36 @@
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.attachment.LocalThumbnail;
import run.halo.app.infra.ExternalLinkProcessor;
import run.halo.app.infra.exception.NotFoundException;

public interface LocalThumbnailService {

/**
* Gets original image URI for the given thumbnail URI.
*
* @param thumbnailUri The thumbnail URI string
* @return The original image URI, {@link NotFoundException} will be thrown if the thumbnail
* record does not exist by the given thumbnail URI
*/
Mono<URI> getOriginalImageUri(String thumbnailUri);

/**
* Gets thumbnail file resource for the given year, size and filename.
*
* @param year directory name to archive the thumbnail to avoid too many files in one
* directory which may cause file system performance issue
* @param size thumbnail size
* @param filename original image filename
* @return The thumbnail file resource.
* @param thumbnailUri The thumbnail URI string
* @return The thumbnail file resource, {@link NotFoundException} will be thrown if the
* thumbnail file does not exist
*/
Mono<Resource> getThumbnail(String year, ThumbnailSize size, String filename);
Mono<Resource> getThumbnail(String thumbnailUri);

/**
* Gets thumbnail file resource for the given URI and size.
* {@link Mono#empty()} will be returned if the thumbnail file does not generate yet.
*
* @param uri image URI
* @param originalImageUri original image URI to get thumbnail
* @param size thumbnail size
*/
Mono<Resource> getThumbnail(URI uri, ThumbnailSize size);
Mono<Resource> getThumbnail(URI originalImageUri, ThumbnailSize size);

/**
* Generate thumbnail file for the given thumbnail.
Expand Down Expand Up @@ -66,4 +75,6 @@ public interface LocalThumbnailService {
URI ensureInSiteUriIsRelative(URI imageUri);

Path toFilePath(String thumbRelativeUnixPath);

String buildThumbnailUri(String year, ThumbnailSize size, String filename);
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
package run.halo.app.core.attachment.endpoint;

import static run.halo.app.core.attachment.AttachmentUtils.toUrlResource;

import java.io.IOException;
import java.net.URI;
import java.time.Instant;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.boot.autoconfigure.web.WebProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.core.io.Resource;
Expand All @@ -19,10 +16,8 @@
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import run.halo.app.core.attachment.LocalThumbnailService;
import run.halo.app.core.attachment.ThumbnailSize;
import run.halo.app.infra.ExternalUrlSupplier;
import run.halo.app.infra.exception.NotFoundException;

/**
Expand All @@ -36,7 +31,6 @@
public class LocalThumbnailEndpoint {
private final LocalThumbnailService localThumbnailService;
private final WebProperties webProperties;
private final ExternalUrlSupplier externalUrlSupplier;

@Bean
RouterFunction<ServerResponse> localThumbnailRouter() {
Expand All @@ -46,19 +40,22 @@ RouterFunction<ServerResponse> localThumbnailRouter() {
var year = request.pathVariable("year");
var fileName = request.pathVariable("fileName");
var size = ThumbnailSize.fromWidth(width);
return localThumbnailService.getThumbnail(year, size, fileName)
.flatMap(resource -> getResourceResponse(request, resource));
var thumbnailUri = localThumbnailService.buildThumbnailUri(year, size, fileName);
return localThumbnailService.getThumbnail(thumbnailUri)
.flatMap(resource -> getResourceResponse(request, resource))
.switchIfEmpty(Mono.defer(
() -> localThumbnailService.getOriginalImageUri(thumbnailUri)
.flatMap(this::fallback))
);
})
.GET("/upload/thumbnails/w{width}", request -> {
var imageUri = request.queryParam("uri")
.orElseThrow(
() -> new ServerWebInputException("Required parameter 'uri' is missing"));
var size = ThumbnailSize.fromWidth(request.pathVariable("width"));
return localThumbnailService.getThumbnail(URI.create(imageUri), size)
.onErrorResume(NotFoundException.class,
e -> fallback(request, URI.create(imageUri))
)
.flatMap(resource -> getResourceResponse(request, resource));
.flatMap(resource -> getResourceResponse(request, resource))
.onErrorResume(NotFoundException.class, e -> fallback(URI.create(imageUri)));
})
.build();
}
Expand All @@ -82,18 +79,8 @@ private Mono<ServerResponse> getResourceResponse(ServerRequest request, Resource
}
}

private Mono<Resource> fallback(ServerRequest request, URI imageUri) {
if (imageUri.isAbsolute()) {
return getUrlResourceMono(imageUri.toString());
}
var url = externalUrlSupplier.getURL(request.exchange().getRequest());
var absoluteUrl = StringUtils.removeEnd(url.toString(), "/") + imageUri;
return getUrlResourceMono(absoluteUrl);
}

private static Mono<Resource> getUrlResourceMono(String absoluteUrl) {
return Mono.fromCallable(() -> toUrlResource(absoluteUrl))
.subscribeOn(Schedulers.boundedElastic());
private Mono<ServerResponse> fallback(URI imageUri) {
return ServerResponse.temporaryRedirect(imageUri).build();
}

private static CacheControl getCacheControl(WebProperties.Resources resourceProperties) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,18 @@
import static org.apache.commons.lang3.StringUtils.defaultIfBlank;
import static org.apache.commons.lang3.StringUtils.removeStart;
import static org.apache.commons.lang3.StringUtils.substringAfterLast;
import static run.halo.app.core.attachment.AttachmentUtils.toUrlResource;
import static run.halo.app.extension.MetadataUtil.nullSafeAnnotations;
import static run.halo.app.extension.index.query.QueryFactory.and;
import static run.halo.app.extension.index.query.QueryFactory.equal;
import static run.halo.app.extension.index.query.QueryFactory.isNull;

import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Optional;
import java.util.concurrent.Callable;
import java.util.function.Consumer;
import lombok.RequiredArgsConstructor;
Expand All @@ -31,7 +28,6 @@
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.springframework.web.server.ServerWebInputException;
import reactor.core.Exceptions;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
Expand All @@ -48,7 +44,6 @@
import run.halo.app.extension.Metadata;
import run.halo.app.extension.PageRequestImpl;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.ExternalLinkProcessor;
import run.halo.app.infra.ExternalUrlSupplier;
import run.halo.app.infra.exception.NotFoundException;

Expand All @@ -59,7 +54,6 @@ public class LocalThumbnailServiceImpl implements LocalThumbnailService {
private final AttachmentRootGetter attachmentDirGetter;
private final ReactiveExtensionClient client;
private final ExternalUrlSupplier externalUrlSupplier;
private final ExternalLinkProcessor externalLinkProcessor;

private static Path buildThumbnailStorePath(Path rootPath, String fileName, String year,
ThumbnailSize size) {
Expand All @@ -70,10 +64,6 @@ private static Path buildThumbnailStorePath(Path rootPath, String fileName, Stri
.resolve(fileName);
}

static String endpointFor(String fileName, String year, ThumbnailSize size) {
return "/upload/thumbnails/%s/w%s/%s".formatted(year, size.getWidth(), fileName);
}

static String geImageFileName(URL imageUrl) {
var fileName = substringAfterLast(imageUrl.getPath(), "/");
fileName = defaultIfBlank(fileName, randomAlphanumeric(10));
Expand All @@ -85,29 +75,40 @@ static String getYear() {
}

@Override
public Mono<Resource> getThumbnail(String year, ThumbnailSize size, String filename) {
Assert.notNull(year, "Year must not be null.");
Assert.notNull(size, "Thumbnail size must not be null.");
Assert.notNull(filename, "Filename must not be null.");
var filePath = buildThumbnailStorePath(attachmentDirGetter.get(), filename, year, size);
if (Files.exists(filePath)) {
return getResourceMono(() -> new FileSystemResource(filePath));
}
var thumbnailUri = endpointFor(filename, year, size);
var thumbSignature = ThumbnailSigner.generateSignature(thumbnailUri);
return fetchThumbnail(thumbSignature)
.switchIfEmpty(Mono.error(new NotFoundException("Thumbnail resource not found.")))
.flatMap(this::generate);
public Mono<URI> getOriginalImageUri(String thumbnailUri) {
return getThumbnailRecord(thumbnailUri)
.map(local -> URI.create(local.getSpec().getImageUri()));
}

@Override
public Mono<Resource> getThumbnail(URI uri, ThumbnailSize size) {
var imageHash = signatureForImageUri(uri);
public Mono<Resource> getThumbnail(String thumbnailUri) {
Assert.notNull(thumbnailUri, "Thumbnail URI must not be null.");
return getThumbnailRecord(thumbnailUri)
.flatMap(thumbnail -> {
var filePath = toFilePath(thumbnail.getSpec().getFilePath());
if (Files.exists(filePath)) {
return getResourceMono(() -> new FileSystemResource(filePath));
}
return generate(thumbnail)
.then(Mono.empty());
});
}

@Override
public Mono<Resource> getThumbnail(URI originalImageUri, ThumbnailSize size) {
var imageHash = signatureForImageUri(originalImageUri);
return fetchByImageHashAndSize(imageHash, size)
.switchIfEmpty(Mono.error(new NotFoundException("Thumbnail resource not found.")))
.flatMap(this::generate);
}

private Mono<LocalThumbnail> getThumbnailRecord(String thumbnailUri) {
Assert.notNull(thumbnailUri, "Thumbnail URI must not be null.");
var thumbSignature = ThumbnailSigner.generateSignature(thumbnailUri);
return fetchThumbnail(thumbSignature)
.switchIfEmpty(Mono.error(new NotFoundException("Thumbnail resource not found.")));
}

private Mono<LocalThumbnail> fetchThumbnail(String thumbSignature) {
return client.listBy(LocalThumbnail.class, ListOptions.builder()
.fieldQuery(equal("spec.thumbSignature", thumbSignature))
Expand All @@ -132,34 +133,17 @@ public Mono<Resource> generate(LocalThumbnail thumbnail) {
if (Files.exists(filePath)) {
return getResourceMono(() -> new FileSystemResource(filePath));
}
var imageUrlOpt = toImageUrl(thumbnail.getSpec().getImageUri());
if (imageUrlOpt.isEmpty()) {
return Mono.error(new ServerWebInputException(
"Failed to parse image URL,please check external-url configuration"));
}
var imageUrl = imageUrlOpt.get();
return updateWithRetry(thumbnail,
record -> nullSafeAnnotations(record)
.put(LocalThumbnail.REQUEST_TO_GENERATE_ANNO, "true"))
.then(getResourceMono(() -> toUrlResource(imageUrl.toString())));
.then(Mono.empty());
}

private static Mono<Resource> getResourceMono(Callable<Resource> callable) {
return Mono.fromCallable(callable)
.subscribeOn(Schedulers.boundedElastic());
}

Optional<URL> toImageUrl(String imageUriStr) {
var imageUri = URI.create(imageUriStr);
try {
var url = new URL(externalLinkProcessor.processLink(imageUri.toString()));
return Optional.of(url);
} catch (MalformedURLException e) {
// Ignore
}
return Optional.empty();
}

private Mono<Void> updateWithRetry(LocalThumbnail localThumbnail, Consumer<LocalThumbnail> op) {
op.accept(localThumbnail);
return client.update(localThumbnail)
Expand Down Expand Up @@ -189,7 +173,7 @@ public Mono<LocalThumbnail> create(URL imageUrl, ThumbnailSize size) {
var thumbnail = new LocalThumbnail();
thumbnail.setMetadata(new Metadata());
thumbnail.getMetadata().setGenerateName("thumbnail-");
var thumbnailUri = endpointFor(thumbFileName, year, size);
var thumbnailUri = buildThumbnailUri(year, size, thumbFileName);
var thumbSignature = ThumbnailSigner.generateSignature(thumbnailUri);
thumbnail.setSpec(new LocalThumbnail.Spec()
.setImageSignature(signatureForImageUri(imageUri))
Expand Down Expand Up @@ -240,7 +224,8 @@ Mono<String> generateUniqueThumbFileName(String originalFileName, String year,

private Mono<String> generateUniqueThumbFileName(String originalFileName, String tryFileName,
String year, ThumbnailSize size) {
var hash = ThumbnailSigner.generateSignature(endpointFor(tryFileName, year, size));
var thumbnailUri = buildThumbnailUri(year, size, tryFileName);
var hash = ThumbnailSigner.generateSignature(thumbnailUri);
return fetchThumbnail(hash)
.flatMap(thumbnail -> {
// use the original file name to generate a new file name
Expand All @@ -258,6 +243,11 @@ public Path toFilePath(String relativeUnixPath) {
return attachmentDirGetter.get().resolve(systemPath);
}

@Override
public String buildThumbnailUri(String year, ThumbnailSize size, String filename) {
return "/upload/thumbnails/%s/w%s/%s".formatted(year, size.getWidth(), filename);
}

private String toRelativeUnixPath(Path filePath) {
var dir = attachmentDirGetter.get().toString();
var relativePath = removeStart(filePath.toString(), dir);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static run.halo.app.core.attachment.impl.LocalThumbnailServiceImpl.endpointFor;

import java.net.MalformedURLException;
import java.net.URI;
Expand Down Expand Up @@ -53,7 +52,8 @@ class LocalThumbnailServiceImplTest {

@Test
void endpointForTest() {
var endpoint = endpointFor("example.jpg", "2024", ThumbnailSize.L);
var endpoint =
localThumbnailService.buildThumbnailUri("2024", ThumbnailSize.L, "example.jpg");
assertThat(endpoint).isEqualTo("/upload/thumbnails/2024/w1200/example.jpg");
}

Expand Down

0 comments on commit 549a4cf

Please sign in to comment.