Fix the problem of not using error template in theme (#3166)

#### What type of PR is this?

/kind bug
/area core

#### What this PR does / why we need it:

Currently, if there is no `error.html` error template in theme, but there is a `404.html` error template, this will not work correctly.

We always get rendering result from global error template `error.html`.

This PR mainly provides a `ThemeTemplateAvailabilityProvider` to check if the template is available in theme instead of in globally predefined templates.

#### Which issue(s) this PR fixes:

Fixes https://github.com/halo-dev/halo/issues/3062

#### Special notes for your reviewer:

1. Download and install any theme
2. Check the theme folder
3. Check folder `templates/error`
4. Try to remove `templates/error/error.html` template file
5. Create `templates/error/404.html`
6. Request a page which does not exist
7. See the result

#### Does this PR introduce a user-facing change?

```release-note
解决主题自定义错误模板不生效的问题。
```
pull/3168/head
John Niang 2023-01-19 15:46:14 +08:00 committed by GitHub
parent da07d75df3
commit 2241c08371
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 130 additions and 63 deletions

View File

@ -1,6 +1,7 @@
package run.halo.app.infra.exception.handlers;
import java.util.Map;
import java.util.Optional;
import org.springframework.boot.autoconfigure.web.ErrorProperties;
import org.springframework.boot.autoconfigure.web.WebProperties;
import org.springframework.boot.autoconfigure.web.reactive.error.DefaultErrorWebExceptionHandler;
@ -11,9 +12,16 @@ import org.springframework.http.ProblemDetail;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import run.halo.app.theme.ThemeContext;
import run.halo.app.theme.ThemeResolver;
import run.halo.app.theme.engine.ThemeTemplateAvailabilityProvider;
public class HaloErrorWebExceptionHandler extends DefaultErrorWebExceptionHandler {
private final ThemeTemplateAvailabilityProvider templateAvailabilityProvider;
private final ThemeResolver themeResolver;
/**
* Create a new {@code DefaultErrorWebExceptionHandler} instance.
*
@ -29,6 +37,9 @@ public class HaloErrorWebExceptionHandler extends DefaultErrorWebExceptionHandle
ErrorProperties errorProperties,
ApplicationContext applicationContext) {
super(errorAttributes, resources, errorProperties, applicationContext);
this.templateAvailabilityProvider =
applicationContext.getBean(ThemeTemplateAvailabilityProvider.class);
this.themeResolver = applicationContext.getBean(ThemeResolver.class);
}
@Override
@ -45,4 +56,24 @@ public class HaloErrorWebExceptionHandler extends DefaultErrorWebExceptionHandle
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.bodyValue(errorAttributes.get("error"));
}
@Override
protected Mono<ServerResponse> renderErrorView(ServerRequest request) {
return themeResolver.getTheme(request.exchange().getRequest())
.flatMap(themeContext -> super.renderErrorView(request)
.contextWrite(context -> context.put(ThemeContext.class, themeContext)));
}
@Override
protected Mono<ServerResponse> renderErrorView(String viewName,
ServerResponse.BodyBuilder responseBody, Map<String, Object> error) {
return Mono.deferContextual(contextView -> {
Optional<ThemeContext> themeContext = contextView.getOrEmpty(ThemeContext.class);
if (themeContext.isPresent()
&& templateAvailabilityProvider.isTemplateAvailable(themeContext.get(), viewName)) {
return responseBody.render(viewName, error);
}
return super.renderErrorView(viewName, responseBody, error);
});
}
}

View File

@ -1,25 +0,0 @@
package run.halo.app.infra.utils;
import java.nio.file.FileSystem;
import java.nio.file.FileSystems;
import java.nio.file.Path;
import java.nio.file.Paths;
import org.thymeleaf.util.StringUtils;
/**
* File system path utils.
*
* @author guqing
* @since 2.0.0
*/
public class FilePathUtils {
private FilePathUtils() {
}
public static Path combinePath(String first, String... more) {
FileSystem fileSystem = FileSystems.getDefault();
Path path = fileSystem.getPath(first, more);
String unixPath = StringUtils.replace(path.normalize(), "\\", "/");
return Paths.get(unixPath);
}
}

View File

@ -100,7 +100,7 @@ public class TemplateEngineManager {
thymeleafProperties.isRenderHiddenMarkersBeforeCheckboxes());
var mainResolver = haloTemplateResolver();
mainResolver.setPrefix(theme.getPath() + "/templates/");
mainResolver.setPrefix(theme.getPath().resolve("templates") + "/");
engine.addTemplateResolver(mainResolver);
// replace StandardDialect with SpringStandardDialect
engine.setDialect(new SpringStandardDialect() {

View File

@ -7,6 +7,7 @@ import static org.springframework.web.reactive.function.server.RouterFunctions.r
import java.io.IOException;
import java.nio.file.Path;
import java.time.Instant;
import org.apache.commons.lang3.StringUtils;
import org.springframework.boot.autoconfigure.web.WebProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@ -18,8 +19,7 @@ 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.properties.HaloProperties;
import run.halo.app.infra.utils.FilePathUtils;
import run.halo.app.infra.ThemeRootGetter;
import run.halo.app.theme.dialect.HaloSpringSecurityDialect;
import run.halo.app.theme.dialect.LinkExpressionObjectDialect;
@ -29,10 +29,11 @@ import run.halo.app.theme.dialect.LinkExpressionObjectDialect;
*/
@Configuration
public class ThemeConfiguration {
private final HaloProperties haloProperties;
public ThemeConfiguration(HaloProperties haloProperties) {
this.haloProperties = haloProperties;
private final ThemeRootGetter themeRoot;
public ThemeConfiguration(ThemeRootGetter themeRoot) {
this.themeRoot = themeRoot;
}
@Bean
@ -43,6 +44,7 @@ public class ThemeConfiguration {
request -> {
var themeName = request.pathVariable("themeName");
var resource = request.pathVariable("resource");
resource = StringUtils.removeStart(resource, "/");
var fsRes = new FileSystemResource(getThemeAssetsPath(themeName, resource));
var bodyBuilder = ServerResponse.ok()
.cacheControl(cacheProperties.getCachecontrol().toHttpCacheControl());
@ -61,8 +63,11 @@ public class ThemeConfiguration {
}
private Path getThemeAssetsPath(String themeName, String resource) {
return FilePathUtils.combinePath(haloProperties.getWorkDir().toString(),
"themes", themeName, "templates", "assets", resource);
return themeRoot.get()
.resolve(themeName)
.resolve("templates")
.resolve("assets")
.resolve(resource);
}
@Bean

View File

@ -1,17 +1,14 @@
package run.halo.app.theme;
import java.nio.file.Files;
import lombok.AllArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.boot.autoconfigure.thymeleaf.ThymeleafProperties;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import reactor.core.publisher.Mono;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting.Theme;
import run.halo.app.infra.properties.HaloProperties;
import run.halo.app.infra.utils.FilePathUtils;
import run.halo.app.infra.ThemeRootGetter;
/**
* @author johnniang
@ -20,17 +17,14 @@ import run.halo.app.infra.utils.FilePathUtils;
@Component
@AllArgsConstructor
public class ThemeResolver {
private static final String THEME_WORK_DIR = "themes";
private final SystemConfigurableEnvironmentFetcher environmentFetcher;
private final HaloProperties haloProperties;
private final ThymeleafProperties thymeleafProperties;
private final ThemeRootGetter themeRoot;
public Mono<ThemeContext> getThemeContext(String themeName) {
Assert.hasText(themeName, "Theme name cannot be empty");
var path = FilePathUtils.combinePath(haloProperties.getWorkDir().toString(),
THEME_WORK_DIR, themeName);
var path = themeRoot.get().resolve(themeName);
return Mono.just(ThemeContext.builder().name(themeName).path(path))
.flatMap(builder -> environmentFetcher.fetch(Theme.GROUP, Theme.class)
.mapNotNull(Theme::getActive)
@ -55,8 +49,7 @@ public class ThemeResolver {
themeName = activatedTheme;
}
boolean active = StringUtils.equals(activatedTheme, themeName);
var path = FilePathUtils.combinePath(haloProperties.getWorkDir().toString(),
THEME_WORK_DIR, themeName);
var path = themeRoot.get().resolve(themeName);
return builder.name(themeName)
.path(path)
.active(active)
@ -64,22 +57,4 @@ public class ThemeResolver {
});
}
/**
* Check whether the template file exists.
*
* @param viewName view name must not be blank
* @return if exists return true, otherwise return false
*/
public Mono<Boolean> isTemplateAvailable(ServerHttpRequest request, String viewName) {
return getTheme(request)
.map(themeContext -> {
String prefix = themeContext.getPath() + "/templates/";
String viewNameToUse = viewName;
if (!viewNameToUse.endsWith(thymeleafProperties.getSuffix())) {
viewNameToUse = viewNameToUse + thymeleafProperties.getSuffix();
}
return Files.exists(FilePathUtils.combinePath(prefix, viewNameToUse));
})
.onErrorResume(e -> Mono.just(false));
}
}

View File

@ -0,0 +1,25 @@
package run.halo.app.theme.engine;
import java.nio.file.Files;
import org.springframework.boot.autoconfigure.thymeleaf.ThymeleafProperties;
import org.springframework.stereotype.Component;
import run.halo.app.theme.ThemeContext;
@Component
public class DefaultThemeTemplateAvailabilityProvider implements ThemeTemplateAvailabilityProvider {
private final ThymeleafProperties thymeleafProperties;
public DefaultThemeTemplateAvailabilityProvider(ThymeleafProperties thymeleafProperties) {
this.thymeleafProperties = thymeleafProperties;
}
@Override
public boolean isTemplateAvailable(ThemeContext themeContext, String viewName) {
var suffix = thymeleafProperties.getSuffix();
// Currently, we only support Path here.
var path = themeContext.getPath().resolve("templates").resolve(viewName + suffix);
return Files.exists(path);
}
}

View File

@ -0,0 +1,9 @@
package run.halo.app.theme.engine;
import run.halo.app.theme.ThemeContext;
public interface ThemeTemplateAvailabilityProvider {
boolean isTemplateAvailable(ThemeContext themeContext, String viewName);
}

View File

@ -0,0 +1,47 @@
package run.halo.app.theme.engine;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.when;
import java.io.FileNotFoundException;
import java.net.URISyntaxException;
import java.nio.file.Path;
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 org.springframework.boot.autoconfigure.thymeleaf.ThymeleafProperties;
import org.springframework.util.ResourceUtils;
import run.halo.app.theme.ThemeContext;
@ExtendWith(MockitoExtension.class)
class DefaultThemeTemplateAvailabilityProviderTest {
@InjectMocks
DefaultThemeTemplateAvailabilityProvider provider;
@Mock
ThymeleafProperties thymeleafProperties;
@Test
void templateAvailableTest() throws FileNotFoundException, URISyntaxException {
var themeUrl = ResourceUtils.getURL("classpath:themes/default");
var themePath = Path.of(themeUrl.toURI());
when(thymeleafProperties.getSuffix()).thenReturn(".html");
var themeContext = ThemeContext.builder()
.name("default")
.path(themePath)
.build();
boolean templateAvailable = provider.isTemplateAvailable(themeContext, "fake");
assertFalse(templateAvailable);
templateAvailable = provider.isTemplateAvailable(themeContext, "index");
assertTrue(templateAvailable);
templateAvailable = provider.isTemplateAvailable(themeContext, "timezone");
assertTrue(templateAvailable);
}
}