diff --git a/api-docs/openapi/v3_0/aggregated.json b/api-docs/openapi/v3_0/aggregated.json index 7426653032..b605b3f4fb 100644 --- a/api-docs/openapi/v3_0/aggregated.json +++ b/api-docs/openapi/v3_0/aggregated.json @@ -2651,30 +2651,6 @@ ] } }, - "/apis/api.console.halo.run/v1alpha1/plugin-presets": { - "get": { - "description": "List all plugin presets in the system.", - "operationId": "ListPluginPresets", - "responses": { - "default": { - "content": { - "*/*": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Plugin" - } - } - } - }, - "description": "default response" - } - }, - "tags": [ - "PluginV1alpha1Console" - ] - } - }, "/apis/api.console.halo.run/v1alpha1/plugins": { "get": { "description": "List plugins using query criteria and sort params", @@ -4309,38 +4285,6 @@ ] } }, - "/apis/api.console.halo.run/v1alpha1/system/initialize": { - "post": { - "description": "Initialize system", - "operationId": "initialize", - "requestBody": { - "content": { - "*/*": { - "schema": { - "$ref": "#/components/schemas/SystemInitializationRequest" - } - } - } - }, - "responses": { - "201": { - "description": "System initialization successfully.", - "headers": { - "Location": { - "description": "Redirect URL.", - "schema": { - "type": "string" - }, - "style": "simple" - } - } - } - }, - "tags": [ - "SystemV1alpha1Console" - ] - } - }, "/apis/api.console.halo.run/v1alpha1/tags": { "get": { "description": "List Post Tags.", @@ -14722,6 +14666,41 @@ ] } }, + "/apis/uc.api.auth.halo.run/v1alpha1/user-connections/{registerId}/disconnect": { + "put": { + "description": "Disconnect my connection from a third-party platform.", + "operationId": "DisconnectMyConnection", + "parameters": [ + { + "description": "The registration ID of the third-party platform.", + "in": "path", + "name": "registerId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserConnection" + } + } + } + }, + "description": "default response" + } + }, + "tags": [ + "UserConnectionV1alpha1Uc" + ] + } + }, "/apis/uc.api.content.halo.run/v1alpha1/attachments": { "post": { "description": "Create attachment for the given post.", @@ -20589,9 +20568,6 @@ } }, "PostStatus": { - "required": [ - "phase" - ], "type": "object", "properties": { "commentsCount": { @@ -22437,9 +22413,6 @@ } }, "SinglePageStatus": { - "required": [ - "phase" - ], "type": "object", "properties": { "commentsCount": { @@ -22839,29 +22812,6 @@ }, "description": "The subscriber to be notified" }, - "SystemInitializationRequest": { - "required": [ - "password", - "username" - ], - "type": "object", - "properties": { - "email": { - "type": "string" - }, - "password": { - "minLength": 3, - "type": "string" - }, - "siteTitle": { - "type": "string" - }, - "username": { - "minLength": 1, - "type": "string" - } - } - }, "Tag": { "required": [ "apiVersion", @@ -23625,36 +23575,15 @@ }, "UserConnectionSpec": { "required": [ - "accessToken", - "displayName", "providerUserId", "registrationId", "username" ], "type": "object", "properties": { - "accessToken": { - "type": "string" - }, - "avatarUrl": { - "type": "string" - }, - "displayName": { - "type": "string" - }, - "expiresAt": { - "type": "string", - "format": "date-time" - }, - "profileUrl": { - "type": "string" - }, "providerUserId": { "type": "string" }, - "refreshToken": { - "type": "string" - }, "registrationId": { "type": "string" }, diff --git a/api-docs/openapi/v3_0/apis_console.api_v1alpha1.json b/api-docs/openapi/v3_0/apis_console.api_v1alpha1.json index 8f2e91c2cb..bee0b0f624 100644 --- a/api-docs/openapi/v3_0/apis_console.api_v1alpha1.json +++ b/api-docs/openapi/v3_0/apis_console.api_v1alpha1.json @@ -518,30 +518,6 @@ ] } }, - "/apis/api.console.halo.run/v1alpha1/plugin-presets": { - "get": { - "description": "List all plugin presets in the system.", - "operationId": "ListPluginPresets", - "responses": { - "default": { - "content": { - "*/*": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Plugin" - } - } - } - }, - "description": "default response" - } - }, - "tags": [ - "PluginV1alpha1Console" - ] - } - }, "/apis/api.console.halo.run/v1alpha1/plugins": { "get": { "description": "List plugins using query criteria and sort params", @@ -2176,38 +2152,6 @@ ] } }, - "/apis/api.console.halo.run/v1alpha1/system/initialize": { - "post": { - "description": "Initialize system", - "operationId": "initialize", - "requestBody": { - "content": { - "*/*": { - "schema": { - "$ref": "#/components/schemas/SystemInitializationRequest" - } - } - } - }, - "responses": { - "201": { - "description": "System initialization successfully.", - "headers": { - "Location": { - "description": "Redirect URL.", - "schema": { - "type": "string" - }, - "style": "simple" - } - } - } - }, - "tags": [ - "SystemV1alpha1Console" - ] - } - }, "/apis/api.console.halo.run/v1alpha1/tags": { "get": { "description": "List Post Tags.", @@ -5486,9 +5430,6 @@ } }, "PostStatus": { - "required": [ - "phase" - ], "type": "object", "properties": { "commentsCount": { @@ -6018,9 +5959,6 @@ } }, "SinglePageStatus": { - "required": [ - "phase" - ], "type": "object", "properties": { "commentsCount": { @@ -6090,29 +6028,6 @@ } } }, - "SystemInitializationRequest": { - "required": [ - "password", - "username" - ], - "type": "object", - "properties": { - "email": { - "type": "string" - }, - "password": { - "minLength": 3, - "type": "string" - }, - "siteTitle": { - "type": "string" - }, - "username": { - "minLength": 1, - "type": "string" - } - } - }, "Tag": { "required": [ "apiVersion", diff --git a/api-docs/openapi/v3_0/apis_extension.api_v1alpha1.json b/api-docs/openapi/v3_0/apis_extension.api_v1alpha1.json index da95940039..90a36eaf33 100644 --- a/api-docs/openapi/v3_0/apis_extension.api_v1alpha1.json +++ b/api-docs/openapi/v3_0/apis_extension.api_v1alpha1.json @@ -11310,9 +11310,6 @@ } }, "PostStatus": { - "required": [ - "phase" - ], "type": "object", "properties": { "commentsCount": { @@ -12497,9 +12494,6 @@ } }, "SinglePageStatus": { - "required": [ - "phase" - ], "type": "object", "properties": { "commentsCount": { @@ -13261,36 +13255,15 @@ }, "UserConnectionSpec": { "required": [ - "accessToken", - "displayName", "providerUserId", "registrationId", "username" ], "type": "object", "properties": { - "accessToken": { - "type": "string" - }, - "avatarUrl": { - "type": "string" - }, - "displayName": { - "type": "string" - }, - "expiresAt": { - "type": "string", - "format": "date-time" - }, - "profileUrl": { - "type": "string" - }, "providerUserId": { "type": "string" }, - "refreshToken": { - "type": "string" - }, "registrationId": { "type": "string" }, diff --git a/api-docs/openapi/v3_0/apis_uc.api_v1alpha1.json b/api-docs/openapi/v3_0/apis_uc.api_v1alpha1.json index 7c8eb55b5b..be52ece490 100644 --- a/api-docs/openapi/v3_0/apis_uc.api_v1alpha1.json +++ b/api-docs/openapi/v3_0/apis_uc.api_v1alpha1.json @@ -17,6 +17,41 @@ } ], "paths": { + "/apis/uc.api.auth.halo.run/v1alpha1/user-connections/{registerId}/disconnect": { + "put": { + "description": "Disconnect my connection from a third-party platform.", + "operationId": "DisconnectMyConnection", + "parameters": [ + { + "description": "The registration ID of the third-party platform.", + "in": "path", + "name": "registerId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserConnection" + } + } + } + }, + "description": "default response" + } + }, + "tags": [ + "UserConnectionV1alpha1Uc" + ] + } + }, "/apis/uc.api.content.halo.run/v1alpha1/attachments": { "post": { "description": "Create attachment for the given post.", @@ -1671,9 +1706,6 @@ } }, "PostStatus": { - "required": [ - "phase" - ], "type": "object", "properties": { "commentsCount": { @@ -2037,6 +2069,52 @@ } } }, + "UserConnection": { + "required": [ + "apiVersion", + "kind", + "metadata", + "spec" + ], + "type": "object", + "properties": { + "apiVersion": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/Metadata" + }, + "spec": { + "$ref": "#/components/schemas/UserConnectionSpec" + } + } + }, + "UserConnectionSpec": { + "required": [ + "providerUserId", + "registrationId", + "username" + ], + "type": "object", + "properties": { + "providerUserId": { + "type": "string" + }, + "registrationId": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "username": { + "type": "string" + } + } + }, "UserDevice": { "required": [ "active", 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 0cc785d71f..8e8096b46b 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 static class PostSpec { @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 728e42a1ba..15dc2c24b0 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.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 RouterFunction endpoint() { .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 @@ private Mono reload(ServerRequest serverRequest) { 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 @@ private Mono install(ServerRequest request) { 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 @@ private Mono upgrade(ServerRequest request) { 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 @@ private Mono installFromFile(FilePart filePart, 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 d6ceb690f1..de6bd4fb07 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 4bb6b21b37..0000000000 --- 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 31af2d0a05..0000000000 --- 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 1403a15ccb..03e8a37e02 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 @@ 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 static void update(@NonNull SystemState systemState, @NonNull ConfigMap c } } + /** + *

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/utils/FileUtils.java b/application/src/main/java/run/halo/app/infra/utils/FileUtils.java index ca790bfde1..ffaac5cbe7 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.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 static Mono deleteFileSilently(Path file, Scheduler scheduler) { .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 08d9e2cc01..190536bebd 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.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.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 static String userAgentFrom(ServerRequest request) { // 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 193318cd68..3e8bb6e601 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 @@ 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 25383e89d4..540dc853b4 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.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.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 @@ void setClock(Clock clock) { } @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 @@ private void satisfiesRequiresVersion(Plugin newPlugin) { } } - 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 0dc3b25e3b..bac91e04bc 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 24dfbf387a..dfb6b4fa12 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.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 @@ 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 08f2e373c2..1b1eda2d7c 100644 --- a/application/src/main/java/run/halo/app/security/InitializeRedirectionWebFilter.java +++ b/application/src/main/java/run/halo/app/security/InitializeRedirectionWebFilter.java @@ -26,7 +26,7 @@ @Component @RequiredArgsConstructor public class InitializeRedirectionWebFilter implements WebFilter { - private final URI location = URI.create("/console"); + private final URI location = URI.create("/system/setup"); private final ServerWebExchangeMatcher redirectMatcher = new PathPatternParserServerWebExchangeMatcher("/", HttpMethod.GET); 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 0000000000..d07b1bc215 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/preauth/SystemSetupEndpoint.java @@ -0,0 +1,266 @@ +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.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.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 = 1, maxLength = 50) + @NotBlank + @Size(min = 1, max = 50) + public String getUsername() { + return formData.getFirst("username"); + } + + @Schema(requiredMode = REQUIRED, minLength = 5, maxLength = 200) + @NotBlank + @Size(min = 5, max = 200) + public String getPassword() { + return formData.getFirst("password"); + } + + @Email + public String getEmail() { + return formData.getFirst("email"); + } + + 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()); + } +} 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 36ac57da3b..9b9487d726 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.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 TemplateEngineManager(ThymeleafProperties thymeleafProperties, 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 007b4d3e58..7a6fa547a7 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 @@ 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 c1abe453de..37a7b142d5 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.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.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.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/extensions/role-template-anonymous.yaml b/application/src/main/resources/extensions/role-template-anonymous.yaml index 8f2ffce10e..0e606e55f4 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 0000000000..0f52aee703 --- /dev/null +++ b/application/src/main/resources/initial-data.yaml @@ -0,0 +1,228 @@ +# 提供了 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: [ ] +--- +apiVersion: content.halo.run/v1alpha1 +kind: Tag +metadata: + name: c33ceabb-d8f1-4711-8991-bb8f5c92ad7c +spec: + displayName: Halo + slug: halo + color: "#ffffff" + cover: "" + +--- +# 文章关联的内容 +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 + template: "" + cover: "" + owner: "${username}" + deleted: false + publish: true + baseSnapshot: fb5cd6bd-998d-4ccc-984d-5cc23b0a09f9 + headSnapshot: fb5cd6bd-998d-4ccc-984d-5cc23b0a09f9 + releaseSnapshot: fb5cd6bd-998d-4ccc-984d-5cc23b0a09f9 + 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: [ ] + +--- +# 自定义页面关联的内容 +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: [ ] +--- +# 首页菜单项 +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/setup.html b/application/src/main/resources/templates/setup.html new file mode 100644 index 0000000000..a8cec96a25 --- /dev/null +++ b/application/src/main/resources/templates/setup.html @@ -0,0 +1,94 @@ + + + + + + +
+
+ +
+

+
+ + +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+ +
+
+
+ diff --git a/application/src/main/resources/templates/setup.properties b/application/src/main/resources/templates/setup.properties new file mode 100644 index 0000000000..139b4c5d1d --- /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 0000000000..f65c17eee1 --- /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 0000000000..f4041620a8 --- /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 0000000000..1f2e3a147b --- /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 cd2238e863..0000000000 --- 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 bb38bd0fd9..94734dea6b 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 9d7a1d81bd..332303a612 100644 --- a/application/src/test/java/run/halo/app/security/InitializeRedirectionWebFilterTest.java +++ b/application/src/test/java/run/halo/app/security/InitializeRedirectionWebFilterTest.java @@ -63,7 +63,7 @@ void shouldRedirectWhenSystemNotInitialized() { .expectComplete() .verify(); - verify(serverRedirectStrategy).sendRedirect(eq(exchange), eq(URI.create("/console"))); + verify(serverRedirectStrategy).sendRedirect(eq(exchange), eq(URI.create("/system/setup"))); verify(chain, never()).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 0000000000..178d636723 --- /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