diff --git a/.gitignore b/.gitignore index eadd96ccc..b7e9268cb 100755 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/src/main/java/run/halo/app/core/extension/theme/ThemeEndpoint.java b/src/main/java/run/halo/app/core/extension/theme/ThemeEndpoint.java index ce4156489..3ef66df43 100644 --- a/src/main/java/run/halo/app/core/extension/theme/ThemeEndpoint.java +++ b/src/main/java/run/halo/app/core/extension/theme/ThemeEndpoint.java @@ -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 listThemes(ServerRequest request) { MultiValueMap queryParams = request.queryParams(); ThemeQuery query = new ThemeQuery(queryParams); @@ -188,69 +172,24 @@ public class ThemeEndpoint implements CustomEndpoint { } private Mono upgrade(ServerRequest request) { - var themeNameInPath = request.pathVariable("name"); - final var tempDir = new AtomicReference(); - final var tempThemeRoot = new AtomicReference(); // 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> 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 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 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 hasSettingsYaml(Theme theme) { - return unstructured -> Setting.KIND.equals(unstructured.getKind()) - && theme.getSpec().getSettingName().equals(unstructured.getMetadata().getName()); - } - - private Predicate 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 deleteThemeAndWaitForComplete(String themeName) { - return client.fetch(Theme.class, themeName) - .flatMap(client::delete) - .flatMap(deletingTheme -> waitForThemeDeleted(themeName) - .thenReturn(deletingTheme)); - } - - Mono 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(); - } } diff --git a/src/main/java/run/halo/app/core/extension/theme/ThemeService.java b/src/main/java/run/halo/app/core/extension/theme/ThemeService.java new file mode 100644 index 000000000..6aafe5679 --- /dev/null +++ b/src/main/java/run/halo/app/core/extension/theme/ThemeService.java @@ -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 install(InputStream is); + + Mono upgrade(String themeName, InputStream is); + + // TODO Migrate other useful methods in ThemeEndpoint in the future. + +} diff --git a/src/main/java/run/halo/app/core/extension/theme/ThemeServiceImpl.java b/src/main/java/run/halo/app/core/extension/theme/ThemeServiceImpl.java new file mode 100644 index 000000000..1e6aecb5b --- /dev/null +++ b/src/main/java/run/halo/app/core/extension/theme/ThemeServiceImpl.java @@ -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 install(InputStream is) { + var themeRoot = this.themeRoot.get(); + return ThemeUtils.unzipThemeTo(is, themeRoot) + .flatMap(this::persistent); + } + + @Override + public Mono upgrade(String themeName, InputStream is) { + var tempDir = new AtomicReference(); + var tempThemeRoot = new AtomicReference(); + 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 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 hasSettingsYaml(Theme theme) { + return unstructured -> Setting.KIND.equals(unstructured.getKind()) + && theme.getSpec().getSettingName().equals(unstructured.getMetadata().getName()); + } + + private Predicate hasConfigYaml(Theme theme) { + return unstructured -> ConfigMap.KIND.equals(unstructured.getKind()) + && theme.getSpec().getConfigMapName().equals(unstructured.getMetadata().getName()); + } + + Mono deleteThemeAndWaitForComplete(String themeName) { + return client.fetch(Theme.class, themeName) + .flatMap(client::delete) + .flatMap(deletingTheme -> waitForThemeDeleted(themeName) + .thenReturn(deletingTheme)); + } + + Mono 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(); + } +} diff --git a/src/main/java/run/halo/app/core/extension/theme/ThemeUtils.java b/src/main/java/run/halo/app/core/extension/theme/ThemeUtils.java index a28e7d78d..1c18aff05 100644 --- a/src/main/java/run/halo/app/core/extension/theme/ThemeUtils.java +++ b/src/main/java/run/halo/app/core/extension/theme/ThemeUtils.java @@ -91,22 +91,27 @@ class ThemeUtils { static Mono unzipThemeTo(InputStream inputStream, Path themeWorkDir, boolean override) { - AtomicReference 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; - } catch (IOException e) { - deleteRecursivelyAndSilently(themeTargetPath); - throw new ThemeInstallationException("Unable to install theme", e); - } - }) - .doOnNext(tempDir::set) - .flatMap(ThemeUtils::locateThemeManifest) + var tempDir = new AtomicReference(); + return Mono.just(inputStream) + .publishOn(Schedulers.boundedElastic()) + .doFirst(() -> { + try { + tempDir.set(createTempDirectory(THEME_TMP_PREFIX)); + } catch (IOException e) { + throw Exceptions.propagate(e); + } + }) + .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) { diff --git a/src/main/java/run/halo/app/infra/DefaultThemeInitializer.java b/src/main/java/run/halo/app/infra/DefaultThemeInitializer.java new file mode 100644 index 000000000..72963f080 --- /dev/null +++ b/src/main/java/run/halo/app/infra/DefaultThemeInitializer.java @@ -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 { + + 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); + } + } + + +} diff --git a/src/main/java/run/halo/app/infra/DefaultThemeRootGetter.java b/src/main/java/run/halo/app/infra/DefaultThemeRootGetter.java new file mode 100644 index 000000000..2df9380cc --- /dev/null +++ b/src/main/java/run/halo/app/infra/DefaultThemeRootGetter.java @@ -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"); + } + +} diff --git a/src/main/java/run/halo/app/infra/ThemeRootGetter.java b/src/main/java/run/halo/app/infra/ThemeRootGetter.java new file mode 100644 index 000000000..2f2a72a53 --- /dev/null +++ b/src/main/java/run/halo/app/infra/ThemeRootGetter.java @@ -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 { + +} diff --git a/src/main/java/run/halo/app/infra/properties/HaloProperties.java b/src/main/java/run/halo/app/infra/properties/HaloProperties.java index 66d2124bb..9816cfc81 100644 --- a/src/main/java/run/halo/app/infra/properties/HaloProperties.java +++ b/src/main/java/run/halo/app/infra/properties/HaloProperties.java @@ -42,4 +42,7 @@ public class HaloProperties { @Valid private final ConsoleProperties console = new ConsoleProperties(); + + @Valid + private final ThemeProperties theme = new ThemeProperties(); } diff --git a/src/main/java/run/halo/app/infra/properties/ThemeProperties.java b/src/main/java/run/halo/app/infra/properties/ThemeProperties.java new file mode 100644 index 000000000..b4decf3f3 --- /dev/null +++ b/src/main/java/run/halo/app/infra/properties/ThemeProperties.java @@ -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"; + + } + +} diff --git a/src/main/java/run/halo/app/theme/ThemePathPolicy.java b/src/main/java/run/halo/app/theme/ThemePathPolicy.java index a7d15fd83..c696ffdaa 100644 --- a/src/main/java/run/halo/app/theme/ThemePathPolicy.java +++ b/src/main/java/run/halo/app/theme/ThemePathPolicy.java @@ -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"; diff --git a/src/main/resources/extensions/system-configurable-configmap.yaml b/src/main/resources/extensions/system-configurable-configmap.yaml index bb2436f31..0694ccc3d 100644 --- a/src/main/resources/extensions/system-configurable-configmap.yaml +++ b/src/main/resources/extensions/system-configurable-configmap.yaml @@ -5,7 +5,7 @@ metadata: data: theme: | { - "active": "default" + "active": "theme-earth" } routeRules: | { diff --git a/src/main/resources/themes/theme-earth.zip b/src/main/resources/themes/theme-earth.zip new file mode 100644 index 000000000..e1149fcc8 Binary files /dev/null and b/src/main/resources/themes/theme-earth.zip differ diff --git a/src/test/java/run/halo/app/core/extension/theme/ThemeEndpointTest.java b/src/test/java/run/halo/app/core/extension/theme/ThemeEndpointTest.java index deb65a1e5..8ec87d846 100644 --- a/src/test/java/run/halo/app/core/extension/theme/ThemeEndpointTest.java +++ b/src/test/java/run/halo/app/core/extension/theme/ThemeEndpointTest.java @@ -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); } diff --git a/src/test/java/run/halo/app/core/extension/theme/ThemeServiceImplTest.java b/src/test/java/run/halo/app/core/extension/theme/ThemeServiceImplTest.java new file mode 100644 index 000000000..321ed468a --- /dev/null +++ b/src/test/java/run/halo/app/core/extension/theme/ThemeServiceImplTest.java @@ -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 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); + } + } + } + +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/security/SuperAdminInitializerTest.java b/src/test/java/run/halo/app/security/SuperAdminInitializerTest.java index 9d12ced3b..f0b830d62 100644 --- a/src/test/java/run/halo/app/security/SuperAdminInitializerTest.java +++ b/src/test/java/run/halo/app/security/SuperAdminInitializerTest.java @@ -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 { diff --git a/src/test/resources/themes/default/theme.yaml b/src/test/resources/themes/default/theme.yaml new file mode 100644 index 000000000..00263a89f --- /dev/null +++ b/src/test/resources/themes/default/theme.yaml @@ -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 diff --git a/src/test/resources/themes/invalid-missing-manifest/i18n/default.properties b/src/test/resources/themes/invalid-missing-manifest/i18n/default.properties new file mode 100644 index 000000000..0321c8140 --- /dev/null +++ b/src/test/resources/themes/invalid-missing-manifest/i18n/default.properties @@ -0,0 +1 @@ +index.welcome=\u6B22\u8FCE\u6765\u5230\u9996\u9875 \ No newline at end of file diff --git a/src/test/resources/themes/invalid-missing-manifest/i18n/en.properties b/src/test/resources/themes/invalid-missing-manifest/i18n/en.properties new file mode 100644 index 000000000..1e6ec93cd --- /dev/null +++ b/src/test/resources/themes/invalid-missing-manifest/i18n/en.properties @@ -0,0 +1 @@ +index.welcome=Welcome to the index \ No newline at end of file diff --git a/src/test/resources/themes/invalid-missing-manifest/templates/index.html b/src/test/resources/themes/invalid-missing-manifest/templates/index.html new file mode 100644 index 000000000..441ad470c --- /dev/null +++ b/src/test/resources/themes/invalid-missing-manifest/templates/index.html @@ -0,0 +1,12 @@ + + + + + Title + + +index +
+
+ + diff --git a/src/test/resources/themes/invalid-missing-manifest/templates/timezone.html b/src/test/resources/themes/invalid-missing-manifest/templates/timezone.html new file mode 100644 index 000000000..d37df4ea6 --- /dev/null +++ b/src/test/resources/themes/invalid-missing-manifest/templates/timezone.html @@ -0,0 +1 @@ +

diff --git a/src/test/resources/themes/other/theme.yaml b/src/test/resources/themes/other/theme.yaml new file mode 100644 index 000000000..1347631ed --- /dev/null +++ b/src/test/resources/themes/other/theme.yaml @@ -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