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.thymeleaf.extras:thymeleaf-extras-springsecurity6"
api 'org.apache.tika:tika-core' api 'org.apache.tika:tika-core'
api "org.imgscalr:imgscalr-lib" api "org.imgscalr:imgscalr-lib"
api 'net.coobird:thumbnailator'
api 'com.drewnoakes:metadata-extractor' api 'com.drewnoakes:metadata-extractor'
api "io.github.resilience4j:resilience4j-spring-boot3" 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.Attachment;
import run.halo.app.core.extension.attachment.Policy; import run.halo.app.core.extension.attachment.Policy;
import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.exception.NotImplementedException;
public interface AttachmentHandler extends ExtensionPoint { public interface AttachmentHandler extends ExtensionPoint {
@ -58,7 +57,6 @@ public interface AttachmentHandler extends ExtensionPoint {
/** /**
* Gets thumbnail links for given attachment. * Gets thumbnail links for given attachment.
* The default implementation will raise NotImplementedException.
* *
* @param attachment the attachment * @param attachment the attachment
* @param policy the policy * @param policy the policy
@ -68,10 +66,7 @@ public interface AttachmentHandler extends ExtensionPoint {
default Mono<Map<ThumbnailSize, URI>> getThumbnailLinks(Attachment attachment, default Mono<Map<ThumbnailSize, URI>> getThumbnailLinks(Attachment attachment,
Policy policy, Policy policy,
ConfigMap configMap) { ConfigMap configMap) {
return Mono.error(new NotImplementedException( return Mono.empty();
"getThumbnailLinks method is not implemented for " + attachment.getMetadata().getName()
+ ", please try to upgrade the corresponding plugin"
));
} }
interface UploadContext { 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 reactor.util.retry.Retry;
import run.halo.app.core.attachment.AttachmentRootGetter; import run.halo.app.core.attachment.AttachmentRootGetter;
import run.halo.app.core.attachment.ThumbnailSize; 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;
import run.halo.app.core.extension.attachment.Attachment.AttachmentSpec; import run.halo.app.core.extension.attachment.Attachment.AttachmentSpec;
import run.halo.app.core.extension.attachment.Constant; import run.halo.app.core.extension.attachment.Constant;
@ -310,6 +311,11 @@ class LocalAttachmentUploadHandler implements AttachmentHandler {
|| !StringUtils.hasText(attachment.getStatus().getPermalink())) { || !StringUtils.hasText(attachment.getStatus().getPermalink())) {
return Mono.just(Map.of()); 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()) var thumbnails = Arrays.stream(ThumbnailSize.values())
.collect(Collectors.toMap(t -> t, t -> { .collect(Collectors.toMap(t -> t, t -> {
var permalink = URI.create(attachment.getStatus().getPermalink()); var permalink = URI.create(attachment.getStatus().getPermalink());

View File

@ -1,19 +1,15 @@
package run.halo.app.core.attachment.endpoint; 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.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.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import javax.imageio.ImageIO; import javax.imageio.ImageIO;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import net.coobird.thumbnailator.Thumbnails;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.imgscalr.Scalr;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.FileSystemResource;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@ -26,6 +22,7 @@ import reactor.core.scheduler.Scheduler;
import reactor.core.scheduler.Schedulers; import reactor.core.scheduler.Schedulers;
import run.halo.app.core.attachment.AttachmentRootGetter; import run.halo.app.core.attachment.AttachmentRootGetter;
import run.halo.app.core.attachment.ThumbnailSize; import run.halo.app.core.attachment.ThumbnailSize;
import run.halo.app.core.attachment.ThumbnailUtils;
@Slf4j @Slf4j
@Component @Component
@ -54,28 +51,40 @@ class ThumbnailRouters {
.GET("/thumbnails/w{width}/upload/{*filename}", serverRequest -> { .GET("/thumbnails/w{width}/upload/{*filename}", serverRequest -> {
var width = serverRequest.pathVariable("width"); var width = serverRequest.pathVariable("width");
String originalFilename = serverRequest.pathVariable("filename"); String originalFilename = serverRequest.pathVariable("filename");
var fileName = StringUtils.removeStart(originalFilename, "/"); var filename = StringUtils.removeStart(originalFilename, "/");
if (StringUtils.isBlank(fileName)) { if (StringUtils.isBlank(filename)) {
log.trace("Filename is blank"); log.trace("Filename is blank");
return Mono.error(new NoResourceFoundException(fileName)); return Mono.error(new NoResourceFoundException(filename));
} }
var size = ThumbnailSize.fromWidth(width); var size = ThumbnailSize.fromWidth(width);
// try to resolve the thumbnail // try to resolve the thumbnail
// build thumbnail path // build thumbnail path
var thumbnailPath = thumbnailRoot.resolve("w" + size.getWidth()) var thumbnailPath = thumbnailRoot.resolve("w" + size.getWidth())
.resolve(fileName); .resolve(filename);
var thumbnailResource = new FileSystemResource(thumbnailPath); var thumbnailResource = new FileSystemResource(thumbnailPath);
if (thumbnailResource.isReadable()) { if (thumbnailResource.isReadable()) {
return ServerResponse.ok().bodyValue(thumbnailResource); 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 // generate for the attachment
return Mono.fromCallable( return Mono.fromCallable(
() -> { () -> generateThumbnail(uploadPath, thumbnailPath, size)
var uploadPath = uploadRoot.resolve(fileName); )
return generateThumbnail(uploadPath, thumbnailPath, size);
})
.subscribeOn(this.thumbnailGeneratingScheduler) .subscribeOn(this.thumbnailGeneratingScheduler)
.switchIfEmpty(Mono.error(() -> new NoResourceFoundException(fileName))) .switchIfEmpty(Mono.error(() -> new NoResourceFoundException(filename)))
.map(FileSystemResource::new) .map(FileSystemResource::new)
.flatMap(resource -> ServerResponse.ok().bodyValue(resource)); .flatMap(resource -> ServerResponse.ok().bodyValue(resource));
}) })
@ -100,16 +109,12 @@ class ThumbnailRouters {
log.debug("Generating thumbnail for path: {}, target: {}, size: {}", log.debug("Generating thumbnail for path: {}, target: {}, size: {}",
attachmentPath, thumbnailPath, size); attachmentPath, thumbnailPath, size);
} }
var formatName = getFormatName(attachmentPath);
if (formatName == null) {
log.info("Cannot determine image format for path: {}", attachmentPath);
return null;
}
try ( try (
var inputStream = Files.newInputStream(attachmentPath, READ); var inputStream = Files.newInputStream(attachmentPath, READ);
) { ) {
var bufferedImage = ImageIO.read(inputStream); var bufferedImage = ImageIO.read(inputStream);
if (bufferedImage == null) { if (bufferedImage == null) {
// indicate that it's not an image or unsupported image format
return null; return null;
} }
if (bufferedImage.getWidth() <= size.getWidth()) { if (bufferedImage.getWidth() <= size.getWidth()) {
@ -118,39 +123,27 @@ class ThumbnailRouters {
Files.copy(attachmentPath, thumbnailPath); Files.copy(attachmentPath, thumbnailPath);
return thumbnailPath; return thumbnailPath;
} }
// TODO Handle image orientation
var thumbnailBufferedImage =
Scalr.resize(bufferedImage, AUTOMATIC, FIT_TO_WIDTH, size.getWidth());
Files.createDirectories(thumbnailPath.getParent()); Files.createDirectories(thumbnailPath.getParent());
try (var outputStream = Thumbnails.of(bufferedImage)
Files.newOutputStream(thumbnailPath, CREATE, TRUNCATE_EXISTING, WRITE) .width(size.getWidth())
) { .toFile(thumbnailPath.toFile());
ImageIO.write(thumbnailBufferedImage, formatName, outputStream);
}
log.info("Generated thumbnail for path: {}, target: {}, size: {}", log.info("Generated thumbnail for path: {}, target: {}, size: {}",
attachmentPath, thumbnailPath, size); attachmentPath, thumbnailPath, size);
return thumbnailPath; return thumbnailPath;
} catch (IOException e) { } catch (IOException e) {
log.warn("Failed to generate thumbnail for path: {}", attachmentPath, 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;
import run.halo.app.extension.controller.Reconciler.Request; import run.halo.app.extension.controller.Reconciler.Request;
import run.halo.app.extension.controller.RequeueException; import run.halo.app.extension.controller.RequeueException;
import run.halo.app.extension.exception.NotImplementedException;
@Slf4j @Slf4j
@Component @Component
@ -66,12 +65,6 @@ public class AttachmentReconciler implements Reconciler<Request> {
.stream() .stream()
.collect(Collectors.toMap(Enum::name, k -> map.get(k).toASCIIString())) .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)) .blockOptional(Duration.ofSeconds(10))
.orElseThrow(() -> new RequeueException(new Result(true, null), """ .orElseThrow(() -> new RequeueException(new Result(true, null), """
Attachment handler is unavailable for getting thumbnails links, \ 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.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import run.halo.app.core.attachment.ThumbnailSize; 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;
import run.halo.app.core.extension.attachment.Policy; import run.halo.app.core.extension.attachment.Policy;
import run.halo.app.core.extension.attachment.endpoint.AttachmentHandler; import run.halo.app.core.extension.attachment.endpoint.AttachmentHandler;
@ -143,6 +144,10 @@ public class DefaultAttachmentService implements AttachmentService {
@Override @Override
public Mono<Map<ThumbnailSize, URI>> getThumbnailLinks(Attachment attachment) { 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()) return client.get(Policy.class, attachment.getSpec().getPolicyName())
.zipWhen(policy -> client.get(ConfigMap.class, policy.getSpec().getConfigMapName())) .zipWhen(policy -> client.get(ConfigMap.class, policy.getSpec().getConfigMapName()))
.flatMap(tuple2 -> { .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' bouncycastle-bcpkix = 'org.bouncycastle:bcpkix-jdk18on:1.81'
twofactor-auth = 'com.j256.two-factor-auth:two-factor-auth:1.3' twofactor-auth = 'com.j256.two-factor-auth:two-factor-auth:1.3'
imgscalr-lib = 'org.imgscalr:imgscalr-lib:4.2' imgscalr-lib = 'org.imgscalr:imgscalr-lib:4.2'
thumbnailator = 'net.coobird:thumbnailator:0.4.20'
metadata-extractor = 'com.drewnoakes:metadata-extractor:2.19.0' metadata-extractor = 'com.drewnoakes:metadata-extractor:2.19.0'
[bundles] [bundles]

View File

@ -32,6 +32,7 @@ dependencies {
api libs.bundles.resilience4j api libs.bundles.resilience4j
api libs.twofactor.auth api libs.twofactor.auth
api libs.imgscalr.lib api libs.imgscalr.lib
api libs.thumbnailator
api libs.metadata.extractor api libs.metadata.extractor
api "org.springframework.integration:spring-integration-core" api "org.springframework.integration:spring-integration-core"
api "org.thymeleaf.extras:thymeleaf-extras-springsecurity6" api "org.thymeleaf.extras:thymeleaf-extras-springsecurity6"