diff --git a/application/src/main/java/run/halo/app/infra/ExternalUrlChangedEvent.java b/application/src/main/java/run/halo/app/infra/ExternalUrlChangedEvent.java new file mode 100644 index 000000000..266aea9df --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/ExternalUrlChangedEvent.java @@ -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; + } + +} diff --git a/application/src/main/java/run/halo/app/infra/SystemConfigFirstExternalUrlSupplier.java b/application/src/main/java/run/halo/app/infra/SystemConfigFirstExternalUrlSupplier.java index b7a3a2337..a81846238 100644 --- a/application/src/main/java/run/halo/app/infra/SystemConfigFirstExternalUrlSupplier.java +++ b/application/src/main/java/run/halo/app/infra/SystemConfigFirstExternalUrlSupplier.java @@ -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 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)); diff --git a/application/src/main/java/run/halo/app/security/preauth/SystemSetupEndpoint.java b/application/src/main/java/run/halo/app/security/preauth/SystemSetupEndpoint.java index e7c933bcc..bcad7024b 100644 --- a/application/src/main/java/run/halo/app/security/preauth/SystemSetupEndpoint.java +++ b/application/src/main/java/run/halo/app/security/preauth/SystemSetupEndpoint.java @@ -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 connectionDetails; + private final ExternalUrlSupplier externalUrl; + private final ApplicationEventPublisher eventPublisher; @Bean @Order(Ordered.HIGHEST_PRECEDENCE + 100) @@ -125,19 +132,19 @@ public class SystemSetupEndpoint { } private Mono 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 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 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 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(); diff --git a/application/src/main/resources/templates/setup.html b/application/src/main/resources/templates/setup.html index d285702f8..e9ff90f46 100644 --- a/application/src/main/resources/templates/setup.html +++ b/application/src/main/resources/templates/setup.html @@ -22,6 +22,7 @@
+
@@ -34,6 +35,20 @@
+
+ +
+ +
+

+
+
diff --git a/application/src/main/resources/templates/setup.properties b/application/src/main/resources/templates/setup.properties index 829c29b22..f18d9b19d 100644 --- a/application/src/main/resources/templates/setup.properties +++ b/application/src/main/resources/templates/setup.properties @@ -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 非常容易因为操作不当导致数据文件损坏。如果必须要使用,请按时进行数据备份。 \ No newline at end of file diff --git a/application/src/main/resources/templates/setup_en.properties b/application/src/main/resources/templates/setup_en.properties index d46b15503..a6d84b0c4 100644 --- a/application/src/main/resources/templates/setup_en.properties +++ b/application/src/main/resources/templates/setup_en.properties @@ -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. \ No newline at end of file diff --git a/application/src/main/resources/templates/setup_es.properties b/application/src/main/resources/templates/setup_es.properties index ee4bbf2fe..90bc4b99d 100644 --- a/application/src/main/resources/templates/setup_es.properties +++ b/application/src/main/resources/templates/setup_es.properties @@ -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. \ No newline at end of file diff --git a/application/src/main/resources/templates/setup_zh_TW.properties b/application/src/main/resources/templates/setup_zh_TW.properties index ad9d471dd..42ee75217 100644 --- a/application/src/main/resources/templates/setup_zh_TW.properties +++ b/application/src/main/resources/templates/setup_zh_TW.properties @@ -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 非常容易因為操作不當導致資料檔案損壞。如果必須要使用,請按時進行資料備份。 \ No newline at end of file