refactor: add system initialization check and redirect to console if 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
添加系统初始化检查,如果未初始化则重定向到控制台
```
pull/3909/head
guqing 2023-05-04 15:40:38 +08:00 committed by GitHub
parent 0f039225ab
commit a8250500fc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 267 additions and 0 deletions

View File

@ -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();
}
}
}

View File

@ -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;
}
}

View File

@ -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));
}
}

View File

@ -24,6 +24,7 @@ import org.springframework.web.reactive.function.server.RouterFunctions;
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");