From 68e80d16edda37fda521648617c5043c1201fe31 Mon Sep 17 00:00:00 2001 From: John Niang Date: Sun, 28 Sep 2025 15:50:15 +0800 Subject: [PATCH] Add thumbnail routing with dynamic width handling and URI generation --- .../endpoint/AttachmentHandler.java | 12 +++ .../ThumbnailImgTagPostProcessor.java | 5 +- .../app/core/attachment/ThumbnailUtils.java | 11 ++- .../attachment/endpoint/ThumbnailRouters.java | 83 ++++++++++--------- 4 files changed, 67 insertions(+), 44 deletions(-) diff --git a/api/src/main/java/run/halo/app/core/extension/attachment/endpoint/AttachmentHandler.java b/api/src/main/java/run/halo/app/core/extension/attachment/endpoint/AttachmentHandler.java index 58f80dc1f..9436e31f2 100644 --- a/api/src/main/java/run/halo/app/core/extension/attachment/endpoint/AttachmentHandler.java +++ b/api/src/main/java/run/halo/app/core/extension/attachment/endpoint/AttachmentHandler.java @@ -3,6 +3,7 @@ package run.halo.app.core.extension.attachment.endpoint; import java.net.URI; import java.time.Duration; import java.util.Map; +import java.util.Optional; import org.pf4j.ExtensionPoint; import org.springframework.http.codec.multipart.FilePart; import reactor.core.publisher.Mono; @@ -69,6 +70,17 @@ public interface AttachmentHandler extends ExtensionPoint { return Mono.empty(); } + /** + * Build the thumbnail links from the given permalink. + * + * @param permalink the permalink + * @return an optional map of thumbnail sizes to their respective URIs + */ + default Optional> buildThumbnailLinks(URI permalink) { + // resolve the thumbnail link + return Optional.empty(); + } + interface UploadContext { FilePart file(); diff --git a/application/src/main/java/run/halo/app/core/attachment/ThumbnailImgTagPostProcessor.java b/application/src/main/java/run/halo/app/core/attachment/ThumbnailImgTagPostProcessor.java index e086eff09..65293ca5b 100644 --- a/application/src/main/java/run/halo/app/core/attachment/ThumbnailImgTagPostProcessor.java +++ b/application/src/main/java/run/halo/app/core/attachment/ThumbnailImgTagPostProcessor.java @@ -100,7 +100,10 @@ class ThumbnailImgTagPostProcessor implements ElementTagPostProcessor { var modelFactory = context.getModelFactory(); if (!tag.hasAttribute("sizes")) { tag = modelFactory.setAttribute(tag, "sizes", """ - (min-width: 800px) 800px, 100vw\ + (max-width: 640px) 94vw, \ + (max-width: 768px) 92vw, \ + (max-width: 1024px) 88vw, \ + min(800px, 85vw)\ """); } var srcset = thumbnails.keySet().stream() diff --git a/application/src/main/java/run/halo/app/core/attachment/ThumbnailUtils.java b/application/src/main/java/run/halo/app/core/attachment/ThumbnailUtils.java index b776eefba..e9716889e 100644 --- a/application/src/main/java/run/halo/app/core/attachment/ThumbnailUtils.java +++ b/application/src/main/java/run/halo/app/core/attachment/ThumbnailUtils.java @@ -51,12 +51,11 @@ public enum ThumbnailUtils { 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()) + .collect(Collectors.toMap(t -> t, t -> + UriComponentsBuilder.fromUri(permalink) + .queryParam("width", t.getWidth()) .build() - .toUri(); - })); + .toUri() + )); } } diff --git a/application/src/main/java/run/halo/app/core/attachment/endpoint/ThumbnailRouters.java b/application/src/main/java/run/halo/app/core/attachment/endpoint/ThumbnailRouters.java index b580391d5..fdee72c12 100644 --- a/application/src/main/java/run/halo/app/core/attachment/endpoint/ThumbnailRouters.java +++ b/application/src/main/java/run/halo/app/core/attachment/endpoint/ThumbnailRouters.java @@ -1,6 +1,8 @@ package run.halo.app.core.attachment.endpoint; import static java.nio.file.StandardOpenOption.READ; +import static org.springframework.web.reactive.function.server.RequestPredicates.accept; +import static org.springframework.web.reactive.function.server.RequestPredicates.queryParam; import java.io.IOException; import java.nio.file.Files; @@ -13,6 +15,7 @@ import org.apache.commons.io.FilenameUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.context.annotation.Bean; import org.springframework.core.io.FileSystemResource; +import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunctions; @@ -29,6 +32,8 @@ import run.halo.app.core.attachment.ThumbnailUtils; @Component class ThumbnailRouters { + private static final MediaType IMAGE_MEDIA_TYPE = MediaType.parseMediaType("image/*"); + private final Path uploadRoot; private final Path thumbnailRoot; @@ -49,46 +54,50 @@ class ThumbnailRouters { @Bean RouterFunction thumbnailRouter() { return RouterFunctions.route() - .GET("/thumbnails/w{width}/upload/{*filename}", serverRequest -> { - var width = serverRequest.pathVariable("width"); - String originalFilename = serverRequest.pathVariable("filename"); - var filename = StringUtils.removeStart(originalFilename, "/"); - if (StringUtils.isBlank(filename)) { - log.trace("Filename is blank"); - return Mono.error(new NoResourceFoundException(filename)); - } - var size = ThumbnailSize.fromWidth(width); - // try to resolve the thumbnail - // build thumbnail path - var thumbnailPath = thumbnailRoot.resolve("w" + size.getWidth()) - .resolve(filename); - var thumbnailResource = new FileSystemResource(thumbnailPath); - if (thumbnailResource.isReadable()) { - return ServerResponse.ok().bodyValue(thumbnailResource); - } - - var uploadPath = uploadRoot.resolve(filename); - var fileSuffix = FilenameUtils.getExtension(filename); - if (!ThumbnailUtils.isSupportedImage(fileSuffix)) { - log.warn("File suffix {} is not supported for thumbnail generation, return " - + "original file", fileSuffix); - // return the original file - thumbnailResource = new FileSystemResource(uploadPath); - if (!thumbnailResource.isReadable()) { + .GET( + "/upload/{*filename}", + queryParam("width", StringUtils::isNotBlank).and(accept(IMAGE_MEDIA_TYPE)), + serverRequest -> { + var size = serverRequest.queryParam("width") + .map(ThumbnailSize::fromWidth) + .orElse(ThumbnailSize.M); + String originalFilename = serverRequest.pathVariable("filename"); + var filename = StringUtils.removeStart(originalFilename, "/"); + if (StringUtils.isBlank(filename)) { + log.trace("Filename is blank"); return Mono.error(new NoResourceFoundException(filename)); } - return ServerResponse.ok().bodyValue(thumbnailResource); - } + // try to resolve the thumbnail + // build thumbnail path + var thumbnailPath = thumbnailRoot.resolve("w" + size.getWidth()) + .resolve(filename); + var thumbnailResource = new FileSystemResource(thumbnailPath); + if (thumbnailResource.isReadable()) { + return ServerResponse.ok().bodyValue(thumbnailResource); + } - // generate for the attachment - return Mono.fromCallable( - () -> generateThumbnail(uploadPath, thumbnailPath, size) - ) - .subscribeOn(this.thumbnailGeneratingScheduler) - .switchIfEmpty(Mono.error(() -> new NoResourceFoundException(filename))) - .map(FileSystemResource::new) - .flatMap(resource -> ServerResponse.ok().bodyValue(resource)); - }) + var uploadPath = uploadRoot.resolve(filename); + var fileSuffix = FilenameUtils.getExtension(filename); + if (!ThumbnailUtils.isSupportedImage(fileSuffix)) { + log.warn("File suffix {} is not supported for thumbnail generation, return " + + "original file", fileSuffix); + // return the original file + thumbnailResource = new FileSystemResource(uploadPath); + if (!thumbnailResource.isReadable()) { + return Mono.error(new NoResourceFoundException(filename)); + } + return ServerResponse.ok().bodyValue(thumbnailResource); + } + + // generate for the attachment + return Mono.fromCallable( + () -> generateThumbnail(uploadPath, thumbnailPath, size) + ) + .subscribeOn(this.thumbnailGeneratingScheduler) + .switchIfEmpty(Mono.error(() -> new NoResourceFoundException(filename))) + .map(FileSystemResource::new) + .flatMap(resource -> ServerResponse.ok().bodyValue(resource)); + }) .build(); }