Refactor thumbnail handling to streamline thumbnail URI generation and enhance image processing

feat/add-thumbnail-router
John Niang 2025-09-26 15:28:01 +08:00
parent ce2cfba4c6
commit 7c1c25348f
No known key found for this signature in database
GPG Key ID: D7363C015BBCAA59
11 changed files with 178 additions and 337 deletions

View File

@ -1,5 +1,6 @@
package run.halo.app.core.attachment;
import java.util.Optional;
import lombok.Getter;
@Getter
@ -40,4 +41,13 @@ public enum ThumbnailSize {
}
throw new IllegalArgumentException("No such thumbnail size: " + name);
}
public static Optional<ThumbnailSize> optionalValueOf(String name) {
for (ThumbnailSize value : values()) {
if (value.name().equalsIgnoreCase(name)) {
return Optional.of(value);
}
}
return Optional.empty();
}
}

View File

@ -1,92 +0,0 @@
package run.halo.app.content;
import java.net.URI;
import java.util.function.Function;
import lombok.experimental.UtilityClass;
import org.apache.commons.lang3.StringUtils;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.select.Elements;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.attachment.ThumbnailService;
import run.halo.app.core.attachment.ThumbnailSize;
@UtilityClass
public class HtmlThumbnailSrcsetInjector {
static final String SRC = "src";
static final String SRCSET = "srcset";
/**
* Inject srcset attribute to img tags in the given html.
*/
public static Mono<String> injectSrcset(String html,
Function<String, Mono<String>> srcSetValueGenerator) {
Document document = Jsoup.parseBodyFragment(html);
document.outputSettings(new Document.OutputSettings().prettyPrint(false));
Elements imgTags = document.select("img[src]");
return Flux.fromIterable(imgTags)
.filter(element -> {
String src = element.attr(SRC);
return !element.hasAttr(SRCSET) && isValidSrc(src);
})
.flatMap(img -> {
String src = img.attr(SRC);
return srcSetValueGenerator.apply(src)
.filter(StringUtils::isNotBlank)
.doOnNext(srcsetValue -> {
img.attr(SRCSET, srcsetValue);
img.attr("sizes", buildSizesAttr());
});
})
.then(Mono.fromSupplier(() -> document.body().html()));
}
static String buildSizesAttr() {
var sb = new StringBuilder();
var delimiter = ", ";
var sizes = ThumbnailSize.values();
for (int i = 0; i < sizes.length; i++) {
var size = sizes[i];
sb.append("(max-width: ").append(size.getWidth()).append("px)")
.append(" ")
.append(size.getWidth())
.append("px");
if (i < sizes.length - 1) {
sb.append(delimiter);
}
}
return sb.toString();
}
/**
* Generate srcset attribute value for the given src.
*/
public static Mono<String> generateSrcset(URI src, ThumbnailService thumbnailService) {
return Flux.fromArray(ThumbnailSize.values())
.flatMap(size -> thumbnailService.get(src, size)
.map(thumbnail -> thumbnail.toString() + " " + size.getWidth() + "w")
)
.collect(StringBuilder::new, (builder, srcsetValue) -> {
if (!builder.isEmpty()) {
builder.append(", ");
}
builder.append(srcsetValue);
})
.map(StringBuilder::toString);
}
private static boolean isValidSrc(String src) {
if (StringUtils.isBlank(src)) {
return false;
}
try {
URI.create(src);
return true;
} catch (IllegalArgumentException e) {
// ignore
}
return false;
}
}

View File

