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 e78c2a619..b755ec1f4 100644
--- a/src/main/java/run/halo/app/core/extension/Theme.java
+++ b/src/main/java/run/halo/app/core/extension/Theme.java
@@ -5,10 +5,13 @@ import java.util.List;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
+import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.lang.NonNull;
+import org.springframework.util.Assert;
import run.halo.app.extension.AbstractExtension;
import run.halo.app.extension.GVK;
+import run.halo.app.infra.ConditionList;
/**
*
Theme extension.
@@ -53,8 +56,12 @@ public class Theme extends AbstractExtension {
private String version;
+ @Deprecated(forRemoval = true, since = "2.2.0")
+ @Schema(description = "Deprecated, use `requires` instead.")
private String require;
+ private String requires;
+
private String settingName;
private String configMapName;
@@ -64,26 +71,53 @@ public class Theme extends AbstractExtension {
@NonNull
public String getVersion() {
- if (StringUtils.isBlank(this.version)) {
- return WILDCARD;
- }
- return version;
+ return StringUtils.defaultString(this.version, WILDCARD);
}
+ /**
+ * if requires is not empty, then return requires, else return require or {@code WILDCARD}.
+ *
+ * @return requires to satisfies system version
+ */
@NonNull
- public String getRequire() {
- if (StringUtils.isBlank(this.require)) {
- return WILDCARD;
+ public String getRequires() {
+ if (StringUtils.isNotBlank(this.requires)) {
+ return this.requires;
}
- return require;
+ return StringUtils.defaultString(this.require, WILDCARD);
}
}
@Data
public static class ThemeStatus {
+ private ThemePhase phase;
+ private ConditionList conditions;
private String location;
}
+ /**
+ * Null-safe get {@link ConditionList} from theme status.
+ *
+ * @param theme theme must not be null
+ * @return condition list
+ */
+ public static ConditionList nullSafeConditionList(Theme theme) {
+ Assert.notNull(theme, "The theme must not be null");
+ ThemeStatus status = ObjectUtils.defaultIfNull(theme.getStatus(), new ThemeStatus());
+ theme.setStatus(status);
+
+ ConditionList conditions =
+ ObjectUtils.defaultIfNull(status.getConditions(), new ConditionList());
+ status.setConditions(conditions);
+ return conditions;
+ }
+
+ public enum ThemePhase {
+ READY,
+ FAILED,
+ UNKNOWN,
+ }
+
@Data
@ToString
public static class Author {
diff --git a/src/main/java/run/halo/app/core/extension/reconciler/ThemeReconciler.java b/src/main/java/run/halo/app/core/extension/reconciler/ThemeReconciler.java
index 412e08c56..e97ae9c8e 100644
--- a/src/main/java/run/halo/app/core/extension/reconciler/ThemeReconciler.java
+++ b/src/main/java/run/halo/app/core/extension/reconciler/ThemeReconciler.java
@@ -2,6 +2,7 @@ package run.halo.app.core.extension.reconciler;
import java.io.IOException;
import java.nio.file.Path;
+import java.time.Instant;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
@@ -24,9 +25,13 @@ import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.extension.controller.Reconciler.Request;
+import run.halo.app.infra.Condition;
+import run.halo.app.infra.ConditionStatus;
+import run.halo.app.infra.SystemVersionSupplier;
import run.halo.app.infra.exception.ThemeUninstallException;
import run.halo.app.infra.properties.HaloProperties;
import run.halo.app.infra.utils.JsonUtils;
+import run.halo.app.infra.utils.VersionUtils;
import run.halo.app.theme.ThemePathPolicy;
/**
@@ -41,6 +46,7 @@ public class ThemeReconciler implements Reconciler {
private final ExtensionClient client;
private final ThemePathPolicy themePathPolicy;
+ private final SystemVersionSupplier systemVersionSupplier;
private final RetryTemplate retryTemplate = RetryTemplate.builder()
.maxAttempts(20)
@@ -48,9 +54,11 @@ public class ThemeReconciler implements Reconciler {
.retryOn(IllegalStateException.class)
.build();
- public ThemeReconciler(ExtensionClient client, HaloProperties haloProperties) {
+ public ThemeReconciler(ExtensionClient client, HaloProperties haloProperties,
+ SystemVersionSupplier systemVersionSupplier) {
this.client = client;
themePathPolicy = new ThemePathPolicy(haloProperties.getWorkDir());
+ this.systemVersionSupplier = systemVersionSupplier;
}
@Override
@@ -77,12 +85,35 @@ public class ThemeReconciler implements Reconciler {
private void reconcileStatus(String name) {
client.fetch(Theme.class, name).ifPresent(theme -> {
- Theme oldTheme = JsonUtils.deepCopy(theme);
+ final Theme oldTheme = JsonUtils.deepCopy(theme);
if (theme.getStatus() == null) {
theme.setStatus(new Theme.ThemeStatus());
}
+ Theme.ThemeStatus status = theme.getStatus();
+
Path themePath = themePathPolicy.generate(theme);
- theme.getStatus().setLocation(themePath.toAbsolutePath().toString());
+ status.setLocation(themePath.toAbsolutePath().toString());
+ if (status.getPhase() == null) {
+ status.setPhase(Theme.ThemePhase.READY);
+ }
+
+ // Check if this theme version is match requires param.
+ String normalVersion = systemVersionSupplier.get().getNormalVersion();
+ String requires = theme.getSpec().getRequires();
+ if (!VersionUtils.satisfiesRequires(normalVersion, requires)) {
+ status.setPhase(Theme.ThemePhase.FAILED);
+ Condition condition = Condition.builder()
+ .type(Theme.ThemePhase.FAILED.name())
+ .status(ConditionStatus.FALSE)
+ .reason("UnsatisfiedRequiresVersion")
+ .message(String.format(
+ "Theme requires a minimum system version of [%s], and you have [%s].",
+ requires, normalVersion))
+ .lastTransitionTime(Instant.now())
+ .build();
+ Theme.nullSafeConditionList(theme).add(condition);
+ }
+
if (!oldTheme.equals(theme)) {
client.update(theme);
}
diff --git a/src/main/java/run/halo/app/core/extension/theme/ThemeServiceImpl.java b/src/main/java/run/halo/app/core/extension/theme/ThemeServiceImpl.java
index 285c0d3cf..a7a58c9dc 100644
--- a/src/main/java/run/halo/app/core/extension/theme/ThemeServiceImpl.java
+++ b/src/main/java/run/halo/app/core/extension/theme/ThemeServiceImpl.java
@@ -17,6 +17,7 @@ import java.util.Objects;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Predicate;
import java.util.zip.ZipInputStream;
+import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.dao.OptimisticLockingFailureException;
@@ -37,21 +38,22 @@ import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.ExtensionUtil;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Unstructured;
+import run.halo.app.infra.SystemVersionSupplier;
import run.halo.app.infra.ThemeRootGetter;
import run.halo.app.infra.exception.ThemeUpgradeException;
+import run.halo.app.infra.exception.UnsatisfiedAttributeValueException;
+import run.halo.app.infra.utils.VersionUtils;
@Slf4j
@Service
+@AllArgsConstructor
public class ThemeServiceImpl implements ThemeService {
private final ReactiveExtensionClient client;
private final ThemeRootGetter themeRoot;
- public ThemeServiceImpl(ReactiveExtensionClient client, ThemeRootGetter themeRoot) {
- this.client = client;
- this.themeRoot = themeRoot;
- }
+ private final SystemVersionSupplier systemVersionSupplier;
@Override
public Mono install(InputStream is) {
@@ -139,6 +141,18 @@ public class ThemeServiceImpl implements ThemeService {
"Theme manifest kind must be Theme.");
return client.create(themeManifest)
.map(theme -> Unstructured.OBJECT_MAPPER.convertValue(theme, Theme.class))
+ .doOnNext(theme -> {
+ String systemVersion = systemVersionSupplier.get().getNormalVersion();
+ String requires = theme.getSpec().getRequires();
+ if (!VersionUtils.satisfiesRequires(systemVersion, requires)) {
+ throw new UnsatisfiedAttributeValueException(
+ String.format("The theme requires a minimum system version of %s, "
+ + "but the current version is %s.",
+ requires, systemVersion),
+ "problemDetail.theme.version.unsatisfied.requires",
+ new String[] {requires, systemVersion});
+ }
+ })
.flatMap(theme -> {
var unstructureds = ThemeUtils.loadThemeResources(getThemePath(theme));
if (unstructureds.stream()
diff --git a/src/main/resources/config/i18n/messages.properties b/src/main/resources/config/i18n/messages.properties
index 77c0d267b..89463b3d7 100644
--- a/src/main/resources/config/i18n/messages.properties
+++ b/src/main/resources/config/i18n/messages.properties
@@ -34,6 +34,7 @@ problemDetail.theme.upgrade.missingManifest=Missing theme manifest file "theme.y
problemDetail.theme.upgrade.nameMismatch=The current theme name {0} did not match the installed theme name.
problemDetail.theme.install.missingManifest=Missing theme manifest file "theme.yaml" or "theme.yml".
problemDetail.theme.install.alreadyExists=Theme {0} already exists.
+problemDetail.theme.version.unsatisfied.requires=The theme requires a minimum system version of {0}, but the current version is {1}.
problemDetail.directoryTraversal=Directory traversal detected. Base path is {0}, but real path is {1}.
problemDetail.plugin.version.unsatisfied.requires=Plugin requires a minimum system version of {0}, but the current version is {1}.
diff --git a/src/main/resources/config/i18n/messages_zh.properties b/src/main/resources/config/i18n/messages_zh.properties
index c7fd50708..2d7c204e3 100644
--- a/src/main/resources/config/i18n/messages_zh.properties
+++ b/src/main/resources/config/i18n/messages_zh.properties
@@ -5,3 +5,5 @@ problemDetail.title.run.halo.app.infra.exception.AttachmentAlreadyExistsExceptio
problemDetail.run.halo.app.infra.exception.AttachmentAlreadyExistsException=文件 {0} 已存在,建议更名后重试。
problemDetail.plugin.version.unsatisfied.requires=插件要求一个最小的系统版本为 {0}, 但当前版本为 {1}。
+
+problemDetail.theme.version.unsatisfied.requires=主题要求一个最小的系统版本为 {0}, 但当前版本为 {1}。
\ No newline at end of file
diff --git a/src/test/java/run/halo/app/core/extension/ThemeTest.java b/src/test/java/run/halo/app/core/extension/ThemeTest.java
index 49340178f..20c8a52d8 100644
--- a/src/test/java/run/halo/app/core/extension/ThemeTest.java
+++ b/src/test/java/run/halo/app/core/extension/ThemeTest.java
@@ -45,7 +45,7 @@ class ThemeTest {
themeSpec.setSettingName("test-setting");
themeSpec.setVersion(null);
- themeSpec.setRequire(null);
+ themeSpec.setRequires(null);
JSONAssert.assertEquals("""
{
"spec": {
@@ -59,7 +59,7 @@ class ThemeTest {
"website": "https://test.com",
"repo": "https://test.com",
"version": "*",
- "require": "*",
+ "requires": "*",
"settingName": "test-setting",
"configMapName": "test-config-map"
},
@@ -74,9 +74,9 @@ class ThemeTest {
true);
themeSpec.setVersion("1.0.0");
- themeSpec.setRequire("2.0.0");
+ themeSpec.setRequires("2.0.0");
assertThat(themeSpec.getVersion()).isEqualTo("1.0.0");
- assertThat(themeSpec.getRequire()).isEqualTo("2.0.0");
+ assertThat(themeSpec.getRequires()).isEqualTo("2.0.0");
}
@Test
diff --git a/src/test/java/run/halo/app/core/extension/reconciler/ThemeReconcilerTest.java b/src/test/java/run/halo/app/core/extension/reconciler/ThemeReconcilerTest.java
index de4772d72..7c2a3a342 100644
--- a/src/test/java/run/halo/app/core/extension/reconciler/ThemeReconcilerTest.java
+++ b/src/test/java/run/halo/app/core/extension/reconciler/ThemeReconcilerTest.java
@@ -4,10 +4,12 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
+import com.github.zafarkhaja.semver.Version;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
@@ -38,6 +40,7 @@ import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.MetadataOperator;
import run.halo.app.extension.controller.Reconciler;
+import run.halo.app.infra.SystemVersionSupplier;
import run.halo.app.infra.properties.HaloProperties;
import run.halo.app.infra.utils.JsonUtils;
import run.halo.app.theme.ThemePathPolicy;
@@ -57,6 +60,9 @@ class ThemeReconcilerTest {
@Mock
private HaloProperties haloProperties;
+ @Mock
+ private SystemVersionSupplier systemVersionSupplier;
+
@Mock
private File defaultTheme;
@@ -66,6 +72,7 @@ class ThemeReconcilerTest {
void setUp() throws IOException {
tempDirectory = Files.createTempDirectory("halo-theme-");
defaultTheme = ResourceUtils.getFile("classpath:themes/default");
+ lenient().when(systemVersionSupplier.get()).thenReturn(Version.valueOf("0.0.0"));
}
@AfterEach
@@ -80,7 +87,7 @@ class ThemeReconcilerTest {
when(haloProperties.getWorkDir()).thenReturn(testWorkDir);
final ThemeReconciler themeReconciler =
- new ThemeReconciler(extensionClient, haloProperties);
+ new ThemeReconciler(extensionClient, haloProperties, systemVersionSupplier);
final ThemePathPolicy themePathPolicy = new ThemePathPolicy(testWorkDir);
Theme theme = new Theme();
@@ -127,7 +134,7 @@ class ThemeReconcilerTest {
when(haloProperties.getWorkDir()).thenReturn(testWorkDir);
final ThemeReconciler themeReconciler =
- new ThemeReconciler(extensionClient, haloProperties);
+ new ThemeReconciler(extensionClient, haloProperties, systemVersionSupplier);
final int[] retryFlags = {0, 0};
when(extensionClient.fetch(eq(Setting.class), eq("theme-test-setting")))
@@ -166,7 +173,7 @@ class ThemeReconcilerTest {
when(haloProperties.getWorkDir()).thenReturn(testWorkDir);
final ThemeReconciler themeReconciler =
- new ThemeReconciler(extensionClient, haloProperties);
+ new ThemeReconciler(extensionClient, haloProperties, systemVersionSupplier);
final int[] retryFlags = {0};
when(extensionClient.fetch(eq(Setting.class), eq("theme-test-setting")))
@@ -211,7 +218,7 @@ class ThemeReconcilerTest {
when(haloProperties.getWorkDir()).thenReturn(testWorkDir);
final ThemeReconciler themeReconciler =
- new ThemeReconciler(extensionClient, haloProperties);
+ new ThemeReconciler(extensionClient, haloProperties, systemVersionSupplier);
Theme theme = new Theme();
Metadata metadata = new Metadata();
diff --git a/src/test/java/run/halo/app/core/extension/theme/ThemeServiceImplTest.java b/src/test/java/run/halo/app/core/extension/theme/ThemeServiceImplTest.java
index 5c67a2044..eed3f5285 100644
--- a/src/test/java/run/halo/app/core/extension/theme/ThemeServiceImplTest.java
+++ b/src/test/java/run/halo/app/core/extension/theme/ThemeServiceImplTest.java
@@ -13,6 +13,7 @@ import static org.mockito.Mockito.when;
import static run.halo.app.infra.utils.FileUtils.deleteRecursivelyAndSilently;
import static run.halo.app.infra.utils.FileUtils.zip;
+import com.github.zafarkhaja.semver.Version;
import java.io.IOException;
import java.net.URISyntaxException;
import java.nio.file.Files;
@@ -44,6 +45,7 @@ import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Unstructured;
import run.halo.app.extension.exception.ExtensionException;
+import run.halo.app.infra.SystemVersionSupplier;
import run.halo.app.infra.ThemeRootGetter;
import run.halo.app.infra.exception.ThemeInstallationException;
import run.halo.app.infra.utils.JsonUtils;
@@ -57,6 +59,9 @@ class ThemeServiceImplTest {
@Mock
ThemeRootGetter themeRoot;
+ @Mock
+ private SystemVersionSupplier systemVersionSupplier;
+
@InjectMocks
ThemeServiceImpl themeService;
@@ -68,6 +73,8 @@ class ThemeServiceImplTest {
lenient().when(themeRoot.get()).thenReturn(tmpDir.resolve("themes"));
// init the folder
Files.createDirectory(themeRoot.get());
+
+ lenient().when(systemVersionSupplier.get()).thenReturn(Version.valueOf("0.0.0"));
}
@AfterEach
@@ -253,7 +260,7 @@ class ThemeServiceImplTest {
"spec": {
"displayName": "Fake Theme",
"version": "*",
- "require": "*"
+ "requires": "*"
},
"apiVersion": "theme.halo.run/v1alpha1",
"kind": "Theme",
@@ -370,7 +377,7 @@ class ThemeServiceImplTest {
"settingName": "fake-setting",
"displayName": "Fake Theme",
"version": "*",
- "require": "*"
+ "requires": "*"
},
"apiVersion": "theme.halo.run/v1alpha1",
"kind": "Theme",