mirror of https://github.com/halo-dev/halo
Add support for setting external URL at setup page (#7488)
#### What type of PR is this? /kind improvement /area core /milestone 2.21.x #### What this PR does / why we need it: This PR allows users to set external URL at setup page without performing a restart. #### Which issue(s) this PR fixes: Fixes https://github.com/halo-dev/halo/issues/7479 #### Special notes for your reviewer: 1. Try to start Halo instance with a fresh environment. 2. Request index page and you will be redirected to setup page. 3. Check if the external URL is equal to the base URL in your browser. 4. Try to change external URL and finish the setup process. 5. Login to console and check the external URL in overview page. #### Does this PR introduce a user-facing change? ```release-note 支持在初始化页面设置外部访问地址 ```pull/7486/head^2
parent
7162b8da92
commit
f80487b1d5
|
@ -0,0 +1,23 @@
|
|||
package run.halo.app.infra;
|
||||
|
||||
import java.net.URL;
|
||||
import lombok.Getter;
|
||||
import org.springframework.context.ApplicationEvent;
|
||||
|
||||
/**
|
||||
* Event triggered when the external URL of the application changes.
|
||||
*
|
||||
* @author johnniang
|
||||
* @since 2.21.0
|
||||
*/
|
||||
public class ExternalUrlChangedEvent extends ApplicationEvent {
|
||||
|
||||
@Getter
|
||||
private final URL externalUrl;
|
||||
|
||||
public ExternalUrlChangedEvent(Object source, URL externalUrl) {
|
||||
super(source);
|
||||
this.externalUrl = externalUrl;
|
||||
}
|
||||
|
||||
}
|
|
@ -5,6 +5,7 @@ import java.net.URI;
|
|||
import java.net.URISyntaxException;
|
||||
import java.net.URL;
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.autoconfigure.web.reactive.WebFluxProperties;
|
||||
import org.springframework.context.event.EventListener;
|
||||
|
@ -43,18 +44,28 @@ class SystemConfigFirstExternalUrlSupplier implements ExternalUrlSupplier {
|
|||
|
||||
@EventListener
|
||||
void onExtensionInitialized(ExtensionInitializedEvent ignored) {
|
||||
systemConfigFetcher.getBasic()
|
||||
refetchExternalUrl().ifPresent(externalUrl -> this.externalUrl = externalUrl);
|
||||
}
|
||||
|
||||
@EventListener
|
||||
void onExternalUrlChanged(ExternalUrlChangedEvent event) {
|
||||
this.externalUrl = event.getExternalUrl();
|
||||
}
|
||||
|
||||
Optional<URL> refetchExternalUrl() {
|
||||
return systemConfigFetcher.getBasic()
|
||||
.mapNotNull(SystemSetting.Basic::getExternalUrl)
|
||||
.filter(StringUtils::hasText)
|
||||
.doOnNext(externalUrlString -> {
|
||||
.mapNotNull(externalUrlString -> {
|
||||
try {
|
||||
this.externalUrl = URI.create(externalUrlString).toURL();
|
||||
return 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.
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.blockOptional(Duration.ofSeconds(10));
|
||||
|
|
|
@ -9,10 +9,12 @@ import static org.springdoc.core.fn.builders.schema.Builder.schemaBuilder;
|
|||
import static org.springframework.web.reactive.function.server.RequestPredicates.accept;
|
||||
import static org.springframework.web.reactive.function.server.RequestPredicates.contentType;
|
||||
import static org.springframework.web.reactive.function.server.RequestPredicates.path;
|
||||
import static run.halo.app.infra.ValidationUtils.validate;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.net.URI;
|
||||
|
@ -23,12 +25,15 @@ import java.util.LinkedHashMap;
|
|||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Properties;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.hibernate.validator.constraints.URL;
|
||||
import org.springdoc.core.fn.builders.content.Builder;
|
||||
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.beans.factory.config.PlaceholderConfigurerSupport;
|
||||
import org.springframework.boot.autoconfigure.r2dbc.R2dbcConnectionDetails;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
|
@ -38,8 +43,6 @@ import org.springframework.http.HttpStatus;
|
|||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.util.InMemoryResource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.util.PropertyPlaceholderHelper;
|
||||
import org.springframework.util.StreamUtils;
|
||||
import org.springframework.validation.BeanPropertyBindingResult;
|
||||
|
@ -55,6 +58,8 @@ import reactor.util.retry.Retry;
|
|||
import run.halo.app.extension.ConfigMap;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.extension.Unstructured;
|
||||
import run.halo.app.infra.ExternalUrlChangedEvent;
|
||||
import run.halo.app.infra.ExternalUrlSupplier;
|
||||
import run.halo.app.infra.InitializationStateGetter;
|
||||
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
|
||||
import run.halo.app.infra.SystemSetting;
|
||||
|
@ -86,6 +91,8 @@ public class SystemSetupEndpoint {
|
|||
private final ThemeService themeService;
|
||||
private final Validator validator;
|
||||
private final ObjectProvider<R2dbcConnectionDetails> connectionDetails;
|
||||
private final ExternalUrlSupplier externalUrl;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
@Bean
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE + 100)
|
||||
|
@ -125,19 +132,19 @@ public class SystemSetupEndpoint {
|
|||
}
|
||||
|
||||
private Mono<ServerResponse> setup(ServerRequest request) {
|
||||
return request.formData()
|
||||
.map(SetupRequest::new)
|
||||
.filterWhen(body -> initializationStateGetter.userInitialized()
|
||||
.map(initialized -> !initialized)
|
||||
return initializationStateGetter.userInitialized()
|
||||
.filter(initialized -> !initialized)
|
||||
.flatMap(ignored -> request.bind(SetupRequest.class)
|
||||
.flatMap(setupRequest -> {
|
||||
// validate it
|
||||
var bindingResult = validate(setupRequest, validator, request.exchange());
|
||||
if (bindingResult.hasErrors()) {
|
||||
return handleValidationErrors(bindingResult, request);
|
||||
}
|
||||
return doInitialization(setupRequest).then(handleSetupSuccessfully(request));
|
||||
})
|
||||
)
|
||||
.flatMap(body -> {
|
||||
var bindingResult = ValidationUtils.validate(body, validator, request.exchange());
|
||||
if (bindingResult.hasErrors()) {
|
||||
return handleValidationErrors(bindingResult, request);
|
||||
}
|
||||
return doInitialization(body)
|
||||
.then(Mono.defer(() -> handleSetupSuccessfully(request)));
|
||||
});
|
||||
.switchIfEmpty(redirectToConsole());
|
||||
}
|
||||
|
||||
private static Mono<ServerResponse> handleSetupSuccessfully(ServerRequest request) {
|
||||
|
@ -187,8 +194,15 @@ public class SystemSetupEndpoint {
|
|||
.filter(t -> t instanceof OptimisticLockingFailureException)
|
||||
)
|
||||
.subscribeOn(Schedulers.boundedElastic())
|
||||
.then();
|
||||
return Mono.when(superUserMono, basicConfigMono,
|
||||
.then(Mono.fromCallable(() -> {
|
||||
eventPublisher.publishEvent(
|
||||
new ExternalUrlChangedEvent(this, URI.create(body.getExternalUrl()).toURL())
|
||||
);
|
||||
return null;
|
||||
}));
|
||||
return Mono.when(
|
||||
basicConfigMono,
|
||||
superUserMono,
|
||||
initializeNecessaryData(body.getUsername()),
|
||||
pluginService.installPresetPlugins(),
|
||||
themeService.installPresetTheme()
|
||||
|
@ -213,6 +227,7 @@ public class SystemSetupEndpoint {
|
|||
var basicSetting = JsonUtils.jsonToObject(basic, SystemSetting.Basic.class);
|
||||
basicSetting.setTitle(body.getSiteTitle());
|
||||
basicSetting.setLanguage(body.getLanguage());
|
||||
basicSetting.setExternalUrl(body.getExternalUrl());
|
||||
data.put(SystemSetting.Basic.GROUP, JsonUtils.objectToJson(basicSetting));
|
||||
}
|
||||
|
||||
|
@ -222,8 +237,11 @@ public class SystemSetupEndpoint {
|
|||
if (initialized) {
|
||||
return redirectToConsole();
|
||||
}
|
||||
var body = new SetupRequest(new LinkedMultiValueMap<>());
|
||||
var bindingResult = new BeanPropertyBindingResult(body, "form");
|
||||
var setupRequest = new SetupRequest();
|
||||
setupRequest.setExternalUrl(
|
||||
externalUrl.getURL(request.exchange().getRequest()).toString()
|
||||
);
|
||||
var bindingResult = new BeanPropertyBindingResult(setupRequest, "form");
|
||||
var model = bindingResult.getModel();
|
||||
model.put("usingH2database", usingH2database());
|
||||
return ServerResponse.ok().render(SETUP_TEMPLATE, model);
|
||||
|
@ -243,41 +261,36 @@ public class SystemSetupEndpoint {
|
|||
.orElse(false);
|
||||
}
|
||||
|
||||
record SetupRequest(MultiValueMap<String, String> formData) {
|
||||
@Data
|
||||
static class SetupRequest {
|
||||
|
||||
@Schema(requiredMode = REQUIRED, minLength = 4, maxLength = 63)
|
||||
@NotBlank
|
||||
@Size(min = 4, max = 63)
|
||||
@Pattern(regexp = ValidationUtils.NAME_REGEX,
|
||||
message = "{validation.error.username.pattern}")
|
||||
public String getUsername() {
|
||||
return formData.getFirst("username");
|
||||
}
|
||||
private String username;
|
||||
|
||||
@Schema(requiredMode = REQUIRED, minLength = 5, maxLength = 257)
|
||||
@NotBlank
|
||||
@Pattern(regexp = ValidationUtils.PASSWORD_REGEX,
|
||||
message = "{validation.error.password.pattern}")
|
||||
@Size(min = 5, max = 257)
|
||||
public String getPassword() {
|
||||
return formData.getFirst("password");
|
||||
}
|
||||
private String password;
|
||||
|
||||
@Email
|
||||
public String getEmail() {
|
||||
return formData.getFirst("email");
|
||||
}
|
||||
private String email;
|
||||
|
||||
@NotBlank
|
||||
@Size(max = 80)
|
||||
public String getSiteTitle() {
|
||||
return formData.getFirst("siteTitle");
|
||||
}
|
||||
private String siteTitle;
|
||||
|
||||
@Pattern(regexp = "^(zh-CN|zh-TW|en|es)$")
|
||||
public String getLanguage() {
|
||||
return formData.getFirst("language");
|
||||
}
|
||||
private String language;
|
||||
|
||||
@NotNull
|
||||
@URL(regexp = "^(http|https).*")
|
||||
private String externalUrl;
|
||||
}
|
||||
|
||||
Flux<Unstructured> loadPresetExtensions(String username) {
|
||||
|
@ -295,7 +308,7 @@ public class SystemSetupEndpoint {
|
|||
var processedContent =
|
||||
PROPERTY_PLACEHOLDER_HELPER.replacePlaceholders(rawContent, properties);
|
||||
// load yaml to unstructured
|
||||
var stringResource =
|
||||
var stringResource =
|
||||
new InMemoryResource(processedContent.getBytes(StandardCharsets.UTF_8));
|
||||
var loader = new YamlUnstructuredLoader(stringResource);
|
||||
return loader.load();
|
||||
|
|
|
@ -22,6 +22,7 @@
|
|||
<span th:text="#{form.messages.h2.content}"> </span>
|
||||
</div>
|
||||
<form th:object="${form}" th:action="@{/system/setup}" class="halo-form" method="post">
|
||||
|
||||
<div class="form-item">
|
||||
<label for="language" th:text="#{form.language.label}"></label>
|
||||
<div class="form-input">
|
||||
|
@ -34,6 +35,20 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label for="externalUrl" th:text="#{form.externalUrl.label}"></label>
|
||||
<div class="form-input">
|
||||
<input name="externalUrl"
|
||||
type="url"
|
||||
th:field="*{externalUrl}"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
autocapitalize="off"
|
||||
required/>
|
||||
</div>
|
||||
<p class="alert alert-error" th:if="${#fields.hasErrors('externalUrl')}" th:errors="*{externalUrl}"></p>
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label for="siteTitle" th:text="#{form.siteTitle.label}"></label>
|
||||
<div class="form-input">
|
||||
|
|
|
@ -5,6 +5,7 @@ form.username.label=用户名
|
|||
form.email.label=电子邮箱
|
||||
form.password.label=密码
|
||||
form.confirmPassword.label=确认密码
|
||||
form.externalUrl.label=外部访问地址
|
||||
form.submit=初始化
|
||||
form.messages.h2.title=警告:正在使用 H2 数据库
|
||||
form.messages.h2.content=H2 数据库仅适用于开发环境和测试环境,不推荐在生产环境中使用,H2 非常容易因为操作不当导致数据文件损坏。如果必须要使用,请按时进行数据备份。
|
|
@ -5,6 +5,7 @@ form.username.label=Username
|
|||
form.email.label=Email
|
||||
form.password.label=Password
|
||||
form.confirmPassword.label=Confirm Password
|
||||
form.externalUrl.label=External URL
|
||||
form.submit=Setup
|
||||
form.messages.h2.title=Warning: Using H2 Database
|
||||
form.messages.h2.content=The H2 database is only suitable for development and testing environments. It is not recommended for production environments, as H2 is very prone to data file corruption due to improper operations. If you must use it, please back up your data regularly.
|
|
@ -5,6 +5,7 @@ form.username.label=Nombre de Usuario
|
|||
form.email.label=Correo Electrónico
|
||||
form.password.label=Contraseña
|
||||
form.confirmPassword.label=Confirmar Contraseña
|
||||
form.externalUrl.label=URL Externa
|
||||
form.submit=Configurar
|
||||
form.messages.h2.title=Advertencia: Usando la base de datos H2
|
||||
form.messages.h2.content=La base de datos H2 solo es adecuada para entornos de desarrollo y prueba. No se recomienda su uso en entornos de producción, ya que H2 es muy susceptible a la corrupción de archivos de datos debido a un manejo inadecuado. Si debe usarla, realice copias de seguridad de los datos regularmente.
|
|
@ -5,6 +5,7 @@ form.username.label=使用者名稱
|
|||
form.email.label=電子郵件
|
||||
form.password.label=密碼
|
||||
form.confirmPassword.label=確認密碼
|
||||
form.externalUrl.label=外部訪問地址
|
||||
form.submit=初始化
|
||||
form.messages.h2.title=警告:正在使用 H2 資料庫
|
||||
form.messages.h2.content=H2 資料庫僅適用於開發環境和測試環境,不建議在生產環境中使用,H2 非常容易因為操作不當導致資料檔案損壞。如果必須要使用,請按時進行資料備份。
|
Loading…
Reference in New Issue