@ -1,41 +0,0 @@
package run.halo.app.content;
import static run.halo.app.content.HtmlThumbnailSrcsetInjector.generateSrcset;
import java.net.URI;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import run.halo.app.core.attachment.ThumbnailService;
import run.halo.app.theme.ReactiveSinglePageContentHandler;
/**
* A single page content handler to handle post html content and generate thumbnail by the img tag.
*
* @author guqing
* @since 2.21.0
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class PageContentThumbnailHandler implements ReactiveSinglePageContentHandler {
private final ThumbnailService thumbnailService;
@Override
public Mono<SinglePageContentContext> handle(
@NonNull SinglePageContentContext singlePageContent) {
var html = singlePageContent.getContent();
return HtmlThumbnailSrcsetInjector.injectSrcset(html,
src -> generateSrcset(URI.create(src), thumbnailService)
)
.onErrorResume(throwable -> {
log.debug("Failed to inject srcset to page content, fallback to original content",
throwable);
return Mono.just(html);
})
.doOnNext(singlePageContent::setContent)
.thenReturn(singlePageContent);
}
}

View File

@ -1,40 +0,0 @@
package run.halo.app.content;
import static run.halo.app.content.HtmlThumbnailSrcsetInjector.generateSrcset;
import java.net.URI;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import run.halo.app.core.attachment.ThumbnailService;
import run.halo.app.theme.ReactivePostContentHandler;
/**
* A post content handler to handle post html content and generate thumbnail by the img tag.
*
* @author guqing
* @since 2.19.0
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class PostContentThumbnailHandler implements ReactivePostContentHandler {
private final ThumbnailService thumbnailService;
@Override
public Mono<PostContentContext> handle(@NonNull PostContentContext postContent) {
var html = postContent.getContent();
return HtmlThumbnailSrcsetInjector.injectSrcset(html,
src -> generateSrcset(URI.create(src), thumbnailService)
)
.onErrorResume(throwable -> {
log.debug("Failed to inject srcset to post content, fallback to original content",
throwable);
return Mono.just(html);
})
.doOnNext(postContent::setContent)
.thenReturn(postContent);
}
}

View File

@ -0,0 +1,111 @@
package run.halo.app.core.attachment;
import static org.thymeleaf.templatemode.TemplateMode.HTML;
import java.net.URI;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FilenameUtils;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import org.thymeleaf.context.ITemplateContext;
import org.thymeleaf.engine.ElementNames;
import org.thymeleaf.model.IAttribute;
import org.thymeleaf.model.IProcessableElementTag;
import org.thymeleaf.processor.element.MatchingElementName;
import org.thymeleaf.spring6.context.SpringContextUtils;
import org.thymeleaf.spring6.context.webflux.SpringWebFluxThymeleafRequestContext;
import reactor.core.publisher.Mono;
import run.halo.app.infra.ExternalUrlSupplier;
import run.halo.app.theme.dialect.ElementTagPostProcessor;
@Slf4j
@Component
class ThumbnailImgTagPostProcessor implements ElementTagPostProcessor {
private final MatchingElementName matchingElementName;
private final ExternalUrlSupplier externalUrlSupplier;
public ThumbnailImgTagPostProcessor(ExternalUrlSupplier externalUrlSupplier) {
this.externalUrlSupplier = externalUrlSupplier;
this.matchingElementName =
MatchingElementName.forElementName(HTML, ElementNames.forHTMLName("img"));
}
@Override
public Mono<IProcessableElementTag> process(ITemplateContext context,
IProcessableElementTag tag) {
if (!matchingElementName.matches(tag.getElementDefinition().getElementName())) {
return Mono.empty();
}
if (tag.hasAttribute("srcset")) {
return Mono.empty();
}
var srcValue = Optional.ofNullable(tag.getAttribute("src"))
.map(IAttribute::getValue)
.filter(StringUtils::hasText)
.map(URI::create);
if (srcValue.isEmpty()) {
log.debug("Skip processing img tag without src attribute");
return Mono.empty();
}
// get img tag
var imageUri = srcValue.get();
if (imageUri.isAbsolute()) {
// check if the uri is belonged to current site
var requestContext = SpringContextUtils.getRequestContext(context);
if (!(requestContext instanceof SpringWebFluxThymeleafRequestContext wrc)) {
log.debug("Skip processing img tag with absolute url: {}, "
+ "because the request context is not webflux", imageUri);
return Mono.empty();
}
var externalUri = externalUrlSupplier.get();
if (!externalUri.isAbsolute()) {
externalUri = wrc.getServerWebExchange().getRequest().getURI();
}
if (!Objects.equals(externalUri.getAuthority(), imageUri.getAuthority())) {
log.debug("""
Skip processing img tag with external absolute url: {} because \
the url does not belong to the current site\
""", imageUri);
return Mono.empty();
}
}
var fileSuffix = FilenameUtils.getExtension(imageUri.getPath());
if (!ThumbnailUtils.isSupportedImage(fileSuffix)) {
log.debug("Skip processing img tag with unsupported image suffix: {}", fileSuffix);
return Mono.empty();
}
// build thumbnails
var thumbnails = ThumbnailUtils.buildSrcsetMap(imageUri);
if (CollectionUtils.isEmpty(thumbnails)) {
log.debug("Skip processing img tag because the image is not supported: {}", imageUri);
return Mono.empty();
}
var modelFactory = context.getModelFactory();
tag = modelFactory.setAttribute(tag, "size", """
(max-width: 400px) 400px, \
(max-width: 800px) 800px, \
(max-width: 1200px) 1200px, \
(max-width: 1600px) 1600px\
""");
var srcset = thumbnails.keySet().stream()
.map(size -> {
var uri = thumbnails.get(size);
return uri + " " + size.getWidth() + "w";
})
.collect(Collectors.joining(", "));
tag = modelFactory.setAttribute(tag, "srcset", srcset);
return Mono.just(tag);
}
}

View File

@ -1,9 +1,14 @@
package run.halo.app.core.attachment;
import java.net.URI;
import java.util.Arrays;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.apache.commons.io.FilenameUtils;
import org.springframework.http.MediaType;
import org.springframework.util.MimeType;
import org.springframework.web.util.UriComponentsBuilder;
public enum ThumbnailUtils {
;
@ -33,4 +38,25 @@ public enum ThumbnailUtils {
return SUPPORTED_IMAGE_MIME_TYPES.stream()
.anyMatch(supported -> supported.isCompatibleWith(mimeType));
}
/**
* Build a map of thumbnail size to its corresponding URI based on the given permalink.
*
* @param permalink permalink of the attachment in local storage
* @return a map where the key is the thumbnail size and the value is the URI of the thumbnail
*/
public static Map<ThumbnailSize, URI> buildSrcsetMap(URI permalink) {
var fileSuffix = FilenameUtils.getExtension(permalink.getPath());
if (!isSupportedImage(fileSuffix)) {
return Map.of();
}
return Arrays.stream(ThumbnailSize.values())
.collect(Collectors.toMap(t -> t, t -> {
var prefix = "/thumbnails/w" + t.getWidth();
return UriComponentsBuilder.fromUri(permalink)
.replacePath(prefix + permalink.getPath())
.build()
.toUri();
}));
}
}

