From a8250500fc56916bb3a4d640e45152900312a6f6 Mon Sep 17 00:00:00 2001 From: guqing <38999863+guqing@users.noreply.github.com> Date: Thu, 4 May 2023 15:40:38 +0800 Subject: [PATCH] refactor: add system initialization check and redirect to console if not initialized (#3892) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### What type of PR is this? /kind improvement /area core /milestone 2.5.2 #### What this PR does / why we need it: 添加系统初始化检查,如果未初始化则重定向到控制台。 此检查只针对首页,当用户访问首页时检查到未初始化则跳转到 Console 让用户初始化以优化没有数据时的访问体验。 SetupStateCache 用于缓存系统初始化状态,当数据库状态改变时会更新缓存以优化性能,避免每次访问首页都查询数据。 #### Which issue(s) this PR fixes: A part of #3230 #### Does this PR introduce a user-facing change? ```release-note 添加系统初始化检查,如果未初始化则重定向到控制台 ``` --- .../run/halo/app/infra/SetupStateCache.java | 96 +++++++++++++++ .../InitializeRedirectionWebFilter.java | 57 +++++++++ .../InitializeRedirectionWebFilterTest.java | 109 ++++++++++++++++++ .../ThemeMessageResolverIntegrationTest.java | 5 + 4 files changed, 267 insertions(+) create mode 100644 application/src/main/java/run/halo/app/infra/SetupStateCache.java create mode 100644 application/src/main/java/run/halo/app/security/InitializeRedirectionWebFilter.java create mode 100644 application/src/test/java/run/halo/app/security/InitializeRedirectionWebFilterTest.java diff --git a/application/src/main/java/run/halo/app/infra/SetupStateCache.java b/application/src/main/java/run/halo/app/infra/SetupStateCache.java new file mode 100644 index 0000000000..b989953433 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/SetupStateCache.java @@ -0,0 +1,96 @@ +package run.halo.app.infra; + +import io.micrometer.common.util.StringUtils; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; +import lombok.Data; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; +import run.halo.app.extension.ConfigMap; +import run.halo.app.extension.ExtensionClient; +import run.halo.app.extension.controller.Controller; +import run.halo.app.extension.controller.ControllerBuilder; +import run.halo.app.extension.controller.Reconciler; +import run.halo.app.infra.utils.JsonUtils; + +/** + *

A cache that caches system setup state.

+ * when setUp state changed, the cache will be updated. + * + * @author guqing + * @since 2.5.2 + */ +@Component +public class SetupStateCache implements Reconciler, Supplier { + public static final String SYSTEM_STATES_CONFIGMAP = "system-states"; + private final ExtensionClient client; + + private final InternalValueCache valueCache = new InternalValueCache(); + + public SetupStateCache(ExtensionClient client) { + this.client = client; + } + + /** + *

Gets system setup state.

+ * Never return null. + * + * @return true if system is initialized, false otherwise. + */ + @NonNull + @Override + public Boolean get() { + return valueCache.get(); + } + + @Override + public Result reconcile(Request request) { + if (!SYSTEM_STATES_CONFIGMAP.equals(request.name())) { + return Result.doNotRetry(); + } + valueCache.cache(isInitialized()); + return Result.doNotRetry(); + } + + @Override + public Controller setupWith(ControllerBuilder builder) { + return builder + .extension(new ConfigMap()) + .build(); + } + + /** + * Check if system is initialized. + * + * @return true if system is initialized, false otherwise. + */ + private boolean isInitialized() { + return client.fetch(ConfigMap.class, SYSTEM_STATES_CONFIGMAP) + .filter(configMap -> configMap.getData() != null) + .map(ConfigMap::getData) + .flatMap(map -> Optional.ofNullable(map.get(SystemStates.GROUP)) + .filter(StringUtils::isNotBlank) + .map(value -> JsonUtils.jsonToObject(value, SystemStates.class).getIsSetup()) + ) + .orElse(false); + } + + @Data + static class SystemStates { + static final String GROUP = "states"; + Boolean isSetup; + } + + static class InternalValueCache { + private final AtomicBoolean value = new AtomicBoolean(false); + + public boolean cache(boolean newValue) { + return value.getAndSet(newValue); + } + + public boolean get() { + return value.get(); + } + } +} diff --git a/application/src/main/java/run/halo/app/security/InitializeRedirectionWebFilter.java b/application/src/main/java/run/halo/app/security/InitializeRedirectionWebFilter.java new file mode 100644 index 0000000000..e7f9b83864 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/InitializeRedirectionWebFilter.java @@ -0,0 +1,57 @@ +package run.halo.app.security; + +import java.net.URI; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpMethod; +import org.springframework.lang.NonNull; +import org.springframework.security.web.server.DefaultServerRedirectStrategy; +import org.springframework.security.web.server.ServerRedirectStrategy; +import org.springframework.security.web.server.util.matcher.PathPatternParserServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; +import org.springframework.stereotype.Component; +import org.springframework.util.Assert; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; +import run.halo.app.infra.SetupStateCache; + +/** + * A web filter that will redirect user to set up page if system is not initialized. + * + * @author guqing + * @since 2.5.2 + */ +@Component +@RequiredArgsConstructor +public class InitializeRedirectionWebFilter implements WebFilter { + private final URI location = URI.create("/console"); + private final ServerWebExchangeMatcher redirectMatcher = + new PathPatternParserServerWebExchangeMatcher("/", HttpMethod.GET); + + private final SetupStateCache setupStateCache; + + private ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy(); + + @Override + @NonNull + public Mono filter(@NonNull ServerWebExchange exchange, @NonNull WebFilterChain chain) { + return redirectMatcher.matches(exchange) + .flatMap(matched -> { + if (!matched.isMatch() || setupStateCache.get()) { + return chain.filter(exchange); + } + // Redirect to set up page if system is not initialized. + return redirectStrategy.sendRedirect(exchange, location); + }); + } + + public ServerRedirectStrategy getRedirectStrategy() { + return redirectStrategy; + } + + public void setRedirectStrategy(ServerRedirectStrategy redirectStrategy) { + Assert.notNull(redirectStrategy, "redirectStrategy cannot be null"); + this.redirectStrategy = redirectStrategy; + } +} diff --git a/application/src/test/java/run/halo/app/security/InitializeRedirectionWebFilterTest.java b/application/src/test/java/run/halo/app/security/InitializeRedirectionWebFilterTest.java new file mode 100644 index 0000000000..121c8f0ee1 --- /dev/null +++ b/application/src/test/java/run/halo/app/security/InitializeRedirectionWebFilterTest.java @@ -0,0 +1,109 @@ +package run.halo.app.security; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.net.URI; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.security.web.server.ServerRedirectStrategy; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import run.halo.app.infra.SetupStateCache; + +/** + * Tests for {@link InitializeRedirectionWebFilter}. + * + * @author guqing + * @since 2.5.2 + */ +@ExtendWith(MockitoExtension.class) +class InitializeRedirectionWebFilterTest { + + @Mock + private SetupStateCache setupStateCache; + + @Mock + private ServerRedirectStrategy serverRedirectStrategy; + + @InjectMocks + private InitializeRedirectionWebFilter filter; + + @BeforeEach + void setUp() { + filter.setRedirectStrategy(serverRedirectStrategy); + } + + @Test + void shouldRedirectWhenSystemNotInitialized() { + when(setupStateCache.get()).thenReturn(false); + + WebFilterChain chain = mock(WebFilterChain.class); + + MockServerHttpRequest request = MockServerHttpRequest.get("/").build(); + MockServerWebExchange exchange = MockServerWebExchange.from(request); + + when(serverRedirectStrategy.sendRedirect(any(), any())).thenReturn(Mono.empty().then()); + + Mono result = filter.filter(exchange, chain); + + StepVerifier.create(result) + .expectNextCount(0) + .expectComplete() + .verify(); + + verify(serverRedirectStrategy).sendRedirect(eq(exchange), eq(URI.create("/console"))); + verify(chain, never()).filter(eq(exchange)); + } + + @Test + void shouldNotRedirectWhenSystemInitialized() { + when(setupStateCache.get()).thenReturn(true); + + WebFilterChain chain = mock(WebFilterChain.class); + + MockServerHttpRequest request = MockServerHttpRequest.get("/").build(); + MockServerWebExchange exchange = MockServerWebExchange.from(request); + when(chain.filter(any())).thenReturn(Mono.empty().then()); + Mono result = filter.filter(exchange, chain); + + StepVerifier.create(result) + .expectNextCount(0) + .expectComplete() + .verify(); + + verify(serverRedirectStrategy, never()).sendRedirect(eq(exchange), + eq(URI.create("/console"))); + verify(chain).filter(eq(exchange)); + } + + @Test + void shouldNotRedirectWhenNotHomePage() { + WebFilterChain chain = mock(WebFilterChain.class); + + MockServerHttpRequest request = MockServerHttpRequest.get("/test").build(); + MockServerWebExchange exchange = MockServerWebExchange.from(request); + when(chain.filter(any())).thenReturn(Mono.empty().then()); + Mono result = filter.filter(exchange, chain); + + StepVerifier.create(result) + .expectNextCount(0) + .expectComplete() + .verify(); + + verify(serverRedirectStrategy, never()).sendRedirect(eq(exchange), + eq(URI.create("/console"))); + verify(chain).filter(eq(exchange)); + } +} diff --git a/application/src/test/java/run/halo/app/theme/message/ThemeMessageResolverIntegrationTest.java b/application/src/test/java/run/halo/app/theme/message/ThemeMessageResolverIntegrationTest.java index c80d6a0cc0..2ce586c917 100644 --- a/application/src/test/java/run/halo/app/theme/message/ThemeMessageResolverIntegrationTest.java +++ b/application/src/test/java/run/halo/app/theme/message/ThemeMessageResolverIntegrationTest.java @@ -24,6 +24,7 @@ import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; +import run.halo.app.infra.SetupStateCache; import run.halo.app.theme.ThemeContext; import run.halo.app.theme.ThemeResolver; @@ -44,11 +45,15 @@ public class ThemeMessageResolverIntegrationTest { private URL otherThemeUrl; + @SpyBean + private SetupStateCache setupStateCache; + @Autowired private WebTestClient webTestClient; @BeforeEach void setUp() throws FileNotFoundException, URISyntaxException { + when(setupStateCache.get()).thenReturn(true); defaultThemeUrl = ResourceUtils.getURL("classpath:themes/default"); otherThemeUrl = ResourceUtils.getURL("classpath:themes/other");