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
|
||||
!src/test/resources/themes/*.zip
|
||||
!src/main/resources/themes/*.zip
|
||||
src/main/resources/console/
|
||||
|
|
|
@ -1,20 +1,13 @@
|
|||
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.content.Builder.contentBuilder;
|
||||
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.schema.Builder.schemaBuilder;
|
||||
import static org.springframework.util.FileSystemUtils.copyRecursively;
|
||||
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.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.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.media.Schema;
|
||||
|
@ -22,12 +15,7 @@ import java.io.IOException;
|
|||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.Duration;
|
||||
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 org.apache.commons.lang3.StringUtils;
|
||||
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.Part;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.retry.RetryException;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.reactive.function.BodyExtractors;
|
||||
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.ServerErrorException;
|
||||
import org.springframework.web.server.ServerWebInputException;
|
||||
import reactor.core.Exceptions;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.util.retry.Retry;
|
||||
import run.halo.app.core.extension.Setting;
|
||||
import run.halo.app.core.extension.Theme;
|
||||
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.ReactiveExtensionClient;
|
||||
import run.halo.app.extension.Unstructured;
|
||||
import run.halo.app.extension.router.IListRequest;
|
||||
import run.halo.app.extension.router.QueryParamBuildUtil;
|
||||
import run.halo.app.infra.exception.ThemeInstallationException;
|
||||
import run.halo.app.infra.properties.HaloProperties;
|
||||
import run.halo.app.theme.ThemePathPolicy;
|
||||
import run.halo.app.infra.ThemeRootGetter;
|
||||
|
||||
/**
|
||||
* Endpoint for managing themes.
|
||||
|
@ -73,13 +53,16 @@ import run.halo.app.theme.ThemePathPolicy;
|
|||
public class ThemeEndpoint implements CustomEndpoint {
|
||||
|
||||
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.haloProperties = haloProperties;
|
||||
this.themePathPolicy = new ThemePathPolicy(haloProperties.getWorkDir());
|
||||
this.themeRoot = themeRoot;
|
||||
this.themeService = themeService;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -147,6 +130,7 @@ public class ThemeEndpoint implements CustomEndpoint {
|
|||
}
|
||||
}
|
||||
|
||||
// TODO Extract the method into ThemeService
|
||||
Mono<ServerResponse> listThemes(ServerRequest request) {
|
||||
MultiValueMap<String, String> queryParams = request.queryParams();
|
||||
ThemeQuery query = new ThemeQuery(queryParams);
|
||||
|
@ -188,69 +172,24 @@ public class ThemeEndpoint implements CustomEndpoint {
|
|||
}
|
||||
|
||||
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
|
||||
return client.fetch(Theme.class, themeNameInPath)
|
||||
.switchIfEmpty(Mono.error(() -> new ServerWebInputException(
|
||||
"The given theme with name " + themeNameInPath + " does not exist")))
|
||||
.then(request.multipartData())
|
||||
var themeNameInPath = request.pathVariable("name");
|
||||
return request.multipartData()
|
||||
.map(UpgradeRequest::new)
|
||||
.map(UpgradeRequest::getFile)
|
||||
.publishOn(boundedElastic())
|
||||
.flatMap(file -> {
|
||||
try (var zis = new ZipInputStream(toInputStream(file.content()))) {
|
||||
tempDir.set(createTempDirectory("halo-theme-"));
|
||||
unzip(zis, tempDir.get());
|
||||
return locateThemeManifest(tempDir.get());
|
||||
try (var inputStream = toInputStream(file.content())) {
|
||||
return themeService.upgrade(themeNameInPath, inputStream);
|
||||
} 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()
|
||||
.bodyValue(updatedTheme))
|
||||
.doFinally(signalType -> {
|
||||
// clear the temporary folder
|
||||
deleteRecursivelyAndSilently(tempDir.get());
|
||||
});
|
||||
.bodyValue(updatedTheme));
|
||||
}
|
||||
|
||||
Mono<ListResult<Theme>> listUninstalled(ThemeQuery query) {
|
||||
Path path = themePathPolicy.themesDir();
|
||||
Path path = themeRoot.get();
|
||||
return ThemeUtils.listAllThemesFromThemeDir(path)
|
||||
.collectList()
|
||||
.flatMap(this::filterUnInstalledThemes)
|
||||
|
@ -272,6 +211,7 @@ public class ThemeEndpoint implements CustomEndpoint {
|
|||
);
|
||||
}
|
||||
|
||||
// TODO Extract the method into ThemeService
|
||||
Mono<ServerResponse> reloadSetting(ServerRequest request) {
|
||||
String name = request.pathVariable("name");
|
||||
return client.fetch(Theme.class, name)
|
||||
|
@ -296,7 +236,7 @@ public class ThemeEndpoint implements CustomEndpoint {
|
|||
.orElse(Mono.just(theme));
|
||||
})
|
||||
.flatMap(themeToUse -> {
|
||||
Path themePath = themePathPolicy.generate(themeToUse);
|
||||
Path themePath = themeRoot.get().resolve(themeToUse.getMetadata().getName());
|
||||
Path themeManifestPath = ThemeUtils.resolveThemeManifest(themePath);
|
||||
if (themeManifestPath == null) {
|
||||
return Mono.error(new IllegalArgumentException(
|
||||
|
@ -322,85 +262,22 @@ public class ThemeEndpoint implements CustomEndpoint {
|
|||
.flatMap(file -> {
|
||||
try {
|
||||
var is = toInputStream(file.content());
|
||||
var themeWorkDir = getThemeWorkDir();
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("Transferring {} into {}", file.filename(), themeWorkDir);
|
||||
}
|
||||
return unzipThemeTo(is, themeWorkDir);
|
||||
return themeService.install(is);
|
||||
} catch (IOException e) {
|
||||
return Mono.error(Exceptions.propagate(e));
|
||||
}
|
||||
})
|
||||
.flatMap(this::persistent)
|
||||
.flatMap(theme -> ServerResponse.ok()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.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) {
|
||||
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() {
|
||||
Path themePath = haloProperties.getWorkDir()
|
||||
.resolve("themes");
|
||||
Path themePath = themeRoot.get();
|
||||
if (Files.notExists(themePath)) {
|
||||
try {
|
||||
Files.createDirectories(themePath);
|
||||
|
@ -425,22 +302,4 @@ public class ThemeEndpoint implements CustomEndpoint {
|
|||
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,
|
||||
boolean override) {
|
||||
AtomicReference<Path> tempDir = new AtomicReference<>();
|
||||
return Mono.fromCallable(
|
||||
() -> {
|
||||
Path tempDirectory = null;
|
||||
Path themeTargetPath = null;
|
||||
try (ZipInputStream zipInputStream = new ZipInputStream(inputStream)) {
|
||||
tempDirectory = createTempDirectory(THEME_TMP_PREFIX);
|
||||
unzip(zipInputStream, tempDirectory);
|
||||
return tempDirectory;
|
||||
var tempDir = new AtomicReference<Path>();
|
||||
return Mono.just(inputStream)
|
||||
.publishOn(Schedulers.boundedElastic())
|
||||
.doFirst(() -> {
|
||||
try {
|
||||
tempDir.set(createTempDirectory(THEME_TMP_PREFIX));
|
||||
} catch (IOException e) {
|
||||
deleteRecursivelyAndSilently(themeTargetPath);
|
||||
throw new ThemeInstallationException("Unable to install theme", e);
|
||||
throw Exceptions.propagate(e);
|
||||
}
|
||||
})
|
||||
.doOnNext(tempDir::set)
|
||||
.flatMap(ThemeUtils::locateThemeManifest)
|
||||
.doOnNext(is -> {
|
||||
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 -> {
|
||||
var theme = loadThemeManifest(themeManifestPath);
|
||||
var themeName = theme.getMetadata().getName();
|
||||
|
@ -123,8 +128,7 @@ class ThemeUtils {
|
|||
throw Exceptions.propagate(e);
|
||||
}
|
||||
})
|
||||
.doFinally(signalType -> deleteRecursivelyAndSilently(tempDir.get()))
|
||||
.subscribeOn(Schedulers.boundedElastic());
|
||||
.doFinally(signalType -> deleteRecursivelyAndSilently(tempDir.get()));
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
* @since 2.0.0
|
||||
* @deprecated Use {@code run.halo.app.infra.ThemeRootGetter}
|
||||
*/
|
||||
@Deprecated(forRemoval = true)
|
||||
public class ThemePathPolicy {
|
||||
public static final String THEME_WORK_DIR = "themes";
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@ metadata:
|
|||
data:
|
||||
theme: |
|
||||
{
|
||||
"active": "default"
|
||||
"active": "theme-earth"
|
||||
}
|
||||
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.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.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
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.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
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.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
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.util.FileSystemUtils;
|
||||
import org.springframework.util.ResourceUtils;
|
||||
import org.springframework.web.server.ServerWebInputException;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.Setting;
|
||||
import run.halo.app.core.extension.Theme;
|
||||
import run.halo.app.extension.AbstractExtension;
|
||||
import run.halo.app.extension.Metadata;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.extension.Unstructured;
|
||||
import run.halo.app.infra.properties.HaloProperties;
|
||||
import run.halo.app.infra.ThemeRootGetter;
|
||||
import run.halo.app.infra.utils.JsonUtils;
|
||||
import run.halo.app.infra.utils.YamlUnstructuredLoader;
|
||||
|
||||
/**
|
||||
* Tests for {@link ThemeEndpoint}.
|
||||
|
@ -51,10 +53,16 @@ import run.halo.app.infra.utils.YamlUnstructuredLoader;
|
|||
class ThemeEndpointTest {
|
||||
|
||||
@Mock
|
||||
private ReactiveExtensionClient extensionClient;
|
||||
ReactiveExtensionClient extensionClient;
|
||||
|
||||
@Mock
|
||||
private HaloProperties haloProperties;
|
||||
ThemeRootGetter themeRoot;
|
||||
|
||||
@Mock
|
||||
ThemeService themeService;
|
||||
|
||||
@InjectMocks
|
||||
ThemeEndpoint themeEndpoint;
|
||||
|
||||
private Path tmpHaloWorkDir;
|
||||
|
||||
|
@ -64,21 +72,17 @@ class ThemeEndpointTest {
|
|||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
tmpHaloWorkDir = Files.createTempDirectory("halo-unit-test");
|
||||
when(haloProperties.getWorkDir()).thenReturn(tmpHaloWorkDir);
|
||||
|
||||
ThemeEndpoint themeEndpoint = new ThemeEndpoint(extensionClient, haloProperties);
|
||||
|
||||
tmpHaloWorkDir = Files.createTempDirectory("halo-theme-endpoint-test");
|
||||
lenient().when(themeRoot.get()).thenReturn(tmpHaloWorkDir);
|
||||
defaultTheme = ResourceUtils.getFile("classpath:themes/test-theme.zip");
|
||||
|
||||
webTestClient = WebTestClient
|
||||
.bindToRouterFunction(themeEndpoint.endpoint())
|
||||
.build();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
FileSystemUtils.deleteRecursively(tmpHaloWorkDir.toFile());
|
||||
void tearDown() throws IOException {
|
||||
FileSystemUtils.deleteRecursively(tmpHaloWorkDir);
|
||||
}
|
||||
|
||||
@Nested
|
||||
|
@ -90,13 +94,17 @@ class ThemeEndpointTest {
|
|||
bodyBuilder.part("file", new FileSystemResource(defaultTheme))
|
||||
.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()
|
||||
.uri("/themes/invalid-theme/upgrade")
|
||||
.uri("/themes/invalid-missing-manifest/upgrade")
|
||||
.body(fromMultipartData(bodyBuilder.build()))
|
||||
.exchange()
|
||||
.expectStatus().isBadRequest();
|
||||
|
||||
verify(themeService).upgrade(eq("invalid-missing-manifest"), isA(InputStream.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -105,24 +113,13 @@ class ThemeEndpointTest {
|
|||
bodyBuilder.part("file", new FileSystemResource(defaultTheme))
|
||||
.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();
|
||||
metadata.setName("default");
|
||||
var newTheme = new Theme();
|
||||
newTheme.setMetadata(metadata);
|
||||
|
||||
when(extensionClient.create(any(Unstructured.class))).thenReturn(
|
||||
Mono.just(OBJECT_MAPPER.convertValue(newTheme, Unstructured.class)));
|
||||
when(themeService.upgrade(eq("default"), isA(InputStream.class)))
|
||||
.thenReturn(Mono.just(newTheme));
|
||||
|
||||
webTestClient.post()
|
||||
.uri("/themes/default/upgrade")
|
||||
|
@ -130,41 +127,36 @@ class ThemeEndpointTest {
|
|||
.exchange()
|
||||
.expectStatus().isOk();
|
||||
|
||||
verify(extensionClient, times(3)).fetch(Theme.class, "default");
|
||||
verify(extensionClient).delete(oldTheme);
|
||||
verify(extensionClient).create(any(Unstructured.class));
|
||||
verify(themeService).upgrade(eq("default"), isA(InputStream.class));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
void install() {
|
||||
when(extensionClient.create(any(Unstructured.class))).thenReturn(
|
||||
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();
|
||||
var multipartBodyBuilder = new MultipartBodyBuilder();
|
||||
multipartBodyBuilder.part("file", new FileSystemResource(defaultTheme))
|
||||
.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()
|
||||
.uri("/themes/install")
|
||||
.body(fromMultipartData(multipartBodyBuilder.build()))
|
||||
.exchange()
|
||||
.expectStatus().isOk()
|
||||
.expectBody(Theme.class)
|
||||
.value(theme -> {
|
||||
verify(extensionClient, times(1)).create(any(Unstructured.class));
|
||||
.isEqualTo(installedTheme);
|
||||
|
||||
assertThat(theme).isNotNull();
|
||||
assertThat(theme.getMetadata().getName()).isEqualTo("default");
|
||||
});
|
||||
verify(themeService).install(any());
|
||||
|
||||
|
||||
when(themeService.install(any())).thenReturn(
|
||||
Mono.error(new RuntimeException("Fake exception")));
|
||||
// Verify the theme is installed.
|
||||
webTestClient.post()
|
||||
.uri("/themes/install")
|
||||
|
@ -190,9 +182,8 @@ class ThemeEndpointTest {
|
|||
when(extensionClient.fetch(Setting.class, "fake-setting"))
|
||||
.thenReturn(Mono.just(setting));
|
||||
|
||||
when(haloProperties.getWorkDir()).thenReturn(tmpHaloWorkDir);
|
||||
Path themeWorkDir = tmpHaloWorkDir.resolve("themes")
|
||||
.resolve(theme.getMetadata().getName());
|
||||
// when(haloProperties.getWorkDir()).thenReturn(tmpHaloWorkDir);
|
||||
Path themeWorkDir = themeRoot.get().resolve(theme.getMetadata().getName());
|
||||
if (!Files.exists(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",
|
||||
"halo.security.initializer.super-admin-username=fake-admin",
|
||||
"halo.security.initializer.super-admin-password=fake-password",
|
||||
"halo.required-extension-disabled=true"})
|
||||
"halo.required-extension-disabled=true",
|
||||
"halo.theme.initializer.disabled=true"})
|
||||
@AutoConfigureWebTestClient
|
||||
@AutoConfigureTestDatabase
|
||||
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