refactor: system initialization process to adapt to the new login method

Signed-off-by: guqing <i@guqing.email>
pull/6739/head
guqing 2024-10-09 12:20:27 +08:00
parent 2585636c48
commit bba242332d
34 changed files with 916 additions and 457 deletions

View File

@ -157,7 +157,6 @@ public class Post extends AbstractExtension {
@Data
public static class PostStatus {
@Schema(requiredMode = RequiredMode.REQUIRED)
private String phase;
@Schema

View File

@ -71,7 +71,6 @@ import run.halo.app.extension.router.IListRequest;
import run.halo.app.extension.router.SortableRequest;
import run.halo.app.infra.ReactiveUrlDataBufferFetcher;
import run.halo.app.infra.utils.SettingUtils;
import run.halo.app.plugin.PluginNotFoundException;
import run.halo.app.plugin.PluginService;
@Slf4j
@ -298,12 +297,6 @@ public class PluginEndpoint implements CustomEndpoint, InitializingBean {
.response(responseBuilder()
.implementation(ObjectNode.class))
)
.GET("plugin-presets", this::listPresets,
builder -> builder.operationId("ListPluginPresets")
.description("List all plugin presets in the system.")
.tag(tag)
.response(responseBuilder().implementationArray(Plugin.class))
)
.GET("plugins/-/bundle.js", this::fetchJsBundle,
builder -> builder.operationId("fetchJsBundle")
.description("Merge all JS bundles of enabled plugins into one.")
@ -472,10 +465,6 @@ public class PluginEndpoint implements CustomEndpoint, InitializingBean {
return ServerResponse.ok().body(pluginService.reload(name), Plugin.class);
}
private Mono<ServerResponse> listPresets(ServerRequest request) {
return ServerResponse.ok().body(pluginService.getPresets(), Plugin.class);
}
private Mono<ServerResponse> fetchPluginConfig(ServerRequest request) {
final var name = request.pathVariable("name");
return client.fetch(Plugin.class, name)
@ -564,10 +553,6 @@ public class PluginEndpoint implements CustomEndpoint, InitializingBean {
if (InstallSource.FILE.equals(source)) {
return installFromFile(installRequest.getFile(), pluginService::install);
}
if (InstallSource.PRESET.equals(source)) {
return installFromPreset(installRequest.getPresetName(),
pluginService::install);
}
return Mono.error(
new UnsupportedOperationException("Unsupported install source " + source));
}))
@ -586,10 +571,6 @@ public class PluginEndpoint implements CustomEndpoint, InitializingBean {
return installFromFile(installRequest.getFile(),
path -> pluginService.upgrade(pluginName, path));
}
if (InstallSource.PRESET.equals(source)) {
return installFromPreset(installRequest.getPresetName(),
path -> pluginService.upgrade(pluginName, path));
}
return Mono.error(
new UnsupportedOperationException("Unsupported install source " + source));
}))
@ -606,16 +587,6 @@ public class PluginEndpoint implements CustomEndpoint, InitializingBean {
this::deleteFileIfExists);
}
private Mono<Plugin> installFromPreset(Mono<String> presetNameMono,
Function<Path, Mono<Plugin>> 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) {

View File

@ -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;

View File

@ -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<ServerResponse> 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<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.created(URI.create("/console")).build());
}
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

@ -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<ApplicationStartedEvent> {
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);
}
}
}

View File

@ -3,11 +3,19 @@ package run.halo.app.infra;
import com.fasterxml.jackson.databind.JsonNode;
import com.github.fge.jsonpatch.JsonPatchException;
import com.github.fge.jsonpatch.mergepatch.JsonMergePatch;
import java.time.Duration;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.Consumer;
import lombok.Data;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.lang.NonNull;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;
import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.utils.JsonParseException;
import run.halo.app.infra.utils.JsonUtils;
@ -68,6 +76,33 @@ public class SystemState {
}
}
/**
* <p>Update system state by the given {@link Consumer}.</p>
* <p>if the system state config map does not exist, it will create a new one.</p>
*/
public static Mono<Void> upsetSystemState(ReactiveExtensionClient client,
Consumer<SystemState> 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 "{}";
}

View File

