mirror of https://github.com/halo-dev/halo
Refactor cache control for static resources (#6015)
#### What type of PR is this? /kind improvement /area core #### What this PR does / why we need it: This PR unifies cache control for static resources. Example configuration of cache control: ```yaml spring: web: resources: cache: cachecontrol: no-cache: true no-store: true use-last-modified: false ``` #### Which issue(s) this PR fixes: Fixes https://github.com/halo-dev/halo/issues/6003 #### Special notes for your reviewer: 1. Run with `default` and `dev` profiles respectively. 2. See the difference of the `Cache-Control` header in HTTP response #### Does this PR introduce a user-facing change? ```release-note 优化 HTTP 缓存控制 ```pull/6022/head
parent
4c6abdcaa1
commit
e881ee9a89
|
@ -41,6 +41,7 @@ import run.halo.app.console.WebSocketRequestPredicate;
|
|||
import run.halo.app.core.endpoint.WebSocketHandlerMapping;
|
||||
import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
||||
import run.halo.app.core.extension.endpoint.CustomEndpointsBuilder;
|
||||
import run.halo.app.infra.properties.AttachmentProperties;
|
||||
import run.halo.app.infra.properties.HaloProperties;
|
||||
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
|
||||
import run.halo.app.webfilter.AdditionalWebFilterChainProxy;
|
||||
|
@ -52,7 +53,6 @@ public class WebFluxConfig implements WebFluxConfigurer {
|
|||
|
||||
private final HaloProperties haloProp;
|
||||
|
||||
|
||||
private final WebProperties.Resources resourceProperties;
|
||||
|
||||
private final ApplicationContext applicationContext;
|
||||
|
@ -142,9 +142,12 @@ public class WebFluxConfig implements WebFluxConfigurer {
|
|||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
var attachmentsRoot = haloProp.getWorkDir().resolve("attachments");
|
||||
final var cacheControl = resourceProperties.getCache()
|
||||
var cacheControl = resourceProperties.getCache()
|
||||
.getCachecontrol()
|
||||
.toHttpCacheControl();
|
||||
if (cacheControl == null) {
|
||||
cacheControl = CacheControl.empty();
|
||||
}
|
||||
final var useLastModified = resourceProperties.getCache().isUseLastModified();
|
||||
|
||||
// Mandatory resource mapping
|
||||
|
@ -173,29 +176,31 @@ public class WebFluxConfig implements WebFluxConfigurer {
|
|||
|
||||
// Additional resource mappings
|
||||
var staticResources = haloProp.getAttachment().getResourceMappings();
|
||||
staticResources.forEach(staticResource -> {
|
||||
for (AttachmentProperties.ResourceMapping staticResource : staticResources) {
|
||||
ResourceHandlerRegistration registration;
|
||||
if (Objects.equals(staticResource.getPathPattern(), "/upload/**")) {
|
||||
registration = uploadRegistration;
|
||||
} else {
|
||||
registration = registry.addResourceHandler(staticResource.getPathPattern());
|
||||
}
|
||||
staticResource.getLocations().forEach(location -> {
|
||||
var path = attachmentsRoot.resolve(location);
|
||||
checkDirectoryTraversal(attachmentsRoot, path);
|
||||
registration.addResourceLocations(FILE_URL_PREFIX + path + "/")
|
||||
registration = registry.addResourceHandler(staticResource.getPathPattern())
|
||||
.setCacheControl(cacheControl)
|
||||
.setUseLastModified(useLastModified);
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
for (String location : staticResource.getLocations()) {
|
||||
var path = attachmentsRoot.resolve(location);
|
||||
checkDirectoryTraversal(attachmentsRoot, path);
|
||||
registration.addResourceLocations(FILE_URL_PREFIX + path + "/");
|
||||
}
|
||||
}
|
||||
|
||||
var haloStaticPath = haloProp.getWorkDir().resolve("static");
|
||||
registry.addResourceHandler("/**")
|
||||
.addResourceLocations(FILE_URL_PREFIX + haloStaticPath + "/")
|
||||
.addResourceLocations(resourceProperties.getStaticLocations())
|
||||
.setCacheControl(CacheControl.noCache())
|
||||
.setUseLastModified(true);
|
||||
.setCacheControl(cacheControl)
|
||||
.setUseLastModified(useLastModified)
|
||||
.resourceChain(true)
|
||||
.addResolver(new EncodedResourceResolver())
|
||||
.addResolver(new PathResourceResolver());
|
||||
}
|
||||
|
||||
|
||||
|
@ -230,4 +235,5 @@ public class WebFluxConfig implements WebFluxConfigurer {
|
|||
AdditionalWebFilterChainProxy additionalWebFilterChainProxy(ExtensionGetter extensionGetter) {
|
||||
return new AdditionalWebFilterChainProxy(extensionGetter);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -100,7 +100,6 @@ public class WebServerSecurityConfig {
|
|||
.referrerPolicy(referrerPolicySpec -> referrerPolicySpec.policy(
|
||||
haloProperties.getSecurity().getReferrerOptions().getPolicy())
|
||||
)
|
||||
.cache(ServerHttpSecurity.HeaderSpec.CacheSpec::disable)
|
||||
.hsts(hstsSpec -> hstsSpec.includeSubdomains(false))
|
||||
);
|
||||
|
||||
|
|
|
@ -19,6 +19,7 @@ import static run.halo.app.infra.utils.FileUtils.deleteFileSilently;
|
|||
import io.swagger.v3.oas.annotations.enums.ParameterIn;
|
||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.nio.file.Files;
|
||||
|
@ -32,20 +33,20 @@ import java.util.List;
|
|||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.concurrent.locks.ReadWriteLock;
|
||||
import java.util.concurrent.locks.ReentrantReadWriteLock;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.function.Supplier;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.reactivestreams.Publisher;
|
||||
import org.springdoc.core.fn.builders.operation.Builder;
|
||||
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
|
||||
import org.springframework.beans.factory.DisposableBean;
|
||||
import org.springframework.beans.factory.InitializingBean;
|
||||
import org.springframework.boot.autoconfigure.web.WebProperties;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.buffer.DataBuffer;
|
||||
|
@ -67,6 +68,7 @@ import org.springframework.web.reactive.function.BodyInserters;
|
|||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import org.springframework.web.reactive.resource.NoResourceFoundException;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import org.springframework.web.server.ServerWebInputException;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
@ -88,9 +90,7 @@ import run.halo.app.plugin.PluginNotFoundException;
|
|||
|
||||
@Slf4j
|
||||
@Component
|
||||
@AllArgsConstructor
|
||||
public class PluginEndpoint implements CustomEndpoint {
|
||||
private static final CacheControl MAX_CACHE_CONTROL = CacheControl.maxAge(365, TimeUnit.DAYS);
|
||||
public class PluginEndpoint implements CustomEndpoint, InitializingBean {
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
|
||||
|
@ -98,9 +98,27 @@ public class PluginEndpoint implements CustomEndpoint {
|
|||
|
||||
private final ReactiveUrlDataBufferFetcher reactiveUrlDataBufferFetcher;
|
||||
|
||||
private final BufferedPluginBundleResource bufferedPluginBundleResource;
|
||||
|
||||
private final WebProperties webProperties;
|
||||
|
||||
private final Scheduler scheduler = Schedulers.boundedElastic();
|
||||
|
||||
private final BufferedPluginBundleResource bufferedPluginBundleResource;
|
||||
private boolean useLastModified;
|
||||
|
||||
private CacheControl bundleCacheControl = CacheControl.empty();
|
||||
|
||||
public PluginEndpoint(ReactiveExtensionClient client,
|
||||
PluginService pluginService,
|
||||
ReactiveUrlDataBufferFetcher reactiveUrlDataBufferFetcher,
|
||||
BufferedPluginBundleResource bufferedPluginBundleResource,
|
||||
WebProperties webProperties) {
|
||||
this.client = client;
|
||||
this.pluginService = pluginService;
|
||||
this.reactiveUrlDataBufferFetcher = reactiveUrlDataBufferFetcher;
|
||||
this.bufferedPluginBundleResource = bufferedPluginBundleResource;
|
||||
this.webProperties = webProperties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RouterFunction<ServerResponse> endpoint() {
|
||||
|
@ -290,6 +308,16 @@ public class PluginEndpoint implements CustomEndpoint {
|
|||
.flatMap(plugin -> ServerResponse.ok().bodyValue(plugin));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterPropertiesSet() {
|
||||
var cache = this.webProperties.getResources().getCache();
|
||||
this.useLastModified = cache.isUseLastModified();
|
||||
var cacheControl = cache.getCachecontrol().toHttpCacheControl();
|
||||
if (cacheControl != null) {
|
||||
this.bundleCacheControl = cacheControl;
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
@Schema(name = "PluginRunningStateRequest")
|
||||
static class RunningStateRequest {
|
||||
|
@ -299,60 +327,65 @@ public class PluginEndpoint implements CustomEndpoint {
|
|||
|
||||
private Mono<ServerResponse> fetchJsBundle(ServerRequest request) {
|
||||
Optional<String> versionOption = request.queryParam("v");
|
||||
return versionOption.map(s ->
|
||||
Mono.defer(() -> bufferedPluginBundleResource
|
||||
.getJsBundle(s, pluginService::uglifyJsBundle)
|
||||
).flatMap(fsRes -> {
|
||||
var bodyBuilder = ServerResponse.ok()
|
||||
.cacheControl(MAX_CACHE_CONTROL)
|
||||
.contentType(MediaType.valueOf("text/javascript"));
|
||||
if (versionOption.isEmpty()) {
|
||||
return pluginService.generateJsBundleVersion()
|
||||
.flatMap(version -> ServerResponse
|
||||
.temporaryRedirect(buildJsBundleUri("js", version))
|
||||
.cacheControl(CacheControl.noStore())
|
||||
.build());
|
||||
}
|
||||
var version = versionOption.get();
|
||||
return bufferedPluginBundleResource.getJsBundle(version, pluginService::uglifyJsBundle)
|
||||
.flatMap(jsRes -> {
|
||||
var bodyBuilder = ServerResponse.ok()
|
||||
.cacheControl(bundleCacheControl)
|
||||
.contentType(MediaType.valueOf("text/javascript"));
|
||||
if (useLastModified) {
|
||||
try {
|
||||
Instant lastModified = Instant.ofEpochMilli(fsRes.lastModified());
|
||||
return request.checkNotModified(lastModified)
|
||||
.switchIfEmpty(Mono.defer(() ->
|
||||
bodyBuilder.lastModified(lastModified)
|
||||
.body(BodyInserters.fromResource(fsRes)))
|
||||
);
|
||||
var lastModified = Instant.ofEpochMilli(jsRes.lastModified());
|
||||
bodyBuilder = bodyBuilder.lastModified(lastModified);
|
||||
} catch (IOException e) {
|
||||
if (e instanceof FileNotFoundException) {
|
||||
return Mono.error(new NoResourceFoundException("bundle.js"));
|
||||
}
|
||||
return Mono.error(e);
|
||||
}
|
||||
})
|
||||
)
|
||||
.orElseGet(() -> pluginService.generateJsBundleVersion()
|
||||
.flatMap(v -> ServerResponse
|
||||
.temporaryRedirect(buildJsBundleUri("js", v))
|
||||
.build()
|
||||
)
|
||||
);
|
||||
}
|
||||
return bodyBuilder.body(BodyInserters.fromResource(jsRes));
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> fetchCssBundle(ServerRequest request) {
|
||||
Optional<String> versionOption = request.queryParam("v");
|
||||
return versionOption.map(s ->
|
||||
Mono.defer(() -> bufferedPluginBundleResource.getCssBundle(s,
|
||||
pluginService::uglifyCssBundle)
|
||||
).flatMap(fsRes -> {
|
||||
var bodyBuilder = ServerResponse.ok()
|
||||
.cacheControl(MAX_CACHE_CONTROL)
|
||||
.contentType(MediaType.valueOf("text/css"));
|
||||
if (versionOption.isEmpty()) {
|
||||
return pluginService.generateJsBundleVersion()
|
||||
.flatMap(version -> ServerResponse
|
||||
.temporaryRedirect(buildJsBundleUri("css", version))
|
||||
.cacheControl(CacheControl.noStore())
|
||||
.build());
|
||||
}
|
||||
|
||||
var version = versionOption.get();
|
||||
return bufferedPluginBundleResource.getCssBundle(version, pluginService::uglifyCssBundle)
|
||||
.flatMap(cssRes -> {
|
||||
var bodyBuilder = ServerResponse.ok()
|
||||
.cacheControl(bundleCacheControl)
|
||||
.contentType(MediaType.valueOf("text/css"));
|
||||
|
||||
if (useLastModified) {
|
||||
try {
|
||||
Instant lastModified = Instant.ofEpochMilli(fsRes.lastModified());
|
||||
return request.checkNotModified(lastModified)
|
||||
.switchIfEmpty(Mono.defer(() ->
|
||||
bodyBuilder.lastModified(lastModified)
|
||||
.body(BodyInserters.fromResource(fsRes)))
|
||||
);
|
||||
var lastModified = Instant.ofEpochMilli(cssRes.lastModified());
|
||||
bodyBuilder = bodyBuilder.lastModified(lastModified);
|
||||
} catch (IOException e) {
|
||||
if (e instanceof FileNotFoundException) {
|
||||
return Mono.error(new NoResourceFoundException("bundle.css"));
|
||||
}
|
||||
return Mono.error(e);
|
||||
}
|
||||
})
|
||||
)
|
||||
.orElseGet(() -> pluginService.generateJsBundleVersion()
|
||||
.flatMap(v -> ServerResponse
|
||||
.temporaryRedirect(buildJsBundleUri("css", v))
|
||||
.build()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return bodyBuilder.body(BodyInserters.fromResource(cssRes));
|
||||
});
|
||||
}
|
||||
|
||||
URI buildJsBundleUri(String type, String version) {
|
||||
|
@ -743,10 +776,10 @@ public class PluginEndpoint implements CustomEndpoint {
|
|||
|
||||
private Path tempDir;
|
||||
|
||||
public Mono<FileSystemResource> getJsBundle(String version,
|
||||
public Mono<Resource> getJsBundle(String version,
|
||||
Supplier<Flux<DataBuffer>> jsSupplier) {
|
||||
var fileName = tempFileName(version, ".js");
|
||||
return Mono.defer(() -> {
|
||||
return Mono.<Resource>defer(() -> {
|
||||
jsLock.readLock().lock();
|
||||
try {
|
||||
var jsBundleResource = jsBundle.get();
|
||||
|
@ -768,12 +801,12 @@ public class PluginEndpoint implements CustomEndpoint {
|
|||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
public Mono<FileSystemResource> getCssBundle(String version,
|
||||
public Mono<Resource> getCssBundle(String version,
|
||||
Supplier<Flux<DataBuffer>> cssSupplier) {
|
||||
var fileName = tempFileName(version, ".css");
|
||||
return Mono.defer(() -> {
|
||||
return Mono.<Resource>defer(() -> {
|
||||
cssLock.readLock().lock();
|
||||
try {
|
||||
cssLock.readLock().lock();
|
||||
var cssBundleResource = cssBundle.get();
|
||||
if (getResourceIfNotChange(fileName, cssBundleResource) != null) {
|
||||
return Mono.just(cssBundleResource);
|
||||
|
|
|
@ -23,7 +23,6 @@ import lombok.extern.slf4j.Slf4j;
|
|||
import org.pf4j.DependencyResolver;
|
||||
import org.pf4j.PluginDescriptor;
|
||||
import org.pf4j.PluginWrapper;
|
||||
import org.pf4j.RuntimeMode;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.buffer.DataBuffer;
|
||||
import org.springframework.core.io.buffer.DataBufferUtils;
|
||||
|
@ -243,9 +242,6 @@ public class PluginServiceImpl implements PluginService {
|
|||
@Override
|
||||
public Mono<String> generateJsBundleVersion() {
|
||||
return Mono.fromSupplier(() -> {
|
||||
if (RuntimeMode.DEVELOPMENT.equals(pluginManager.getRuntimeMode())) {
|
||||
return String.valueOf(System.currentTimeMillis());
|
||||
}
|
||||
var compactVersion = pluginManager.getStartedPlugins()
|
||||
.stream()
|
||||
.sorted(Comparator.comparing(PluginWrapper::getPluginId))
|
||||
|
|
|
@ -22,7 +22,7 @@ public interface PluginConst {
|
|||
|
||||
String RUNTIME_MODE_ANNO = "plugin.halo.run/runtime-mode";
|
||||
|
||||
static String assertsRoutePrefix(String pluginName) {
|
||||
static String assetsRoutePrefix(String pluginName) {
|
||||
return "/plugins/" + pluginName + "/assets/";
|
||||
}
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ import static org.springframework.http.MediaType.ALL;
|
|||
import static org.springframework.web.reactive.function.server.RequestPredicates.GET;
|
||||
import static org.springframework.web.reactive.function.server.RequestPredicates.accept;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
@ -16,6 +17,7 @@ import org.springframework.context.ApplicationContext;
|
|||
import org.springframework.core.io.DefaultResourceLoader;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.ResourceLoader;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.server.PathContainer;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.lang.Nullable;
|
||||
|
@ -26,6 +28,7 @@ import org.springframework.web.reactive.function.server.RouterFunction;
|
|||
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import org.springframework.web.reactive.resource.NoResourceFoundException;
|
||||
import org.springframework.web.util.pattern.PathPatternParser;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.ReverseProxy;
|
||||
|
@ -71,34 +74,44 @@ public class ReverseProxyRouterFunctionFactory {
|
|||
ReverseProxy reverseProxy, @NonNull String pluginName) {
|
||||
Assert.notNull(reverseProxy, "The reverseProxy must not be null.");
|
||||
var rules = getReverseProxyRules(reverseProxy);
|
||||
var cacheProperties = webProperties.getResources().getCache();
|
||||
var useLastModified = cacheProperties.isUseLastModified();
|
||||
var cacheControl = cacheProperties.getCachecontrol().toHttpCacheControl();
|
||||
if (cacheControl == null) {
|
||||
cacheControl = CacheControl.empty();
|
||||
}
|
||||
var finalCacheControl = cacheControl;
|
||||
return rules.stream().map(rule -> {
|
||||
String routePath = buildRoutePath(pluginName, rule);
|
||||
log.debug("Plugin [{}] registered reverse proxy route path [{}]", pluginName,
|
||||
routePath);
|
||||
return RouterFunctions.route(GET(routePath).and(accept(ALL)),
|
||||
request -> {
|
||||
Resource resource =
|
||||
loadResourceByFileRule(pluginName, rule, request);
|
||||
var resource = loadResourceByFileRule(pluginName, rule, request);
|
||||
if (!resource.exists()) {
|
||||
return ServerResponse.notFound().build();
|
||||
return Mono.error(new NoResourceFoundException(routePath));
|
||||
}
|
||||
var cacheProperties = webProperties.getResources().getCache();
|
||||
var useLastModified = cacheProperties.isUseLastModified();
|
||||
var bodyBuilder = ServerResponse.ok()
|
||||
.cacheControl(cacheProperties.getCachecontrol().toHttpCacheControl());
|
||||
if (!useLastModified) {
|
||||
return ServerResponse.ok()
|
||||
.cacheControl(finalCacheControl)
|
||||
.body(BodyInserters.fromResource(resource));
|
||||
}
|
||||
Instant lastModified;
|
||||
try {
|
||||
if (useLastModified) {
|
||||
var lastModified = Instant.ofEpochMilli(resource.lastModified());
|
||||
return request.checkNotModified(lastModified)
|
||||
.switchIfEmpty(Mono.defer(
|
||||
() -> bodyBuilder.lastModified(lastModified)
|
||||
.body(BodyInserters.fromResource(resource)))
|
||||
);
|
||||
}
|
||||
return bodyBuilder.body(BodyInserters.fromResource(resource));
|
||||
lastModified = Instant.ofEpochMilli(resource.lastModified());
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
if (e instanceof FileNotFoundException) {
|
||||
return Mono.error(new NoResourceFoundException(routePath));
|
||||
}
|
||||
return Mono.error(e);
|
||||
}
|
||||
return request.checkNotModified(lastModified)
|
||||
.switchIfEmpty(Mono.defer(
|
||||
() -> ServerResponse.ok()
|
||||
.cacheControl(finalCacheControl)
|
||||
.lastModified(lastModified)
|
||||
.body(BodyInserters.fromResource(resource)))
|
||||
);
|
||||
});
|
||||
}).reduce(RouterFunction::and).orElse(null);
|
||||
}
|
||||
|
@ -112,7 +125,7 @@ public class ReverseProxyRouterFunctionFactory {
|
|||
}
|
||||
|
||||
public static String buildRoutePath(String pluginId, ReverseProxyRule reverseProxyRule) {
|
||||
return PathUtils.combinePath(PluginConst.assertsRoutePrefix(pluginId),
|
||||
return PathUtils.combinePath(PluginConst.assetsRoutePrefix(pluginId),
|
||||
reverseProxyRule.path());
|
||||
}
|
||||
|
||||
|
|
|
@ -1,102 +0,0 @@
|
|||
package run.halo.app.theme;
|
||||
|
||||
import static org.springframework.web.reactive.function.server.RequestPredicates.GET;
|
||||
import static org.springframework.web.reactive.function.server.RequestPredicates.accept;
|
||||
import static org.springframework.web.reactive.function.server.RouterFunctions.route;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.autoconfigure.web.WebProperties;
|
||||
import org.springframework.boot.info.BuildProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.web.server.context.ServerSecurityContextRepository;
|
||||
import org.springframework.web.reactive.function.BodyInserters;
|
||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import org.thymeleaf.extras.springsecurity6.dialect.SpringSecurityDialect;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.infra.ThemeRootGetter;
|
||||
import run.halo.app.infra.utils.FileUtils;
|
||||
import run.halo.app.theme.dialect.GeneratorMetaProcessor;
|
||||
import run.halo.app.theme.dialect.HaloSpringSecurityDialect;
|
||||
import run.halo.app.theme.dialect.LinkExpressionObjectDialect;
|
||||
import run.halo.app.theme.dialect.TemplateHeadProcessor;
|
||||
|
||||
/**
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Configuration
|
||||
public class ThemeConfiguration {
|
||||
|
||||
private final ThemeRootGetter themeRoot;
|
||||
|
||||
public ThemeConfiguration(ThemeRootGetter themeRoot) {
|
||||
this.themeRoot = themeRoot;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RouterFunction<ServerResponse> themeAssets(WebProperties webProperties) {
|
||||
var cacheProperties = webProperties.getResources().getCache();
|
||||
return route(
|
||||
GET("/themes/{themeName}/assets/{*resource}").and(accept(MediaType.TEXT_PLAIN)),
|
||||
request -> {
|
||||
var themeName = request.pathVariable("themeName");
|
||||
var resource = request.pathVariable("resource");
|
||||
resource = StringUtils.removeStart(resource, "/");
|
||||
var fsRes = new FileSystemResource(getThemeAssetsPath(themeName, resource));
|
||||
if (!fsRes.exists()) {
|
||||
return ServerResponse.notFound().build();
|
||||
}
|
||||
var bodyBuilder = ServerResponse.ok()
|
||||
.cacheControl(cacheProperties.getCachecontrol().toHttpCacheControl());
|
||||
try {
|
||||
if (cacheProperties.isUseLastModified()) {
|
||||
var lastModified = Instant.ofEpochMilli(fsRes.lastModified());
|
||||
return request.checkNotModified(lastModified)
|
||||
.switchIfEmpty(Mono.defer(() -> bodyBuilder.lastModified(lastModified)
|
||||
.body(BodyInserters.fromResource(fsRes))));
|
||||
}
|
||||
return bodyBuilder.body(BodyInserters.fromResource(fsRes));
|
||||
} catch (IOException e) {
|
||||
return Mono.error(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Path getThemeAssetsPath(String themeName, String resource) {
|
||||
Path basePath = themeRoot.get()
|
||||
.resolve(themeName)
|
||||
.resolve("templates")
|
||||
.resolve("assets");
|
||||
Path result = basePath.resolve(resource);
|
||||
FileUtils.checkDirectoryTraversal(basePath, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Bean
|
||||
LinkExpressionObjectDialect linkExpressionObjectDialect() {
|
||||
return new LinkExpressionObjectDialect();
|
||||
}
|
||||
|
||||
@Bean
|
||||
SpringSecurityDialect springSecurityDialect(
|
||||
ServerSecurityContextRepository securityContextRepository) {
|
||||
return new HaloSpringSecurityDialect(securityContextRepository);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(name = "halo.theme.generator-meta-disabled",
|
||||
havingValue = "false",
|
||||
matchIfMissing = true)
|
||||
TemplateHeadProcessor generatorMetaProcessor(ObjectProvider<BuildProperties> buildProperties) {
|
||||
return new GeneratorMetaProcessor(buildProperties);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
package run.halo.app.theme.config;
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.info.BuildProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.web.server.context.ServerSecurityContextRepository;
|
||||
import org.thymeleaf.extras.springsecurity6.dialect.SpringSecurityDialect;
|
||||
import run.halo.app.theme.dialect.GeneratorMetaProcessor;
|
||||
import run.halo.app.theme.dialect.HaloSpringSecurityDialect;
|
||||
import run.halo.app.theme.dialect.LinkExpressionObjectDialect;
|
||||
import run.halo.app.theme.dialect.TemplateHeadProcessor;
|
||||
|
||||
/**
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Configuration
|
||||
public class ThemeConfiguration {
|
||||
|
||||
@Bean
|
||||
LinkExpressionObjectDialect linkExpressionObjectDialect() {
|
||||
return new LinkExpressionObjectDialect();
|
||||
}
|
||||
|
||||
@Bean
|
||||
SpringSecurityDialect springSecurityDialect(
|
||||
ServerSecurityContextRepository securityContextRepository) {
|
||||
return new HaloSpringSecurityDialect(securityContextRepository);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(name = "halo.theme.generator-meta-disabled",
|
||||
havingValue = "false",
|
||||
matchIfMissing = true)
|
||||
TemplateHeadProcessor generatorMetaProcessor(ObjectProvider<BuildProperties> buildProperties) {
|
||||
return new GeneratorMetaProcessor(buildProperties);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,102 @@
|
|||
package run.halo.app.theme.config;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.MalformedURLException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.boot.autoconfigure.web.WebProperties;
|
||||
import org.springframework.core.io.FileUrlResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.HandlerMapping;
|
||||
import org.springframework.web.reactive.config.ResourceHandlerRegistry;
|
||||
import org.springframework.web.reactive.config.WebFluxConfigurer;
|
||||
import org.springframework.web.reactive.resource.AbstractResourceResolver;
|
||||
import org.springframework.web.reactive.resource.EncodedResourceResolver;
|
||||
import org.springframework.web.reactive.resource.PathResourceResolver;
|
||||
import org.springframework.web.reactive.resource.ResourceResolverChain;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.infra.ThemeRootGetter;
|
||||
|
||||
@Component
|
||||
public class ThemeWebFluxConfigurer implements WebFluxConfigurer {
|
||||
|
||||
private final ThemeRootGetter themeRootGetter;
|
||||
|
||||
private final WebProperties.Resources resourcesProperties;
|
||||
|
||||
public ThemeWebFluxConfigurer(ThemeRootGetter themeRootGetter,
|
||||
WebProperties webProperties) {
|
||||
this.themeRootGetter = themeRootGetter;
|
||||
this.resourcesProperties = webProperties.getResources();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
var cacheControl = resourcesProperties.getCache().getCachecontrol().toHttpCacheControl();
|
||||
if (cacheControl == null) {
|
||||
cacheControl = CacheControl.empty();
|
||||
}
|
||||
var useLastModified = resourcesProperties.getCache().isUseLastModified();
|
||||
registry.addResourceHandler("/themes/{themeName}/assets/{*resourcePaths}")
|
||||
.setCacheControl(cacheControl)
|
||||
.setUseLastModified(useLastModified)
|
||||
.resourceChain(true)
|
||||
.addResolver(new EncodedResourceResolver())
|
||||
.addResolver(new ThemePathResourceResolver(themeRootGetter.get()))
|
||||
.addResolver(new PathResourceResolver());
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme path resource resolver. The resolver is used to resolve theme assets from the request
|
||||
* path.
|
||||
*
|
||||
* @author johnniang
|
||||
*/
|
||||
private static class ThemePathResourceResolver extends AbstractResourceResolver {
|
||||
|
||||
private final Resource themeRootResource;
|
||||
|
||||
private ThemePathResourceResolver(Path themeRoot) {
|
||||
try {
|
||||
this.themeRootResource = new FileUrlResource(themeRoot.toUri().toURL());
|
||||
} catch (MalformedURLException e) {
|
||||
throw new RuntimeException("Failed to resolve " + themeRoot + " to URL.", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Mono<Resource> resolveResourceInternal(ServerWebExchange exchange,
|
||||
String requestPath, List<? extends Resource> locations, ResourceResolverChain chain) {
|
||||
if (exchange == null) {
|
||||
return Mono.empty();
|
||||
}
|
||||
Map<String, String> requiredAttribute =
|
||||
exchange.getRequiredAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE);
|
||||
var themeName = requiredAttribute.get("themeName");
|
||||
var resourcePaths = requiredAttribute.get("resourcePaths");
|
||||
|
||||
if (StringUtils.isAnyBlank(themeName, resourcePaths)) {
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
try {
|
||||
var location = themeRootResource.createRelative(themeName + "/templates/assets/");
|
||||
return chain.resolveResource(exchange, resourcePaths, List.of(location));
|
||||
} catch (IOException e) {
|
||||
return Mono.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Mono<String> resolveUrlPathInternal(String resourceUrlPath,
|
||||
List<? extends Resource> locations,
|
||||
ResourceResolverChain chain) {
|
||||
return chain.resolveUrlPath(resourceUrlPath, locations);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -8,7 +8,9 @@ import static org.mockito.ArgumentMatchers.argThat;
|
|||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isA;
|
||||
import static org.mockito.ArgumentMatchers.same;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.reactive.server.WebTestClient.bindToRouterFunction;
|
||||
|
@ -33,10 +35,15 @@ import org.junit.jupiter.api.Test;
|
|||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Spy;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.boot.autoconfigure.web.WebProperties;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.buffer.DataBuffer;
|
||||
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.client.MultipartBodyBuilder;
|
||||
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||
|
@ -46,6 +53,7 @@ import reactor.core.publisher.Mono;
|
|||
import reactor.test.StepVerifier;
|
||||
import run.halo.app.core.extension.Plugin;
|
||||
import run.halo.app.core.extension.Setting;
|
||||
import run.halo.app.core.extension.endpoint.PluginEndpoint.BufferedPluginBundleResource;
|
||||
import run.halo.app.core.extension.service.PluginService;
|
||||
import run.halo.app.extension.ConfigMap;
|
||||
import run.halo.app.extension.ListResult;
|
||||
|
@ -71,6 +79,12 @@ class PluginEndpointTest {
|
|||
@Mock
|
||||
PluginService pluginService;
|
||||
|
||||
@Spy
|
||||
WebProperties webProperties = new WebProperties();
|
||||
|
||||
@Mock
|
||||
BufferedPluginBundleResource bufferedPluginBundleResource;
|
||||
|
||||
@InjectMocks
|
||||
PluginEndpoint endpoint;
|
||||
|
||||
|
@ -391,8 +405,8 @@ class PluginEndpointTest {
|
|||
|
||||
@Nested
|
||||
class BufferedPluginBundleResourceTest {
|
||||
private final PluginEndpoint.BufferedPluginBundleResource bufferedPluginBundleResource =
|
||||
new PluginEndpoint.BufferedPluginBundleResource();
|
||||
private final BufferedPluginBundleResource bufferedPluginBundleResource =
|
||||
new BufferedPluginBundleResource();
|
||||
|
||||
private static Flux<DataBuffer> getDataBufferFlux(String x) {
|
||||
var buffer = DefaultDataBufferFactory.sharedInstance
|
||||
|
@ -475,4 +489,119 @@ class PluginEndpointTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class BundleResourceEndpointTest {
|
||||
|
||||
private long lastModified;
|
||||
|
||||
WebTestClient webClient;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
webClient = WebTestClient.bindToRouterFunction(endpoint.endpoint()).build();
|
||||
long currentTimeMillis = System.currentTimeMillis();
|
||||
// We should ignore milliseconds here
|
||||
// See https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.1.1 for more.
|
||||
this.lastModified = currentTimeMillis - currentTimeMillis % 1_000;
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldBeRedirectedWhileFetchingBundleJsWithoutVersion() {
|
||||
when(pluginService.generateJsBundleVersion()).thenReturn(Mono.just("fake-version"));
|
||||
webClient.get().uri("/plugins/-/bundle.js")
|
||||
.exchange()
|
||||
.expectStatus().is3xxRedirection()
|
||||
.expectHeader().cacheControl(CacheControl.noStore())
|
||||
.expectHeader().location(
|
||||
"/apis/api.console.halo.run/v1alpha1/plugins/-/bundle.js?v=fake-version");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldBeRedirectedWhileFetchingBundleCssWithoutVersion() {
|
||||
when(pluginService.generateJsBundleVersion()).thenReturn(Mono.just("fake-version"));
|
||||
webClient.get().uri("/plugins/-/bundle.css")
|
||||
.exchange()
|
||||
.expectStatus().is3xxRedirection()
|
||||
.expectHeader().cacheControl(CacheControl.noStore())
|
||||
.expectHeader().location(
|
||||
"/apis/api.console.halo.run/v1alpha1/plugins/-/bundle.css?v=fake-version");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFetchBundleCssWithCacheControl() {
|
||||
var cache = webProperties.getResources().getCache();
|
||||
cache.setUseLastModified(true);
|
||||
var cachecontrol = cache.getCachecontrol();
|
||||
cachecontrol.setNoCache(true);
|
||||
endpoint.afterPropertiesSet();
|
||||
|
||||
when(bufferedPluginBundleResource.getCssBundle(eq("fake-version"), any()))
|
||||
.thenReturn(Mono.fromSupplier(() -> mockResource("fake-css")));
|
||||
webClient.get().uri("/plugins/-/bundle.css?v=fake-version")
|
||||
.exchange()
|
||||
.expectStatus().isOk()
|
||||
.expectHeader().cacheControl(CacheControl.noCache())
|
||||
.expectHeader().contentType("text/css")
|
||||
.expectHeader().lastModified(lastModified)
|
||||
.expectBody(String.class).isEqualTo("fake-css");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFetchBundleJsWithCacheControl() {
|
||||
var cache = webProperties.getResources().getCache();
|
||||
cache.setUseLastModified(true);
|
||||
var cachecontrol = cache.getCachecontrol();
|
||||
cachecontrol.setNoStore(true);
|
||||
endpoint.afterPropertiesSet();
|
||||
|
||||
when(bufferedPluginBundleResource.getJsBundle(eq("fake-version"), any()))
|
||||
.thenReturn(Mono.fromSupplier(() -> mockResource("fake-js")));
|
||||
webClient.get().uri("/plugins/-/bundle.js?v=fake-version")
|
||||
.exchange()
|
||||
.expectStatus().isOk()
|
||||
.expectHeader().cacheControl(CacheControl.noStore())
|
||||
.expectHeader().contentType("text/javascript")
|
||||
.expectHeader().lastModified(lastModified)
|
||||
.expectBody(String.class).isEqualTo("fake-js");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFetchBundleCss() {
|
||||
when(bufferedPluginBundleResource.getCssBundle(eq("fake-version"), any()))
|
||||
.thenReturn(Mono.fromSupplier(() -> mockResource("fake-css")));
|
||||
webClient.get().uri("/plugins/-/bundle.css?v=fake-version")
|
||||
.exchange()
|
||||
.expectStatus().isOk()
|
||||
.expectHeader().cacheControl(CacheControl.empty())
|
||||
.expectHeader().contentType("text/css")
|
||||
.expectHeader().lastModified(-1)
|
||||
.expectBody(String.class).isEqualTo("fake-css");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFetchBundleJs() {
|
||||
when(bufferedPluginBundleResource.getJsBundle(eq("fake-version"), any()))
|
||||
.thenReturn(Mono.fromSupplier(() -> mockResource("fake-js")));
|
||||
webClient.get().uri("/plugins/-/bundle.js?v=fake-version")
|
||||
.exchange()
|
||||
.expectStatus().isOk()
|
||||
.expectHeader().cacheControl(CacheControl.empty())
|
||||
.expectHeader().contentType("text/javascript")
|
||||
.expectHeader().lastModified(-1)
|
||||
.expectBody(String.class).isEqualTo("fake-js");
|
||||
}
|
||||
|
||||
Resource mockResource(String content) {
|
||||
var resource = new ByteArrayResource(content.getBytes(StandardCharsets.UTF_8));
|
||||
resource = spy(resource);
|
||||
try {
|
||||
doReturn(lastModified).when(resource).lastModified();
|
||||
} catch (IOException e) {
|
||||
// should never happen
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
return resource;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -31,7 +31,6 @@ import org.mockito.Mock;
|
|||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.pf4j.PluginDescriptor;
|
||||
import org.pf4j.PluginWrapper;
|
||||
import org.pf4j.RuntimeMode;
|
||||
import org.springframework.web.server.ServerWebInputException;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
|
@ -239,14 +238,6 @@ class PluginServiceImplTest {
|
|||
|
||||
@Test
|
||||
void generateJsBundleVersionTest() {
|
||||
when(pluginManager.getRuntimeMode()).thenReturn(RuntimeMode.DEVELOPMENT);
|
||||
|
||||
pluginService.generateJsBundleVersion()
|
||||
.as(StepVerifier::create)
|
||||
.consumeNextWith(version -> assertThat(version).isNotNull())
|
||||
.verifyComplete();
|
||||
|
||||
when(pluginManager.getRuntimeMode()).thenReturn(RuntimeMode.DEPLOYMENT);
|
||||
var plugin1 = mock(PluginWrapper.class);
|
||||
var plugin2 = mock(PluginWrapper.class);
|
||||
var plugin3 = mock(PluginWrapper.class);
|
||||
|
|
|
@ -1,17 +1,33 @@
|
|||
package run.halo.app.plugin.resources;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.net.URL;
|
||||
import java.net.URLClassLoader;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Mockito;
|
||||
import org.mockito.Spy;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.pf4j.PluginManager;
|
||||
import org.pf4j.PluginWrapper;
|
||||
import org.springframework.boot.autoconfigure.web.WebProperties;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||
import org.springframework.util.ResourceUtils;
|
||||
import run.halo.app.core.extension.ReverseProxy;
|
||||
import run.halo.app.core.extension.ReverseProxy.FileReverseProxyProvider;
|
||||
import run.halo.app.core.extension.ReverseProxy.ReverseProxyRule;
|
||||
import run.halo.app.extension.Metadata;
|
||||
import run.halo.app.plugin.PluginConst;
|
||||
|
||||
|
@ -30,21 +46,83 @@ class ReverseProxyRouterFunctionFactoryTest {
|
|||
@Mock
|
||||
private ApplicationContext applicationContext;
|
||||
|
||||
@Spy
|
||||
WebProperties webProperties = new WebProperties();
|
||||
|
||||
@InjectMocks
|
||||
private ReverseProxyRouterFunctionFactory reverseProxyRouterFunctionFactory;
|
||||
private ReverseProxyRouterFunctionFactory factory;
|
||||
|
||||
@Test
|
||||
void create() {
|
||||
var routerFunction = reverseProxyRouterFunctionFactory.create(mockReverseProxy(), "fakeA");
|
||||
void shouldProxyStaticResourceWithCacheControl() throws FileNotFoundException {
|
||||
var cache = webProperties.getResources().getCache();
|
||||
cache.setUseLastModified(true);
|
||||
cache.getCachecontrol().setMaxAge(Duration.ofDays(7));
|
||||
|
||||
var routerFunction = factory.create(mockReverseProxy(), "fakeA");
|
||||
assertNotNull(routerFunction);
|
||||
var webClient = WebTestClient.bindToRouterFunction(routerFunction).build();
|
||||
|
||||
var pluginWrapper = Mockito.mock(PluginWrapper.class);
|
||||
var pluginRoot = ResourceUtils.getURL("classpath:plugin/plugin-for-reverseproxy/");
|
||||
var classLoader = new URLClassLoader(new URL[] {pluginRoot});
|
||||
when(pluginWrapper.getPluginClassLoader()).thenReturn(classLoader);
|
||||
when(pluginManager.getPlugin("fakeA")).thenReturn(pluginWrapper);
|
||||
|
||||
webClient.get().uri("/plugins/fakeA/assets/static/test.txt")
|
||||
.exchange()
|
||||
.expectStatus().isOk()
|
||||
.expectHeader().cacheControl(CacheControl.maxAge(Duration.ofDays(7)))
|
||||
.expectHeader().value(HttpHeaders.LAST_MODIFIED, Assertions::assertNotNull)
|
||||
.expectBody(String.class).isEqualTo("Fake content.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldProxyStaticResourceWithoutLastModified() throws FileNotFoundException {
|
||||
var cache = webProperties.getResources().getCache();
|
||||
cache.setUseLastModified(false);
|
||||
cache.getCachecontrol().setMaxAge(Duration.ofDays(7));
|
||||
|
||||
var routerFunction = factory.create(mockReverseProxy(), "fakeA");
|
||||
assertNotNull(routerFunction);
|
||||
var webClient = WebTestClient.bindToRouterFunction(routerFunction).build();
|
||||
|
||||
var pluginWrapper = Mockito.mock(PluginWrapper.class);
|
||||
var pluginRoot = ResourceUtils.getURL("classpath:plugin/plugin-for-reverseproxy/");
|
||||
var classLoader = new URLClassLoader(new URL[] {pluginRoot});
|
||||
when(pluginWrapper.getPluginClassLoader()).thenReturn(classLoader);
|
||||
when(pluginManager.getPlugin("fakeA")).thenReturn(pluginWrapper);
|
||||
|
||||
webClient.get().uri("/plugins/fakeA/assets/static/test.txt")
|
||||
.exchange()
|
||||
.expectStatus().isOk()
|
||||
.expectHeader().cacheControl(CacheControl.maxAge(Duration.ofDays(7)))
|
||||
.expectHeader().lastModified(-1)
|
||||
.expectBody(String.class).isEqualTo("Fake content.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnNotFoundIfResourceNotFound() throws FileNotFoundException {
|
||||
var routerFunction = factory.create(mockReverseProxy(), "fakeA");
|
||||
assertNotNull(routerFunction);
|
||||
var webClient = WebTestClient.bindToRouterFunction(routerFunction).build();
|
||||
|
||||
var pluginWrapper = Mockito.mock(PluginWrapper.class);
|
||||
var pluginRoot = ResourceUtils.getURL("classpath:plugin/plugin-for-reverseproxy/");
|
||||
var classLoader = new URLClassLoader(new URL[] {pluginRoot});
|
||||
when(pluginWrapper.getPluginClassLoader()).thenReturn(classLoader);
|
||||
when(pluginManager.getPlugin("fakeA")).thenReturn(pluginWrapper);
|
||||
|
||||
webClient.get().uri("/plugins/fakeA/assets/static/non-existing-file.txt")
|
||||
.exchange()
|
||||
.expectHeader().cacheControl(CacheControl.empty())
|
||||
.expectStatus().isNotFound();
|
||||
}
|
||||
|
||||
private ReverseProxy mockReverseProxy() {
|
||||
ReverseProxy.ReverseProxyRule reverseProxyRule =
|
||||
new ReverseProxy.ReverseProxyRule("/static/**",
|
||||
new ReverseProxy.FileReverseProxyProvider("static", ""));
|
||||
ReverseProxy reverseProxy = new ReverseProxy();
|
||||
Metadata metadata = new Metadata();
|
||||
var reverseProxyRule = new ReverseProxyRule("/static/**",
|
||||
new FileReverseProxyProvider("static", ""));
|
||||
var reverseProxy = new ReverseProxy();
|
||||
var metadata = new Metadata();
|
||||
metadata.setLabels(
|
||||
Map.of(PluginConst.PLUGIN_NAME_LABEL_NAME, "fakeA"));
|
||||
reverseProxy.setMetadata(metadata);
|
||||
|
|
|
@ -1,60 +0,0 @@
|
|||
package run.halo.app.theme;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import run.halo.app.infra.ThemeRootGetter;
|
||||
import run.halo.app.infra.exception.AccessDeniedException;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ThemeConfigurationTest {
|
||||
@Mock
|
||||
private ThemeRootGetter themeRootGetter;
|
||||
|
||||
@InjectMocks
|
||||
private ThemeConfiguration themeConfiguration;
|
||||
|
||||
private final Path themeRoot = Paths.get("tmp", ".halo", "themes");
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
when(themeRootGetter.get()).thenReturn(themeRoot);
|
||||
}
|
||||
|
||||
@Test
|
||||
void themeAssets() {
|
||||
Path path = themeConfiguration.getThemeAssetsPath("fake-theme", "hello.jpg");
|
||||
assertThat(path).isEqualTo(
|
||||
themeRoot.resolve(Paths.get("fake-theme", "templates", "assets", "hello.jpg")));
|
||||
|
||||
path = themeConfiguration.getThemeAssetsPath("fake-theme", "./hello.jpg");
|
||||
assertThat(path).isEqualTo(
|
||||
themeRoot.resolve(Paths.get("fake-theme", "templates", "assets", ".", "hello.jpg")));
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
themeConfiguration.getThemeAssetsPath("fake-theme", "../../hello.jpg"))
|
||||
.isInstanceOf(AccessDeniedException.class)
|
||||
.hasMessageContaining("Directory traversal detected");
|
||||
|
||||
path = themeConfiguration.getThemeAssetsPath("fake-theme", "%2e%2e/f.jpg");
|
||||
assertThat(path).isEqualTo(
|
||||
themeRoot.resolve(Paths.get("fake-theme", "templates", "assets", "%2e%2e", "f.jpg")));
|
||||
|
||||
path = themeConfiguration.getThemeAssetsPath("fake-theme", "f/./../p.jpg");
|
||||
assertThat(path).isEqualTo(themeRoot.resolve(
|
||||
Paths.get("fake-theme", "templates", "assets", "f", ".", "..", "p.jpg")));
|
||||
|
||||
path = themeConfiguration.getThemeAssetsPath("fake-theme", "f../p.jpg");
|
||||
assertThat(path).isEqualTo(
|
||||
themeRoot.resolve(Paths.get("fake-theme", "templates", "assets", "f..", "p.jpg")));
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
Fake content.
|
Loading…
Reference in New Issue