mirror of https://github.com/halo-dev/halo
Provide an endpoint to upgrade theme (#2600)
#### 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:  #### 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 提供主题更新功能 ```pull/2601/head
parent
969c0d56f6
commit
e93f028a25
|
@ -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<String, Part> multipartData;
|
||||
|
||||
public UpgradeRequest(MultiValueMap<String, Part> 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<ServerResponse> upgrade(ServerRequest request) {
|
||||
var themeNameInPath = request.pathVariable("name");
|
||||
final var tempDir = new AtomicReference<Path>();
|
||||
final var tempThemeRoot = new AtomicReference<Path>();
|
||||
// validate the theme first
|
||||
return client.fetch(Theme.class, themeNameInPath)
|
||||
.switchIfEmpty(Mono.error(() -> new ServerWebInputException(
|
||||
"The given theme with name " + themeNameInPath + " does not exist")))
|
||||
.then(request.multipartData())
|
||||
.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<ListResult<Theme>> 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<ServerResponse> 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<Theme> 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<Path> walkThemesFromPath(Path path) {
|
||||
return Flux.using(() -> Files.walk(path, 2),
|
||||
Flux::fromStream,
|
||||
BaseStream::close
|
||||
)
|
||||
.subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
static List<Unstructured> loadThemeSetting(Path themePath) {
|
||||
return loadUnstructured(themePath, THEME_SETTING);
|
||||
}
|
||||
|
||||
private static List<Unstructured> loadUnstructured(Path themePath,
|
||||
String[] themeSetting) {
|
||||
List<Resource> 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<Unstructured> 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<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 : 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<Theme> deleteThemeAndWaitForComplete(String themeName) {
|
||||
return client.fetch(Theme.class, themeName)
|
||||
.flatMap(client::delete)
|
||||
.flatMap(deletingTheme -> waitForThemeDeleted(themeName)
|
||||
.thenReturn(deletingTheme));
|
||||
}
|
||||
|
||||
Mono<Void> waitForThemeDeleted(String themeName) {
|
||||
return client.fetch(Theme.class, themeName)
|
||||
.doOnNext(theme -> {
|
||||
throw new RetryException("Re-check if the theme is deleted successfully");
|
||||
})
|
||||
.retryWhen(Retry.fixedDelay(20, Duration.ofMillis(100))
|
||||
.filter(t -> t instanceof RetryException))
|
||||
.onErrorMap(Exceptions::isRetryExhausted,
|
||||
throwable -> new ServerErrorException("Wait timeout for theme deleted", throwable))
|
||||
.then();
|
||||
}
|
||||
}
|
|
@ -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<Unstructured> loadThemeSetting(Path themePath) {
|
||||
return loadUnstructured(themePath, THEME_SETTING);
|
||||
}
|
||||
|
||||
static Flux<Theme> 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<Path> walkThemesFromPath(Path path) {
|
||||
return Flux.using(() -> Files.walk(path, 2),
|
||||
Flux::fromStream,
|
||||
BaseStream::close
|
||||
)
|
||||
.subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
private static List<Unstructured> loadUnstructured(Path themePath,
|
||||
String[] themeSetting) {
|
||||
List<Resource> 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<Unstructured> loadThemeResources(Path themePath) {
|
||||
String[] resourceNames = ArrayUtils.addAll(THEME_SETTING, THEME_CONFIG);
|
||||
return loadUnstructured(themePath, resourceNames);
|
||||
}
|
||||
|
||||
static Mono<Unstructured> unzipThemeTo(InputStream inputStream, Path themeWorkDir) {
|
||||
return unzipThemeTo(inputStream, themeWorkDir, false);
|
||||
}
|
||||
|
||||
static Mono<Unstructured> unzipThemeTo(InputStream inputStream, Path themeWorkDir,
|
||||
boolean override) {
|
||||
AtomicReference<Path> 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<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
|
||||
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<Path> locateThemeManifest(Path dir) {
|
||||
return Mono.justOrEmpty(dir)
|
||||
.filter(Files::isDirectory)
|
||||
.publishOn(Schedulers.boundedElastic())
|
||||
.mapNotNull(path -> {
|
||||
var queue = new LinkedList<Path>();
|
||||
queue.add(dir);
|
||||
var manifest = Optional.<Path>empty();
|
||||
while (!queue.isEmpty()) {
|
||||
var current = queue.pop();
|
||||
try (Stream<Path> 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());
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
Loading…
Reference in New Issue