From c5f9c766bb2e04e7e5f8bf0f16c279c8b8af158b Mon Sep 17 00:00:00 2001 From: John Niang Date: Sat, 14 Sep 2024 10:48:29 +0800 Subject: [PATCH] Support changing locale using query language (#6658) 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.20.x #### What this PR does / why we need it: This PR adds support changing locale using query `language`. After passing the query, we will automatically respond a cookie `language` back to browser. Please see the result below: ```bash http http://localhost:8090/\?language\=zh-CN Accept:text/html -p h HTTP/1.1 200 OK Cache-Control: no-cache, no-store, max-age=0, must-revalidate Content-Language: zh-CN Content-Type: text/html Expires: 0 Pragma: no-cache Referrer-Policy: strict-origin-when-cross-origin Vary: Origin Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers X-Content-Type-Options: nosniff X-Frame-Options: SAMEORIGIN X-XSS-Protection: 0 content-encoding: gzip content-length: 4765 set-cookie: language=zh-CN; Path=/; Secure set-cookie: XSRF-TOKEN=f0f2c972-0024-4575-aef2-0609356b4757; Path=/ ``` #### Does this PR introduce a user-facing change? ```release-note 支持利用参数 language 切换地域语言 ``` --- .../app/webfilter/LocaleChangeWebFilter.java | 59 ++++++++++++ .../webfilter/LocaleChangeWebFilterTest.java | 90 +++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 application/src/main/java/run/halo/app/webfilter/LocaleChangeWebFilter.java create mode 100644 application/src/test/java/run/halo/app/webfilter/LocaleChangeWebFilterTest.java diff --git a/application/src/main/java/run/halo/app/webfilter/LocaleChangeWebFilter.java b/application/src/main/java/run/halo/app/webfilter/LocaleChangeWebFilter.java new file mode 100644 index 000000000..3329d34f3 --- /dev/null +++ b/application/src/main/java/run/halo/app/webfilter/LocaleChangeWebFilter.java @@ -0,0 +1,59 @@ +package run.halo.app.webfilter; + +import static run.halo.app.theme.ThemeLocaleContextResolver.LANGUAGE_COOKIE_NAME; +import static run.halo.app.theme.ThemeLocaleContextResolver.LANGUAGE_PARAMETER_NAME; + +import java.util.Locale; +import java.util.Set; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseCookie; +import org.springframework.lang.NonNull; +import org.springframework.security.web.server.util.matcher.AndServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.MediaTypeServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher.MatchResult; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; + +@Component +public class LocaleChangeWebFilter implements WebFilter { + + private final ServerWebExchangeMatcher matcher; + + public LocaleChangeWebFilter() { + var pathMatcher = ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, "/**"); + var textHtmlMatcher = new MediaTypeServerWebExchangeMatcher(MediaType.TEXT_HTML); + textHtmlMatcher.setIgnoredMediaTypes(Set.of(MediaType.ALL)); + matcher = new AndServerWebExchangeMatcher(pathMatcher, textHtmlMatcher); + } + + @Override + @NonNull + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + var request = exchange.getRequest(); + return matcher.matches(exchange) + .filter(MatchResult::isMatch) + .doOnNext(result -> { + var language = request + .getQueryParams() + .getFirst(LANGUAGE_PARAMETER_NAME); + if (StringUtils.hasText(language)) { + var locale = Locale.forLanguageTag(language); + exchange.getResponse() + .addCookie(ResponseCookie.from(LANGUAGE_COOKIE_NAME, locale.toLanguageTag()) + .path("/") + .secure(true) + .build() + ); + } + }) + .then(Mono.defer(() -> chain.filter(exchange))); + } + +} diff --git a/application/src/test/java/run/halo/app/webfilter/LocaleChangeWebFilterTest.java b/application/src/test/java/run/halo/app/webfilter/LocaleChangeWebFilterTest.java new file mode 100644 index 000000000..0ec207fa8 --- /dev/null +++ b/application/src/test/java/run/halo/app/webfilter/LocaleChangeWebFilterTest.java @@ -0,0 +1,90 @@ +package run.halo.app.webfilter; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.http.MediaType; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; + +class LocaleChangeWebFilterTest { + + LocaleChangeWebFilter filter; + + @BeforeEach + void setUp() { + filter = new LocaleChangeWebFilter(); + } + + @Test + void shouldRespondLanguageCookie() { + WebFilterChain webFilterChain = filterExchange -> { + var languageCookie = filterExchange.getResponse().getCookies().getFirst("language"); + assertNotNull(languageCookie); + assertEquals("zh-CN", languageCookie.getValue()); + return Mono.empty(); + }; + var exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/home") + .accept(MediaType.TEXT_HTML) + .queryParam("language", "zh-CN") + .build() + ); + this.filter.filter(exchange, webFilterChain).block(); + } + + @Test + void shouldRespondLanguageCookieWithUndefinedLanguageTag() { + WebFilterChain webFilterChain = filterExchange -> { + var languageCookie = filterExchange.getResponse().getCookies().getFirst("language"); + assertNotNull(languageCookie); + assertEquals("und", languageCookie.getValue()); + return Mono.empty(); + }; + var exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/home") + .accept(MediaType.TEXT_HTML) + .queryParam("language", "invalid_language_tag") + .build() + ); + this.filter.filter(exchange, webFilterChain).block(); + } + + @ParameterizedTest + @MethodSource("provideInvalidRequest") + void shouldNotRespondLanguageCookieIfRequestNotMatch(MockServerHttpRequest mockRequest) { + WebFilterChain webFilterChain = filterExchange -> { + var languageCookie = filterExchange.getResponse().getCookies().getFirst("language"); + assertNull(languageCookie); + return Mono.empty(); + }; + var exchange = MockServerWebExchange.from(mockRequest); + this.filter.filter(exchange, webFilterChain).block(); + } + + static Stream provideInvalidRequest() { + return Stream.of( + MockServerHttpRequest.get("/home") + .accept(MediaType.ALL) + .queryParam("language", "zh-CN") + .build(), + MockServerHttpRequest.get("/home") + .accept(MediaType.APPLICATION_JSON) + .queryParam("language", "zh-CN") + .build(), + MockServerHttpRequest.post("/home") + .accept(MediaType.TEXT_HTML) + .queryParam("language", "zh-CN") + .build(), + MockServerHttpRequest.get("/home") + .accept(MediaType.TEXT_HTML) + .build() + ); + } +} \ No newline at end of file