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
John Niang 2024-05-30 15:05:15 +08:00 committed by GitHub
parent 4c6abdcaa1
commit e881ee9a89
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 498 additions and 272 deletions

View File

@ -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);
}
}

View File

@ -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))
);

View File

@ -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);

View File

@ -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))

View File

@ -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/";
}

View File

@ -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());
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}
}

View File

@ -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;
}
}
}

View File

@ -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);

View File

@ -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);

View File

@ -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")));
}
}

View File

@ -0,0 +1 @@
Fake content.