Add thumbnail routing with dynamic width handling and URI generation

feat/add-thumbnail-router
John Niang 2025-09-28 15:50:15 +08:00
parent 0a18d9ef20
commit 68e80d16ed
No known key found for this signature in database
GPG Key ID: D7363C015BBCAA59
4 changed files with 67 additions and 44 deletions

View File

@ -3,6 +3,7 @@ package run.halo.app.core.extension.attachment.endpoint;
import java.net.URI; import java.net.URI;
import java.time.Duration; import java.time.Duration;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import org.pf4j.ExtensionPoint; import org.pf4j.ExtensionPoint;
import org.springframework.http.codec.multipart.FilePart; import org.springframework.http.codec.multipart.FilePart;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
@ -69,6 +70,17 @@ public interface AttachmentHandler extends ExtensionPoint {
return Mono.empty(); 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<Map<ThumbnailSize, URI>> buildThumbnailLinks(URI permalink) {
// resolve the thumbnail link
return Optional.empty();
}
interface UploadContext { interface UploadContext {
FilePart file(); FilePart file();

View File

@ -100,7 +100,10 @@ class ThumbnailImgTagPostProcessor implements ElementTagPostProcessor {
var modelFactory = context.getModelFactory(); var modelFactory = context.getModelFactory();
if (!tag.hasAttribute("sizes")) { if (!tag.hasAttribute("sizes")) {
tag = modelFactory.setAttribute(tag, "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() var srcset = thumbnails.keySet().stream()

View File

@ -51,12 +51,11 @@ public enum ThumbnailUtils {
return Map.of(); return Map.of();
} }
return Arrays.stream(ThumbnailSize.values()) return Arrays.stream(ThumbnailSize.values())
.collect(Collectors.toMap(t -> t, t -> { .collect(Collectors.toMap(t -> t, t ->
var prefix = "/thumbnails/w" + t.getWidth(); UriComponentsBuilder.fromUri(permalink)
return UriComponentsBuilder.fromUri(permalink) .queryParam("width", t.getWidth())
.replacePath(prefix + permalink.getPath())
.build() .build()
.toUri(); .toUri()
})); ));
} }
} }

View File

@ -1,6 +1,8 @@
package run.halo.app.core.attachment.endpoint; package run.halo.app.core.attachment.endpoint;
import static java.nio.file.StandardOpenOption.READ; 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.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
@ -13,6 +15,7 @@ import org.apache.commons.io.FilenameUtils;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
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.http.MediaType;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions; import org.springframework.web.reactive.function.server.RouterFunctions;
@ -29,6 +32,8 @@ import run.halo.app.core.attachment.ThumbnailUtils;
@Component @Component
class ThumbnailRouters { class ThumbnailRouters {
private static final MediaType IMAGE_MEDIA_TYPE = MediaType.parseMediaType("image/*");
private final Path uploadRoot; private final Path uploadRoot;
private final Path thumbnailRoot; private final Path thumbnailRoot;
@ -49,46 +54,50 @@ class ThumbnailRouters {
@Bean @Bean
RouterFunction<ServerResponse> thumbnailRouter() { RouterFunction<ServerResponse> thumbnailRouter() {
return RouterFunctions.route() return RouterFunctions.route()
.GET("/thumbnails/w{width}/upload/{*filename}", serverRequest -> { .GET(
var width = serverRequest.pathVariable("width"); "/upload/{*filename}",
String originalFilename = serverRequest.pathVariable("filename"); queryParam("width", StringUtils::isNotBlank).and(accept(IMAGE_MEDIA_TYPE)),
var filename = StringUtils.removeStart(originalFilename, "/"); serverRequest -> {
if (StringUtils.isBlank(filename)) { var size = serverRequest.queryParam("width")
log.trace("Filename is blank"); .map(ThumbnailSize::fromWidth)
return Mono.error(new NoResourceFoundException(filename)); .orElse(ThumbnailSize.M);
} String originalFilename = serverRequest.pathVariable("filename");
var size = ThumbnailSize.fromWidth(width); var filename = StringUtils.removeStart(originalFilename, "/");
// try to resolve the thumbnail if (StringUtils.isBlank(filename)) {
// build thumbnail path log.trace("Filename is blank");
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()) {
return Mono.error(new NoResourceFoundException(filename)); 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 var uploadPath = uploadRoot.resolve(filename);
return Mono.fromCallable( var fileSuffix = FilenameUtils.getExtension(filename);
() -> generateThumbnail(uploadPath, thumbnailPath, size) if (!ThumbnailUtils.isSupportedImage(fileSuffix)) {
) log.warn("File suffix {} is not supported for thumbnail generation, return "
.subscribeOn(this.thumbnailGeneratingScheduler) + "original file", fileSuffix);
.switchIfEmpty(Mono.error(() -> new NoResourceFoundException(filename))) // return the original file
.map(FileSystemResource::new) thumbnailResource = new FileSystemResource(uploadPath);
.flatMap(resource -> ServerResponse.ok().bodyValue(resource)); 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(); .build();
} }