@ -6,8 +6,14 @@ import org.apache.commons.lang3.StringUtils;
@UtilityClass
public class ValidationUtils {
public static final Pattern NAME_PATTERN =
Pattern.compile("^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$");
public static final String NAME_REGEX =
"^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$";
public static final Pattern NAME_PATTERN = Pattern.compile(NAME_REGEX);
/**
* No Chinese, no spaces.
*/
public static final String PASSWORD_REGEX = "^(?!.*[\\u4e00-\\u9fa5])(?=\\S+$).+$";
public static final String EMAIL_REGEX =
"^[a-zA-Z0-9_+&*-]+(?:\\.[a-zA-Z0-9_+&*-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,7}$";

View File

@ -25,6 +25,7 @@ import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
import lombok.extern.slf4j.Slf4j;
import org.reactivestreams.Publisher;
import org.springframework.core.io.Resource;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.lang.NonNull;
import org.springframework.util.AntPathMatcher;
@ -296,6 +297,14 @@ public abstract class FileUtils {
.subscribeOn(scheduler);
}
public static void copyResource(Resource resource, Path path) {
try (var inputStream = resource.getInputStream()) {
Files.copy(inputStream, path, REPLACE_EXISTING);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public static void copy(Path source, Path dest, CopyOption... options) {
try {
Files.copy(source, dest, options);

View File

@ -5,6 +5,7 @@ import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.ZoneId;
import lombok.experimental.UtilityClass;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.core.io.ClassPathResource;
@ -14,12 +15,22 @@ import org.springframework.util.StreamUtils;
import org.springframework.web.reactive.function.server.ServerRequest;
/**
* Halo utilities.
*
* @author guqing
* @date 2022-04-12
* @since 2.0.0
*/
@Slf4j
@UtilityClass
public class HaloUtils {
/**
* Check if the request is an XMLHttpRequest.
*/
public static boolean isXhr(HttpHeaders headers) {
return headers.getOrEmpty("X-Requested-With").contains("XMLHttpRequest");
}
/**
* <p>Read the file under the classpath as a string.</p>
*
@ -51,7 +62,7 @@ public class HaloUtils {
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-CH-UA
userAgent = httpHeaders.getFirst("Sec-CH-UA");
}
return StringUtils.defaultString(userAgent, "unknown");
return StringUtils.defaultIfBlank(userAgent, "unknown");
}
public static String getDayText(Instant instant) {

View File

@ -10,15 +10,7 @@ import run.halo.app.core.extension.Plugin;
public interface PluginService {
Flux<Plugin> getPresets();
/**
* Gets a plugin information by preset name from plugin presets.
*
* @param presetName is preset name of plugin.
* @return plugin preset information.
*/
Mono<Plugin> getPreset(String presetName);
Mono<Void> installPresetPlugins();
/**
* Installs a plugin from a temporary Jar path.

View File

@ -23,6 +23,7 @@ import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.pf4j.DependencyResolver;
import org.pf4j.PluginDescriptor;
import org.pf4j.PluginWrapper;
@ -36,6 +37,7 @@ import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
@ -110,18 +112,36 @@ public class PluginServiceImpl implements PluginService, InitializingBean, Dispo
}
@Override
public Flux<Plugin> getPresets() {
// list presets from classpath
return Flux.defer(() -> getPresetJars()
.map(this::toPath)
.map(path -> new YamlPluginFinder().find(path)));
public Mono<Void> 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<Plugin> getPreset(String presetName) {
return getPresets()
.filter(plugin -> Objects.equals(plugin.getMetadata().getName(), presetName))
.next();
private Mono<Plugin> enablePlugin(Plugin plugin) {
plugin.getSpec().setEnabled(true);
return client.update(plugin)
.onErrorResume(OptimisticLockingFailureException.class,
e -> enablePlugin(plugin.getMetadata().getName())
);
}
private Mono<Plugin> enablePlugin(String name) {
return Mono.defer(() -> client.get(Plugin.class, name)
.flatMap(plugin -> {
plugin.getSpec().setEnabled(true);
return client.update(plugin);
})
)
.retryWhen(Retry.backoff(8, Duration.ofMillis(100))
.filter(OptimisticLockingFailureException.class::isInstance));
}
@Override
@ -481,24 +501,25 @@ public class PluginServiceImpl implements PluginService, InitializingBean, Dispo
}
}
private Flux<Resource> getPresetJars() {
private Flux<Path> 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();

View File

@ -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())

View File

@ -10,6 +10,7 @@ import org.springframework.security.web.server.util.matcher.ServerWebExchangeMat
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher.MatchResult;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import run.halo.app.infra.utils.HaloUtils;
/**
* Default authentication entry point.
@ -22,8 +23,7 @@ import reactor.core.publisher.Mono;
public class DefaultServerAuthenticationEntryPoint implements ServerAuthenticationEntryPoint {
private final ServerWebExchangeMatcher xhrMatcher = exchange -> {
if (exchange.getRequest().getHeaders().getOrEmpty("X-Requested-With")
.contains("XMLHttpRequest")) {
if (HaloUtils.isXhr(exchange.getRequest().getHeaders())) {
return MatchResult.match();
}
return MatchResult.notMatch();

View File

@ -1,13 +1,17 @@
package run.halo.app.security;
import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers;
import java.net.URI;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.lang.NonNull;
import org.springframework.security.web.server.DefaultServerRedirectStrategy;
import org.springframework.security.web.server.ServerRedirectStrategy;
import org.springframework.security.web.server.util.matcher.PathPatternParserServerWebExchangeMatcher;
import org.springframework.security.web.server.util.matcher.AndServerWebExchangeMatcher;
import org.springframework.security.web.server.util.matcher.MediaTypeServerWebExchangeMatcher;
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
@ -26,9 +30,11 @@ import run.halo.app.infra.InitializationStateGetter;
@Component
@RequiredArgsConstructor
public class InitializeRedirectionWebFilter implements WebFilter {
private final URI location = URI.create("/console");
private final ServerWebExchangeMatcher redirectMatcher =
new PathPatternParserServerWebExchangeMatcher("/", HttpMethod.GET);
private final URI location = URI.create("/system/setup");
private final ServerWebExchangeMatcher redirectMatcher = new AndServerWebExchangeMatcher(
pathMatchers(HttpMethod.GET, "/", "/console/**", "/uc/**", "/login", "/signup"),
new MediaTypeServerWebExchangeMatcher(MediaType.TEXT_HTML)
);
private final InitializationStateGetter initializationStateGetter;

View File

@ -0,0 +1,274 @@
package run.halo.app.security.preauth;
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;
import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder;
import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder;
import static org.springframework.web.reactive.function.server.RequestPredicates.accept;
import static org.springframework.web.reactive.function.server.RequestPredicates.contentType;
import static org.springframework.web.reactive.function.server.RequestPredicates.path;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Properties;
import lombok.RequiredArgsConstructor;
import org.springdoc.core.fn.builders.content.Builder;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
import org.springframework.beans.factory.config.PlaceholderConfigurerSupport;
import org.springframework.context.annotation.Bean;
import org.springframework.core.io.ClassPathResource;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.util.InMemoryResource;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.PropertyPlaceholderHelper;
import org.springframework.util.StreamUtils;
import org.springframework.validation.BeanPropertyBindingResult;
import org.springframework.validation.BindingResult;
import org.springframework.validation.Validator;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import reactor.util.retry.Retry;
import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Unstructured;
import run.halo.app.infra.InitializationStateGetter;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;
import run.halo.app.infra.SystemState;
import run.halo.app.infra.ValidationUtils;
import run.halo.app.infra.exception.RequestBodyValidationException;
import run.halo.app.infra.utils.HaloUtils;
import run.halo.app.infra.utils.JsonUtils;
import run.halo.app.infra.utils.YamlUnstructuredLoader;
import run.halo.app.plugin.PluginService;
import run.halo.app.security.SuperAdminInitializer;
import run.halo.app.theme.service.ThemeService;
@Component
@RequiredArgsConstructor
public class SystemSetupEndpoint {
static final String SETUP_TEMPLATE = "setup";
static final PropertyPlaceholderHelper PROPERTY_PLACEHOLDER_HELPER =
new PropertyPlaceholderHelper(
PlaceholderConfigurerSupport.DEFAULT_PLACEHOLDER_PREFIX,
PlaceholderConfigurerSupport.DEFAULT_PLACEHOLDER_SUFFIX
);
private final InitializationStateGetter initializationStateGetter;
private final SystemConfigurableEnvironmentFetcher systemConfigFetcher;
private final SuperAdminInitializer superAdminInitializer;
private final ReactiveExtensionClient client;
private final PluginService pluginService;
private final ThemeService themeService;
private final Validator validator;
@Bean
RouterFunction<ServerResponse> 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<ServerResponse> 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<ServerResponse> handleSetupSuccessfully(ServerRequest request) {
if (isHtmlRequest(request)) {
return redirectToConsole();
}
return ServerResponse.noContent().build();
}
private Mono<ServerResponse> 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<ServerResponse> redirectToConsole() {
return ServerResponse.temporaryRedirect(URI.create("/console")).build();
}
private Mono<Void> 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<Void> initializeNecessaryData(String username) {
return loadPresetExtensions(username)
.concatMap(client::create)
.subscribeOn(Schedulers.boundedElastic())
.then();
}
private static void mergeToBasicConfig(SetupRequest body, ConfigMap configMap) {
Map<String, String> 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<ServerResponse> 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<String, String> formData) {
@Schema(requiredMode = REQUIRED, minLength = 4, maxLength = 63)
@NotBlank
@Size(min = 4, max = 63)
@Pattern(regexp = ValidationUtils.NAME_REGEX,
message = "{validation.error.username.pattern}")
public String getUsername() {
return formData.getFirst("username");
}
@Schema(requiredMode = REQUIRED, minLength = 5, maxLength = 257)
@NotBlank
@Pattern(regexp = ValidationUtils.PASSWORD_REGEX,
message = "{validation.error.password.pattern}")
@Size(min = 5, max = 257)
public String getPassword() {
return formData.getFirst("password");
}
@Email
public String getEmail() {
return formData.getFirst("email");
}
@NotBlank
@Size(max = 80)
public String getSiteTitle() {
return formData.getFirst("siteTitle");
}
public BindingResult toBindingResult() {
return new BeanPropertyBindingResult(this, "form");
}
}
Flux<Unstructured> 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());
}
}

View File

@ -1,13 +1,10 @@
package run.halo.app.theme;
import java.io.FileNotFoundException;
import java.nio.file.Path;
import lombok.NonNull;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.thymeleaf.ThymeleafProperties;
import org.springframework.stereotype.Component;
import org.springframework.util.ConcurrentLruCache;
import org.springframework.util.ResourceUtils;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.dialect.IDialect;
import org.thymeleaf.spring6.ISpringWebFluxTemplateEngine;
@ -17,7 +14,6 @@ import org.thymeleaf.templateresolver.FileTemplateResolver;
import org.thymeleaf.templateresolver.ITemplateResolver;
import reactor.core.publisher.Mono;
import run.halo.app.infra.ExternalUrlSupplier;
import run.halo.app.infra.exception.NotFoundException;
import run.halo.app.plugin.HaloPluginManager;
import run.halo.app.theme.dialect.HaloProcessorDialect;
import run.halo.app.theme.engine.HaloTemplateEngine;
@ -71,24 +67,9 @@ public class TemplateEngineManager {
public ISpringWebFluxTemplateEngine getTemplateEngine(ThemeContext theme) {
CacheKey cacheKey = buildCacheKey(theme);
// cache not exists, will create new engine
if (!engineCache.contains(cacheKey)) {
// before this, check if theme exists
if (!fileExists(theme.getPath())) {
throw new NotFoundException("Theme not found.");
}
}
return engineCache.get(cacheKey);
}
private boolean fileExists(Path path) {
try {
return ResourceUtils.getFile(path.toUri()).exists();
} catch (FileNotFoundException e) {
return false;
}
}
public Mono<Void> clearCache(String themeName) {
return themeResolver.getThemeContext(themeName)
.doOnNext(themeContext -> {

View File

@ -8,6 +8,8 @@ import run.halo.app.extension.ConfigMap;
public interface ThemeService {
Mono<Void> installPresetTheme();
Mono<Theme> install(Publisher<DataBuffer> content);
Mono<Theme> upgrade(String themeName, Publisher<DataBuffer> content);

View File

@ -8,6 +8,8 @@ import static run.halo.app.theme.service.ThemeUtils.loadThemeManifest;
import static run.halo.app.theme.service.ThemeUtils.locateThemeManifest;
import static run.halo.app.theme.service.ThemeUtils.unzipThemeTo;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.HashMap;
@ -18,11 +20,17 @@ import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.reactivestreams.Publisher;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.UrlResource;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.retry.RetryException;
import org.springframework.stereotype.Service;
import org.springframework.util.Assert;
import org.springframework.util.ResourceUtils;
import org.springframework.util.StreamUtils;
import org.springframework.web.server.ServerErrorException;
import org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Flux;
@ -41,6 +49,8 @@ import run.halo.app.infra.SystemVersionSupplier;
import run.halo.app.infra.ThemeRootGetter;
import run.halo.app.infra.exception.ThemeUpgradeException;
import run.halo.app.infra.exception.UnsatisfiedAttributeValueException;
import run.halo.app.infra.properties.HaloProperties;
import run.halo.app.infra.utils.FileUtils;
import run.halo.app.infra.utils.SettingUtils;
import run.halo.app.infra.utils.VersionUtils;
@ -53,10 +63,48 @@ public class ThemeServiceImpl implements ThemeService {
private final ThemeRootGetter themeRoot;
private final HaloProperties haloProperties;
private final SystemVersionSupplier systemVersionSupplier;
private final Scheduler scheduler = Schedulers.boundedElastic();
@Override
public Mono<Void> 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<Path> 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<Path> createThemeTempPath() {
return Mono.fromCallable(() -> Files.createTempDirectory("halo-theme-preset"))
.subscribeOn(Schedulers.boundedElastic());
}
@Override
public Mono<Theme> install(Publisher<DataBuffer> content) {
var themeRoot = this.themeRoot.get();

View File

@ -86,4 +86,7 @@ problemDetail.comment.waitingForApproval=Comment is awaiting approval.
title.visibility.identification.private=(Private)
signup.error.confirm-password-not-match=The confirmation password does not match the password.
signup.error.email-code.invalid=Invalid email code.
signup.error.email-code.invalid=Invalid email code.
validation.error.username.pattern=The username can only be lowercase and can only contain letters, numbers, hyphens, and dots, starting and ending with characters.
validation.error.password.pattern=The password cannot contain Chinese characters and spaces.

View File

@ -60,3 +60,6 @@ problemDetail.comment.waitingForApproval=评论审核中。
title.visibility.identification.private=(私有)
signup.error.confirm-password-not-match=确认密码与密码不匹配。
signup.error.email-code.invalid=邮箱验证码无效。
validation.error.username.pattern=用户名只能小写且只能包含字母、数字、中划线和点,以字符开头和结尾
validation.error.password.pattern=密码不能包含中文和空格

View File

@ -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"

View File

@ -0,0 +1,239 @@
# 提供了 timestamp、username 变量,用于初始化数据时填充时间戳和用户名
# 初始化文章关联的分类、标签数据
apiVersion: content.halo.run/v1alpha1
kind: Category
metadata:
name: 76514a40-6ef1-4ed9-b58a-e26945bde3ca
spec:
displayName: 默认分类
slug: default
description: 这是你的默认分类,如不需要,删除即可。
cover: ""
template: ""
priority: 0
children: [ ]
status:
permalink: "/categories/default"
---
apiVersion: content.halo.run/v1alpha1
kind: Tag
metadata:
name: c33ceabb-d8f1-4711-8991-bb8f5c92ad7c
spec:
displayName: Halo
slug: halo
color: "#ffffff"
cover: ""
status:
permalink: "/tags/halo"
---
# 文章关联的内容
apiVersion: content.halo.run/v1alpha1
kind: Snapshot
metadata:
name: fb5cd6bd-998d-4ccc-984d-5cc23b0a09f9
annotations:
content.halo.run/keep-raw: "true"
spec:
subjectRef:
group: content.halo.run
version: v1alpha1
kind: Post
name: 5152aea5-c2e8-4717-8bba-2263d46e19d5
rawType: HTML
rawPatch: <h2 id="hello-halo"><strong>Hello
Halo</strong></h2><p>如果你看到了这一篇文章,那么证明你已经安装成功了,感谢使用 <a target="_blank"
rel="noopener noreferrer nofollow" href="https://www.halo.run/">Halo</a>
进行创作,希望能够使用愉快。</p><h2
id="%E7%9B%B8%E5%85%B3%E9%93%BE%E6%8E%A5"><strong>相关链接</strong></h2><ul><li><p>官网:<a
target="_blank" rel="noopener noreferrer nofollow"
href="https://www.halo.run">https://www.halo.run</a></p></li><li><p>文档:<a
target="_blank" rel="noopener noreferrer nofollow"
href="https://docs.halo.run">https://docs.halo.run</a></p></li><li><p>社区:<a
target="_blank" rel="noopener noreferrer nofollow"
href="https://bbs.halo.run">https://bbs.halo.run</a></p></li><li><p>应用市场:<a
target="_blank" rel="noopener noreferrer nofollow"
href="https://www.halo.run/store/apps">https://www.halo.run/store/apps</a></p></li><li><p>开源地址:<a
target="_blank" rel="noopener noreferrer nofollow"
href="https://github.com/halo-dev/halo">https://github.com/halo-dev/halo</a></p></li></ul><p>在使用过程中,有任何问题都可以通过以上链接找寻答案,或者联系我们。</p><blockquote><p>这是一篇自动生成的文章,请删除这篇文章之后开始你的创作吧!</p></blockquote>
contentPatch: <h2 id="hello-halo"><strong>Hello
Halo</strong></h2><p>如果你看到了这一篇文章,那么证明你已经安装成功了,感谢使用 <a target="_blank"
rel="noopener noreferrer nofollow" href="https://www.halo.run/">Halo</a>
进行创作,希望能够使用愉快。</p><h2
id="%E7%9B%B8%E5%85%B3%E9%93%BE%E6%8E%A5"><strong>相关链接</strong></h2><ul><li><p>官网:<a
target="_blank" rel="noopener noreferrer nofollow"
href="https://www.halo.run">https://www.halo.run</a></p></li><li><p>文档:<a
target="_blank" rel="noopener noreferrer nofollow"
href="https://docs.halo.run">https://docs.halo.run</a></p></li><li><p>社区:<a
target="_blank" rel="noopener noreferrer nofollow"
href="https://bbs.halo.run">https://bbs.halo.run</a></p></li><li><p>应用市场:<a
target="_blank" rel="noopener noreferrer nofollow"
href="https://www.halo.run/store/apps">https://www.halo.run/store/apps</a></p></li><li><p>开源地址:<a
target="_blank" rel="noopener noreferrer nofollow"
href="https://github.com/halo-dev/halo">https://github.com/halo-dev/halo</a></p></li></ul><p>在使用过程中,有任何问题都可以通过以上链接找寻答案,或者联系我们。</p><blockquote><p>这是一篇自动生成的文章,请删除这篇文章之后开始你的创作吧!</p></blockquote>
lastModifyTime: "${timestamp}"
owner: "${username}"
contributors:
- "${username}"
---
# 初始化文章数据
apiVersion: content.halo.run/v1alpha1
kind: Post
metadata:
name: 5152aea5-c2e8-4717-8bba-2263d46e19d5
spec:
title: Hello Halo
slug: hello-halo
releaseSnapshot: fb5cd6bd-998d-4ccc-984d-5cc23b0a09f9
headSnapshot: fb5cd6bd-998d-4ccc-984d-5cc23b0a09f9
baseSnapshot: fb5cd6bd-998d-4ccc-984d-5cc23b0a09f9
owner: "${username}"
template: ""
cover: ""
deleted: false
publish: true
publishTime: "${timestamp}"
pinned: false
allowComment: true
visible: PUBLIC
priority: 0
excerpt:
autoGenerate: false
raw: 如果你看到了这一篇文章,那么证明你已经安装成功了,感谢使用 Halo 进行创作,希望能够使用愉快。
categories:
- 76514a40-6ef1-4ed9-b58a-e26945bde3ca
tags:
- c33ceabb-d8f1-4711-8991-bb8f5c92ad7c
htmlMetas: [ ]
status:
permalink: /archives/hello-halo
---
# 自定义页面关联的内容
apiVersion: content.halo.run/v1alpha1
kind: Snapshot
metadata:
name: c3f73cc2-194e-4cd8-9092-7386aa50a0e5
annotations:
content.halo.run/keep-raw: "true"
spec:
subjectRef:
group: content.halo.run
version: v1alpha1
kind: SinglePage
name: 373a5f79-f44f-441a-9df1-85a4f553ece8
rawType: HTML
rawPatch: <h2><strong>关于页面</strong></h2><p>这是一个自定义页面,你可以在后台的 <code>页面</code>
-&gt; <code>自定义页面</code>
找到它,你可以用于新建关于页面、联系我们页面等等。</p><blockquote><p>这是一篇自动生成的页面,你可以在后台删除它。</p></blockquote>
contentPatch: <h2><strong>关于页面</strong></h2><p>这是一个自定义页面,你可以在后台的 <code>页面</code>
-&gt; <code>自定义页面</code>
找到它,你可以用于新建关于页面、联系我们页面等等。</p><blockquote><p>这是一篇自动生成的页面,你可以在后台删除它。</p></blockquote>
lastModifyTime: "${timestamp}"
owner: "${username}"
contributors:
- "${username}"
---
# 初始化自定义页面数据
apiVersion: content.halo.run/v1alpha1
kind: SinglePage
metadata:
name: 373a5f79-f44f-441a-9df1-85a4f553ece8
spec:
title: 关于
slug: about
template: ""
cover: ""
owner: "${username}"
deleted: false
publish: true
baseSnapshot: c3f73cc2-194e-4cd8-9092-7386aa50a0e5
headSnapshot: c3f73cc2-194e-4cd8-9092-7386aa50a0e5
releaseSnapshot: c3f73cc2-194e-4cd8-9092-7386aa50a0e5
pinned: false
allowComment: true
visible: PUBLIC
version: 1
priority: 0
excerpt:
autoGenerate: false
raw: 这是一个自定义页面,你可以在后台的 页面 -> 自定义页面 找到它,你可以用于新建关于页面、联系我们页面等等。
htmlMetas: [ ]
status:
permalink: "/about"
---
# 首页菜单项
apiVersion: v1alpha1
kind: MenuItem
metadata:
name: 88c3f10b-321c-4092-86a8-70db00251b74
spec:
displayName: 首页
href: /
children: [ ]
priority: 0
---
# 关联到文章作为菜单
apiVersion: v1alpha1
kind: MenuItem
metadata:
name: c4c814d1-0c2c-456b-8c96-4864965fee94
spec:
displayName: "Hello Halo"
href: "/archives/hello-halo"
children: [ ]
priority: 1
targetRef:
group: content.halo.run
version: v1alpha1
kind: Post
name: 5152aea5-c2e8-4717-8bba-2263d46e19d5
---
# 关联到标签作为菜单
apiVersion: v1alpha1
kind: MenuItem
metadata:
name: 35869bd3-33b5-448b-91ee-cf6517a59644
spec:
displayName: "Halo"
href: "/tags/halo"
children: [ ]
priority: 2
targetRef:
group: content.halo.run
version: v1alpha1
kind: Tag
name: c33ceabb-d8f1-4711-8991-bb8f5c92ad7c
---
# 关联到自定义页面作为菜单
apiVersion: v1alpha1
kind: MenuItem
metadata:
name: b0d041fa-dc99-48f6-a193-8604003379cf
spec:
displayName: "关于"
href: "/about"
children: [ ]
priority: 3
targetRef:
group: content.halo.run
version: v1alpha1
kind: SinglePage
name: 373a5f79-f44f-441a-9df1-85a4f553ece8
---
apiVersion: v1alpha1
kind: Menu
metadata:
name: primary
spec:
displayName: 主菜单
menuItems:
- 88c3f10b-321c-4092-86a8-70db00251b74
- c4c814d1-0c2c-456b-8c96-4864965fee94
- 35869bd3-33b5-448b-91ee-cf6517a59644
- b0d041fa-dc99-48f6-a193-8604003379cf

View File

@ -134,7 +134,7 @@
<div class="form-item">
<label for="password" th:text="#{form.signup.password.label}"></label>
<th:block
th:replace="~{gateway_modules/input_fragments :: password(id = 'password', name = 'password', required = 'true', minlength = 6, enableToggle = true)}"
th:replace="~{gateway_modules/input_fragments :: password(id = 'password', name = 'password', required = 'true', minlength = 5, maxlength = 257, enableToggle = true)}"
></th:block>
<p class="alert alert-error" th:if="${#fields.hasErrors('password')}" th:errors="*{password}"></p>
</div>
@ -142,7 +142,7 @@
<div class="form-item">
<label for="confirmPassword" th:text="#{form.signup.confirmPassword.label}"></label>
<th:block
th:replace="~{gateway_modules/input_fragments :: password(id = 'confirmPassword', name = 'confirmPassword', required = 'true', minlength = 6, enableToggle = true)}"
th:replace="~{gateway_modules/input_fragments :: password(id = 'confirmPassword', name = 'confirmPassword', required = 'true', minlength = 5, maxlength = 257, enableToggle = true)}"
></th:block>
<p class="alert alert-error" th:if="${#fields.hasErrors('confirmPassword')}" th:errors="*{confirmPassword}"></p>
</div>
@ -254,13 +254,13 @@
<div class="form-item">
<label for="password" th:text="#{form.passwordResetLink.password.label}">Password</label>
<th:block
th:replace="~{gateway_modules/input_fragments :: password(id = 'password', name = 'password', required = 'true', minlength = 6, enableToggle = true)}"
th:replace="~{gateway_modules/input_fragments :: password(id = 'password', name = 'password', required = 'true', minlength = 5, maxlength = 257, enableToggle = true)}"
></th:block>
</div>
<div class="form-item">
<label for="confirmPassword" th:text="#{form.passwordResetLink.confirmPassword.label}">Confirm Password</label>
<th:block
th:replace="~{gateway_modules/input_fragments :: password(id = 'confirmPassword', name = 'confirmPassword', required = 'true', minlength = 6, enableToggle = true)}"
th:replace="~{gateway_modules/input_fragments :: password(id = 'confirmPassword', name = 'confirmPassword', required = 'true', minlength = 5, maxlength = 257, enableToggle = true)}"
></th:block>
</div>
<div class="form-item">
@ -294,4 +294,4 @@
<button type="submit" th:text="#{form.passwordReset.submit}"></button>
</div>
</form>
</th:block>
</th:block>

View File

@ -1,4 +1,4 @@
<div th:remove="tag" th:fragment="password(id,name,required,minlength,enableToggle)">
<div th:remove="tag" th:fragment="password(id,name,required,minlength,maxlength,enableToggle)">
<div class="form-input" th:classappend="${enableToggle ? 'form-input-stack toggle-password-display-flag' : ''}">
<input
th:id="${id}"
@ -10,6 +10,7 @@
autocapitalize="off"
th:required="${required}"
th:minlength="${minlength}"
th:maxlength="${maxlength}"
/>
<div th:if="${enableToggle}" class="form-input-stack-icon toggle-password-button">
@ -28,4 +29,4 @@
</svg>
</div>
</div>
</div>
</div>

View File

@ -50,7 +50,7 @@
</div>
<th:block
th:replace="~{gateway_modules/input_fragments :: password(id = 'password', name = 'password', required = 'true', minlength = null, enableToggle = true)}"
th:replace="~{gateway_modules/input_fragments :: password(id = 'password', name = 'password', required = 'true', minlength = null, maxlength = 257, enableToggle = true)}"
></th:block>
</div>
</div>
</div>

View File

@ -0,0 +1,121 @@
<!doctype html>
<html
xmlns:th="https://www.thymeleaf.org"
th:replace="~{gateway_modules/layout :: layout(title = |#{title} - Halo|, head = ~{::head}, body = ~{::body})}"
>
<th:block th:fragment="head">
<style>
.setup-page-wrapper {
max-width: 35em;
}
</style>
</th:block>
<th:block th:fragment="body">
<div class="gateway-wrapper setup-page-wrapper">
<div th:replace="~{gateway_modules/common_fragments::haloLogo}"></div>
<div class="halo-form-wrapper">
<h1 class="form-title" th:text="#{title}"></h1>
<form th:object="${form}" th:action="@{/system/setup}" class="halo-form" method="post">
<div class="form-item">
<label for="siteTitle" th:text="#{form.siteTitle.label}"></label>
<div class="form-input">
<input
name="siteTitle"
id="siteTitle"
type="text"
th:field="*{siteTitle}"
autocomplete="off"
spellcheck="false"
autocorrect="off"
autocapitalize="off"
maxlength="80"
required
autofocus
/>
</div>
<p class="alert alert-error" th:if="${#fields.hasErrors('siteTitle')}" th:errors="*{siteTitle}"></p>
</div>
<div class="form-item">
<label for="username" th:text="#{form.username.label}"></label>
<div class="form-input">
<input
name="username"
id="username"
type="text"
th:field="*{username}"
autocomplete="off"
spellcheck="false"
autocorrect="off"
autocapitalize="off"
maxlength="63"
minlength="4"
required
/>
</div>
<p class="alert alert-error" th:if="${#fields.hasErrors('username')}" th:errors="*{username}"></p>
</div>
<div class="form-item">
<label for="email" th:text="#{form.email.label}"></label>
<div class="form-input">
<input
name="email"
id="email"
type="email"
th:field="*{email}"
autocomplete="off"
spellcheck="false"
autocorrect="off"
autocapitalize="off"
required
/>
</div>
<p class="alert alert-error" th:if="${#fields.hasErrors('email')}" th:errors="*{email}"></p>
</div>
<div class="form-item">
<label for="password" th:text="#{form.password.label}"></label>
<th:block
th:replace="~{gateway_modules/input_fragments :: password(id = 'password', name = 'password', required = 'true', minlength = 5, maxlength = 257, enableToggle = true)}"
></th:block>
<p class="alert alert-error" th:if="${#fields.hasErrors('password')}" th:errors="*{password}"></p>
</div>
<div class="form-item">
<label for="confirmPassword" th:text="#{form.confirmPassword.label}"></label>
<th:block
th:replace="~{gateway_modules/input_fragments :: password(id = 'confirmPassword', name = null, required = 'true', minlength = 5, maxlength = 257, enableToggle = true)}"
></th:block>
</div>
<div class="form-item">
<button type="submit" th:text="#{form.submit}">初始化</button>
</div>
</form>
</div>
<div th:replace="~{gateway_modules/common_fragments::languageSwitcher}"></div>
</div>
<script th:inline="javascript">
document.addEventListener("DOMContentLoaded", function () {
var password = document.getElementById("password"),
confirm_password = document.getElementById("confirmPassword");
function validatePassword() {
if (password.value != confirm_password.value) {
confirm_password.setCustomValidity("Passwords Don't Match");
} else {
confirm_password.setCustomValidity("");
}
}
password.onchange = validatePassword;
confirm_password.onkeyup = validatePassword;
});
</script>
</th:block>
</html>

View File

@ -0,0 +1,7 @@
title=系统初始化
form.siteTitle.label=站点标题
form.username.label=用户名
form.email.label=电子邮箱
form.password.label=密码
form.confirmPassword.label=确认密码
form.submit=初始化

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,7 @@
title=系統初始化
form.siteTitle.label=站點標題
form.username.label=使用者名稱
form.email.label=電子郵件
form.password.label=密碼
form.confirmPassword.label=確認密碼
form.submit=初始化

View File

@ -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);
}
}

View File

@ -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 {

View File

@ -2,6 +2,7 @@ package run.halo.app.security;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@ -14,6 +15,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.MediaType;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.web.server.MockServerWebExchange;
import org.springframework.security.web.server.ServerRedirectStrategy;
@ -50,49 +52,57 @@ class InitializeRedirectionWebFilterTest {
when(initializationStateGetter.userInitialized()).thenReturn(Mono.just(false));
WebFilterChain chain = mock(WebFilterChain.class);
var paths = new String[] {"/", "/console/test", "/uc/test", "/login", "/signup"};
for (String path : paths) {
MockServerHttpRequest request = MockServerHttpRequest.get(path)
.accept(MediaType.TEXT_HTML).build();
MockServerWebExchange exchange = MockServerWebExchange.from(request);
MockServerHttpRequest request = MockServerHttpRequest.get("/").build();
MockServerWebExchange exchange = MockServerWebExchange.from(request);
when(serverRedirectStrategy.sendRedirect(any(), any())).thenReturn(Mono.empty().then());
when(serverRedirectStrategy.sendRedirect(any(), any())).thenReturn(Mono.empty().then());
Mono<Void> result = filter.filter(exchange, chain);
Mono<Void> result = filter.filter(exchange, chain);
StepVerifier.create(result)
.expectNextCount(0)
.expectComplete()
.verify();
StepVerifier.create(result)
.expectNextCount(0)
.expectComplete()
.verify();
verify(serverRedirectStrategy).sendRedirect(eq(exchange), eq(URI.create("/console")));
verify(chain, never()).filter(eq(exchange));
verify(serverRedirectStrategy).sendRedirect(eq(exchange),
eq(URI.create("/system/setup")));
verify(chain, never()).filter(eq(exchange));
}
}
@Test
void shouldNotRedirectWhenSystemInitialized() {
when(initializationStateGetter.userInitialized()).thenReturn(Mono.just(true));
lenient().when(initializationStateGetter.userInitialized()).thenReturn(Mono.just(true));
WebFilterChain chain = mock(WebFilterChain.class);
MockServerHttpRequest request = MockServerHttpRequest.get("/").build();
MockServerWebExchange exchange = MockServerWebExchange.from(request);
when(chain.filter(any())).thenReturn(Mono.empty().then());
Mono<Void> result = filter.filter(exchange, chain);
var paths = new String[] {"/test", "/apis/test", "system/setup", "/logout"};
for (String path : paths) {
MockServerHttpRequest request = MockServerHttpRequest.get(path)
.accept(MediaType.TEXT_HTML).build();
MockServerWebExchange exchange = MockServerWebExchange.from(request);
when(chain.filter(any())).thenReturn(Mono.empty().then());
Mono<Void> result = filter.filter(exchange, chain);
StepVerifier.create(result)
.expectNextCount(0)
.expectComplete()
.verify();
StepVerifier.create(result)
.expectNextCount(0)
.expectComplete()
.verify();
verify(serverRedirectStrategy, never()).sendRedirect(eq(exchange),
eq(URI.create("/console")));
verify(chain).filter(eq(exchange));
verify(serverRedirectStrategy, never()).sendRedirect(eq(exchange), any());
verify(chain).filter(eq(exchange));
}
}
@Test
void shouldNotRedirectWhenNotHomePage() {
void shouldNotRedirectTest() {
WebFilterChain chain = mock(WebFilterChain.class);
MockServerHttpRequest request = MockServerHttpRequest.get("/test").build();
MockServerHttpRequest request = MockServerHttpRequest.get("/test")
.accept(MediaType.TEXT_HTML).build();
MockServerWebExchange exchange = MockServerWebExchange.from(request);
when(chain.filter(any())).thenReturn(Mono.empty().then());
Mono<Void> result = filter.filter(exchange, chain);
@ -102,8 +112,7 @@ class InitializeRedirectionWebFilterTest {
.expectComplete()
.verify();
verify(serverRedirectStrategy, never()).sendRedirect(eq(exchange),
eq(URI.create("/console")));
verify(serverRedirectStrategy, never()).sendRedirect(eq(exchange), any());
verify(chain).filter(eq(exchange));
}
}

View File

@ -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
""");
}
}