Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initialize default theme when Halo starts up for the first time #2704

Merged
merged 4 commits into from
Nov 15, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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.

}