From e93f028a25dfa388c587b4f0a8bb3d929aa2671a Mon Sep 17 00:00:00 2001 From: John Niang Date: Fri, 21 Oct 2022 10:16:12 +0800 Subject: [PATCH] Provide an endpoint to upgrade theme (#2600) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### What type of PR is this? /kind feature /area core /milestone 2.0 #### What this PR does / why we need it: This PR mainly provides an endpoint to upgrade theme. Please see the request sample as follows: ```bash curl -X 'POST' \ 'http://localhost:8090/apis/api.console.halo.run/v1alpha1/themes/theme-default/upgrade' \ -H 'accept: */*' \ -H 'Content-Type: multipart/form-data' \ -F 'file=@theme-default-main.zip;type=application/x-zip-compressed' ``` We also can refer to API documentation: ![image](https://user-images.githubusercontent.com/16865714/196628148-09900fc2-85d9-49e5-9508-6b7f79df0537.png) #### Which issue(s) this PR fixes: Fixes https://github.com/halo-dev/halo/issues/2550 #### How to test? 1. Install any theme you want 2. Unzip the theme and change the content of theme installed just now 3. Zip the theme and try to upgrade it by requesting theme upgrade API #### Does this PR introduce a user-facing change? ```release-note 提供主题更新功能 ``` --- .../{endpoint => theme}/ThemeEndpoint.java | 288 +++++++++--------- .../app/core/extension/theme/ThemeUtils.java | 192 ++++++++++++ .../run/halo/app/infra/utils/FileUtils.java | 23 ++ .../ThemeEndpointTest.java | 75 ++++- 4 files changed, 424 insertions(+), 154 deletions(-) rename src/main/java/run/halo/app/core/extension/{endpoint => theme}/ThemeEndpoint.java (68%) create mode 100644 src/main/java/run/halo/app/core/extension/theme/ThemeUtils.java rename src/test/java/run/halo/app/core/extension/{endpoint => theme}/ThemeEndpointTest.java (75%) diff --git a/src/main/java/run/halo/app/core/extension/endpoint/ThemeEndpoint.java b/src/main/java/run/halo/app/core/extension/theme/ThemeEndpoint.java similarity index 68% rename from src/main/java/run/halo/app/core/extension/endpoint/ThemeEndpoint.java rename to src/main/java/run/halo/app/core/extension/theme/ThemeEndpoint.java index 5d1427db2..ce4156489 100644 --- a/src/main/java/run/halo/app/core/extension/endpoint/ThemeEndpoint.java +++ b/src/main/java/run/halo/app/core/extension/theme/ThemeEndpoint.java @@ -1,53 +1,57 @@ -package run.halo.app.core.extension.endpoint; +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.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.Comparator; +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.stream.BaseStream; -import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.lang3.StringUtils; -import org.springdoc.core.fn.builders.schema.Builder; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; -import org.springframework.core.io.FileSystemResource; -import org.springframework.core.io.Resource; 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.lang.Nullable; +import org.springframework.retry.RetryException; import org.springframework.stereotype.Component; import org.springframework.util.Assert; -import org.springframework.util.CollectionUtils; -import org.springframework.util.FileSystemUtils; 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.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.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ListResult; import run.halo.app.extension.ReactiveExtensionClient; @@ -56,9 +60,6 @@ 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.infra.utils.DataBufferUtils; -import run.halo.app.infra.utils.FileUtils; -import run.halo.app.infra.utils.YamlUnstructuredLoader; import run.halo.app.theme.ThemePathPolicy; /** @@ -93,12 +94,21 @@ public class ThemeEndpoint implements CustomEndpoint { .required(true) .content(contentBuilder() .mediaType(MediaType.MULTIPART_FORM_DATA_VALUE) - .schema(Builder.schemaBuilder() + .schema(schemaBuilder() .implementation(InstallRequest.class)) )) .response(responseBuilder() .implementation(Theme.class)) ) + .POST("themes/{name}/upgrade", this::upgrade, + builder -> builder.operationId("UpgradeTheme") + .description("Upgrade theme") + .tag(tag) + .parameter(parameterBuilder().in(ParameterIn.PATH).name("name").required(true)) + .requestBody(requestBodyBuilder().required(true) + .content(contentBuilder().mediaType(MediaType.MULTIPART_FORM_DATA_VALUE) + .schema(schemaBuilder().implementation(UpgradeRequest.class)))) + .build()) .PUT("themes/{name}/reload-setting", this::reloadSetting, builder -> builder.operationId("ReloadThemeSetting") .description("Reload theme setting.") @@ -148,6 +158,97 @@ public class ThemeEndpoint implements CustomEndpoint { }).flatMap(extensions -> ServerResponse.ok().bodyValue(extensions)); } + public interface IUpgradeRequest { + + @Schema(required = true, description = "Theme zip file.") + FilePart getFile(); + + } + + public static class UpgradeRequest implements IUpgradeRequest { + + private final MultiValueMap multipartData; + + public UpgradeRequest(MultiValueMap multipartData) { + this.multipartData = multipartData; + } + + @Override + public FilePart getFile() { + var part = multipartData.getFirst("file"); + if (!(part instanceof FilePart filePart)) { + throw new ServerWebInputException("Invalid multipart type of file"); + } + if (!filePart.filename().endsWith(".zip")) { + throw new ServerWebInputException("Only zip extension supported"); + } + return filePart; + } + + } + + 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()) + .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); + } 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()); + }); + } + Mono> listUninstalled(ThemeQuery query) { Path path = themePathPolicy.themesDir(); return ThemeUtils.listAllThemesFromThemeDir(path) @@ -201,7 +302,7 @@ public class ThemeEndpoint implements CustomEndpoint { return Mono.error(new IllegalArgumentException( "The manifest file [theme.yaml] is required.")); } - Unstructured unstructured = ThemeUtils.loadThemeManifest(themeManifestPath); + Unstructured unstructured = loadThemeManifest(themeManifestPath); Theme newTheme = Unstructured.OBJECT_MAPPER.convertValue(unstructured, Theme.class); themeToUse.setSpec(newTheme.getSpec()); return client.update(themeToUse); @@ -218,19 +319,18 @@ public class ThemeEndpoint implements CustomEndpoint { Mono install(ServerRequest request) { return request.body(BodyExtractors.toMultipartData()) .flatMap(this::getZipFilePart) - .map(file -> { + .flatMap(file -> { try { - var is = DataBufferUtils.toInputStream(file.content()); + var is = toInputStream(file.content()); var themeWorkDir = getThemeWorkDir(); if (log.isDebugEnabled()) { log.debug("Transferring {} into {}", file.filename(), themeWorkDir); } - return ThemeUtils.unzipThemeTo(is, themeWorkDir); + return unzipThemeTo(is, themeWorkDir); } catch (IOException e) { - throw Exceptions.propagate(e); + return Mono.error(Exceptions.propagate(e)); } }) - .subscribeOn(Schedulers.boundedElastic()) .flatMap(this::persistent) .flatMap(theme -> ServerResponse.ok() .contentType(MediaType.APPLICATION_JSON) @@ -298,127 +398,6 @@ public class ThemeEndpoint implements CustomEndpoint { && theme.getSpec().getConfigMapName().equals(unstructured.getMetadata().getName()); } - static class ThemeUtils { - private static final String THEME_TMP_PREFIX = "halo-theme-"; - public static final String[] THEME_MANIFESTS = {"theme.yaml", "theme.yml"}; - - private static final String[] THEME_CONFIG = {"config.yaml", "config.yml"}; - - private static final String[] THEME_SETTING = {"settings.yaml", "settings.yml"}; - - static Flux listAllThemesFromThemeDir(Path themesDir) { - return walkThemesFromPath(themesDir) - .filter(Files::isDirectory) - .map(themePath -> loadUnstructured(themePath, THEME_MANIFESTS)) - .flatMap(Flux::fromIterable) - .map(unstructured -> Unstructured.OBJECT_MAPPER.convertValue(unstructured, - Theme.class)) - .sort(Comparator.comparing(theme -> theme.getMetadata().getName())); - } - - private static Flux walkThemesFromPath(Path path) { - return Flux.using(() -> Files.walk(path, 2), - Flux::fromStream, - BaseStream::close - ) - .subscribeOn(Schedulers.boundedElastic()); - } - - static List loadThemeSetting(Path themePath) { - return loadUnstructured(themePath, THEME_SETTING); - } - - private static List loadUnstructured(Path themePath, - String[] themeSetting) { - List resources = new ArrayList<>(4); - for (String themeResource : themeSetting) { - Path resourcePath = themePath.resolve(themeResource); - if (Files.exists(resourcePath)) { - resources.add(new FileSystemResource(resourcePath)); - } - } - if (CollectionUtils.isEmpty(resources)) { - return List.of(); - } - return new YamlUnstructuredLoader(resources.toArray(new Resource[0])) - .load(); - } - - static List loadThemeResources(Path themePath) { - String[] resourceNames = ArrayUtils.addAll(THEME_SETTING, THEME_CONFIG); - return loadUnstructured(themePath, resourceNames); - } - - static Unstructured unzipThemeTo(InputStream inputStream, Path themeWorkDir) { - return unzipThemeTo(inputStream, themeWorkDir, false); - } - - static Unstructured unzipThemeTo(InputStream inputStream, Path themeWorkDir, - boolean override) { - - Path tempDirectory = null; - try (ZipInputStream zipInputStream = new ZipInputStream(inputStream)) { - tempDirectory = Files.createTempDirectory(THEME_TMP_PREFIX); - - ZipEntry firstEntry = zipInputStream.getNextEntry(); - if (firstEntry == null) { - throw new IllegalArgumentException("Theme zip file is empty."); - } - - Path themeTempWorkDir = tempDirectory.resolve(firstEntry.getName()); - FileUtils.unzip(zipInputStream, tempDirectory); - - Path themeManifestPath = resolveThemeManifest(themeTempWorkDir); - if (themeManifestPath == null) { - throw new IllegalArgumentException( - "It's an invalid zip format for the theme, manifest " - + "file [theme.yaml] is required."); - } - Unstructured unstructured = loadThemeManifest(themeManifestPath); - String themeName = unstructured.getMetadata().getName(); - Path themeTargetPath = themeWorkDir.resolve(themeName); - if (!override && !FileUtils.isEmpty(themeTargetPath)) { - throw new ThemeInstallationException("Theme already exists."); - } - // install theme to theme work dir - FileSystemUtils.copyRecursively(themeTempWorkDir, themeTargetPath); - return unstructured; - } catch (IOException e) { - throw new ThemeInstallationException("Unable to install theme", e); - } finally { - // clean temp directory - try { - // null safe - FileSystemUtils.deleteRecursively(tempDirectory); - } catch (IOException e) { - // ignore this exception - } - } - } - - static Unstructured loadThemeManifest(Path themeManifestPath) { - List unstructureds = - new YamlUnstructuredLoader(new FileSystemResource(themeManifestPath)) - .load(); - if (CollectionUtils.isEmpty(unstructureds)) { - throw new IllegalArgumentException( - "The [theme.yaml] does not conform to the theme specification."); - } - return unstructureds.get(0); - } - - @Nullable - private static Path resolveThemeManifest(Path tempDirectory) { - for (String themeManifest : THEME_MANIFESTS) { - Path path = tempDirectory.resolve(themeManifest); - if (Files.exists(path)) { - return path; - } - } - return null; - } - } - private Path getThemeWorkDir() { Path themePath = haloProperties.getWorkDir() .resolve("themes"); @@ -445,4 +424,23 @@ 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/ThemeUtils.java b/src/main/java/run/halo/app/core/extension/theme/ThemeUtils.java new file mode 100644 index 000000000..a28e7d78d --- /dev/null +++ b/src/main/java/run/halo/app/core/extension/theme/ThemeUtils.java @@ -0,0 +1,192 @@ +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.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.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedList; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.BaseStream; +import java.util.stream.Stream; +import java.util.zip.ZipInputStream; +import org.apache.commons.lang3.ArrayUtils; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; +import org.springframework.lang.Nullable; +import org.springframework.util.CollectionUtils; +import reactor.core.Exceptions; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; +import run.halo.app.core.extension.Theme; +import run.halo.app.extension.Unstructured; +import run.halo.app.infra.exception.ThemeInstallationException; +import run.halo.app.infra.utils.FileUtils; +import run.halo.app.infra.utils.YamlUnstructuredLoader; + +class ThemeUtils { + private static final String THEME_TMP_PREFIX = "halo-theme-"; + private static final String[] THEME_MANIFESTS = {"theme.yaml", "theme.yml"}; + + private static final String[] THEME_CONFIG = {"config.yaml", "config.yml"}; + + private static final String[] THEME_SETTING = {"settings.yaml", "settings.yml"}; + + static List loadThemeSetting(Path themePath) { + return loadUnstructured(themePath, THEME_SETTING); + } + + static Flux listAllThemesFromThemeDir(Path themesDir) { + return walkThemesFromPath(themesDir) + .filter(Files::isDirectory) + .map(themePath -> loadUnstructured(themePath, THEME_MANIFESTS)) + .flatMap(Flux::fromIterable) + .map(unstructured -> Unstructured.OBJECT_MAPPER.convertValue(unstructured, + Theme.class)) + .sort(Comparator.comparing(theme -> theme.getMetadata().getName())); + } + + private static Flux walkThemesFromPath(Path path) { + return Flux.using(() -> Files.walk(path, 2), + Flux::fromStream, + BaseStream::close + ) + .subscribeOn(Schedulers.boundedElastic()); + } + + private static List loadUnstructured(Path themePath, + String[] themeSetting) { + List resources = new ArrayList<>(4); + for (String themeResource : themeSetting) { + Path resourcePath = themePath.resolve(themeResource); + if (Files.exists(resourcePath)) { + resources.add(new FileSystemResource(resourcePath)); + } + } + if (CollectionUtils.isEmpty(resources)) { + return List.of(); + } + return new YamlUnstructuredLoader(resources.toArray(new Resource[0])) + .load(); + } + + static List loadThemeResources(Path themePath) { + String[] resourceNames = ArrayUtils.addAll(THEME_SETTING, THEME_CONFIG); + return loadUnstructured(themePath, resourceNames); + } + + static Mono unzipThemeTo(InputStream inputStream, Path themeWorkDir) { + return unzipThemeTo(inputStream, themeWorkDir, false); + } + + 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) + .map(themeManifestPath -> { + var theme = loadThemeManifest(themeManifestPath); + var themeName = theme.getMetadata().getName(); + var themeTargetPath = themeWorkDir.resolve(themeName); + try { + if (!override && !FileUtils.isEmpty(themeTargetPath)) { + throw new ThemeInstallationException("Theme already exists."); + } + // install theme to theme work dir + copyRecursively(themeManifestPath.getParent(), themeTargetPath); + return theme; + } catch (IOException e) { + deleteRecursivelyAndSilently(themeTargetPath); + throw Exceptions.propagate(e); + } + }) + .doFinally(signalType -> deleteRecursivelyAndSilently(tempDir.get())) + .subscribeOn(Schedulers.boundedElastic()); + } + + static Unstructured loadThemeManifest(Path themeManifestPath) { + List unstructureds = + new YamlUnstructuredLoader(new FileSystemResource(themeManifestPath)) + .load(); + if (CollectionUtils.isEmpty(unstructureds)) { + throw new IllegalArgumentException( + "The [theme.yaml] does not conform to the theme specification."); + } + return unstructureds.get(0); + } + + @Nullable + static Path resolveThemeManifest(Path tempDirectory) { + for (String themeManifest : THEME_MANIFESTS) { + Path path = tempDirectory.resolve(themeManifest); + if (Files.exists(path)) { + return path; + } + } + return null; + } + + static Mono locateThemeManifest(Path dir) { + return Mono.justOrEmpty(dir) + .filter(Files::isDirectory) + .publishOn(Schedulers.boundedElastic()) + .mapNotNull(path -> { + var queue = new LinkedList(); + queue.add(dir); + var manifest = Optional.empty(); + while (!queue.isEmpty()) { + var current = queue.pop(); + try (Stream subPaths = Files.list(current)) { + manifest = subPaths.filter(Files::isReadable) + .filter(subPath -> { + if (Files.isDirectory(subPath)) { + queue.add(subPath); + return false; + } + return true; + }) + .filter(Files::isRegularFile) + .filter(ThemeUtils::isManifest) + .findFirst(); + } catch (IOException e) { + throw Exceptions.propagate(e); + } + if (manifest.isPresent()) { + break; + } + } + return manifest.orElse(null); + }); + } + + static boolean isManifest(Path file) { + if (!Files.isRegularFile(file)) { + return false; + } + return Set.of(THEME_MANIFESTS).contains(file.getFileName().toString()); + } + +} diff --git a/src/main/java/run/halo/app/infra/utils/FileUtils.java b/src/main/java/run/halo/app/infra/utils/FileUtils.java index d0114aa61..e3d2a91e4 100644 --- a/src/main/java/run/halo/app/infra/utils/FileUtils.java +++ b/src/main/java/run/halo/app/infra/utils/FileUtils.java @@ -1,5 +1,7 @@ package run.halo.app.infra.utils; +import static org.springframework.util.FileSystemUtils.deleteRecursively; + import java.io.Closeable; import java.io.IOException; import java.nio.file.DirectoryNotEmptyException; @@ -22,6 +24,9 @@ import run.halo.app.infra.exception.AccessDeniedException; @Slf4j public abstract class FileUtils { + private FileUtils() { + } + public static void unzip(@NonNull ZipInputStream zis, @NonNull Path targetPath) throws IOException { // 1. unzip file to folder @@ -171,4 +176,22 @@ public abstract class FileUtils { checkDirectoryTraversal(parentPath, Paths.get(pathToCheck)); } + /** + * Delete folder recursively without exception throwing. + * + * @param root the root File to delete + */ + public static void deleteRecursivelyAndSilently(Path root) { + try { + var deleted = deleteRecursively(root); + if (log.isDebugEnabled()) { + log.debug("Delete {} result: {}", root, deleted); + } + } catch (IOException e) { + // Ignore this error + if (log.isTraceEnabled()) { + log.trace("Failed to delete {} recursively", root); + } + } + } } diff --git a/src/test/java/run/halo/app/core/extension/endpoint/ThemeEndpointTest.java b/src/test/java/run/halo/app/core/extension/theme/ThemeEndpointTest.java similarity index 75% rename from src/test/java/run/halo/app/core/extension/endpoint/ThemeEndpointTest.java rename to src/test/java/run/halo/app/core/extension/theme/ThemeEndpointTest.java index 6b6547dbb..deb65a1e5 100644 --- a/src/test/java/run/halo/app/core/extension/endpoint/ThemeEndpointTest.java +++ b/src/test/java/run/halo/app/core/extension/theme/ThemeEndpointTest.java @@ -1,10 +1,13 @@ -package run.halo.app.core.extension.endpoint; +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.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; @@ -14,6 +17,7 @@ import java.util.List; import org.json.JSONException; 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.ArgumentCaptor; @@ -26,7 +30,6 @@ 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.reactive.function.BodyInserters; import reactor.core.publisher.Mono; import run.halo.app.core.extension.Setting; import run.halo.app.core.extension.Theme; @@ -78,6 +81,62 @@ class ThemeEndpointTest { FileSystemUtils.deleteRecursively(tmpHaloWorkDir.toFile()); } + @Nested + class UpgradeTest { + + @Test + void shouldNotOkIfThemeNotInstalled() { + var bodyBuilder = new MultipartBodyBuilder(); + bodyBuilder.part("file", new FileSystemResource(defaultTheme)) + .contentType(MediaType.MULTIPART_FORM_DATA); + + when(extensionClient.fetch(Theme.class, "invalid-theme")).thenReturn(Mono.empty()); + + webTestClient.post() + .uri("/themes/invalid-theme/upgrade") + .body(fromMultipartData(bodyBuilder.build())) + .exchange() + .expectStatus().isBadRequest(); + } + + @Test + void shouldUpgradeSuccessfullyIfThemeInstalled() { + var bodyBuilder = new MultipartBodyBuilder(); + 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))); + + webTestClient.post() + .uri("/themes/default/upgrade") + .body(fromMultipartData(bodyBuilder.build())) + .exchange() + .expectStatus().isOk(); + + verify(extensionClient, times(3)).fetch(Theme.class, "default"); + verify(extensionClient).delete(oldTheme); + verify(extensionClient).create(any(Unstructured.class)); + } + + } + @Test void install() { when(extensionClient.create(any(Unstructured.class))).thenReturn( @@ -87,7 +146,7 @@ class ThemeEndpointTest { return new YamlUnstructuredLoader(new FileSystemResource(defaultThemeManifestPath)) .load() .get(0); - })).thenReturn(Mono.empty()).thenReturn(Mono.empty()); + })); MultipartBodyBuilder multipartBodyBuilder = new MultipartBodyBuilder(); multipartBodyBuilder.part("file", new FileSystemResource(defaultTheme)) @@ -95,10 +154,9 @@ class ThemeEndpointTest { webTestClient.post() .uri("/themes/install") - .body(BodyInserters.fromMultipartData(multipartBodyBuilder.build())) + .body(fromMultipartData(multipartBodyBuilder.build())) .exchange() - .expectStatus() - .isOk() + .expectStatus().isOk() .expectBody(Theme.class) .value(theme -> { verify(extensionClient, times(1)).create(any(Unstructured.class)); @@ -110,10 +168,9 @@ class ThemeEndpointTest { // Verify the theme is installed. webTestClient.post() .uri("/themes/install") - .body(BodyInserters.fromMultipartData(multipartBodyBuilder.build())) + .body(fromMultipartData(multipartBodyBuilder.build())) .exchange() - .expectStatus() - .is5xxServerError(); + .expectStatus().is5xxServerError(); } @Test