From a0d55c58f6a00c20dbf5deba56585838b9481157 Mon Sep 17 00:00:00 2001 From: guqing <38999863+guqing@users.noreply.github.com> Date: Thu, 15 Sep 2022 14:52:13 +0800 Subject: [PATCH] feat: theme side provides variables for theme and some system settings (#2406) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### What type of PR is this? /kind feature /milestone 2.0 /area core #### What this PR does / why we need it: 提供当前使用主题(预览或激活)的 configMap 变量和部分系统设置等变量。 提供了以下变量: - `${theme}` 当前主题的信息,theme.yaml - `${theme.config}` 获取当前主题的设置项 - ~`${siteSetting}`~ `${site}` 提供必要系统变量 #### Which issue(s) this PR fixes: Fixes #2389 #### Special notes for your reviewer: how to test it? 再任意主题模板上使用表达式获取例如:`${theme}`,`${theme.config.sns?.email}` /cc @halo-dev/sig-halo #### Does this PR introduce a user-facing change? ```release-note None ``` --- build.gradle | 1 + src/main/java/run/halo/app/Application.java | 4 +- .../reconciler/SystemSettingReconciler.java | 261 ++++++++++++------ .../SystemConfigurableEnvironmentFetcher.java | 6 +- .../run/halo/app/infra/SystemSetting.java | 40 +++ .../run/halo/app/theme/HaloViewResolver.java | 51 +++- .../theme/SiteSettingVariablesAcquirer.java | 34 +++ .../ThemeContextBasedVariablesAcquirer.java | 41 +++ .../ViewContextBasedVariablesAcquirer.java | 11 + .../theme/dialect/HaloProcessorDialect.java | 3 +- ...dePropertyAccessorBoundariesProcessor.java | 45 +++ .../halo/app/theme/finders/ThemeFinder.java | 16 ++ .../theme/finders/impl/ThemeFinderImpl.java | 68 +++++ .../app/theme/finders/vo/SiteSettingVo.java | 121 ++++++++ .../halo/app/theme/finders/vo/ThemeVo.java | 42 +++ .../resources/extensions/system-setting.yaml | 2 +- .../SystemSettingReconcilerTest.java | 144 +++++++++- .../ThemeJava8TimeDialectIntegrationTest.java | 7 +- 18 files changed, 796 insertions(+), 101 deletions(-) create mode 100644 src/main/java/run/halo/app/theme/SiteSettingVariablesAcquirer.java create mode 100644 src/main/java/run/halo/app/theme/ThemeContextBasedVariablesAcquirer.java create mode 100644 src/main/java/run/halo/app/theme/ViewContextBasedVariablesAcquirer.java create mode 100644 src/main/java/run/halo/app/theme/dialect/JsonNodePropertyAccessorBoundariesProcessor.java create mode 100644 src/main/java/run/halo/app/theme/finders/ThemeFinder.java create mode 100644 src/main/java/run/halo/app/theme/finders/impl/ThemeFinderImpl.java create mode 100644 src/main/java/run/halo/app/theme/finders/vo/SiteSettingVo.java create mode 100644 src/main/java/run/halo/app/theme/finders/vo/ThemeVo.java diff --git a/build.gradle b/build.gradle index d2b802949..4d9c1d9be 100644 --- a/build.gradle +++ b/build.gradle @@ -74,6 +74,7 @@ dependencies { implementation "com.google.guava:guava:$guava" implementation "org.jsoup:jsoup:$jsoup" implementation "io.github.java-diff-utils:java-diff-utils:$javaDiffUtils" + implementation "org.springframework.integration:spring-integration-core" compileOnly 'org.projectlombok:lombok' testCompileOnly 'org.projectlombok:lombok' diff --git a/src/main/java/run/halo/app/Application.java b/src/main/java/run/halo/app/Application.java index a7f8a8e5a..b3c1c8b66 100644 --- a/src/main/java/run/halo/app/Application.java +++ b/src/main/java/run/halo/app/Application.java @@ -2,6 +2,7 @@ package run.halo.app; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.integration.IntegrationAutoConfiguration; import org.springframework.boot.context.properties.EnableConfigurationProperties; import run.halo.app.infra.properties.HaloProperties; import run.halo.app.infra.properties.JwtProperties; @@ -14,7 +15,8 @@ import run.halo.app.infra.properties.JwtProperties; * @author guqing * @date 2017-11-14 */ -@SpringBootApplication +@SpringBootApplication(scanBasePackages = "run.halo.app", exclude = + IntegrationAutoConfiguration.class) @EnableConfigurationProperties({HaloProperties.class, JwtProperties.class}) public class Application { diff --git a/src/main/java/run/halo/app/core/extension/reconciler/SystemSettingReconciler.java b/src/main/java/run/halo/app/core/extension/reconciler/SystemSettingReconciler.java index fafda15f4..82731ccc0 100644 --- a/src/main/java/run/halo/app/core/extension/reconciler/SystemSettingReconciler.java +++ b/src/main/java/run/halo/app/core/extension/reconciler/SystemSettingReconciler.java @@ -1,7 +1,10 @@ package run.halo.app.core.extension.reconciler; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.context.ApplicationContext; import run.halo.app.extension.ConfigMap; @@ -19,13 +22,16 @@ import run.halo.app.theme.router.PermalinkRuleChangedEvent; * @author guqing * @since 2.0.0 */ +@Slf4j public class SystemSettingReconciler implements Reconciler<Reconciler.Request> { - - private static final String OLD_THEME_ROUTE_RULES = "halo.run/old-theme-route-rules"; + public static final String OLD_THEME_ROUTE_RULES = "halo.run/old-theme-route-rules"; + public static final String FINALIZER_NAME = "system-setting-protection"; private final ExtensionClient client; private final ApplicationContext applicationContext; + private final RouteRuleReconciler routeRuleReconciler = new RouteRuleReconciler(); + public SystemSettingReconciler(ExtensionClient client, ApplicationContext applicationContext) { this.client = client; this.applicationContext = applicationContext; @@ -39,110 +45,197 @@ public class SystemSettingReconciler implements Reconciler<Reconciler.Request> { } client.fetch(ConfigMap.class, name) .ifPresent(configMap -> { - ConfigMap oldConfigMap = JsonUtils.deepCopy(configMap); - - ruleChangedDispatcher(configMap); - - if (!configMap.equals(oldConfigMap)) { - client.update(configMap); - } + addFinalizerIfNecessary(configMap); + routeRuleReconciler.reconcile(name); }); return new Result(false, null); } - private void ruleChangedDispatcher(ConfigMap configMap) { - Map<String, String> data = configMap.getData(); + private void addFinalizerIfNecessary(ConfigMap oldConfigMap) { + Set<String> finalizers = oldConfigMap.getMetadata().getFinalizers(); + if (finalizers != null && finalizers.contains(FINALIZER_NAME)) { + return; + } + client.fetch(ConfigMap.class, oldConfigMap.getMetadata().getName()) + .ifPresent(configMap -> { + Set<String> newFinalizers = configMap.getMetadata().getFinalizers(); + if (newFinalizers == null) { + newFinalizers = new HashSet<>(); + configMap.getMetadata().setFinalizers(newFinalizers); + } + newFinalizers.add(FINALIZER_NAME); + client.update(configMap); + }); + } - Map<String, String> annotations = getAnnotationsSafe(configMap); - String oldRulesJson = annotations.get(OLD_THEME_ROUTE_RULES); + class RouteRuleReconciler { - String routeRulesJson = data.get(SystemSetting.ThemeRouteRules.GROUP); - // get new rules and replace old rules to new rules - SystemSetting.ThemeRouteRules newRouteRules = - JsonUtils.jsonToObject(routeRulesJson, SystemSetting.ThemeRouteRules.class); - - // old rules is empty, means this is the first time to update theme route rules - if (oldRulesJson == null) { - oldRulesJson = "{}"; + public void reconcile(String name) { + reconcileArchivesRule(name); + reconcileTagsRule(name); + reconcileCategoriesRule(name); + reconcilePostRule(name); } - // diff old rules and new rules - SystemSetting.ThemeRouteRules oldRules = - JsonUtils.jsonToObject(oldRulesJson, SystemSetting.ThemeRouteRules.class); + private void reconcileArchivesRule(String name) { + client.fetch(ConfigMap.class, name).ifPresent(configMap -> { + SystemSetting.ThemeRouteRules oldRules = getOldRouteRulesFromAnno(configMap); + SystemSetting.ThemeRouteRules newRules = getRouteRules(configMap); - // dispatch event - if (!StringUtils.equals(oldRules.getArchives(), newRouteRules.getArchives())) { - // archives rule changed - applicationContext.publishEvent(new PermalinkRuleChangedEvent(this, - DefaultTemplateEnum.ARCHIVES, - newRouteRules.getArchives())); + final String oldArchivesPrefix = oldRules.getArchives(); + final String oldPostPattern = oldRules.getPost(); + + // dispatch event + final boolean archivesPrefixChanged = + !StringUtils.equals(oldRules.getArchives(), newRules.getArchives()); + + final boolean postPatternChanged = + changePostPatternPrefixIfNecessary(oldArchivesPrefix, newRules); + + if (archivesPrefixChanged || postPatternChanged) { + oldRules.setPost(newRules.getPost()); + oldRules.setArchives(newRules.getArchives()); + updateNewRuleToConfigMap(configMap, oldRules, newRules); + } + + // archives rule changed + if (archivesPrefixChanged) { + log.debug("Archives prefix changed from [{}] to [{}].", oldArchivesPrefix, + newRules.getArchives()); + applicationContext.publishEvent(new PermalinkRuleChangedEvent(this, + DefaultTemplateEnum.ARCHIVES, + newRules.getArchives())); + } + + if (postPatternChanged) { + log.debug("Post pattern changed from [{}] to [{}].", oldPostPattern, + newRules.getPost()); + // post rule changed + applicationContext.publishEvent(new PermalinkRuleChangedEvent(this, + DefaultTemplateEnum.POST, newRules.getPost())); + } + }); } - if (!StringUtils.equals(oldRules.getTags(), newRouteRules.getTags())) { - // tags rule changed - applicationContext.publishEvent(new PermalinkRuleChangedEvent(this, - DefaultTemplateEnum.TAGS, - newRouteRules.getTags())); - } - - if (!StringUtils.equals(oldRules.getCategories(), newRouteRules.getCategories())) { - // categories rule changed - applicationContext.publishEvent(new PermalinkRuleChangedEvent(this, - DefaultTemplateEnum.CATEGORIES, - newRouteRules.getCategories())); - } - - if (!StringUtils.equals(oldRules.getPost(), newRouteRules.getPost())) { - // post rule changed - applicationContext.publishEvent(new PermalinkRuleChangedEvent(this, - DefaultTemplateEnum.POST, - newRouteRules.getPost())); - } - - // TODO 此处立即同步 post 的新 pattern 到数据库,才能更新到文章页面的 permalink 地址 - // 但会导致乐观锁失效会失败一次 reconcile - if (changePostPatternPrefixIfNecessary(oldRules, newRouteRules)) { - data.put(SystemSetting.ThemeRouteRules.GROUP, JsonUtils.objectToJson(newRouteRules)); - annotations.put(OLD_THEME_ROUTE_RULES, JsonUtils.objectToJson(newRouteRules)); - // update config map immediately + private void updateNewRuleToConfigMap(ConfigMap configMap, + SystemSetting.ThemeRouteRules oldRules, + SystemSetting.ThemeRouteRules newRules) { + Map<String, String> annotations = getAnnotationsSafe(configMap); + annotations.put(OLD_THEME_ROUTE_RULES, JsonUtils.objectToJson(oldRules)); + configMap.getData().put(SystemSetting.ThemeRouteRules.GROUP, + JsonUtils.objectToJson(newRules)); client.update(configMap); } - // update theme setting - data.put(SystemSetting.ThemeRouteRules.GROUP, JsonUtils.objectToJson(newRouteRules)); - annotations.put(OLD_THEME_ROUTE_RULES, JsonUtils.objectToJson(newRouteRules)); - } + private void reconcileTagsRule(String name) { + client.fetch(ConfigMap.class, name).ifPresent(configMap -> { + SystemSetting.ThemeRouteRules oldRules = getOldRouteRulesFromAnno(configMap); + SystemSetting.ThemeRouteRules newRules = getRouteRules(configMap); + final String oldTagsPrefix = oldRules.getTags(); + if (!StringUtils.equals(oldTagsPrefix, newRules.getTags())) { + oldRules.setTags(newRules.getTags()); + updateNewRuleToConfigMap(configMap, oldRules, newRules); - boolean changePostPatternPrefixIfNecessary(SystemSetting.ThemeRouteRules oldRules, - SystemSetting.ThemeRouteRules newRules) { - if (StringUtils.isBlank(oldRules.getArchives()) - || StringUtils.isBlank(newRules.getPost())) { + log.debug("Tags prefix changed from [{}] to [{}].", oldTagsPrefix, + newRules.getTags()); + // then publish event + applicationContext.publishEvent(new PermalinkRuleChangedEvent(this, + DefaultTemplateEnum.TAGS, + newRules.getTags())); + } + }); + } + + private void reconcileCategoriesRule(String name) { + client.fetch(ConfigMap.class, name).ifPresent(configMap -> { + SystemSetting.ThemeRouteRules oldRules = getOldRouteRulesFromAnno(configMap); + SystemSetting.ThemeRouteRules newRules = getRouteRules(configMap); + final String oldCategoriesPrefix = oldRules.getCategories(); + if (!StringUtils.equals(oldCategoriesPrefix, newRules.getCategories())) { + oldRules.setCategories(newRules.getCategories()); + updateNewRuleToConfigMap(configMap, oldRules, newRules); + + log.debug("Categories prefix changed from [{}] to [{}].", oldCategoriesPrefix, + newRules.getCategories()); + // categories rule changed + applicationContext.publishEvent(new PermalinkRuleChangedEvent(this, + DefaultTemplateEnum.CATEGORIES, + newRules.getCategories())); + } + }); + } + + private void reconcilePostRule(String name) { + client.fetch(ConfigMap.class, name).ifPresent(configMap -> { + SystemSetting.ThemeRouteRules oldRules = getOldRouteRulesFromAnno(configMap); + SystemSetting.ThemeRouteRules newRules = getRouteRules(configMap); + + final String oldPostPattern = oldRules.getPost(); + if (!StringUtils.equals(oldPostPattern, newRules.getPost())) { + oldRules.setPost(newRules.getPost()); + updateNewRuleToConfigMap(configMap, oldRules, newRules); + + log.debug("Categories prefix changed from [{}] to [{}].", oldPostPattern, + newRules.getPost()); + // post rule changed + applicationContext.publishEvent(new PermalinkRuleChangedEvent(this, + DefaultTemplateEnum.POST, + newRules.getPost())); + } + }); + } + + static boolean changePostPatternPrefixIfNecessary(String oldArchivePrefix, + SystemSetting.ThemeRouteRules newRules) { + if (StringUtils.isBlank(oldArchivePrefix) + || StringUtils.isBlank(newRules.getPost())) { + return false; + } + String newArchivesPrefix = newRules.getArchives(); + if (StringUtils.equals(oldArchivePrefix, newArchivesPrefix)) { + return false; + } + + String oldPrefix = StringUtils.removeStart(oldArchivePrefix, "/"); + String postPattern = StringUtils.removeStart(newRules.getPost(), "/"); + + if (postPattern.startsWith(oldPrefix)) { + String postPatternToUpdate = PathUtils.combinePath(newArchivesPrefix, + StringUtils.removeStart(postPattern, oldPrefix)); + newRules.setPost(postPatternToUpdate); + return true; + } return false; } - String oldArchivesPrefix = StringUtils.removeStart(oldRules.getArchives(), "/"); - String postPattern = StringUtils.removeStart(newRules.getPost(), "/"); - String newArchivesPrefix = newRules.getArchives(); - if (postPattern.startsWith(oldArchivesPrefix)) { - String postPatternToUpdate = PathUtils.combinePath(newArchivesPrefix, - StringUtils.removeStart(postPattern, oldArchivesPrefix)); - newRules.setPost(postPatternToUpdate); + private SystemSetting.ThemeRouteRules getOldRouteRulesFromAnno(ConfigMap configMap) { + Map<String, String> annotations = getAnnotationsSafe(configMap); + String oldRulesJson = annotations.get(OLD_THEME_ROUTE_RULES); - // post rule changed - applicationContext.publishEvent(new PermalinkRuleChangedEvent(this, - DefaultTemplateEnum.POST, postPatternToUpdate)); - return true; + // old rules is empty, means this is the first time to update theme route rules + if (oldRulesJson == null) { + oldRulesJson = "{}"; + } + + // diff old rules and new rules + return JsonUtils.jsonToObject(oldRulesJson, SystemSetting.ThemeRouteRules.class); } - return false; - } - private Map<String, String> getAnnotationsSafe(ConfigMap configMap) { - Map<String, String> annotations = configMap.getMetadata().getAnnotations(); - if (annotations == null) { - annotations = new HashMap<>(); - configMap.getMetadata().setAnnotations(annotations); + private SystemSetting.ThemeRouteRules getRouteRules(ConfigMap configMap) { + Map<String, String> data = configMap.getData(); + // get new rules and replace old rules to new rules + return JsonUtils.jsonToObject(data.get(SystemSetting.ThemeRouteRules.GROUP), + SystemSetting.ThemeRouteRules.class); + } + + private Map<String, String> getAnnotationsSafe(ConfigMap configMap) { + Map<String, String> annotations = configMap.getMetadata().getAnnotations(); + if (annotations == null) { + annotations = new HashMap<>(); + configMap.getMetadata().setAnnotations(annotations); + } + return annotations; } - return annotations; } public boolean isSystemSetting(String name) { diff --git a/src/main/java/run/halo/app/infra/SystemConfigurableEnvironmentFetcher.java b/src/main/java/run/halo/app/infra/SystemConfigurableEnvironmentFetcher.java index 8ece26e6d..3e698865f 100644 --- a/src/main/java/run/halo/app/infra/SystemConfigurableEnvironmentFetcher.java +++ b/src/main/java/run/halo/app/infra/SystemConfigurableEnvironmentFetcher.java @@ -36,9 +36,13 @@ public class SystemConfigurableEnvironmentFetcher { @NonNull private Mono<Map<String, String>> getValuesInternal() { - return extensionClient.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG) + return getConfigMap() .filter(configMap -> configMap.getData() != null) .map(ConfigMap::getData) .defaultIfEmpty(Map.of()); } + + public Mono<ConfigMap> getConfigMap() { + return extensionClient.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG); + } } diff --git a/src/main/java/run/halo/app/infra/SystemSetting.java b/src/main/java/run/halo/app/infra/SystemSetting.java index a44d93a7a..2e5e912e1 100644 --- a/src/main/java/run/halo/app/infra/SystemSetting.java +++ b/src/main/java/run/halo/app/infra/SystemSetting.java @@ -38,4 +38,44 @@ public class SystemSetting { private String footer; } + + @Data + public static class Basic { + public static final String GROUP = "basic"; + String title; + String subtitle; + String logo; + String favicon; + } + + @Data + public static class User { + public static final String GROUP = "user"; + Boolean allowRegistration; + String defaultRole; + } + + @Data + public static class Post { + public static final String GROUP = "post"; + String sortOrder; + Integer pageSize; + Boolean review; + } + + @Data + public static class Seo { + public static final String GROUP = "seo"; + Boolean blockSpiders; + String keywords; + String description; + } + + @Data + public static class Comment { + public static final String GROUP = "comment"; + Boolean enable; + Boolean requireReviewForNew; + Boolean systemUserOnly; + } } diff --git a/src/main/java/run/halo/app/theme/HaloViewResolver.java b/src/main/java/run/halo/app/theme/HaloViewResolver.java index bc7157ad6..5059591a2 100644 --- a/src/main/java/run/halo/app/theme/HaloViewResolver.java +++ b/src/main/java/run/halo/app/theme/HaloViewResolver.java @@ -1,14 +1,19 @@ package run.halo.app.theme; +import java.util.HashMap; +import java.util.List; import java.util.Locale; import java.util.Map; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; import org.springframework.http.MediaType; +import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import org.springframework.web.reactive.result.view.View; import org.springframework.web.server.ServerWebExchange; import org.thymeleaf.spring6.view.reactive.ThymeleafReactiveView; import org.thymeleaf.spring6.view.reactive.ThymeleafReactiveViewResolver; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; import run.halo.app.theme.finders.FinderRegistry; @@ -49,9 +54,51 @@ public class HaloViewResolver extends ThymeleafReactiveViewResolver { // calculate the engine before rendering setTemplateEngine(engineManager.getTemplateEngine(theme)); return super.render(model, contentType, exchange) - .subscribeOn(Schedulers.boundedElastic()); + .subscribeOn(Schedulers.boundedElastic()); }); } - } + @Override + @NonNull + protected Mono<Map<String, Object>> getModelAttributes(Map<String, ?> model, + @NonNull ServerWebExchange exchange) { + Mono<Map<String, Object>> contextBasedStaticVariables = + getContextBasedStaticVariables(exchange); + Mono<Map<String, Object>> modelAttributes = super.getModelAttributes(model, exchange); + return Flux.merge(modelAttributes, contextBasedStaticVariables) + .collectList() + .map(modelMapList -> { + Map<String, Object> result = new HashMap<>(); + modelMapList.forEach(result::putAll); + return result; + }); + } + + @NonNull + private Mono<Map<String, Object>> getContextBasedStaticVariables( + ServerWebExchange exchange) { + ApplicationContext applicationContext = obtainApplicationContext(); + + return Mono.just(new HashMap<String, Object>()) + .flatMap(staticVariables -> { + List<Mono<Map<String, Object>>> monoList = applicationContext.getBeansOfType( + ViewContextBasedVariablesAcquirer.class) + .values() + .stream() + .map(acquirer -> acquirer.acquire(exchange)) + .toList(); + return Flux.merge(monoList) + .collectList() + .map(modelList -> { + Map<String, Object> mergedModel = new HashMap<>(); + modelList.forEach(mergedModel::putAll); + return mergedModel; + }) + .map(mergedModel -> { + staticVariables.putAll(mergedModel); + return staticVariables; + }); + }); + } + } } diff --git a/src/main/java/run/halo/app/theme/SiteSettingVariablesAcquirer.java b/src/main/java/run/halo/app/theme/SiteSettingVariablesAcquirer.java new file mode 100644 index 000000000..413a57cfa --- /dev/null +++ b/src/main/java/run/halo/app/theme/SiteSettingVariablesAcquirer.java @@ -0,0 +1,34 @@ +package run.halo.app.theme; + +import java.util.Map; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; +import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; +import run.halo.app.theme.finders.vo.SiteSettingVo; + +/** + * Site setting variables acquirer. + * + * @author guqing + * @since 2.0.0 + */ +@Component +public class SiteSettingVariablesAcquirer implements ViewContextBasedVariablesAcquirer { + + private final SystemConfigurableEnvironmentFetcher environmentFetcher; + + public SiteSettingVariablesAcquirer(SystemConfigurableEnvironmentFetcher environmentFetcher) { + this.environmentFetcher = environmentFetcher; + } + + @Override + public Mono<Map<String, Object>> acquire(ServerWebExchange exchange) { + return environmentFetcher.getConfigMap() + .filter(configMap -> configMap.getData() != null) + .map(configMap -> { + SiteSettingVo siteSettingVo = SiteSettingVo.from(configMap); + return Map.of("site", siteSettingVo); + }); + } +} diff --git a/src/main/java/run/halo/app/theme/ThemeContextBasedVariablesAcquirer.java b/src/main/java/run/halo/app/theme/ThemeContextBasedVariablesAcquirer.java new file mode 100644 index 000000000..151937d5e --- /dev/null +++ b/src/main/java/run/halo/app/theme/ThemeContextBasedVariablesAcquirer.java @@ -0,0 +1,41 @@ +package run.halo.app.theme; + +import java.util.Map; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; +import run.halo.app.theme.finders.ThemeFinder; +import run.halo.app.theme.finders.vo.ThemeVo; + +/** + * Theme context based variables acquirer. + * + * @author guqing + * @since 2.0.0 + */ +@Component +public class ThemeContextBasedVariablesAcquirer implements ViewContextBasedVariablesAcquirer { + private final ThemeFinder themeFinder; + private final ThemeResolver themeResolver; + + public ThemeContextBasedVariablesAcquirer(ThemeFinder themeFinder, + ThemeResolver themeResolver) { + this.themeFinder = themeFinder; + this.themeResolver = themeResolver; + } + + @Override + public Mono<Map<String, Object>> acquire(ServerWebExchange exchange) { + return themeResolver.getTheme(exchange.getRequest()) + .publishOn(Schedulers.boundedElastic()) + .map(themeContext -> { + String name = themeContext.getName(); + ThemeVo themeVo = themeFinder.getByName(name); + if (themeVo == null) { + return Map.of(); + } + return Map.of("theme", themeVo); + }); + } +} diff --git a/src/main/java/run/halo/app/theme/ViewContextBasedVariablesAcquirer.java b/src/main/java/run/halo/app/theme/ViewContextBasedVariablesAcquirer.java new file mode 100644 index 000000000..003c307a4 --- /dev/null +++ b/src/main/java/run/halo/app/theme/ViewContextBasedVariablesAcquirer.java @@ -0,0 +1,11 @@ +package run.halo.app.theme; + +import java.util.Map; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +@FunctionalInterface +public interface ViewContextBasedVariablesAcquirer { + + Mono<Map<String, Object>> acquire(ServerWebExchange exchange); +} diff --git a/src/main/java/run/halo/app/theme/dialect/HaloProcessorDialect.java b/src/main/java/run/halo/app/theme/dialect/HaloProcessorDialect.java index 7a8cb77dd..8aea154e2 100644 --- a/src/main/java/run/halo/app/theme/dialect/HaloProcessorDialect.java +++ b/src/main/java/run/halo/app/theme/dialect/HaloProcessorDialect.java @@ -13,7 +13,7 @@ import org.thymeleaf.standard.StandardDialect; * @since 2.0.0 */ public class HaloProcessorDialect extends AbstractProcessorDialect { - private static final String DIALECT_NAME = "Halo Theme Dialect"; + private static final String DIALECT_NAME = "haloThemeProcessorDialect"; public HaloProcessorDialect() { // We will set this dialect the same "dialect processor" precedence as @@ -27,6 +27,7 @@ public class HaloProcessorDialect extends AbstractProcessorDialect { // add more processors processors.add(new GlobalHeadInjectionProcessor(dialectPrefix)); processors.add(new TemplateFooterElementTagProcessor(dialectPrefix)); + processors.add(new JsonNodePropertyAccessorBoundariesProcessor()); return processors; } } diff --git a/src/main/java/run/halo/app/theme/dialect/JsonNodePropertyAccessorBoundariesProcessor.java b/src/main/java/run/halo/app/theme/dialect/JsonNodePropertyAccessorBoundariesProcessor.java new file mode 100644 index 000000000..3b69a0cdc --- /dev/null +++ b/src/main/java/run/halo/app/theme/dialect/JsonNodePropertyAccessorBoundariesProcessor.java @@ -0,0 +1,45 @@ +package run.halo.app.theme.dialect; + +import org.springframework.integration.json.JsonPropertyAccessor; +import org.thymeleaf.context.ITemplateContext; +import org.thymeleaf.model.ITemplateEnd; +import org.thymeleaf.model.ITemplateStart; +import org.thymeleaf.processor.templateboundaries.AbstractTemplateBoundariesProcessor; +import org.thymeleaf.processor.templateboundaries.ITemplateBoundariesStructureHandler; +import org.thymeleaf.spring6.expression.ThymeleafEvaluationContext; +import org.thymeleaf.standard.StandardDialect; +import org.thymeleaf.templatemode.TemplateMode; + +/** + * A template boundaries processor for add {@link JsonPropertyAccessor} to + * {@link ThymeleafEvaluationContext}. + * + * @author guqing + * @since 2.0.0 + */ +public class JsonNodePropertyAccessorBoundariesProcessor + extends AbstractTemplateBoundariesProcessor { + private static final int PRECEDENCE = StandardDialect.PROCESSOR_PRECEDENCE; + private static final JsonPropertyAccessor JSON_PROPERTY_ACCESSOR = new JsonPropertyAccessor(); + + public JsonNodePropertyAccessorBoundariesProcessor() { + super(TemplateMode.HTML, PRECEDENCE); + } + + @Override + public void doProcessTemplateStart(ITemplateContext context, ITemplateStart templateStart, + ITemplateBoundariesStructureHandler structureHandler) { + ThymeleafEvaluationContext evaluationContext = + (ThymeleafEvaluationContext) context.getVariable( + ThymeleafEvaluationContext.THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME); + if (evaluationContext != null) { + evaluationContext.addPropertyAccessor(JSON_PROPERTY_ACCESSOR); + } + } + + @Override + public void doProcessTemplateEnd(ITemplateContext context, ITemplateEnd templateEnd, + ITemplateBoundariesStructureHandler structureHandler) { + // nothing to do + } +} diff --git a/src/main/java/run/halo/app/theme/finders/ThemeFinder.java b/src/main/java/run/halo/app/theme/finders/ThemeFinder.java new file mode 100644 index 000000000..aed5db34c --- /dev/null +++ b/src/main/java/run/halo/app/theme/finders/ThemeFinder.java @@ -0,0 +1,16 @@ +package run.halo.app.theme.finders; + +import run.halo.app.theme.finders.vo.ThemeVo; + +/** + * A finder for theme. + * + * @author guqing + * @since 2.0.0 + */ +public interface ThemeFinder { + + ThemeVo activation(); + + ThemeVo getByName(String themeName); +} diff --git a/src/main/java/run/halo/app/theme/finders/impl/ThemeFinderImpl.java b/src/main/java/run/halo/app/theme/finders/impl/ThemeFinderImpl.java new file mode 100644 index 000000000..aba9b9102 --- /dev/null +++ b/src/main/java/run/halo/app/theme/finders/impl/ThemeFinderImpl.java @@ -0,0 +1,68 @@ +package run.halo.app.theme.finders.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.HashMap; +import java.util.Map; +import org.apache.commons.lang3.StringUtils; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.Theme; +import run.halo.app.extension.ConfigMap; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.SystemSetting; +import run.halo.app.infra.utils.JsonUtils; +import run.halo.app.theme.finders.Finder; +import run.halo.app.theme.finders.ThemeFinder; +import run.halo.app.theme.finders.vo.ThemeVo; + +/** + * A default implementation for {@link ThemeFinder}. + * + * @author guqing + * @since 2.0.0 + */ +@Finder("themeFinder") +public class ThemeFinderImpl implements ThemeFinder { + + private final ReactiveExtensionClient client; + private final SystemConfigurableEnvironmentFetcher environmentFetcher; + + public ThemeFinderImpl(ReactiveExtensionClient client, + SystemConfigurableEnvironmentFetcher environmentFetcher) { + this.client = client; + this.environmentFetcher = environmentFetcher; + } + + @Override + public ThemeVo activation() { + return environmentFetcher.fetch(SystemSetting.Theme.GROUP, SystemSetting.Theme.class) + .map(SystemSetting.Theme::getActive) + .flatMap(themeName -> client.fetch(Theme.class, themeName)) + .flatMap(theme -> themeWithConfig(ThemeVo.from(theme))) + .block(); + } + + @Override + public ThemeVo getByName(String themeName) { + return client.fetch(Theme.class, themeName) + .flatMap(theme -> themeWithConfig(ThemeVo.from(theme))) + .block(); + } + + private Mono<ThemeVo> themeWithConfig(ThemeVo themeVo) { + if (StringUtils.isBlank(themeVo.getSpec().getConfigMapName())) { + return Mono.just(themeVo); + } + return client.fetch(ConfigMap.class, themeVo.getSpec().getConfigMapName()) + .map(configMap -> { + Map<String, JsonNode> config = new HashMap<>(); + configMap.getData().forEach((k, v) -> { + JsonNode jsonNode = JsonUtils.jsonToObject(v, JsonNode.class); + config.put(k, jsonNode); + }); + JsonNode configJson = JsonUtils.mapToObject(config, JsonNode.class); + return themeVo.withConfig(configJson); + }) + .switchIfEmpty(Mono.just(themeVo)); + } +} diff --git a/src/main/java/run/halo/app/theme/finders/vo/SiteSettingVo.java b/src/main/java/run/halo/app/theme/finders/vo/SiteSettingVo.java new file mode 100644 index 000000000..0e7684912 --- /dev/null +++ b/src/main/java/run/halo/app/theme/finders/vo/SiteSettingVo.java @@ -0,0 +1,121 @@ +package run.halo.app.theme.finders.vo; + +import java.util.Map; +import lombok.Builder; +import lombok.Value; +import org.springframework.util.Assert; +import run.halo.app.extension.ConfigMap; +import run.halo.app.infra.SystemSetting; +import run.halo.app.infra.utils.JsonUtils; + +/** + * Site setting value object for theme. + * + * @author guqing + * @since 2.0.0 + */ +@Value +@Builder +public class SiteSettingVo { + + String title; + + String subtitle; + + String logo; + + String favicon; + + Boolean allowRegistration; + + PostSetting post; + + SeoSetting seo; + + CommentSetting comment; + + /** + * Convert to system {@link ConfigMap} to {@link SiteSettingVo}. + * + * @param configMap config map named system + * @return site setting value object + */ + public static SiteSettingVo from(ConfigMap configMap) { + Assert.notNull(configMap, "The configMap must not be null."); + Map<String, String> data = configMap.getData(); + if (data == null) { + return builder().build(); + } + SystemSetting.Basic basicSetting = + toObject(data.get(SystemSetting.Basic.GROUP), SystemSetting.Basic.class); + + SystemSetting.User userSetting = + toObject(data.get(SystemSetting.User.GROUP), SystemSetting.User.class); + + SystemSetting.Post postSetting = + toObject(data.get(SystemSetting.Post.GROUP), SystemSetting.Post.class); + + SystemSetting.Seo seoSetting = + toObject(data.get(SystemSetting.Seo.GROUP), SystemSetting.Seo.class); + + SystemSetting.Comment commentSetting = toObject(data.get(SystemSetting.Comment.GROUP), + SystemSetting.Comment.class); + return builder() + .title(basicSetting.getTitle()) + .subtitle(basicSetting.getSubtitle()) + .logo(basicSetting.getLogo()) + .favicon(basicSetting.getFavicon()) + .allowRegistration(userSetting.getAllowRegistration()) + .post(PostSetting.builder() + .sortOrder(postSetting.getSortOrder()) + .pageSize(postSetting.getPageSize()) + .build()) + .seo(SeoSetting.builder() + .blockSpiders(seoSetting.getBlockSpiders()) + .keywords(seoSetting.getKeywords()) + .description(seoSetting.getDescription()) + .build()) + .comment(CommentSetting.builder() + .enable(commentSetting.getEnable()) + .requireReviewForNew(commentSetting.getRequireReviewForNew()) + .systemUserOnly(commentSetting.getSystemUserOnly()) + .build()) + .build(); + } + + private static <T> T toObject(String json, Class<T> type) { + if (json == null) { + // empty object + json = "{}"; + } + return JsonUtils.jsonToObject(json, type); + } + + @Value + @Builder + public static class PostSetting { + String sortOrder; + + Integer pageSize; + } + + @Value + @Builder + public static class SeoSetting { + Boolean blockSpiders; + + String keywords; + + String description; + } + + @Value + @Builder + public static class CommentSetting { + Boolean enable; + + Boolean systemUserOnly; + + Boolean requireReviewForNew; + } +} diff --git a/src/main/java/run/halo/app/theme/finders/vo/ThemeVo.java b/src/main/java/run/halo/app/theme/finders/vo/ThemeVo.java new file mode 100644 index 000000000..b6725ef0b --- /dev/null +++ b/src/main/java/run/halo/app/theme/finders/vo/ThemeVo.java @@ -0,0 +1,42 @@ +package run.halo.app.theme.finders.vo; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Builder; +import lombok.ToString; +import lombok.Value; +import lombok.With; +import run.halo.app.core.extension.Theme; +import run.halo.app.extension.MetadataOperator; + +/** + * A value object for {@link Theme}. + * + * @author guqing + * @since 2.0.0 + */ +@Value +@Builder +@ToString +public class ThemeVo { + + MetadataOperator metadata; + + Theme.ThemeSpec spec; + + @With + JsonNode config; + + /** + * Convert {@link Theme} to {@link ThemeVo}. + * + * @param theme theme extension + * @return theme value object + */ + public static ThemeVo from(Theme theme) { + return ThemeVo.builder() + .metadata(theme.getMetadata()) + .spec(theme.getSpec()) + .config(null) + .build(); + } +} diff --git a/src/main/resources/extensions/system-setting.yaml b/src/main/resources/extensions/system-setting.yaml index e8e073b73..2b72192a6 100644 --- a/src/main/resources/extensions/system-setting.yaml +++ b/src/main/resources/extensions/system-setting.yaml @@ -91,7 +91,7 @@ spec: label: SEO 设置 formSchema: - $formkit: checkbox - name: blockSpides + name: blockSpiders label: "屏蔽搜索引擎" value: false - $formkit: textarea diff --git a/src/test/java/run/halo/app/core/extension/reconciler/SystemSettingReconcilerTest.java b/src/test/java/run/halo/app/core/extension/reconciler/SystemSettingReconcilerTest.java index 64e1d0d1c..3a6b0d3e2 100644 --- a/src/test/java/run/halo/app/core/extension/reconciler/SystemSettingReconcilerTest.java +++ b/src/test/java/run/halo/app/core/extension/reconciler/SystemSettingReconcilerTest.java @@ -1,15 +1,30 @@ package run.halo.app.core.extension.reconciler; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationContext; +import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ExtensionClient; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.controller.Reconciler; import run.halo.app.infra.SystemSetting; +import run.halo.app.infra.utils.JsonUtils; /** * Tests for {@link SystemSettingReconciler}. @@ -34,16 +49,133 @@ class SystemSettingReconcilerTest { } @Test - void changePostPatternPrefixIfNecessary() { - SystemSetting.ThemeRouteRules oldRouteRules = new SystemSetting.ThemeRouteRules(); - oldRouteRules.setPost("/archives/{slug}"); - oldRouteRules.setArchives("archives"); + void reconcileArchivesRouteRule() { + ConfigMap configMap = systemConfigMapForRouteRule(rules -> { + rules.setArchives("archives-new"); + return rules; + }); + when(client.fetch(eq(ConfigMap.class), eq(SystemSetting.SYSTEM_CONFIG))) + .thenReturn(Optional.of(configMap)); + systemSettingReconciler.reconcile(new Reconciler.Request(SystemSetting.SYSTEM_CONFIG)); + ArgumentCaptor<ConfigMap> captor = ArgumentCaptor.forClass(ConfigMap.class); + verify(client, times(2)).update(captor.capture()); + List<ConfigMap> allValues = captor.getAllValues(); + ConfigMap updatedConfigMap = allValues.get(1); + assertThat(rulesFrom(updatedConfigMap).getArchives()).isEqualTo("archives-new"); + assertThat(rulesFrom(updatedConfigMap).getPost()).isEqualTo("/archives-new/{slug}"); + + assertThat(oldRulesFromAnno(updatedConfigMap).getArchives()).isEqualTo("archives-new"); + assertThat(oldRulesFromAnno(updatedConfigMap).getPost()).isEqualTo("/archives-new/{slug}"); + + // archives and post + verify(applicationContext, times(2)).publishEvent(any()); + } + + @Test + void reconcileTagsRule() { + ConfigMap configMap = systemConfigMapForRouteRule(rules -> { + rules.setTags("tags-new"); + return rules; + }); + when(client.fetch(eq(ConfigMap.class), eq(SystemSetting.SYSTEM_CONFIG))) + .thenReturn(Optional.of(configMap)); + systemSettingReconciler.reconcile(new Reconciler.Request(SystemSetting.SYSTEM_CONFIG)); + ArgumentCaptor<ConfigMap> captor = ArgumentCaptor.forClass(ConfigMap.class); + verify(client, times(2)).update(captor.capture()); + + List<ConfigMap> allValues = captor.getAllValues(); + ConfigMap updatedConfigMap = allValues.get(1); + assertThat(rulesFrom(updatedConfigMap).getTags()).isEqualTo("tags-new"); + + assertThat(oldRulesFromAnno(updatedConfigMap).getTags()).isEqualTo("tags-new"); + + verify(applicationContext, times(1)).publishEvent(any()); + } + + @Test + void reconcileCategoriesRule() { + ConfigMap configMap = systemConfigMapForRouteRule(rules -> { + rules.setCategories("categories-new"); + return rules; + }); + when(client.fetch(eq(ConfigMap.class), eq(SystemSetting.SYSTEM_CONFIG))) + .thenReturn(Optional.of(configMap)); + systemSettingReconciler.reconcile(new Reconciler.Request(SystemSetting.SYSTEM_CONFIG)); + ArgumentCaptor<ConfigMap> captor = ArgumentCaptor.forClass(ConfigMap.class); + verify(client, times(2)).update(captor.capture()); + + List<ConfigMap> allValues = captor.getAllValues(); + ConfigMap updatedConfigMap = allValues.get(1); + assertThat(rulesFrom(updatedConfigMap).getCategories()).isEqualTo("categories-new"); + + assertThat(oldRulesFromAnno(updatedConfigMap).getCategories()).isEqualTo("categories-new"); + + verify(applicationContext, times(1)).publishEvent(any()); + } + + @Test + void reconcilePostRule() { + ConfigMap configMap = systemConfigMapForRouteRule(rules -> { + rules.setPost("/post-new/{slug}"); + return rules; + }); + when(client.fetch(eq(ConfigMap.class), eq(SystemSetting.SYSTEM_CONFIG))) + .thenReturn(Optional.of(configMap)); + systemSettingReconciler.reconcile(new Reconciler.Request(SystemSetting.SYSTEM_CONFIG)); + ArgumentCaptor<ConfigMap> captor = ArgumentCaptor.forClass(ConfigMap.class); + verify(client, times(2)).update(captor.capture()); + + List<ConfigMap> allValues = captor.getAllValues(); + ConfigMap updatedConfigMap = allValues.get(1); + assertThat(rulesFrom(updatedConfigMap).getPost()).isEqualTo("/post-new/{slug}"); + + assertThat(oldRulesFromAnno(updatedConfigMap).getPost()).isEqualTo("/post-new/{slug}"); + + verify(applicationContext, times(1)).publishEvent(any()); + } + + private SystemSetting.ThemeRouteRules rulesFrom(ConfigMap configMap) { + String s = configMap.getData().get(SystemSetting.ThemeRouteRules.GROUP); + return JsonUtils.jsonToObject(s, SystemSetting.ThemeRouteRules.class); + } + + private SystemSetting.ThemeRouteRules oldRulesFromAnno(ConfigMap configMap) { + Map<String, String> annotations = configMap.getMetadata().getAnnotations(); + String s = annotations.get(SystemSettingReconciler.OLD_THEME_ROUTE_RULES); + return JsonUtils.jsonToObject(s, SystemSetting.ThemeRouteRules.class); + } + + private ConfigMap systemConfigMapForRouteRule( + Function<SystemSetting.ThemeRouteRules, SystemSetting.ThemeRouteRules> function) { + ConfigMap configMap = new ConfigMap(); + Metadata metadata = new Metadata(); + metadata.setName(SystemSetting.SYSTEM_CONFIG); + configMap.setMetadata(metadata); + + SystemSetting.ThemeRouteRules themeRouteRules = new SystemSetting.ThemeRouteRules(); + themeRouteRules.setArchives("archives"); + themeRouteRules.setTags("tags"); + themeRouteRules.setCategories("categories"); + themeRouteRules.setPost("/archives/{slug}"); + Map<String, String> annotations = new HashMap<>(); + annotations.put(SystemSettingReconciler.OLD_THEME_ROUTE_RULES, + JsonUtils.objectToJson(themeRouteRules)); + metadata.setAnnotations(annotations); + + SystemSetting.ThemeRouteRules newRules = function.apply(themeRouteRules); + configMap.putDataItem(SystemSetting.ThemeRouteRules.GROUP, + JsonUtils.objectToJson(newRules)); + return configMap; + } + + @Test + void changePostPatternPrefixIfNecessary() { SystemSetting.ThemeRouteRules newRouteRules = new SystemSetting.ThemeRouteRules(); newRouteRules.setPost("/archives/{slug}"); newRouteRules.setArchives("new"); - boolean result = systemSettingReconciler.changePostPatternPrefixIfNecessary(oldRouteRules, - newRouteRules); + boolean result = SystemSettingReconciler.RouteRuleReconciler + .changePostPatternPrefixIfNecessary("archives", newRouteRules); assertThat(result).isTrue(); assertThat(newRouteRules.getPost()).isEqualTo("/new/{slug}"); diff --git a/src/test/java/run/halo/app/theme/dialect/ThemeJava8TimeDialectIntegrationTest.java b/src/test/java/run/halo/app/theme/dialect/ThemeJava8TimeDialectIntegrationTest.java index f90c86cd1..64374da63 100644 --- a/src/test/java/run/halo/app/theme/dialect/ThemeJava8TimeDialectIntegrationTest.java +++ b/src/test/java/run/halo/app/theme/dialect/ThemeJava8TimeDialectIntegrationTest.java @@ -13,7 +13,6 @@ import java.time.ZoneId; import java.util.Locale; import java.util.Map; import java.util.TimeZone; -import java.util.function.Function; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -53,8 +52,6 @@ class ThemeJava8TimeDialectIntegrationTest { private URL defaultThemeUrl; - Function<ServerHttpRequest, ThemeContext> themeContextFunction; - @Autowired private WebTestClient webTestClient; @@ -63,8 +60,8 @@ class ThemeJava8TimeDialectIntegrationTest { @BeforeEach void setUp() throws FileNotFoundException { defaultThemeUrl = ResourceUtils.getURL("classpath:themes/default"); - when(themeResolver.getTheme(any(ServerHttpRequest.class))).thenReturn( - Mono.just(createDefaultContext())); + when(themeResolver.getTheme(any(ServerHttpRequest.class))) + .thenReturn(Mono.just(createDefaultContext())); defaultTimeZone = TimeZone.getDefault(); }