refactor: improve the system initialization process (#4306)

* refactor: improve the system initialization process

* Sync api-client

Signed-off-by: Ryan Wang <i@ryanc.cc>

* feat: add initialized state to global info

* Refine setup page ui

Signed-off-by: Ryan Wang <i@ryanc.cc>

* refactor: improve the system initialization process

* Refine setup page ui

Signed-off-by: Ryan Wang <i@ryanc.cc>

* Refine setup page ui

Signed-off-by: Ryan Wang <i@ryanc.cc>

* fix: update with initialize state

* Refactor setup

Signed-off-by: Ryan Wang <i@ryanc.cc>

* refactor: initialization state

* Refactor router guards

Signed-off-by: Ryan Wang <i@ryanc.cc>

* Refine i18n

Signed-off-by: Ryan Wang <i@ryanc.cc>

* Refactor init data

Signed-off-by: Ryan Wang <i@ryanc.cc>

* Refactor init data

Signed-off-by: Ryan Wang <i@ryanc.cc>

* Update console/src/views/system/Setup.vue

Co-authored-by: Takagi <mail@e.lixingyong.com>

* refactor: initialization interface

---------

Signed-off-by: Ryan Wang <i@ryanc.cc>
Co-authored-by: Ryan Wang <i@ryanc.cc>
Co-authored-by: Takagi <mail@e.lixingyong.com>
pull/4411/head
guqing 2023-08-11 09:10:35 +08:00 committed by GitHub
parent 1172f4a98c
commit 5690de3f24
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 1270 additions and 519 deletions

View File

@ -22,6 +22,10 @@ public class JsonUtils {
private JsonUtils() {
}
public static ObjectMapper mapper() {
return DEFAULT_JSON_MAPPER;
}
/**
* Converts a map to the object specified type.
*

View File

@ -14,6 +14,7 @@ import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.boot.actuate.endpoint.web.annotation.WebEndpoint;
import org.springframework.stereotype.Component;
import run.halo.app.extension.ConfigMap;
import run.halo.app.infra.InitializationStateGetter;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;
import run.halo.app.infra.SystemSetting.Basic;
@ -33,6 +34,8 @@ public class GlobalInfoEndpoint {
private final AuthProviderService authProviderService;
private final InitializationStateGetter initializationStateGetter;
@ReadOperation
public GlobalInfo globalInfo() {
final var info = new GlobalInfo();
@ -40,6 +43,10 @@ public class GlobalInfoEndpoint {
info.setUseAbsolutePermalink(haloProperties.isUseAbsolutePermalink());
info.setLocale(Locale.getDefault());
info.setTimeZone(TimeZone.getDefault());
info.setUserInitialized(initializationStateGetter.userInitialized()
.blockOptional().orElse(false));
info.setDataInitialized(initializationStateGetter.dataInitialized()
.blockOptional().orElse(false));
handleSocialAuthProvider(info);
systemConfigFetcher.ifAvailable(fetcher -> fetcher.getConfigMapBlocking()
.ifPresent(configMap -> {
@ -70,6 +77,10 @@ public class GlobalInfoEndpoint {
private String favicon;
private boolean userInitialized;
private boolean dataInitialized;
private List<SocialAuthProvider> socialAuthProviders;
}

View File

@ -6,7 +6,6 @@ import static org.springframework.security.web.server.util.matcher.ServerWebExch
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
@ -27,13 +26,11 @@ import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import run.halo.app.core.extension.service.RoleService;
import run.halo.app.core.extension.service.UserService;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.AnonymousUserConst;
import run.halo.app.infra.properties.HaloProperties;
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
import run.halo.app.security.DefaultUserDetailService;
import run.halo.app.security.DynamicMatcherSecurityWebFilterChain;
import run.halo.app.security.SuperAdminInitializer;
import run.halo.app.security.authentication.SecurityConfigurer;
import run.halo.app.security.authentication.login.CryptoService;
import run.halo.app.security.authentication.login.PublicKeyRouteBuilder;
@ -127,16 +124,6 @@ public class WebServerSecurityConfig {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean
@ConditionalOnProperty(name = "halo.security.initializer.disabled",
havingValue = "false",
matchIfMissing = true)
SuperAdminInitializer superAdminInitializer(ReactiveExtensionClient client,
HaloProperties halo) {
return new SuperAdminInitializer(client, passwordEncoder(),
halo.getSecurity().getInitializer());
}
@Bean
RouterFunction<ServerResponse> publicKeyRoute(CryptoService cryptoService) {
return new PublicKeyRouteBuilder(cryptoService).build();

View File

@ -0,0 +1,136 @@
package run.halo.app.core.extension.endpoint;
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 io.swagger.v3.oas.annotations.media.Schema;
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.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.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<ServerResponse> endpoint() {
var tag = "api.console.halo.run/v1alpha1/System";
// 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().implementation(Boolean.class))
)
.build();
}
private Mono<ServerResponse> 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.ok().bodyValue(true));
}
private Mono<Void> initializeSystem(SystemInitializationRequest requestBody) {
Mono<Void> initializeAdminUser = superAdminInitializer.initialize(
SuperAdminInitializer.InitializationParam.builder()
.username(requestBody.getUsername())
.password(requestBody.getPassword())
.email(requestBody.getEmail())
.build());
Mono<Void> siteSetting =
Mono.defer(() -> client.get(ConfigMap.class, SystemSetting.SYSTEM_CONFIG)
.flatMap(config -> {
Map<String, String> 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;
}
}

View File

@ -0,0 +1,65 @@
package run.halo.app.infra;
import static org.apache.commons.lang3.BooleanUtils.isTrue;
import java.util.concurrent.atomic.AtomicBoolean;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.User;
import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.MetadataUtil;
import run.halo.app.extension.ReactiveExtensionClient;
/**
* <p>A cache that caches system setup state.</p>
* when setUp state changed, the cache will be updated.
*
* @author guqing
* @since 2.5.2
*/
@Component
@RequiredArgsConstructor
public class DefaultInitializationStateGetter implements InitializationStateGetter {
private final ReactiveExtensionClient client;
private final AtomicBoolean userInitialized = new AtomicBoolean(false);
private final AtomicBoolean dataInitialized = new AtomicBoolean(false);
@Override
public Mono<Boolean> userInitialized() {
// If user is initialized, return true directly.
if (userInitialized.get()) {
return Mono.just(true);
}
return hasUser()
.doOnNext(userInitialized::set);
}
@Override
public Mono<Boolean> dataInitialized() {
if (dataInitialized.get()) {
return Mono.just(true);
}
return client.fetch(ConfigMap.class, SystemState.SYSTEM_STATES_CONFIGMAP)
.map(config -> {
SystemState systemState = SystemState.deserialize(config);
return isTrue(systemState.getIsSetup());
})
.defaultIfEmpty(false)
.doOnNext(dataInitialized::set);
}
private Mono<Boolean> hasUser() {
return client.list(User.class,
user -> {
var labels = MetadataUtil.nullSafeLabels(user);
return isNotTrue(labels.get("halo.run/hidden-user"));
}, null, 1, 10)
.map(result -> result.getTotal() > 0)
.defaultIfEmpty(false);
}
static boolean isNotTrue(String value) {
return !Boolean.parseBoolean(value);
}
}

View File

@ -0,0 +1,26 @@
package run.halo.app.infra;
import reactor.core.publisher.Mono;
/**
* <p>A interface that get system initialization state.</p>
*
* @author guqing
* @since 2.9.0
*/
public interface InitializationStateGetter {
/**
* Check if system user is initialized.
*
* @return <code>true</code> if system user is initialized, <code>false</code> otherwise.
*/
Mono<Boolean> userInitialized();
/**
* Check if system basic data is initialized.
*
* @return <code>true</code> if system basic data is initialized, <code>false</code> otherwise.
*/
Mono<Boolean> dataInitialized();
}

View File

@ -1,96 +0,0 @@
package run.halo.app.infra;
import io.micrometer.common.util.StringUtils;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Supplier;
import lombok.Data;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.infra.utils.JsonUtils;
/**
* <p>A cache that caches system setup state.</p>
* when setUp state changed, the cache will be updated.
*
* @author guqing
* @since 2.5.2
*/
@Component
public class SetupStateCache implements Reconciler<Reconciler.Request>, Supplier<Boolean> {
public static final String SYSTEM_STATES_CONFIGMAP = "system-states";
private final ExtensionClient client;
private final InternalValueCache valueCache = new InternalValueCache();
public SetupStateCache(ExtensionClient client) {
this.client = client;
}
/**
* <p>Gets system setup state.</p>
* Never return null.
*
* @return <code>true</code> if system is initialized, <code>false</code> otherwise.
*/
@NonNull
@Override
public Boolean get() {
return valueCache.get();
}
@Override
public Result reconcile(Request request) {
if (!SYSTEM_STATES_CONFIGMAP.equals(request.name())) {
return Result.doNotRetry();
}
valueCache.cache(isInitialized());
return Result.doNotRetry();
}
@Override
public Controller setupWith(ControllerBuilder builder) {
return builder
.extension(new ConfigMap())
.build();
}
/**
* Check if system is initialized.
*
* @return <code>true</code> if system is initialized, <code>false</code> otherwise.
*/
private boolean isInitialized() {
return client.fetch(ConfigMap.class, SYSTEM_STATES_CONFIGMAP)
.filter(configMap -> configMap.getData() != null)
.map(ConfigMap::getData)
.flatMap(map -> Optional.ofNullable(map.get(SystemStates.GROUP))
.filter(StringUtils::isNotBlank)
.map(value -> JsonUtils.jsonToObject(value, SystemStates.class).getIsSetup())
)
.orElse(false);
}
@Data
static class SystemStates {
static final String GROUP = "states";
Boolean isSetup;
}
static class InternalValueCache {
private final AtomicBoolean value = new AtomicBoolean(false);
public boolean cache(boolean newValue) {
return value.getAndSet(newValue);
}
public boolean get() {
return value.get();
}
}
}

View File

@ -0,0 +1,74 @@
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.util.LinkedHashMap;
import java.util.Map;
import lombok.Data;
import org.springframework.lang.NonNull;
import run.halo.app.extension.ConfigMap;
import run.halo.app.infra.utils.JsonParseException;
import run.halo.app.infra.utils.JsonUtils;
/**
* A model for system state deserialize from {@link run.halo.app.extension.ConfigMap}
* named {@code system-states}.
*
* @author guqing
* @since 2.8.0
*/
@Data
public class SystemState {
public static final String SYSTEM_STATES_CONFIGMAP = "system-states";
static final String GROUP = "states";
private Boolean isSetup;
/**
* Deserialize from {@link ConfigMap}.
*
* @return config map
*/
public static SystemState deserialize(@NonNull ConfigMap configMap) {
Map<String, String> data = configMap.getData();
if (data == null) {
return new SystemState();
}
return JsonUtils.jsonToObject(data.getOrDefault(GROUP, emptyJsonObject()),
SystemState.class);
}
/**
* Update modified system state to config map.
*
* @param systemState modified system state
* @param configMap config map
*/
public static void update(@NonNull SystemState systemState, @NonNull ConfigMap configMap) {
Map<String, String> data = configMap.getData();
if (data == null) {
data = new LinkedHashMap<>();
configMap.setData(data);
}
JsonNode modifiedJson = JsonUtils.mapper()
.convertValue(systemState, JsonNode.class);
// original
JsonNode sourceJson =
JsonUtils.jsonToObject(data.getOrDefault(GROUP, emptyJsonObject()), JsonNode.class);
try {
// patch
JsonMergePatch jsonMergePatch = JsonMergePatch.fromJson(modifiedJson);
// apply patch to original
JsonNode patchedNode = jsonMergePatch.apply(sourceJson);
data.put(GROUP, JsonUtils.objectToJson(patchedNode));
} catch (JsonPatchException e) {
throw new JsonParseException(e);
}
}
private static String emptyJsonObject() {
return "{}";
}
}

View File

@ -75,7 +75,5 @@ public class HaloProperties implements Validator {
errors.rejectValue("externalUrl", "external-url.required.when-using-absolute-permalink",
"External URL is required when property `use-absolute-permalink` is set to true.");
}
SecurityProperties.Initializer.validateUsername(props.getSecurity().getInitializer(),
errors);
}
}

