mirror of https://github.com/halo-dev/halo
Add support for configuring external URL online (#7459)
* Add support for configuring external URL online * Fix the problem of timeout on external URL initialization * Add external URL editing capability to overview page Signed-off-by: Ryan Wang <i@ryanc.cc> --------- Signed-off-by: Ryan Wang <i@ryanc.cc> Co-authored-by: Ryan Wang <i@ryanc.cc>pull/7466/head
commit
fb6b6f632d
|
@ -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() {
|
||||||
|
|
|
@ -89,10 +89,9 @@ public class ExtensionResourceInitializer implements SmartLifecycle {
|
||||||
extension.getMetadata().getName());
|
extension.getMetadata().getName());
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.then(Mono.fromRunnable(
|
.then()
|
||||||
() -> eventPublisher.publishEvent(new ExtensionInitializedEvent(this)))
|
|
||||||
)
|
|
||||||
.block(Duration.ofMinutes(1));
|
.block(Duration.ofMinutes(1));
|
||||||
|
eventPublisher.publishEvent(new ExtensionInitializedEvent(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 {
|
||||||
|
if (copiedDefault.containsKey(group)) {
|
||||||
newValue = mergeRemappingFunction(dataValue, defaultV);
|
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)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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());
|
||||||
|
|
|
@ -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());
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -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));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -1,8 +1,9 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import H2WarningAlert from "@/components/alerts/H2WarningAlert.vue";
|
import H2WarningAlert from "@/components/alerts/H2WarningAlert.vue";
|
||||||
import type { GlobalInfo, Info, Startup } from "@/types";
|
import type { Info, Startup } from "@/types";
|
||||||
import { formatDatetime } from "@/utils/date";
|
import { formatDatetime } from "@/utils/date";
|
||||||
import { usePermission } from "@/utils/permission";
|
import { usePermission } from "@/utils/permission";
|
||||||
|
import { useGlobalInfoFetch } from "@console/composables/use-global-info";
|
||||||
import { useThemeStore } from "@console/stores/theme";
|
import { useThemeStore } from "@console/stores/theme";
|
||||||
import type { Plugin } from "@halo-dev/api-client";
|
import type { Plugin } from "@halo-dev/api-client";
|
||||||
import { consoleApiClient } from "@halo-dev/api-client";
|
import { consoleApiClient } from "@halo-dev/api-client";
|
||||||
|
@ -10,7 +11,6 @@ import {
|
||||||
IconClipboardLine,
|
IconClipboardLine,
|
||||||
IconTerminalBoxLine,
|
IconTerminalBoxLine,
|
||||||
Toast,
|
Toast,
|
||||||
VAlert,
|
|
||||||
VButton,
|
VButton,
|
||||||
VCard,
|
VCard,
|
||||||
VDescription,
|
VDescription,
|
||||||
|
@ -24,6 +24,7 @@ import { useClipboard } from "@vueuse/core";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
|
import ExternalUrlItem from "./components/ExternalUrlItem.vue";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const themeStore = useThemeStore();
|
const themeStore = useThemeStore();
|
||||||
|
@ -40,16 +41,7 @@ const { data: info } = useQuery<Info>({
|
||||||
retry: 0,
|
retry: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: globalInfo } = useQuery<GlobalInfo>({
|
const { globalInfo } = useGlobalInfoFetch();
|
||||||
queryKey: ["system-global-info"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const { data } = await axios.get<GlobalInfo>(`/actuator/globalinfo`, {
|
|
||||||
withCredentials: true,
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
retry: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: startup } = useQuery<Startup>({
|
const { data: startup } = useQuery<Startup>({
|
||||||
queryKey: ["system-startup-info"],
|
queryKey: ["system-startup-info"],
|
||||||
|
@ -76,20 +68,6 @@ const { data: plugins, isLoading: isPluginsLoading } = useQuery<Plugin[]>({
|
||||||
enabled: computed(() => currentUserHasPermission(["system:plugins:view"])),
|
enabled: computed(() => currentUserHasPermission(["system:plugins:view"])),
|
||||||
});
|
});
|
||||||
|
|
||||||
const isExternalUrlValid = computed(() => {
|
|
||||||
if (!globalInfo.value?.useAbsolutePermalink) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!globalInfo.value?.externalUrl) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = new URL(globalInfo.value.externalUrl);
|
|
||||||
const { host: currentHost, protocol: currentProtocol } = window.location;
|
|
||||||
return url.host === currentHost && url.protocol === currentProtocol;
|
|
||||||
});
|
|
||||||
|
|
||||||
// copy system information to clipboard
|
// copy system information to clipboard
|
||||||
const { copy, isSupported } = useClipboard({ legacy: true });
|
const { copy, isSupported } = useClipboard({ legacy: true });
|
||||||
|
|
||||||
|
@ -243,25 +221,7 @@ const handleDownloadLogfile = () => {
|
||||||
</div>
|
</div>
|
||||||
<div class="border-t border-gray-200">
|
<div class="border-t border-gray-200">
|
||||||
<VDescription>
|
<VDescription>
|
||||||
<VDescriptionItem :label="$t('core.overview.fields.external_url')">
|
<ExternalUrlItem />
|
||||||
<span v-if="globalInfo?.externalUrl">
|
|
||||||
{{ globalInfo?.externalUrl }}
|
|
||||||
</span>
|
|
||||||
<span v-else>
|
|
||||||
{{ $t("core.overview.fields_values.external_url.not_setup") }}
|
|
||||||
</span>
|
|
||||||
<VAlert
|
|
||||||
v-if="!isExternalUrlValid"
|
|
||||||
class="mt-3"
|
|
||||||
type="warning"
|
|
||||||
:title="$t('core.common.text.warning')"
|
|
||||||
:closable="false"
|
|
||||||
>
|
|
||||||
<template #description>
|
|
||||||
{{ $t("core.overview.alert.external_url_invalid") }}
|
|
||||||
</template>
|
|
||||||
</VAlert>
|
|
||||||
</VDescriptionItem>
|
|
||||||
<VDescriptionItem
|
<VDescriptionItem
|
||||||
v-if="startup?.timeline.startTime"
|
v-if="startup?.timeline.startTime"
|
||||||
:label="$t('core.overview.fields.start_time')"
|
:label="$t('core.overview.fields.start_time')"
|
||||||
|
|
|
@ -0,0 +1,114 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { setFocus } from "@/formkit/utils/focus";
|
||||||
|
import { useGlobalInfoFetch } from "@console/composables/use-global-info";
|
||||||
|
import { coreApiClient } from "@halo-dev/api-client";
|
||||||
|
import { Dialog, Toast, VButton, VLoading, VSpace } from "@halo-dev/components";
|
||||||
|
import { useQuery } from "@tanstack/vue-query";
|
||||||
|
import axios from "axios";
|
||||||
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { globalInfo } = useGlobalInfoFetch();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: "close"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const isRestarting = ref(false);
|
||||||
|
|
||||||
|
function onSubmit({ externalUrl }: { externalUrl: string }) {
|
||||||
|
Dialog.warning({
|
||||||
|
title: t("core.overview.external_url_form.operations.save.title"),
|
||||||
|
description: t(
|
||||||
|
"core.overview.external_url_form.operations.save.description"
|
||||||
|
),
|
||||||
|
confirmType: "danger",
|
||||||
|
confirmText: t("core.common.buttons.confirm"),
|
||||||
|
cancelText: t("core.common.buttons.cancel"),
|
||||||
|
onConfirm: async () => {
|
||||||
|
const { data: configMap } = await coreApiClient.configMap.getConfigMap({
|
||||||
|
name: "system",
|
||||||
|
});
|
||||||
|
|
||||||
|
const basicConfig = JSON.parse(configMap.data?.["basic"] || "{}");
|
||||||
|
|
||||||
|
basicConfig.externalUrl = externalUrl.trim();
|
||||||
|
|
||||||
|
await coreApiClient.configMap.patchConfigMap({
|
||||||
|
name: "system",
|
||||||
|
jsonPatchInner: [
|
||||||
|
{
|
||||||
|
op: "add",
|
||||||
|
path: "/data/basic",
|
||||||
|
value: JSON.stringify(basicConfig),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await axios.post(`/actuator/restart`);
|
||||||
|
|
||||||
|
isRestarting.value = true;
|
||||||
|
|
||||||
|
Toast.success(t("core.common.toast.save_success"));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setFocus("externalUrl");
|
||||||
|
});
|
||||||
|
|
||||||
|
useQuery({
|
||||||
|
queryKey: ["check-health"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await axios.get("/actuator/health");
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess(data) {
|
||||||
|
if (data.status === "UP") {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
retry: true,
|
||||||
|
retryDelay: 2000,
|
||||||
|
enabled: computed(() => isRestarting.value),
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<template v-if="!isRestarting">
|
||||||
|
<FormKit
|
||||||
|
id="external-url-form"
|
||||||
|
type="form"
|
||||||
|
name="external-url-form"
|
||||||
|
@submit="onSubmit"
|
||||||
|
>
|
||||||
|
<FormKit
|
||||||
|
id="externalUrl"
|
||||||
|
:model-value="globalInfo?.externalUrl"
|
||||||
|
type="url"
|
||||||
|
name="externalUrl"
|
||||||
|
validation="url|required"
|
||||||
|
:validation-label="$t('core.overview.fields.external_url')"
|
||||||
|
:classes="{ outer: '!pb-0' }"
|
||||||
|
></FormKit>
|
||||||
|
</FormKit>
|
||||||
|
<VSpace class="mt-4">
|
||||||
|
<VButton type="secondary" @click="$formkit.submit('external-url-form')">
|
||||||
|
{{ $t("core.common.buttons.save") }}
|
||||||
|
</VButton>
|
||||||
|
<VButton @click="emit('close')">
|
||||||
|
{{ $t("core.common.buttons.cancel") }}
|
||||||
|
</VButton>
|
||||||
|
</VSpace>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<VLoading class="!inline-flex !py-0" />
|
||||||
|
<div class="text-xs text-gray-600">
|
||||||
|
{{ $t("core.overview.external_url_form.tips.restarting") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
|
@ -0,0 +1,53 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useGlobalInfoFetch } from "@console/composables/use-global-info";
|
||||||
|
import {
|
||||||
|
IconRiPencilFill,
|
||||||
|
VAlert,
|
||||||
|
VDescriptionItem,
|
||||||
|
} from "@halo-dev/components";
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import ExternalUrlForm from "./ExternalUrlForm.vue";
|
||||||
|
|
||||||
|
const { globalInfo } = useGlobalInfoFetch();
|
||||||
|
|
||||||
|
const isExternalUrlValid = computed(() => {
|
||||||
|
if (!globalInfo.value?.externalUrl) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(globalInfo.value.externalUrl);
|
||||||
|
const { host: currentHost, protocol: currentProtocol } = window.location;
|
||||||
|
return url.host === currentHost && url.protocol === currentProtocol;
|
||||||
|
});
|
||||||
|
|
||||||
|
const showExternalUrlForm = ref(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VDescriptionItem :label="$t('core.overview.fields.external_url')">
|
||||||
|
<div v-if="!showExternalUrlForm" class="flex items-center gap-3">
|
||||||
|
<span v-if="globalInfo?.externalUrl">
|
||||||
|
{{ globalInfo?.externalUrl }}
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
{{ $t("core.overview.fields_values.external_url.not_setup") }}
|
||||||
|
</span>
|
||||||
|
<IconRiPencilFill
|
||||||
|
class="cursor-pointer text-sm text-gray-600 hover:text-gray-900"
|
||||||
|
@click="showExternalUrlForm = true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ExternalUrlForm v-else @close="showExternalUrlForm = false" />
|
||||||
|
<VAlert
|
||||||
|
v-if="!isExternalUrlValid && !showExternalUrlForm"
|
||||||
|
class="mt-3"
|
||||||
|
type="warning"
|
||||||
|
:title="$t('core.common.text.warning')"
|
||||||
|
:closable="false"
|
||||||
|
>
|
||||||
|
<template #description>
|
||||||
|
{{ $t("core.overview.alert.external_url_invalid") }}
|
||||||
|
</template>
|
||||||
|
</VAlert>
|
||||||
|
</VDescriptionItem>
|
||||||
|
</template>
|
|
@ -1458,6 +1458,16 @@ core:
|
||||||
The external access url detected is inconsistent with the current access
|
The external access url detected is inconsistent with the current access
|
||||||
url, which may cause some links to fail to redirect properly. Please
|
url, which may cause some links to fail to redirect properly. Please
|
||||||
check the external access url settings.
|
check the external access url settings.
|
||||||
|
external_url_form:
|
||||||
|
operations:
|
||||||
|
save:
|
||||||
|
title: Modify external url
|
||||||
|
description: >-
|
||||||
|
Modifying the external access address requires restarting the Halo
|
||||||
|
service. It will automatically restart after the modification is
|
||||||
|
completed. Do you want to continue?
|
||||||
|
tips:
|
||||||
|
restarting: Modification completed, waiting for restart...
|
||||||
backup:
|
backup:
|
||||||
title: Backup and Restore
|
title: Backup and Restore
|
||||||
tabs:
|
tabs:
|
||||||
|
|
|
@ -1356,6 +1356,13 @@ core:
|
||||||
os: 操作系统:{os}
|
os: 操作系统:{os}
|
||||||
alert:
|
alert:
|
||||||
external_url_invalid: 检测到外部访问地址与当前访问地址不一致,可能会导致部分链接无法正常跳转,请检查外部访问地址设置。
|
external_url_invalid: 检测到外部访问地址与当前访问地址不一致,可能会导致部分链接无法正常跳转,请检查外部访问地址设置。
|
||||||
|
external_url_form:
|
||||||
|
operations:
|
||||||
|
save:
|
||||||
|
title: 修改外部访问地址
|
||||||
|
description: 修改外部访问地址需要重启 Halo 服务,修改完成之后会自动进行重启,是否继续?
|
||||||
|
tips:
|
||||||
|
restarting: 修改完成,等待重启...
|
||||||
backup:
|
backup:
|
||||||
title: 备份与恢复
|
title: 备份与恢复
|
||||||
tabs:
|
tabs:
|
||||||
|
|
|
@ -1341,6 +1341,13 @@ core:
|
||||||
os: 操作系統:{os}
|
os: 操作系統:{os}
|
||||||
alert:
|
alert:
|
||||||
external_url_invalid: 檢測到外部訪問地址與當前訪問地址不一致,可能會導致部分連結無法正常跳轉,請檢查外部訪問地址設置。
|
external_url_invalid: 檢測到外部訪問地址與當前訪問地址不一致,可能會導致部分連結無法正常跳轉,請檢查外部訪問地址設置。
|
||||||
|
external_url_form:
|
||||||
|
operations:
|
||||||
|
save:
|
||||||
|
title: 修改外部訪問地址
|
||||||
|
description: 修改外部訪問地址需要重啟 Halo 服務,修改完成之後會自動進行重啟,是否繼續?
|
||||||
|
tips:
|
||||||
|
restarting: 修改完成,等待重啟...
|
||||||
backup:
|
backup:
|
||||||
title: 備份與還原
|
title: 備份與還原
|
||||||
tabs:
|
tabs:
|
||||||
|
|
Loading…
Reference in New Issue