mirror of https://github.com/halo-dev/halo
Initialize default theme when Halo starts up for the first time (#2704)
#### What type of PR is this? /kind feature /area core /milestone 2.0 #### What this PR does / why we need it: 1. Initialize default theme when we detect the theme root has no themes here. This process won't stop Halo starting up if error occurs. 2. Refactor ThemeEndpoint with ThemeService to make it reusable. Default theme configuration is as following: ```yaml halo: theme: initializer: disabled: false location: classpath:themes/theme-earth.zip ``` #### Which issue(s) this PR fixes: Fixes https://github.com/halo-dev/halo/issues/2700 #### Special notes for your reviewer: Steps to test: 1. Delete all themes at console if installed 2. Restart Halo and check the log 4. Check the theme root folder `~/halo-next/themes` 5. Try to access index page and you will see the default theme #### Does this PR introduce a user-facing change? ```release-note 在首次启动 Halo 时初始化默认主题 ```pull/2707/head
parent
04a7b67fe9
commit
0c8ccecdf3
|
@ -72,4 +72,5 @@ application-local.properties
|
||||||
|
|
||||||
### Zip file for test
|
### Zip file for test
|
||||||
!src/test/resources/themes/*.zip
|
!src/test/resources/themes/*.zip
|
||||||
|
!src/main/resources/themes/*.zip
|
||||||
src/main/resources/console/
|
src/main/resources/console/
|
||||||
|
|
|
@ -1,20 +1,13 @@
|
||||||
package run.halo.app.core.extension.theme;
|
package run.halo.app.core.extension.theme;
|
||||||
|
|
||||||
import static java.nio.file.Files.createTempDirectory;
|
|
||||||
import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder;
|
import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder;
|
||||||
import static org.springdoc.core.fn.builders.content.Builder.contentBuilder;
|
import static org.springdoc.core.fn.builders.content.Builder.contentBuilder;
|
||||||
import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder;
|
import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder;
|
||||||
import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder;
|
import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder;
|
||||||
import static org.springdoc.core.fn.builders.schema.Builder.schemaBuilder;
|
import static org.springdoc.core.fn.builders.schema.Builder.schemaBuilder;
|
||||||
import static org.springframework.util.FileSystemUtils.copyRecursively;
|
|
||||||
import static org.springframework.web.reactive.function.server.RequestPredicates.contentType;
|
import static org.springframework.web.reactive.function.server.RequestPredicates.contentType;
|
||||||
import static reactor.core.scheduler.Schedulers.boundedElastic;
|
|
||||||
import static run.halo.app.core.extension.theme.ThemeUtils.loadThemeManifest;
|
import static run.halo.app.core.extension.theme.ThemeUtils.loadThemeManifest;
|
||||||
import static run.halo.app.core.extension.theme.ThemeUtils.locateThemeManifest;
|
|
||||||
import static run.halo.app.core.extension.theme.ThemeUtils.unzipThemeTo;
|
|
||||||
import static run.halo.app.infra.utils.DataBufferUtils.toInputStream;
|
import static run.halo.app.infra.utils.DataBufferUtils.toInputStream;
|
||||||
import static run.halo.app.infra.utils.FileUtils.deleteRecursivelyAndSilently;
|
|
||||||
import static run.halo.app.infra.utils.FileUtils.unzip;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.enums.ParameterIn;
|
import io.swagger.v3.oas.annotations.enums.ParameterIn;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
@ -22,12 +15,7 @@ import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.time.Duration;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
|
||||||
import java.util.function.Predicate;
|
|
||||||
import java.util.zip.ZipInputStream;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
|
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
|
||||||
|
@ -35,32 +23,24 @@ import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.codec.multipart.FilePart;
|
import org.springframework.http.codec.multipart.FilePart;
|
||||||
import org.springframework.http.codec.multipart.Part;
|
import org.springframework.http.codec.multipart.Part;
|
||||||
import org.springframework.lang.NonNull;
|
import org.springframework.lang.NonNull;
|
||||||
import org.springframework.retry.RetryException;
|
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.util.Assert;
|
|
||||||
import org.springframework.util.MultiValueMap;
|
import org.springframework.util.MultiValueMap;
|
||||||
import org.springframework.web.reactive.function.BodyExtractors;
|
import org.springframework.web.reactive.function.BodyExtractors;
|
||||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||||
import org.springframework.web.server.ServerErrorException;
|
|
||||||
import org.springframework.web.server.ServerWebInputException;
|
import org.springframework.web.server.ServerWebInputException;
|
||||||
import reactor.core.Exceptions;
|
import reactor.core.Exceptions;
|
||||||
import reactor.core.publisher.Flux;
|
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
import reactor.util.retry.Retry;
|
|
||||||
import run.halo.app.core.extension.Setting;
|
import run.halo.app.core.extension.Setting;
|
||||||
import run.halo.app.core.extension.Theme;
|
import run.halo.app.core.extension.Theme;
|
||||||
import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
||||||
import run.halo.app.extension.ConfigMap;
|
|
||||||
import run.halo.app.extension.ListResult;
|
import run.halo.app.extension.ListResult;
|
||||||
import run.halo.app.extension.ReactiveExtensionClient;
|
import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
import run.halo.app.extension.Unstructured;
|
import run.halo.app.extension.Unstructured;
|
||||||
import run.halo.app.extension.router.IListRequest;
|
import run.halo.app.extension.router.IListRequest;
|
||||||
import run.halo.app.extension.router.QueryParamBuildUtil;
|
import run.halo.app.extension.router.QueryParamBuildUtil;
|
||||||
import run.halo.app.infra.exception.ThemeInstallationException;
|
import run.halo.app.infra.ThemeRootGetter;
|
||||||
import run.halo.app.infra.properties.HaloProperties;
|
|
||||||
import run.halo.app.theme.ThemePathPolicy;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Endpoint for managing themes.
|
* Endpoint for managing themes.
|
||||||
|
@ -73,13 +53,16 @@ import run.halo.app.theme.ThemePathPolicy;
|
||||||
public class ThemeEndpoint implements CustomEndpoint {
|
public class ThemeEndpoint implements CustomEndpoint {
|
||||||
|
|
||||||
private final ReactiveExtensionClient client;
|
private final ReactiveExtensionClient client;
|
||||||
private final HaloProperties haloProperties;
|
|
||||||
private final ThemePathPolicy themePathPolicy;
|
|
||||||
|
|
||||||
public ThemeEndpoint(ReactiveExtensionClient client, HaloProperties haloProperties) {
|
private final ThemeRootGetter themeRoot;
|
||||||
|
|
||||||
|
private final ThemeService themeService;
|
||||||
|
|
||||||
|
public ThemeEndpoint(ReactiveExtensionClient client, ThemeRootGetter themeRoot,
|
||||||
|
ThemeService themeService) {
|
||||||
this.client = client;
|
this.client = client;
|
||||||
this.haloProperties = haloProperties;
|
this.themeRoot = themeRoot;
|
||||||
this.themePathPolicy = new ThemePathPolicy(haloProperties.getWorkDir());
|
this.themeService = themeService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -147,6 +130,7 @@ public class ThemeEndpoint implements CustomEndpoint {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO Extract the method into ThemeService
|
||||||
Mono<ServerResponse> listThemes(ServerRequest request) {
|
Mono<ServerResponse> listThemes(ServerRequest request) {
|
||||||
MultiValueMap<String, String> queryParams = request.queryParams();
|
MultiValueMap<String, String> queryParams = request.queryParams();
|
||||||
ThemeQuery query = new ThemeQuery(queryParams);
|
ThemeQuery query = new ThemeQuery(queryParams);
|
||||||
|
@ -188,69 +172,24 @@ public class ThemeEndpoint implements CustomEndpoint {
|
||||||
}
|
}
|
||||||
|
|
||||||
private Mono<ServerResponse> upgrade(ServerRequest request) {
|
private Mono<ServerResponse> upgrade(ServerRequest request) {
|
||||||
var themeNameInPath = request.pathVariable("name");
|
|
||||||
final var tempDir = new AtomicReference<Path>();
|
|
||||||
final var tempThemeRoot = new AtomicReference<Path>();
|
|
||||||
// validate the theme first
|
// validate the theme first
|
||||||
return client.fetch(Theme.class, themeNameInPath)
|
var themeNameInPath = request.pathVariable("name");
|
||||||
.switchIfEmpty(Mono.error(() -> new ServerWebInputException(
|
return request.multipartData()
|
||||||
"The given theme with name " + themeNameInPath + " does not exist")))
|
|
||||||
.then(request.multipartData())
|
|
||||||
.map(UpgradeRequest::new)
|
.map(UpgradeRequest::new)
|
||||||
.map(UpgradeRequest::getFile)
|
.map(UpgradeRequest::getFile)
|
||||||
.publishOn(boundedElastic())
|
|
||||||
.flatMap(file -> {
|
.flatMap(file -> {
|
||||||
try (var zis = new ZipInputStream(toInputStream(file.content()))) {
|
try (var inputStream = toInputStream(file.content())) {
|
||||||
tempDir.set(createTempDirectory("halo-theme-"));
|
return themeService.upgrade(themeNameInPath, inputStream);
|
||||||
unzip(zis, tempDir.get());
|
|
||||||
return locateThemeManifest(tempDir.get());
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
return Mono.error(Exceptions.propagate(e));
|
return Mono.error(e);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.switchIfEmpty(Mono.error(() -> new ThemeInstallationException(
|
|
||||||
"Missing theme manifest file: theme.yaml or theme.yml")))
|
|
||||||
.doOnNext(themeManifest -> {
|
|
||||||
if (log.isDebugEnabled()) {
|
|
||||||
log.debug("Found theme manifest file: {}", themeManifest);
|
|
||||||
}
|
|
||||||
tempThemeRoot.set(themeManifest.getParent());
|
|
||||||
})
|
|
||||||
.map(ThemeUtils::loadThemeManifest)
|
|
||||||
.doOnNext(newTheme -> {
|
|
||||||
if (!Objects.equals(themeNameInPath, newTheme.getMetadata().getName())) {
|
|
||||||
if (log.isDebugEnabled()) {
|
|
||||||
log.error("Want theme name: {}, but provided: {}", themeNameInPath,
|
|
||||||
newTheme.getMetadata().getName());
|
|
||||||
}
|
|
||||||
throw new ServerWebInputException("please make sure the theme name is correct");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.flatMap(newTheme -> {
|
|
||||||
// Remove the theme before upgrading
|
|
||||||
return deleteThemeAndWaitForComplete(newTheme.getMetadata().getName())
|
|
||||||
.thenReturn(newTheme);
|
|
||||||
})
|
|
||||||
.doOnNext(newTheme -> {
|
|
||||||
// prepare the theme
|
|
||||||
var themePath = getThemeWorkDir().resolve(newTheme.getMetadata().getName());
|
|
||||||
try {
|
|
||||||
copyRecursively(tempThemeRoot.get(), themePath);
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw Exceptions.propagate(e);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.flatMap(this::persistent)
|
|
||||||
.flatMap(updatedTheme -> ServerResponse.ok()
|
.flatMap(updatedTheme -> ServerResponse.ok()
|
||||||
.bodyValue(updatedTheme))
|
.bodyValue(updatedTheme));
|
||||||
.doFinally(signalType -> {
|
|
||||||
// clear the temporary folder
|
|
||||||
deleteRecursivelyAndSilently(tempDir.get());
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Mono<ListResult<Theme>> listUninstalled(ThemeQuery query) {
|
Mono<ListResult<Theme>> listUninstalled(ThemeQuery query) {
|
||||||
Path path = themePathPolicy.themesDir();
|
Path path = themeRoot.get();
|
||||||
return ThemeUtils.listAllThemesFromThemeDir(path)
|
return ThemeUtils.listAllThemesFromThemeDir(path)
|
||||||
.collectList()
|
.collectList()
|
||||||
.flatMap(this::filterUnInstalledThemes)
|
.flatMap(this::filterUnInstalledThemes)
|
||||||
|
@ -272,6 +211,7 @@ public class ThemeEndpoint implements CustomEndpoint {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO Extract the method into ThemeService
|
||||||
Mono<ServerResponse> reloadSetting(ServerRequest request) {
|
Mono<ServerResponse> reloadSetting(ServerRequest request) {
|
||||||
String name = request.pathVariable("name");
|
String name = request.pathVariable("name");
|
||||||
return client.fetch(Theme.class, name)
|
return client.fetch(Theme.class, name)
|
||||||
|
@ -296,7 +236,7 @@ public class ThemeEndpoint implements CustomEndpoint {
|
||||||
.orElse(Mono.just(theme));
|
.orElse(Mono.just(theme));
|
||||||
})
|
})
|
||||||
.flatMap(themeToUse -> {
|
.flatMap(themeToUse -> {
|
||||||
Path themePath = themePathPolicy.generate(themeToUse);
|
Path themePath = themeRoot.get().resolve(themeToUse.getMetadata().getName());
|
||||||
Path themeManifestPath = ThemeUtils.resolveThemeManifest(themePath);
|
Path themeManifestPath = ThemeUtils.resolveThemeManifest(themePath);
|
||||||
if (themeManifestPath == null) {
|
if (themeManifestPath == null) {
|
||||||
return Mono.error(new IllegalArgumentException(
|
return Mono.error(new IllegalArgumentException(
|
||||||
|
@ -322,85 +262,22 @@ public class ThemeEndpoint implements CustomEndpoint {
|
||||||
.flatMap(file -> {
|
.flatMap(file -> {
|
||||||
try {
|
try {
|
||||||
var is = toInputStream(file.content());
|
var is = toInputStream(file.content());
|
||||||
var themeWorkDir = getThemeWorkDir();
|
return themeService.install(is);
|
||||||
if (log.isDebugEnabled()) {
|
|
||||||
log.debug("Transferring {} into {}", file.filename(), themeWorkDir);
|
|
||||||
}
|
|
||||||
return unzipThemeTo(is, themeWorkDir);
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
return Mono.error(Exceptions.propagate(e));
|
return Mono.error(Exceptions.propagate(e));
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.flatMap(this::persistent)
|
|
||||||
.flatMap(theme -> ServerResponse.ok()
|
.flatMap(theme -> ServerResponse.ok()
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.bodyValue(theme));
|
.bodyValue(theme));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates theme manifest and related unstructured resources.
|
|
||||||
* TODO: In case of failure in saving midway, the problem of data consistency needs to be
|
|
||||||
* solved.
|
|
||||||
*
|
|
||||||
* @param themeManifest the theme custom model
|
|
||||||
* @return a theme custom model
|
|
||||||
* @see Theme
|
|
||||||
*/
|
|
||||||
public Mono<Theme> persistent(Unstructured themeManifest) {
|
|
||||||
Assert.state(StringUtils.equals(Theme.KIND, themeManifest.getKind()),
|
|
||||||
"Theme manifest kind must be Theme.");
|
|
||||||
return client.create(themeManifest)
|
|
||||||
.map(theme -> Unstructured.OBJECT_MAPPER.convertValue(theme, Theme.class))
|
|
||||||
.flatMap(theme -> {
|
|
||||||
var unstructureds = ThemeUtils.loadThemeResources(getThemePath(theme));
|
|
||||||
if (unstructureds.stream()
|
|
||||||
.filter(hasSettingsYaml(theme))
|
|
||||||
.count() > 1) {
|
|
||||||
return Mono.error(new IllegalStateException(
|
|
||||||
"Theme must only have one settings.yaml or settings.yml."));
|
|
||||||
}
|
|
||||||
if (unstructureds.stream()
|
|
||||||
.filter(hasConfigYaml(theme))
|
|
||||||
.count() > 1) {
|
|
||||||
return Mono.error(new IllegalStateException(
|
|
||||||
"Theme must only have one config.yaml or config.yml."));
|
|
||||||
}
|
|
||||||
return Flux.fromIterable(unstructureds)
|
|
||||||
.flatMap(unstructured -> {
|
|
||||||
var spec = theme.getSpec();
|
|
||||||
String name = unstructured.getMetadata().getName();
|
|
||||||
|
|
||||||
boolean isThemeSetting = unstructured.getKind().equals(Setting.KIND)
|
|
||||||
&& StringUtils.equals(spec.getSettingName(), name);
|
|
||||||
|
|
||||||
boolean isThemeConfig = unstructured.getKind().equals(ConfigMap.KIND)
|
|
||||||
&& StringUtils.equals(spec.getConfigMapName(), name);
|
|
||||||
if (isThemeSetting || isThemeConfig) {
|
|
||||||
return client.create(unstructured);
|
|
||||||
}
|
|
||||||
return Mono.empty();
|
|
||||||
})
|
|
||||||
.then(Mono.just(theme));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private Path getThemePath(Theme theme) {
|
private Path getThemePath(Theme theme) {
|
||||||
return getThemeWorkDir().resolve(theme.getMetadata().getName());
|
return getThemeWorkDir().resolve(theme.getMetadata().getName());
|
||||||
}
|
}
|
||||||
|
|
||||||
private Predicate<Unstructured> hasSettingsYaml(Theme theme) {
|
|
||||||
return unstructured -> Setting.KIND.equals(unstructured.getKind())
|
|
||||||
&& theme.getSpec().getSettingName().equals(unstructured.getMetadata().getName());
|
|
||||||
}
|
|
||||||
|
|
||||||
private Predicate<Unstructured> hasConfigYaml(Theme theme) {
|
|
||||||
return unstructured -> ConfigMap.KIND.equals(unstructured.getKind())
|
|
||||||
&& theme.getSpec().getConfigMapName().equals(unstructured.getMetadata().getName());
|
|
||||||
}
|
|
||||||
|
|
||||||
private Path getThemeWorkDir() {
|
private Path getThemeWorkDir() {
|
||||||
Path themePath = haloProperties.getWorkDir()
|
Path themePath = themeRoot.get();
|
||||||
.resolve("themes");
|
|
||||||
if (Files.notExists(themePath)) {
|
if (Files.notExists(themePath)) {
|
||||||
try {
|
try {
|
||||||
Files.createDirectories(themePath);
|
Files.createDirectories(themePath);
|
||||||
|
@ -425,22 +302,4 @@ public class ThemeEndpoint implements CustomEndpoint {
|
||||||
return Mono.just(file);
|
return Mono.just(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
Mono<Theme> deleteThemeAndWaitForComplete(String themeName) {
|
|
||||||
return client.fetch(Theme.class, themeName)
|
|
||||||
.flatMap(client::delete)
|
|
||||||
.flatMap(deletingTheme -> waitForThemeDeleted(themeName)
|
|
||||||
.thenReturn(deletingTheme));
|
|
||||||
}
|
|
||||||
|
|
||||||
Mono<Void> waitForThemeDeleted(String themeName) {
|
|
||||||
return client.fetch(Theme.class, themeName)
|
|
||||||
.doOnNext(theme -> {
|
|
||||||
throw new RetryException("Re-check if the theme is deleted successfully");
|
|
||||||
})
|
|
||||||
.retryWhen(Retry.fixedDelay(20, Duration.ofMillis(100))
|
|
||||||
.filter(t -> t instanceof RetryException))
|
|
||||||
.onErrorMap(Exceptions::isRetryExhausted,
|
|
||||||
throwable -> new ServerErrorException("Wait timeout for theme deleted", throwable))
|
|
||||||
.then();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
package run.halo.app.core.extension.theme;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import run.halo.app.core.extension.Theme;
|
||||||
|
|
||||||
|
public interface ThemeService {
|
||||||
|
|
||||||
|
Mono<Theme> install(InputStream is);
|
||||||
|
|
||||||
|
Mono<Theme> upgrade(String themeName, InputStream is);
|
||||||
|
|
||||||
|
// TODO Migrate other useful methods in ThemeEndpoint in the future.
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,198 @@
|
||||||
|
package run.halo.app.core.extension.theme;
|
||||||
|
|
||||||
|
import static java.nio.file.Files.createTempDirectory;
|
||||||
|
import static org.springframework.util.FileSystemUtils.copyRecursively;
|
||||||
|
import static run.halo.app.core.extension.theme.ThemeUtils.locateThemeManifest;
|
||||||
|
import static run.halo.app.infra.utils.FileUtils.deleteRecursivelyAndSilently;
|
||||||
|
import static run.halo.app.infra.utils.FileUtils.unzip;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import java.util.function.Predicate;
|
||||||
|
import java.util.zip.ZipInputStream;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.springframework.retry.RetryException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.Assert;
|
||||||
|
import org.springframework.web.server.ServerErrorException;
|
||||||
|
import org.springframework.web.server.ServerWebInputException;
|
||||||
|
import reactor.core.Exceptions;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.core.scheduler.Schedulers;
|
||||||
|
import reactor.util.retry.Retry;
|
||||||
|
import run.halo.app.core.extension.Setting;
|
||||||
|
import run.halo.app.core.extension.Theme;
|
||||||
|
import run.halo.app.extension.ConfigMap;
|
||||||
|
import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
|
import run.halo.app.extension.Unstructured;
|
||||||
|
import run.halo.app.infra.ThemeRootGetter;
|
||||||
|
import run.halo.app.infra.exception.ThemeInstallationException;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class ThemeServiceImpl implements ThemeService {
|
||||||
|
|
||||||
|
private final ReactiveExtensionClient client;
|
||||||
|
|
||||||
|
private final ThemeRootGetter themeRoot;
|
||||||
|
|
||||||
|
public ThemeServiceImpl(ReactiveExtensionClient client, ThemeRootGetter themeRoot) {
|
||||||
|
this.client = client;
|
||||||
|
this.themeRoot = themeRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Theme> install(InputStream is) {
|
||||||
|
var themeRoot = this.themeRoot.get();
|
||||||
|
return ThemeUtils.unzipThemeTo(is, themeRoot)
|
||||||
|
.flatMap(this::persistent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Theme> upgrade(String themeName, InputStream is) {
|
||||||
|
var tempDir = new AtomicReference<Path>();
|
||||||
|
var tempThemeRoot = new AtomicReference<Path>();
|
||||||
|
return client.fetch(Theme.class, themeName)
|
||||||
|
.switchIfEmpty(Mono.error(() -> new ServerWebInputException(
|
||||||
|
"The given theme with name " + themeName + " did not exist")))
|
||||||
|
.publishOn(Schedulers.boundedElastic())
|
||||||
|
.doFirst(() -> {
|
||||||
|
try {
|
||||||
|
tempDir.set(createTempDirectory("halo-theme-"));
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw Exceptions.propagate(e);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.flatMap(oldTheme -> {
|
||||||
|
try (var zis = new ZipInputStream(is)) {
|
||||||
|
unzip(zis, tempDir.get());
|
||||||
|
return locateThemeManifest(tempDir.get())
|
||||||
|
.switchIfEmpty(Mono.error(() -> new ThemeInstallationException(
|
||||||
|
"Missing theme manifest file: theme.yaml or theme.yml")));
|
||||||
|
} catch (IOException e) {
|
||||||
|
return Mono.error(e);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.doOnNext(themeManifest -> {
|
||||||
|
if (log.isDebugEnabled()) {
|
||||||
|
log.debug("Found theme manifest file: {}", themeManifest);
|
||||||
|
}
|
||||||
|
tempThemeRoot.set(themeManifest.getParent());
|
||||||
|
})
|
||||||
|
.map(ThemeUtils::loadThemeManifest)
|
||||||
|
.doOnNext(newTheme -> {
|
||||||
|
if (!Objects.equals(themeName, newTheme.getMetadata().getName())) {
|
||||||
|
if (log.isDebugEnabled()) {
|
||||||
|
log.error("Want theme name: {}, but provided: {}", themeName,
|
||||||
|
newTheme.getMetadata().getName());
|
||||||
|
}
|
||||||
|
throw new ServerWebInputException("please make sure the theme name is correct");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.flatMap(newTheme -> {
|
||||||
|
// Remove the theme before upgrading
|
||||||
|
return deleteThemeAndWaitForComplete(newTheme.getMetadata().getName())
|
||||||
|
.thenReturn(newTheme);
|
||||||
|
})
|
||||||
|
.doOnNext(newTheme -> {
|
||||||
|
// prepare the theme
|
||||||
|
var themePath = themeRoot.get().resolve(newTheme.getMetadata().getName());
|
||||||
|
try {
|
||||||
|
copyRecursively(tempThemeRoot.get(), themePath);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw Exceptions.propagate(e);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.flatMap(this::persistent)
|
||||||
|
.doFinally(signalType -> {
|
||||||
|
// clear the temporary folder
|
||||||
|
deleteRecursivelyAndSilently(tempDir.get());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates theme manifest and related unstructured resources.
|
||||||
|
* TODO: In case of failure in saving midway, the problem of data consistency needs to be
|
||||||
|
* solved.
|
||||||
|
*
|
||||||
|
* @param themeManifest the theme custom model
|
||||||
|
* @return a theme custom model
|
||||||
|
* @see Theme
|
||||||
|
*/
|
||||||
|
public Mono<Theme> persistent(Unstructured themeManifest) {
|
||||||
|
Assert.state(StringUtils.equals(Theme.KIND, themeManifest.getKind()),
|
||||||
|
"Theme manifest kind must be Theme.");
|
||||||
|
return client.create(themeManifest)
|
||||||
|
.map(theme -> Unstructured.OBJECT_MAPPER.convertValue(theme, Theme.class))
|
||||||
|
.flatMap(theme -> {
|
||||||
|
var unstructureds = ThemeUtils.loadThemeResources(getThemePath(theme));
|
||||||
|
if (unstructureds.stream()
|
||||||
|
.filter(hasSettingsYaml(theme))
|
||||||
|
.count() > 1) {
|
||||||
|
return Mono.error(new IllegalStateException(
|
||||||
|
"Theme must only have one settings.yaml or settings.yml."));
|
||||||
|
}
|
||||||
|
if (unstructureds.stream()
|
||||||
|
.filter(hasConfigYaml(theme))
|
||||||
|
.count() > 1) {
|
||||||
|
return Mono.error(new IllegalStateException(
|
||||||
|
"Theme must only have one config.yaml or config.yml."));
|
||||||
|
}
|
||||||
|
return Flux.fromIterable(unstructureds)
|
||||||
|
.flatMap(unstructured -> {
|
||||||
|
var spec = theme.getSpec();
|
||||||
|
String name = unstructured.getMetadata().getName();
|
||||||
|
|
||||||
|
boolean isThemeSetting = unstructured.getKind().equals(Setting.KIND)
|
||||||
|
&& StringUtils.equals(spec.getSettingName(), name);
|
||||||
|
|
||||||
|
boolean isThemeConfig = unstructured.getKind().equals(ConfigMap.KIND)
|
||||||
|
&& StringUtils.equals(spec.getConfigMapName(), name);
|
||||||
|
if (isThemeSetting || isThemeConfig) {
|
||||||
|
return client.create(unstructured);
|
||||||
|
}
|
||||||
|
return Mono.empty();
|
||||||
|
})
|
||||||
|
.then(Mono.just(theme));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path getThemePath(Theme theme) {
|
||||||
|
return themeRoot.get().resolve(theme.getMetadata().getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Predicate<Unstructured> hasSettingsYaml(Theme theme) {
|
||||||
|
return unstructured -> Setting.KIND.equals(unstructured.getKind())
|
||||||
|
&& theme.getSpec().getSettingName().equals(unstructured.getMetadata().getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Predicate<Unstructured> hasConfigYaml(Theme theme) {
|
||||||
|
return unstructured -> ConfigMap.KIND.equals(unstructured.getKind())
|
||||||
|
&& theme.getSpec().getConfigMapName().equals(unstructured.getMetadata().getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
Mono<Theme> deleteThemeAndWaitForComplete(String themeName) {
|
||||||
|
return client.fetch(Theme.class, themeName)
|
||||||
|
.flatMap(client::delete)
|
||||||
|
.flatMap(deletingTheme -> waitForThemeDeleted(themeName)
|
||||||
|
.thenReturn(deletingTheme));
|
||||||
|
}
|
||||||
|
|
||||||
|
Mono<Void> waitForThemeDeleted(String themeName) {
|
||||||
|
return client.fetch(Theme.class, themeName)
|
||||||
|
.doOnNext(theme -> {
|
||||||
|
throw new RetryException("Re-check if the theme is deleted successfully");
|
||||||
|
})
|
||||||
|
.retryWhen(Retry.fixedDelay(20, Duration.ofMillis(100))
|
||||||
|
.filter(t -> t instanceof RetryException))
|
||||||
|
.onErrorMap(Exceptions::isRetryExhausted,
|
||||||
|
throwable -> new ServerErrorException("Wait timeout for theme deleted", throwable))
|
||||||
|
.then();
|
||||||
|
}
|
||||||
|
}
|
|
@ -91,22 +91,27 @@ class ThemeUtils {
|
||||||
|
|
||||||
static Mono<Unstructured> unzipThemeTo(InputStream inputStream, Path themeWorkDir,
|
static Mono<Unstructured> unzipThemeTo(InputStream inputStream, Path themeWorkDir,
|
||||||
boolean override) {
|
boolean override) {
|
||||||
AtomicReference<Path> tempDir = new AtomicReference<>();
|
var tempDir = new AtomicReference<Path>();
|
||||||
return Mono.fromCallable(
|
return Mono.just(inputStream)
|
||||||
() -> {
|
.publishOn(Schedulers.boundedElastic())
|
||||||
Path tempDirectory = null;
|
.doFirst(() -> {
|
||||||
Path themeTargetPath = null;
|
try {
|
||||||
try (ZipInputStream zipInputStream = new ZipInputStream(inputStream)) {
|
tempDir.set(createTempDirectory(THEME_TMP_PREFIX));
|
||||||
tempDirectory = createTempDirectory(THEME_TMP_PREFIX);
|
|
||||||
unzip(zipInputStream, tempDirectory);
|
|
||||||
return tempDirectory;
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
deleteRecursivelyAndSilently(themeTargetPath);
|
throw Exceptions.propagate(e);
|
||||||
throw new ThemeInstallationException("Unable to install theme", e);
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.doOnNext(tempDir::set)
|
.doOnNext(is -> {
|
||||||
.flatMap(ThemeUtils::locateThemeManifest)
|
try (var zipIs = new ZipInputStream(is)) {
|
||||||
|
// unzip input stream into temporary directory
|
||||||
|
unzip(zipIs, tempDir.get());
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw Exceptions.propagate(e);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.flatMap(is -> ThemeUtils.locateThemeManifest(tempDir.get()))
|
||||||
|
.switchIfEmpty(
|
||||||
|
Mono.error(() -> new ThemeInstallationException("Missing theme manifest")))
|
||||||
.map(themeManifestPath -> {
|
.map(themeManifestPath -> {
|
||||||
var theme = loadThemeManifest(themeManifestPath);
|
var theme = loadThemeManifest(themeManifestPath);
|
||||||
var themeName = theme.getMetadata().getName();
|
var themeName = theme.getMetadata().getName();
|
||||||
|
@ -123,8 +128,7 @@ class ThemeUtils {
|
||||||
throw Exceptions.propagate(e);
|
throw Exceptions.propagate(e);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.doFinally(signalType -> deleteRecursivelyAndSilently(tempDir.get()))
|
.doFinally(signalType -> deleteRecursivelyAndSilently(tempDir.get()));
|
||||||
.subscribeOn(Schedulers.boundedElastic());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static Unstructured loadThemeManifest(Path themeManifestPath) {
|
static Unstructured loadThemeManifest(Path themeManifestPath) {
|
||||||
|
|
|
@ -0,0 +1,65 @@
|
||||||
|
package run.halo.app.infra;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URISyntaxException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.context.ApplicationListener;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.ResourceUtils;
|
||||||
|
import run.halo.app.core.extension.theme.ThemeService;
|
||||||
|
import run.halo.app.infra.properties.HaloProperties;
|
||||||
|
import run.halo.app.infra.properties.ThemeProperties;
|
||||||
|
import run.halo.app.infra.utils.FileUtils;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class DefaultThemeInitializer implements ApplicationListener<SchemeInitializedEvent> {
|
||||||
|
|
||||||
|
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(SchemeInitializedEvent 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 defaultThemeUri = ResourceUtils.getURL(location).toURI();
|
||||||
|
var latch = new CountDownLatch(1);
|
||||||
|
themeService.install(Files.newInputStream(Path.of(defaultThemeUri)))
|
||||||
|
.doFinally(signalType -> latch.countDown())
|
||||||
|
.subscribe(theme -> log.info("Initialized default theme: {}",
|
||||||
|
theme.getMetadata().getName()));
|
||||||
|
latch.await();
|
||||||
|
// Because default active theme is default, we don't need to enabled it manually.
|
||||||
|
} catch (IOException | URISyntaxException | InterruptedException e) {
|
||||||
|
// we should skip the initialization error at here
|
||||||
|
log.warn("Failed to initialize theme from " + location, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
package run.halo.app.infra;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import run.halo.app.infra.properties.HaloProperties;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class DefaultThemeRootGetter implements ThemeRootGetter {
|
||||||
|
|
||||||
|
private final HaloProperties haloProps;
|
||||||
|
|
||||||
|
public DefaultThemeRootGetter(HaloProperties haloProps) {
|
||||||
|
this.haloProps = haloProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Path get() {
|
||||||
|
return haloProps.getWorkDir().resolve("themes");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
package run.halo.app.infra;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ThemeRootGetter allows us to get root path of themes.
|
||||||
|
*
|
||||||
|
* @author johnniang
|
||||||
|
*/
|
||||||
|
public interface ThemeRootGetter extends Supplier<Path> {
|
||||||
|
|
||||||
|
}
|
|
@ -42,4 +42,7 @@ public class HaloProperties {
|
||||||
|
|
||||||
@Valid
|
@Valid
|
||||||
private final ConsoleProperties console = new ConsoleProperties();
|
private final ConsoleProperties console = new ConsoleProperties();
|
||||||
|
|
||||||
|
@Valid
|
||||||
|
private final ThemeProperties theme = new ThemeProperties();
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
package run.halo.app.infra.properties;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class ThemeProperties {
|
||||||
|
|
||||||
|
@Valid
|
||||||
|
private final Initializer initializer = new Initializer();
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class Initializer {
|
||||||
|
|
||||||
|
private boolean disabled = false;
|
||||||
|
|
||||||
|
private String location = "classpath:themes/theme-earth.zip";
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -9,7 +9,9 @@ import run.halo.app.core.extension.Theme;
|
||||||
*
|
*
|
||||||
* @author guqing
|
* @author guqing
|
||||||
* @since 2.0.0
|
* @since 2.0.0
|
||||||
|
* @deprecated Use {@code run.halo.app.infra.ThemeRootGetter}
|
||||||
*/
|
*/
|
||||||
|
@Deprecated(forRemoval = true)
|
||||||
public class ThemePathPolicy {
|
public class ThemePathPolicy {
|
||||||
public static final String THEME_WORK_DIR = "themes";
|
public static final String THEME_WORK_DIR = "themes";
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@ metadata:
|
||||||
data:
|
data:
|
||||||
theme: |
|
theme: |
|
||||||
{
|
{
|
||||||
"active": "default"
|
"active": "theme-earth"
|
||||||
}
|
}
|
||||||
routeRules: |
|
routeRules: |
|
||||||
{
|
{
|
||||||
|
|
Binary file not shown.
|
@ -2,15 +2,17 @@ package run.halo.app.core.extension.theme;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.ArgumentMatchers.isA;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
import static org.mockito.Mockito.times;
|
import static org.mockito.Mockito.times;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
import static org.springframework.web.reactive.function.BodyInserters.fromMultipartData;
|
import static org.springframework.web.reactive.function.BodyInserters.fromMultipartData;
|
||||||
import static run.halo.app.extension.Unstructured.OBJECT_MAPPER;
|
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
@ -21,6 +23,7 @@ import org.junit.jupiter.api.Nested;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.skyscreamer.jsonassert.JSONAssert;
|
import org.skyscreamer.jsonassert.JSONAssert;
|
||||||
|
@ -30,16 +33,15 @@ import org.springframework.http.client.MultipartBodyBuilder;
|
||||||
import org.springframework.test.web.reactive.server.WebTestClient;
|
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||||
import org.springframework.util.FileSystemUtils;
|
import org.springframework.util.FileSystemUtils;
|
||||||
import org.springframework.util.ResourceUtils;
|
import org.springframework.util.ResourceUtils;
|
||||||
|
import org.springframework.web.server.ServerWebInputException;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
import run.halo.app.core.extension.Setting;
|
import run.halo.app.core.extension.Setting;
|
||||||
import run.halo.app.core.extension.Theme;
|
import run.halo.app.core.extension.Theme;
|
||||||
import run.halo.app.extension.AbstractExtension;
|
import run.halo.app.extension.AbstractExtension;
|
||||||
import run.halo.app.extension.Metadata;
|
import run.halo.app.extension.Metadata;
|
||||||
import run.halo.app.extension.ReactiveExtensionClient;
|
import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
import run.halo.app.extension.Unstructured;
|
import run.halo.app.infra.ThemeRootGetter;
|
||||||
import run.halo.app.infra.properties.HaloProperties;
|
|
||||||
import run.halo.app.infra.utils.JsonUtils;
|
import run.halo.app.infra.utils.JsonUtils;
|
||||||
import run.halo.app.infra.utils.YamlUnstructuredLoader;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests for {@link ThemeEndpoint}.
|
* Tests for {@link ThemeEndpoint}.
|
||||||
|
@ -51,10 +53,16 @@ import run.halo.app.infra.utils.YamlUnstructuredLoader;
|
||||||
class ThemeEndpointTest {
|
class ThemeEndpointTest {
|
||||||
|
|
||||||
@Mock
|
@Mock
|
||||||
private ReactiveExtensionClient extensionClient;
|
ReactiveExtensionClient extensionClient;
|
||||||
|
|
||||||
@Mock
|
@Mock
|
||||||
private HaloProperties haloProperties;
|
ThemeRootGetter themeRoot;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
ThemeService themeService;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
ThemeEndpoint themeEndpoint;
|
||||||
|
|
||||||
private Path tmpHaloWorkDir;
|
private Path tmpHaloWorkDir;
|
||||||
|
|
||||||
|
@ -64,21 +72,17 @@ class ThemeEndpointTest {
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() throws IOException {
|
void setUp() throws IOException {
|
||||||
tmpHaloWorkDir = Files.createTempDirectory("halo-unit-test");
|
tmpHaloWorkDir = Files.createTempDirectory("halo-theme-endpoint-test");
|
||||||
when(haloProperties.getWorkDir()).thenReturn(tmpHaloWorkDir);
|
lenient().when(themeRoot.get()).thenReturn(tmpHaloWorkDir);
|
||||||
|
|
||||||
ThemeEndpoint themeEndpoint = new ThemeEndpoint(extensionClient, haloProperties);
|
|
||||||
|
|
||||||
defaultTheme = ResourceUtils.getFile("classpath:themes/test-theme.zip");
|
defaultTheme = ResourceUtils.getFile("classpath:themes/test-theme.zip");
|
||||||
|
|
||||||
webTestClient = WebTestClient
|
webTestClient = WebTestClient
|
||||||
.bindToRouterFunction(themeEndpoint.endpoint())
|
.bindToRouterFunction(themeEndpoint.endpoint())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@AfterEach
|
@AfterEach
|
||||||
void tearDown() {
|
void tearDown() throws IOException {
|
||||||
FileSystemUtils.deleteRecursively(tmpHaloWorkDir.toFile());
|
FileSystemUtils.deleteRecursively(tmpHaloWorkDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Nested
|
@Nested
|
||||||
|
@ -90,13 +94,17 @@ class ThemeEndpointTest {
|
||||||
bodyBuilder.part("file", new FileSystemResource(defaultTheme))
|
bodyBuilder.part("file", new FileSystemResource(defaultTheme))
|
||||||
.contentType(MediaType.MULTIPART_FORM_DATA);
|
.contentType(MediaType.MULTIPART_FORM_DATA);
|
||||||
|
|
||||||
when(extensionClient.fetch(Theme.class, "invalid-theme")).thenReturn(Mono.empty());
|
when(themeService.upgrade(eq("invalid-missing-manifest"), isA(InputStream.class)))
|
||||||
|
.thenReturn(
|
||||||
|
Mono.error(() -> new ServerWebInputException("Failed to upgrade theme")));
|
||||||
|
|
||||||
webTestClient.post()
|
webTestClient.post()
|
||||||
.uri("/themes/invalid-theme/upgrade")
|
.uri("/themes/invalid-missing-manifest/upgrade")
|
||||||
.body(fromMultipartData(bodyBuilder.build()))
|
.body(fromMultipartData(bodyBuilder.build()))
|
||||||
.exchange()
|
.exchange()
|
||||||
.expectStatus().isBadRequest();
|
.expectStatus().isBadRequest();
|
||||||
|
|
||||||
|
verify(themeService).upgrade(eq("invalid-missing-manifest"), isA(InputStream.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -105,24 +113,13 @@ class ThemeEndpointTest {
|
||||||
bodyBuilder.part("file", new FileSystemResource(defaultTheme))
|
bodyBuilder.part("file", new FileSystemResource(defaultTheme))
|
||||||
.contentType(MediaType.MULTIPART_FORM_DATA);
|
.contentType(MediaType.MULTIPART_FORM_DATA);
|
||||||
|
|
||||||
var oldTheme = mock(Theme.class);
|
|
||||||
when(extensionClient.fetch(Theme.class, "default"))
|
|
||||||
// for old theme check
|
|
||||||
.thenReturn(Mono.just(oldTheme))
|
|
||||||
// for theme deletion
|
|
||||||
.thenReturn(Mono.just(oldTheme))
|
|
||||||
// for theme deleted check
|
|
||||||
.thenReturn(Mono.empty());
|
|
||||||
|
|
||||||
when(extensionClient.delete(oldTheme)).thenReturn(Mono.just(oldTheme));
|
|
||||||
|
|
||||||
var metadata = new Metadata();
|
var metadata = new Metadata();
|
||||||
metadata.setName("default");
|
metadata.setName("default");
|
||||||
var newTheme = new Theme();
|
var newTheme = new Theme();
|
||||||
newTheme.setMetadata(metadata);
|
newTheme.setMetadata(metadata);
|
||||||
|
|
||||||
when(extensionClient.create(any(Unstructured.class))).thenReturn(
|
when(themeService.upgrade(eq("default"), isA(InputStream.class)))
|
||||||
Mono.just(OBJECT_MAPPER.convertValue(newTheme, Unstructured.class)));
|
.thenReturn(Mono.just(newTheme));
|
||||||
|
|
||||||
webTestClient.post()
|
webTestClient.post()
|
||||||
.uri("/themes/default/upgrade")
|
.uri("/themes/default/upgrade")
|
||||||
|
@ -130,41 +127,36 @@ class ThemeEndpointTest {
|
||||||
.exchange()
|
.exchange()
|
||||||
.expectStatus().isOk();
|
.expectStatus().isOk();
|
||||||
|
|
||||||
verify(extensionClient, times(3)).fetch(Theme.class, "default");
|
verify(themeService).upgrade(eq("default"), isA(InputStream.class));
|
||||||
verify(extensionClient).delete(oldTheme);
|
|
||||||
verify(extensionClient).create(any(Unstructured.class));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void install() {
|
void install() {
|
||||||
when(extensionClient.create(any(Unstructured.class))).thenReturn(
|
var multipartBodyBuilder = new MultipartBodyBuilder();
|
||||||
Mono.fromCallable(() -> {
|
|
||||||
var defaultThemeManifestPath = tmpHaloWorkDir.resolve("themes/default/theme.yaml");
|
|
||||||
assertThat(Files.exists(defaultThemeManifestPath)).isTrue();
|
|
||||||
return new YamlUnstructuredLoader(new FileSystemResource(defaultThemeManifestPath))
|
|
||||||
.load()
|
|
||||||
.get(0);
|
|
||||||
}));
|
|
||||||
|
|
||||||
MultipartBodyBuilder multipartBodyBuilder = new MultipartBodyBuilder();
|
|
||||||
multipartBodyBuilder.part("file", new FileSystemResource(defaultTheme))
|
multipartBodyBuilder.part("file", new FileSystemResource(defaultTheme))
|
||||||
.contentType(MediaType.MULTIPART_FORM_DATA);
|
.contentType(MediaType.MULTIPART_FORM_DATA);
|
||||||
|
|
||||||
|
var installedTheme = new Theme();
|
||||||
|
var metadata = new Metadata();
|
||||||
|
metadata.setName("fake-name");
|
||||||
|
installedTheme.setMetadata(metadata);
|
||||||
|
when(themeService.install(any())).thenReturn(Mono.just(installedTheme));
|
||||||
|
|
||||||
webTestClient.post()
|
webTestClient.post()
|
||||||
.uri("/themes/install")
|
.uri("/themes/install")
|
||||||
.body(fromMultipartData(multipartBodyBuilder.build()))
|
.body(fromMultipartData(multipartBodyBuilder.build()))
|
||||||
.exchange()
|
.exchange()
|
||||||
.expectStatus().isOk()
|
.expectStatus().isOk()
|
||||||
.expectBody(Theme.class)
|
.expectBody(Theme.class)
|
||||||
.value(theme -> {
|
.isEqualTo(installedTheme);
|
||||||
verify(extensionClient, times(1)).create(any(Unstructured.class));
|
|
||||||
|
|
||||||
assertThat(theme).isNotNull();
|
verify(themeService).install(any());
|
||||||
assertThat(theme.getMetadata().getName()).isEqualTo("default");
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
when(themeService.install(any())).thenReturn(
|
||||||
|
Mono.error(new RuntimeException("Fake exception")));
|
||||||
// Verify the theme is installed.
|
// Verify the theme is installed.
|
||||||
webTestClient.post()
|
webTestClient.post()
|
||||||
.uri("/themes/install")
|
.uri("/themes/install")
|
||||||
|
@ -190,9 +182,8 @@ class ThemeEndpointTest {
|
||||||
when(extensionClient.fetch(Setting.class, "fake-setting"))
|
when(extensionClient.fetch(Setting.class, "fake-setting"))
|
||||||
.thenReturn(Mono.just(setting));
|
.thenReturn(Mono.just(setting));
|
||||||
|
|
||||||
when(haloProperties.getWorkDir()).thenReturn(tmpHaloWorkDir);
|
// when(haloProperties.getWorkDir()).thenReturn(tmpHaloWorkDir);
|
||||||
Path themeWorkDir = tmpHaloWorkDir.resolve("themes")
|
Path themeWorkDir = themeRoot.get().resolve(theme.getMetadata().getName());
|
||||||
.resolve(theme.getMetadata().getName());
|
|
||||||
if (!Files.exists(themeWorkDir)) {
|
if (!Files.exists(themeWorkDir)) {
|
||||||
Files.createDirectories(themeWorkDir);
|
Files.createDirectories(themeWorkDir);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,182 @@
|
||||||
|
package run.halo.app.core.extension.theme;
|
||||||
|
|
||||||
|
import static java.nio.file.Files.createTempDirectory;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.mockito.ArgumentMatchers.isA;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
import static run.halo.app.infra.utils.FileUtils.deleteRecursivelyAndSilently;
|
||||||
|
import static run.halo.app.infra.utils.FileUtils.zip;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URISyntaxException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Nested;
|
||||||
|
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.util.ResourceUtils;
|
||||||
|
import org.springframework.web.server.ServerWebInputException;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.test.StepVerifier;
|
||||||
|
import run.halo.app.core.extension.Theme;
|
||||||
|
import run.halo.app.extension.Metadata;
|
||||||
|
import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
|
import run.halo.app.extension.Unstructured;
|
||||||
|
import run.halo.app.extension.exception.ExtensionException;
|
||||||
|
import run.halo.app.infra.ThemeRootGetter;
|
||||||
|
import run.halo.app.infra.exception.ThemeInstallationException;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class ThemeServiceImplTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
ReactiveExtensionClient client;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
ThemeRootGetter themeRoot;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
ThemeServiceImpl themeService;
|
||||||
|
|
||||||
|
Path tmpDir;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() throws IOException {
|
||||||
|
tmpDir = createTempDirectory("halo-theme-service-test-");
|
||||||
|
lenient().when(themeRoot.get()).thenReturn(tmpDir.resolve("themes"));
|
||||||
|
// init the folder
|
||||||
|
Files.createDirectory(themeRoot.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void cleanUp() {
|
||||||
|
deleteRecursivelyAndSilently(tmpDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
Path prepareTheme(String themeFilename) throws IOException, URISyntaxException {
|
||||||
|
var defaultThemeUri = ResourceUtils.getURL("classpath:themes/" + themeFilename).toURI();
|
||||||
|
var defaultThemeZipPath = tmpDir.resolve("default.zip");
|
||||||
|
zip(Path.of(defaultThemeUri), defaultThemeZipPath);
|
||||||
|
return defaultThemeZipPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
Theme createTheme() {
|
||||||
|
return createTheme(theme -> {
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Theme createTheme(Consumer<Theme> customizer) {
|
||||||
|
var metadata = new Metadata();
|
||||||
|
metadata.setName("default");
|
||||||
|
|
||||||
|
var spec = new Theme.ThemeSpec();
|
||||||
|
spec.setDisplayName("Default");
|
||||||
|
|
||||||
|
var theme = new Theme();
|
||||||
|
theme.setMetadata(metadata);
|
||||||
|
theme.setSpec(spec);
|
||||||
|
customizer.accept(theme);
|
||||||
|
return theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
Unstructured convert(Theme theme) {
|
||||||
|
return Unstructured.OBJECT_MAPPER.convertValue(theme, Unstructured.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
class UpgradeTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldFailIfThemeNotInstalledBefore() throws IOException, URISyntaxException {
|
||||||
|
var themeZipPath = prepareTheme("other");
|
||||||
|
when(client.fetch(Theme.class, "default")).thenReturn(Mono.empty());
|
||||||
|
try (var is = Files.newInputStream(themeZipPath)) {
|
||||||
|
StepVerifier.create(themeService.upgrade("default", is))
|
||||||
|
.verifyError(ServerWebInputException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
verify(client).fetch(Theme.class, "default");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldUpgradeSuccessfully() throws IOException, URISyntaxException {
|
||||||
|
var themeZipPath = prepareTheme("other");
|
||||||
|
|
||||||
|
var oldTheme = createTheme();
|
||||||
|
when(client.fetch(Theme.class, "default"))
|
||||||
|
// for old theme check
|
||||||
|
.thenReturn(Mono.just(oldTheme))
|
||||||
|
// for theme deletion
|
||||||
|
.thenReturn(Mono.just(oldTheme))
|
||||||
|
// for theme deleted check
|
||||||
|
.thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
when(client.delete(oldTheme)).thenReturn(Mono.just(oldTheme));
|
||||||
|
when(client.create(isA(Unstructured.class))).thenReturn(
|
||||||
|
Mono.just(convert(createTheme(t -> t.getSpec().setDisplayName("New fake theme")))));
|
||||||
|
|
||||||
|
try (var is = Files.newInputStream(themeZipPath)) {
|
||||||
|
StepVerifier.create(themeService.upgrade("default", is))
|
||||||
|
.consumeNextWith(newTheme -> {
|
||||||
|
assertEquals("default", newTheme.getMetadata().getName());
|
||||||
|
assertEquals("New fake theme", newTheme.getSpec().getDisplayName());
|
||||||
|
})
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
verify(client, times(3)).fetch(Theme.class, "default");
|
||||||
|
verify(client).delete(oldTheme);
|
||||||
|
verify(client).create(isA(Unstructured.class));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
class InstallTest {
|
||||||
|
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldInstallSuccessfully() throws IOException, URISyntaxException {
|
||||||
|
var defaultThemeZipPath = prepareTheme("default");
|
||||||
|
when(client.create(isA(Unstructured.class))).thenReturn(
|
||||||
|
Mono.just(convert(createTheme())));
|
||||||
|
try (var is = Files.newInputStream(defaultThemeZipPath)) {
|
||||||
|
StepVerifier.create(themeService.install(is))
|
||||||
|
.consumeNextWith(theme -> {
|
||||||
|
assertEquals("default", theme.getMetadata().getName());
|
||||||
|
assertEquals("Default", theme.getSpec().getDisplayName());
|
||||||
|
})
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldFailWhenPersistentError() throws IOException, URISyntaxException {
|
||||||
|
var defaultThemeZipPath = prepareTheme("default");
|
||||||
|
when(client.create(isA(Unstructured.class))).thenReturn(
|
||||||
|
Mono.error(() -> new ExtensionException("Failed to create the extension")));
|
||||||
|
try (var is = Files.newInputStream(defaultThemeZipPath)) {
|
||||||
|
StepVerifier.create(themeService.install(is))
|
||||||
|
.verifyError(ExtensionException.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldFailWhenThemeManifestIsInvalid() throws IOException, URISyntaxException {
|
||||||
|
var defaultThemeZipPath = prepareTheme("invalid-missing-manifest");
|
||||||
|
try (var is = Files.newInputStream(defaultThemeZipPath)) {
|
||||||
|
StepVerifier.create(themeService.install(is))
|
||||||
|
.verifyError(ThemeInstallationException.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -20,7 +20,8 @@ import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
@SpringBootTest(properties = {"halo.security.initializer.disabled=false",
|
@SpringBootTest(properties = {"halo.security.initializer.disabled=false",
|
||||||
"halo.security.initializer.super-admin-username=fake-admin",
|
"halo.security.initializer.super-admin-username=fake-admin",
|
||||||
"halo.security.initializer.super-admin-password=fake-password",
|
"halo.security.initializer.super-admin-password=fake-password",
|
||||||
"halo.required-extension-disabled=true"})
|
"halo.required-extension-disabled=true",
|
||||||
|
"halo.theme.initializer.disabled=true"})
|
||||||
@AutoConfigureWebTestClient
|
@AutoConfigureWebTestClient
|
||||||
@AutoConfigureTestDatabase
|
@AutoConfigureTestDatabase
|
||||||
class SuperAdminInitializerTest {
|
class SuperAdminInitializerTest {
|
||||||
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
apiVersion: theme.halo.run/v1alpha1
|
||||||
|
kind: Theme
|
||||||
|
metadata:
|
||||||
|
name: default
|
||||||
|
spec:
|
||||||
|
displayName: Default
|
||||||
|
author:
|
||||||
|
name: halo-dev
|
||||||
|
website: https://halo.run
|
||||||
|
description: Default theme for Halo 2.0
|
||||||
|
logo: https://halo.run/logo
|
||||||
|
website: https://github.com/halo-sigs/theme-default
|
||||||
|
repo: https://github.com/halo-sigs/theme-default.git
|
||||||
|
version: 1.0.0
|
||||||
|
require: 2.0.0
|
|
@ -0,0 +1 @@
|
||||||
|
index.welcome=\u6B22\u8FCE\u6765\u5230\u9996\u9875
|
|
@ -0,0 +1 @@
|
||||||
|
index.welcome=Welcome to the index
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Title</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
index
|
||||||
|
<div th:text="${#locale}"></div>
|
||||||
|
<div th:text="#{index.welcome}"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1 @@
|
||||||
|
<p th:text="${#temporals.format(instants, 'yyyy-MM-dd HH:mm:ss')}"></p>
|
|
@ -0,0 +1,15 @@
|
||||||
|
apiVersion: theme.halo.run/v1alpha1
|
||||||
|
kind: Theme
|
||||||
|
metadata:
|
||||||
|
name: default
|
||||||
|
spec:
|
||||||
|
displayName: Default
|
||||||
|
author:
|
||||||
|
name: halo-dev
|
||||||
|
website: https://halo.run
|
||||||
|
description: Default theme for Halo 2.0
|
||||||
|
logo: https://halo.run/logo
|
||||||
|
website: https://github.com/halo-sigs/theme-default
|
||||||
|
repo: https://github.com/halo-sigs/theme-default.git
|
||||||
|
version: 1.0.1
|
||||||
|
require: 2.0.0
|
Loading…
Reference in New Issue