View File

@ -3,17 +3,12 @@ package run.halo.app.infra.properties;
import static org.springframework.security.web.server.header.ReferrerPolicyServerHttpHeadersWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN;
import lombok.Data;
import org.springframework.lang.NonNull;
import org.springframework.security.web.server.header.ReferrerPolicyServerHttpHeadersWriter.ReferrerPolicy;
import org.springframework.security.web.server.header.XFrameOptionsServerHttpHeadersWriter.Mode;
import org.springframework.validation.Errors;
import run.halo.app.infra.ValidationUtils;
@Data
public class SecurityProperties {
private final Initializer initializer = new Initializer();
private final FrameOptions frameOptions = new FrameOptions();
private final ReferrerOptions referrerOptions = new ReferrerOptions();
@ -32,24 +27,4 @@ public class SecurityProperties {
private ReferrerPolicy policy = STRICT_ORIGIN_WHEN_CROSS_ORIGIN;
}
@Data
public static class Initializer {
private boolean disabled;
private String superAdminUsername = "admin";
private String superAdminPassword;
static void validateUsername(@NonNull Initializer initializer, @NonNull Errors errors) {
if (initializer.isDisabled() || ValidationUtils.validateName(
initializer.getSuperAdminUsername())) {
return;
}
errors.rejectValue("security.initializer.superAdminUsername",
"initializer.superAdminUsername.invalid",
ValidationUtils.NAME_VALIDATION_MESSAGE);
}
}
}

View File

@ -0,0 +1,83 @@
package run.halo.app.security;
import java.time.Instant;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.RoleBinding;
import run.halo.app.core.extension.RoleBinding.RoleRef;
import run.halo.app.core.extension.RoleBinding.Subject;
import run.halo.app.core.extension.User;
import run.halo.app.core.extension.User.UserSpec;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
@Slf4j
@Component
@RequiredArgsConstructor
public class DefaultSuperAdminInitializer implements SuperAdminInitializer {
private static final String SUPER_ROLE_NAME = "super-role";
private final ReactiveExtensionClient client;
private final PasswordEncoder passwordEncoder;
@Override
public Mono<Void> initialize(InitializationParam param) {
return client.fetch(User.class, param.getUsername())
.switchIfEmpty(Mono.defer(() -> client.create(
createAdmin(param.getUsername(), param.getPassword(), param.getEmail())))
.flatMap(admin -> {
var binding = bindAdminAndSuperRole(admin);
return client.create(binding).thenReturn(admin);
})
)
.then();
}
RoleBinding bindAdminAndSuperRole(User admin) {
String adminUserName = admin.getMetadata().getName();
var metadata = new Metadata();
String name =
String.join("-", adminUserName, SUPER_ROLE_NAME, "binding");
metadata.setName(name);
var roleRef = new RoleRef();
roleRef.setName(SUPER_ROLE_NAME);
roleRef.setApiGroup(Role.GROUP);
roleRef.setKind(Role.KIND);
var subject = new Subject();
subject.setName(adminUserName);
subject.setApiGroup(admin.groupVersionKind().group());
subject.setKind(admin.groupVersionKind().kind());
var roleBinding = new RoleBinding();
roleBinding.setMetadata(metadata);
roleBinding.setRoleRef(roleRef);
roleBinding.setSubjects(List.of(subject));
return roleBinding;
}
User createAdmin(String username, String password, String email) {
var metadata = new Metadata();
metadata.setName(username);
var spec = new UserSpec();
spec.setDisplayName("Administrator");
spec.setDisabled(false);
spec.setRegisteredAt(Instant.now());
spec.setTwoFactorAuthEnabled(false);
spec.setEmail(email);
spec.setPassword(passwordEncoder.encode(password));
var user = new User();
user.setMetadata(metadata);
user.setSpec(spec);
return user;
}
}

