diff --git a/api/src/main/java/run/halo/app/core/extension/content/Post.java b/api/src/main/java/run/halo/app/core/extension/content/Post.java index 0cc785d71..8e8096b46 100644 --- a/api/src/main/java/run/halo/app/core/extension/content/Post.java +++ b/api/src/main/java/run/halo/app/core/extension/content/Post.java @@ -157,7 +157,6 @@ public class Post extends AbstractExtension { @Data public static class PostStatus { - @Schema(requiredMode = RequiredMode.REQUIRED) private String phase; @Schema diff --git a/application/src/main/java/run/halo/app/core/endpoint/console/PluginEndpoint.java b/application/src/main/java/run/halo/app/core/endpoint/console/PluginEndpoint.java index 728e42a1b..15dc2c24b 100644 --- a/application/src/main/java/run/halo/app/core/endpoint/console/PluginEndpoint.java +++ b/application/src/main/java/run/halo/app/core/endpoint/console/PluginEndpoint.java @@ -71,7 +71,6 @@ import run.halo.app.extension.router.IListRequest; import run.halo.app.extension.router.SortableRequest; import run.halo.app.infra.ReactiveUrlDataBufferFetcher; import run.halo.app.infra.utils.SettingUtils; -import run.halo.app.plugin.PluginNotFoundException; import run.halo.app.plugin.PluginService; @Slf4j @@ -298,12 +297,6 @@ public class PluginEndpoint implements CustomEndpoint, InitializingBean { .response(responseBuilder() .implementation(ObjectNode.class)) ) - .GET("plugin-presets", this::listPresets, - builder -> builder.operationId("ListPluginPresets") - .description("List all plugin presets in the system.") - .tag(tag) - .response(responseBuilder().implementationArray(Plugin.class)) - ) .GET("plugins/-/bundle.js", this::fetchJsBundle, builder -> builder.operationId("fetchJsBundle") .description("Merge all JS bundles of enabled plugins into one.") @@ -472,10 +465,6 @@ public class PluginEndpoint implements CustomEndpoint, InitializingBean { return ServerResponse.ok().body(pluginService.reload(name), Plugin.class); } - private Mono listPresets(ServerRequest request) { - return ServerResponse.ok().body(pluginService.getPresets(), Plugin.class); - } - private Mono fetchPluginConfig(ServerRequest request) { final var name = request.pathVariable("name"); return client.fetch(Plugin.class, name) @@ -564,10 +553,6 @@ public class PluginEndpoint implements CustomEndpoint, InitializingBean { if (InstallSource.FILE.equals(source)) { return installFromFile(installRequest.getFile(), pluginService::install); } - if (InstallSource.PRESET.equals(source)) { - return installFromPreset(installRequest.getPresetName(), - pluginService::install); - } return Mono.error( new UnsupportedOperationException("Unsupported install source " + source)); })) @@ -586,10 +571,6 @@ public class PluginEndpoint implements CustomEndpoint, InitializingBean { return installFromFile(installRequest.getFile(), path -> pluginService.upgrade(pluginName, path)); } - if (InstallSource.PRESET.equals(source)) { - return installFromPreset(installRequest.getPresetName(), - path -> pluginService.upgrade(pluginName, path)); - } return Mono.error( new UnsupportedOperationException("Unsupported install source " + source)); })) @@ -606,16 +587,6 @@ public class PluginEndpoint implements CustomEndpoint, InitializingBean { this::deleteFileIfExists); } - private Mono installFromPreset(Mono presetNameMono, - Function> resourceClosure) { - return presetNameMono.flatMap(pluginService::getPreset) - .switchIfEmpty( - Mono.error(() -> new PluginNotFoundException("Plugin preset was not found."))) - .map(pluginPreset -> pluginPreset.getStatus().getLoadLocation()) - .map(Path::of) - .flatMap(resourceClosure); - } - public static class ListRequest extends SortableRequest { public ListRequest(ServerRequest request) { diff --git a/application/src/main/java/run/halo/app/core/endpoint/SystemConfigEndpoint.java b/application/src/main/java/run/halo/app/core/endpoint/console/SystemConfigEndpoint.java similarity index 99% rename from application/src/main/java/run/halo/app/core/endpoint/SystemConfigEndpoint.java rename to application/src/main/java/run/halo/app/core/endpoint/console/SystemConfigEndpoint.java index d6ceb690f..de6bd4fb0 100644 --- a/application/src/main/java/run/halo/app/core/endpoint/SystemConfigEndpoint.java +++ b/application/src/main/java/run/halo/app/core/endpoint/console/SystemConfigEndpoint.java @@ -1,4 +1,4 @@ -package run.halo.app.core.endpoint; +package run.halo.app.core.endpoint.console; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.content.Builder.contentBuilder; diff --git a/application/src/main/java/run/halo/app/core/endpoint/console/SystemInitializationEndpoint.java b/application/src/main/java/run/halo/app/core/endpoint/console/SystemInitializationEndpoint.java deleted file mode 100644 index 4bb6b21b3..000000000 --- a/application/src/main/java/run/halo/app/core/endpoint/console/SystemInitializationEndpoint.java +++ /dev/null @@ -1,147 +0,0 @@ -package run.halo.app.core.endpoint.console; - -import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; -import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; -import static org.springdoc.core.fn.builders.header.Builder.headerBuilder; -import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; - -import io.swagger.v3.oas.annotations.media.Schema; -import java.net.URI; -import java.time.Duration; -import java.util.LinkedHashMap; -import java.util.Map; -import lombok.Data; -import lombok.RequiredArgsConstructor; -import org.apache.commons.lang3.StringUtils; -import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; -import org.springframework.dao.OptimisticLockingFailureException; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Component; -import org.springframework.web.reactive.function.server.RouterFunction; -import org.springframework.web.reactive.function.server.ServerRequest; -import org.springframework.web.reactive.function.server.ServerResponse; -import org.springframework.web.server.ResponseStatusException; -import org.springframework.web.server.ServerWebInputException; -import reactor.core.publisher.Mono; -import reactor.util.retry.Retry; -import run.halo.app.core.extension.endpoint.CustomEndpoint; -import run.halo.app.extension.ConfigMap; -import run.halo.app.extension.ReactiveExtensionClient; -import run.halo.app.infra.InitializationStateGetter; -import run.halo.app.infra.SystemSetting; -import run.halo.app.infra.ValidationUtils; -import run.halo.app.infra.exception.UnsatisfiedAttributeValueException; -import run.halo.app.infra.utils.JsonUtils; -import run.halo.app.security.SuperAdminInitializer; - -/** - * System initialization endpoint. - * - * @author guqing - * @since 2.9.0 - */ -@Component -@RequiredArgsConstructor -public class SystemInitializationEndpoint implements CustomEndpoint { - - private final ReactiveExtensionClient client; - private final SuperAdminInitializer superAdminInitializer; - private final InitializationStateGetter initializationStateSupplier; - - @Override - public RouterFunction endpoint() { - var tag = "SystemV1alpha1Console"; - // define a non-resource api - return SpringdocRouteBuilder.route() - .POST("/system/initialize", this::initialize, - builder -> builder.operationId("initialize") - .description("Initialize system") - .tag(tag) - .requestBody(requestBodyBuilder() - .implementation(SystemInitializationRequest.class)) - .response(responseBuilder() - .responseCode(HttpStatus.CREATED.value() + "") - .description("System initialization successfully.") - .header(headerBuilder() - .name(HttpHeaders.LOCATION) - .description("Redirect URL.") - ) - ) - ) - .build(); - } - - private Mono initialize(ServerRequest request) { - return request.bodyToMono(SystemInitializationRequest.class) - .switchIfEmpty( - Mono.error(new ServerWebInputException("Request body must not be empty")) - ) - .doOnNext(requestBody -> { - if (!ValidationUtils.validateName(requestBody.getUsername())) { - throw new UnsatisfiedAttributeValueException( - "The username does not meet the specifications", - "problemDetail.user.username.unsatisfied", null); - } - if (StringUtils.isBlank(requestBody.getPassword())) { - throw new UnsatisfiedAttributeValueException( - "The password does not meet the specifications", - "problemDetail.user.password.unsatisfied", null); - } - }) - .flatMap(requestBody -> initializationStateSupplier.userInitialized() - .flatMap(result -> { - if (result) { - return Mono.error(new ResponseStatusException(HttpStatus.CONFLICT, - "System has been initialized")); - } - return initializeSystem(requestBody); - }) - ) - .then(ServerResponse.created(URI.create("/console")).build()); - } - - private Mono initializeSystem(SystemInitializationRequest requestBody) { - Mono initializeAdminUser = superAdminInitializer.initialize( - SuperAdminInitializer.InitializationParam.builder() - .username(requestBody.getUsername()) - .password(requestBody.getPassword()) - .email(requestBody.getEmail()) - .build()); - - Mono siteSetting = - Mono.defer(() -> client.get(ConfigMap.class, SystemSetting.SYSTEM_CONFIG) - .flatMap(config -> { - Map data = config.getData(); - if (data == null) { - data = new LinkedHashMap<>(); - config.setData(data); - } - String basic = data.getOrDefault(SystemSetting.Basic.GROUP, "{}"); - SystemSetting.Basic basicSetting = - JsonUtils.jsonToObject(basic, SystemSetting.Basic.class); - basicSetting.setTitle(requestBody.getSiteTitle()); - data.put(SystemSetting.Basic.GROUP, JsonUtils.objectToJson(basicSetting)); - return client.update(config); - })) - .retryWhen(Retry.backoff(5, Duration.ofMillis(100)) - .filter(t -> t instanceof OptimisticLockingFailureException) - ) - .then(); - return Mono.when(initializeAdminUser, siteSetting); - } - - @Data - public static class SystemInitializationRequest { - - @Schema(requiredMode = REQUIRED, minLength = 1) - private String username; - - @Schema(requiredMode = REQUIRED, minLength = 3) - private String password; - - private String email; - - private String siteTitle; - } -} diff --git a/application/src/main/java/run/halo/app/infra/DefaultThemeInitializer.java b/application/src/main/java/run/halo/app/infra/DefaultThemeInitializer.java deleted file mode 100644 index 31af2d0a0..000000000 --- a/application/src/main/java/run/halo/app/infra/DefaultThemeInitializer.java +++ /dev/null @@ -1,65 +0,0 @@ -package run.halo.app.infra; - -import java.io.IOException; -import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.context.event.ApplicationStartedEvent; -import org.springframework.context.ApplicationListener; -import org.springframework.core.io.UrlResource; -import org.springframework.core.io.buffer.DataBufferUtils; -import org.springframework.core.io.buffer.DefaultDataBufferFactory; -import org.springframework.stereotype.Component; -import org.springframework.util.ResourceUtils; -import org.springframework.util.StreamUtils; -import run.halo.app.infra.properties.HaloProperties; -import run.halo.app.infra.properties.ThemeProperties; -import run.halo.app.infra.utils.FileUtils; -import run.halo.app.theme.service.ThemeService; - -@Slf4j -@Component -public class DefaultThemeInitializer implements ApplicationListener { - - private final ThemeService themeService; - - private final ThemeRootGetter themeRoot; - - private final ThemeProperties themeProps; - - public DefaultThemeInitializer(ThemeService themeService, ThemeRootGetter themeRoot, - HaloProperties haloProps) { - this.themeService = themeService; - this.themeRoot = themeRoot; - this.themeProps = haloProps.getTheme(); - } - - @Override - public void onApplicationEvent(ApplicationStartedEvent event) { - if (themeProps.getInitializer().isDisabled()) { - log.debug("Skipped initializing default theme due to disabled"); - return; - } - var themeRoot = this.themeRoot.get(); - var location = themeProps.getInitializer().getLocation(); - try { - // TODO Checking if any themes are installed here in the future might be better? - if (!FileUtils.isEmpty(themeRoot)) { - log.debug("Skipped initializing default theme because there are themes " - + "inside theme root"); - return; - } - log.info("Initializing default theme from {}", location); - var themeUrl = ResourceUtils.getURL(location); - var content = DataBufferUtils.read(new UrlResource(themeUrl), - DefaultDataBufferFactory.sharedInstance, - StreamUtils.BUFFER_SIZE); - var theme = themeService.install(content).block(); - log.info("Initialized default theme: {}", theme); - // Because default active theme is default, we don't need to enabled it manually. - } catch (IOException e) { - // we should skip the initialization error at here - log.warn("Failed to initialize theme from " + location, e); - } - } - - -} diff --git a/application/src/main/java/run/halo/app/infra/SystemState.java b/application/src/main/java/run/halo/app/infra/SystemState.java index 1403a15cc..03e8a37e0 100644 --- a/application/src/main/java/run/halo/app/infra/SystemState.java +++ b/application/src/main/java/run/halo/app/infra/SystemState.java @@ -3,11 +3,19 @@ package run.halo.app.infra; import com.fasterxml.jackson.databind.JsonNode; import com.github.fge.jsonpatch.JsonPatchException; import com.github.fge.jsonpatch.mergepatch.JsonMergePatch; +import java.time.Duration; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.Map; +import java.util.function.Consumer; import lombok.Data; +import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.lang.NonNull; +import reactor.core.publisher.Mono; +import reactor.util.retry.Retry; import run.halo.app.extension.ConfigMap; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.utils.JsonParseException; import run.halo.app.infra.utils.JsonUtils; @@ -68,6 +76,33 @@ public class SystemState { } } + /** + *

Update system state by the given {@link Consumer}.

+ *

if the system state config map does not exist, it will create a new one.

+ */ + public static Mono upsetSystemState(ReactiveExtensionClient client, + Consumer consumer) { + return Mono.defer(() -> client.fetch(ConfigMap.class, SYSTEM_STATES_CONFIGMAP) + .switchIfEmpty(Mono.defer(() -> { + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata(new Metadata()); + configMap.getMetadata().setName(SYSTEM_STATES_CONFIGMAP); + configMap.setData(new HashMap<>()); + return client.create(configMap); + })) + .flatMap(configMap -> { + SystemState systemState = deserialize(configMap); + consumer.accept(systemState); + update(systemState, configMap); + return client.update(configMap); + }) + ) + .retryWhen(Retry.backoff(5, Duration.ofMillis(100)) + .filter(OptimisticLockingFailureException.class::isInstance) + ) + .then(); + } + private static String emptyJsonObject() { return "{}"; } diff --git a/application/src/main/java/run/halo/app/infra/ValidationUtils.java b/application/src/main/java/run/halo/app/infra/ValidationUtils.java index bb5213205..9ce908c36 100644 --- a/application/src/main/java/run/halo/app/infra/ValidationUtils.java +++ b/application/src/main/java/run/halo/app/infra/ValidationUtils.java @@ -6,8 +6,14 @@ import org.apache.commons.lang3.StringUtils; @UtilityClass public class ValidationUtils { - public static final Pattern NAME_PATTERN = - Pattern.compile("^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$"); + public static final String NAME_REGEX = + "^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$"; + public static final Pattern NAME_PATTERN = Pattern.compile(NAME_REGEX); + + /** + * No Chinese, no spaces. + */ + public static final String PASSWORD_REGEX = "^(?!.*[\\u4e00-\\u9fa5])(?=\\S+$).+$"; public static final String EMAIL_REGEX = "^[a-zA-Z0-9_+&*-]+(?:\\.[a-zA-Z0-9_+&*-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,7}$"; diff --git a/application/src/main/java/run/halo/app/infra/utils/FileUtils.java b/application/src/main/java/run/halo/app/infra/utils/FileUtils.java index ca790bfde..ffaac5cbe 100644 --- a/application/src/main/java/run/halo/app/infra/utils/FileUtils.java +++ b/application/src/main/java/run/halo/app/infra/utils/FileUtils.java @@ -25,6 +25,7 @@ import java.util.zip.ZipInputStream; import java.util.zip.ZipOutputStream; import lombok.extern.slf4j.Slf4j; import org.reactivestreams.Publisher; +import org.springframework.core.io.Resource; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.lang.NonNull; import org.springframework.util.AntPathMatcher; @@ -296,6 +297,14 @@ public abstract class FileUtils { .subscribeOn(scheduler); } + public static void copyResource(Resource resource, Path path) { + try (var inputStream = resource.getInputStream()) { + Files.copy(inputStream, path, REPLACE_EXISTING); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + public static void copy(Path source, Path dest, CopyOption... options) { try { Files.copy(source, dest, options); diff --git a/application/src/main/java/run/halo/app/infra/utils/HaloUtils.java b/application/src/main/java/run/halo/app/infra/utils/HaloUtils.java index 08d9e2cc0..190536beb 100644 --- a/application/src/main/java/run/halo/app/infra/utils/HaloUtils.java +++ b/application/src/main/java/run/halo/app/infra/utils/HaloUtils.java @@ -5,6 +5,7 @@ import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.time.Instant; import java.time.ZoneId; +import lombok.experimental.UtilityClass; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.core.io.ClassPathResource; @@ -14,12 +15,22 @@ import org.springframework.util.StreamUtils; import org.springframework.web.reactive.function.server.ServerRequest; /** + * Halo utilities. + * * @author guqing - * @date 2022-04-12 + * @since 2.0.0 */ @Slf4j +@UtilityClass public class HaloUtils { + /** + * Check if the request is an XMLHttpRequest. + */ + public static boolean isXhr(HttpHeaders headers) { + return headers.getOrEmpty("X-Requested-With").contains("XMLHttpRequest"); + } + /** *

Read the file under the classpath as a string.

* @@ -51,7 +62,7 @@ public class HaloUtils { // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-CH-UA userAgent = httpHeaders.getFirst("Sec-CH-UA"); } - return StringUtils.defaultString(userAgent, "unknown"); + return StringUtils.defaultIfBlank(userAgent, "unknown"); } public static String getDayText(Instant instant) { diff --git a/application/src/main/java/run/halo/app/plugin/PluginService.java b/application/src/main/java/run/halo/app/plugin/PluginService.java index 193318cd6..3e8bb6e60 100644 --- a/application/src/main/java/run/halo/app/plugin/PluginService.java +++ b/application/src/main/java/run/halo/app/plugin/PluginService.java @@ -10,15 +10,7 @@ import run.halo.app.core.extension.Plugin; public interface PluginService { - Flux getPresets(); - - /** - * Gets a plugin information by preset name from plugin presets. - * - * @param presetName is preset name of plugin. - * @return plugin preset information. - */ - Mono getPreset(String presetName); + Mono installPresetPlugins(); /** * Installs a plugin from a temporary Jar path. diff --git a/application/src/main/java/run/halo/app/plugin/PluginServiceImpl.java b/application/src/main/java/run/halo/app/plugin/PluginServiceImpl.java index 25383e89d..540dc853b 100644 --- a/application/src/main/java/run/halo/app/plugin/PluginServiceImpl.java +++ b/application/src/main/java/run/halo/app/plugin/PluginServiceImpl.java @@ -23,6 +23,7 @@ import java.util.Objects; import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; import org.pf4j.DependencyResolver; import org.pf4j.PluginDescriptor; import org.pf4j.PluginWrapper; @@ -36,6 +37,7 @@ import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBufferUtils; import org.springframework.core.io.buffer.DefaultDataBufferFactory; import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; @@ -110,18 +112,36 @@ public class PluginServiceImpl implements PluginService, InitializingBean, Dispo } @Override - public Flux getPresets() { - // list presets from classpath - return Flux.defer(() -> getPresetJars() - .map(this::toPath) - .map(path -> new YamlPluginFinder().find(path))); + public Mono installPresetPlugins() { + return getPresetJars() + .flatMap(path -> this.install(path) + .onErrorResume(PluginAlreadyExistsException.class, e -> Mono.empty()) + .flatMap(plugin -> FileUtils.deleteFileSilently(path) + .thenReturn(plugin) + ) + ) + .flatMap(this::enablePlugin) + .subscribeOn(Schedulers.boundedElastic()) + .then(); } - @Override - public Mono getPreset(String presetName) { - return getPresets() - .filter(plugin -> Objects.equals(plugin.getMetadata().getName(), presetName)) - .next(); + private Mono enablePlugin(Plugin plugin) { + plugin.getSpec().setEnabled(true); + return client.update(plugin) + .onErrorResume(OptimisticLockingFailureException.class, + e -> enablePlugin(plugin.getMetadata().getName()) + ); + } + + private Mono enablePlugin(String name) { + return Mono.defer(() -> client.get(Plugin.class, name) + .flatMap(plugin -> { + plugin.getSpec().setEnabled(true); + return client.update(plugin); + }) + ) + .retryWhen(Retry.backoff(8, Duration.ofMillis(100)) + .filter(OptimisticLockingFailureException.class::isInstance)); } @Override @@ -481,24 +501,25 @@ public class PluginServiceImpl implements PluginService, InitializingBean, Dispo } } - private Flux getPresetJars() { + private Flux getPresetJars() { var resolver = new PathMatchingResourcePatternResolver(); try { var resources = resolver.getResources(PRESETS_LOCATION_PATTERN); - return Flux.fromArray(resources); + return Flux.fromArray(resources) + .mapNotNull(resource -> { + var filename = resource.getFilename(); + if (StringUtils.isBlank(filename)) { + return null; + } + var path = tempDir.resolve(filename); + FileUtils.copyResource(resource, path); + return path; + }); } catch (IOException e) { return Flux.error(e); } } - private Path toPath(Resource resource) { - try { - return Path.of(resource.getURI()); - } catch (IOException e) { - throw Exceptions.propagate(e); - } - } - private static void updatePlugin(Plugin oldPlugin, Plugin newPlugin) { var oldMetadata = oldPlugin.getMetadata(); var newMetadata = newPlugin.getMetadata(); diff --git a/application/src/main/java/run/halo/app/security/CsrfConfigurer.java b/application/src/main/java/run/halo/app/security/CsrfConfigurer.java index 0dc3b25e3..bac91e04b 100644 --- a/application/src/main/java/run/halo/app/security/CsrfConfigurer.java +++ b/application/src/main/java/run/halo/app/security/CsrfConfigurer.java @@ -18,7 +18,7 @@ public class CsrfConfigurer implements SecurityConfigurer { public void configure(ServerHttpSecurity http) { var csrfMatcher = new AndServerWebExchangeMatcher( CsrfWebFilter.DEFAULT_CSRF_MATCHER, - new NegatedServerWebExchangeMatcher(pathMatchers("/api/**", "/apis/**") + new NegatedServerWebExchangeMatcher(pathMatchers("/api/**", "/apis/**", "/system/setup") )); http.csrf(csrfSpec -> csrfSpec .csrfTokenRepository(withHttpOnlyFalse()) diff --git a/application/src/main/java/run/halo/app/security/DefaultServerAuthenticationEntryPoint.java b/application/src/main/java/run/halo/app/security/DefaultServerAuthenticationEntryPoint.java index 24dfbf387..dfb6b4fa1 100644 --- a/application/src/main/java/run/halo/app/security/DefaultServerAuthenticationEntryPoint.java +++ b/application/src/main/java/run/halo/app/security/DefaultServerAuthenticationEntryPoint.java @@ -10,6 +10,7 @@ import org.springframework.security.web.server.util.matcher.ServerWebExchangeMat import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher.MatchResult; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; +import run.halo.app.infra.utils.HaloUtils; /** * Default authentication entry point. @@ -22,8 +23,7 @@ import reactor.core.publisher.Mono; public class DefaultServerAuthenticationEntryPoint implements ServerAuthenticationEntryPoint { private final ServerWebExchangeMatcher xhrMatcher = exchange -> { - if (exchange.getRequest().getHeaders().getOrEmpty("X-Requested-With") - .contains("XMLHttpRequest")) { + if (HaloUtils.isXhr(exchange.getRequest().getHeaders())) { return MatchResult.match(); } return MatchResult.notMatch(); diff --git a/application/src/main/java/run/halo/app/security/InitializeRedirectionWebFilter.java b/application/src/main/java/run/halo/app/security/InitializeRedirectionWebFilter.java index 08f2e373c..d4820d921 100644 --- a/application/src/main/java/run/halo/app/security/InitializeRedirectionWebFilter.java +++ b/application/src/main/java/run/halo/app/security/InitializeRedirectionWebFilter.java @@ -1,13 +1,17 @@ package run.halo.app.security; +import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers; + import java.net.URI; import lombok.Getter; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; import org.springframework.lang.NonNull; import org.springframework.security.web.server.DefaultServerRedirectStrategy; import org.springframework.security.web.server.ServerRedirectStrategy; -import org.springframework.security.web.server.util.matcher.PathPatternParserServerWebExchangeMatcher; +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.stereotype.Component; import org.springframework.util.Assert; @@ -26,9 +30,11 @@ import run.halo.app.infra.InitializationStateGetter; @Component @RequiredArgsConstructor public class InitializeRedirectionWebFilter implements WebFilter { - private final URI location = URI.create("/console"); - private final ServerWebExchangeMatcher redirectMatcher = - new PathPatternParserServerWebExchangeMatcher("/", HttpMethod.GET); + private final URI location = URI.create("/system/setup"); + private final ServerWebExchangeMatcher redirectMatcher = new AndServerWebExchangeMatcher( + pathMatchers(HttpMethod.GET, "/", "/console/**", "/uc/**", "/login", "/signup"), + new MediaTypeServerWebExchangeMatcher(MediaType.TEXT_HTML) + ); private final InitializationStateGetter initializationStateGetter; 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 new file mode 100644 index 000000000..afe07f8ff --- /dev/null +++ b/application/src/main/java/run/halo/app/security/preauth/SystemSetupEndpoint.java @@ -0,0 +1,274 @@ +package run.halo.app.security.preauth; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; +import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; +import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; +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 io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Properties; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.fn.builders.content.Builder; +import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; +import org.springframework.beans.factory.config.PlaceholderConfigurerSupport; +import org.springframework.context.annotation.Bean; +import org.springframework.core.io.ClassPathResource; +import org.springframework.dao.OptimisticLockingFailureException; +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; +import org.springframework.validation.BindingResult; +import org.springframework.validation.Validator; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.ServerRequest; +import org.springframework.web.reactive.function.server.ServerResponse; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; +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.InitializationStateGetter; +import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.SystemSetting; +import run.halo.app.infra.SystemState; +import run.halo.app.infra.ValidationUtils; +import run.halo.app.infra.exception.RequestBodyValidationException; +import run.halo.app.infra.utils.HaloUtils; +import run.halo.app.infra.utils.JsonUtils; +import run.halo.app.infra.utils.YamlUnstructuredLoader; +import run.halo.app.plugin.PluginService; +import run.halo.app.security.SuperAdminInitializer; +import run.halo.app.theme.service.ThemeService; + +@Component +@RequiredArgsConstructor +public class SystemSetupEndpoint { + static final String SETUP_TEMPLATE = "setup"; + static final PropertyPlaceholderHelper PROPERTY_PLACEHOLDER_HELPER = + new PropertyPlaceholderHelper( + PlaceholderConfigurerSupport.DEFAULT_PLACEHOLDER_PREFIX, + PlaceholderConfigurerSupport.DEFAULT_PLACEHOLDER_SUFFIX + ); + + private final InitializationStateGetter initializationStateGetter; + private final SystemConfigurableEnvironmentFetcher systemConfigFetcher; + private final SuperAdminInitializer superAdminInitializer; + private final ReactiveExtensionClient client; + private final PluginService pluginService; + private final ThemeService themeService; + private final Validator validator; + + @Bean + RouterFunction setupPageRouter() { + final var tag = "System"; + return SpringdocRouteBuilder.route() + .GET(path("/system/setup").and(accept(MediaType.TEXT_HTML)), this::setupPage, + builder -> builder.operationId("JumpToSetupPage") + .description("Jump to setup page") + .tag(tag) + .response(responseBuilder() + .content(Builder.contentBuilder() + .mediaType(MediaType.TEXT_HTML_VALUE)) + .implementation(String.class) + ) + ) + .POST("/system/setup", contentType(MediaType.APPLICATION_FORM_URLENCODED), this::setup, + builder -> builder + .operationId("SetupSystem") + .description("Setup system") + .tag(tag) + .requestBody(requestBodyBuilder() + .implementation(SetupRequest.class) + .content(Builder.contentBuilder() + .mediaType(MediaType.APPLICATION_FORM_URLENCODED_VALUE) + ) + ) + .response(responseBuilder() + .responseCode(String.valueOf(HttpStatus.NO_CONTENT.value())) + .implementation(Void.class) + ) + ) + .build(); + } + + private Mono setup(ServerRequest request) { + return request.formData() + .map(SetupRequest::new) + .filterWhen(body -> initializationStateGetter.userInitialized() + .map(initialized -> !initialized) + ) + .flatMap(body -> { + var bindingResult = body.toBindingResult(); + validator.validate(body, bindingResult); + if (bindingResult.hasErrors()) { + return handleValidationErrors(bindingResult, request); + } + return doInitialization(body) + .then(Mono.defer(() -> handleSetupSuccessfully(request))); + }); + } + + private static Mono handleSetupSuccessfully(ServerRequest request) { + if (isHtmlRequest(request)) { + return redirectToConsole(); + } + return ServerResponse.noContent().build(); + } + + private Mono handleValidationErrors(BindingResult bindingResult, + ServerRequest request) { + if (isHtmlRequest(request)) { + return ServerResponse.status(HttpStatus.BAD_REQUEST) + .render(SETUP_TEMPLATE, bindingResult.getModel()); + } + return Mono.error(new RequestBodyValidationException(bindingResult)); + } + + private static boolean isHtmlRequest(ServerRequest request) { + return request.headers().accept().contains(MediaType.TEXT_HTML) + && !HaloUtils.isXhr(request.headers().asHttpHeaders()); + } + + private static Mono redirectToConsole() { + return ServerResponse.temporaryRedirect(URI.create("/console")).build(); + } + + private Mono doInitialization(SetupRequest body) { + var superUserMono = superAdminInitializer.initialize( + SuperAdminInitializer.InitializationParam.builder() + .username(body.getUsername()) + .password(body.getPassword()) + .email(body.getEmail()) + .build() + ) + .subscribeOn(Schedulers.boundedElastic()); + + var basicConfigMono = Mono.defer(() -> systemConfigFetcher.getConfigMap() + .flatMap(configMap -> { + mergeToBasicConfig(body, configMap); + return client.update(configMap); + }) + ) + .retryWhen(Retry.backoff(5, Duration.ofMillis(100)) + .filter(t -> t instanceof OptimisticLockingFailureException) + ) + .subscribeOn(Schedulers.boundedElastic()) + .then(); + return Mono.when(superUserMono, basicConfigMono, + initializeNecessaryData(body.getUsername()), + pluginService.installPresetPlugins(), + themeService.installPresetTheme() + ) + .then(SystemState.upsetSystemState(client, state -> state.setIsSetup(true))); + } + + private Mono initializeNecessaryData(String username) { + return loadPresetExtensions(username) + .concatMap(client::create) + .subscribeOn(Schedulers.boundedElastic()) + .then(); + } + + private static void mergeToBasicConfig(SetupRequest body, ConfigMap configMap) { + Map data = configMap.getData(); + if (data == null) { + data = new LinkedHashMap<>(); + configMap.setData(data); + } + String basic = data.getOrDefault(SystemSetting.Basic.GROUP, "{}"); + var basicSetting = JsonUtils.jsonToObject(basic, SystemSetting.Basic.class); + basicSetting.setTitle(body.getSiteTitle()); + data.put(SystemSetting.Basic.GROUP, JsonUtils.objectToJson(basicSetting)); + } + + private Mono setupPage(ServerRequest request) { + return initializationStateGetter.userInitialized() + .flatMap(initialized -> { + if (initialized) { + return redirectToConsole(); + } + var body = new SetupRequest(new LinkedMultiValueMap<>()); + var bindingResult = body.toBindingResult(); + return ServerResponse.ok().render(SETUP_TEMPLATE, bindingResult.getModel()); + }); + } + + record SetupRequest(MultiValueMap formData) { + + @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"); + } + + @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"); + } + + @Email + public String getEmail() { + return formData.getFirst("email"); + } + + @NotBlank + @Size(max = 80) + public String getSiteTitle() { + return formData.getFirst("siteTitle"); + } + + public BindingResult toBindingResult() { + return new BeanPropertyBindingResult(this, "form"); + } + } + + Flux loadPresetExtensions(String username) { + return Mono.fromCallable( + () -> { + // read initial-data.yaml to string + var classPathResource = new ClassPathResource("initial-data.yaml"); + String rawContent = StreamUtils.copyToString(classPathResource.getInputStream(), + StandardCharsets.UTF_8); + // build properties + var properties = new Properties(); + properties.setProperty("username", username); + properties.setProperty("timestamp", Instant.now().toString()); + // replace placeholders + var processedContent = + PROPERTY_PLACEHOLDER_HELPER.replacePlaceholders(rawContent, properties); + // load yaml to unstructured + var stringResource = new InMemoryResource(processedContent); + var loader = new YamlUnstructuredLoader(stringResource); + return loader.load(); + }) + .flatMapMany(Flux::fromIterable) + .subscribeOn(Schedulers.boundedElastic()); + } +} \ No newline at end of file diff --git a/application/src/main/java/run/halo/app/theme/TemplateEngineManager.java b/application/src/main/java/run/halo/app/theme/TemplateEngineManager.java index 36ac57da3..9b9487d72 100644 --- a/application/src/main/java/run/halo/app/theme/TemplateEngineManager.java +++ b/application/src/main/java/run/halo/app/theme/TemplateEngineManager.java @@ -1,13 +1,10 @@ package run.halo.app.theme; -import java.io.FileNotFoundException; -import java.nio.file.Path; import lombok.NonNull; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.thymeleaf.ThymeleafProperties; import org.springframework.stereotype.Component; import org.springframework.util.ConcurrentLruCache; -import org.springframework.util.ResourceUtils; import org.thymeleaf.TemplateEngine; import org.thymeleaf.dialect.IDialect; import org.thymeleaf.spring6.ISpringWebFluxTemplateEngine; @@ -17,7 +14,6 @@ import org.thymeleaf.templateresolver.FileTemplateResolver; import org.thymeleaf.templateresolver.ITemplateResolver; import reactor.core.publisher.Mono; import run.halo.app.infra.ExternalUrlSupplier; -import run.halo.app.infra.exception.NotFoundException; import run.halo.app.plugin.HaloPluginManager; import run.halo.app.theme.dialect.HaloProcessorDialect; import run.halo.app.theme.engine.HaloTemplateEngine; @@ -71,24 +67,9 @@ public class TemplateEngineManager { public ISpringWebFluxTemplateEngine getTemplateEngine(ThemeContext theme) { CacheKey cacheKey = buildCacheKey(theme); - // cache not exists, will create new engine - if (!engineCache.contains(cacheKey)) { - // before this, check if theme exists - if (!fileExists(theme.getPath())) { - throw new NotFoundException("Theme not found."); - } - } return engineCache.get(cacheKey); } - private boolean fileExists(Path path) { - try { - return ResourceUtils.getFile(path.toUri()).exists(); - } catch (FileNotFoundException e) { - return false; - } - } - public Mono clearCache(String themeName) { return themeResolver.getThemeContext(themeName) .doOnNext(themeContext -> { diff --git a/application/src/main/java/run/halo/app/theme/service/ThemeService.java b/application/src/main/java/run/halo/app/theme/service/ThemeService.java index 007b4d3e5..7a6fa547a 100644 --- a/application/src/main/java/run/halo/app/theme/service/ThemeService.java +++ b/application/src/main/java/run/halo/app/theme/service/ThemeService.java @@ -8,6 +8,8 @@ import run.halo.app.extension.ConfigMap; public interface ThemeService { + Mono installPresetTheme(); + Mono install(Publisher content); Mono upgrade(String themeName, Publisher content); diff --git a/application/src/main/java/run/halo/app/theme/service/ThemeServiceImpl.java b/application/src/main/java/run/halo/app/theme/service/ThemeServiceImpl.java index c1abe453d..37a7b142d 100644 --- a/application/src/main/java/run/halo/app/theme/service/ThemeServiceImpl.java +++ b/application/src/main/java/run/halo/app/theme/service/ThemeServiceImpl.java @@ -8,6 +8,8 @@ import static run.halo.app.theme.service.ThemeUtils.loadThemeManifest; import static run.halo.app.theme.service.ThemeUtils.locateThemeManifest; import static run.halo.app.theme.service.ThemeUtils.unzipThemeTo; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; import java.util.HashMap; @@ -18,11 +20,17 @@ import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.reactivestreams.Publisher; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.UrlResource; import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.retry.RetryException; import org.springframework.stereotype.Service; import org.springframework.util.Assert; +import org.springframework.util.ResourceUtils; +import org.springframework.util.StreamUtils; import org.springframework.web.server.ServerErrorException; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Flux; @@ -41,6 +49,8 @@ import run.halo.app.infra.SystemVersionSupplier; import run.halo.app.infra.ThemeRootGetter; import run.halo.app.infra.exception.ThemeUpgradeException; import run.halo.app.infra.exception.UnsatisfiedAttributeValueException; +import run.halo.app.infra.properties.HaloProperties; +import run.halo.app.infra.utils.FileUtils; import run.halo.app.infra.utils.SettingUtils; import run.halo.app.infra.utils.VersionUtils; @@ -53,10 +63,48 @@ public class ThemeServiceImpl implements ThemeService { private final ThemeRootGetter themeRoot; + private final HaloProperties haloProperties; + private final SystemVersionSupplier systemVersionSupplier; private final Scheduler scheduler = Schedulers.boundedElastic(); + @Override + public Mono installPresetTheme() { + var themeProps = haloProperties.getTheme(); + var location = themeProps.getInitializer().getLocation(); + return createThemeTempPath() + .flatMap(tempPath -> Mono.usingWhen(copyPresetThemeToPath(location, tempPath), + path -> { + var content = DataBufferUtils.read(new FileSystemResource(path), + DefaultDataBufferFactory.sharedInstance, + StreamUtils.BUFFER_SIZE); + return install(content); + }, path -> deleteRecursivelyAndSilently(tempPath, scheduler) + )) + .onErrorResume(IOException.class, e -> { + log.warn("Failed to initialize theme from {}", location, e); + return Mono.empty(); + }) + .then(); + } + + private Mono copyPresetThemeToPath(String location, Path tempDir) { + return Mono.fromCallable( + () -> { + var themeUrl = ResourceUtils.getURL(location); + var resource = new UrlResource(themeUrl); + var tempThemePath = tempDir.resolve("theme.zip"); + FileUtils.copyResource(resource, tempThemePath); + return tempThemePath; + }); + } + + private static Mono createThemeTempPath() { + return Mono.fromCallable(() -> Files.createTempDirectory("halo-theme-preset")) + .subscribeOn(Schedulers.boundedElastic()); + } + @Override public Mono install(Publisher content) { var themeRoot = this.themeRoot.get(); diff --git a/application/src/main/resources/config/i18n/messages.properties b/application/src/main/resources/config/i18n/messages.properties index b053fc49c..1123fcc8f 100644 --- a/application/src/main/resources/config/i18n/messages.properties +++ b/application/src/main/resources/config/i18n/messages.properties @@ -86,4 +86,7 @@ problemDetail.comment.waitingForApproval=Comment is awaiting approval. title.visibility.identification.private=(Private) signup.error.confirm-password-not-match=The confirmation password does not match the password. -signup.error.email-code.invalid=Invalid email code. \ No newline at end of file +signup.error.email-code.invalid=Invalid email code. + +validation.error.username.pattern=The username can only be lowercase and can only contain letters, numbers, hyphens, and dots, starting and ending with characters. +validation.error.password.pattern=The password cannot contain Chinese characters and spaces. \ No newline at end of file diff --git a/application/src/main/resources/config/i18n/messages_zh.properties b/application/src/main/resources/config/i18n/messages_zh.properties index 8655c96a0..3b5883143 100644 --- a/application/src/main/resources/config/i18n/messages_zh.properties +++ b/application/src/main/resources/config/i18n/messages_zh.properties @@ -60,3 +60,6 @@ problemDetail.comment.waitingForApproval=评论审核中。 title.visibility.identification.private=(私有) signup.error.confirm-password-not-match=确认密码与密码不匹配。 signup.error.email-code.invalid=邮箱验证码无效。 + +validation.error.username.pattern=用户名只能小写且只能包含字母、数字、中划线和点,以字符开头和结尾 +validation.error.password.pattern=密码不能包含中文和空格 \ No newline at end of file diff --git a/application/src/main/resources/extensions/role-template-anonymous.yaml b/application/src/main/resources/extensions/role-template-anonymous.yaml index 8f2ffce10..0e606e55f 100644 --- a/application/src/main/resources/extensions/role-template-anonymous.yaml +++ b/application/src/main/resources/extensions/role-template-anonymous.yaml @@ -23,8 +23,6 @@ rules: verbs: [ "create" ] - nonResourceURLs: [ "/actuator/globalinfo", "/actuator/health", "/actuator/health/*", "/login/public-key" ] verbs: [ "get" ] - - nonResourceURLs: [ "/apis/api.console.halo.run/v1alpha1/system/initialize" ] - verbs: [ "create" ] --- apiVersion: v1alpha1 kind: "Role" diff --git a/application/src/main/resources/initial-data.yaml b/application/src/main/resources/initial-data.yaml new file mode 100644 index 000000000..66112e3ac --- /dev/null +++ b/application/src/main/resources/initial-data.yaml @@ -0,0 +1,239 @@ +# 提供了 timestamp、username 变量,用于初始化数据时填充时间戳和用户名 +# 初始化文章关联的分类、标签数据 +apiVersion: content.halo.run/v1alpha1 +kind: Category +metadata: + name: 76514a40-6ef1-4ed9-b58a-e26945bde3ca +spec: + displayName: 默认分类 + slug: default + description: 这是你的默认分类,如不需要,删除即可。 + cover: "" + template: "" + priority: 0 + children: [ ] +status: + permalink: "/categories/default" + +--- +apiVersion: content.halo.run/v1alpha1 +kind: Tag +metadata: + name: c33ceabb-d8f1-4711-8991-bb8f5c92ad7c +spec: + displayName: Halo + slug: halo + color: "#ffffff" + cover: "" +status: + permalink: "/tags/halo" + +--- +# 文章关联的内容 +apiVersion: content.halo.run/v1alpha1 +kind: Snapshot +metadata: + name: fb5cd6bd-998d-4ccc-984d-5cc23b0a09f9 + annotations: + content.halo.run/keep-raw: "true" +spec: + subjectRef: + group: content.halo.run + version: v1alpha1 + kind: Post + name: 5152aea5-c2e8-4717-8bba-2263d46e19d5 + rawType: HTML + rawPatch:

Hello + Halo

如果你看到了这一篇文章,那么证明你已经安装成功了,感谢使用 Halo + 进行创作,希望能够使用愉快。

相关链接

在使用过程中,有任何问题都可以通过以上链接找寻答案,或者联系我们。

这是一篇自动生成的文章,请删除这篇文章之后开始你的创作吧!

+ contentPatch:

Hello + Halo

如果你看到了这一篇文章,那么证明你已经安装成功了,感谢使用 Halo + 进行创作,希望能够使用愉快。

相关链接

在使用过程中,有任何问题都可以通过以上链接找寻答案,或者联系我们。

这是一篇自动生成的文章,请删除这篇文章之后开始你的创作吧!

+ lastModifyTime: "${timestamp}" + owner: "${username}" + contributors: + - "${username}" + +--- +# 初始化文章数据 +apiVersion: content.halo.run/v1alpha1 +kind: Post +metadata: + name: 5152aea5-c2e8-4717-8bba-2263d46e19d5 +spec: + title: Hello Halo + slug: hello-halo + releaseSnapshot: fb5cd6bd-998d-4ccc-984d-5cc23b0a09f9 + headSnapshot: fb5cd6bd-998d-4ccc-984d-5cc23b0a09f9 + baseSnapshot: fb5cd6bd-998d-4ccc-984d-5cc23b0a09f9 + owner: "${username}" + template: "" + cover: "" + deleted: false + publish: true + publishTime: "${timestamp}" + pinned: false + allowComment: true + visible: PUBLIC + priority: 0 + excerpt: + autoGenerate: false + raw: 如果你看到了这一篇文章,那么证明你已经安装成功了,感谢使用 Halo 进行创作,希望能够使用愉快。 + categories: + - 76514a40-6ef1-4ed9-b58a-e26945bde3ca + tags: + - c33ceabb-d8f1-4711-8991-bb8f5c92ad7c + htmlMetas: [ ] +status: + permalink: /archives/hello-halo + +--- +# 自定义页面关联的内容 +apiVersion: content.halo.run/v1alpha1 +kind: Snapshot +metadata: + name: c3f73cc2-194e-4cd8-9092-7386aa50a0e5 + annotations: + content.halo.run/keep-raw: "true" +spec: + subjectRef: + group: content.halo.run + version: v1alpha1 + kind: SinglePage + name: 373a5f79-f44f-441a-9df1-85a4f553ece8 + rawType: HTML + rawPatch:

关于页面

这是一个自定义页面,你可以在后台的 页面 + -> 自定义页面 + 找到它,你可以用于新建关于页面、联系我们页面等等。

这是一篇自动生成的页面,你可以在后台删除它。

+ contentPatch:

关于页面

这是一个自定义页面,你可以在后台的 页面 + -> 自定义页面 + 找到它,你可以用于新建关于页面、联系我们页面等等。

这是一篇自动生成的页面,你可以在后台删除它。

+ lastModifyTime: "${timestamp}" + owner: "${username}" + contributors: + - "${username}" + +--- +# 初始化自定义页面数据 +apiVersion: content.halo.run/v1alpha1 +kind: SinglePage +metadata: + name: 373a5f79-f44f-441a-9df1-85a4f553ece8 +spec: + title: 关于 + slug: about + template: "" + cover: "" + owner: "${username}" + deleted: false + publish: true + baseSnapshot: c3f73cc2-194e-4cd8-9092-7386aa50a0e5 + headSnapshot: c3f73cc2-194e-4cd8-9092-7386aa50a0e5 + releaseSnapshot: c3f73cc2-194e-4cd8-9092-7386aa50a0e5 + pinned: false + allowComment: true + visible: PUBLIC + version: 1 + priority: 0 + excerpt: + autoGenerate: false + raw: 这是一个自定义页面,你可以在后台的 页面 -> 自定义页面 找到它,你可以用于新建关于页面、联系我们页面等等。 + htmlMetas: [ ] +status: + permalink: "/about" + +--- +# 首页菜单项 +apiVersion: v1alpha1 +kind: MenuItem +metadata: + name: 88c3f10b-321c-4092-86a8-70db00251b74 +spec: + displayName: 首页 + href: / + children: [ ] + priority: 0 +--- +# 关联到文章作为菜单 +apiVersion: v1alpha1 +kind: MenuItem +metadata: + name: c4c814d1-0c2c-456b-8c96-4864965fee94 +spec: + displayName: "Hello Halo" + href: "/archives/hello-halo" + children: [ ] + priority: 1 + targetRef: + group: content.halo.run + version: v1alpha1 + kind: Post + name: 5152aea5-c2e8-4717-8bba-2263d46e19d5 +--- +# 关联到标签作为菜单 +apiVersion: v1alpha1 +kind: MenuItem +metadata: + name: 35869bd3-33b5-448b-91ee-cf6517a59644 +spec: + displayName: "Halo" + href: "/tags/halo" + children: [ ] + priority: 2 + targetRef: + group: content.halo.run + version: v1alpha1 + kind: Tag + name: c33ceabb-d8f1-4711-8991-bb8f5c92ad7c +--- +# 关联到自定义页面作为菜单 +apiVersion: v1alpha1 +kind: MenuItem +metadata: + name: b0d041fa-dc99-48f6-a193-8604003379cf +spec: + displayName: "关于" + href: "/about" + children: [ ] + priority: 3 + targetRef: + group: content.halo.run + version: v1alpha1 + kind: SinglePage + name: 373a5f79-f44f-441a-9df1-85a4f553ece8 +--- +apiVersion: v1alpha1 +kind: Menu +metadata: + name: primary +spec: + displayName: 主菜单 + menuItems: + - 88c3f10b-321c-4092-86a8-70db00251b74 + - c4c814d1-0c2c-456b-8c96-4864965fee94 + - 35869bd3-33b5-448b-91ee-cf6517a59644 + - b0d041fa-dc99-48f6-a193-8604003379cf diff --git a/application/src/main/resources/templates/gateway_modules/form_fragments.html b/application/src/main/resources/templates/gateway_modules/form_fragments.html index 1533e1cfa..269771c09 100644 --- a/application/src/main/resources/templates/gateway_modules/form_fragments.html +++ b/application/src/main/resources/templates/gateway_modules/form_fragments.html @@ -134,7 +134,7 @@

@@ -142,7 +142,7 @@

@@ -254,13 +254,13 @@
@@ -294,4 +294,4 @@
- + \ No newline at end of file diff --git a/application/src/main/resources/templates/gateway_modules/input_fragments.html b/application/src/main/resources/templates/gateway_modules/input_fragments.html index ffffb5cd6..9a21e3cdc 100644 --- a/application/src/main/resources/templates/gateway_modules/input_fragments.html +++ b/application/src/main/resources/templates/gateway_modules/input_fragments.html @@ -1,4 +1,4 @@ -
+
@@ -28,4 +29,4 @@
-
+
\ No newline at end of file diff --git a/application/src/main/resources/templates/login_local.html b/application/src/main/resources/templates/login_local.html index 3ae24d400..a6021c6f1 100644 --- a/application/src/main/resources/templates/login_local.html +++ b/application/src/main/resources/templates/login_local.html @@ -50,7 +50,7 @@ - + \ No newline at end of file diff --git a/application/src/main/resources/templates/setup.html b/application/src/main/resources/templates/setup.html new file mode 100644 index 000000000..3bc8788b6 --- /dev/null +++ b/application/src/main/resources/templates/setup.html @@ -0,0 +1,121 @@ + + + + + + +
+
+ +
+

+ +
+
+ +
+ +
+

+
+ +
+ +
+ +
+

+
+ +
+ +
+ +
+

+
+ +
+ + +

+
+ +
+ + +
+ +
+ +
+
+
+ +
+
+ + +
+ \ No newline at end of file diff --git a/application/src/main/resources/templates/setup.properties b/application/src/main/resources/templates/setup.properties new file mode 100644 index 000000000..139b4c5d1 --- /dev/null +++ b/application/src/main/resources/templates/setup.properties @@ -0,0 +1,7 @@ +title=系统初始化 +form.siteTitle.label=站点标题 +form.username.label=用户名 +form.email.label=电子邮箱 +form.password.label=密码 +form.confirmPassword.label=确认密码 +form.submit=初始化 \ 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 new file mode 100644 index 000000000..f65c17eee --- /dev/null +++ b/application/src/main/resources/templates/setup_en.properties @@ -0,0 +1,7 @@ +title=Setup +form.siteTitle.label=Site title +form.username.label=Username +form.email.label=Email +form.password.label=Password +form.confirmPassword.label=Confirm Password +form.submit=Setup diff --git a/application/src/main/resources/templates/setup_es.properties b/application/src/main/resources/templates/setup_es.properties new file mode 100644 index 000000000..f4041620a --- /dev/null +++ b/application/src/main/resources/templates/setup_es.properties @@ -0,0 +1,7 @@ +title=Configuración +form.siteTitle.label=Título del Sitio +form.username.label=Nombre de Usuario +form.email.label=Correo Electrónico +form.password.label=Contraseña +form.confirmPassword.label=Confirmar Contraseña +form.submit=Configurar \ 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 new file mode 100644 index 000000000..1f2e3a147 --- /dev/null +++ b/application/src/main/resources/templates/setup_zh_TW.properties @@ -0,0 +1,7 @@ +title=系統初始化 +form.siteTitle.label=站點標題 +form.username.label=使用者名稱 +form.email.label=電子郵件 +form.password.label=密碼 +form.confirmPassword.label=確認密碼 +form.submit=初始化 \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/core/endpoint/console/SystemInitializationEndpointTest.java b/application/src/test/java/run/halo/app/core/endpoint/console/SystemInitializationEndpointTest.java deleted file mode 100644 index cd2238e86..000000000 --- a/application/src/test/java/run/halo/app/core/endpoint/console/SystemInitializationEndpointTest.java +++ /dev/null @@ -1,89 +0,0 @@ -package run.halo.app.core.endpoint.console; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import static org.springframework.test.web.reactive.server.WebTestClient.bindToRouterFunction; - -import org.junit.jupiter.api.BeforeEach; -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.test.web.reactive.server.WebTestClient; -import reactor.core.publisher.Mono; -import run.halo.app.core.endpoint.console.SystemInitializationEndpoint.SystemInitializationRequest; -import run.halo.app.extension.ConfigMap; -import run.halo.app.extension.ReactiveExtensionClient; -import run.halo.app.infra.InitializationStateGetter; -import run.halo.app.infra.SystemSetting; -import run.halo.app.security.SuperAdminInitializer; -import run.halo.app.security.SuperAdminInitializer.InitializationParam; - -/** - * Tests for {@link SystemInitializationEndpoint}. - * - * @author guqing - * @since 2.9.0 - */ -@ExtendWith(MockitoExtension.class) -class SystemInitializationEndpointTest { - - @Mock - InitializationStateGetter initializationStateGetter; - - @Mock - SuperAdminInitializer superAdminInitializer; - - @Mock - ReactiveExtensionClient client; - - @InjectMocks - SystemInitializationEndpoint initializationEndpoint; - - WebTestClient webTestClient; - - @BeforeEach - void setUp() { - webTestClient = bindToRouterFunction(initializationEndpoint.endpoint()).build(); - } - - @Test - void initializeWithoutRequestBody() { - webTestClient.post() - .uri("/system/initialize") - .exchange() - .expectStatus() - .isBadRequest(); - } - - @Test - void initializeWithRequestBody() { - var initialization = new SystemInitializationRequest(); - initialization.setUsername("faker"); - initialization.setPassword("openfaker"); - initialization.setEmail("faker@halo.run"); - initialization.setSiteTitle("Fake Site"); - - when(initializationStateGetter.userInitialized()).thenReturn(Mono.just(false)); - when(superAdminInitializer.initialize(any(InitializationParam.class))) - .thenReturn(Mono.empty()); - - var configMap = new ConfigMap(); - when(client.get(ConfigMap.class, SystemSetting.SYSTEM_CONFIG)) - .thenReturn(Mono.just(configMap)); - when(client.update(configMap)).thenReturn(Mono.just(configMap)); - - webTestClient.post().uri("/system/initialize") - .bodyValue(initialization) - .exchange() - .expectStatus().isCreated() - .expectHeader().location("/console"); - - verify(initializationStateGetter).userInitialized(); - verify(superAdminInitializer).initialize(any()); - verify(client).get(ConfigMap.class, SystemSetting.SYSTEM_CONFIG); - verify(client).update(configMap); - } -} diff --git a/application/src/test/java/run/halo/app/plugin/PluginServiceImplTest.java b/application/src/test/java/run/halo/app/plugin/PluginServiceImplTest.java index bb38bd0fd..94734dea6 100644 --- a/application/src/test/java/run/halo/app/plugin/PluginServiceImplTest.java +++ b/application/src/test/java/run/halo/app/plugin/PluginServiceImplTest.java @@ -77,33 +77,6 @@ class PluginServiceImplTest { @InjectMocks PluginServiceImpl pluginService; - @Test - void getPresetsTest() { - var presets = pluginService.getPresets(); - StepVerifier.create(presets) - .assertNext(plugin -> { - assertEquals("fake-plugin", plugin.getMetadata().getName()); - assertEquals("0.0.2", plugin.getSpec().getVersion()); - assertEquals(Plugin.Phase.PENDING, plugin.getStatus().getPhase()); - }) - .verifyComplete(); - } - - @Test - void getPresetIfNotFound() { - var plugin = pluginService.getPreset("not-found-plugin"); - StepVerifier.create(plugin) - .verifyComplete(); - } - - @Test - void getPresetIfFound() { - var plugin = pluginService.getPreset("fake-plugin"); - StepVerifier.create(plugin) - .expectNextCount(1) - .verifyComplete(); - } - @Nested class InstallUpdateReloadTest { diff --git a/application/src/test/java/run/halo/app/security/InitializeRedirectionWebFilterTest.java b/application/src/test/java/run/halo/app/security/InitializeRedirectionWebFilterTest.java index 9d7a1d81b..2e9d83991 100644 --- a/application/src/test/java/run/halo/app/security/InitializeRedirectionWebFilterTest.java +++ b/application/src/test/java/run/halo/app/security/InitializeRedirectionWebFilterTest.java @@ -2,6 +2,7 @@ package run.halo.app.security; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -14,6 +15,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; import org.springframework.mock.web.server.MockServerWebExchange; import org.springframework.security.web.server.ServerRedirectStrategy; @@ -50,49 +52,57 @@ class InitializeRedirectionWebFilterTest { when(initializationStateGetter.userInitialized()).thenReturn(Mono.just(false)); WebFilterChain chain = mock(WebFilterChain.class); + var paths = new String[] {"/", "/console/test", "/uc/test", "/login", "/signup"}; + for (String path : paths) { + MockServerHttpRequest request = MockServerHttpRequest.get(path) + .accept(MediaType.TEXT_HTML).build(); + MockServerWebExchange exchange = MockServerWebExchange.from(request); - MockServerHttpRequest request = MockServerHttpRequest.get("/").build(); - MockServerWebExchange exchange = MockServerWebExchange.from(request); + when(serverRedirectStrategy.sendRedirect(any(), any())).thenReturn(Mono.empty().then()); - when(serverRedirectStrategy.sendRedirect(any(), any())).thenReturn(Mono.empty().then()); + Mono result = filter.filter(exchange, chain); - Mono result = filter.filter(exchange, chain); + StepVerifier.create(result) + .expectNextCount(0) + .expectComplete() + .verify(); - StepVerifier.create(result) - .expectNextCount(0) - .expectComplete() - .verify(); - - verify(serverRedirectStrategy).sendRedirect(eq(exchange), eq(URI.create("/console"))); - verify(chain, never()).filter(eq(exchange)); + verify(serverRedirectStrategy).sendRedirect(eq(exchange), + eq(URI.create("/system/setup"))); + verify(chain, never()).filter(eq(exchange)); + } } @Test void shouldNotRedirectWhenSystemInitialized() { - when(initializationStateGetter.userInitialized()).thenReturn(Mono.just(true)); + lenient().when(initializationStateGetter.userInitialized()).thenReturn(Mono.just(true)); WebFilterChain chain = mock(WebFilterChain.class); - MockServerHttpRequest request = MockServerHttpRequest.get("/").build(); - MockServerWebExchange exchange = MockServerWebExchange.from(request); - when(chain.filter(any())).thenReturn(Mono.empty().then()); - Mono result = filter.filter(exchange, chain); + var paths = new String[] {"/test", "/apis/test", "system/setup", "/logout"}; + for (String path : paths) { + MockServerHttpRequest request = MockServerHttpRequest.get(path) + .accept(MediaType.TEXT_HTML).build(); + MockServerWebExchange exchange = MockServerWebExchange.from(request); + when(chain.filter(any())).thenReturn(Mono.empty().then()); + Mono result = filter.filter(exchange, chain); - StepVerifier.create(result) - .expectNextCount(0) - .expectComplete() - .verify(); + StepVerifier.create(result) + .expectNextCount(0) + .expectComplete() + .verify(); - verify(serverRedirectStrategy, never()).sendRedirect(eq(exchange), - eq(URI.create("/console"))); - verify(chain).filter(eq(exchange)); + verify(serverRedirectStrategy, never()).sendRedirect(eq(exchange), any()); + verify(chain).filter(eq(exchange)); + } } @Test - void shouldNotRedirectWhenNotHomePage() { + void shouldNotRedirectTest() { WebFilterChain chain = mock(WebFilterChain.class); - MockServerHttpRequest request = MockServerHttpRequest.get("/test").build(); + MockServerHttpRequest request = MockServerHttpRequest.get("/test") + .accept(MediaType.TEXT_HTML).build(); MockServerWebExchange exchange = MockServerWebExchange.from(request); when(chain.filter(any())).thenReturn(Mono.empty().then()); Mono result = filter.filter(exchange, chain); @@ -102,8 +112,7 @@ class InitializeRedirectionWebFilterTest { .expectComplete() .verify(); - verify(serverRedirectStrategy, never()).sendRedirect(eq(exchange), - eq(URI.create("/console"))); + verify(serverRedirectStrategy, never()).sendRedirect(eq(exchange), any()); verify(chain).filter(eq(exchange)); } } diff --git a/application/src/test/java/run/halo/app/security/preauth/SystemSetupEndpointTest.java b/application/src/test/java/run/halo/app/security/preauth/SystemSetupEndpointTest.java new file mode 100644 index 000000000..178d63672 --- /dev/null +++ b/application/src/test/java/run/halo/app/security/preauth/SystemSetupEndpointTest.java @@ -0,0 +1,30 @@ +package run.halo.app.security.preauth; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Properties; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link SystemSetupEndpoint}. + * + * @author guqing + * @since 2.20.0 + */ +class SystemSetupEndpointTest { + + @Test + void placeholderTest() { + var properties = new Properties(); + properties.setProperty("username", "guqing"); + properties.setProperty("timestamp", "2024-09-30"); + var str = SystemSetupEndpoint.PROPERTY_PLACEHOLDER_HELPER.replacePlaceholders(""" + ${username} + ${timestamp} + """, properties); + assertThat(str).isEqualTo(""" + guqing + 2024-09-30 + """); + } +} \ No newline at end of file