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.endpoint.WebSocketHandlerMapping;
|
||||||
import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
||||||
import run.halo.app.core.extension.endpoint.CustomEndpointsBuilder;
|
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.infra.properties.HaloProperties;
|
||||||
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
|
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
|
||||||
import run.halo.app.webfilter.AdditionalWebFilterChainProxy;
|
import run.halo.app.webfilter.AdditionalWebFilterChainProxy;
|
||||||
|
@ -52,7 +53,6 @@ public class WebFluxConfig implements WebFluxConfigurer {
|
||||||
|
|
||||||
private final HaloProperties haloProp;
|
private final HaloProperties haloProp;
|
||||||
|
|
||||||
|
|
||||||
private final WebProperties.Resources resourceProperties;
|
private final WebProperties.Resources resourceProperties;
|
||||||
|
|
||||||
private final ApplicationContext applicationContext;
|
private final ApplicationContext applicationContext;
|
||||||
|
@ -142,9 +142,12 @@ public class WebFluxConfig implements WebFluxConfigurer {
|
||||||
@Override
|
@Override
|
||||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||||
var attachmentsRoot = haloProp.getWorkDir().resolve("attachments");
|
var attachmentsRoot = haloProp.getWorkDir().resolve("attachments");
|
||||||
final var cacheControl = resourceProperties.getCache()
|
var cacheControl = resourceProperties.getCache()
|
||||||
.getCachecontrol()
|
.getCachecontrol()
|
||||||
.toHttpCacheControl();
|
.toHttpCacheControl();
|
||||||
|
if (cacheControl == null) {
|
||||||
|
cacheControl = CacheControl.empty();
|
||||||
|
}
|
||||||
final var useLastModified = resourceProperties.getCache().isUseLastModified();
|
final var useLastModified = resourceProperties.getCache().isUseLastModified();
|
||||||
|
|
||||||
// Mandatory resource mapping
|
// Mandatory resource mapping
|
||||||
|
@ -173,29 +176,31 @@ public class WebFluxConfig implements WebFluxConfigurer {
|
||||||
|
|
||||||
// Additional resource mappings
|
// Additional resource mappings
|
||||||
var staticResources = haloProp.getAttachment().getResourceMappings();
|
var staticResources = haloProp.getAttachment().getResourceMappings();
|
||||||
staticResources.forEach(staticResource -> {
|
for (AttachmentProperties.ResourceMapping staticResource : staticResources) {
|
||||||
ResourceHandlerRegistration registration;
|
ResourceHandlerRegistration registration;
|
||||||
if (Objects.equals(staticResource.getPathPattern(), "/upload/**")) {
|
if (Objects.equals(staticResource.getPathPattern(), "/upload/**")) {
|
||||||
registration = uploadRegistration;
|
registration = uploadRegistration;
|
||||||
} else {
|
} else {
|
||||||
registration = registry.addResourceHandler(staticResource.getPathPattern());
|
registration = registry.addResourceHandler(staticResource.getPathPattern())
|
||||||
}
|
|
||||||
staticResource.getLocations().forEach(location -> {
|
|
||||||
var path = attachmentsRoot.resolve(location);
|
|
||||||
checkDirectoryTraversal(attachmentsRoot, path);
|
|
||||||
registration.addResourceLocations(FILE_URL_PREFIX + path + "/")
|
|
||||||
.setCacheControl(cacheControl)
|
.setCacheControl(cacheControl)
|
||||||
.setUseLastModified(useLastModified);
|
.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");
|
var haloStaticPath = haloProp.getWorkDir().resolve("static");
|
||||||
registry.addResourceHandler("/**")
|
registry.addResourceHandler("/**")
|
||||||
.addResourceLocations(FILE_URL_PREFIX + haloStaticPath + "/")
|
.addResourceLocations(FILE_URL_PREFIX + haloStaticPath + "/")
|
||||||
.addResourceLocations(resourceProperties.getStaticLocations())
|
.addResourceLocations(resourceProperties.getStaticLocations())
|
||||||
.setCacheControl(CacheControl.noCache())
|
.setCacheControl(cacheControl)
|
||||||
.setUseLastModified(true);
|
.setUseLastModified(useLastModified)
|
||||||
|
.resourceChain(true)
|
||||||
|
.addResolver(new EncodedResourceResolver())
|
||||||
|
.addResolver(new PathResourceResolver());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -230,4 +235,5 @@ public class WebFluxConfig implements WebFluxConfigurer {
|
||||||
AdditionalWebFilterChainProxy additionalWebFilterChainProxy(ExtensionGetter extensionGetter) {
|
AdditionalWebFilterChainProxy additionalWebFilterChainProxy(ExtensionGetter extensionGetter) {
|
||||||
return new AdditionalWebFilterChainProxy(extensionGetter);
|
return new AdditionalWebFilterChainProxy(extensionGetter);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -100,7 +100,6 @@ public class WebServerSecurityConfig {
|
||||||
.referrerPolicy(referrerPolicySpec -> referrerPolicySpec.policy(
|
.referrerPolicy(referrerPolicySpec -> referrerPolicySpec.policy(
|
||||||
haloProperties.getSecurity().getReferrerOptions().getPolicy())
|
haloProperties.getSecurity().getReferrerOptions().getPolicy())
|
||||||
)
|
)
|
||||||
.cache(ServerHttpSecurity.HeaderSpec.CacheSpec::disable)
|
|
||||||
.hsts(hstsSpec -> hstsSpec.includeSubdomains(false))
|
.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.enums.ParameterIn;
|
||||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import java.io.FileNotFoundException;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
|
@ -32,20 +33,20 @@ import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
import java.util.concurrent.locks.ReadWriteLock;
|
import java.util.concurrent.locks.ReadWriteLock;
|
||||||
import java.util.concurrent.locks.ReentrantReadWriteLock;
|
import java.util.concurrent.locks.ReentrantReadWriteLock;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
import java.util.function.Predicate;
|
import java.util.function.Predicate;
|
||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
import lombok.AllArgsConstructor;
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.reactivestreams.Publisher;
|
import org.reactivestreams.Publisher;
|
||||||
import org.springdoc.core.fn.builders.operation.Builder;
|
import org.springdoc.core.fn.builders.operation.Builder;
|
||||||
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
|
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
|
||||||
import org.springframework.beans.factory.DisposableBean;
|
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.FileSystemResource;
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.core.io.buffer.DataBuffer;
|
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.RouterFunction;
|
||||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
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.ServerWebExchange;
|
||||||
import org.springframework.web.server.ServerWebInputException;
|
import org.springframework.web.server.ServerWebInputException;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
|
@ -88,9 +90,7 @@ import run.halo.app.plugin.PluginNotFoundException;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
@AllArgsConstructor
|
public class PluginEndpoint implements CustomEndpoint, InitializingBean {
|
||||||
public class PluginEndpoint implements CustomEndpoint {
|
|
||||||
private static final CacheControl MAX_CACHE_CONTROL = CacheControl.maxAge(365, TimeUnit.DAYS);
|
|
||||||
|
|
||||||
private final ReactiveExtensionClient client;
|
private final ReactiveExtensionClient client;
|
||||||
|
|
||||||
|
@ -98,9 +98,27 @@ public class PluginEndpoint implements CustomEndpoint {
|
||||||
|
|
||||||
private final ReactiveUrlDataBufferFetcher reactiveUrlDataBufferFetcher;
|
private final ReactiveUrlDataBufferFetcher reactiveUrlDataBufferFetcher;
|
||||||
|
|
||||||
|
private final BufferedPluginBundleResource bufferedPluginBundleResource;
|
||||||
|
|
||||||
|
private final WebProperties webProperties;
|
||||||
|
|
||||||
private final Scheduler scheduler = Schedulers.boundedElastic();
|
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
|
@Override
|
||||||
public RouterFunction<ServerResponse> endpoint() {
|
public RouterFunction<ServerResponse> endpoint() {
|
||||||
|
@ -290,6 +308,16 @@ public class PluginEndpoint implements CustomEndpoint {
|
||||||
.flatMap(plugin -> ServerResponse.ok().bodyValue(plugin));
|
.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
|
@Data
|
||||||
@Schema(name = "PluginRunningStateRequest")
|
@Schema(name = "PluginRunningStateRequest")
|
||||||
static class RunningStateRequest {
|
static class RunningStateRequest {
|
||||||
|
@ -299,60 +327,65 @@ public class PluginEndpoint implements CustomEndpoint {
|
||||||
|
|
||||||
private Mono<ServerResponse> fetchJsBundle(ServerRequest request) {
|
private Mono<ServerResponse> fetchJsBundle(ServerRequest request) {
|
||||||
Optional<String> versionOption = request.queryParam("v");
|
Optional<String> versionOption = request.queryParam("v");
|
||||||
return versionOption.map(s ->
|
if (versionOption.isEmpty()) {
|
||||||
Mono.defer(() -> bufferedPluginBundleResource
|
return pluginService.generateJsBundleVersion()
|
||||||
.getJsBundle(s, pluginService::uglifyJsBundle)
|
.flatMap(version -> ServerResponse
|
||||||
).flatMap(fsRes -> {
|
.temporaryRedirect(buildJsBundleUri("js", version))
|
||||||
var bodyBuilder = ServerResponse.ok()
|
.cacheControl(CacheControl.noStore())
|
||||||
.cacheControl(MAX_CACHE_CONTROL)
|
.build());
|
||||||
.contentType(MediaType.valueOf("text/javascript"));
|
}
|
||||||
|
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 {
|
try {
|
||||||
Instant lastModified = Instant.ofEpochMilli(fsRes.lastModified());
|
var lastModified = Instant.ofEpochMilli(jsRes.lastModified());
|
||||||
return request.checkNotModified(lastModified)
|
bodyBuilder = bodyBuilder.lastModified(lastModified);
|
||||||
.switchIfEmpty(Mono.defer(() ->
|
|
||||||
bodyBuilder.lastModified(lastModified)
|
|
||||||
.body(BodyInserters.fromResource(fsRes)))
|
|
||||||
);
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
|
if (e instanceof FileNotFoundException) {
|
||||||
|
return Mono.error(new NoResourceFoundException("bundle.js"));
|
||||||
|
}
|
||||||
return Mono.error(e);
|
return Mono.error(e);
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
)
|
return bodyBuilder.body(BodyInserters.fromResource(jsRes));
|
||||||
.orElseGet(() -> pluginService.generateJsBundleVersion()
|
});
|
||||||
.flatMap(v -> ServerResponse
|
|
||||||
.temporaryRedirect(buildJsBundleUri("js", v))
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Mono<ServerResponse> fetchCssBundle(ServerRequest request) {
|
private Mono<ServerResponse> fetchCssBundle(ServerRequest request) {
|
||||||
Optional<String> versionOption = request.queryParam("v");
|
Optional<String> versionOption = request.queryParam("v");
|
||||||
return versionOption.map(s ->
|
if (versionOption.isEmpty()) {
|
||||||
Mono.defer(() -> bufferedPluginBundleResource.getCssBundle(s,
|
return pluginService.generateJsBundleVersion()
|
||||||
pluginService::uglifyCssBundle)
|
.flatMap(version -> ServerResponse
|
||||||
).flatMap(fsRes -> {
|
.temporaryRedirect(buildJsBundleUri("css", version))
|
||||||
var bodyBuilder = ServerResponse.ok()
|
.cacheControl(CacheControl.noStore())
|
||||||
.cacheControl(MAX_CACHE_CONTROL)
|
.build());
|
||||||
.contentType(MediaType.valueOf("text/css"));
|
}
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
Instant lastModified = Instant.ofEpochMilli(fsRes.lastModified());
|
var lastModified = Instant.ofEpochMilli(cssRes.lastModified());
|
||||||
return request.checkNotModified(lastModified)
|
bodyBuilder = bodyBuilder.lastModified(lastModified);
|
||||||
.switchIfEmpty(Mono.defer(() ->
|
|
||||||
bodyBuilder.lastModified(lastModified)
|
|
||||||
.body(BodyInserters.fromResource(fsRes)))
|
|
||||||
);
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
|
if (e instanceof FileNotFoundException) {
|
||||||
|
return Mono.error(new NoResourceFoundException("bundle.css"));
|
||||||
|
}
|
||||||
return Mono.error(e);
|
return Mono.error(e);
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
)
|
|
||||||
.orElseGet(() -> pluginService.generateJsBundleVersion()
|
return bodyBuilder.body(BodyInserters.fromResource(cssRes));
|
||||||
.flatMap(v -> ServerResponse
|
});
|
||||||
.temporaryRedirect(buildJsBundleUri("css", v))
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
URI buildJsBundleUri(String type, String version) {
|
URI buildJsBundleUri(String type, String version) {
|
||||||
|
@ -743,10 +776,10 @@ public class PluginEndpoint implements CustomEndpoint {
|
||||||
|
|
||||||
private Path tempDir;
|
private Path tempDir;
|
||||||
|
|
||||||
public Mono<FileSystemResource> getJsBundle(String version,
|
public Mono<Resource> getJsBundle(String version,
|
||||||
Supplier<Flux<DataBuffer>> jsSupplier) {
|
Supplier<Flux<DataBuffer>> jsSupplier) {
|
||||||
var fileName = tempFileName(version, ".js");
|
var fileName = tempFileName(version, ".js");
|
||||||
return Mono.defer(() -> {
|
return Mono.<Resource>defer(() -> {
|
||||||
jsLock.readLock().lock();
|
jsLock.readLock().lock();
|
||||||
try {
|
try {
|
||||||
var jsBundleResource = jsBundle.get();
|
var jsBundleResource = jsBundle.get();
|
||||||
|
@ -768,12 +801,12 @@ public class PluginEndpoint implements CustomEndpoint {
|
||||||
}).subscribeOn(Schedulers.boundedElastic());
|
}).subscribeOn(Schedulers.boundedElastic());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<FileSystemResource> getCssBundle(String version,
|
public Mono<Resource> getCssBundle(String version,
|
||||||
Supplier<Flux<DataBuffer>> cssSupplier) {
|
Supplier<Flux<DataBuffer>> cssSupplier) {
|
||||||
var fileName = tempFileName(version, ".css");
|
var fileName = tempFileName(version, ".css");
|
||||||
return Mono.defer(() -> {
|
return Mono.<Resource>defer(() -> {
|
||||||
|
cssLock.readLock().lock();
|
||||||
try {
|
try {
|
||||||
cssLock.readLock().lock();
|
|
||||||
var cssBundleResource = cssBundle.get();
|
var cssBundleResource = cssBundle.get();
|
||||||
if (getResourceIfNotChange(fileName, cssBundleResource) != null) {
|
if (getResourceIfNotChange(fileName, cssBundleResource) != null) {
|
||||||
return Mono.just(cssBundleResource);
|
return Mono.just(cssBundleResource);
|
||||||
|
|
|
@ -23,7 +23,6 @@ import lombok.extern.slf4j.Slf4j;
|
||||||
import org.pf4j.DependencyResolver;
|
import org.pf4j.DependencyResolver;
|
||||||
import org.pf4j.PluginDescriptor;
|
import org.pf4j.PluginDescriptor;
|
||||||
import org.pf4j.PluginWrapper;
|
import org.pf4j.PluginWrapper;
|
||||||
import org.pf4j.RuntimeMode;
|
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.core.io.buffer.DataBuffer;
|
import org.springframework.core.io.buffer.DataBuffer;
|
||||||
import org.springframework.core.io.buffer.DataBufferUtils;
|
import org.springframework.core.io.buffer.DataBufferUtils;
|
||||||
|
@ -243,9 +242,6 @@ public class PluginServiceImpl implements PluginService {
|
||||||
@Override
|
@Override
|
||||||
public Mono<String> generateJsBundleVersion() {
|
public Mono<String> generateJsBundleVersion() {
|
||||||
return Mono.fromSupplier(() -> {
|
return Mono.fromSupplier(() -> {
|
||||||
if (RuntimeMode.DEVELOPMENT.equals(pluginManager.getRuntimeMode())) {
|
|
||||||
return String.valueOf(System.currentTimeMillis());
|
|
||||||
}
|
|
||||||
var compactVersion = pluginManager.getStartedPlugins()
|
var compactVersion = pluginManager.getStartedPlugins()
|
||||||
.stream()
|
.stream()
|
||||||
.sorted(Comparator.comparing(PluginWrapper::getPluginId))
|
.sorted(Comparator.comparing(PluginWrapper::getPluginId))
|
||||||
|
|
|
@ -22,7 +22,7 @@ public interface PluginConst {
|
||||||
|
|
||||||
String RUNTIME_MODE_ANNO = "plugin.halo.run/runtime-mode";
|
String RUNTIME_MODE_ANNO = "plugin.halo.run/runtime-mode";
|
||||||
|
|
||||||
static String assertsRoutePrefix(String pluginName) {
|
static String assetsRoutePrefix(String pluginName) {
|
||||||
return "/plugins/" + pluginName + "/assets/";
|
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.GET;
|
||||||
import static org.springframework.web.reactive.function.server.RequestPredicates.accept;
|
import static org.springframework.web.reactive.function.server.RequestPredicates.accept;
|
||||||
|
|
||||||
|
import java.io.FileNotFoundException;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
@ -16,6 +17,7 @@ import org.springframework.context.ApplicationContext;
|
||||||
import org.springframework.core.io.DefaultResourceLoader;
|
import org.springframework.core.io.DefaultResourceLoader;
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.core.io.ResourceLoader;
|
import org.springframework.core.io.ResourceLoader;
|
||||||
|
import org.springframework.http.CacheControl;
|
||||||
import org.springframework.http.server.PathContainer;
|
import org.springframework.http.server.PathContainer;
|
||||||
import org.springframework.lang.NonNull;
|
import org.springframework.lang.NonNull;
|
||||||
import org.springframework.lang.Nullable;
|
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.RouterFunctions;
|
||||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||||
|
import org.springframework.web.reactive.resource.NoResourceFoundException;
|
||||||
import org.springframework.web.util.pattern.PathPatternParser;
|
import org.springframework.web.util.pattern.PathPatternParser;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
import run.halo.app.core.extension.ReverseProxy;
|
import run.halo.app.core.extension.ReverseProxy;
|
||||||
|
@ -71,34 +74,44 @@ public class ReverseProxyRouterFunctionFactory {
|
||||||
ReverseProxy reverseProxy, @NonNull String pluginName) {
|
ReverseProxy reverseProxy, @NonNull String pluginName) {
|
||||||
Assert.notNull(reverseProxy, "The reverseProxy must not be null.");
|
Assert.notNull(reverseProxy, "The reverseProxy must not be null.");
|
||||||
var rules = getReverseProxyRules(reverseProxy);
|
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 -> {
|
return rules.stream().map(rule -> {
|
||||||
String routePath = buildRoutePath(pluginName, rule);
|
String routePath = buildRoutePath(pluginName, rule);
|
||||||
log.debug("Plugin [{}] registered reverse proxy route path [{}]", pluginName,
|
log.debug("Plugin [{}] registered reverse proxy route path [{}]", pluginName,
|
||||||
routePath);
|
routePath);
|
||||||
return RouterFunctions.route(GET(routePath).and(accept(ALL)),
|
return RouterFunctions.route(GET(routePath).and(accept(ALL)),
|
||||||
request -> {
|
request -> {
|
||||||
Resource resource =
|
var resource = loadResourceByFileRule(pluginName, rule, request);
|
||||||
loadResourceByFileRule(pluginName, rule, request);
|
|
||||||
if (!resource.exists()) {
|
if (!resource.exists()) {
|
||||||
return ServerResponse.notFound().build();
|
return Mono.error(new NoResourceFoundException(routePath));
|
||||||
}
|
}
|
||||||
var cacheProperties = webProperties.getResources().getCache();
|
if (!useLastModified) {
|
||||||
var useLastModified = cacheProperties.isUseLastModified();
|
return ServerResponse.ok()
|
||||||
var bodyBuilder = ServerResponse.ok()
|
.cacheControl(finalCacheControl)
|
||||||
.cacheControl(cacheProperties.getCachecontrol().toHttpCacheControl());
|
.body(BodyInserters.fromResource(resource));
|
||||||
|
}
|
||||||
|
Instant lastModified;
|
||||||
try {
|
try {
|
||||||
if (useLastModified) {
|
lastModified = Instant.ofEpochMilli(resource.lastModified());
|
||||||
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));
|
|
||||||
} catch (IOException e) {
|
} 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);
|
}).reduce(RouterFunction::and).orElse(null);
|
||||||
}
|
}
|
||||||
|
@ -112,7 +125,7 @@ public class ReverseProxyRouterFunctionFactory {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static String buildRoutePath(String pluginId, ReverseProxyRule reverseProxyRule) {
|
public static String buildRoutePath(String pluginId, ReverseProxyRule reverseProxyRule) {
|
||||||
return PathUtils.combinePath(PluginConst.assertsRoutePrefix(pluginId),
|
return PathUtils.combinePath(PluginConst.assetsRoutePrefix(pluginId),
|
||||||
reverseProxyRule.path());
|
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.eq;
|
||||||
import static org.mockito.ArgumentMatchers.isA;
|
import static org.mockito.ArgumentMatchers.isA;
|
||||||
import static org.mockito.ArgumentMatchers.same;
|
import static org.mockito.ArgumentMatchers.same;
|
||||||
|
import static org.mockito.Mockito.doReturn;
|
||||||
import static org.mockito.Mockito.lenient;
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.spy;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
import static org.springframework.test.web.reactive.server.WebTestClient.bindToRouterFunction;
|
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.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.Spy;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
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.FileSystemResource;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.core.io.buffer.DataBuffer;
|
import org.springframework.core.io.buffer.DataBuffer;
|
||||||
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
|
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
|
||||||
|
import org.springframework.http.CacheControl;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.client.MultipartBodyBuilder;
|
import org.springframework.http.client.MultipartBodyBuilder;
|
||||||
import org.springframework.test.web.reactive.server.WebTestClient;
|
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||||
|
@ -46,6 +53,7 @@ import reactor.core.publisher.Mono;
|
||||||
import reactor.test.StepVerifier;
|
import reactor.test.StepVerifier;
|
||||||
import run.halo.app.core.extension.Plugin;
|
import run.halo.app.core.extension.Plugin;
|
||||||
import run.halo.app.core.extension.Setting;
|
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.core.extension.service.PluginService;
|
||||||
import run.halo.app.extension.ConfigMap;
|
import run.halo.app.extension.ConfigMap;
|
||||||
import run.halo.app.extension.ListResult;
|
import run.halo.app.extension.ListResult;
|
||||||
|
@ -71,6 +79,12 @@ class PluginEndpointTest {
|
||||||
@Mock
|
@Mock
|
||||||
PluginService pluginService;
|
PluginService pluginService;
|
||||||
|
|
||||||
|
@Spy
|
||||||
|
WebProperties webProperties = new WebProperties();
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
BufferedPluginBundleResource bufferedPluginBundleResource;
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks
|
||||||
PluginEndpoint endpoint;
|
PluginEndpoint endpoint;
|
||||||
|
|
||||||
|
@ -391,8 +405,8 @@ class PluginEndpointTest {
|
||||||
|
|
||||||
@Nested
|
@Nested
|
||||||
class BufferedPluginBundleResourceTest {
|
class BufferedPluginBundleResourceTest {
|
||||||
private final PluginEndpoint.BufferedPluginBundleResource bufferedPluginBundleResource =
|
private final BufferedPluginBundleResource bufferedPluginBundleResource =
|
||||||
new PluginEndpoint.BufferedPluginBundleResource();
|
new BufferedPluginBundleResource();
|
||||||
|
|
||||||
private static Flux<DataBuffer> getDataBufferFlux(String x) {
|
private static Flux<DataBuffer> getDataBufferFlux(String x) {
|
||||||
var buffer = DefaultDataBufferFactory.sharedInstance
|
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.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.pf4j.PluginDescriptor;
|
import org.pf4j.PluginDescriptor;
|
||||||
import org.pf4j.PluginWrapper;
|
import org.pf4j.PluginWrapper;
|
||||||
import org.pf4j.RuntimeMode;
|
|
||||||
import org.springframework.web.server.ServerWebInputException;
|
import org.springframework.web.server.ServerWebInputException;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
import reactor.test.StepVerifier;
|
import reactor.test.StepVerifier;
|
||||||
|
@ -239,14 +238,6 @@ class PluginServiceImplTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void generateJsBundleVersionTest() {
|
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 plugin1 = mock(PluginWrapper.class);
|
||||||
var plugin2 = mock(PluginWrapper.class);
|
var plugin2 = mock(PluginWrapper.class);
|
||||||
var plugin3 = mock(PluginWrapper.class);
|
var plugin3 = mock(PluginWrapper.class);
|
||||||
|
|
|
@ -1,17 +1,33 @@
|
||||||
package run.halo.app.plugin.resources;
|
package run.halo.app.plugin.resources;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
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.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import org.junit.jupiter.api.Assertions;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
import org.mockito.Spy;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.pf4j.PluginManager;
|
import org.pf4j.PluginManager;
|
||||||
|
import org.pf4j.PluginWrapper;
|
||||||
|
import org.springframework.boot.autoconfigure.web.WebProperties;
|
||||||
import org.springframework.context.ApplicationContext;
|
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;
|
||||||
|
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.extension.Metadata;
|
||||||
import run.halo.app.plugin.PluginConst;
|
import run.halo.app.plugin.PluginConst;
|
||||||
|
|
||||||
|
@ -30,21 +46,83 @@ class ReverseProxyRouterFunctionFactoryTest {
|
||||||
@Mock
|
@Mock
|
||||||
private ApplicationContext applicationContext;
|
private ApplicationContext applicationContext;
|
||||||
|
|
||||||
|
@Spy
|
||||||
|
WebProperties webProperties = new WebProperties();
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks
|
||||||
private ReverseProxyRouterFunctionFactory reverseProxyRouterFunctionFactory;
|
private ReverseProxyRouterFunctionFactory factory;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void create() {
|
void shouldProxyStaticResourceWithCacheControl() throws FileNotFoundException {
|
||||||
var routerFunction = reverseProxyRouterFunctionFactory.create(mockReverseProxy(), "fakeA");
|
var cache = webProperties.getResources().getCache();
|
||||||
|
cache.setUseLastModified(true);
|
||||||
|
cache.getCachecontrol().setMaxAge(Duration.ofDays(7));
|
||||||
|
|
||||||
|
var routerFunction = factory.create(mockReverseProxy(), "fakeA");
|
||||||
assertNotNull(routerFunction);
|
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() {
|
private ReverseProxy mockReverseProxy() {
|
||||||
ReverseProxy.ReverseProxyRule reverseProxyRule =
|
var reverseProxyRule = new ReverseProxyRule("/static/**",
|
||||||
new ReverseProxy.ReverseProxyRule("/static/**",
|
new FileReverseProxyProvider("static", ""));
|
||||||
new ReverseProxy.FileReverseProxyProvider("static", ""));
|
var reverseProxy = new ReverseProxy();
|
||||||
ReverseProxy reverseProxy = new ReverseProxy();
|
var metadata = new Metadata();
|
||||||
Metadata metadata = new Metadata();
|
|
||||||
metadata.setLabels(
|
metadata.setLabels(
|
||||||
Map.of(PluginConst.PLUGIN_NAME_LABEL_NAME, "fakeA"));
|
Map.of(PluginConst.PLUGIN_NAME_LABEL_NAME, "fakeA"));
|
||||||
reverseProxy.setMetadata(metadata);
|
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