From 4e61191962f875903ed48f8e725632f0b22c0f12 Mon Sep 17 00:00:00 2001 From: JohnNiang Date: Thu, 19 Sep 2024 18:08:04 +0800 Subject: [PATCH] Use ElementTagPostProcessor to handle image replacement Signed-off-by: JohnNiang --- build.gradle | 2 +- .../se/webp/plugin/ImageTagProcessor.java | 111 ++++++++++ src/main/java/se/webp/plugin/Settings.java | 17 +- .../WebpCloudImageOptimizerWebFilter.java | 189 ------------------ src/main/resources/plugin.yaml | 2 +- 5 files changed, 122 insertions(+), 199 deletions(-) create mode 100644 src/main/java/se/webp/plugin/ImageTagProcessor.java delete mode 100644 src/main/java/se/webp/plugin/WebpCloudImageOptimizerWebFilter.java diff --git a/build.gradle b/build.gradle index 51d0e19..d528564 100644 --- a/build.gradle +++ b/build.gradle @@ -16,7 +16,7 @@ repositories { } dependencies { - implementation platform('run.halo.tools.platform:plugin:2.17.0-SNAPSHOT') + implementation platform('run.halo.tools.platform:plugin:2.20.0-SNAPSHOT') compileOnly 'run.halo.app:api' testImplementation 'run.halo.app:api' diff --git a/src/main/java/se/webp/plugin/ImageTagProcessor.java b/src/main/java/se/webp/plugin/ImageTagProcessor.java new file mode 100644 index 0000000..6c6e431 --- /dev/null +++ b/src/main/java/se/webp/plugin/ImageTagProcessor.java @@ -0,0 +1,111 @@ +package se.webp.plugin; + +import static org.thymeleaf.templatemode.TemplateMode.HTML; +import static se.webp.plugin.Settings.BasicConfig.GROUP; + +import java.net.URI; +import java.net.URL; +import java.util.Objects; +import java.util.Optional; +import org.apache.commons.lang3.ArrayUtils; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.util.UriComponentsBuilder; +import org.thymeleaf.context.ITemplateContext; +import org.thymeleaf.engine.ElementNames; +import org.thymeleaf.model.IAttribute; +import org.thymeleaf.model.IProcessableElementTag; +import org.thymeleaf.processor.element.MatchingElementName; +import org.thymeleaf.spring6.context.SpringContextUtils; +import org.thymeleaf.spring6.context.webflux.SpringWebFluxThymeleafRequestContext; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import run.halo.app.infra.ExternalUrlSupplier; +import run.halo.app.plugin.ReactiveSettingFetcher; +import run.halo.app.theme.dialect.ElementTagPostProcessor; + +@Component +public class ImageTagProcessor implements ElementTagPostProcessor { + + private final MatchingElementName matchingElementName; + + private final ReactiveSettingFetcher settingFetcher; + + private final ExternalUrlSupplier externalUrlSupplier; + + public ImageTagProcessor(ReactiveSettingFetcher settingFetcher, + ExternalUrlSupplier externalUrlSupplier) { + this.settingFetcher = settingFetcher; + this.externalUrlSupplier = externalUrlSupplier; + this.matchingElementName = + MatchingElementName.forElementName(HTML, ElementNames.forHTMLName("img")); + } + + private static boolean urlMatches(URL firstUrl, URL secondUrl) { + return Objects.equals(firstUrl.getAuthority(), secondUrl.getAuthority()); + } + + @Override + public Mono process(ITemplateContext context, + IProcessableElementTag tag) { + if (!matchingElementName.matches(tag.getElementDefinition().getElementName())) { + return Mono.empty(); + } + var requestContext = SpringContextUtils.getRequestContext(context); + if (!(requestContext instanceof SpringWebFluxThymeleafRequestContext springWebContext)) { + return Mono.empty(); + } + var srcValue = Optional.ofNullable(tag.getAttribute("src")) + .map(IAttribute::getValue) + .filter(StringUtils::hasText) + .map(URI::create); + if (srcValue.isEmpty()) { + return Mono.empty(); + } + var exchange = springWebContext.getServerWebExchange(); + var externalUrl = externalUrlSupplier.getURL(exchange.getRequest()); + + return settingFetcher.fetch(GROUP, Settings.BasicConfig.class) + .filter(config -> !ArrayUtils.isEmpty(config.getProxies())) + .flatMapMany(config -> Flux.fromArray(config.getProxies())) + .filter(proxy -> + Objects.nonNull(proxy.getProxyUrl()) && Objects.nonNull(proxy.getOriginUrl()) + ) + .filter(proxy -> urlMatches(externalUrl, proxy.getOriginUrl())) + .next() + .map(proxy -> { + var srcBuilder = UriComponentsBuilder.fromHttpUrl(proxy.getProxyUrl().toString()) + .path(srcValue.get().getPath()) + .query(srcValue.get().getQuery()) + .fragment(srcValue.get().getFragment()); + var newSrc = srcBuilder.toUriString(); + var modelFactory = context.getModelFactory(); + var newTag = tag; + if (!tag.hasAttribute("srcset")) { + // calculate srcset and sizes + newTag = modelFactory.setAttribute(newTag, "sizes", + """ + (max-width: 400px) 400px, (max-width: 800px) 800px, \ + (max-width: 1200px) 1200px, (max-width: 1600px) 1600px\ + """); + var w400Src = srcBuilder.cloneBuilder().queryParam("width", 400) + .toUriString(); + var w800Src = srcBuilder.cloneBuilder().queryParam("width", 800) + .toUriString(); + var w1200Src = srcBuilder.cloneBuilder().queryParam("width", 1200) + .toUriString(); + var w1600Src = srcBuilder.cloneBuilder().queryParam("width", 1600) + .toUriString(); + newTag = modelFactory.setAttribute(newTag, "srcset", + w400Src + " 400w, " + + w800Src + " 800w, " + + w1200Src + " 1200w, " + + w1600Src + " 1600w" + ); + } + newTag = modelFactory.setAttribute(newTag, "src", + newSrc); + return newTag; + }); + } +} diff --git a/src/main/java/se/webp/plugin/Settings.java b/src/main/java/se/webp/plugin/Settings.java index 647e7d2..4e3461f 100644 --- a/src/main/java/se/webp/plugin/Settings.java +++ b/src/main/java/se/webp/plugin/Settings.java @@ -1,28 +1,29 @@ package se.webp.plugin; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.net.URL; import lombok.Data; -import reactor.core.publisher.Mono; -import run.halo.app.plugin.ReactiveSettingFetcher; public class Settings { - public static Mono getBasicConfig(ReactiveSettingFetcher settingFetcher) { - return settingFetcher.fetch(BasicConfig.GROUP, BasicConfig.class); - } - @Data public static class BasicConfig { + public static final String GROUP = "basic"; String apiKeySecret; Proxy[] proxies; + } @Data public static class Proxy { - String origin_url; - String proxy_url; + @JsonProperty("origin_url") + URL originUrl; + + @JsonProperty("proxy_url") + URL proxyUrl; } } diff --git a/src/main/java/se/webp/plugin/WebpCloudImageOptimizerWebFilter.java b/src/main/java/se/webp/plugin/WebpCloudImageOptimizerWebFilter.java deleted file mode 100644 index f4780b3..0000000 --- a/src/main/java/se/webp/plugin/WebpCloudImageOptimizerWebFilter.java +++ /dev/null @@ -1,189 +0,0 @@ -package se.webp.plugin; - -import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers; - -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.Set; - -import org.jsoup.Jsoup; -import org.jsoup.nodes.Document; -import org.reactivestreams.Publisher; -import org.springframework.core.io.buffer.DataBuffer; -import org.springframework.core.io.buffer.DataBufferUtils; -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.server.reactive.ServerHttpResponse; -import org.springframework.http.server.reactive.ServerHttpResponseDecorator; -import org.springframework.lang.NonNull; -import org.springframework.security.web.server.util.matcher.AndServerWebExchangeMatcher; -import org.springframework.security.web.server.util.matcher.MediaTypeServerWebExchangeMatcher; -import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; -import org.springframework.stereotype.Component; -import org.springframework.web.server.ServerWebExchange; -import org.springframework.web.server.WebFilterChain; - -import lombok.RequiredArgsConstructor; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; -import run.halo.app.infra.ExternalUrlSupplier; -import run.halo.app.infra.utils.PathUtils; -import run.halo.app.plugin.ReactiveSettingFetcher; -import run.halo.app.security.AdditionalWebFilter; -import se.webp.plugin.Settings.Proxy; - -/** - * This implementation references: - * https://github.com/guqing/plugin-cloudinary/blob/93f1eb999fa8db5682b13124fa74f0d00efa4d6f/src/main/java/io/github/guqing/cloudinary/DefaultImageOptimizer.java#L19 - */ -@Component -@RequiredArgsConstructor -public class WebpCloudImageOptimizerWebFilter implements AdditionalWebFilter { - - private final ReactiveSettingFetcher settingFetcher; - - private final ExternalUrlSupplier externalUrlSupplier; - - private final ServerWebExchangeMatcher pathMatcher = createPathMatcher(); - - @Override - @NonNull - public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { - return pathMatcher.matches(exchange) - .flatMap(matchResult -> { - if (matchResult.isMatch() && shouldOptimize(exchange)) { - var decoratedExchange = exchange.mutate() - .response(new ImageOptimizerResponseDecorator(exchange)) - .build(); - return chain.filter(decoratedExchange); - } - return chain.filter(exchange); - }); - } - - boolean shouldOptimize(ServerWebExchange exchange) { - var response = exchange.getResponse(); - var statusCode = response.getStatusCode(); - return statusCode != null && statusCode.isSameCodeAs(HttpStatus.OK); - } - - ServerWebExchangeMatcher createPathMatcher() { - var pathMatcher = pathMatchers(HttpMethod.GET, "/**"); - var mediaTypeMatcher = new MediaTypeServerWebExchangeMatcher(MediaType.TEXT_HTML); - mediaTypeMatcher.setIgnoredMediaTypes(Set.of(MediaType.ALL)); - return new AndServerWebExchangeMatcher(pathMatcher, mediaTypeMatcher); - } - - class ImageOptimizerResponseDecorator extends ServerHttpResponseDecorator { - - public ImageOptimizerResponseDecorator(ServerWebExchange exchange) { - super(exchange.getResponse()); - } - - boolean isHtmlResponse(ServerHttpResponse response) { - return response.getHeaders().getContentType() != null && - response.getHeaders().getContentType().includes(MediaType.TEXT_HTML); - } - - @Override - @NonNull - public Mono writeWith(@NonNull Publisher body) { - var response = getDelegate(); - if (!isHtmlResponse(response)) { - return super.writeWith(body); - } - var bodyWrap = Flux.from(body) - .map(dataBuffer -> { - var byteBuffer = ByteBuffer.allocateDirect(dataBuffer.readableByteCount()); - dataBuffer.toByteBuffer(byteBuffer); - DataBufferUtils.release(dataBuffer); - return byteBuffer.asReadOnlyBuffer(); - }) - .collectSortedList() - .flatMap(byteBuffers -> { - var html = byteBuffersToString(byteBuffers); - - return Settings.getBasicConfig(settingFetcher) - .switchIfEmpty(Mono.just(new Settings.BasicConfig())) - .flatMap(config -> { - var apiKeySecret = config.getApiKeySecret(); - var proxies = config.getProxies(); - - if (apiKeySecret == null || proxies == null) { - return Mono.just(stringToByteBuffer(html)); - } - - var optimizedHtml = replaceImageSrc(html, proxies); - var byteBuffer = stringToByteBuffer(optimizedHtml); - return Mono.just(byteBuffer); - }); - }) - .map(byteBuffer -> response.bufferFactory().wrap(byteBuffer)); - return super.writeWith(bodyWrap); - } - - private String replaceImageSrc(String html, Proxy[] proxies) { - Document document = Jsoup.parse(html); - var externalUrl = externalUrlSupplier.getRaw(); - - document.select("img").forEach(img -> { - String src = img.attr("src"); - - if (!PathUtils.isAbsoluteUri(src)) { - if (externalUrl != null) { - String proxyUrl = getProxyUrl(proxies, externalUrl.toString()); - - if (proxyUrl != null) { - img.attr("src", proxyUrl + src); - } - } - } else { - for (Proxy proxy : proxies) { - if (src.startsWith(proxy.getOrigin_url())) { - img.attr("src", proxy.getProxy_url() + src.substring(proxy.getOrigin_url().length())); - break; - } - } - } - }); - - return document.outerHtml(); - } - - private String getProxyUrl(Proxy[] proxies, String externalUrl) { - for (Proxy proxy : proxies) { - if (proxy.getOrigin_url().equals(externalUrl)) { - return proxy.getProxy_url(); - } - } - return null; - } - } - - private String byteBuffersToString(List byteBuffers) { - int total = byteBuffers.stream().mapToInt(ByteBuffer::remaining).sum(); - ByteBuffer combined = ByteBuffer.allocate(total); - - for (ByteBuffer buffer : byteBuffers) { - combined.put(buffer); - } - - combined.flip(); - byte[] byteArray = new byte[combined.remaining()]; - combined.get(byteArray); - - return new String(byteArray, StandardCharsets.UTF_8); - } - - public ByteBuffer stringToByteBuffer(String str) { - byte[] byteArray = str.getBytes(StandardCharsets.UTF_8); - return ByteBuffer.wrap(byteArray); - } - - @Override - public int getOrder() { - return LOWEST_PRECEDENCE - 100; - } -} diff --git a/src/main/resources/plugin.yaml b/src/main/resources/plugin.yaml index 9060cec..e222b27 100644 --- a/src/main/resources/plugin.yaml +++ b/src/main/resources/plugin.yaml @@ -4,7 +4,7 @@ metadata: name: plugin-webp-se-cloud spec: enabled: true - requires: ">=2.7.0" + requires: ">=2.20.0" author: name: WebP Cloud Services website: https://webp.se/