From ce2cfba4c6fec04861cc97f03b88bd1ea4d54164 Mon Sep 17 00:00:00 2001 From: John Niang Date: Fri, 26 Sep 2025 00:18:49 +0800 Subject: [PATCH] Implement thumbnail generation and add support for image format validation --- api/build.gradle | 1 + .../endpoint/AttachmentHandler.java | 7 +- .../app/core/attachment/ThumbnailUtils.java | 36 ++++++++ .../LocalAttachmentUploadHandler.java | 6 ++ .../attachment/endpoint/ThumbnailRouters.java | 83 +++++++++---------- .../reconciler/AttachmentReconciler.java | 7 -- .../impl/DefaultAttachmentService.java | 5 ++ .../core/attachment/ThumbnailUtilsTest.java | 29 +++++++ gradle/libs.versions.toml | 1 + platform/application/build.gradle | 1 + 10 files changed, 118 insertions(+), 58 deletions(-) create mode 100644 application/src/main/java/run/halo/app/core/attachment/ThumbnailUtils.java create mode 100644 application/src/test/java/run/halo/app/core/attachment/ThumbnailUtilsTest.java diff --git a/api/build.gradle b/api/build.gradle index adef63e9e..56f658f5d 100644 --- a/api/build.gradle +++ b/api/build.gradle @@ -89,6 +89,7 @@ dependencies { api "org.thymeleaf.extras:thymeleaf-extras-springsecurity6" api 'org.apache.tika:tika-core' api "org.imgscalr:imgscalr-lib" + api 'net.coobird:thumbnailator' api 'com.drewnoakes:metadata-extractor' api "io.github.resilience4j:resilience4j-spring-boot3" 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 fb031eadf..58f80dc1f 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 @@ -10,7 +10,6 @@ import run.halo.app.core.attachment.ThumbnailSize; import run.halo.app.core.extension.attachment.Attachment; import run.halo.app.core.extension.attachment.Policy; import run.halo.app.extension.ConfigMap; -import run.halo.app.extension.exception.NotImplementedException; public interface AttachmentHandler extends ExtensionPoint { @@ -58,7 +57,6 @@ public interface AttachmentHandler extends ExtensionPoint { /** * Gets thumbnail links for given attachment. - * The default implementation will raise NotImplementedException. * * @param attachment the attachment * @param policy the policy @@ -68,10 +66,7 @@ public interface AttachmentHandler extends ExtensionPoint { default Mono> getThumbnailLinks(Attachment attachment, Policy policy, ConfigMap configMap) { - return Mono.error(new NotImplementedException( - "getThumbnailLinks method is not implemented for " + attachment.getMetadata().getName() - + ", please try to upgrade the corresponding plugin" - )); + return Mono.empty(); } interface UploadContext { 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 new file mode 100644 index 000000000..09f12d388 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/attachment/ThumbnailUtils.java @@ -0,0 +1,36 @@ +package run.halo.app.core.attachment; + +import java.util.Set; +import java.util.stream.Collectors; +import org.springframework.http.MediaType; +import org.springframework.util.MimeType; + +public enum ThumbnailUtils { + ; + + private static final Set SUPPORTED_IMAGE_SUFFIXES = Set.of( + "jpg", "jpeg", "png", "bmp", "wbmp" + ); + + private static final Set SUPPORTED_IMAGE_MIME_TYPES = Set.of( + "image/jpg", "image/jpeg", "image/png", "image/bmp", "image/vnd.wap.wbmp" + ) + .stream() + .map(MediaType::parseMediaType) + .collect(Collectors.toSet()); + + /** + * Check if the given file suffix is a supported image format for thumbnail generation. + * + * @param fileSuffix the file suffix to check (without the dot) + * @return true if the file suffix is supported, false otherwise + */ + public static boolean isSupportedImage(String fileSuffix) { + return SUPPORTED_IMAGE_SUFFIXES.contains(fileSuffix.toLowerCase()); + } + + public static boolean isSupportedImage(MimeType mimeType) { + return SUPPORTED_IMAGE_MIME_TYPES.stream() + .anyMatch(supported -> supported.isCompatibleWith(mimeType)); + } +} diff --git a/application/src/main/java/run/halo/app/core/attachment/endpoint/LocalAttachmentUploadHandler.java b/application/src/main/java/run/halo/app/core/attachment/endpoint/LocalAttachmentUploadHandler.java index e29122a20..5dcf0a774 100644 --- a/application/src/main/java/run/halo/app/core/attachment/endpoint/LocalAttachmentUploadHandler.java +++ b/application/src/main/java/run/halo/app/core/attachment/endpoint/LocalAttachmentUploadHandler.java @@ -45,6 +45,7 @@ import reactor.core.scheduler.Schedulers; import reactor.util.retry.Retry; import run.halo.app.core.attachment.AttachmentRootGetter; import run.halo.app.core.attachment.ThumbnailSize; +import run.halo.app.core.attachment.ThumbnailUtils; 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; @@ -310,6 +311,11 @@ 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()); 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 18ed6fff9..3147085eb 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,19 +1,15 @@ package run.halo.app.core.attachment.endpoint; -import static java.nio.file.StandardOpenOption.CREATE; import static java.nio.file.StandardOpenOption.READ; -import static java.nio.file.StandardOpenOption.TRUNCATE_EXISTING; -import static java.nio.file.StandardOpenOption.WRITE; -import static org.imgscalr.Scalr.Method.AUTOMATIC; -import static org.imgscalr.Scalr.Mode.FIT_TO_WIDTH; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import javax.imageio.ImageIO; import lombok.extern.slf4j.Slf4j; +import net.coobird.thumbnailator.Thumbnails; +import org.apache.commons.io.FilenameUtils; import org.apache.commons.lang3.StringUtils; -import org.imgscalr.Scalr; import org.springframework.context.annotation.Bean; import org.springframework.core.io.FileSystemResource; import org.springframework.stereotype.Component; @@ -26,6 +22,7 @@ import reactor.core.scheduler.Scheduler; import reactor.core.scheduler.Schedulers; import run.halo.app.core.attachment.AttachmentRootGetter; import run.halo.app.core.attachment.ThumbnailSize; +import run.halo.app.core.attachment.ThumbnailUtils; @Slf4j @Component @@ -54,28 +51,40 @@ class ThumbnailRouters { .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)) { + var filename = StringUtils.removeStart(originalFilename, "/"); + if (StringUtils.isBlank(filename)) { log.trace("Filename is blank"); - return Mono.error(new NoResourceFoundException(fileName)); + 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); + .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()) { + return Mono.error(new NoResourceFoundException(filename)); + } + return ServerResponse.ok().bodyValue(thumbnailResource); + } + // generate for the attachment return Mono.fromCallable( - () -> { - var uploadPath = uploadRoot.resolve(fileName); - return generateThumbnail(uploadPath, thumbnailPath, size); - }) + () -> generateThumbnail(uploadPath, thumbnailPath, size) + ) .subscribeOn(this.thumbnailGeneratingScheduler) - .switchIfEmpty(Mono.error(() -> new NoResourceFoundException(fileName))) + .switchIfEmpty(Mono.error(() -> new NoResourceFoundException(filename))) .map(FileSystemResource::new) .flatMap(resource -> ServerResponse.ok().bodyValue(resource)); }) @@ -100,16 +109,12 @@ class ThumbnailRouters { log.debug("Generating thumbnail for path: {}, target: {}, size: {}", attachmentPath, thumbnailPath, size); } - var formatName = getFormatName(attachmentPath); - if (formatName == null) { - log.info("Cannot determine image format for path: {}", attachmentPath); - return null; - } try ( var inputStream = Files.newInputStream(attachmentPath, READ); ) { var bufferedImage = ImageIO.read(inputStream); if (bufferedImage == null) { + // indicate that it's not an image or unsupported image format return null; } if (bufferedImage.getWidth() <= size.getWidth()) { @@ -118,39 +123,27 @@ class ThumbnailRouters { Files.copy(attachmentPath, thumbnailPath); return thumbnailPath; } - // TODO Handle image orientation - var thumbnailBufferedImage = - Scalr.resize(bufferedImage, AUTOMATIC, FIT_TO_WIDTH, size.getWidth()); Files.createDirectories(thumbnailPath.getParent()); - try (var outputStream = - Files.newOutputStream(thumbnailPath, CREATE, TRUNCATE_EXISTING, WRITE) - ) { - ImageIO.write(thumbnailBufferedImage, formatName, outputStream); - } + Thumbnails.of(bufferedImage) + .width(size.getWidth()) + .toFile(thumbnailPath.toFile()); log.info("Generated thumbnail for path: {}, target: {}, size: {}", attachmentPath, thumbnailPath, size); return thumbnailPath; } catch (IOException e) { log.warn("Failed to generate thumbnail for path: {}", attachmentPath, e); - return null; + // delete the possibly created file + try { + Files.deleteIfExists(thumbnailPath); + } catch (IOException ex) { + // ignore this error + log.warn("Failed to delete possibly created thumbnail file: {}", + thumbnailPath, ex); + } + // return the original attachment path + return attachmentPath; } } } - private String getFormatName(Path imagePath) { - try (var imageInputStream = ImageIO.createImageInputStream( - Files.newInputStream(imagePath)) - ) { - var readers = ImageIO.getImageReaders(imageInputStream); - if (!readers.hasNext()) { - return null; - } - var reader = readers.next(); - return reader.getFormatName(); - } catch (IOException e) { - log.warn("Failed to get image format for path: {}", imagePath, e); - return null; - } - } - } diff --git a/application/src/main/java/run/halo/app/core/attachment/reconciler/AttachmentReconciler.java b/application/src/main/java/run/halo/app/core/attachment/reconciler/AttachmentReconciler.java index d8a700378..003f206aa 100644 --- a/application/src/main/java/run/halo/app/core/attachment/reconciler/AttachmentReconciler.java +++ b/application/src/main/java/run/halo/app/core/attachment/reconciler/AttachmentReconciler.java @@ -23,7 +23,6 @@ import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.Reconciler; import run.halo.app.extension.controller.Reconciler.Request; import run.halo.app.extension.controller.RequeueException; -import run.halo.app.extension.exception.NotImplementedException; @Slf4j @Component @@ -66,12 +65,6 @@ public class AttachmentReconciler implements Reconciler { .stream() .collect(Collectors.toMap(Enum::name, k -> map.get(k).toASCIIString())) ) - .onErrorMap(NotImplementedException.class, - e -> new RequeueException(new Result(true, null), - "Attachment handler does not implement thumbnail generation, requeue " - + "the " - + "request" - )) .blockOptional(Duration.ofSeconds(10)) .orElseThrow(() -> new RequeueException(new Result(true, null), """ Attachment handler is unavailable for getting thumbnails links, \ diff --git a/application/src/main/java/run/halo/app/core/user/service/impl/DefaultAttachmentService.java b/application/src/main/java/run/halo/app/core/user/service/impl/DefaultAttachmentService.java index 57302ca35..3ec3494cb 100644 --- a/application/src/main/java/run/halo/app/core/user/service/impl/DefaultAttachmentService.java +++ b/application/src/main/java/run/halo/app/core/user/service/impl/DefaultAttachmentService.java @@ -26,6 +26,7 @@ import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.attachment.ThumbnailSize; +import run.halo.app.core.attachment.ThumbnailUtils; import run.halo.app.core.extension.attachment.Attachment; import run.halo.app.core.extension.attachment.Policy; import run.halo.app.core.extension.attachment.endpoint.AttachmentHandler; @@ -143,6 +144,10 @@ public class DefaultAttachmentService implements AttachmentService { @Override public Mono> getThumbnailLinks(Attachment attachment) { + var mediaType = MediaType.parseMediaType(attachment.getSpec().getMediaType()); + if (!ThumbnailUtils.isSupportedImage(mediaType)) { + return Mono.just(Map.of()); + } return client.get(Policy.class, attachment.getSpec().getPolicyName()) .zipWhen(policy -> client.get(ConfigMap.class, policy.getSpec().getConfigMapName())) .flatMap(tuple2 -> { diff --git a/application/src/test/java/run/halo/app/core/attachment/ThumbnailUtilsTest.java b/application/src/test/java/run/halo/app/core/attachment/ThumbnailUtilsTest.java new file mode 100644 index 000000000..112be1d63 --- /dev/null +++ b/application/src/test/java/run/halo/app/core/attachment/ThumbnailUtilsTest.java @@ -0,0 +1,29 @@ +package run.halo.app.core.attachment; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.http.MediaType; + +class ThumbnailUtilsTest { + + @ParameterizedTest + @ValueSource(strings = { + "image/jpg", "image/jpeg", "image/png", "image/bmp", "image/vnd.wap.wbmp", + }) + void isSupportedImageTestByMimeType(String mimeType) { + assertTrue(ThumbnailUtils.isSupportedImage(MediaType.parseMediaType(mimeType))); + } + + @ParameterizedTest + @ValueSource(strings = { + "image/svg+xml", "image/gif", "image/webp", "image/x-icon", "image/avif", "image/tiff", + "application/json", "text/plain" + }) + void isNotSupportedImageTestByMimeType(String mimeType) { + assertFalse(ThumbnailUtils.isSupportedImage(MediaType.parseMediaType(mimeType))); + } + +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1d5244f0f..e2567fe26 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,6 +30,7 @@ openapi-schema-validator = 'org.openapi4j:openapi-schema-validator:1.0.7' bouncycastle-bcpkix = 'org.bouncycastle:bcpkix-jdk18on:1.81' twofactor-auth = 'com.j256.two-factor-auth:two-factor-auth:1.3' imgscalr-lib = 'org.imgscalr:imgscalr-lib:4.2' +thumbnailator = 'net.coobird:thumbnailator:0.4.20' metadata-extractor = 'com.drewnoakes:metadata-extractor:2.19.0' [bundles] diff --git a/platform/application/build.gradle b/platform/application/build.gradle index ba7696ce9..6db3da3f9 100644 --- a/platform/application/build.gradle +++ b/platform/application/build.gradle @@ -32,6 +32,7 @@ dependencies { api libs.bundles.resilience4j api libs.twofactor.auth api libs.imgscalr.lib + api libs.thumbnailator api libs.metadata.extractor api "org.springframework.integration:spring-integration-core" api "org.thymeleaf.extras:thymeleaf-extras-springsecurity6"