diff --git a/.gitignore b/.gitignore index 13a5efbba..fda0af54e 100755 --- a/.gitignore +++ b/.gitignore @@ -68,4 +68,7 @@ nbdist/ ### Local file application-local.yml application-local.yaml -application-local.properties \ No newline at end of file +application-local.properties + +### Zip file for test +!src/test/resources/themes/*.zip \ No newline at end of file diff --git a/src/main/java/run/halo/app/core/extension/Setting.java b/src/main/java/run/halo/app/core/extension/Setting.java index a7294c4e3..9a60918b6 100644 --- a/src/main/java/run/halo/app/core/extension/Setting.java +++ b/src/main/java/run/halo/app/core/extension/Setting.java @@ -15,10 +15,12 @@ import run.halo.app.extension.GVK; */ @Data @EqualsAndHashCode(callSuper = true) -@GVK(group = "", version = "v1alpha1", kind = "Setting", +@GVK(group = "", version = "v1alpha1", kind = Setting.KIND, plural = "settings", singular = "setting") public class Setting extends AbstractExtension { + public static final String KIND = "Setting"; + @Schema(required = true, minLength = 1) private List spec; diff --git a/src/main/java/run/halo/app/core/extension/Theme.java b/src/main/java/run/halo/app/core/extension/Theme.java index 9ecfe7789..3f3506751 100644 --- a/src/main/java/run/halo/app/core/extension/Theme.java +++ b/src/main/java/run/halo/app/core/extension/Theme.java @@ -16,10 +16,12 @@ import run.halo.app.extension.GVK; @Data @ToString(callSuper = true) @EqualsAndHashCode(callSuper = true) -@GVK(group = "theme.halo.run", version = "v1alpha1", kind = "Theme", +@GVK(group = "theme.halo.run", version = "v1alpha1", kind = Theme.KIND, plural = "themes", singular = "theme") public class Theme extends AbstractExtension { + public static final String KIND = "Theme"; + @Schema(required = true) private ThemeSpec spec; diff --git a/src/main/java/run/halo/app/core/extension/endpoint/ThemeEndpoint.java b/src/main/java/run/halo/app/core/extension/endpoint/ThemeEndpoint.java new file mode 100644 index 000000000..363779cdd --- /dev/null +++ b/src/main/java/run/halo/app/core/extension/endpoint/ThemeEndpoint.java @@ -0,0 +1,280 @@ +package run.halo.app.core.extension.endpoint; + +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.requestbody.Builder.requestBodyBuilder; +import static org.springframework.web.reactive.function.server.RequestPredicates.contentType; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.io.IOException; +import java.io.InputStream; +import java.io.SequenceInputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +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.ParameterizedTypeReference; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.MediaType; +import org.springframework.http.codec.multipart.FilePart; +import org.springframework.http.codec.multipart.Part; +import org.springframework.lang.Nullable; +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.server.RouterFunction; +import org.springframework.web.reactive.function.server.ServerRequest; +import org.springframework.web.reactive.function.server.ServerResponse; +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.ConfigMap; +import run.halo.app.extension.ExtensionClient; +import run.halo.app.extension.Unstructured; +import run.halo.app.infra.ThemeInstallationException; +import run.halo.app.infra.properties.HaloProperties; +import run.halo.app.infra.utils.FileUtils; +import run.halo.app.infra.utils.YamlUnstructuredLoader; + +/** + * Endpoint for managing themes. + * + * @author guqing + * @since 2.0.0 + */ +@Component +public class ThemeEndpoint implements CustomEndpoint { + + private final ExtensionClient client; + private final HaloProperties haloProperties; + + public ThemeEndpoint(ExtensionClient client, HaloProperties haloProperties) { + this.client = client; + this.haloProperties = haloProperties; + } + + @Override + public RouterFunction endpoint() { + final var tag = "api.halo.run/v1alpha1/Theme"; + return SpringdocRouteBuilder.route() + .POST("themes/install", contentType(MediaType.MULTIPART_FORM_DATA), + this::install, builder -> builder.operationId("InstallTheme") + .description("Install a theme by uploading a zip file.") + .tag(tag) + .requestBody(requestBodyBuilder() + .required(true) + .content(contentBuilder() + .mediaType(MediaType.MULTIPART_FORM_DATA_VALUE) + .schema(Builder.schemaBuilder() + .implementation(InstallRequest.class)) + )) + .response(responseBuilder() + .implementation(Theme.class)) + ) + .build(); + } + + public record InstallRequest( + @Schema(required = true, description = "Theme zip file.") FilePart file) { + } + + Mono install(ServerRequest request) { + return request.bodyToMono(new ParameterizedTypeReference>() { + }) + .flatMap(this::getZipFilePart) + .flatMap(file -> file.content() + .map(DataBuffer::asInputStream) + .reduce(SequenceInputStream::new) + .map(inputStream -> ThemeUtils.unzipThemeTo(inputStream, getThemeWorkDir()))) + .map(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 Theme persistent(Unstructured themeManifest) { + Assert.state(StringUtils.equals(Theme.KIND, themeManifest.getKind()), + "Theme manifest kind must be Theme."); + client.create(themeManifest); + Theme theme = client.fetch(Theme.class, themeManifest.getMetadata().getName()) + .orElseThrow(); + List unstructureds = ThemeUtils.loadThemeResources(getThemePath(theme)); + if (unstructureds.stream() + .filter(unstructured -> unstructured.getKind().equals(Setting.KIND)) + .filter(unstructured -> unstructured.getMetadata().getName() + .equals(theme.getSpec().getSettingName())) + .count() > 1) { + throw new IllegalStateException( + "Theme must only have one settings.yaml or settings.yml."); + } + if (unstructureds.stream() + .filter(unstructured -> unstructured.getKind().equals(ConfigMap.KIND)) + .filter(unstructured -> unstructured.getMetadata().getName() + .equals(theme.getSpec().getConfigMapName())) + .count() > 1) { + throw new IllegalStateException( + "Theme must only have one config.yaml or config.yml."); + } + Theme.ThemeSpec spec = theme.getSpec(); + for (Unstructured unstructured : unstructureds) { + 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) { + client.create(unstructured); + } + } + return theme; + } + + private Path getThemePath(Theme theme) { + return getThemeWorkDir().resolve(theme.getMetadata().getName()); + } + + static class ThemeUtils { + private static final String THEME_TMP_PREFIX = "halo-theme-"; + private static final String[] themeManifests = {"theme.yaml", "theme.yml"}; + private static final String[] THEME_RESOURCES = { + "settings.yaml", + "settings.yml", + "config.yaml", + "config.yml" + }; + + static List loadThemeResources(Path themePath) { + List resources = new ArrayList<>(4); + for (String themeResource : THEME_RESOURCES) { + 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 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) { + FileSystemUtils.deleteRecursively(tempDirectory); + 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 + } + } + } + + private 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 : themeManifests) { + Path path = tempDirectory.resolve(themeManifest); + if (Files.exists(path)) { + return path; + } + } + return null; + } + } + + private Path getThemeWorkDir() { + Path themePath = haloProperties.getWorkDir() + .resolve("themes"); + if (Files.notExists(themePath)) { + try { + Files.createDirectories(themePath); + } catch (IOException e) { + throw new UnsupportedOperationException( + "Failed to create directory " + themePath, e); + } + } + return themePath; + } + + Mono getZipFilePart(MultiValueMap formData) { + Part part = formData.getFirst("file"); + if (!(part instanceof FilePart file)) { + return Mono.error(new ServerWebInputException( + "Invalid parameter of file, binary data is required")); + } + if (!Paths.get(file.filename()).toString().endsWith(".zip")) { + return Mono.error(new ServerWebInputException( + "Invalid file type, only zip format is supported")); + } + return Mono.just(file); + } +} diff --git a/src/main/java/run/halo/app/extension/ConfigMap.java b/src/main/java/run/halo/app/extension/ConfigMap.java index 770563dab..abcf422cd 100644 --- a/src/main/java/run/halo/app/extension/ConfigMap.java +++ b/src/main/java/run/halo/app/extension/ConfigMap.java @@ -15,10 +15,12 @@ import lombok.ToString; @Data @ToString(callSuper = true) @EqualsAndHashCode(callSuper = true) -@GVK(group = "", version = "v1alpha1", kind = "ConfigMap", plural = "configmaps", +@GVK(group = "", version = "v1alpha1", kind = ConfigMap.KIND, plural = "configmaps", singular = "configmap") public class ConfigMap extends AbstractExtension { + public static final String KIND = "ConfigMap"; + private Map data; public ConfigMap putDataItem(String key, String dataItem) { diff --git a/src/main/java/run/halo/app/infra/ThemeInstallationException.java b/src/main/java/run/halo/app/infra/ThemeInstallationException.java new file mode 100644 index 000000000..bf9dc4151 --- /dev/null +++ b/src/main/java/run/halo/app/infra/ThemeInstallationException.java @@ -0,0 +1,15 @@ +package run.halo.app.infra; + +/** + * @author guqing + * @since 2.0.0 + */ +public class ThemeInstallationException extends RuntimeException { + public ThemeInstallationException(String message) { + super(message); + } + + public ThemeInstallationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/run/halo/app/infra/utils/FileUtils.java b/src/main/java/run/halo/app/infra/utils/FileUtils.java new file mode 100644 index 000000000..5ad18556e --- /dev/null +++ b/src/main/java/run/halo/app/infra/utils/FileUtils.java @@ -0,0 +1,134 @@ +package run.halo.app.infra.utils; + +import java.io.Closeable; +import java.io.IOException; +import java.nio.file.DirectoryNotEmptyException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.function.Consumer; +import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import lombok.extern.slf4j.Slf4j; +import org.springframework.lang.NonNull; +import org.springframework.util.Assert; + +/** + * @author guqing + * @since 2.0.0 + */ +@Slf4j +public abstract class FileUtils { + + public static void unzip(@NonNull ZipInputStream zis, @NonNull Path targetPath) + throws IOException { + // 1. unzip file to folder + // 2. return the folder path + Assert.notNull(zis, "Zip input stream must not be null"); + Assert.notNull(targetPath, "Target path must not be null"); + + // Create path if absent + createIfAbsent(targetPath); + + // Folder must be empty + ensureEmpty(targetPath); + + ZipEntry zipEntry = zis.getNextEntry(); + + while (zipEntry != null) { + // Resolve the entry path + Path entryPath = targetPath.resolve(zipEntry.getName()); + + // Check directory + if (targetPath.normalize().startsWith(entryPath)) { + throw new IllegalArgumentException("Cannot unzip to a subdirectory of itself"); + } + + if (Files.notExists(entryPath.getParent())) { + Files.createDirectories(entryPath.getParent()); + } + + if (zipEntry.isDirectory()) { + // Create directory + Files.createDirectory(entryPath); + } else { + // Copy file + Files.copy(zis, entryPath); + } + + zipEntry = zis.getNextEntry(); + } + } + + /** + * Creates directories if absent. + * + * @param path path must not be null + * @throws IOException io exception + */ + public static void createIfAbsent(@NonNull Path path) throws IOException { + Assert.notNull(path, "Path must not be null"); + + if (Files.notExists(path)) { + // Create directories + Files.createDirectories(path); + + log.debug("Created directory: [{}]", path); + } + } + + /** + * The given path must be empty. + * + * @param path path must not be null + * @throws IOException io exception + */ + public static void ensureEmpty(@NonNull Path path) throws IOException { + if (!isEmpty(path)) { + throw new DirectoryNotEmptyException("Target directory: " + path + " was not empty"); + } + } + + /** + * Checks if the given path is empty. + * + * @param path path must not be null + * @return true if the given path is empty; false otherwise + * @throws IOException io exception + */ + public static boolean isEmpty(@NonNull Path path) throws IOException { + Assert.notNull(path, "Path must not be null"); + + if (!Files.isDirectory(path) || Files.notExists(path)) { + return true; + } + + try (Stream pathStream = Files.list(path)) { + return pathStream.findAny().isEmpty(); + } + } + + public static void closeQuietly(final Closeable closeable) { + closeQuietly(closeable, null); + } + + /** + * Closes the given {@link Closeable} as a null-safe operation while consuming IOException by + * the given {@code consumer}. + * + * @param closeable The resource to close, may be null. + * @param consumer Consumes the IOException thrown by {@link Closeable#close()}. + */ + public static void closeQuietly(final Closeable closeable, + final Consumer consumer) { + if (closeable != null) { + try { + closeable.close(); + } catch (IOException e) { + if (consumer != null) { + consumer.accept(e); + } + } + } + } +} diff --git a/src/test/java/run/halo/app/core/extension/endpoint/ThemeEndpointTest.java b/src/test/java/run/halo/app/core/extension/endpoint/ThemeEndpointTest.java new file mode 100644 index 000000000..8dc7f60ed --- /dev/null +++ b/src/test/java/run/halo/app/core/extension/endpoint/ThemeEndpointTest.java @@ -0,0 +1,116 @@ +package run.halo.app.core.extension.endpoint; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.io.FileSystemResource; +import org.springframework.http.MediaType; +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 run.halo.app.core.extension.Theme; +import run.halo.app.extension.ExtensionClient; +import run.halo.app.extension.Unstructured; +import run.halo.app.infra.properties.HaloProperties; +import run.halo.app.infra.utils.YamlUnstructuredLoader; + +/** + * Tests for {@link ThemeEndpoint}. + * + * @author guqing + * @since 2.0.0 + */ +@ExtendWith(MockitoExtension.class) +class ThemeEndpointTest { + + @Mock + private ExtensionClient extensionClient; + + @Mock + private HaloProperties haloProperties; + + private Path tmpHaloWorkDir; + + WebTestClient webTestClient; + + private File defaultTheme; + + @BeforeEach + void setUp() throws IOException { + tmpHaloWorkDir = Files.createTempDirectory("halo-unit-test"); + + ThemeEndpoint themeEndpoint = new ThemeEndpoint(extensionClient, haloProperties); + + when(haloProperties.getWorkDir()).thenReturn(tmpHaloWorkDir); + + defaultTheme = ResourceUtils.getFile("classpath:themes/test-theme.zip"); + + webTestClient = WebTestClient + .bindToRouterFunction(themeEndpoint.endpoint()) + .build(); + } + + @AfterEach + void tearDown() { + FileSystemUtils.deleteRecursively(tmpHaloWorkDir.toFile()); + } + + @Test + void install() { + when(extensionClient.fetch(eq(Theme.class), eq("default"))) + .then(answer -> { + Path defaultThemeManifestPath = tmpHaloWorkDir.resolve("themes/default/theme.yaml"); + assertThat(Files.exists(defaultThemeManifestPath)).isTrue(); + + Unstructured unstructured = + new YamlUnstructuredLoader(new FileSystemResource(defaultThemeManifestPath)) + .load() + .get(0); + return Optional.of( + Unstructured.OBJECT_MAPPER.convertValue(unstructured, Theme.class)); + }); + + MultipartBodyBuilder multipartBodyBuilder = new MultipartBodyBuilder(); + multipartBodyBuilder.part("file", new FileSystemResource(defaultTheme)) + .contentType(MediaType.MULTIPART_FORM_DATA); + + webTestClient.post() + .uri("/themes/install") + .body(BodyInserters.fromMultipartData(multipartBodyBuilder.build())) + .exchange() + .expectStatus() + .isOk() + .expectBody(Theme.class) + .value(theme -> { + verify(extensionClient, times(1)).create(any(Unstructured.class)); + + assertThat(theme).isNotNull(); + assertThat(theme.getMetadata().getName()).isEqualTo("default"); + }); + + // Verify the theme is installed. + webTestClient.post() + .uri("/themes/install") + .body(BodyInserters.fromMultipartData(multipartBodyBuilder.build())) + .exchange() + .expectStatus() + .is5xxServerError(); + } +} \ No newline at end of file diff --git a/src/test/resources/themes/test-theme.zip b/src/test/resources/themes/test-theme.zip new file mode 100644 index 000000000..7fb443d7c Binary files /dev/null and b/src/test/resources/themes/test-theme.zip differ