View File

@ -1,6 +1,7 @@
package run.halo.app.security;
import java.net.URI;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpMethod;
import org.springframework.lang.NonNull;
@ -14,7 +15,7 @@ import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;
import run.halo.app.infra.SetupStateCache;
import run.halo.app.infra.InitializationStateGetter;
/**
* A web filter that will redirect user to set up page if system is not initialized.
@ -29,8 +30,9 @@ public class InitializeRedirectionWebFilter implements WebFilter {
private final ServerWebExchangeMatcher redirectMatcher =
new PathPatternParserServerWebExchangeMatcher("/", HttpMethod.GET);
private final SetupStateCache setupStateCache;
private final InitializationStateGetter initializationStateGetter;
@Getter
private ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy();
@Override
@ -38,18 +40,21 @@ public class InitializeRedirectionWebFilter implements WebFilter {
public Mono<Void> filter(@NonNull ServerWebExchange exchange, @NonNull WebFilterChain chain) {
return redirectMatcher.matches(exchange)
.flatMap(matched -> {
if (!matched.isMatch() || setupStateCache.get()) {
if (!matched.isMatch()) {
return chain.filter(exchange);
}
// Redirect to set up page if system is not initialized.
return redirectStrategy.sendRedirect(exchange, location);
return initializationStateGetter.userInitialized()
.defaultIfEmpty(false)
.flatMap(initialized -> {
if (initialized) {
return chain.filter(exchange);
}
// Redirect to set up page if system is not initialized.
return redirectStrategy.sendRedirect(exchange, location);
});
});
}
public ServerRedirectStrategy getRedirectStrategy() {
return redirectStrategy;
}
public void setRedirectStrategy(ServerRedirectStrategy redirectStrategy) {
Assert.notNull(redirectStrategy, "redirectStrategy cannot be null");
this.redirectStrategy = redirectStrategy;

View File

@ -1,99 +1,29 @@
package run.halo.app.security;
import java.time.Instant;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.RandomStringUtils;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.util.StringUtils;
import lombok.Builder;
import lombok.Data;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.RoleBinding;
import run.halo.app.core.extension.RoleBinding.RoleRef;
import run.halo.app.core.extension.RoleBinding.Subject;
import run.halo.app.core.extension.User;
import run.halo.app.core.extension.User.UserSpec;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.properties.SecurityProperties.Initializer;
@Slf4j
public class SuperAdminInitializer {
/**
* Super admin initializer.
*
* @author guqing
* @since 2.9.0
*/
public interface SuperAdminInitializer {
private static final String SUPER_ROLE_NAME = "super-role";
/**
* Initialize super admin.
*
* @param param super admin initialization param
*/
Mono<Void> initialize(InitializationParam param);
private final ReactiveExtensionClient client;
private final PasswordEncoder passwordEncoder;
private final Initializer initializer;
public SuperAdminInitializer(ReactiveExtensionClient client, PasswordEncoder passwordEncoder,
Initializer initializer) {
this.client = client;
this.passwordEncoder = passwordEncoder;
this.initializer = initializer;
}
@EventListener
public Mono<Void> initialize(ApplicationReadyEvent readyEvent) {
return client.fetch(User.class, initializer.getSuperAdminUsername())
.switchIfEmpty(Mono.defer(() -> client.create(createAdmin())).flatMap(admin -> {
var binding = bindAdminAndSuperRole(admin);
return client.create(binding).thenReturn(admin);
})).then();
}
RoleBinding bindAdminAndSuperRole(User admin) {
var metadata = new Metadata();
String name =
String.join("-", initializer.getSuperAdminUsername(), SUPER_ROLE_NAME, "binding");
metadata.setName(name);
var roleRef = new RoleRef();
roleRef.setName(SUPER_ROLE_NAME);
roleRef.setApiGroup(Role.GROUP);
roleRef.setKind(Role.KIND);
var subject = new Subject();
subject.setName(admin.getMetadata().getName());
subject.setApiGroup(admin.groupVersionKind().group());
subject.setKind(admin.groupVersionKind().kind());
var roleBinding = new RoleBinding();
roleBinding.setMetadata(metadata);
roleBinding.setRoleRef(roleRef);
roleBinding.setSubjects(List.of(subject));
return roleBinding;
}
User createAdmin() {
var metadata = new Metadata();
metadata.setName(initializer.getSuperAdminUsername());
var spec = new UserSpec();
spec.setDisplayName("Administrator");
spec.setDisabled(false);
spec.setRegisteredAt(Instant.now());
spec.setTwoFactorAuthEnabled(false);
spec.setEmail("admin@halo.run");
spec.setPassword(passwordEncoder.encode(getPassword()));
var user = new User();
user.setMetadata(metadata);
user.setSpec(spec);
return user;
}
private String getPassword() {
var password = this.initializer.getSuperAdminPassword();
if (!StringUtils.hasText(password)) {
// generate password
password = RandomStringUtils.randomAlphanumeric(16);
log.info("=== Generated random password: {} for super administrator: {} ===",
password, this.initializer.getSuperAdminUsername());
}
return password;
@Data
@Builder
class InitializationParam {
private String username;
private String password;
private String email;
}
}

View File

@ -19,10 +19,6 @@ halo:
proxy:
endpoint: http://localhost:3000/
enabled: true
security:
initializer:
super-admin-username: admin
super-admin-password: admin
plugin:
runtime-mode: development # development, deployment
work-dir: ${user.home}/halo2-dev

View File

@ -39,6 +39,8 @@ problemDetail.run.halo.app.infra.exception.DuplicateNameException=Duplicate name
problemDetail.run.halo.app.infra.exception.PluginAlreadyExistsException=Plugin {0} already exists.
problemDetail.run.halo.app.infra.exception.RateLimitExceededException=API rate limit exceeded, please try again later.
problemDetail.user.password.unsatisfied=The password does not meet the specifications.
problemDetail.user.username.unsatisfied=The username does not meet the specifications.
problemDetail.user.signUpFailed.disallowed=System does not allow new users to register.
problemDetail.user.duplicateName=The username {0} already exists, please rename it and retry.
problemDetail.comment.turnedOff=The comment function has been turned off.

View File

@ -16,6 +16,8 @@ problemDetail.run.halo.app.infra.exception.DuplicateNameException=检测到有
problemDetail.run.halo.app.infra.exception.PluginAlreadyExistsException=插件 {0} 已经存。
problemDetail.run.halo.app.infra.exception.RateLimitExceededException=请求过于频繁,请稍候再试。
problemDetail.user.password.unsatisfied=密码不符合规范。
problemDetail.user.username.unsatisfied=用户名不符合规范。
problemDetail.user.signUpFailed.disallowed=系统不允许注册新用户。
problemDetail.user.duplicateName=用户名 {0} 已存在,请更换用户名后重试。
problemDetail.plugin.version.unsatisfied.requires=插件要求一个最小的系统版本为 {0}, 但当前版本为 {1}。

View File

@ -23,6 +23,8 @@ 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"

View File

@ -0,0 +1,39 @@
package run.halo.app.core.extension.endpoint;
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.junit.jupiter.MockitoExtension;
import org.springframework.test.web.reactive.server.WebTestClient;
/**
* Tests for {@link SystemInitializationEndpoint}.
*
* @author guqing
* @since 2.9.0
*/
@ExtendWith(MockitoExtension.class)
class SystemInitializationEndpointTest {
@InjectMocks
SystemInitializationEndpoint initializationEndpoint;
WebTestClient webTestClient;
@BeforeEach
void setUp() {
webTestClient = bindToRouterFunction(initializationEndpoint.endpoint()).build();
}
@Test
void initialize() {
webTestClient.post()
.uri("/system/initialize")
.exchange()
.expectStatus()
.isBadRequest();
}
}

View File

@ -0,0 +1,84 @@
package run.halo.app.infra;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Map;
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 reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import run.halo.app.core.extension.User;
import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
/**
* Tests for {@link InitializationStateGetter}.
*
* @author guqing
* @since 2.9.0
*/
@ExtendWith(MockitoExtension.class)
class InitializationStateGetterTest {
@Mock
private ReactiveExtensionClient client;
@InjectMocks
private DefaultInitializationStateGetter initializationStateGetter;
@Test
void userInitialized() {
when(client.list(eq(User.class), any(), any(), anyInt(), anyInt()))
.thenReturn(Mono.empty());
initializationStateGetter.userInitialized()
.as(StepVerifier::create)
.expectNext(false)
.verifyComplete();
User user = new User();
user.setMetadata(new Metadata());
user.getMetadata().setName("fake-hidden-user");
user.getMetadata().setLabels(Map.of("halo.run/hidden-user", "true"));
user.setSpec(new User.UserSpec());
user.getSpec().setDisplayName("fake-hidden-user");
ListResult<User> listResult = new ListResult<>(List.of(user));
when(client.list(eq(User.class), any(), any(), anyInt(), anyInt()))
.thenReturn(Mono.just(listResult));
initializationStateGetter.userInitialized()
.as(StepVerifier::create)
.expectNext(true)
.verifyComplete();
}
@Test
void dataInitialized() {
ConfigMap configMap = new ConfigMap();
configMap.setMetadata(new Metadata());
configMap.getMetadata().setName(SystemState.SYSTEM_STATES_CONFIGMAP);
configMap.setData(Map.of("states", "{\"isSetup\":true}"));
when(client.fetch(eq(ConfigMap.class), eq(SystemState.SYSTEM_STATES_CONFIGMAP)))
.thenReturn(Mono.just(configMap));
initializationStateGetter.dataInitialized()
.as(StepVerifier::create)
.expectNext(true)
.verifyComplete();
// call again
initializationStateGetter.dataInitialized()
.as(StepVerifier::create)
.expectNext(true)
.verifyComplete();
// execute only once
verify(client).fetch(eq(ConfigMap.class), eq(SystemState.SYSTEM_STATES_CONFIGMAP));
}
}

View File

@ -0,0 +1,51 @@
package run.halo.app.infra;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.LinkedHashMap;
import java.util.Map;
import org.junit.jupiter.api.Test;
import run.halo.app.extension.ConfigMap;
/**
* Tests for {@link SystemState}.
*
* @author guqing
* @since 2.8.0
*/
class SystemStateTest {
@Test
void deserialize() {
ConfigMap configMap = new ConfigMap();
SystemState systemState = SystemState.deserialize(configMap);
assertThat(systemState).isNotNull();
configMap.setData(Map.of(SystemState.GROUP, "{\"isSetup\":true}"));
systemState = SystemState.deserialize(configMap);
assertThat(systemState.getIsSetup()).isTrue();
}
@Test
void update() {
SystemState newSystemState = new SystemState();
newSystemState.setIsSetup(true);
ConfigMap configMap = new ConfigMap();
SystemState.update(newSystemState, configMap);
assertThat(configMap.getData().get(SystemState.GROUP)).isEqualTo("{\"isSetup\":true}");
var data = new LinkedHashMap<String, String>();
configMap.setData(data);
data.put(SystemState.GROUP, "{\"isSetup\":false}");
SystemState.update(newSystemState, configMap);
assertThat(configMap.getData().get(SystemState.GROUP)).isEqualTo("{\"isSetup\":true}");
data.clear();
data.put(SystemState.GROUP, "{\"isSetup\":true, \"foo\":\"bar\"}");
newSystemState.setIsSetup(false);
SystemState.update(newSystemState, configMap);
assertThat(configMap.getData().get(SystemState.GROUP))
.isEqualTo("{\"isSetup\":false,\"foo\":\"bar\"}");
}
}

View File

@ -20,7 +20,7 @@ import org.springframework.security.web.server.ServerRedirectStrategy;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import run.halo.app.infra.SetupStateCache;
import run.halo.app.infra.InitializationStateGetter;
/**
* Tests for {@link InitializeRedirectionWebFilter}.
@ -32,7 +32,7 @@ import run.halo.app.infra.SetupStateCache;
class InitializeRedirectionWebFilterTest {
@Mock
private SetupStateCache setupStateCache;
private InitializationStateGetter initializationStateGetter;
@Mock
private ServerRedirectStrategy serverRedirectStrategy;
@ -47,7 +47,7 @@ class InitializeRedirectionWebFilterTest {
@Test
void shouldRedirectWhenSystemNotInitialized() {
when(setupStateCache.get()).thenReturn(false);
when(initializationStateGetter.userInitialized()).thenReturn(Mono.just(false));
WebFilterChain chain = mock(WebFilterChain.class);
@ -69,7 +69,7 @@ class InitializeRedirectionWebFilterTest {
@Test
void shouldNotRedirectWhenSystemInitialized() {
when(setupStateCache.get()).thenReturn(true);
when(initializationStateGetter.userInitialized()).thenReturn(Mono.just(true));
WebFilterChain chain = mock(WebFilterChain.class);

View File

@ -24,7 +24,7 @@ import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import run.halo.app.infra.SetupStateCache;
import run.halo.app.infra.InitializationStateGetter;
import run.halo.app.theme.ThemeContext;
import run.halo.app.theme.ThemeResolver;
@ -46,14 +46,14 @@ public class ThemeMessageResolverIntegrationTest {
private URL otherThemeUrl;
@SpyBean
private SetupStateCache setupStateCache;
private InitializationStateGetter initializationStateGetter;
@Autowired
private WebTestClient webTestClient;
@BeforeEach
void setUp() throws FileNotFoundException, URISyntaxException {
when(setupStateCache.get()).thenReturn(true);
when(initializationStateGetter.userInitialized()).thenReturn(Mono.just(true));
defaultThemeUrl = ResourceUtils.getURL("classpath:themes/default");
otherThemeUrl = ResourceUtils.getURL("classpath:themes/other");

View File

@ -10,6 +10,7 @@ api/api-console-halo-run-v1alpha1-post-api.ts
api/api-console-halo-run-v1alpha1-reply-api.ts
api/api-console-halo-run-v1alpha1-single-page-api.ts
api/api-console-halo-run-v1alpha1-stats-api.ts
api/api-console-halo-run-v1alpha1-system-api.ts
api/api-console-halo-run-v1alpha1-theme-api.ts
api/api-console-halo-run-v1alpha1-user-api.ts
api/api-console-migration-halo-run-v1alpha1-migration-api.ts
@ -219,6 +220,7 @@ models/snapshot.ts
models/stats-vo.ts
models/stats.ts
models/subject.ts
models/system-initialization-request.ts
models/tag-list.ts
models/tag-spec.ts
models/tag-status.ts

View File

@ -21,6 +21,7 @@ export * from "./api/api-console-halo-run-v1alpha1-post-api";
export * from "./api/api-console-halo-run-v1alpha1-reply-api";
export * from "./api/api-console-halo-run-v1alpha1-single-page-api";
export * from "./api/api-console-halo-run-v1alpha1-stats-api";
export * from "./api/api-console-halo-run-v1alpha1-system-api";
export * from "./api/api-console-halo-run-v1alpha1-theme-api";
export * from "./api/api-console-halo-run-v1alpha1-user-api";
export * from "./api/api-console-migration-halo-run-v1alpha1-migration-api";

View File

@ -0,0 +1,208 @@
/* tslint:disable */
/* eslint-disable */
/**
* Halo Next API
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 2.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import type { Configuration } from "../configuration";
import type { AxiosPromise, AxiosInstance, AxiosRequestConfig } from "axios";
import globalAxios from "axios";
// Some imports not used depending on template conditions
// @ts-ignore
import {
DUMMY_BASE_URL,
assertParamExists,
setApiKeyToObject,
setBasicAuthToObject,
setBearerAuthToObject,
setOAuthToObject,
setSearchParams,
serializeDataIfNeeded,
toPathString,
createRequestFunction,
} from "../common";
// @ts-ignore
import {
BASE_PATH,
COLLECTION_FORMATS,
RequestArgs,
BaseAPI,
RequiredError,
} from "../base";
// @ts-ignore
import { SystemInitializationRequest } from "../models";
/**
* ApiConsoleHaloRunV1alpha1SystemApi - axios parameter creator
* @export
*/
export const ApiConsoleHaloRunV1alpha1SystemApiAxiosParamCreator = function (
configuration?: Configuration
) {
return {
/**
* Initialize system
* @param {SystemInitializationRequest} [systemInitializationRequest]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
initialize: async (
systemInitializationRequest?: SystemInitializationRequest,
options: AxiosRequestConfig = {}
): Promise<RequestArgs> => {
const localVarPath = `/apis/api.console.halo.run/v1alpha1/system/initialize`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = {
method: "POST",
...baseOptions,
...options,
};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication BasicAuth required
// http basic authentication required
setBasicAuthToObject(localVarRequestOptions, configuration);
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration);
localVarHeaderParameter["Content-Type"] = "application/json";
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions =
baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {
...localVarHeaderParameter,
...headersFromBaseOptions,
...options.headers,
};
localVarRequestOptions.data = serializeDataIfNeeded(
systemInitializationRequest,
localVarRequestOptions,
configuration
);
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
};
};
/**
* ApiConsoleHaloRunV1alpha1SystemApi - functional programming interface
* @export
*/
export const ApiConsoleHaloRunV1alpha1SystemApiFp = function (
configuration?: Configuration
) {
const localVarAxiosParamCreator =
ApiConsoleHaloRunV1alpha1SystemApiAxiosParamCreator(configuration);
return {
/**
* Initialize system
* @param {SystemInitializationRequest} [systemInitializationRequest]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async initialize(
systemInitializationRequest?: SystemInitializationRequest,
options?: AxiosRequestConfig
): Promise<
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<boolean>
> {
const localVarAxiosArgs = await localVarAxiosParamCreator.initialize(
systemInitializationRequest,
options
);
return createRequestFunction(
localVarAxiosArgs,
globalAxios,
BASE_PATH,
configuration
);
},
};
};
/**
* ApiConsoleHaloRunV1alpha1SystemApi - factory interface
* @export
*/
export const ApiConsoleHaloRunV1alpha1SystemApiFactory = function (
configuration?: Configuration,
basePath?: string,
axios?: AxiosInstance
) {
const localVarFp = ApiConsoleHaloRunV1alpha1SystemApiFp(configuration);
return {
/**
* Initialize system
* @param {ApiConsoleHaloRunV1alpha1SystemApiInitializeRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
initialize(
requestParameters: ApiConsoleHaloRunV1alpha1SystemApiInitializeRequest = {},
options?: AxiosRequestConfig
): AxiosPromise<boolean> {
return localVarFp
.initialize(requestParameters.systemInitializationRequest, options)
.then((request) => request(axios, basePath));
},
};
};
/**
* Request parameters for initialize operation in ApiConsoleHaloRunV1alpha1SystemApi.
* @export
* @interface ApiConsoleHaloRunV1alpha1SystemApiInitializeRequest
*/
export interface ApiConsoleHaloRunV1alpha1SystemApiInitializeRequest {
/**
*
* @type {SystemInitializationRequest}
* @memberof ApiConsoleHaloRunV1alpha1SystemApiInitialize
*/
readonly systemInitializationRequest?: SystemInitializationRequest;
}
/**
* ApiConsoleHaloRunV1alpha1SystemApi - object-oriented interface
* @export
* @class ApiConsoleHaloRunV1alpha1SystemApi
* @extends {BaseAPI}
*/
export class ApiConsoleHaloRunV1alpha1SystemApi extends BaseAPI {
/**
* Initialize system
* @param {ApiConsoleHaloRunV1alpha1SystemApiInitializeRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ApiConsoleHaloRunV1alpha1SystemApi
*/
public initialize(
requestParameters: ApiConsoleHaloRunV1alpha1SystemApiInitializeRequest = {},
options?: AxiosRequestConfig
) {
return ApiConsoleHaloRunV1alpha1SystemApiFp(this.configuration)
.initialize(requestParameters.systemInitializationRequest, options)
.then((request) => request(this.axios, this.basePath));
}
}

View File

@ -154,6 +154,7 @@ export * from "./snapshot-list";
export * from "./stats";
export * from "./stats-vo";
export * from "./subject";
export * from "./system-initialization-request";
export * from "./tag";
export * from "./tag-list";
export * from "./tag-spec";

View File

@ -0,0 +1,45 @@
/* tslint:disable */
/* eslint-disable */
/**
* Halo Next API
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 2.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
*
* @export
* @interface SystemInitializationRequest
*/
export interface SystemInitializationRequest {
/**
*
* @type {string}
* @memberof SystemInitializationRequest
*/
email?: string;
/**
*
* @type {string}
* @memberof SystemInitializationRequest
*/
password: string;
/**
*
* @type {string}
* @memberof SystemInitializationRequest
*/
siteTitle?: string;
/**
*
* @type {string}
* @memberof SystemInitializationRequest
*/
username: string;
}

View File

@ -1,11 +1,10 @@
<script lang="ts" setup>
import { RouterView, useRoute } from "vue-router";
import { computed, watch, ref, reactive, onMounted, inject } from "vue";
import { computed, watch, reactive, onMounted, inject } from "vue";
import { useTitle } from "@vueuse/core";
import { useFavicon } from "@vueuse/core";
import { useSystemConfigMapStore } from "./stores/system-configmap";
import { storeToRefs } from "pinia";
import axios from "axios";
import { useI18n } from "vue-i18n";
import {
useOverlayScrollbars,
@ -14,10 +13,12 @@ import {
import type { FormKitConfig } from "@formkit/core";
import { i18n } from "./locales";
import { AppName } from "./constants/app";
import { useGlobalInfoStore } from "./stores/global-info";
const { t } = useI18n();
const { configMap } = storeToRefs(useSystemConfigMapStore());
const globalInfoStore = useGlobalInfoStore();
const route = useRoute();
const title = useTitle();
@ -36,19 +37,6 @@ watch(
// Favicon
const defaultFavicon = "/console/favicon.ico";
const globalInfoFavicon = ref("");
(async () => {
const { data } = await axios.get(
`${import.meta.env.VITE_API_URL}/actuator/globalinfo`,
{
withCredentials: true,
}
);
if (data?.favicon) {
globalInfoFavicon.value = data.favicon;
}
})();
const favicon = computed(() => {
if (configMap?.value?.data?.["basic"]) {
@ -59,8 +47,8 @@ const favicon = computed(() => {
}
}
if (globalInfoFavicon.value) {
return globalInfoFavicon.value;
if (globalInfoStore.globalInfo?.favicon) {
return globalInfoStore.globalInfo.favicon;
}
return defaultFavicon;

View File

@ -0,0 +1,42 @@
<script lang="ts" setup>
import { useLocalStorage } from "@vueuse/core";
import { locales, getBrowserLanguage, i18n } from "@/locales";
import { watch } from "vue";
import MdiTranslate from "~icons/mdi/translate";
// setup locale
const currentLocale = useLocalStorage(
"locale",
getBrowserLanguage() || locales[0].code
);
watch(
() => currentLocale.value,
(value) => {
i18n.global.locale.value = value;
},
{
immediate: true,
}
);
</script>
<template>
<label
for="locale"
class="block flex-shrink-0 text-sm font-medium text-gray-600"
>
<MdiTranslate />
</label>
<select
id="locale"
v-model="currentLocale"
class="block appearance-none rounded-md border-0 py-1.5 pl-3 pr-10 text-sm text-gray-800 outline-none ring-1 ring-inset ring-gray-200 focus:ring-primary"
>
<template v-for="locale in locales">
<option v-if="locale.name" :key="locale.code" :value="locale.code">
{{ locale.name }}
</option>
</template>
</select>
</template>

View File

@ -1,5 +1,5 @@
<script lang="ts" setup>
import type { SocialAuthProvider } from "@/modules/system/actuator/types";
import type { SocialAuthProvider } from "@/types";
import { useRouteQuery } from "@vueuse/router";
import { inject, ref } from "vue";
import type { Ref } from "vue";

View File

@ -1,4 +1,4 @@
import type { GlobalInfo } from "@/modules/system/actuator/types";
import type { GlobalInfo } from "@/types";
import { useQuery } from "@tanstack/vue-query";
import axios from "axios";

View File

@ -1034,10 +1034,19 @@ core:
submit:
button: Setup
toast_success: Setup successfully
setup_initial_data:
loading: Initializing data, please wait...
fields:
site_title:
placeholder: Site title
validation: Please enter the site title
label: Site title
email:
label: Email
username:
label: Username
password:
label: Password
confirm_password:
label: Confirm password
rbac:
Attachments Management: Attachments
Attachment Manage: Attachment Manage

View File

@ -1034,10 +1034,19 @@ core:
submit:
button: 初始化
toast_success: 初始化成功
setup_initial_data:
loading: 正在初始化数据,请稍后...
fields:
site_title:
placeholder: 站点名称
validation: 请输入站点名称
label: 站点名称
email:
label: 邮箱
username:
label: 用户名
password:
label: 密码
confirm_password:
label: 确认密码
rbac:
Attachments Management: 附件
Attachment Manage: 附件管理

View File

@ -1034,10 +1034,19 @@ core:
submit:
button: 初始化
toast_success: 初始化成功
setup_initial_data:
loading: 正在初始化資料,請稍後...
fields:
site_title:
placeholder: 站點名稱
validation: 請輸入站點名稱
label: 站點名稱
email:
label: 電子郵箱
username:
label: 用戶名
password:
label: 密碼
confirm_password:
label: 確認密碼
rbac:
Attachments Management: 附件
Attachment Manage: 附件管理

View File

@ -18,10 +18,10 @@ import { hasPermission } from "@/utils/permission";
import { useRoleStore } from "@/stores/role";
import type { RouteRecordRaw } from "vue-router";
import { useThemeStore } from "./stores/theme";
import { useSystemStatesStore } from "./stores/system-states";
import { useUserStore } from "./stores/user";
import { useSystemConfigMapStore } from "./stores/system-configmap";
import { setupVueQuery } from "./setup/setupVueQuery";
import { useGlobalInfoStore } from "./stores/global-info";
const app = createApp(App);
@ -248,6 +248,9 @@ async function initApp() {
i18n.global.locale.value =
localStorage.getItem("locale") || getBrowserLanguage();
const globalInfoStore = useGlobalInfoStore();
await globalInfoStore.fetchGlobalInfo();
if (userStore.isAnonymous) {
return;
}
@ -260,15 +263,11 @@ async function initApp() {
console.error("Failed to load plugins", e);
}
// load system setup state
const systemStateStore = useSystemStatesStore();
await systemStateStore.fetchSystemStates();
// load system configMap
const systemConfigMapStore = useSystemConfigMapStore();
await systemConfigMapStore.fetchSystemConfigMap();
if (systemStateStore.states.isSetup) {
if (globalInfoStore.globalInfo?.userInitialized) {
await loadActivatedTheme();
}
} catch (e) {

View File

@ -11,7 +11,7 @@ import {
VDescriptionItem,
} from "@halo-dev/components";
import { computed, onMounted, ref } from "vue";
import type { Info, GlobalInfo, Startup } from "./types";
import type { Info, GlobalInfo, Startup } from "@/types";
import axios from "axios";
import { formatDatetime } from "@/utils/date";
import { useClipboard } from "@vueuse/core";

View File

@ -9,10 +9,8 @@ import { useGlobalInfoFetch } from "@/composables/use-global-info";
import { useTitle } from "@vueuse/core";
import { useI18n } from "vue-i18n";
import { AppName } from "@/constants/app";
import { locales, getBrowserLanguage, i18n } from "@/locales";
import MdiTranslate from "~icons/mdi/translate";
import { useLocalStorage } from "@vueuse/core";
import MdiKeyboardBackspace from "~icons/mdi/keyboard-backspace";
import LocaleChange from "@/components/common/LocaleChange.vue";
const { globalInfo } = useGlobalInfoFetch();
const { t } = useI18n();
@ -42,22 +40,6 @@ watch(
title.value = [routeTitle, AppName].join(" - ");
}
);
// setup locale
const currentLocale = useLocalStorage(
"locale",
getBrowserLanguage() || locales[0].code
);
watch(
() => currentLocale.value,
(value) => {
i18n.global.locale.value = value;
},
{
immediate: true,
}
);
</script>
<template>
<div class="flex h-screen flex-col items-center bg-white/90 pt-[30vh]">
@ -101,23 +83,7 @@ watch(
<div
class="bottom-0 mb-10 mt-auto flex items-center justify-center gap-2.5"
>
<label
for="locale"
class="block flex-shrink-0 text-sm font-medium text-gray-600"
>
<MdiTranslate />
</label>
<select
id="locale"
v-model="currentLocale"
class="block appearance-none rounded-md border-0 py-1.5 pl-3 pr-10 text-sm text-gray-800 outline-none ring-1 ring-inset ring-gray-200 focus:ring-primary"
>
<template v-for="locale in locales">
<option v-if="locale.name" :key="locale.code" :value="locale.code">
{{ locale.name }}
</option>
</template>
</select>
<LocaleChange />
</div>
</div>
</template>

View File

@ -1,22 +1,37 @@
import { useSystemStatesStore } from "@/stores/system-states";
import { useGlobalInfoStore } from "@/stores/global-info";
import { useUserStore } from "@/stores/user";
import type { Router } from "vue-router";
const whiteList = ["Setup", "Login", "Binding"];
export function setupCheckStatesGuard(router: Router) {
router.beforeEach(async (to, from, next) => {
if (whiteList.includes(to.name as string)) {
next();
const userStore = useUserStore();
const { globalInfo } = useGlobalInfoStore();
const { userInitialized, dataInitialized } = globalInfo || {};
if (to.name === "Setup" && userInitialized) {
next({ name: "Dashboard" });
return;
}
const systemStateStore = useSystemStatesStore();
if (to.name === "SetupInitialData" && dataInitialized) {
next({ name: "Dashboard" });
return;
}
if (!systemStateStore.states.isSetup) {
if (userInitialized === false && to.name !== "Setup") {
next({ name: "Setup" });
return;
}
if (
dataInitialized === false &&
!userStore.isAnonymous &&
to.name !== "SetupInitialData"
) {
next({ name: "SetupInitialData" });
return;
}
next();
});
}

View File

@ -22,8 +22,8 @@ const router = createRouter({
},
});
setupCheckStatesGuard(router);
setupAuthCheckGuard(router);
setupPermissionGuard(router);
setupCheckStatesGuard(router);
export default router;

View File

@ -5,6 +5,7 @@ import BasicLayout from "@/layouts/BasicLayout.vue";
import Setup from "@/views/system/Setup.vue";
import Redirect from "@/views/system/Redirect.vue";
import type { MenuGroupType } from "@halo-dev/console-shared";
import SetupInitialData from "@/views/system/SetupInitialData.vue";
export const routes: Array<RouteRecordRaw> = [
{
@ -31,6 +32,14 @@ export const routes: Array<RouteRecordRaw> = [
title: "core.setup.title",
},
},
{
path: "/setup-initial-data",
name: "SetupInitialData",
component: SetupInitialData,
meta: {
title: "core.setup.title",
},
},
{
path: "/redirect",
name: "Redirect",

View File

@ -0,0 +1,20 @@
import { defineStore } from "pinia";
import type { GlobalInfo } from "@/types";
import { ref } from "vue";
import axios from "axios";
export const useGlobalInfoStore = defineStore("global-info", () => {
const globalInfo = ref<GlobalInfo>();
async function fetchGlobalInfo() {
const { data } = await axios.get<GlobalInfo>(
`${import.meta.env.VITE_API_URL}/actuator/globalinfo`,
{
withCredentials: true,
}
);
globalInfo.value = data;
}
return { globalInfo, fetchGlobalInfo };
});

View File

@ -1,40 +0,0 @@
import { defineStore } from "pinia";
import { apiClient } from "@/utils/api-client";
interface SystemState {
isSetup: boolean;
}
interface SystemStatesState {
states: SystemState;
}
export const useSystemStatesStore = defineStore({
id: "system-states",
state: (): SystemStatesState => ({
states: {
isSetup: false,
},
}),
actions: {
async fetchSystemStates() {
try {
const { data } =
await apiClient.extension.configMap.getv1alpha1ConfigMap(
{
name: "system-states",
},
{ mute: true }
);
if (data.data) {
this.states = JSON.parse(data.data["states"]);
return;
}
this.states.isSetup = false;
} catch (error) {
this.states.isSetup = false;
}
},
},
});

View File

@ -7,6 +7,9 @@ export interface GlobalInfo {
allowRegistration: boolean;
socialAuthProviders: SocialAuthProvider[];
useAbsolutePermalink: boolean;
userInitialized: boolean;
dataInitialized: boolean;
favicon?: string;
}
export interface Info {

View File

@ -0,0 +1 @@
export * from "./actuator";

View File

@ -10,6 +10,7 @@ import {
ApiConsoleHaloRunV1alpha1AttachmentApi,
ApiConsoleHaloRunV1alpha1IndicesApi,
ApiConsoleHaloRunV1alpha1AuthProviderApi,
ApiConsoleHaloRunV1alpha1SystemApi,
ContentHaloRunV1alpha1CategoryApi,
ContentHaloRunV1alpha1CommentApi,
ContentHaloRunV1alpha1PostApi,
@ -218,6 +219,7 @@ function setupApiClient(axios: AxiosInstance) {
baseURL,
axios
),
system: new ApiConsoleHaloRunV1alpha1SystemApi(undefined, baseURL, axios),
};
}

View File

@ -1,201 +1,119 @@
<script lang="ts" setup>
import IconLogo from "~icons/core/logo?width=5rem&height=2rem";
import { useSettingForm } from "@/composables/use-setting-form";
import { useSystemStatesStore } from "@/stores/system-states";
import { apiClient } from "@/utils/api-client";
import { Toast, VButton } from "@halo-dev/components";
import { onMounted, ref } from "vue";
import { ref } from "vue";
import { useRouter } from "vue-router";
import category from "./setup-data/category.json";
import tag from "./setup-data/tag.json";
import post from "./setup-data/post.json";
import singlePage from "./setup-data/singlePage.json";
import menu from "./setup-data/menu.json";
import menuItems from "./setup-data/menu-items.json";
import type {
Category,
Plugin,
PostRequest,
SinglePageRequest,
Tag,
} from "@halo-dev/api-client";
import { useThemeStore } from "@/stores/theme";
import { useMutation } from "@tanstack/vue-query";
import type { SystemInitializationRequest } from "@halo-dev/api-client";
import { useI18n } from "vue-i18n";
import { useGlobalInfoStore } from "@/stores/global-info";
import LocaleChange from "@/components/common/LocaleChange.vue";
const router = useRouter();
const { t } = useI18n();
const {
configMapFormData,
handleSaveConfigMap,
handleFetchSettings,
handleFetchConfigMap,
} = useSettingForm(ref("system"), ref("system"));
const siteTitle = ref("");
const loading = ref(false);
const { mutate: pluginInstallMutate } = useMutation({
mutationKey: ["plugin-install"],
mutationFn: async (plugin: Plugin) => {
const { data } = await apiClient.plugin.installPlugin(
{
source: "PRESET",
presetName: plugin.metadata.name as string,
},
{
mute: true,
}
);
return data;
},
retry: 3,
retryDelay: 1000,
onSuccess(data) {
pluginStartMutate(data);
},
});
const { mutate: pluginStartMutate } = useMutation({
mutationKey: ["plugin-start"],
mutationFn: async (plugin: Plugin) => {
const { data: pluginToUpdate } =
await apiClient.extension.plugin.getpluginHaloRunV1alpha1Plugin({
name: plugin.metadata.name,
});
pluginToUpdate.spec.enabled = true;
return apiClient.extension.plugin.updatepluginHaloRunV1alpha1Plugin(
{
name: plugin.metadata.name,
plugin: pluginToUpdate,
},
{
mute: true,
}
);
},
retry: 3,
retryDelay: 1000,
const formState = ref<SystemInitializationRequest>({
siteTitle: "",
username: "",
password: "",
email: "",
});
const handleSubmit = async () => {
try {
loading.value = true;
loading.value = true;
// Set site title
if (configMapFormData.value) {
configMapFormData.value["basic"].title = siteTitle.value;
await handleSaveConfigMap();
}
// Create category / tag / post
await apiClient.extension.category.createcontentHaloRunV1alpha1Category({
category: category as Category,
});
await apiClient.extension.tag.createcontentHaloRunV1alpha1Tag({
tag: tag as Tag,
});
const { data: postData } = await apiClient.post.draftPost({
postRequest: post as PostRequest,
});
await apiClient.post.publishPost({ name: postData.metadata.name });
// Create singlePage
const { data: singlePageData } = await apiClient.singlePage.draftSinglePage(
{
singlePageRequest: singlePage as SinglePageRequest,
}
);
await apiClient.singlePage.publishSinglePage({
name: singlePageData.metadata.name,
});
// Create menu and menu items
const menuItemPromises = menuItems.map((item) => {
return apiClient.extension.menuItem.createv1alpha1MenuItem({
menuItem: item,
});
});
await Promise.all(menuItemPromises);
await apiClient.extension.menu.createv1alpha1Menu({ menu: menu });
// Install preset plugins
const { data: presetPlugins } = await apiClient.plugin.listPluginPresets();
for (let i = 0; i < presetPlugins.length; i++) {
pluginInstallMutate(presetPlugins[i]);
}
} catch (error) {
console.error("Failed to initialize preset data", error);
}
// Create system-states ConfigMap
await apiClient.extension.configMap.createv1alpha1ConfigMap({
configMap: {
metadata: {
name: "system-states",
},
kind: "ConfigMap",
apiVersion: "v1alpha1",
data: {
states: JSON.stringify({ isSetup: true }),
},
},
await apiClient.system.initialize({
systemInitializationRequest: formState.value,
});
const systemStateStore = useSystemStatesStore();
await systemStateStore.fetchSystemStates();
const themeStore = useThemeStore();
await themeStore.fetchActivatedTheme();
const globalInfoStore = useGlobalInfoStore();
await globalInfoStore.fetchGlobalInfo();
loading.value = false;
router.push({ name: "Dashboard" });
router.push({ name: "Login" });
Toast.success(t("core.setup.operations.submit.toast_success"));
};
onMounted(async () => {
const systemStatesStore = useSystemStatesStore();
if (systemStatesStore.states.isSetup) {
router.push({ name: "Dashboard" });
return;
}
handleFetchSettings();
handleFetchConfigMap();
});
const inputClasses = {
outer: "!py-3 first:!pt-0 last:!pb-0",
};
</script>
<template>
<div class="flex h-screen flex-col items-center justify-center">
<div class="flex h-screen flex-col items-center bg-white/90 pt-[30vh]">
<IconLogo class="mb-8" />
<div class="flex w-72 flex-col gap-4">
<div class="flex w-72 flex-col">
<FormKit
id="setup-form"
v-model="formState"
name="setup-form"
:actions="false"
:classes="{
form: '!divide-none',
}"
:config="{ validationVisibility: 'submit' }"
type="form"
@submit="handleSubmit"
@keyup.enter="$formkit.submit('setup-form')"
>
<FormKit
v-model="siteTitle"
:validation-messages="{
required: $t('core.setup.fields.site_title.validation'),
}"
name="siteTitle"
:classes="inputClasses"
:autofocus="true"
type="text"
:placeholder="$t('core.setup.fields.site_title.placeholder')"
validation="required|length:0,100"
:validation-label="$t('core.setup.fields.site_title.label')"
:placeholder="$t('core.setup.fields.site_title.label')"
validation="required:trim|length:0,100"
></FormKit>
<FormKit
name="email"
:classes="inputClasses"
type="text"
:validation-label="$t('core.setup.fields.email.label')"
:placeholder="$t('core.setup.fields.email.label')"
validation="required|email|length:0,100"
></FormKit>
<FormKit
name="username"
:classes="inputClasses"
type="text"
:validation-label="$t('core.setup.fields.username.label')"
:placeholder="$t('core.setup.fields.username.label')"
:validation="[
['required'],
['length:0,63'],
[
'matches',
/^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$/,
],
]"
></FormKit>
<FormKit
name="password"
:classes="inputClasses"
type="password"
:validation-label="$t('core.setup.fields.password.label')"
:placeholder="$t('core.setup.fields.password.label')"
validation="required:trim|length:5,100|matches:/^\S.*\S$/"
autocomplete="current-password"
></FormKit>
<FormKit
name="password_confirm"
:classes="inputClasses"
type="password"
:validation-label="$t('core.setup.fields.confirm_password.label')"
:placeholder="$t('core.setup.fields.confirm_password.label')"
validation="confirm|required:trim|length:5,100|matches:/^\S.*\S$/"
autocomplete="current-password"
></FormKit>
</FormKit>
<VButton
block
class="mt-8"
type="secondary"
:loading="loading"
@click="$formkit.submit('setup-form')"
@ -203,5 +121,10 @@ onMounted(async () => {
{{ $t("core.setup.operations.submit.button") }}
</VButton>
</div>
<div
class="bottom-0 mb-10 mt-auto flex items-center justify-center gap-2.5"
>
<LocaleChange />
</div>
</div>
</template>

View File

@ -0,0 +1,165 @@
<script lang="ts" setup>
import { apiClient } from "@/utils/api-client";
import { VLoading } from "@halo-dev/components";
import { useMutation } from "@tanstack/vue-query";
import type {
Category,
Plugin,
PostRequest,
SinglePageRequest,
Tag,
} from "@halo-dev/api-client";
import { onMounted } from "vue";
import category from "./setup-data/category.json";
import tag from "./setup-data/tag.json";
import post from "./setup-data/post.json";
import singlePage from "./setup-data/singlePage.json";
import menu from "./setup-data/menu.json";
import menuItems from "./setup-data/menu-items.json";
import { useRouter } from "vue-router";
import { useGlobalInfoStore } from "@/stores/global-info";
import { ref } from "vue";
import { useUserStore } from "@/stores/user";
const router = useRouter();
const globalInfoStore = useGlobalInfoStore();
const { mutate: pluginInstallMutate } = useMutation({
mutationKey: ["plugin-install"],
mutationFn: async (plugin: Plugin) => {
const { data } = await apiClient.plugin.installPlugin(
{
source: "PRESET",
presetName: plugin.metadata.name as string,
},
{
mute: true,
}
);
return data;
},
retry: 3,
retryDelay: 1000,
onSuccess(data) {
pluginStartMutate(data);
},
});
const { mutate: pluginStartMutate } = useMutation({
mutationKey: ["plugin-start"],
mutationFn: async (plugin: Plugin) => {
const { data: pluginToUpdate } =
await apiClient.extension.plugin.getpluginHaloRunV1alpha1Plugin({
name: plugin.metadata.name,
});
pluginToUpdate.spec.enabled = true;
return apiClient.extension.plugin.updatepluginHaloRunV1alpha1Plugin(
{
name: plugin.metadata.name,
plugin: pluginToUpdate,
},
{
mute: true,
}
);
},
retry: 3,
retryDelay: 1000,
});
const processing = ref(false);
async function setupInitialData() {
try {
processing.value = true;
// Create category / tag / post
await apiClient.extension.category.createcontentHaloRunV1alpha1Category({
category: category as Category,
});
await apiClient.extension.tag.createcontentHaloRunV1alpha1Tag({
tag: tag as Tag,
});
const { data: postData } = await apiClient.post.draftPost({
postRequest: post as PostRequest,
});
await apiClient.post.publishPost({ name: postData.metadata.name });
// Create singlePage
const { data: singlePageData } = await apiClient.singlePage.draftSinglePage(
{
singlePageRequest: singlePage as SinglePageRequest,
}
);
await apiClient.singlePage.publishSinglePage({
name: singlePageData.metadata.name,
});
// Create menu and menu items
const menuItemPromises = menuItems.map((item) => {
return apiClient.extension.menuItem.createv1alpha1MenuItem({
menuItem: item,
});
});
await Promise.all(menuItemPromises);
await apiClient.extension.menu.createv1alpha1Menu({ menu: menu });
// Install preset plugins
const { data: presetPlugins } = await apiClient.plugin.listPluginPresets();
for (let i = 0; i < presetPlugins.length; i++) {
pluginInstallMutate(presetPlugins[i]);
}
await apiClient.extension.configMap.createv1alpha1ConfigMap({
configMap: {
metadata: {
name: "system-states",
},
kind: "ConfigMap",
apiVersion: "v1alpha1",
data: {
states: JSON.stringify({ isSetup: true }),
},
},
});
} catch (error) {
console.error(error);
} finally {
processing.value = false;
}
await globalInfoStore.fetchGlobalInfo();
router.push({ name: "Dashboard" });
}
const userStore = useUserStore();
onMounted(async () => {
await globalInfoStore.fetchGlobalInfo();
if (
globalInfoStore.globalInfo &&
globalInfoStore.globalInfo.dataInitialized === false &&
!userStore.isAnonymous
) {
setupInitialData();
return;
}
router.push({ name: "Dashboard" });
});
</script>
<template>
<div class="flex h-screen flex-col items-center justify-center">
<VLoading />
<div v-if="processing" class="text-xs text-gray-600">
{{ $t("core.setup.operations.setup_initial_data.loading") }}
</div>
</div>
</template>

View File

@ -1,6 +1,6 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"include": ["env.d.ts", "src/**/*", "src/**/*.vue", "src/**/*.json"],
"exclude": ["src/**/__tests__/*", "packages/**/*"],
"compilerOptions": {
"baseUrl": ".",