diff --git a/src/main/java/run/halo/app/config/HaloConfiguration.java b/src/main/java/run/halo/app/config/HaloConfiguration.java index dc228149a..e7cbabc91 100644 --- a/src/main/java/run/halo/app/config/HaloConfiguration.java +++ b/src/main/java/run/halo/app/config/HaloConfiguration.java @@ -14,5 +14,4 @@ public class HaloConfiguration { builder.serializationInclusion(JsonInclude.Include.NON_NULL); }; } - -} +} \ No newline at end of file diff --git a/src/main/java/run/halo/app/config/WebFluxConfig.java b/src/main/java/run/halo/app/config/WebFluxConfig.java index 7649198ce..41800294e 100644 --- a/src/main/java/run/halo/app/config/WebFluxConfig.java +++ b/src/main/java/run/halo/app/config/WebFluxConfig.java @@ -11,7 +11,6 @@ import org.springframework.http.codec.ServerCodecConfigurer; import org.springframework.http.codec.json.Jackson2JsonDecoder; import org.springframework.http.codec.json.Jackson2JsonEncoder; import org.springframework.lang.NonNull; -import org.springframework.web.reactive.config.EnableWebFlux; import org.springframework.web.reactive.config.WebFluxConfigurer; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerResponse; @@ -21,7 +20,6 @@ import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.core.extension.endpoint.CustomEndpointsBuilder; @Configuration -@EnableWebFlux public class WebFluxConfig implements WebFluxConfigurer { final ObjectMapper objectMapper; @@ -64,4 +62,5 @@ public class WebFluxConfig implements WebFluxConfigurer { .forEach(customEndpoint -> builder.add(customEndpoint.endpoint())); return builder.build(); } + } diff --git a/src/main/java/run/halo/app/config/WebServerSecurityConfig.java b/src/main/java/run/halo/app/config/WebServerSecurityConfig.java index 5d1bce693..42dadce71 100644 --- a/src/main/java/run/halo/app/config/WebServerSecurityConfig.java +++ b/src/main/java/run/halo/app/config/WebServerSecurityConfig.java @@ -27,7 +27,6 @@ import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder; import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder; import org.springframework.security.oauth2.jwt.SupplierReactiveJwtDecoder; import org.springframework.security.web.server.SecurityWebFilterChain; -import org.springframework.security.web.server.csrf.CookieServerCsrfTokenRepository; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.reactive.CorsConfigurationSource; import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource; @@ -66,10 +65,12 @@ public class WebServerSecurityConfig { RoleService roleService) { http.csrf().disable() .cors(corsSpec -> corsSpec.configurationSource(apiCorsConfigurationSource())) - .securityMatcher(pathMatchers("/api/**", "/apis/**")) + .securityMatcher(pathMatchers("/api/**", "/apis/**", "/login", "/logout")) .authorizeExchange(exchanges -> exchanges.anyExchange().access(new RequestInfoAuthorizationManager(roleService))) .httpBasic(withDefaults()) + .formLogin(withDefaults()) + .logout(withDefaults()) // for reuse the JWT authentication .oauth2ResourceServer().jwt(); @@ -86,24 +87,6 @@ public class WebServerSecurityConfig { return http.build(); } - @Bean - @Order(0) - SecurityWebFilterChain webFilterChain(ServerHttpSecurity http) { - http.authorizeExchange(exchanges -> exchanges.pathMatchers( - "/actuator/**", - "/swagger-ui.html", "/webjars/**", "/v3/api-docs/**" - ).permitAll()) - .cors(corsSpec -> corsSpec.configurationSource(apiCorsConfigurationSource())) - .authorizeExchange(exchanges -> exchanges.anyExchange().authenticated()) - .cors(withDefaults()) - .httpBasic(withDefaults()) - .formLogin(withDefaults()) - .csrf().csrfTokenRepository(CookieServerCsrfTokenRepository.withHttpOnlyFalse()).and() - .logout(withDefaults()); - - return http.build(); - } - CorsConfigurationSource apiCorsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOriginPatterns(List.of("*")); diff --git a/src/main/java/run/halo/app/core/extension/Theme.java b/src/main/java/run/halo/app/core/extension/Theme.java new file mode 100644 index 000000000..8598d9839 --- /dev/null +++ b/src/main/java/run/halo/app/core/extension/Theme.java @@ -0,0 +1,56 @@ +package run.halo.app.core.extension; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import run.halo.app.extension.AbstractExtension; +import run.halo.app.extension.GVK; + +/** + * @author guqing + * @since 2.0.0 + */ +@Data +@ToString(callSuper = true) +@EqualsAndHashCode(callSuper = true) +@GVK(group = "theme.halo.run", version = "v1alpha1", kind = "Theme", + plural = "themes", singular = "theme") +public class Theme extends AbstractExtension { + + @Schema(required = true) + private ThemeSpec spec; + + @Data + @ToString + public static class ThemeSpec { + + @Schema(required = true, minLength = 1) + private String displayName; + + @Schema(required = true) + private Author author; + + private String description; + + private String logo; + + private String website; + + private String repo; + + private String version = "*"; + + private String require = "*"; + } + + @Data + @ToString + public static class Author { + + @Schema(required = true, minLength = 1) + private String name; + + private String website; + } +} diff --git a/src/main/java/run/halo/app/infra/NotFoundException.java b/src/main/java/run/halo/app/infra/NotFoundException.java new file mode 100644 index 000000000..db22f108d --- /dev/null +++ b/src/main/java/run/halo/app/infra/NotFoundException.java @@ -0,0 +1,17 @@ +package run.halo.app.infra; + +/** + * Not found exception. + * + * @author guqing + * @since 2.0.0 + */ +public class NotFoundException extends RuntimeException { + public NotFoundException(String message) { + super(message); + } + + public NotFoundException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/run/halo/app/infra/SchemeInitializer.java b/src/main/java/run/halo/app/infra/SchemeInitializer.java index db039987e..86333da3f 100644 --- a/src/main/java/run/halo/app/infra/SchemeInitializer.java +++ b/src/main/java/run/halo/app/infra/SchemeInitializer.java @@ -9,6 +9,7 @@ import run.halo.app.core.extension.ReverseProxy; import run.halo.app.core.extension.Role; import run.halo.app.core.extension.RoleBinding; import run.halo.app.core.extension.Setting; +import run.halo.app.core.extension.Theme; import run.halo.app.core.extension.User; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.SchemeManager; @@ -33,5 +34,6 @@ public class SchemeInitializer implements ApplicationListener Optional fetch(String key, Class type) { + var stringValue = getInternal(key); + if (stringValue == null) { + return Optional.empty(); + } + if (conversionService.canConvert(String.class, type)) { + return Optional.ofNullable(conversionService.convert(stringValue, type)); + } + return Optional.of(JsonUtils.jsonToObject(stringValue, type)); + } + + private String getInternal(String group) { + return getValuesInternal().get(group); + } + + @NonNull + private Map getValuesInternal() { + return extensionClient.fetch(ConfigMap.class, SYSTEM_CONFIGMAP_NAME) + .filter(configMap -> configMap.getData() != null) + .map(ConfigMap::getData) + .orElse(Map.of()); + } +} diff --git a/src/main/java/run/halo/app/infra/SystemSetting.java b/src/main/java/run/halo/app/infra/SystemSetting.java new file mode 100644 index 000000000..655ff7c68 --- /dev/null +++ b/src/main/java/run/halo/app/infra/SystemSetting.java @@ -0,0 +1,19 @@ +package run.halo.app.infra; + +import lombok.Data; + +/** + * TODO Optimization value acquisition. + * + * @author guqing + * @since 2.0.0 + */ +public class SystemSetting { + + @Data + public static class Theme { + public static final String GROUP = "theme"; + + private String active; + } +} diff --git a/src/main/java/run/halo/app/infra/properties/HaloProperties.java b/src/main/java/run/halo/app/infra/properties/HaloProperties.java index f0bdc75a6..81f19cea0 100644 --- a/src/main/java/run/halo/app/infra/properties/HaloProperties.java +++ b/src/main/java/run/halo/app/infra/properties/HaloProperties.java @@ -28,5 +28,4 @@ public class HaloProperties { private final ExtensionProperties extension = new ExtensionProperties(); private final SecurityProperties security = new SecurityProperties(); - } diff --git a/src/main/java/run/halo/app/infra/utils/FilePathUtils.java b/src/main/java/run/halo/app/infra/utils/FilePathUtils.java new file mode 100644 index 000000000..ebfba53cb --- /dev/null +++ b/src/main/java/run/halo/app/infra/utils/FilePathUtils.java @@ -0,0 +1,25 @@ +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); + } +} diff --git a/src/main/java/run/halo/app/infra/utils/PathUtils.java b/src/main/java/run/halo/app/infra/utils/PathUtils.java index f0580ea90..0ee96f186 100644 --- a/src/main/java/run/halo/app/infra/utils/PathUtils.java +++ b/src/main/java/run/halo/app/infra/utils/PathUtils.java @@ -4,7 +4,7 @@ import lombok.experimental.UtilityClass; import org.apache.commons.lang3.StringUtils; /** - * Path manipulation tool class. + * Http path manipulation tool class. * * @author guqing * @since 2.0.0 @@ -21,6 +21,9 @@ public class PathUtils { public static String combinePath(String... pathSegments) { StringBuilder sb = new StringBuilder(); for (String path : pathSegments) { + if (path == null) { + continue; + } String s = path.startsWith("/") ? path : "/" + path; String segment = s.endsWith("/") ? s.substring(0, s.length() - 1) : s; sb.append(segment); diff --git a/src/main/java/run/halo/app/theme/HaloViewResolver.java b/src/main/java/run/halo/app/theme/HaloViewResolver.java new file mode 100644 index 000000000..7b7c30a93 --- /dev/null +++ b/src/main/java/run/halo/app/theme/HaloViewResolver.java @@ -0,0 +1,45 @@ +package run.halo.app.theme; + +import java.util.Map; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import org.thymeleaf.spring6.ISpringWebFluxTemplateEngine; +import org.thymeleaf.spring6.view.reactive.ThymeleafReactiveView; +import org.thymeleaf.spring6.view.reactive.ThymeleafReactiveViewResolver; +import reactor.core.publisher.Mono; + +@Component("thymeleafReactiveViewResolver") +public class HaloViewResolver extends ThymeleafReactiveViewResolver { + + public HaloViewResolver() { + setViewClass(HaloView.class); + } + + public static class HaloView extends ThymeleafReactiveView { + + @Autowired + private TemplateEngineManager engineManager; + + @Autowired + private ThemeResolver themeResolver; + + @Override + public Mono render(Map model, MediaType contentType, + ServerWebExchange exchange) { + // calculate the engine before rendering + var theme = themeResolver.getTheme(exchange.getRequest()); + var templateEngine = engineManager.getTemplateEngine(theme); + setTemplateEngine(templateEngine); + + return super.render(model, contentType, exchange); + } + + @Override + protected ISpringWebFluxTemplateEngine getTemplateEngine() { + return super.getTemplateEngine(); + } + } + +} diff --git a/src/main/java/run/halo/app/theme/TemplateEngineManager.java b/src/main/java/run/halo/app/theme/TemplateEngineManager.java new file mode 100644 index 000000000..87e40114b --- /dev/null +++ b/src/main/java/run/halo/app/theme/TemplateEngineManager.java @@ -0,0 +1,102 @@ +package run.halo.app.theme; + +import java.io.FileNotFoundException; +import java.nio.file.Path; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.thymeleaf.ThymeleafProperties; +import org.springframework.stereotype.Component; +import org.springframework.util.ConcurrentLruCache; +import org.springframework.util.ResourceUtils; +import org.thymeleaf.dialect.IDialect; +import org.thymeleaf.spring6.ISpringWebFluxTemplateEngine; +import org.thymeleaf.templateresolver.FileTemplateResolver; +import org.thymeleaf.templateresolver.ITemplateResolver; +import run.halo.app.infra.NotFoundException; +import run.halo.app.theme.engine.SpringWebFluxTemplateEngine; +import run.halo.app.theme.message.ThemeMessageResolver; + +/** + *

The {@link TemplateEngineManager} uses an {@link ConcurrentLruCache LRU cache} to manage + * theme's {@link ISpringWebFluxTemplateEngine}.

+ *

The default limit size of the {@link ConcurrentLruCache LRU cache} is + * {@link TemplateEngineManager#CACHE_SIZE_LIMIT} to prevent unnecessary memory occupation.

+ *

If theme's {@link ISpringWebFluxTemplateEngine} already exists, it returns.

+ *

Otherwise, it checks whether the theme exists and creates the + * {@link ISpringWebFluxTemplateEngine} into the LRU cache according to the {@link ThemeContext} + * .

+ *

It is thread safe.

+ * + * @author johnniang + * @author guqing + * @since 2.0.0 + */ +@Component +public class TemplateEngineManager { + private static final int CACHE_SIZE_LIMIT = 5; + private final ConcurrentLruCache engineCache; + + private final ThymeleafProperties thymeleafProperties; + + private final ObjectProvider templateResolvers; + + private final ObjectProvider dialects; + + public TemplateEngineManager(ThymeleafProperties thymeleafProperties, + ObjectProvider templateResolvers, + ObjectProvider dialects) { + this.thymeleafProperties = thymeleafProperties; + this.templateResolvers = templateResolvers; + this.dialects = dialects; + engineCache = new ConcurrentLruCache<>(CACHE_SIZE_LIMIT, this::templateEngineGenerator); + } + + public ISpringWebFluxTemplateEngine getTemplateEngine(ThemeContext theme) { + // cache not exists, will create new engine + if (!engineCache.contains(theme)) { + // before this, check if theme exists + if (!fileExists(theme.getPath())) { + throw new NotFoundException("Theme not found."); + } + } + return engineCache.get(theme); + } + + private boolean fileExists(Path path) { + try { + return ResourceUtils.getFile(path.toUri()).exists(); + } catch (FileNotFoundException e) { + return false; + } + } + + private ISpringWebFluxTemplateEngine templateEngineGenerator(ThemeContext theme) { + var engine = new SpringWebFluxTemplateEngine(); + engine.setEnableSpringELCompiler(thymeleafProperties.isEnableSpringElCompiler()); + engine.setMessageResolver(new ThemeMessageResolver(theme)); + engine.setLinkBuilder(new ThemeLinkBuilder(theme)); + engine.setRenderHiddenMarkersBeforeCheckboxes( + thymeleafProperties.isRenderHiddenMarkersBeforeCheckboxes()); + + var mainResolver = haloTemplateResolver(); + mainResolver.setPrefix(theme.getPath() + "/templates/"); + engine.addTemplateResolver(mainResolver); + + templateResolvers.orderedStream().forEach(engine::addTemplateResolver); + dialects.orderedStream().forEach(engine::addDialect); + + return engine; + } + + FileTemplateResolver haloTemplateResolver() { + final var resolver = new FileTemplateResolver(); + resolver.setTemplateMode(thymeleafProperties.getMode()); + resolver.setPrefix(thymeleafProperties.getPrefix()); + resolver.setSuffix(thymeleafProperties.getSuffix()); + resolver.setCacheable(thymeleafProperties.isCache()); + resolver.setCheckExistence(thymeleafProperties.isCheckTemplate()); + if (thymeleafProperties.getEncoding() != null) { + resolver.setCharacterEncoding(thymeleafProperties.getEncoding().name()); + } + return resolver; + } +} diff --git a/src/main/java/run/halo/app/theme/ThemeConfiguration.java b/src/main/java/run/halo/app/theme/ThemeConfiguration.java new file mode 100644 index 000000000..4ef4ca043 --- /dev/null +++ b/src/main/java/run/halo/app/theme/ThemeConfiguration.java @@ -0,0 +1,62 @@ +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 java.nio.file.Path; +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.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerResponse; +import run.halo.app.infra.properties.HaloProperties; +import run.halo.app.infra.utils.FilePathUtils; + +/** + * @author guqing + * @since 2.0.0 + */ +@Configuration +public class ThemeConfiguration { + private final HaloProperties haloProperties; + + public ThemeConfiguration(HaloProperties haloProperties) { + this.haloProperties = haloProperties; + } + + @Bean + public RouterFunction themeAssets() { + return RouterFunctions + .route(GET("/themes/{themeName}/assets/{*resource}") + .and(accept(MediaType.TEXT_HTML)), + request -> { + String themeName = request.pathVariable("themeName"); + String resource = request.pathVariable("resource"); + FileSystemResource fileSystemResource = + new FileSystemResource(getThemeAssetsPath(themeName, resource)); + return ServerResponse.ok() + .bodyValue(fileSystemResource); + }); + } + + @Bean + RouterFunction routeIndex() { + return RouterFunctions + .route(GET("/").or(GET("/index")) + .and(accept(MediaType.TEXT_HTML)), + request -> ServerResponse.ok().render("index")); + } + + @Bean + RouterFunction about() { + return RouterFunctions.route(GET("/about").and(accept(MediaType.TEXT_HTML)), + request -> ServerResponse.ok().render("about")); + } + + private Path getThemeAssetsPath(String themeName, String resource) { + return FilePathUtils.combinePath(haloProperties.getWorkDir().toString(), + "themes", themeName, "templates", "assets", resource); + } +} diff --git a/src/main/java/run/halo/app/theme/ThemeContext.java b/src/main/java/run/halo/app/theme/ThemeContext.java new file mode 100644 index 000000000..f931208c8 --- /dev/null +++ b/src/main/java/run/halo/app/theme/ThemeContext.java @@ -0,0 +1,24 @@ +package run.halo.app.theme; + +import java.nio.file.Path; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * @author guqing + * @since 2.0.0 + */ +@Data +@Builder +@EqualsAndHashCode(of = "name") +public class ThemeContext { + + public static final String THEME_PREVIEW_PARAM_NAME = "preview-theme"; + + private String name; + + private Path path; + + private boolean active; +} diff --git a/src/main/java/run/halo/app/theme/ThemeLinkBuilder.java b/src/main/java/run/halo/app/theme/ThemeLinkBuilder.java new file mode 100644 index 000000000..3ce73c521 --- /dev/null +++ b/src/main/java/run/halo/app/theme/ThemeLinkBuilder.java @@ -0,0 +1,81 @@ +package run.halo.app.theme; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.web.util.UriComponentsBuilder; +import org.thymeleaf.context.IExpressionContext; +import org.thymeleaf.linkbuilder.StandardLinkBuilder; +import run.halo.app.infra.utils.PathUtils; + +/** + * @author guqing + * @since 2.0.0 + */ +public class ThemeLinkBuilder extends StandardLinkBuilder { + private static final String THEME_ASSETS_PREFIX = "/assets"; + private static final String THEME_PREVIEW_PREFIX = "/themes"; + + private final ThemeContext theme; + + public ThemeLinkBuilder(ThemeContext theme) { + this.theme = theme; + } + + @Override + protected String processLink(IExpressionContext context, String link) { + if (link == null || isLinkBaseAbsolute(link)) { + return link; + } + + if (StringUtils.isBlank(link)) { + link = "/"; + } + + if (isAssetsRequest(link)) { + return PathUtils.combinePath(THEME_PREVIEW_PREFIX, theme.getName(), link); + } + + // not assets link + if (theme.isActive()) { + return link; + } + + return UriComponentsBuilder.fromUriString(link) + .queryParam(ThemeContext.THEME_PREVIEW_PARAM_NAME, theme.getName()) + .build().toString(); + } + + private static boolean isLinkBaseAbsolute(final CharSequence linkBase) { + final int linkBaseLen = linkBase.length(); + if (linkBaseLen < 2) { + return false; + } + final char c0 = linkBase.charAt(0); + if (c0 == 'm' || c0 == 'M') { + // Let's check for "mailto:" + if (linkBase.length() >= 7 + && Character.toLowerCase(linkBase.charAt(1)) == 'a' + && Character.toLowerCase(linkBase.charAt(2)) == 'i' + && Character.toLowerCase(linkBase.charAt(3)) == 'l' + && Character.toLowerCase(linkBase.charAt(4)) == 't' + && Character.toLowerCase(linkBase.charAt(5)) == 'o' + && Character.toLowerCase(linkBase.charAt(6)) == ':') { + return true; + } + } else if (c0 == '/') { + return linkBase.charAt(1) + == '/'; // It starts with '//' -> true, any other '/x' -> false + } + for (int i = 0; i < (linkBaseLen - 2); i++) { + // Let's try to find the '://' sequence anywhere in the base --> true + if (linkBase.charAt(i) == ':' && linkBase.charAt(i + 1) == '/' + && linkBase.charAt(i + 2) == '/') { + return true; + } + } + return false; + } + + private boolean isAssetsRequest(String link) { + return link.startsWith(THEME_ASSETS_PREFIX); + } +} diff --git a/src/main/java/run/halo/app/theme/ThemeLocaleContextResolver.java b/src/main/java/run/halo/app/theme/ThemeLocaleContextResolver.java new file mode 100644 index 000000000..cf82601a5 --- /dev/null +++ b/src/main/java/run/halo/app/theme/ThemeLocaleContextResolver.java @@ -0,0 +1,92 @@ +package run.halo.app.theme; + +import java.util.Locale; +import java.util.TimeZone; +import java.util.function.Function; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.context.i18n.LocaleContext; +import org.springframework.context.i18n.SimpleTimeZoneAwareLocaleContext; +import org.springframework.http.HttpCookie; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.adapter.WebHttpHandlerBuilder; +import org.springframework.web.server.i18n.AcceptHeaderLocaleContextResolver; + +/** + * @author guqing + * @since 2.0.0 + */ +@Slf4j +@Component(WebHttpHandlerBuilder.LOCALE_CONTEXT_RESOLVER_BEAN_NAME) +public class ThemeLocaleContextResolver extends AcceptHeaderLocaleContextResolver { + public static final String TIME_ZONE_REQUEST_ATTRIBUTE_NAME = + ThemeLocaleContextResolver.class.getName() + ".TIME_ZONE"; + public static final String LOCALE_REQUEST_ATTRIBUTE_NAME = + ThemeLocaleContextResolver.class.getName() + ".LOCALE"; + + public static final String DEFAULT_PARAMETER_NAME = "language"; + public static final String TIME_ZONE_COOKIE_NAME = "time_zone"; + + private final Function defaultTimeZoneFunction = + exchange -> getDefaultTimeZone(); + + @Override + @NonNull + public LocaleContext resolveLocaleContext(@NonNull ServerWebExchange exchange) { + parseLocaleCookieIfNecessary(exchange); + + Locale locale = getLocale(exchange); + + return new SimpleTimeZoneAwareLocaleContext(locale, + exchange.getAttribute(TIME_ZONE_REQUEST_ATTRIBUTE_NAME)); + } + + @Nullable + private Locale getLocale(ServerWebExchange exchange) { + String language = exchange.getRequest().getQueryParams() + .getFirst(DEFAULT_PARAMETER_NAME); + + Locale locale; + if (StringUtils.isNotBlank(language)) { + locale = new Locale(language); + } else if (exchange.getAttribute(LOCALE_REQUEST_ATTRIBUTE_NAME) != null) { + locale = exchange.getAttribute(LOCALE_REQUEST_ATTRIBUTE_NAME); + } else { + locale = super.resolveLocaleContext(exchange).getLocale(); + } + return locale; + } + + private TimeZone getDefaultTimeZone() { + return TimeZone.getDefault(); + } + + private void parseLocaleCookieIfNecessary(ServerWebExchange exchange) { + if (exchange.getAttribute(TIME_ZONE_REQUEST_ATTRIBUTE_NAME) == null) { + TimeZone timeZone = null; + HttpCookie cookie = exchange.getRequest() + .getCookies() + .getFirst(TIME_ZONE_COOKIE_NAME); + if (cookie != null) { + String value = cookie.getValue(); + timeZone = TimeZone.getTimeZone(value); + } + exchange.getAttributes().put(TIME_ZONE_REQUEST_ATTRIBUTE_NAME, + (timeZone != null ? timeZone : this.defaultTimeZoneFunction.apply(exchange))); + } + + if (exchange.getAttribute(LOCALE_REQUEST_ATTRIBUTE_NAME) == null) { + HttpCookie cookie = exchange.getRequest() + .getCookies() + .getFirst(DEFAULT_PARAMETER_NAME); + if (cookie != null) { + String value = cookie.getValue(); + exchange.getAttributes() + .put(LOCALE_REQUEST_ATTRIBUTE_NAME, new Locale(value)); + } + } + } +} diff --git a/src/main/java/run/halo/app/theme/ThemeResolver.java b/src/main/java/run/halo/app/theme/ThemeResolver.java new file mode 100644 index 000000000..aadd8f087 --- /dev/null +++ b/src/main/java/run/halo/app/theme/ThemeResolver.java @@ -0,0 +1,67 @@ +package run.halo.app.theme; + +import java.util.function.Function; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.stereotype.Component; +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; + +/** + * @author johnniang + * @since 2.0.0 + */ +@Component +public class ThemeResolver { + private static final String THEME_WORK_DIR = "themes"; + private final SystemConfigurableEnvironmentFetcher environmentFetcher; + private Function themeContextFunction; + private final HaloProperties haloProperties; + + public ThemeResolver(SystemConfigurableEnvironmentFetcher environmentFetcher, + HaloProperties haloProperties) { + this.environmentFetcher = environmentFetcher; + this.haloProperties = haloProperties; + themeContextFunction = this::defaultThemeContextFunction; + } + + public ThemeContext getTheme(ServerHttpRequest request) { + return themeContextFunction.apply(request); + } + + private ThemeContext defaultThemeContextFunction(ServerHttpRequest request) { + var builder = ThemeContext.builder(); + var themeName = request.getQueryParams().getFirst(ThemeContext.THEME_PREVIEW_PARAM_NAME); + // TODO Fetch activated theme name from other place. + String activation = environmentFetcher.fetch(Theme.GROUP, Theme.class) + .map(Theme::getActive) + .orElseThrow(); + if (StringUtils.isBlank(themeName)) { + themeName = activation; + } + if (StringUtils.equals(activation, themeName)) { + builder.active(true); + } + + // TODO Validate the existence of the theme name. + + var path = FilePathUtils.combinePath(haloProperties.getWorkDir().toString(), + THEME_WORK_DIR, themeName); + + return builder + .name(themeName) + .path(path) + .build(); + } + + public Function getThemeContextFunction() { + return themeContextFunction; + } + + public void setThemeContextFunction( + Function themeContextFunction) { + this.themeContextFunction = themeContextFunction; + } +} diff --git a/src/main/java/run/halo/app/theme/engine/SpringTemplateEngine.java b/src/main/java/run/halo/app/theme/engine/SpringTemplateEngine.java new file mode 100644 index 000000000..7cf3a567a --- /dev/null +++ b/src/main/java/run/halo/app/theme/engine/SpringTemplateEngine.java @@ -0,0 +1,330 @@ +package run.halo.app.theme.engine; + +import java.util.Set; +import org.springframework.context.MessageSource; +import org.springframework.context.MessageSourceAware; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.dialect.IDialect; +import org.thymeleaf.messageresolver.IMessageResolver; +import org.thymeleaf.messageresolver.StandardMessageResolver; +import org.thymeleaf.spring6.ISpringTemplateEngine; +import org.thymeleaf.spring6.dialect.SpringStandardDialect; +import org.thymeleaf.spring6.messageresolver.SpringMessageResolver; + +/** + *

+ * Implementation of {@link ISpringTemplateEngine} meant for Spring-enabled applications, + * that establishes by default an instance of {@link SpringStandardDialect} + * as a dialect (instead of an instance of {@link org.thymeleaf.standard.StandardDialect}. + *

+ *

+ * It also configures a {@link SpringMessageResolver} as message resolver, and + * implements the {@link MessageSourceAware} interface in order to let Spring + * automatically setting the {@link MessageSource} used at the application + * (bean needs to have id {@code "messageSource"}). If this Spring standard setting + * needs to be overridden, the {@link #setTemplateEngineMessageSource(MessageSource)} can + * be used. + *

+ *

+ * Code from + * Thymeleaf SpringTemplateEngine + *

+ * + * @author Daniel Fernández + * @author guqing + * @see org.thymeleaf.spring6.SpringTemplateEngine + * @since 2.0.0 + */ +public class SpringTemplateEngine extends TemplateEngine + implements ISpringTemplateEngine, MessageSourceAware { + + private static final SpringStandardDialect SPRINGSTANDARD_DIALECT = new SpringStandardDialect(); + + private MessageSource messageSource = null; + private MessageSource templateEngineMessageSource = null; + + + public SpringTemplateEngine() { + super(); + // This will set the SpringStandardDialect, overriding the Standard one set in the super + // constructor + super.setDialect(SPRINGSTANDARD_DIALECT); + } + + + /** + *

+ * Implementation of the {@link MessageSourceAware#setMessageSource(MessageSource)} + * method at the {@link MessageSourceAware} interface, provided so that + * Spring is able to automatically set the currently configured {@link MessageSource} into + * this template engine. + *

+ *

+ * If several {@link MessageSource} implementation beans exist, Spring will inject here + * the one with id {@code "messageSource"}. + *

+ *

+ * This property should not be set manually in most scenarios (see + * {@link #setTemplateEngineMessageSource(MessageSource)} instead). + *

+ * + * @param messageSource the message source to be used by the message resolver + */ + @Override + public void setMessageSource(final MessageSource messageSource) { + this.messageSource = messageSource; + } + + + /** + *

+ * Convenience method for setting the message source that will + * be used by this template engine, overriding the one automatically set by + * Spring at the {@link #setMessageSource(MessageSource)} method. + *

+ * + * @param templateEngineMessageSource the message source to be used by the message resolver + */ + @Override + public void setTemplateEngineMessageSource(final MessageSource templateEngineMessageSource) { + this.templateEngineMessageSource = templateEngineMessageSource; + } + + + /** + *

+ * Returns whether the SpringEL compiler should be enabled in SpringEL expressions or not. + *

+ *

+ * (This is just a convenience method, equivalent to calling + * {@link SpringStandardDialect#getEnableSpringELCompiler()} on the dialect instance itself. + * It is provided + * here in order to allow users to enable the SpEL compiler without + * having to directly create instances of the {@link SpringStandardDialect}) + *

+ *

+ * Expression compilation can significantly improve the performance of Spring EL expressions, + * but + * might not be adequate for every environment. Read + * the + * official Spring documentation for more detail. + *

+ *

+ * Also note that although Spring includes a SpEL compiler since Spring 4.1, most expressions + * in Thymeleaf templates will only be able to properly benefit from this compilation step + * when at least + * Spring Framework version 4.2.4 is used. + *

+ *

+ * This flag is set to {@code false} by default. + *

+ * + * @return {@code true} if SpEL expressions should be compiled if possible, {@code false} if + * not. + */ + public boolean getEnableSpringELCompiler() { + final Set dialects = getDialects(); + for (final IDialect dialect : dialects) { + if (dialect instanceof SpringStandardDialect) { + return ((SpringStandardDialect) dialect).getEnableSpringELCompiler(); + } + } + return false; + } + + + /** + *

+ * Sets whether the SpringEL compiler should be enabled in SpringEL expressions or not. + *

+ *

+ * (This is just a convenience method, equivalent to calling + * {@link SpringStandardDialect#setEnableSpringELCompiler(boolean)} on the dialect instance + * itself. It is provided + * here in order to allow users to enable the SpEL compiler without + * having to directly create instances of the {@link SpringStandardDialect}) + *

+ *

+ * Expression compilation can significantly improve the performance of Spring EL expressions, + * but + * might not be adequate for every environment. Read + * the + * official Spring documentation for more detail. + *

+ *

+ * Also note that although Spring includes a SpEL compiler since Spring 4.1, most expressions + * in Thymeleaf templates will only be able to properly benefit from this compilation step + * when at least + * Spring Framework version 4.2.4 is used. + *

+ *

+ * This flag is set to {@code false} by default. + *

+ * + * @param enableSpringELCompiler {@code true} if SpEL expressions should be compiled if + * possible, {@code false} if not. + */ + public void setEnableSpringELCompiler(final boolean enableSpringELCompiler) { + final Set dialects = getDialects(); + for (final IDialect dialect : dialects) { + if (dialect instanceof SpringStandardDialect) { + ((SpringStandardDialect) dialect).setEnableSpringELCompiler(enableSpringELCompiler); + } + } + } + + + /** + *

+ * Returns whether the {@code } marker tags rendered to signal the + * presence + * of checkboxes in forms when unchecked should be rendered before the checkbox tag + * itself, + * or after (default). + *

+ *

+ * (This is just a convenience method, equivalent to calling + * {@link SpringStandardDialect#getRenderHiddenMarkersBeforeCheckboxes()} on the dialect + * instance + * itself. It is provided here in order to allow users to modify this behaviour without + * having to directly create instances of the {@link SpringStandardDialect}) + *

+ *

+ * A number of CSS frameworks and style guides assume that the {@code

+ *

+ * Note this hidden field is introduced in order to signal the existence of the field in the + * form being sent, + * even if the checkbox is unchecked (no URL parameter is added for unchecked check boxes). + *

+ *

+ * This flag is set to {@code false} by default. + *

+ * + * @return {@code true} if hidden markers should be rendered before the checkboxes, {@code + * false} if not. + * @since 3.0.10 + */ + public boolean getRenderHiddenMarkersBeforeCheckboxes() { + final Set dialects = getDialects(); + for (final IDialect dialect : dialects) { + if (dialect instanceof SpringStandardDialect) { + return ((SpringStandardDialect) dialect).getRenderHiddenMarkersBeforeCheckboxes(); + } + } + return false; + } + + + /** + *

+ * Sets whether the {@code } marker tags rendered to signal the + * presence + * of checkboxes in forms when unchecked should be rendered before the checkbox tag + * itself, + * or after (default). + *

+ *

+ * (This is just a convenience method, equivalent to calling + * {@link SpringStandardDialect#setRenderHiddenMarkersBeforeCheckboxes(boolean)} on the + * dialect instance + * itself. It is provided here in order to allow users to modify this behaviour without + * having to directly create instances of the {@link SpringStandardDialect}) + *

+ *

+ * A number of CSS frameworks and style guides assume that the {@code

+ *

+ * Note this hidden field is introduced in order to signal the existence of the field in the + * form being sent, + * even if the checkbox is unchecked (no URL parameter is added for unchecked check boxes). + *

+ *

+ * This flag is set to {@code false} by default. + *

+ * + * @param renderHiddenMarkersBeforeCheckboxes {@code true} if hidden markers should be rendered + * before the checkboxes, {@code false} if not. + * @since 3.0.10 + */ + public void setRenderHiddenMarkersBeforeCheckboxes( + final boolean renderHiddenMarkersBeforeCheckboxes) { + final Set dialects = getDialects(); + for (final IDialect dialect : dialects) { + if (dialect instanceof SpringStandardDialect) { + ((SpringStandardDialect) dialect).setRenderHiddenMarkersBeforeCheckboxes( + renderHiddenMarkersBeforeCheckboxes); + } + } + } + + + @Override + protected void initializeSpecific() { + + // First of all, give the opportunity to subclasses to apply their own configurations + initializeSpringSpecific(); + + // Once the subclasses have had their opportunity, compute configurations belonging to + // SpringTemplateEngine + super.initializeSpecific(); + + final MessageSource messageSource = + this.templateEngineMessageSource == null ? this.messageSource + : this.templateEngineMessageSource; + + final IMessageResolver messageResolver; + if (messageSource != null) { + final SpringMessageResolver springMessageResolver = new SpringMessageResolver(); + springMessageResolver.setMessageSource(messageSource); + messageResolver = springMessageResolver; + } else { + messageResolver = new StandardMessageResolver(); + } + + super.addMessageResolver(messageResolver); + + } + + + /** + *

+ * This method performs additional initializations required for a + * {@code SpringTemplateEngine} subclass instance. This method + * is called before the first execution of + * {@link TemplateEngine#process(String, org.thymeleaf.context.IContext)} + * or {@link TemplateEngine#processThrottled(String, org.thymeleaf.context.IContext)} + * in order to create all the structures required for a quick execution of + * templates. + *

+ *

+ * THIS METHOD IS INTERNAL AND SHOULD NEVER BE CALLED DIRECTLY. + *

+ *

+ * The implementation of this method does nothing, and it is designed + * for being overridden by subclasses of {@code SpringTemplateEngine}. + *

+ */ + protected void initializeSpringSpecific() { + // Nothing to be executed here. Meant for extension + } + +} diff --git a/src/main/java/run/halo/app/theme/engine/SpringWebFluxTemplateEngine.java b/src/main/java/run/halo/app/theme/engine/SpringWebFluxTemplateEngine.java new file mode 100644 index 000000000..6a6328a98 --- /dev/null +++ b/src/main/java/run/halo/app/theme/engine/SpringWebFluxTemplateEngine.java @@ -0,0 +1,1010 @@ +package run.halo.app.theme.engine; + +import static run.halo.app.theme.engine.SpringWebFluxTemplateEngine.DataDrivenFluxStep.FluxStepPhase.DATA_DRIVEN_PHASE_BUFFER; +import static run.halo.app.theme.engine.SpringWebFluxTemplateEngine.DataDrivenFluxStep.FluxStepPhase.DATA_DRIVEN_PHASE_HEAD; +import static run.halo.app.theme.engine.SpringWebFluxTemplateEngine.DataDrivenFluxStep.FluxStepPhase.DATA_DRIVEN_PHASE_TAIL; + +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.nio.charset.Charset; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.logging.Level; +import org.reactivestreams.Publisher; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.http.MediaType; +import org.thymeleaf.IThrottledTemplateProcessor; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.TemplateSpec; +import org.thymeleaf.context.IContext; +import org.thymeleaf.context.IEngineContext; +import org.thymeleaf.context.IWebContext; +import org.thymeleaf.engine.DataDrivenTemplateIterator; +import org.thymeleaf.engine.ISSEThrottledTemplateWriterControl; +import org.thymeleaf.engine.IThrottledTemplateWriterControl; +import org.thymeleaf.engine.ThrottledTemplateProcessor; +import org.thymeleaf.exceptions.TemplateProcessingException; +import org.thymeleaf.spring6.ISpringWebFluxTemplateEngine; +import org.thymeleaf.spring6.context.Contexts; +import org.thymeleaf.spring6.context.webflux.IReactiveDataDriverContextVariable; +import org.thymeleaf.spring6.context.webflux.IReactiveSSEDataDriverContextVariable; +import org.thymeleaf.util.LoggingUtils; +import org.thymeleaf.web.IWebExchange; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + + +/** + *

+ * Standard implementation of {@link ISpringWebFluxTemplateEngine}, and default + * template engine implementation to be used in Spring WebFlux environments. + *

+ * Code from + * thymeleaf SpringWebFluxTemplateEngine + * + * @author Daniel Fernández + * @see ISpringWebFluxTemplateEngine + * @see org.thymeleaf.spring6.SpringWebFluxTemplateEngine + * @since 2.0.0 + */ +public class SpringWebFluxTemplateEngine extends SpringTemplateEngine + implements ISpringWebFluxTemplateEngine { + + private static final Logger logger = LoggerFactory.getLogger(SpringWebFluxTemplateEngine.class); + private static final String LOG_CATEGORY_FULL_OUTPUT = + SpringWebFluxTemplateEngine.class.getName() + ".DOWNSTREAM.FULL"; + private static final String LOG_CATEGORY_CHUNKED_OUTPUT = + SpringWebFluxTemplateEngine.class.getName() + ".DOWNSTREAM.CHUNKED"; + private static final String LOG_CATEGORY_DATADRIVEN_INPUT = + SpringWebFluxTemplateEngine.class.getName() + ".UPSTREAM.DATA-DRIVEN"; + private static final String LOG_CATEGORY_DATADRIVEN_OUTPUT = + SpringWebFluxTemplateEngine.class.getName() + ".DOWNSTREAM.DATA-DRIVEN"; + + + public SpringWebFluxTemplateEngine() { + super(); + } + + + @Override + public Publisher processStream( + final String template, final Set markupSelectors, final IContext context, + final DataBufferFactory bufferFactory, final MediaType mediaType, final Charset charset) { + return processStream(template, markupSelectors, context, bufferFactory, mediaType, charset, + Integer.MAX_VALUE); + } + + + @Override + public Publisher processStream( + final String template, final Set markupSelectors, final IContext context, + final DataBufferFactory bufferFactory, final MediaType mediaType, final Charset charset, + final int responseMaxChunkSizeBytes) { + + /* + * PERFORM VALIDATIONS + */ + if (template == null) { + return Flux.error(new IllegalArgumentException("Template cannot be null")); + } + if (context == null) { + return Flux.error(new IllegalArgumentException("Context cannot be null")); + } + if (bufferFactory == null) { + return Flux.error(new IllegalArgumentException("Buffer Factory cannot be null")); + } + if (mediaType == null) { + return Flux.error(new IllegalArgumentException("Media Type cannot be null")); + } + if (charset == null) { + return Flux.error(new IllegalArgumentException("Charset cannot be null")); + } + + if (responseMaxChunkSizeBytes == 0) { + return Flux.error(new IllegalArgumentException("Max Chunk Size cannot be zero")); + } + + // Normalize the chunk size in bytes (MAX_VALUE == no limit) + final int chunkSizeBytes = + (responseMaxChunkSizeBytes < 0 ? Integer.MAX_VALUE : responseMaxChunkSizeBytes); + + // Determine whether we have been asked to return data as SSE (Server-Sent Events) + final boolean sse = MediaType.TEXT_EVENT_STREAM.includes(mediaType); + + /* + * CHECK FOR DATA-DRIVEN EXECUTION + */ + try { + final String dataDriverVariableName = findDataDriverInModel(context); + if (dataDriverVariableName != null) { + // We should be executing in data-driven mode + return createDataDrivenStream( + template, markupSelectors, context, dataDriverVariableName, bufferFactory, + charset, chunkSizeBytes, sse); + } + } catch (final Throwable t) { + return Flux.error(t); + } + + // Check if we need to fail here: If SSE has been requested, a data-driver variable is + // mandatory + if (sse) { + return Flux.error(new TemplateProcessingException( + "SSE mode has been requested ('Accept: text/event-stream') but no data-driver " + + "variable has been " + + + "added to the model/context. In order to perform SSE rendering, a variable " + + "implementing the " + + + IReactiveDataDriverContextVariable.class.getName() + + " interface is required.")); + } + + /* + * IS THERE A LIMIT IN BUFFER SIZE? if not, given we are not data-driven, we should + * switch to FULL + */ + if (chunkSizeBytes == Integer.MAX_VALUE) { + // No limit on buffer size, so there is no reason to throttle: using FULL mode instead. + return createFullStream(template, markupSelectors, context, bufferFactory, charset); + } + + /* + * CREATE A CHUNKED STREAM + */ + return createChunkedStream( + template, markupSelectors, context, bufferFactory, charset, responseMaxChunkSizeBytes); + + } + + + private Mono createFullStream( + final String templateName, final Set markupSelectors, final IContext context, + final DataBufferFactory bufferFactory, final Charset charset) { + + final Mono stream = + Mono.create( + subscriber -> { + + if (logger.isTraceEnabled()) { + logger.trace( + "[THYMELEAF][{}] STARTING STREAM PROCESS (FULL MODE) OF TEMPLATE " + + "\"{}\" WITH LOCALE {}", + new Object[] {TemplateEngine.threadIndex(), + LoggingUtils.loggifyTemplateName(templateName), + context.getLocale()}); + } + + final DataBuffer dataBuffer = bufferFactory.allocateBuffer(); + // OutputStreamWriter object have an 8K buffer, but process(...) will flush + // it at the end + final OutputStreamWriter + writer = new OutputStreamWriter(dataBuffer.asOutputStream(), charset); + + try { + + process(templateName, markupSelectors, context, writer); + + } catch (final Throwable t) { + logger.error( + String.format( + "[THYMELEAF][%s] Exception processing template \"%s\": %s", + new Object[] {TemplateEngine.threadIndex(), + LoggingUtils.loggifyTemplateName(templateName), + t.getMessage()}), + t); + subscriber.error(t); + return; + } + + final int bytesProduced = dataBuffer.readableByteCount(); + + if (logger.isTraceEnabled()) { + logger.trace( + "[THYMELEAF][{}] FINISHED STREAM PROCESS (FULL MODE) OF TEMPLATE " + + "\"{}\" WITH LOCALE {}. PRODUCED {} BYTES", + new Object[] { + TemplateEngine.threadIndex(), + LoggingUtils.loggifyTemplateName(templateName), + context.getLocale(), Integer.valueOf(bytesProduced)}); + } + + // This is a Mono, so no need to call "next()" or "complete()" + subscriber.success(dataBuffer); + + }); + + // Will add some logging to the data stream + return stream.log(LOG_CATEGORY_FULL_OUTPUT, Level.FINEST); + + } + + + private Flux createChunkedStream( + final String templateName, final Set markupSelectors, final IContext context, + final DataBufferFactory bufferFactory, final Charset charset, + final int responseMaxChunkSizeBytes) { + + final Flux stream = Flux.generate( + + // Using the throttledProcessor as state in this Flux.generate allows us to delay the + // initialization of the throttled processor until the last moment, when output + // generation + // is really requested. + // NOTE 'sse' is specified as 'false' because SSE is only allowed in data-driven mode + // . Also, no + // data-driven iterator is available (we are in chunked mode). + () -> new SpringWebFluxTemplateEngine.StreamThrottledTemplateProcessor( + processThrottled(templateName, markupSelectors, context), null, null, 0L, false), + + // This stream will execute, in a one-by-one (non-interleaved) fashion, the following + // code + // for each back-pressure request coming from downstream. Each of these steps + // (chunks) will + // execute the throttled processor once and return its result as a DataBuffer object. + (throttledProcessor, emitter) -> { + + throttledProcessor.startChunk(); + + if (logger.isTraceEnabled()) { + logger.trace( + "[THYMELEAF][{}][{}] STARTING PARTIAL STREAM PROCESS (CHUNKED MODE, " + + "THROTTLER ID " + + + "\"{}\", CHUNK {}) FOR TEMPLATE \"{}\" WITH LOCALE {}", + new Object[] { + TemplateEngine.threadIndex(), + throttledProcessor.getProcessorIdentifier(), + throttledProcessor.getProcessorIdentifier(), + Integer.valueOf(throttledProcessor.getChunkCount()), + LoggingUtils.loggifyTemplateName(templateName), context.getLocale()}); + } + + final DataBuffer buffer = bufferFactory.allocateBuffer(responseMaxChunkSizeBytes); + + final int bytesProduced; + try { + bytesProduced = + throttledProcessor.process(responseMaxChunkSizeBytes, + buffer.asOutputStream(), charset); + } catch (final Throwable t) { + emitter.error(t); + return null; + } + + if (logger.isTraceEnabled()) { + logger.trace( + "[THYMELEAF][{}][{}] FINISHED PARTIAL STREAM PROCESS (CHUNKED MODE, " + + "THROTTLER ID " + + + "\"{}\", CHUNK {}) FOR TEMPLATE \"{}\" WITH LOCALE {}. PRODUCED {} " + + "BYTES", + new Object[] { + TemplateEngine.threadIndex(), + throttledProcessor.getProcessorIdentifier(), + throttledProcessor.getProcessorIdentifier(), + Integer.valueOf(throttledProcessor.getChunkCount()), + LoggingUtils.loggifyTemplateName(templateName), context.getLocale(), + Integer.valueOf(bytesProduced)}); + } + + emitter.next(buffer); + + if (throttledProcessor.isFinished()) { + + if (logger.isTraceEnabled()) { + logger.trace( + "[THYMELEAF][{}][{}] FINISHED ALL STREAM PROCESS (CHUNKED MODE, " + + "THROTTLER ID " + + + "\"{}\") FOR TEMPLATE \"{}\" WITH LOCALE {}. PRODUCED A TOTAL OF " + + "{} BYTES IN {} CHUNKS", + new Object[] { + TemplateEngine.threadIndex(), + throttledProcessor.getProcessorIdentifier(), + throttledProcessor.getProcessorIdentifier(), + LoggingUtils.loggifyTemplateName(templateName), context.getLocale(), + Long.valueOf(throttledProcessor.getTotalBytesProduced()), + Integer.valueOf(throttledProcessor.getChunkCount() + 1)}); + } + + emitter.complete(); + + } + + return throttledProcessor; + + }); + + // Will add some logging to the data stream + return stream.log(LOG_CATEGORY_CHUNKED_OUTPUT, Level.FINEST); + + } + + + private Flux createDataDrivenStream( + final String templateName, final Set markupSelectors, final IContext context, + final String dataDriverVariableName, final DataBufferFactory bufferFactory, + final Charset charset, + final int responseMaxChunkSizeBytes, final boolean sse) { + + // STEP 1: Obtain the data-driver variable and its metadata + final IReactiveDataDriverContextVariable dataDriver = + (IReactiveDataDriverContextVariable) context.getVariable(dataDriverVariableName); + final int bufferSizeElements = dataDriver.getBufferSizeElements(); + final String sseEventsPrefix = + (dataDriver instanceof IReactiveSSEDataDriverContextVariable + ? ((IReactiveSSEDataDriverContextVariable) dataDriver).getSseEventsPrefix() : null); + final long sseEventsID = + (dataDriver instanceof IReactiveSSEDataDriverContextVariable + ? ((IReactiveSSEDataDriverContextVariable) dataDriver).getSseEventsFirstID() : 0L); + final ReactiveAdapterRegistry reactiveAdapterRegistry; + if (Contexts.isSpringWebFluxWebContext(context)) { + reactiveAdapterRegistry = + Contexts.getSpringWebFluxWebExchange(context).getApplication() + .getReactiveAdapterRegistry(); + } else { + reactiveAdapterRegistry = null; + } + + + // STEP 2: Replace the data driver variable with a DataDrivenTemplateIterator + final DataDrivenTemplateIterator dataDrivenIterator = new DataDrivenTemplateIterator(); + final IContext wrappedContext = + applyDataDriverWrapper(context, dataDriverVariableName, dataDrivenIterator); + + + // STEP 3: Create the data stream buffers, plus add some logging in order to know how the + // stream is being used + final Flux> dataDrivenBufferedStream = + Flux.from(dataDriver.getDataStream(reactiveAdapterRegistry)) + .buffer(bufferSizeElements) + .log(LOG_CATEGORY_DATADRIVEN_INPUT, Level.FINEST); + + + // STEP 4: Initialize the (throttled) template engine for each subscriber (normally there + // will only be one) + final Flux dataDrivenWithContextStream = + Flux.using( + + // Using the throttledProcessor as state in this Flux.using allows us to delay the + // initialization of the throttled processor until the last moment, when output + // generation + // is really requested. + () -> { + final String outputContentType = sse ? "text/event-stream" : null; + final TemplateSpec templateSpec = + new TemplateSpec(templateName, markupSelectors, outputContentType, null); + return new SpringWebFluxTemplateEngine.StreamThrottledTemplateProcessor( + processThrottled(templateSpec, wrappedContext), dataDrivenIterator, + sseEventsPrefix, sseEventsID, sse); + }, + + // This flux will be made by concatenating a phase for the head (template before + // data-driven + // iteration), another phase composed of most possibly several steps for the + // data-driven iteration, + // and finally a tail phase (template after data-driven iteration). + // + // But this concatenation will be done from a Flux created with Flux.generate, so + // that we have the + // opportunity to check if the processor has already signaled that it has + // finished, and in such + // case we might be able to avoid the subscription to the upstream data driver if + // its iteration is + // not needed at the template. + throttledProcessor -> Flux.concat(Flux.generate( + () -> DATA_DRIVEN_PHASE_HEAD, + (phase, emitter) -> { + + // Check if the processor has already signaled it has finished, in which + // case we + // might be able to avoid the BUFFER phase (if no iteration of the + // data-driver is present). + // + // *NOTE* we CANNOT GUARANTEE that this will stop the upstream data + // driver publisher from + // being subscribed to or even consumed, because there is no guarantee + // that this code + // will be executed for the BUFFER phase after the entire Flux generated + // downstream + // for the HEAD phase (see STEP 5 in the stream being built). Actually, + // it might even be + // executed concurrently to one of the steps of a Flux for the + // HEAD/BUFFER phases, which + // is why the IThrottledProcessor.isFinished() called here needs to be + // thread-safe. + if (throttledProcessor.isFinished()) { + // We can short-cut, and if we are lucky even avoid the BUFFER phase. + emitter.complete(); + return null; + } + + switch (phase) { + + case DATA_DRIVEN_PHASE_HEAD: + emitter.next(Mono.just( + DataDrivenFluxStep.forHead( + throttledProcessor))); + return DATA_DRIVEN_PHASE_BUFFER; + + case DATA_DRIVEN_PHASE_BUFFER: + emitter.next(dataDrivenBufferedStream.map( + values -> DataDrivenFluxStep.forBuffer( + throttledProcessor, values))); + return DATA_DRIVEN_PHASE_TAIL; + + case DATA_DRIVEN_PHASE_TAIL: + emitter.next(Mono.just( + DataDrivenFluxStep.forTail( + throttledProcessor))); + emitter.complete(); + break; + default: + return null; + } + + return null; + + } + )), + + // No need to explicitly dispose the throttled template processor. + throttledProcessor -> { /* Nothing to be done here! */ }); + + + // STEP 5: React to each buffer of published data by creating one or many (concatMap) + // DataBuffers containing + // the result of processing only that buffer. + final Flux stream = dataDrivenWithContextStream.concatMap( + (step) -> Flux.generate( + + // We set initialize to TRUE as a state, so that the first step executed for this + // Flux + // performs the initialization of the dataDrivenIterator for the entire Flux. It + // is a need + // that this initialization is performed when the first step of this Flux is + // executed, + // because initialization actually consists of a lateral effect on a mutable + // variable + // (the dataDrivenIterator). And this way we are certain that it is executed in the + // right order, given concatMap guarantees to us that these Fluxes generated here + // will + // be consumed in the right order and executed one at a time (and the Reactor + // guarantees us + // that there will be no thread visibility issues between Flux steps). + () -> Boolean.TRUE, + + // The first time this is executed, initialize will be TRUE. From then on, it + // will be FALSE + // so that it is the first execution of this that initializes the (mutable) + // dataDrivenIterator. + (initialize, emitter) -> { + + final SpringWebFluxTemplateEngine.StreamThrottledTemplateProcessor + throttledProcessor = step.getThrottledProcessor(); + final DataDrivenTemplateIterator dataDrivenTemplateIterator = + throttledProcessor.getDataDrivenTemplateIterator(); + + // Let's check if we can short cut and simply finish execution. Maybe we can + // avoid consuming + // the data from the upstream data-driver publisher (e.g. if the data-driver + // variable is + // never actually iterated). + if (throttledProcessor.isFinished()) { + emitter.complete(); + return Boolean.FALSE; + } + + // Initialize the dataDrivenIterator. This is a lateral effect, this variable + // is mutable, + // so it is important to do it here so that we make sure it is executed in + // the right order. + if (initialize.booleanValue()) { + + if (step.isHead()) { + // Feed with no elements - we just want to output the part of the + // template that goes before the iteration of the data driver. + dataDrivenTemplateIterator.startHead(); + } else if (step.isDataBuffer()) { + // Value-based execution: we have values and we want to iterate them + dataDrivenTemplateIterator.feedBuffer(step.getValues()); + } else { // step.isTail() + // Signal feeding complete, indicating this is just meant to output the + // rest of the template after the iteration of the data driver. Note + // there + // is a case when this phase will still provoke the output of an + // iteration, + // and this is when the number of iterations is exactly ONE. In this + // case, + // it won't be possible to determine the iteration type (ZERO, ONE, + // MULTIPLE) + // until we close it with this 'feedingComplete()' + dataDrivenTemplateIterator.feedingComplete(); + dataDrivenTemplateIterator.startTail(); + } + + } + + // Signal the start of a new chunk (we are counting them for the logs) + throttledProcessor.startChunk(); + + if (logger.isTraceEnabled()) { + logger.trace( + "[THYMELEAF][{}][{}] STARTING PARTIAL STREAM PROCESS (DATA-DRIVEN " + + "MODE, THROTTLER ID " + + + "\"{}\", CHUNK {}) FOR TEMPLATE \"{}\" WITH LOCALE {}", + new Object[] { + TemplateEngine.threadIndex(), + throttledProcessor.getProcessorIdentifier(), + throttledProcessor.getProcessorIdentifier(), + Integer.valueOf(throttledProcessor.getChunkCount()), + LoggingUtils.loggifyTemplateName(templateName), + context.getLocale()}); + } + + final DataBuffer buffer = + (responseMaxChunkSizeBytes != Integer.MAX_VALUE + ? bufferFactory.allocateBuffer(responseMaxChunkSizeBytes) : + bufferFactory.allocateBuffer()); + + final int bytesProduced; + try { + + bytesProduced = + throttledProcessor.process(responseMaxChunkSizeBytes, + buffer.asOutputStream(), charset); + + } catch (final Throwable t) { + emitter.error(t); + return Boolean.FALSE; + } + + + if (logger.isTraceEnabled()) { + logger.trace( + "[THYMELEAF][{}][{}] FINISHED PARTIAL STREAM PROCESS (DATA-DRIVEN " + + "MODE, THROTTLER ID " + + + "\"{}\", CHUNK {}) FOR TEMPLATE \"{}\" WITH LOCALE {}. PRODUCED " + + "{} BYTES", + new Object[] { + TemplateEngine.threadIndex(), + throttledProcessor.getProcessorIdentifier(), + throttledProcessor.getProcessorIdentifier(), + Integer.valueOf(throttledProcessor.getChunkCount()), + LoggingUtils.loggifyTemplateName(templateName), context.getLocale(), + Integer.valueOf(bytesProduced)}); + } + + + // If we produced no bytes, then let's avoid skipping an event number from + // the sequence + if (bytesProduced == 0) { + dataDrivenTemplateIterator.takeBackLastEventID(); + } + + + // Now it's time to determine if we should execute another time for the same + // data-driven step or rather we should consider we have done everything + // possible + // for this step (e.g. produced all markup for a data stream buffer) and just + // emit "complete" and go for the next step. + boolean phaseFinished = false; + if (throttledProcessor.isFinished()) { + + if (logger.isTraceEnabled()) { + logger.trace( + "[THYMELEAF][{}][{}] FINISHED ALL STREAM PROCESS (DATA-DRIVEN " + + "MODE, THROTTLER ID " + + + "\"{}\") FOR TEMPLATE \"{}\" WITH LOCALE {}. PRODUCED A TOTAL" + + " OF {} BYTES IN {} CHUNKS", + new Object[] { + TemplateEngine.threadIndex(), + throttledProcessor.getProcessorIdentifier(), + throttledProcessor.getProcessorIdentifier(), + LoggingUtils.loggifyTemplateName(templateName), + context.getLocale(), + Long.valueOf(throttledProcessor.getTotalBytesProduced()), + Integer.valueOf(throttledProcessor.getChunkCount() + 1)}); + } + + // We have finished executing the template, which can happen after + // finishing iterating all data driver values, or also if we are at the + // first execution and there was no need to use the data driver at all + phaseFinished = true; + dataDrivenTemplateIterator.finishStep(); + + } else { + + if (step.isHead() && dataDrivenTemplateIterator.hasBeenQueried()) { + + // We know everything before the data driven iteration has already been + // processed because the iterator has been used at least once (i.e. its + // 'hasNext()' or 'next()' method have been called at least once). + // This will + // mean we can switch to the buffer phase. + phaseFinished = true; + dataDrivenTemplateIterator.finishStep(); + + } else if (step.isDataBuffer() + && !dataDrivenTemplateIterator.continueBufferExecution()) { + // We have finished executing this buffer of items and we can go for the + // next one or maybe the tail. + phaseFinished = true; + } + // fluxStep.isTail(): nothing to do, as the only reason we would have to + // emit + // 'complete' at the tail step would be throttledProcessor.isFinished(), + // which + // has been already checked. + + } + + // Compute if the output for this step has been already finished (i.e. not + // only the + // processing of the model's events, but also any existing overflows). This + // has to be + // queried BEFORE the buffer is emitted. + final boolean stepOutputFinished = + dataDrivenTemplateIterator.isStepOutputFinished(); + + // Buffer has now everything it should, so send it to the output channels + emitter.next(buffer); + + + // If step finished, we have ot emit 'complete' now, giving the opportunity + // to execute + // again if processing has finished, but we still have some overflow to be + // flushed + if (phaseFinished && stepOutputFinished) { + emitter.complete(); + } + + + return Boolean.FALSE; + + })); + + + // Will add some logging to the data flow + return stream.log(LOG_CATEGORY_DATADRIVEN_OUTPUT, Level.FINEST); + + } + + + /* + * This method will apply a wrapper on the data driver variable so that a + * DataDrivenTemplateIterator takes + * the place of the original data-driver variable. This is done via a wrapper in order to not + * perform such a + * strong modification on the original context object. Even if context objects should not be + * reused among template + * engine executions, when a non-IEngineContext implementation is used we will let that + * degree of liberty to the + * user just in case. + */ + private static IContext applyDataDriverWrapper( + final IContext context, final String dataDriverVariableName, + final DataDrivenTemplateIterator dataDrivenTemplateIterator) { + + // This is an IEngineContext, a very internal, low-level context implementation, so let's + // simply modify it + if (context instanceof IEngineContext) { + ((IEngineContext) context).setVariable(dataDriverVariableName, + dataDrivenTemplateIterator); + return context; + } + + // Not an IEngineContext, but might still be an IWebContext, and we don't want to lose + // that info + if (Contexts.isWebContext(context)) { + return new SpringWebFluxTemplateEngine.DataDrivenWebContextWrapper( + Contexts.asWebContext(context), dataDriverVariableName, dataDrivenTemplateIterator); + } + + // Not a recognized context interface: just use a default implementation + return new SpringWebFluxTemplateEngine.DataDrivenContextWrapper(context, + dataDriverVariableName, dataDrivenTemplateIterator); + + + } + + + private static String findDataDriverInModel(final IContext context) { + + // In SpringWebFluxContext (used most of the times), variables are backed by a + // Map. So this iteration on all the names and many "getVariable()" calls + // shouldn't be an issue perf-wise. + + String dataDriverVariableName = null; + final Set contextVariableNames = context.getVariableNames(); + + for (final String contextVariableName : contextVariableNames) { + + final Object contextVariableValue = context.getVariable(contextVariableName); + if (contextVariableValue instanceof IReactiveDataDriverContextVariable) { + if (dataDriverVariableName != null) { + throw new TemplateProcessingException( + "Only one data-driver variable is allowed to be specified as a model " + + "attribute, but " + + "at least two have been identified: '" + dataDriverVariableName + "' " + + "and '" + contextVariableName + "'"); + } + dataDriverVariableName = contextVariableName; + } + + } + + return dataDriverVariableName; + + } + + + /* + * This internal class is meant to be used in multi-step streams so that an account on the total + * number of bytes and steps/chunks can be kept, and also other aspects such as SSE event + * management can be offered. + * + * NOTE there is no need to synchronize these variables, even if different steps/chunks might + * be executed + * (non-concurrently) by different threads, because Reactive Streams implementations like + * Reactor should + * take care to establish the adequate thread synchronization/memory barriers at their + * asynchronous boundaries, + * thus avoiding thread visibility issues. + */ + static class StreamThrottledTemplateProcessor { + + private final IThrottledTemplateProcessor throttledProcessor; + private final DataDrivenTemplateIterator dataDrivenTemplateIterator; + private int chunkCount; + private long totalBytesProduced; + + StreamThrottledTemplateProcessor( + final IThrottledTemplateProcessor throttledProcessor, + final DataDrivenTemplateIterator dataDrivenTemplateIterator, + final String sseEventsPrefix, final long sseEventsFirstID, final boolean sse) { + + super(); + + this.throttledProcessor = throttledProcessor; + this.dataDrivenTemplateIterator = dataDrivenTemplateIterator; + + final IThrottledTemplateWriterControl writerControl; + if (this.throttledProcessor instanceof ThrottledTemplateProcessor) { + writerControl = ((ThrottledTemplateProcessor) + this.throttledProcessor).getThrottledTemplateWriterControl(); + } else { + writerControl = null; + } + + if (sse) { + if (writerControl == null + || !(writerControl instanceof ISSEThrottledTemplateWriterControl)) { + throw new TemplateProcessingException( + "Cannot process template in Server-Sent Events (SSE) mode: template " + + "writer is not SSE capable. " + + + "Either SSE content type has not been declared at the " + + TemplateSpec.class.getSimpleName() + " or " + + "an implementation of " + IThrottledTemplateProcessor.class.getName() + + " other than " + + ThrottledTemplateProcessor.class.getName() + " is being used."); + } + if (this.dataDrivenTemplateIterator == null) { + throw new TemplateProcessingException( + "Cannot process template in Server-Sent Events (SSE) mode: a data-driven " + + "template iterator " + + "is required in context in order to apply SSE."); + } + } + + if (this.dataDrivenTemplateIterator != null) { + this.dataDrivenTemplateIterator.setWriterControl(writerControl); + this.dataDrivenTemplateIterator.setSseEventsPrefix(sseEventsPrefix); + this.dataDrivenTemplateIterator.setSseEventsFirstID(sseEventsFirstID); + } + + this.chunkCount = -1; // First chunk will be considered number 0 + this.totalBytesProduced = 0L; + + } + + int process(final int maxOutputInBytes, final OutputStream outputStream, + final Charset charset) { + final int chunkBytes = + this.throttledProcessor.process(maxOutputInBytes, outputStream, charset); + this.totalBytesProduced += chunkBytes; + return chunkBytes; + } + + String getProcessorIdentifier() { + return this.throttledProcessor.getProcessorIdentifier(); + } + + boolean isFinished() { + return this.throttledProcessor.isFinished(); + } + + void startChunk() { + this.chunkCount++; + } + + int getChunkCount() { + return this.chunkCount; + } + + long getTotalBytesProduced() { + return this.totalBytesProduced; + } + + DataDrivenTemplateIterator getDataDrivenTemplateIterator() { + return this.dataDrivenTemplateIterator; + } + + } + + + /* + * This internal class is used for keeping the accounting of the different phases in a + * data-driven stream: + * head (no value, template before the data-driven iteration), buffer (values, data-driven + * iteration), and + * tail (no value, template after the data-driven iteration). + * + * NOTE there is no need to synchronize these variables, even if different steps/chunks might + * be executed + * (non-concurrently) by different threads, because Reactive Streams implementations like + * Reactor should + * take care to establish the adequate thread synchronization/memory barriers at their + * asynchronous boundaries, + * thus avoiding thread visibility issues. + */ + static final class DataDrivenFluxStep { + + enum FluxStepPhase { + DATA_DRIVEN_PHASE_HEAD, DATA_DRIVEN_PHASE_BUFFER, DATA_DRIVEN_PHASE_TAIL + } + + private final SpringWebFluxTemplateEngine.StreamThrottledTemplateProcessor + throttledProcessor; + private final List values; + private final SpringWebFluxTemplateEngine.DataDrivenFluxStep.FluxStepPhase phase; + + + static SpringWebFluxTemplateEngine.DataDrivenFluxStep forHead( + final SpringWebFluxTemplateEngine.StreamThrottledTemplateProcessor throttledProcessor) { + return new SpringWebFluxTemplateEngine.DataDrivenFluxStep(throttledProcessor, null, + DATA_DRIVEN_PHASE_HEAD); + } + + static SpringWebFluxTemplateEngine.DataDrivenFluxStep forBuffer( + final SpringWebFluxTemplateEngine.StreamThrottledTemplateProcessor throttledProcessor, + final List values) { + return new SpringWebFluxTemplateEngine.DataDrivenFluxStep(throttledProcessor, values, + DATA_DRIVEN_PHASE_BUFFER); + } + + static SpringWebFluxTemplateEngine.DataDrivenFluxStep forTail( + final SpringWebFluxTemplateEngine.StreamThrottledTemplateProcessor throttledProcessor) { + return new SpringWebFluxTemplateEngine.DataDrivenFluxStep(throttledProcessor, null, + DATA_DRIVEN_PHASE_TAIL); + } + + private DataDrivenFluxStep( + final SpringWebFluxTemplateEngine.StreamThrottledTemplateProcessor throttledProcessor, + final List values, + final SpringWebFluxTemplateEngine.DataDrivenFluxStep.FluxStepPhase phase) { + super(); + this.throttledProcessor = throttledProcessor; + this.values = values; + this.phase = phase; + } + + SpringWebFluxTemplateEngine.StreamThrottledTemplateProcessor getThrottledProcessor() { + return this.throttledProcessor; + } + + List getValues() { + return this.values; + } + + boolean isHead() { + return this.phase == DATA_DRIVEN_PHASE_HEAD; + } + + boolean isDataBuffer() { + return this.phase == DATA_DRIVEN_PHASE_BUFFER; + } + + boolean isTail() { + return this.phase == DATA_DRIVEN_PHASE_TAIL; + } + + } + + + /* + * This wrapper of an IWebContext is meant to wrap the original context object sent to the + * template engine while hiding the data driver variable, returning a + * DataDrivenTemplateIterator in its place. + */ + static class DataDrivenWebContextWrapper + extends SpringWebFluxTemplateEngine.DataDrivenContextWrapper implements IWebContext { + + private final IWebContext context; + + DataDrivenWebContextWrapper( + final IWebContext context, final String dataDriverVariableName, + final DataDrivenTemplateIterator dataDrivenTemplateIterator) { + super(context, dataDriverVariableName, dataDrivenTemplateIterator); + this.context = context; + } + + @Override + public IWebExchange getExchange() { + return this.context.getExchange(); + } + + } + + + /* + * This wrapper of an IContext (non-SpringWebFlux-specific) is meant to wrap the original + * context object sent + * to the template engine while hiding the data driver variable, returning a + * DataDrivenTemplateIterator in + * its place. + */ + static class DataDrivenContextWrapper implements IContext { + + private final IContext context; + private final String dataDriverVariableName; + private final DataDrivenTemplateIterator dataDrivenTemplateIterator; + + DataDrivenContextWrapper( + final IContext context, final String dataDriverVariableName, + final DataDrivenTemplateIterator dataDrivenTemplateIterator) { + super(); + this.context = context; + this.dataDriverVariableName = dataDriverVariableName; + this.dataDrivenTemplateIterator = dataDrivenTemplateIterator; + } + + public IContext getWrappedContext() { + return this.context; + } + + @Override + public Locale getLocale() { + return this.context.getLocale(); + } + + @Override + public boolean containsVariable(final String name) { + return this.context.containsVariable(name); + } + + @Override + public Set getVariableNames() { + return this.context.getVariableNames(); + } + + @Override + public Object getVariable(final String name) { + if (this.dataDriverVariableName.equals(name)) { + return this.dataDrivenTemplateIterator; + } + return this.context.getVariable(name); + } + + } +} \ No newline at end of file diff --git a/src/main/java/run/halo/app/theme/message/ThemeMessageResolutionUtils.java b/src/main/java/run/halo/app/theme/message/ThemeMessageResolutionUtils.java new file mode 100644 index 000000000..d6943dfb9 --- /dev/null +++ b/src/main/java/run/halo/app/theme/message/ThemeMessageResolutionUtils.java @@ -0,0 +1,261 @@ +package run.halo.app.theme.message; + +import java.io.BufferedInputStream; +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Properties; +import org.springframework.lang.Nullable; +import org.thymeleaf.exceptions.TemplateInputException; +import org.thymeleaf.exceptions.TemplateProcessingException; +import org.thymeleaf.util.StringUtils; +import run.halo.app.theme.ThemeContext; + +/** + * @author guqing + * @since 2.0.0 + */ +public class ThemeMessageResolutionUtils { + + private static final Map EMPTY_MESSAGES = Collections.emptyMap(); + private static final String PROPERTIES_FILE_EXTENSION = ".properties"; + private static final String LOCATION = "i18n"; + private static final Object[] EMPTY_MESSAGE_PARAMETERS = new Object[0]; + + @Nullable + private static Reader messageReader(String messageResourceName, ThemeContext theme) + throws FileNotFoundException { + var themePath = theme.getPath(); + File messageFile = themePath.resolve(messageResourceName).toFile(); + if (!messageFile.exists()) { + return null; + } + final InputStream inputStream = new FileInputStream(messageFile); + return new BufferedReader(new InputStreamReader(new BufferedInputStream(inputStream))); + } + + public static Map resolveMessagesForTemplate(final Locale locale, + ThemeContext theme) { + + // Compute all the resource names we should use: *_gl_ES-gheada.properties, *_gl_ES + // .properties, _gl.properties... + // The order here is important: as we will let values from more specific files + // overwrite those in less specific, + // (e.g. a value for gl_ES will have more precedence than a value for gl). So we will + // iterate these resource + // names from less specific to more specific. + final List + messageResourceNames = computeMessageResourceNamesFromBase(locale); + + // Build the combined messages + Map combinedMessages = null; + for (final String messageResourceName : messageResourceNames) { + try { + final Reader messageResourceReader = messageReader(messageResourceName, theme); + if (messageResourceReader != null) { + + final Properties messageProperties = + readMessagesResource(messageResourceReader); + if (messageProperties != null && !messageProperties.isEmpty()) { + + if (combinedMessages == null) { + combinedMessages = new HashMap<>(20); + } + + for (final Map.Entry propertyEntry : + messageProperties.entrySet()) { + combinedMessages.put((String) propertyEntry.getKey(), + (String) propertyEntry.getValue()); + } + + } + + } + + } catch (final IOException ignored) { + // File might not exist, simply try the next one + } + } + + if (combinedMessages == null) { + return EMPTY_MESSAGES; + } + + return Collections.unmodifiableMap(combinedMessages); + } + + public static Map resolveMessagesForOrigin(final Class origin, + final Locale locale) { + + final Map combinedMessages = new HashMap<>(20); + + Class currentClass = origin; + combinedMessages.putAll(resolveMessagesForSpecificClass(currentClass, locale)); + + while (!currentClass.getSuperclass().equals(Object.class)) { + + currentClass = currentClass.getSuperclass(); + final Map messagesForCurrentClass = + resolveMessagesForSpecificClass(currentClass, locale); + for (final String messageKey : messagesForCurrentClass.keySet()) { + if (!combinedMessages.containsKey(messageKey)) { + combinedMessages.put(messageKey, messagesForCurrentClass.get(messageKey)); + } + } + } + + return Collections.unmodifiableMap(combinedMessages); + + } + + + private static Map resolveMessagesForSpecificClass( + final Class originClass, final Locale locale) { + + + final ClassLoader originClassLoader = originClass.getClassLoader(); + + // Compute all the resource names we should use: *_gl_ES-gheada.properties, *_gl_ES + // .properties, _gl.properties... + // The order here is important: as we will let values from more specific files + // overwrite those in less specific, + // (e.g. a value for gl_ES will have more precedence than a value for gl). So we will + // iterate these resource + // names from less specific to more specific. + final List messageResourceNames = + computeMessageResourceNamesFromBase(locale); + + // Build the combined messages + Map combinedMessages = null; + for (final String messageResourceName : messageResourceNames) { + + final InputStream inputStream = + originClassLoader.getResourceAsStream(messageResourceName); + if (inputStream != null) { + + // At this point we cannot be specified a character encoding (that's only for + // template resolution), + // so we will use the standard character encoding for .properties files, + // which is ISO-8859-1 + // (see Properties#load(InputStream) javadoc). + final InputStreamReader messageResourceReader = + new InputStreamReader(inputStream); + + final Properties messageProperties = + readMessagesResource(messageResourceReader); + if (messageProperties != null && !messageProperties.isEmpty()) { + + if (combinedMessages == null) { + combinedMessages = new HashMap<>(20); + } + + for (final Map.Entry propertyEntry : + messageProperties.entrySet()) { + combinedMessages.put((String) propertyEntry.getKey(), + (String) propertyEntry.getValue()); + } + + } + + } + + } + + if (combinedMessages == null) { + return EMPTY_MESSAGES; + } + + return Collections.unmodifiableMap(combinedMessages); + } + + + private static List computeMessageResourceNamesFromBase(final Locale locale) { + + final List resourceNames = new ArrayList<>(5); + + if (StringUtils.isEmptyOrWhitespace(locale.getLanguage())) { + throw new TemplateProcessingException( + "Locale \"" + locale + "\" " + + "cannot be used as it does not specify a language."); + } + + resourceNames.add(getResourceName("default")); + resourceNames.add(getResourceName(locale.getLanguage())); + + if (!StringUtils.isEmptyOrWhitespace(locale.getCountry())) { + resourceNames.add( + getResourceName(locale.getLanguage() + "_" + locale.getCountry())); + } + + if (!StringUtils.isEmptyOrWhitespace(locale.getVariant())) { + resourceNames.add(getResourceName( + locale.getLanguage() + "_" + locale.getCountry() + "-" + locale.getVariant())); + } + + return resourceNames; + + } + + private static String getResourceName(String name) { + return LOCATION + "/" + name + PROPERTIES_FILE_EXTENSION; + } + + + private static Properties readMessagesResource(final Reader propertiesReader) { + if (propertiesReader == null) { + return null; + } + final Properties properties = new Properties(); + try (propertiesReader) { + // Note Properties#load(Reader) this is JavaSE 6 specific, but Thymeleaf 3.0 does + // not support Java 5 anymore... + properties.load(propertiesReader); + } catch (final Exception e) { + throw new TemplateInputException("Exception loading messages file", e); + } + // ignore errors closing + return properties; + } + + public static String formatMessage(final Locale locale, final String message, + final Object[] messageParameters) { + if (message == null) { + return null; + } + if (!isFormatCandidate(message)) { + // trying to avoid creating MessageFormat if not needed + return message; + } + final MessageFormat messageFormat = new MessageFormat(message, locale); + return messageFormat.format( + (messageParameters != null ? messageParameters : EMPTY_MESSAGE_PARAMETERS)); + } + + /* + * This will allow us to determine whether a message might actually contain parameter + * placeholders. + */ + private static boolean isFormatCandidate(final String message) { + char c; + int n = message.length(); + while (n-- != 0) { + c = message.charAt(n); + if (c == '}' || c == '\'') { + return true; + } + } + return false; + } +} diff --git a/src/main/java/run/halo/app/theme/message/ThemeMessageResolver.java b/src/main/java/run/halo/app/theme/message/ThemeMessageResolver.java new file mode 100644 index 000000000..673770023 --- /dev/null +++ b/src/main/java/run/halo/app/theme/message/ThemeMessageResolver.java @@ -0,0 +1,37 @@ +package run.halo.app.theme.message; + +import java.util.Locale; +import java.util.Map; +import org.thymeleaf.messageresolver.StandardMessageResolver; +import org.thymeleaf.templateresource.ITemplateResource; +import run.halo.app.theme.ThemeContext; + +/** + * @author guqing + * @since 2.0.0 + */ +public class ThemeMessageResolver extends StandardMessageResolver { + + private final ThemeContext theme; + + public ThemeMessageResolver(ThemeContext theme) { + this.theme = theme; + } + + @Override + protected Map resolveMessagesForTemplate(String template, + ITemplateResource templateResource, + Locale locale) { + return ThemeMessageResolutionUtils.resolveMessagesForTemplate(locale, theme); + } + + @Override + protected Map resolveMessagesForOrigin(Class origin, Locale locale) { + return ThemeMessageResolutionUtils.resolveMessagesForOrigin(origin, locale); + } + + @Override + protected String formatMessage(Locale locale, String message, Object[] messageParameters) { + return ThemeMessageResolutionUtils.formatMessage(locale, message, messageParameters); + } +} diff --git a/src/main/resources/extensions/system-configurable-configmap.yaml b/src/main/resources/extensions/system-configurable-configmap.yaml new file mode 100644 index 000000000..8ee2ef76d --- /dev/null +++ b/src/main/resources/extensions/system-configurable-configmap.yaml @@ -0,0 +1,9 @@ +apiVersion: v1alpha1 +kind: "ConfigMap" +metadata: + name: system +data: + theme: | + { + "active": "default" + } \ No newline at end of file diff --git a/src/test/java/run/halo/app/theme/ThemeContextTest.java b/src/test/java/run/halo/app/theme/ThemeContextTest.java new file mode 100644 index 000000000..dc6f87a7e --- /dev/null +++ b/src/test/java/run/halo/app/theme/ThemeContextTest.java @@ -0,0 +1,35 @@ +package run.halo.app.theme; + +import java.nio.file.Paths; +import org.json.JSONException; +import org.junit.jupiter.api.Test; +import org.skyscreamer.jsonassert.JSONAssert; +import run.halo.app.infra.utils.JsonUtils; + +/** + * Tests for {@link ThemeContext}. + * + * @author guqing + * @since 2.0.0 + */ +class ThemeContextTest { + + @Test + void constructorBuilderTest() throws JSONException { + ThemeContext testTheme = ThemeContext.builder() + .name("testTheme") + .path(Paths.get("/tmp/themes/testTheme")) + .active(true) + .build(); + String s = JsonUtils.objectToJson(testTheme); + JSONAssert.assertEquals(""" + { + "name": "testTheme", + "path": "file:///tmp/themes/testTheme", + "active": true + } + """, + s, + false); + } +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/theme/ThemeLinkBuilderTest.java b/src/test/java/run/halo/app/theme/ThemeLinkBuilderTest.java new file mode 100644 index 000000000..5401f1a66 --- /dev/null +++ b/src/test/java/run/halo/app/theme/ThemeLinkBuilderTest.java @@ -0,0 +1,91 @@ +package run.halo.app.theme; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.file.Paths; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link ThemeLinkBuilder}. + * + * @author guqing + * @since 2.0.0 + */ +class ThemeLinkBuilderTest { + private ThemeLinkBuilder themeLinkBuilder; + + @Test + void processTemplateLinkWithNoActive() { + themeLinkBuilder = new ThemeLinkBuilder(getTheme(false)); + + String link = "/post"; + String processed = themeLinkBuilder.processLink(null, link); + assertThat(processed).isEqualTo("/post?preview-theme=test-theme"); + + processed = themeLinkBuilder.processLink(null, "/post?foo=bar"); + assertThat(processed).isEqualTo("/post?foo=bar&preview-theme=test-theme"); + } + + @Test + void processTemplateLinkWithActive() { + themeLinkBuilder = new ThemeLinkBuilder(getTheme(true)); + + String link = "/post"; + String processed = themeLinkBuilder.processLink(null, link); + assertThat(processed).isEqualTo("/post"); + } + + @Test + void processAssetsLink() { + // activated theme + themeLinkBuilder = new ThemeLinkBuilder(getTheme(true)); + + String link = "/assets/css/style.css"; + String processed = themeLinkBuilder.processLink(null, link); + assertThat(processed).isEqualTo("/themes/test-theme/assets/css/style.css"); + + // preview theme + getTheme(false); + link = "/assets/js/main.js"; + processed = themeLinkBuilder.processLink(null, link); + assertThat(processed).isEqualTo("/themes/test-theme/assets/js/main.js"); + } + + @Test + void processNullLink() { + themeLinkBuilder = new ThemeLinkBuilder(getTheme(false)); + + String link = null; + String processed = themeLinkBuilder.processLink(null, link); + assertThat(processed).isEqualTo(null); + + // empty link + link = ""; + processed = themeLinkBuilder.processLink(null, link); + assertThat(processed).isEqualTo("/?preview-theme=test-theme"); + } + + @Test + void processAbsoluteLink() { + themeLinkBuilder = new ThemeLinkBuilder(getTheme(false)); + String link = "https://github.com/halo-dev"; + String processed = themeLinkBuilder.processLink(null, link); + assertThat(processed).isEqualTo(link); + + link = "http://example.com"; + processed = themeLinkBuilder.processLink(null, link); + assertThat(processed).isEqualTo(link); + + link = "//example.com"; + processed = themeLinkBuilder.processLink(null, link); + assertThat(processed).isEqualTo(link); + } + + private ThemeContext getTheme(boolean isActive) { + return ThemeContext.builder() + .name("test-theme") + .path(Paths.get("/themes/test-theme")) + .active(isActive) + .build(); + } +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/theme/ThemeLocaleContextResolverTest.java b/src/test/java/run/halo/app/theme/ThemeLocaleContextResolverTest.java new file mode 100644 index 000000000..8ab62e89e --- /dev/null +++ b/src/test/java/run/halo/app/theme/ThemeLocaleContextResolverTest.java @@ -0,0 +1,193 @@ +package run.halo.app.theme; + +import static java.util.Locale.CANADA; +import static java.util.Locale.CHINA; +import static java.util.Locale.CHINESE; +import static java.util.Locale.ENGLISH; +import static java.util.Locale.GERMAN; +import static java.util.Locale.GERMANY; +import static java.util.Locale.JAPAN; +import static java.util.Locale.JAPANESE; +import static java.util.Locale.KOREA; +import static java.util.Locale.UK; +import static java.util.Locale.US; +import static org.assertj.core.api.Assertions.assertThat; +import static run.halo.app.theme.ThemeLocaleContextResolver.DEFAULT_PARAMETER_NAME; +import static run.halo.app.theme.ThemeLocaleContextResolver.TIME_ZONE_COOKIE_NAME; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Locale; +import java.util.TimeZone; +import org.junit.jupiter.api.Test; +import org.springframework.context.i18n.TimeZoneAwareLocaleContext; +import org.springframework.http.HttpCookie; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.web.server.ServerWebExchange; + +/** + * Test for {@link ThemeLocaleContextResolver}. + * + * @author guqing + * @since 2.0.0 + */ +class ThemeLocaleContextResolverTest { + private final ThemeLocaleContextResolver resolver = new ThemeLocaleContextResolver(); + + @Test + public void resolveTimeZone() { + TimeZoneAwareLocaleContext localeContext = + (TimeZoneAwareLocaleContext) this.resolver.resolveLocaleContext( + exchangeTimeZone(CHINA)); + assertThat(localeContext.getTimeZone()).isNotNull(); + assertThat(localeContext.getTimeZone()) + .isEqualTo(TimeZone.getTimeZone("America/Adak")); + assertThat(localeContext.getLocale()).isNotNull(); + assertThat(localeContext.getLocale().getLanguage()).isEqualTo("en"); + } + + @Test + public void resolve() { + assertThat(this.resolver.resolveLocaleContext(exchange(CANADA)).getLocale()) + .isEqualTo(CANADA); + assertThat(this.resolver.resolveLocaleContext(exchange(US, CANADA)).getLocale()) + .isEqualTo(US); + } + + @Test + public void resolveFromParam() { + assertThat(this.resolver.resolveLocaleContext(exchangeForParam("en")).getLocale()) + .isEqualTo(ENGLISH); + assertThat(this.resolver.resolveLocaleContext(exchangeForParam("zh")).getLocale()) + .isEqualTo(CHINESE); + } + + @Test + public void resolvePreferredSupported() { + this.resolver.setSupportedLocales(Collections.singletonList(CANADA)); + assertThat(this.resolver.resolveLocaleContext(exchange(US, CANADA)).getLocale()).isEqualTo( + CANADA); + } + + @Test + public void resolvePreferredNotSupported() { + this.resolver.setSupportedLocales(Collections.singletonList(CANADA)); + assertThat(this.resolver.resolveLocaleContext(exchange(US, UK)).getLocale()).isEqualTo(US); + } + + @Test + public void resolvePreferredNotSupportedWithDefault() { + this.resolver.setSupportedLocales(Arrays.asList(US, JAPAN)); + this.resolver.setDefaultLocale(JAPAN); + assertThat(this.resolver.resolveLocaleContext(exchange(KOREA)).getLocale()).isEqualTo( + JAPAN); + } + + @Test + public void resolvePreferredAgainstLanguageOnly() { + this.resolver.setSupportedLocales(Collections.singletonList(ENGLISH)); + assertThat( + this.resolver.resolveLocaleContext(exchange(GERMANY, US, UK)).getLocale()).isEqualTo( + ENGLISH); + } + + @Test + public void resolvePreferredAgainstCountryIfPossible() { + this.resolver.setSupportedLocales(Arrays.asList(ENGLISH, UK)); + assertThat( + this.resolver.resolveLocaleContext(exchange(GERMANY, US, UK)).getLocale()).isEqualTo( + UK); + } + + @Test + public void resolvePreferredAgainstLanguageWithMultipleSupportedLocales() { + this.resolver.setSupportedLocales(Arrays.asList(GERMAN, US)); + assertThat( + this.resolver.resolveLocaleContext(exchange(GERMANY, US, UK)).getLocale()).isEqualTo( + GERMAN); + } + + @Test + public void resolveMissingAcceptLanguageHeader() { + MockServerHttpRequest request = MockServerHttpRequest.get("/").build(); + MockServerWebExchange exchange = MockServerWebExchange.from(request); + assertThat(this.resolver.resolveLocaleContext(exchange).getLocale()).isNull(); + } + + @Test + public void resolveMissingAcceptLanguageHeaderWithDefault() { + this.resolver.setDefaultLocale(US); + + MockServerHttpRequest request = MockServerHttpRequest.get("/").build(); + MockServerWebExchange exchange = MockServerWebExchange.from(request); + assertThat(this.resolver.resolveLocaleContext(exchange).getLocale()).isEqualTo(US); + } + + @Test + public void resolveEmptyAcceptLanguageHeader() { + MockServerHttpRequest request = + MockServerHttpRequest.get("/").header(HttpHeaders.ACCEPT_LANGUAGE, "").build(); + MockServerWebExchange exchange = MockServerWebExchange.from(request); + assertThat(this.resolver.resolveLocaleContext(exchange).getLocale()).isNull(); + } + + @Test + public void resolveEmptyAcceptLanguageHeaderWithDefault() { + this.resolver.setDefaultLocale(US); + + MockServerHttpRequest request = + MockServerHttpRequest.get("/").header(HttpHeaders.ACCEPT_LANGUAGE, "").build(); + MockServerWebExchange exchange = MockServerWebExchange.from(request); + assertThat(this.resolver.resolveLocaleContext(exchange).getLocale()).isEqualTo(US); + } + + @Test + public void resolveInvalidAcceptLanguageHeader() { + MockServerHttpRequest request = + MockServerHttpRequest.get("/").header(HttpHeaders.ACCEPT_LANGUAGE, "en_US").build(); + MockServerWebExchange exchange = MockServerWebExchange.from(request); + assertThat(this.resolver.resolveLocaleContext(exchange).getLocale()).isNull(); + } + + @Test + public void resolveInvalidAcceptLanguageHeaderWithDefault() { + this.resolver.setDefaultLocale(US); + + MockServerHttpRequest request = + MockServerHttpRequest.get("/").header(HttpHeaders.ACCEPT_LANGUAGE, "en_US").build(); + MockServerWebExchange exchange = MockServerWebExchange.from(request); + assertThat(this.resolver.resolveLocaleContext(exchange).getLocale()).isEqualTo(US); + } + + @Test + public void defaultLocale() { + this.resolver.setDefaultLocale(JAPANESE); + MockServerHttpRequest request = MockServerHttpRequest.get("/").build(); + MockServerWebExchange exchange = MockServerWebExchange.from(request); + assertThat(this.resolver.resolveLocaleContext(exchange).getLocale()).isEqualTo(JAPANESE); + + request = MockServerHttpRequest.get("/").acceptLanguageAsLocales(US).build(); + exchange = MockServerWebExchange.from(request); + assertThat(this.resolver.resolveLocaleContext(exchange).getLocale()).isEqualTo(US); + } + + + private ServerWebExchange exchange(Locale... locales) { + return MockServerWebExchange.from( + MockServerHttpRequest.get("").acceptLanguageAsLocales(locales)); + } + + private ServerWebExchange exchangeTimeZone(Locale... locales) { + return MockServerWebExchange.from( + MockServerHttpRequest.get("").acceptLanguageAsLocales(locales) + .cookie(new HttpCookie(TIME_ZONE_COOKIE_NAME, "America/Adak")) + .cookie(new HttpCookie(DEFAULT_PARAMETER_NAME, "en"))); + } + + private ServerWebExchange exchangeForParam(String language) { + return MockServerWebExchange.from( + MockServerHttpRequest.get("/index?language=" + language)); + } +} diff --git a/src/test/java/run/halo/app/theme/message/ThemeMessageResolutionUtilsTest.java b/src/test/java/run/halo/app/theme/message/ThemeMessageResolutionUtilsTest.java new file mode 100644 index 000000000..08eae220c --- /dev/null +++ b/src/test/java/run/halo/app/theme/message/ThemeMessageResolutionUtilsTest.java @@ -0,0 +1,58 @@ +package run.halo.app.theme.message; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.FileNotFoundException; +import java.net.URL; +import java.nio.file.Paths; +import java.util.Locale; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.util.ResourceUtils; +import run.halo.app.theme.ThemeContext; + +/** + * @author guqing + * @since 2.0.0 + */ +class ThemeMessageResolutionUtilsTest { + private URL defaultThemeUrl; + + @BeforeEach + void setUp() throws FileNotFoundException { + defaultThemeUrl = ResourceUtils.getURL("classpath:themes/default"); + } + + @Test + void resolveMessagesForTemplateForDefault() { + Map properties = + ThemeMessageResolutionUtils.resolveMessagesForTemplate(Locale.CHINESE, getTheme()); + assertThat(properties).hasSize(1); + assertThat(properties).containsEntry("index.welcome", "欢迎来到首页"); + } + + @Test + void resolveMessagesForTemplateForEnglish() { + Map properties = + ThemeMessageResolutionUtils.resolveMessagesForTemplate(Locale.ENGLISH, getTheme()); + assertThat(properties).hasSize(1); + assertThat(properties).containsEntry("index.welcome", "Welcome to the index"); + } + + @Test + void messageFormat() { + String s = + ThemeMessageResolutionUtils.formatMessage(Locale.ENGLISH, "Welcome {0} to the index", + new Object[] {"Halo"}); + assertThat(s).isEqualTo("Welcome Halo to the index"); + } + + ThemeContext getTheme() { + return ThemeContext.builder() + .name("default") + .path(Paths.get(defaultThemeUrl.getPath())) + .active(true) + .build(); + } +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/theme/message/ThemeMessageResolverIntegrationTest.java b/src/test/java/run/halo/app/theme/message/ThemeMessageResolverIntegrationTest.java new file mode 100644 index 000000000..76460097b --- /dev/null +++ b/src/test/java/run/halo/app/theme/message/ThemeMessageResolverIntegrationTest.java @@ -0,0 +1,225 @@ +package run.halo.app.theme.message; + +import java.io.FileNotFoundException; +import java.net.URL; +import java.nio.file.Paths; +import java.time.Duration; +import java.util.function.Function; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.util.ResourceUtils; +import org.springframework.web.reactive.function.server.RequestPredicates; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerResponse; +import run.halo.app.theme.ThemeContext; +import run.halo.app.theme.ThemeResolver; + +/** + * Tests for {@link ThemeMessageResolver}. + * + * @author guqing + * @since 2.0.0 + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class ThemeMessageResolverIntegrationTest { + @Autowired + private ApplicationContext applicationContext; + @Autowired + private ThemeResolver themeResolver; + private URL defaultThemeUrl; + private URL otherThemeUrl; + + Function themeContextFunction; + + private WebTestClient webTestClient; + + @BeforeEach + void setUp() throws FileNotFoundException { + themeContextFunction = themeResolver.getThemeContextFunction(); + webTestClient = WebTestClient + .bindToApplicationContext(applicationContext) + .configureClient() + .responseTimeout(Duration.ofMinutes(1)) + .build(); + + defaultThemeUrl = ResourceUtils.getURL("classpath:themes/default"); + otherThemeUrl = ResourceUtils.getURL("classpath:themes/other"); + } + + @AfterEach + void tearDown() { + this.themeResolver.setThemeContextFunction(themeContextFunction); + } + + @Test + void messageResolverWhenDefaultTheme() { + themeResolver.setThemeContextFunction(request -> createDefaultContext()); + webTestClient.get() + .uri("/?language=zh") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo(""" + + + + + Title + + + index +
zh
+
欢迎来到首页
+ + + """); + } + + @Test + void messageResolverForEnLanguageWhenDefaultTheme() { + themeResolver.setThemeContextFunction(request -> createDefaultContext()); + webTestClient.get() + .uri("/?language=en") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo(""" + + + + + Title + + + index +
en
+
Welcome to the index
+ + + """); + } + + @Test + void shouldUseDefaultWhenLanguageNotSupport() { + themeResolver.setThemeContextFunction(request -> createDefaultContext()); + webTestClient.get() + .uri("/index?language=foo") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo(""" + + + + + Title + + + index +
foo
+
欢迎来到首页
+ + + """); + } + + @Test + void switchTheme() { + themeResolver.setThemeContextFunction(request -> createDefaultContext()); + webTestClient.get() + .uri("/index?language=zh") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo(""" + + + + + Title + + + index +
zh
+
欢迎来到首页
+ + + """); + + // For other theme + themeResolver.setThemeContextFunction(request -> createOtherContext()); + webTestClient.get() + .uri("/index?language=zh") + .exchange() + .expectBody(String.class) + .isEqualTo(""" + + + + + Other theme title + + +

Other 首页

+ + + """); + webTestClient.get() + .uri("/index?language=en") + .exchange() + .expectBody(String.class) + .isEqualTo(""" + + + + + Other theme title + + +

other index

+ + + """); + } + + ThemeContext createDefaultContext() { + return ThemeContext.builder() + .name("default") + .path(Paths.get(defaultThemeUrl.getPath())) + .active(true) + .build(); + } + + ThemeContext createOtherContext() { + return ThemeContext.builder() + .name("other") + .path(Paths.get(otherThemeUrl.getPath())) + .active(false) + .build(); + } + + @TestConfiguration + static class MessageResolverConfig { + @Bean + RouterFunction routeTestIndex() { + return RouterFunctions + .route(RequestPredicates.GET("/").or(RequestPredicates.GET("/index")) + .and(RequestPredicates.accept(MediaType.TEXT_HTML)), + request -> ServerResponse.ok().render("index")); + } + } +} diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml index 84350717c..408e2edeb 100644 --- a/src/test/resources/application.yaml +++ b/src/test/resources/application.yaml @@ -18,6 +18,7 @@ spring: show-sql: true halo: + work-dir: ${user.home}/halo-next-test security: initializer: disabled: true diff --git a/src/test/resources/themes/default/i18n/default.properties b/src/test/resources/themes/default/i18n/default.properties new file mode 100644 index 000000000..0321c8140 --- /dev/null +++ b/src/test/resources/themes/default/i18n/default.properties @@ -0,0 +1 @@ +index.welcome=\u6B22\u8FCE\u6765\u5230\u9996\u9875 \ No newline at end of file diff --git a/src/test/resources/themes/default/i18n/en.properties b/src/test/resources/themes/default/i18n/en.properties new file mode 100644 index 000000000..1e6ec93cd --- /dev/null +++ b/src/test/resources/themes/default/i18n/en.properties @@ -0,0 +1 @@ +index.welcome=Welcome to the index \ No newline at end of file diff --git a/src/test/resources/themes/default/templates/index.html b/src/test/resources/themes/default/templates/index.html new file mode 100644 index 000000000..441ad470c --- /dev/null +++ b/src/test/resources/themes/default/templates/index.html @@ -0,0 +1,12 @@ + + + + + Title + + +index +
+
+ + diff --git a/src/test/resources/themes/other/i18n/default.properties b/src/test/resources/themes/other/i18n/default.properties new file mode 100644 index 000000000..7faa99e8c --- /dev/null +++ b/src/test/resources/themes/other/i18n/default.properties @@ -0,0 +1 @@ +index.welcome=Other \u9996\u9875 \ No newline at end of file diff --git a/src/test/resources/themes/other/i18n/en.properties b/src/test/resources/themes/other/i18n/en.properties new file mode 100644 index 000000000..82b9a280a --- /dev/null +++ b/src/test/resources/themes/other/i18n/en.properties @@ -0,0 +1 @@ +index.welcome=other index \ No newline at end of file diff --git a/src/test/resources/themes/other/templates/index.html b/src/test/resources/themes/other/templates/index.html new file mode 100644 index 000000000..ca476f69c --- /dev/null +++ b/src/test/resources/themes/other/templates/index.html @@ -0,0 +1,10 @@ + + + + + Other theme title + + +

+ +