mirror of https://github.com/halo-dev/halo
Implement thumbnail generation and add support for image format validation
parent
59f06c8e95
commit
ce2cfba4c6
|
@ -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"
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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, \
|
||||
|
|
|
@ -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 -> {
|
||||
|
|
|
@ -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)));
|
||||
}
|
||||
|
||||
}
|
|
@ -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]
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue