Add support for configuring external URL online

pull/7459/head
John Niang 2025-05-22 00:00:55 +08:00
parent b309bc532b
commit e9011eb3ec
No known key found for this signature in database
GPG Key ID: D7363C015BBCAA59
7 changed files with 292 additions and 212 deletions

View File

@ -71,6 +71,7 @@ public class SystemSetting {
String logo; String logo;
String favicon; String favicon;
String language; String language;
String externalUrl;
@JsonIgnore @JsonIgnore
public Optional<Locale> useSystemLocale() { public Optional<Locale> useSystemLocale() {

View File

@ -1,71 +0,0 @@
package run.halo.app.infra;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import org.springframework.boot.autoconfigure.web.reactive.WebFluxProperties;
import org.springframework.http.HttpRequest;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import run.halo.app.infra.properties.HaloProperties;
/**
* Default implementation for getting external url from halo properties.
*
* @author johnniang
*/
@Component
public class HaloPropertiesExternalUrlSupplier implements ExternalUrlSupplier {
private final HaloProperties haloProperties;
private final WebFluxProperties webFluxProperties;
public HaloPropertiesExternalUrlSupplier(HaloProperties haloProperties,
WebFluxProperties webFluxProperties) {
this.haloProperties = haloProperties;
this.webFluxProperties = webFluxProperties;
}
@Override
public URI get() {
if (!haloProperties.isUseAbsolutePermalink()) {
return URI.create(getBasePath());
}
try {
return haloProperties.getExternalUrl().toURI();
} catch (URISyntaxException e) {
throw new RuntimeException(e);
}
}
@Override
public URL getURL(HttpRequest request) {
var externalUrl = haloProperties.getExternalUrl();
if (externalUrl == null) {
try {
externalUrl = request.getURI().resolve(getBasePath()).toURL();
} catch (MalformedURLException e) {
throw new RuntimeException("Cannot parse request URI to URL.", e);
}
}
return externalUrl;
}
@Nullable
@Override
public URL getRaw() {
return haloProperties.getExternalUrl();
}
private String getBasePath() {
var basePath = webFluxProperties.getBasePath();
if (!StringUtils.hasText(basePath)) {
basePath = "/";
}
return basePath;
}
}

View File

@ -0,0 +1,109 @@
package run.halo.app.infra;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.time.Duration;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.web.reactive.WebFluxProperties;
import org.springframework.context.event.EventListener;
import org.springframework.http.HttpRequest;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import reactor.core.Exceptions;
import run.halo.app.infra.properties.HaloProperties;
/**
* Default implementation for getting external url from system config first, halo properties second.
*
* @author johnniang
*/
@Slf4j
@Component
class SystemConfigFirstExternalUrlSupplier implements ExternalUrlSupplier {
private final HaloProperties haloProperties;
private final WebFluxProperties webFluxProperties;
private final SystemConfigurableEnvironmentFetcher systemConfigFetcher;
@Nullable
private URL externalUrl;
public SystemConfigFirstExternalUrlSupplier(HaloProperties haloProperties,
WebFluxProperties webFluxProperties,
SystemConfigurableEnvironmentFetcher systemConfigFetcher) {
this.haloProperties = haloProperties;
this.webFluxProperties = webFluxProperties;
this.systemConfigFetcher = systemConfigFetcher;
}
@EventListener
void onExtensionInitialized(ExtensionInitializedEvent ignored) {
systemConfigFetcher.getBasic()
.mapNotNull(SystemSetting.Basic::getExternalUrl)
.filter(StringUtils::hasText)
.doOnNext(externalUrlString -> {
try {
this.externalUrl = URI.create(externalUrlString).toURL();
} catch (MalformedURLException e) {
log.error("""
Cannot parse external URL {} from system config. Fallback to default \
external URL supplier from properties.\
""", externalUrlString, e);
// For continuing the application startup, we need to return null here.
}
})
.blockOptional(Duration.ofSeconds(10));
}
@Override
public URI get() {
try {
if (externalUrl != null) {
return externalUrl.toURI();
}
if (!haloProperties.isUseAbsolutePermalink()) {
return URI.create(getBasePath());
}
return haloProperties.getExternalUrl().toURI();
} catch (URISyntaxException e) {
throw Exceptions.propagate(e);
}
}
@Override
public URL getURL(HttpRequest request) {
if (this.externalUrl != null) {
return this.externalUrl;
}
var externalUrl = haloProperties.getExternalUrl();
if (externalUrl != null) {
return externalUrl;
}
try {
externalUrl = request.getURI().resolve(getBasePath()).toURL();
} catch (MalformedURLException e) {
throw new RuntimeException("Cannot parse request URI to URL.", e);
}
return externalUrl;
}
@Nullable
@Override
public URL getRaw() {
return externalUrl != null ? externalUrl : haloProperties.getExternalUrl();
}
private String getBasePath() {
var basePath = webFluxProperties.getBasePath();
if (!StringUtils.hasText(basePath)) {
basePath = "/";
}
return basePath;
}
}

View File

@ -61,17 +61,17 @@ public class SystemConfigurableEnvironmentFetcher implements Reconciler<Reconcil
public Mono<SystemSetting.Basic> getBasic() { public Mono<SystemSetting.Basic> getBasic() {
return fetch(SystemSetting.Basic.GROUP, SystemSetting.Basic.class) return fetch(SystemSetting.Basic.GROUP, SystemSetting.Basic.class)
.switchIfEmpty(Mono.just(new SystemSetting.Basic())); .switchIfEmpty(Mono.fromSupplier(SystemSetting.Basic::new));
} }
public Mono<SystemSetting.Comment> fetchComment() { public Mono<SystemSetting.Comment> fetchComment() {
return fetch(SystemSetting.Comment.GROUP, SystemSetting.Comment.class) return fetch(SystemSetting.Comment.GROUP, SystemSetting.Comment.class)
.switchIfEmpty(Mono.just(new SystemSetting.Comment())); .switchIfEmpty(Mono.fromSupplier(SystemSetting.Comment::new));
} }
public Mono<SystemSetting.Post> fetchPost() { public Mono<SystemSetting.Post> fetchPost() {
return fetch(SystemSetting.Post.GROUP, SystemSetting.Post.class) return fetch(SystemSetting.Post.GROUP, SystemSetting.Post.class)
.switchIfEmpty(Mono.just(new SystemSetting.Post())); .switchIfEmpty(Mono.fromSupplier(SystemSetting.Post::new));
} }
public Mono<SystemSetting.ThemeRouteRules> fetchRouteRules() { public Mono<SystemSetting.ThemeRouteRules> fetchRouteRules() {
@ -123,15 +123,17 @@ public class SystemConfigurableEnvironmentFetcher implements Reconciler<Reconcil
data.forEach((group, dataValue) -> { data.forEach((group, dataValue) -> {
// https://www.rfc-editor.org/rfc/rfc7386 // https://www.rfc-editor.org/rfc/rfc7386
String defaultV = copiedDefault.get(group); String defaultV = copiedDefault.get(group);
String newValue; String newValue = null;
if (dataValue == null) { if (dataValue == null) {
if (copiedDefault.containsKey(group)) { if (!copiedDefault.containsKey(group)) {
newValue = null;
} else {
newValue = defaultV; newValue = defaultV;
} }
} else { } else {
newValue = mergeRemappingFunction(dataValue, defaultV); if (copiedDefault.containsKey(group)) {
newValue = mergeRemappingFunction(dataValue, defaultV);
} else {
newValue = dataValue;
}
} }
if (newValue == null) { if (newValue == null) {
@ -195,20 +197,18 @@ public class SystemConfigurableEnvironmentFetcher implements Reconciler<Reconcil
* @return a new {@link ConfigMap} named <code>system</code> by json merge patch. * @return a new {@link ConfigMap} named <code>system</code> by json merge patch.
*/ */
private Mono<ConfigMap> loadConfigMapInternal() { private Mono<ConfigMap> loadConfigMapInternal() {
Mono<ConfigMap> mapMono = var defaultConfigMono =
extensionClient.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG_DEFAULT); extensionClient.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG_DEFAULT);
if (mapMono == null) { var configMono = extensionClient.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG);
return Mono.empty(); return defaultConfigMono.flatMap(defaultConfig -> configMono.map(
} config -> {
return mapMono.flatMap(systemDefault -> Map<String, String> defaultData = defaultConfig.getData();
extensionClient.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG) Map<String, String> data = config.getData();
.map(system -> {
Map<String, String> defaultData = systemDefault.getData();
Map<String, String> data = system.getData();
Map<String, String> mergedData = mergeData(defaultData, data); Map<String, String> mergedData = mergeData(defaultData, data);
system.setData(mergedData); config.setData(mergedData);
return system; return config;
}) })
.switchIfEmpty(Mono.just(systemDefault))); .defaultIfEmpty(defaultConfig)
);
} }
} }

View File

@ -12,6 +12,7 @@ import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ConfigMap;
import run.halo.app.infra.ExternalUrlSupplier;
import run.halo.app.infra.InitializationStateGetter; import run.halo.app.infra.InitializationStateGetter;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting; import run.halo.app.infra.SystemSetting;
@ -36,20 +37,24 @@ public class GlobalInfoServiceImpl implements GlobalInfoService {
private final ObjectProvider<SystemConfigurableEnvironmentFetcher> private final ObjectProvider<SystemConfigurableEnvironmentFetcher>
systemConfigFetcher; systemConfigFetcher;
private final ExternalUrlSupplier externalUrl;
public GlobalInfoServiceImpl(HaloProperties haloProperties, public GlobalInfoServiceImpl(HaloProperties haloProperties,
AuthProviderService authProviderService, AuthProviderService authProviderService,
InitializationStateGetter initializationStateGetter, InitializationStateGetter initializationStateGetter,
ObjectProvider<SystemConfigurableEnvironmentFetcher> systemConfigFetcher) { ObjectProvider<SystemConfigurableEnvironmentFetcher> systemConfigFetcher,
ExternalUrlSupplier externalUrl) {
this.haloProperties = haloProperties; this.haloProperties = haloProperties;
this.authProviderService = authProviderService; this.authProviderService = authProviderService;
this.initializationStateGetter = initializationStateGetter; this.initializationStateGetter = initializationStateGetter;
this.systemConfigFetcher = systemConfigFetcher; this.systemConfigFetcher = systemConfigFetcher;
this.externalUrl = externalUrl;
} }
@Override @Override
public Mono<GlobalInfo> getGlobalInfo() { public Mono<GlobalInfo> getGlobalInfo() {
final var info = new GlobalInfo(); final var info = new GlobalInfo();
info.setExternalUrl(haloProperties.getExternalUrl()); info.setExternalUrl(externalUrl.getRaw());
info.setUseAbsolutePermalink(haloProperties.isUseAbsolutePermalink()); info.setUseAbsolutePermalink(haloProperties.isUseAbsolutePermalink());
info.setLocale(Locale.getDefault()); info.setLocale(Locale.getDefault());
info.setTimeZone(TimeZone.getDefault()); info.setTimeZone(TimeZone.getDefault());

View File

@ -1,119 +0,0 @@
package run.halo.app.infra;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
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.boot.autoconfigure.web.reactive.WebFluxProperties;
import org.springframework.http.HttpRequest;
import run.halo.app.infra.properties.HaloProperties;
@ExtendWith(MockitoExtension.class)
class HaloPropertiesExternalUrlSupplierTest {
@Mock
HaloProperties haloProperties;
@Mock
WebFluxProperties webFluxProperties;
@InjectMocks
HaloPropertiesExternalUrlSupplier externalUrl;
@Test
void getURIWhenUsingAbsolutePermalink() throws MalformedURLException {
var fakeUri = URI.create("https://halo.run/fake");
when(haloProperties.getExternalUrl()).thenReturn(fakeUri.toURL());
when(haloProperties.isUseAbsolutePermalink()).thenReturn(true);
assertEquals(fakeUri, externalUrl.get());
}
@Test
void getURIWhenBasePathSetAndNotUsingAbsolutePermalink() throws MalformedURLException {
when(webFluxProperties.getBasePath()).thenReturn("/blog");
when(haloProperties.isUseAbsolutePermalink()).thenReturn(false);
assertEquals(URI.create("/blog"), externalUrl.get());
}
@Test
void getURIWhenBasePathSetAndUsingAbsolutePermalink() throws MalformedURLException {
var fakeUri = URI.create("https://halo.run/fake");
when(haloProperties.getExternalUrl()).thenReturn(fakeUri.toURL());
lenient().when(webFluxProperties.getBasePath()).thenReturn("/blog");
when(haloProperties.isUseAbsolutePermalink()).thenReturn(true);
assertEquals(URI.create("https://halo.run/fake"), externalUrl.get());
}
@Test
void getURIWhenUsingRelativePermalink() throws MalformedURLException {
when(haloProperties.isUseAbsolutePermalink()).thenReturn(false);
assertEquals(URI.create("/"), externalUrl.get());
}
@Test
void getURLWhenExternalURLProvided() throws MalformedURLException {
var fakeUri = URI.create("https://halo.run/fake");
when(haloProperties.getExternalUrl()).thenReturn(fakeUri.toURL());
var mockRequest = mock(HttpRequest.class);
var url = externalUrl.getURL(mockRequest);
assertEquals(fakeUri.toURL(), url);
}
@Test
void getURLWhenExternalURLAbsent() throws MalformedURLException {
var fakeUri = URI.create("https://localhost/fake");
when(haloProperties.getExternalUrl()).thenReturn(null);
var mockRequest = mock(HttpRequest.class);
when(mockRequest.getURI()).thenReturn(fakeUri);
var url = externalUrl.getURL(mockRequest);
assertEquals(new URL("https://localhost/"), url);
}
@Test
void getURLWhenBasePathSetAndExternalURLProvided() throws MalformedURLException {
var fakeUri = URI.create("https://localhost/fake");
when(haloProperties.getExternalUrl()).thenReturn(fakeUri.toURL());
lenient().when(webFluxProperties.getBasePath()).thenReturn("/blog");
var mockRequest = mock(HttpRequest.class);
lenient().when(mockRequest.getURI()).thenReturn(fakeUri);
var url = externalUrl.getURL(mockRequest);
assertEquals(new URL("https://localhost/fake"), url);
}
@Test
void getURLWhenBasePathSetAndExternalURLAbsent() throws MalformedURLException {
var fakeUri = URI.create("https://localhost/fake");
when(haloProperties.getExternalUrl()).thenReturn(null);
when(webFluxProperties.getBasePath()).thenReturn("/blog");
var mockRequest = mock(HttpRequest.class);
when(mockRequest.getURI()).thenReturn(fakeUri);
var url = externalUrl.getURL(mockRequest);
assertEquals(new URL("https://localhost/blog"), url);
}
@Test
void getRaw() throws MalformedURLException {
var fakeUri = URI.create("http://localhost/fake");
when(haloProperties.getExternalUrl()).thenReturn(fakeUri.toURL());
assertEquals(fakeUri.toURL(), externalUrl.getRaw());
when(haloProperties.getExternalUrl()).thenReturn(null);
assertNull(externalUrl.getRaw());
}
}

View File

@ -0,0 +1,155 @@
package run.halo.app.infra;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
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.boot.autoconfigure.web.reactive.WebFluxProperties;
import org.springframework.http.HttpRequest;
import reactor.core.publisher.Mono;
import run.halo.app.infra.properties.HaloProperties;
@ExtendWith(MockitoExtension.class)
class SystemConfigFirstExternalUrlSupplierTest {
@Mock
HaloProperties haloProperties;
@Mock
WebFluxProperties webFluxProperties;
@Mock
SystemConfigurableEnvironmentFetcher systemConfigFetcher;
@InjectMocks
SystemConfigFirstExternalUrlSupplier externalUrl;
@Nested
class HaloPropertiesSupplier {
@BeforeEach
void setUp() {
when(systemConfigFetcher.getBasic()).thenReturn(Mono.empty());
externalUrl.onExtensionInitialized(null);
}
@Test
void getURIWhenUsingAbsolutePermalink() throws MalformedURLException {
var fakeUri = URI.create("https://halo.run/fake");
when(haloProperties.getExternalUrl()).thenReturn(fakeUri.toURL());
when(haloProperties.isUseAbsolutePermalink()).thenReturn(true);
assertEquals(fakeUri, externalUrl.get());
}
@Test
void getURIWhenBasePathSetAndNotUsingAbsolutePermalink() throws MalformedURLException {
when(webFluxProperties.getBasePath()).thenReturn("/blog");
when(haloProperties.isUseAbsolutePermalink()).thenReturn(false);
assertEquals(URI.create("/blog"), externalUrl.get());
}
@Test
void getURIWhenBasePathSetAndUsingAbsolutePermalink() throws MalformedURLException {
var fakeUri = URI.create("https://halo.run/fake");
when(haloProperties.getExternalUrl()).thenReturn(fakeUri.toURL());
lenient().when(webFluxProperties.getBasePath()).thenReturn("/blog");
when(haloProperties.isUseAbsolutePermalink()).thenReturn(true);
assertEquals(URI.create("https://halo.run/fake"), externalUrl.get());
}
@Test
void getURIWhenUsingRelativePermalink() throws MalformedURLException {
when(haloProperties.isUseAbsolutePermalink()).thenReturn(false);
assertEquals(URI.create("/"), externalUrl.get());
}
@Test
void getURLWhenExternalURLProvided() throws MalformedURLException {
var fakeUri = URI.create("https://halo.run/fake");
when(haloProperties.getExternalUrl()).thenReturn(fakeUri.toURL());
var mockRequest = mock(HttpRequest.class);
var url = externalUrl.getURL(mockRequest);
assertEquals(fakeUri.toURL(), url);
}
@Test
void getURLWhenExternalURLAbsent() throws MalformedURLException {
var fakeUri = URI.create("https://localhost/fake");
when(haloProperties.getExternalUrl()).thenReturn(null);
var mockRequest = mock(HttpRequest.class);
when(mockRequest.getURI()).thenReturn(fakeUri);
var url = externalUrl.getURL(mockRequest);
assertEquals(new URL("https://localhost/"), url);
}
@Test
void getURLWhenBasePathSetAndExternalURLProvided() throws MalformedURLException {
var fakeUri = URI.create("https://localhost/fake");
when(haloProperties.getExternalUrl()).thenReturn(fakeUri.toURL());
lenient().when(webFluxProperties.getBasePath()).thenReturn("/blog");
var mockRequest = mock(HttpRequest.class);
lenient().when(mockRequest.getURI()).thenReturn(fakeUri);
var url = externalUrl.getURL(mockRequest);
assertEquals(new URL("https://localhost/fake"), url);
}
@Test
void getURLWhenBasePathSetAndExternalURLAbsent() throws MalformedURLException {
var fakeUri = URI.create("https://localhost/fake");
when(haloProperties.getExternalUrl()).thenReturn(null);
when(webFluxProperties.getBasePath()).thenReturn("/blog");
var mockRequest = mock(HttpRequest.class);
when(mockRequest.getURI()).thenReturn(fakeUri);
var url = externalUrl.getURL(mockRequest);
assertEquals(new URL("https://localhost/blog"), url);
}
@Test
void getRaw() throws MalformedURLException {
var fakeUri = URI.create("http://localhost/fake");
when(haloProperties.getExternalUrl()).thenReturn(fakeUri.toURL());
assertEquals(fakeUri.toURL(), externalUrl.getRaw());
when(haloProperties.getExternalUrl()).thenReturn(null);
assertNull(externalUrl.getRaw());
}
}
@Nested
class SystemConfigSupplier {
@Test
void shouldGetUrlCorrectly() throws Exception {
var basic = new SystemSetting.Basic();
basic.setExternalUrl("https://www.halo.run");
when(systemConfigFetcher.getBasic()).thenReturn(Mono.just(basic));
externalUrl.onExtensionInitialized(null);
assertEquals(URI.create("https://www.halo.run").toURL(), externalUrl.getRaw());
assertEquals(URI.create("https://www.halo.run"), externalUrl.get());
var mockRequest = mock(HttpRequest.class);
assertEquals(URI.create("https://www.halo.run").toURL(),
externalUrl.getURL(mockRequest));
}
}
}