Skip to content

Commit

Permalink
Initialize default theme when Halo starts up for the first time (#2704)
Browse files Browse the repository at this point in the history
#### 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 #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 时初始化默认主题
```
  • Loading branch information
JohnNiang committed Nov 15, 2022
1 parent 04a7b67 commit 0c8ccec
Show file tree
Hide file tree
Showing 22 changed files with 654 additions and 233 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
183 changes: 21 additions & 162 deletions src/main/java/run/halo/app/core/extension/theme/ThemeEndpoint.java
Original file line number Diff line number Diff line change
@@ -1,66 +1,46 @@
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;
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;
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.
Expand All @@ -73,13 +53,16 @@
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
Expand Down Expand Up @@ -147,6 +130,7 @@ public Boolean getUninstalled() {
}
}

// TODO Extract the method into ThemeService
Mono<ServerResponse> listThemes(ServerRequest request) {
MultiValueMap<String, String> queryParams = request.queryParams();
ThemeQuery query = new ThemeQuery(queryParams);
Expand Down Expand Up @@ -188,69 +172,24 @@ public FilePart getFile() {
}

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());
} catch (IOException e) {
return Mono.error(Exceptions.propagate(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);
try (var inputStream = toInputStream(file.content())) {
return themeService.upgrade(themeNameInPath, inputStream);
} catch (IOException e) {
throw Exceptions.propagate(e);
return Mono.error(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)
Expand All @@ -272,6 +211,7 @@ private Mono<List<Theme>> filterUnInstalledThemes(@NonNull List<Theme> allThemes
);
}

// TODO Extract the method into ThemeService
Mono<ServerResponse> reloadSetting(ServerRequest request) {
String name = request.pathVariable("name");
return client.fetch(Theme.class, name)
Expand All @@ -296,7 +236,7 @@ Mono<ServerResponse> reloadSetting(ServerRequest request) {
.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(
Expand All @@ -322,85 +262,22 @@ Mono<ServerResponse> install(ServerRequest request) {
.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);
Expand All @@ -425,22 +302,4 @@ Mono<FilePart> getZipFilePart(MultiValueMap<String, Part> formData) {
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();
}
}
15 changes: 15 additions & 0 deletions src/main/java/run/halo/app/core/extension/theme/ThemeService.java
Original file line number Diff line number Diff line change
@@ -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 comments on commit 0c8ccec

Please sign in to comment.