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
John Niang 2022-11-15 18:50:18 +08:00 committed by GitHub
parent 04a7b67fe9
commit 0c8ccecdf3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 654 additions and 233 deletions

1
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -42,4 +42,7 @@ public class HaloProperties {
@Valid
private final ConsoleProperties console = new ConsoleProperties();
@Valid
private final ThemeProperties theme = new ThemeProperties();
}

View File

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

View File

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

View File

@ -5,7 +5,7 @@ metadata:
data:
theme: |
{
"active": "default"
"active": "theme-earth"
}
routeRules: |
{

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
index.welcome=\u6B22\u8FCE\u6765\u5230\u9996\u9875

View File

@ -0,0 +1 @@
index.welcome=Welcome to the index

View File

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

View File

@ -0,0 +1 @@
<p th:text="${#temporals.format(instants, 'yyyy-MM-dd HH:mm:ss')}"></p>

View File

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