Implement thumbnail generation and add support for image format validation

feat/add-thumbnail-router
John Niang 2025-09-26 00:18:49 +08:00
parent 59f06c8e95
commit ce2cfba4c6
No known key found for this signature in database
GPG Key ID: D7363C015BBCAA59
10 changed files with 118 additions and 58 deletions

View File

@ -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"

View File

@ -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<Map<ThumbnailSize, URI>> 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 {

View File

@ -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<String> SUPPORTED_IMAGE_SUFFIXES = Set.of(
"jpg", "jpeg", "png", "bmp", "wbmp"
);
private static final Set<MimeType> 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));
}
}

View File

@ -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());

View File

@ -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;
}
}
}

View File

@ -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<Request> {
.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, \

View File

@ -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<Map<ThumbnailSize, URI>> 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 -> {

View File

@ -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)));
}
}

View File

@ -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]

View File

@ -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"