View File

@ -21,7 +21,6 @@ import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.RandomStringUtils;
@ -311,20 +310,8 @@ class LocalAttachmentUploadHandler implements AttachmentHandler {
|| !StringUtils.hasText(attachment.getStatus().getPermalink())) {
return Mono.just(Map.of());
}
var mediaType = MediaType.parseMediaType(attachment.getSpec().getMediaType());
if (!ThumbnailUtils.isSupportedImage(mediaType)) {
return Mono.just(Map.of());
}
var thumbnails = Arrays.stream(ThumbnailSize.values())
.collect(Collectors.toMap(t -> t, t -> {
var permalink = URI.create(attachment.getStatus().getPermalink());
var prefix = "/thumbnails/w" + t.getWidth();
return UriComponentsBuilder.fromUri(permalink)
.replacePath(prefix + permalink.getPath())
.build()
.toUri();
}));
var permalinkUri = URI.create(attachment.getStatus().getPermalink());
var thumbnails = ThumbnailUtils.buildSrcsetMap(permalinkUri);
return Mono.just(thumbnails);
}

View File

@ -18,7 +18,6 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Component;
import run.halo.app.core.attachment.AttachmentRootGetter;
import run.halo.app.core.attachment.AttachmentUtils;
import run.halo.app.core.attachment.LocalThumbnailService;
@ -36,7 +35,7 @@ import run.halo.app.extension.controller.Reconciler;
import run.halo.app.infra.ExternalLinkProcessor;
@Slf4j
@Component
// @Component
@RequiredArgsConstructor
public class LocalThumbnailsReconciler implements Reconciler<Reconciler.Request> {
private final LocalThumbnailService localThumbnailService;

View File

@ -6,34 +6,28 @@ import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.media.Schema;
import java.io.IOException;
import java.net.URI;
import java.time.Instant;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springdoc.core.fn.builders.operation.Builder;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
import org.springframework.boot.autoconfigure.web.WebProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.core.io.Resource;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.util.MultiValueMap;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Mono;
import run.halo.app.core.attachment.LocalThumbnailService;
import run.halo.app.core.attachment.ThumbnailService;
import run.halo.app.core.attachment.ThumbnailSize;
import run.halo.app.core.extension.attachment.Attachment;
import run.halo.app.core.extension.endpoint.CustomEndpoint;
import run.halo.app.extension.ExtensionUtil;
import run.halo.app.extension.GroupVersion;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.index.query.QueryFactory;
/**
* Thumbnail endpoint for thumbnail resource access.
@ -44,10 +38,8 @@ import run.halo.app.extension.GroupVersion;
@Component
@RequiredArgsConstructor
public class ThumbnailEndpoint implements CustomEndpoint {
private final WebClient webClient = WebClient.builder().build();
private final LocalThumbnailService localThumbnailService;
private final WebProperties webProperties;
private final ThumbnailService thumbnailService;
private final ReactiveExtensionClient client;
@Override
public RouterFunction<ServerResponse> endpoint() {
@ -66,27 +58,25 @@ public class ThumbnailEndpoint implements CustomEndpoint {
private Mono<ServerResponse> getThumbnailByUri(ServerRequest request) {
var query = new ThumbnailQuery(request.queryParams());
return thumbnailService.get(query.getUri(), query.getSize())
.filterWhen(uri -> isAccessible(request, uri))
.defaultIfEmpty(query.getUri())
.flatMap(uri -> ServerResponse.temporaryRedirect(uri).build());
}
Mono<Boolean> isAccessible(ServerRequest request, URI uri) {
var url = Optional.of(uri)
.filter(URI::isAbsolute)
.orElseGet(() -> request.uriBuilder().replacePath(uri.toASCIIString()).build());
// resource handler does not support head access for Halo, so use get request here
return webClient.get()
.uri(url)
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range
.header(HttpHeaders.RANGE, "bytes=0-0")
.exchangeToMono(response -> {
var statusCode = response.statusCode();
return Mono.just(statusCode.is2xxSuccessful() || statusCode.is3xxRedirection());
var size = query.getSize();
var uri = query.getUri().toASCIIString();
var listOptions = ListOptions.builder()
.andQuery(ExtensionUtil.notDeleting())
.andQuery(QueryFactory.equal("status.permalink", uri))
.build();
// query by permalink
return client.listAll(Attachment.class, listOptions, ExtensionUtil.defaultSort())
// find the first one
.next()
.mapNotNull(attachment -> {
var thumbnails = attachment.getStatus().getThumbnails();
return thumbnails.get(size.name());
})
.onErrorReturn(false)
.defaultIfEmpty(false);
.defaultIfEmpty(uri)
.flatMap(thumbnailLink -> ServerResponse.status(HttpStatus.FOUND)
.location(URI.create(thumbnailLink))
.build()
);
}
static class ThumbnailQuery {
@ -132,61 +122,9 @@ public class ThumbnailEndpoint implements CustomEndpoint {
}
}
@Bean
RouterFunction<ServerResponse> localThumbnailResourceRouter() {
return RouterFunctions.route()
.GET("/upload/thumbnails/{year}/w{width}/{fileName}", request -> {
var width = request.pathVariable("width");
var year = request.pathVariable("year");
var fileName = request.pathVariable("fileName");
var size = ThumbnailSize.fromWidth(width);
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))
);
})
.build();
}
@Override
public GroupVersion groupVersion() {
return new GroupVersion("api.storage.halo.run", "v1alpha1");
}
private Mono<ServerResponse> getResourceResponse(ServerRequest request, Resource resource) {
var resourceProperties = webProperties.getResources();
final var useLastModified = resourceProperties.getCache().isUseLastModified();
final var cacheControl = getCacheControl(resourceProperties);
var bodyBuilder = ServerResponse.ok().cacheControl(cacheControl);
try {
if (useLastModified) {
var lastModified = Instant.ofEpochMilli(resource.lastModified());
return request.checkNotModified(lastModified)
.switchIfEmpty(Mono.defer(() -> bodyBuilder.lastModified(lastModified)
.body(BodyInserters.fromResource(resource))
));
}
return bodyBuilder.body(BodyInserters.fromResource(resource));
} catch (IOException e) {
return Mono.error(e);
}
}
private Mono<ServerResponse> fallback(URI imageUri) {
return ServerResponse.temporaryRedirect(imageUri).build();
}
private static CacheControl getCacheControl(WebProperties.Resources resourceProperties) {
var cacheControl = resourceProperties.getCache()
.getCachecontrol()
.toHttpCacheControl();
if (cacheControl == null) {
cacheControl = CacheControl.empty();
}
return cacheControl;
}
}

View File

@ -1,11 +1,8 @@
package run.halo.app.theme.finders.impl;
import java.net.URI;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import reactor.core.publisher.Mono;
import run.halo.app.core.attachment.ThumbnailService;
import run.halo.app.core.attachment.ThumbnailSize;
import run.halo.app.theme.finders.Finder;
import run.halo.app.theme.finders.ThumbnailFinder;
@ -13,18 +10,10 @@ import run.halo.app.theme.finders.ThumbnailFinder;
@Finder("thumbnail")
@RequiredArgsConstructor
public class ThumbnailFinderImpl implements ThumbnailFinder {
private final ThumbnailService thumbnailService;
@Override
public Mono<String> gen(String uriStr, String size) {
return Mono.fromSupplier(() -> URI.create(uriStr))
.flatMap(uri -> thumbnailService.get(uri, ThumbnailSize.fromName(size)))
.map(URI::toString)
.onErrorResume(Throwable.class, e -> {
log.debug("Failed to generate thumbnail for [{}], error: [{}]", uriStr,
e.getMessage());
return Mono.just(uriStr);
})
.defaultIfEmpty(uriStr);
// TODO Implement me
return Mono.just(uriStr);
}
}

View File

@ -1,46 +0,0 @@
package run.halo.app.content;
import static org.assertj.core.api.Assertions.assertThat;
import static run.halo.app.content.HtmlThumbnailSrcsetInjector.buildSizesAttr;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
/**
* Tests for {@link HtmlThumbnailSrcsetInjector}.
*
* @author guqing
* @since 2.19.0
*/
class HtmlThumbnailSrcsetInjectorTest {
@Test
void injectSrcset() {
String html = """
<div>
<img src='image1.jpg' alt="test">
<img src='image2.jpg' srcset='image2-small.jpg \
480w, image2-large.jpg 800w'>
</div>
""";
var result = HtmlThumbnailSrcsetInjector.injectSrcset(html,
src -> Mono.just(src + " 480w, " + src + " 800w")).block();
assertThat(result).isEqualToIgnoringWhitespace("""
<div>
<img src="image1.jpg" alt="test" srcset="image1.jpg 480w, image1.jpg 800w"\
sizes="(max-width: 400px) 400px, (max-width: 800px) 800px,\
(max-width: 1200px) 1200px, (max-width: 1600px) 1600px">
<img src="image2.jpg" srcset="image2-small.jpg 480w, image2-large.jpg 800w">
</div>
""");
}
@Test
void buildSizesTest() {
var sizes = buildSizesAttr();
assertThat(sizes).isEqualToIgnoringWhitespace("""
(max-width: 400px) 400px, (max-width: 800px) 800px,
(max-width: 1200px) 1200px, (max-width: 1600px) 1600px
""");
}
}