Skip to content

Commit

Permalink
refactor: add system initialization check and redirect to console if …
Browse files Browse the repository at this point in the history
…not initialized (#3892)

#### 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
添加系统初始化检查,如果未初始化则重定向到控制台
```
  • Loading branch information
guqing committed May 4, 2023
1 parent 0f03922 commit a825050
Show file tree
Hide file tree
Showing 4 changed files with 267 additions and 0 deletions.
96 changes: 96 additions & 0 deletions 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;

/**
* <p>A cache that caches system setup state.</p>
* when setUp state changed, the cache will be updated.
*
* @author guqing
* @since 2.5.2
*/
@Component
public class SetupStateCache implements Reconciler<Reconciler.Request>, Supplier<Boolean> {
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;
}

/**
* <p>Gets system setup state.</p>
* Never return null.
*
* @return <code>true</code> if system is initialized, <code>false</code> 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 <code>true</code> if system is initialized, <code>false</code> 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();
}
}
}
@@ -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<Void> 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;
}
}
@@ -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<Void> 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<Void> 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<Void> 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));
}
}
Expand Up @@ -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;

Expand All @@ -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");

Expand Down

0 comments on commit a825050

Please sign in to comment.