Skip to content

Commit

Permalink
feat: implement new mechanisms for generating and managing attachment…
Browse files Browse the repository at this point in the history
… thumbnails
  • Loading branch information
guqing committed Aug 20, 2024
1 parent 1f7f103 commit 7ecf54d
Show file tree
Hide file tree
Showing 38 changed files with 2,211 additions and 113 deletions.
1 change: 1 addition & 0 deletions api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ dependencies {
api "com.github.java-json-tools:json-patch"
api "org.thymeleaf.extras:thymeleaf-extras-springsecurity6"
api 'org.apache.tika:tika-core'
api "org.imgscalr:imgscalr-lib"

api "io.github.resilience4j:resilience4j-spring-boot3"
api "io.github.resilience4j:resilience4j-reactor"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package run.halo.app.core.attachment;

import java.net.URI;
import java.net.URL;
import lombok.Builder;
import lombok.Data;
import org.pf4j.ExtensionPoint;
import reactor.core.publisher.Mono;

public interface ThumbnailProvider extends ExtensionPoint {

/**
* Generate thumbnail URI for given image URL and size.
*
* @param context Thumbnail context including image URI and size
* @return Generated thumbnail URI
*/
Mono<URI> generate(ThumbnailContext context);

/**
* Delete thumbnail file for given image URL.
*
* @param imageUrl original image URL
*/
Mono<Void> delete(URL imageUrl);

/**
* Whether the provider supports the given image URI.
*
* @return {@code true} if supports, {@code false} otherwise
*/
Mono<Boolean> supports(ThumbnailContext context);

@Data
@Builder
class ThumbnailContext {
private final URL imageUrl;
private final ThumbnailSize size;
}
}
43 changes: 43 additions & 0 deletions api/src/main/java/run/halo/app/core/attachment/ThumbnailSize.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package run.halo.app.core.attachment;

import lombok.Getter;

@Getter
public enum ThumbnailSize {
S(400),
M(800),
L(1200),
XL(1600);

private final int width;

ThumbnailSize(int width) {
this.width = width;
}

/**
* Convert width string to {@link ThumbnailSize}.
*
* @param width width string
*/
public static ThumbnailSize fromWidth(String width) {
for (ThumbnailSize value : values()) {
if (String.valueOf(value.getWidth()).equals(width)) {
return value;
}
}
return ThumbnailSize.M;
}

/**
* Convert name to {@link ThumbnailSize}.
*/
public static ThumbnailSize fromName(String name) {
for (ThumbnailSize value : values()) {
if (value.name().equalsIgnoreCase(name)) {
return value;
}
}
throw new IllegalArgumentException("No such thumbnail size: " + name);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.Map;
import java.util.Set;
import lombok.Data;
import lombok.EqualsAndHashCode;
Expand Down Expand Up @@ -64,5 +65,6 @@ public static class AttachmentStatus {
""")
private String permalink;

private Map<String, String> thumbnails;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package run.halo.app.content;

import java.net.URI;
import java.util.function.Function;
import lombok.experimental.UtilityClass;
import org.apache.commons.lang3.StringUtils;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.select.Elements;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.attachment.ThumbnailService;
import run.halo.app.core.attachment.ThumbnailSize;

@UtilityClass
public class HtmlThumbnailSrcsetInjector {
static final String SRC = "src";
static final String SRCSET = "srcset";

/**
* Inject srcset attribute to img tags in the given html.
*/
public static Mono<String> injectSrcset(String html,
Function<String, Mono<String>> srcSetValueGenerator) {
Document document = Jsoup.parseBodyFragment(html);
document.outputSettings(new Document.OutputSettings().prettyPrint(false));

Elements imgTags = document.select("img[src]");
return Flux.fromIterable(imgTags)
.filter(element -> {
String src = element.attr(SRC);
return !element.hasAttr(SRCSET) && isValidSrc(src);
})
.flatMap(img -> {
String src = img.attr(SRC);
return srcSetValueGenerator.apply(src)
.filter(StringUtils::isNotBlank)
.doOnNext(srcsetValue -> {
img.attr(SRCSET, srcsetValue);
img.attr("sizes", buildSizesAttr());
});
})
.then(Mono.fromSupplier(() -> document.body().html()));
}

static String buildSizesAttr() {
var sb = new StringBuilder();
var delimiter = ", ";
var sizes = ThumbnailSize.values();
for (int i = 0; i < sizes.length; i++) {
var size = sizes[i];
sb.append("(max-width: ").append(size.getWidth()).append("px)")
.append(" ")
.append(size.getWidth())
.append("px");
if (i < sizes.length - 1) {
sb.append(delimiter);
}
}
return sb.toString();
}

/**
* Generate srcset attribute value for the given src.
*/
public static Mono<String> generateSrcset(URI src, ThumbnailService thumbnailService) {
return Flux.fromArray(ThumbnailSize.values())
.flatMap(size -> thumbnailService.generate(src, size)
.map(thumbnail -> thumbnail.toString() + " " + size.getWidth() + "w")
)
.collect(StringBuilder::new, (builder, srcsetValue) -> {
if (!builder.isEmpty()) {
builder.append(", ");
}
builder.append(srcsetValue);
})
.map(StringBuilder::toString);
}

private static boolean isValidSrc(String src) {
if (StringUtils.isBlank(src)) {
return false;
}
try {
URI.create(src);
return true;
} catch (IllegalArgumentException e) {
// ignore
}
return false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package run.halo.app.content;

import static run.halo.app.content.HtmlThumbnailSrcsetInjector.generateSrcset;

import java.net.URI;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import run.halo.app.core.attachment.ThumbnailService;
import run.halo.app.theme.ReactivePostContentHandler;

/**
* A post content handler to handle post html content and generate thumbnail by the img tag.
*
* @author guqing
* @since 2.19.0
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class PostContentThumbnailHandler implements ReactivePostContentHandler {
private final ThumbnailService thumbnailService;

@Override
public Mono<PostContentContext> handle(@NonNull PostContentContext postContent) {
var html = postContent.getContent();
return HtmlThumbnailSrcsetInjector.injectSrcset(html,
src -> generateSrcset(URI.create(src), thumbnailService)
)
.onErrorResume(throwable -> {
log.debug("Failed to inject srcset to post content, fallback to original content",
throwable);
return Mono.just(html);
})
.doOnNext(postContent::setContent)
.thenReturn(postContent);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package run.halo.app.core.attachment;

import java.nio.file.Path;
import java.util.function.Supplier;

/**
* Gets the root path(work dir) of the local attachment.
*/
public interface AttachmentRootGetter extends Supplier<Path> {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package run.halo.app.core.attachment;

import static run.halo.app.infra.FileCategoryMatcher.IMAGE;

import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import lombok.experimental.UtilityClass;
import org.springframework.lang.NonNull;
import org.springframework.util.Assert;
import org.springframework.web.util.UriComponentsBuilder;
import org.springframework.web.util.UriUtils;
import run.halo.app.core.extension.attachment.Attachment;

@UtilityClass
public class AttachmentUtils {
/**
* Check whether the attachment is an image.
*
* @param attachment Attachment must not be null
* @return true if the attachment is an image, false otherwise
*/
public static boolean isImage(Attachment attachment) {
Assert.notNull(attachment, "Attachment must not be null");
var mediaType = attachment.getSpec().getMediaType();
return mediaType != null && IMAGE.match(mediaType);
}

/**
* Convert URI to URL.
*
* @param uri URI must not be null
* @return URL
* @throws IllegalArgumentException if the URL is malformed
*/
public static URL toUrl(@NonNull URI uri) {
try {
return uri.toURL();
} catch (MalformedURLException e) {
throw new IllegalArgumentException(e);
}
}

/**
* Encode uri string to URI.
* This method will decode the uri string first and then encode it.
*/
public static URI encodeUri(String uriStr) {
var decodedUriStr = UriUtils.decode(uriStr, StandardCharsets.UTF_8);
return UriComponentsBuilder.fromUriString(decodedUriStr)
.encode(StandardCharsets.UTF_8)
.build()
.toUri();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package run.halo.app.core.attachment;

import java.net.URI;
import java.net.URL;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import reactor.core.publisher.Mono;
import run.halo.app.infra.ExternalUrlSupplier;

@Component
@RequiredArgsConstructor
public class LocalThumbnailProvider implements ThumbnailProvider {
private final ExternalUrlSupplier externalUrlSupplier;
private final LocalThumbnailService localThumbnailService;

@Override
public Mono<URI> generate(ThumbnailContext context) {
return localThumbnailService.create(context.getImageUrl(), context.getSize())
.map(localThumbnail -> localThumbnail.getSpec().getThumbnailUri())
.map(URI::create);
}

@Override
public Mono<Void> delete(URL imageUrl) {
Assert.notNull(imageUrl, "Image URL must not be null");
return localThumbnailService.delete(URI.create(imageUrl.toString()));
}

@Override
public Mono<Boolean> supports(ThumbnailContext context) {
var imageUrl = context.getImageUrl();
var externalUrl = externalUrlSupplier.getRaw();
return Mono.fromSupplier(() -> externalUrl != null
&& isSameOrigin(imageUrl, externalUrl));
}

private boolean isSameOrigin(URL imageUrl, URL externalUrl) {
return StringUtils.equals(imageUrl.getHost(), externalUrl.getHost())
&& imageUrl.getPort() == externalUrl.getPort();
}
}
Loading

0 comments on commit 7ecf54d

Please sign in to comment.