feat: add theme install endpoint (#2302)

<!--  Thanks for sending a pull request!  Here are some tips for you:
1. 如果这是你的第一次,请阅读我们的贡献指南:<https://github.com/halo-dev/halo/blob/master/CONTRIBUTING.md>。
1. If this is your first time, please read our contributor guidelines: <https://github.com/halo-dev/halo/blob/master/CONTRIBUTING.md>.
2. 请根据你解决问题的类型为 Pull Request 添加合适的标签。
2. Please label this pull request according to what type of issue you are addressing, especially if this is a release targeted pull request.
3. 请确保你已经添加并运行了适当的测试。
3. Ensure you have added or ran the appropriate tests for your PR.
-->

#### What type of PR is this?
/kind feature
/area core
/milestone 2.0
<!--
添加其中一个类别:
Add one of the following kinds:

/kind bug
/kind cleanup
/kind documentation
/kind feature
/kind improvement

适当添加其中一个或多个类别(可选):
Optionally add one or more of the following kinds if applicable:

/kind api-change
/kind deprecation
/kind failing-test
/kind flake
/kind regression
-->

#### What this PR does / why we need it:
新增主题安装功能,endpoint:POST /apis/api.halo.run/v1alpha1/themes/install
限制:主题不允许重复安装,重复安装属于更新功能
#### Which issue(s) this PR fixes:

<!--
PR 合并时自动关闭 issue。
Automatically closes linked issue when PR is merged.

用法:`Fixes #<issue 号>`,或者 `Fixes (粘贴 issue 完整链接)`
Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`.
-->
Fixes #2291

#### Special notes for your reviewer:
/cc @halo-dev/sig-halo 
#### Does this PR introduce a user-facing change?

<!--
如果当前 Pull Request 的修改不会造成用户侧的任何变更,在 `release-note` 代码块儿中填写 `NONE`。
否则请填写用户侧能够理解的 Release Note。如果当前 Pull Request 包含破坏性更新(Break Change),
Release Note 需要以 `action required` 开头。
If no, just write "NONE" in the release-note block below.
If yes, a release note is required:
Enter your extended release note in the block below. If the PR requires additional action from users switching to the new release, include the string "action required".
-->

```release-note
None
```
pull/2321/head
guqing 2022-08-04 17:24:16 +08:00 committed by GitHub
parent ebecef89c6
commit 349db687e3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 558 additions and 4 deletions

5
.gitignore vendored
View File

@ -68,4 +68,7 @@ nbdist/
### Local file
application-local.yml
application-local.yaml
application-local.properties
application-local.properties
### Zip file for test
!src/test/resources/themes/*.zip

View File

@ -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<SettingSpec> spec;

View File

@ -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;

View File

@ -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<ServerResponse> 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<ServerResponse> install(ServerRequest request) {
return request.bodyToMono(new ParameterizedTypeReference<MultiValueMap<String, Part>>() {
})
.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<Unstructured> 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<Unstructured> loadThemeResources(Path themePath) {
List<Resource> 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<Unstructured> 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<FilePart> getZipFilePart(MultiValueMap<String, Part> 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);
}
}

View File

@ -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<String, String> data;
public ConfigMap putDataItem(String key, String dataItem) {

View File

@ -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);
}
}

View File

@ -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<Path> 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<IOException> consumer) {
if (closeable != null) {
try {
closeable.close();
} catch (IOException e) {
if (consumer != null) {
consumer.accept(e);
}
}
}
}
}

View File

@ -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();
}
}

Binary file not shown.