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:

![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
提供主题更新功能
```
pull/2601/head
John Niang 2022-10-21 10:16:12 +08:00 committed by GitHub
parent 969c0d56f6
commit e93f028a25
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 424 additions and 154 deletions

View File

@ -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.apiresponse.Builder.responseBuilder;
import static org.springdoc.core.fn.builders.content.Builder.contentBuilder; 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.parameter.Builder.parameterBuilder;
import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; 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 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.enums.ParameterIn;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.util.ArrayList; import java.time.Duration;
import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Predicate; import java.util.function.Predicate;
import java.util.stream.BaseStream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream; import java.util.zip.ZipInputStream;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springdoc.core.fn.builders.schema.Builder;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; 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.MediaType;
import org.springframework.http.codec.multipart.FilePart; import org.springframework.http.codec.multipart.FilePart;
import org.springframework.http.codec.multipart.Part; import org.springframework.http.codec.multipart.Part;
import org.springframework.lang.NonNull; import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable; import org.springframework.retry.RetryException;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.Assert; import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.FileSystemUtils;
import org.springframework.util.MultiValueMap; import org.springframework.util.MultiValueMap;
import org.springframework.web.reactive.function.BodyExtractors; import org.springframework.web.reactive.function.BodyExtractors;
import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.server.ServerErrorException;
import org.springframework.web.server.ServerWebInputException; import org.springframework.web.server.ServerWebInputException;
import reactor.core.Exceptions; import reactor.core.Exceptions;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; 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.Setting;
import run.halo.app.core.extension.Theme; 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.ConfigMap;
import run.halo.app.extension.ListResult; import run.halo.app.extension.ListResult;
import run.halo.app.extension.ReactiveExtensionClient; 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.extension.router.QueryParamBuildUtil;
import run.halo.app.infra.exception.ThemeInstallationException; import run.halo.app.infra.exception.ThemeInstallationException;
import run.halo.app.infra.properties.HaloProperties; 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; import run.halo.app.theme.ThemePathPolicy;
/** /**
@ -93,12 +94,21 @@ public class ThemeEndpoint implements CustomEndpoint {
.required(true) .required(true)
.content(contentBuilder() .content(contentBuilder()
.mediaType(MediaType.MULTIPART_FORM_DATA_VALUE) .mediaType(MediaType.MULTIPART_FORM_DATA_VALUE)
.schema(Builder.schemaBuilder() .schema(schemaBuilder()
.implementation(InstallRequest.class)) .implementation(InstallRequest.class))
)) ))
.response(responseBuilder() .response(responseBuilder()
.implementation(Theme.class)) .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, .PUT("themes/{name}/reload-setting", this::reloadSetting,
builder -> builder.operationId("ReloadThemeSetting") builder -> builder.operationId("ReloadThemeSetting")
.description("Reload theme setting.") .description("Reload theme setting.")
@ -148,6 +158,97 @@ public class ThemeEndpoint implements CustomEndpoint {
}).flatMap(extensions -> ServerResponse.ok().bodyValue(extensions)); }).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) { Mono<ListResult<Theme>> listUninstalled(ThemeQuery query) {
Path path = themePathPolicy.themesDir(); Path path = themePathPolicy.themesDir();
return ThemeUtils.listAllThemesFromThemeDir(path) return ThemeUtils.listAllThemesFromThemeDir(path)
@ -201,7 +302,7 @@ public class ThemeEndpoint implements CustomEndpoint {
return Mono.error(new IllegalArgumentException( return Mono.error(new IllegalArgumentException(
"The manifest file [theme.yaml] is required.")); "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); Theme newTheme = Unstructured.OBJECT_MAPPER.convertValue(unstructured, Theme.class);
themeToUse.setSpec(newTheme.getSpec()); themeToUse.setSpec(newTheme.getSpec());
return client.update(themeToUse); return client.update(themeToUse);
@ -218,19 +319,18 @@ public class ThemeEndpoint implements CustomEndpoint {
Mono<ServerResponse> install(ServerRequest request) { Mono<ServerResponse> install(ServerRequest request) {
return request.body(BodyExtractors.toMultipartData()) return request.body(BodyExtractors.toMultipartData())
.flatMap(this::getZipFilePart) .flatMap(this::getZipFilePart)
.map(file -> { .flatMap(file -> {
try { try {
var is = DataBufferUtils.toInputStream(file.content()); var is = toInputStream(file.content());
var themeWorkDir = getThemeWorkDir(); var themeWorkDir = getThemeWorkDir();
if (log.isDebugEnabled()) { if (log.isDebugEnabled()) {
log.debug("Transferring {} into {}", file.filename(), themeWorkDir); log.debug("Transferring {} into {}", file.filename(), themeWorkDir);
} }
return ThemeUtils.unzipThemeTo(is, themeWorkDir); return unzipThemeTo(is, themeWorkDir);
} catch (IOException e) { } catch (IOException e) {
throw Exceptions.propagate(e); return Mono.error(Exceptions.propagate(e));
} }
}) })
.subscribeOn(Schedulers.boundedElastic())
.flatMap(this::persistent) .flatMap(this::persistent)
.flatMap(theme -> ServerResponse.ok() .flatMap(theme -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
@ -298,127 +398,6 @@ public class ThemeEndpoint implements CustomEndpoint {
&& theme.getSpec().getConfigMapName().equals(unstructured.getMetadata().getName()); && 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() { private Path getThemeWorkDir() {
Path themePath = haloProperties.getWorkDir() Path themePath = haloProperties.getWorkDir()
.resolve("themes"); .resolve("themes");
@ -445,4 +424,23 @@ public class ThemeEndpoint implements CustomEndpoint {
} }
return Mono.just(file); 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();
}
} }

View File

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

View File

@ -1,5 +1,7 @@
package run.halo.app.infra.utils; package run.halo.app.infra.utils;
import static org.springframework.util.FileSystemUtils.deleteRecursively;
import java.io.Closeable; import java.io.Closeable;
import java.io.IOException; import java.io.IOException;
import java.nio.file.DirectoryNotEmptyException; import java.nio.file.DirectoryNotEmptyException;
@ -22,6 +24,9 @@ import run.halo.app.infra.exception.AccessDeniedException;
@Slf4j @Slf4j
public abstract class FileUtils { public abstract class FileUtils {
private FileUtils() {
}
public static void unzip(@NonNull ZipInputStream zis, @NonNull Path targetPath) public static void unzip(@NonNull ZipInputStream zis, @NonNull Path targetPath)
throws IOException { throws IOException {
// 1. unzip file to folder // 1. unzip file to folder
@ -171,4 +176,22 @@ public abstract class FileUtils {
checkDirectoryTraversal(parentPath, Paths.get(pathToCheck)); 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);
}
}
}
} }

View File

@ -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.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; 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.File;
import java.io.IOException; import java.io.IOException;
@ -14,6 +17,7 @@ import java.util.List;
import org.json.JSONException; import org.json.JSONException;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor; 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.test.web.reactive.server.WebTestClient;
import org.springframework.util.FileSystemUtils; import org.springframework.util.FileSystemUtils;
import org.springframework.util.ResourceUtils; import org.springframework.util.ResourceUtils;
import org.springframework.web.reactive.function.BodyInserters;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Setting; import run.halo.app.core.extension.Setting;
import run.halo.app.core.extension.Theme; import run.halo.app.core.extension.Theme;
@ -78,6 +81,62 @@ class ThemeEndpointTest {
FileSystemUtils.deleteRecursively(tmpHaloWorkDir.toFile()); 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 @Test
void install() { void install() {
when(extensionClient.create(any(Unstructured.class))).thenReturn( when(extensionClient.create(any(Unstructured.class))).thenReturn(
@ -87,7 +146,7 @@ class ThemeEndpointTest {
return new YamlUnstructuredLoader(new FileSystemResource(defaultThemeManifestPath)) return new YamlUnstructuredLoader(new FileSystemResource(defaultThemeManifestPath))
.load() .load()
.get(0); .get(0);
})).thenReturn(Mono.empty()).thenReturn(Mono.empty()); }));
MultipartBodyBuilder multipartBodyBuilder = new MultipartBodyBuilder(); MultipartBodyBuilder multipartBodyBuilder = new MultipartBodyBuilder();
multipartBodyBuilder.part("file", new FileSystemResource(defaultTheme)) multipartBodyBuilder.part("file", new FileSystemResource(defaultTheme))
@ -95,10 +154,9 @@ class ThemeEndpointTest {
webTestClient.post() webTestClient.post()
.uri("/themes/install") .uri("/themes/install")
.body(BodyInserters.fromMultipartData(multipartBodyBuilder.build())) .body(fromMultipartData(multipartBodyBuilder.build()))
.exchange() .exchange()
.expectStatus() .expectStatus().isOk()
.isOk()
.expectBody(Theme.class) .expectBody(Theme.class)
.value(theme -> { .value(theme -> {
verify(extensionClient, times(1)).create(any(Unstructured.class)); verify(extensionClient, times(1)).create(any(Unstructured.class));
@ -110,10 +168,9 @@ class ThemeEndpointTest {
// Verify the theme is installed. // Verify the theme is installed.
webTestClient.post() webTestClient.post()
.uri("/themes/install") .uri("/themes/install")
.body(BodyInserters.fromMultipartData(multipartBodyBuilder.build())) .body(fromMultipartData(multipartBodyBuilder.build()))
.exchange() .exchange()
.expectStatus() .expectStatus().is5xxServerError();
.is5xxServerError();
} }
@Test @Test