Merge branch 'main' into feature-user-center

pull/4857/head
Ryan Wang 2023-11-15 13:47:25 +08:00
commit d356b65671
36 changed files with 525 additions and 367 deletions

View File

@ -1,15 +1,20 @@
plugins { plugins {
id 'java-library' id 'java-library'
id 'halo.publish' id 'halo.publish'
id "io.freefair.lombok" version "8.0.0-rc2" id "io.freefair.lombok" version "8.4"
} }
group = 'run.halo.app' group = 'run.halo.app'
description = 'API of halo project, connecting by other projects.' description = 'API of halo project, connecting by other projects.'
compileJava.options.encoding = "UTF-8"
compileTestJava.options.encoding = "UTF-8"
javadoc.options.encoding = "UTF-8"
repositories { repositories {
mavenCentral() mavenCentral()
maven { url 'https://repo.spring.io/milestone' } maven { url 'https://repo.spring.io/milestone' }
maven { url 'https://s01.oss.sonatype.org/content/repositories/snapshots' }
} }
dependencies { dependencies {

View File

@ -1,16 +1,18 @@
plugins { plugins {
id 'org.springframework.boot' version '3.1.5' id 'org.springframework.boot' version '3.2.0-RC2'
id 'io.spring.dependency-management' version '1.1.0' id 'io.spring.dependency-management' version '1.1.0'
id "com.gorylenko.gradle-git-properties" version "2.3.2" id "com.gorylenko.gradle-git-properties" version "2.3.2"
id "checkstyle" id "checkstyle"
id 'java' id 'java'
id 'jacoco' id 'jacoco'
id "de.undercouch.download" version "5.3.1" id "de.undercouch.download" version "5.3.1"
id "io.freefair.lombok" version "8.0.0-rc2" id "io.freefair.lombok" version "8.4"
} }
group = "run.halo.app" group = "run.halo.app"
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
compileJava.options.encoding = "UTF-8"
compileTestJava.options.encoding = "UTF-8"
checkstyle { checkstyle {
toolVersion = "9.3" toolVersion = "9.3"
@ -22,6 +24,7 @@ repositories {
mavenCentral() mavenCentral()
maven { url 'https://repo.spring.io/milestone' } maven { url 'https://repo.spring.io/milestone' }
maven { url 'https://s01.oss.sonatype.org/content/repositories/snapshots' }
} }

View File

@ -31,7 +31,7 @@ public interface ReplyService {
reply -> reply.getMetadata().getCreationTimestamp(); reply -> reply.getMetadata().getCreationTimestamp();
// ascending order by creation time // ascending order by creation time
// asc nulls high will be placed at the end // asc nulls high will be placed at the end
return Comparator.comparing(creationTime, Comparators.nullsHigh()) return Comparator.comparing(creationTime, Comparators.nullsLow())
.thenComparing(metadataCreationTime) .thenComparing(metadataCreationTime)
.thenComparing(reply -> reply.getMetadata().getName()); .thenComparing(reply -> reply.getMetadata().getName());
} }

View File

@ -273,6 +273,7 @@ public class AttachmentEndpoint implements CustomEndpoint {
} }
} }
@Schema(types = "object")
public interface IUploadRequest { public interface IUploadRequest {
@Schema(requiredMode = REQUIRED, description = "Attachment file") @Schema(requiredMode = REQUIRED, description = "Attachment file")

View File

@ -673,7 +673,7 @@ public class PluginEndpoint implements CustomEndpoint {
.flatMap(listResult -> ServerResponse.ok().bodyValue(listResult)); .flatMap(listResult -> ServerResponse.ok().bodyValue(listResult));
} }
@Schema(name = "PluginInstallRequest") @Schema(name = "PluginInstallRequest", types = "object")
public static class InstallRequest { public static class InstallRequest {
private final MultiValueMap<String, Part> multipartData; private final MultiValueMap<String, Part> multipartData;

View File

@ -8,7 +8,7 @@ import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuil
import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.enums.ParameterIn;
import java.time.Duration; import java.time.Duration;
import java.util.Objects; import java.util.Objects;
import lombok.AllArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springdoc.core.fn.builders.schema.Builder; import org.springdoc.core.fn.builders.schema.Builder;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
@ -42,9 +42,10 @@ import run.halo.app.extension.router.QueryParamBuildUtil;
*/ */
@Slf4j @Slf4j
@Component @Component
@AllArgsConstructor @RequiredArgsConstructor
public class PostEndpoint implements CustomEndpoint { public class PostEndpoint implements CustomEndpoint {
private int maxAttemptsWaitForPublish = 10;
private final PostService postService; private final PostService postService;
private final ReactiveExtensionClient client; private final ReactiveExtensionClient client;
@ -243,7 +244,7 @@ public class PostEndpoint implements CustomEndpoint {
}) })
.switchIfEmpty(Mono.error( .switchIfEmpty(Mono.error(
() -> new RetryException("Retry to check post publish status")))) () -> new RetryException("Retry to check post publish status"))))
.retryWhen(Retry.fixedDelay(10, Duration.ofMillis(200)) .retryWhen(Retry.backoff(maxAttemptsWaitForPublish, Duration.ofMillis(100))
.filter(t -> t instanceof RetryException)); .filter(t -> t instanceof RetryException));
} }
@ -278,4 +279,11 @@ public class PostEndpoint implements CustomEndpoint {
return postService.listPost(postQuery) return postService.listPost(postQuery)
.flatMap(listedPosts -> ServerResponse.ok().bodyValue(listedPosts)); .flatMap(listedPosts -> ServerResponse.ok().bodyValue(listedPosts));
} }
/**
* Convenient for testing, to avoid waiting too long for post published when testing.
*/
public void setMaxAttemptsWaitForPublish(int maxAttempts) {
this.maxAttemptsWaitForPublish = maxAttempts;
}
} }

View File

@ -244,6 +244,7 @@ public class UserEndpoint implements CustomEndpoint {
.flatMap(user -> ServerResponse.ok().bodyValue(user)); .flatMap(user -> ServerResponse.ok().bodyValue(user));
} }
@Schema(types = "object")
public interface IAvatarUploadRequest { public interface IAvatarUploadRequest {
@Schema(requiredMode = REQUIRED, description = "Avatar file") @Schema(requiredMode = REQUIRED, description = "Avatar file")
FilePart getFile(); FilePart getFile();

View File

@ -8,7 +8,10 @@ import static run.halo.app.plugin.PluginConst.PLUGIN_PATH;
import static run.halo.app.plugin.PluginConst.RELOAD_ANNO; import static run.halo.app.plugin.PluginConst.RELOAD_ANNO;
import java.io.IOException; import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URI; import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
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;
@ -40,6 +43,7 @@ import org.springframework.lang.Nullable;
import org.springframework.retry.support.RetryTemplate; import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.Assert; import org.springframework.util.Assert;
import org.springframework.util.ResourceUtils;
import org.springframework.web.util.UriComponentsBuilder; import org.springframework.web.util.UriComponentsBuilder;
import run.halo.app.core.extension.Plugin; import run.halo.app.core.extension.Plugin;
import run.halo.app.core.extension.ReverseProxy; import run.halo.app.core.extension.ReverseProxy;
@ -649,11 +653,19 @@ public class PluginReconciler implements Reconciler<Request> {
if (StringUtils.isBlank(pathString)) { if (StringUtils.isBlank(pathString)) {
return null; return null;
} }
String processedPathString = pathString; try {
if (processedPathString.startsWith("file:")) { var pathURL = new URL(pathString);
processedPathString = processedPathString.substring(7); if (!ResourceUtils.isFileURL(pathURL)) {
throw new IllegalArgumentException("The path cannot be resolved to absolute file"
+ " path because it does not reside in the file system: "
+ pathString);
}
var pathURI = ResourceUtils.toURI(pathURL);
return Paths.get(pathURI);
} catch (MalformedURLException | URISyntaxException ignored) {
// the given path string is not a valid URL.
} }
return Paths.get(processedPathString); return Paths.get(pathString);
} }
URI toUri(String pathString) { URI toUri(String pathString) {

View File

@ -466,7 +466,7 @@ public class ThemeEndpoint implements CustomEndpoint {
.bodyValue(theme)); .bodyValue(theme));
} }
@Schema(name = "ThemeInstallRequest") @Schema(name = "ThemeInstallRequest", types = "object")
public static class InstallRequest { public static class InstallRequest {
@Schema(hidden = true) @Schema(hidden = true)

View File

@ -9,6 +9,11 @@ public class ThemeProperties {
@Valid @Valid
private final Initializer initializer = new Initializer(); private final Initializer initializer = new Initializer();
/**
* Indicates whether the generator meta needs to be disabled.
*/
private boolean generatorMetaDisabled;
@Data @Data
public static class Initializer { public static class Initializer {

View File

@ -140,6 +140,7 @@ public class MigrationEndpoint implements CustomEndpoint {
.switchIfEmpty(backupFileContent); .switchIfEmpty(backupFileContent);
} }
@Schema(types = "object")
public static class RestoreRequest { public static class RestoreRequest {
private final MultiValueMap<String, Part> multipart; private final MultiValueMap<String, Part> multipart;

View File

@ -8,7 +8,10 @@ import java.io.IOException;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.Instant; import java.time.Instant;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.web.WebProperties; import org.springframework.boot.autoconfigure.web.WebProperties;
import org.springframework.boot.info.BuildProperties;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.FileSystemResource;
@ -21,8 +24,10 @@ import org.thymeleaf.extras.springsecurity6.dialect.SpringSecurityDialect;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import run.halo.app.infra.ThemeRootGetter; import run.halo.app.infra.ThemeRootGetter;
import run.halo.app.infra.utils.FileUtils; import run.halo.app.infra.utils.FileUtils;
import run.halo.app.theme.dialect.GeneratorMetaProcessor;
import run.halo.app.theme.dialect.HaloSpringSecurityDialect; import run.halo.app.theme.dialect.HaloSpringSecurityDialect;
import run.halo.app.theme.dialect.LinkExpressionObjectDialect; import run.halo.app.theme.dialect.LinkExpressionObjectDialect;
import run.halo.app.theme.dialect.TemplateHeadProcessor;
/** /**
* @author guqing * @author guqing
@ -86,4 +91,12 @@ public class ThemeConfiguration {
ServerSecurityContextRepository securityContextRepository) { ServerSecurityContextRepository securityContextRepository) {
return new HaloSpringSecurityDialect(securityContextRepository); return new HaloSpringSecurityDialect(securityContextRepository);
} }
@Bean
@ConditionalOnProperty(name = "halo.theme.generator-meta-disabled",
havingValue = "false",
matchIfMissing = true)
TemplateHeadProcessor generatorMetaProcessor(ObjectProvider<BuildProperties> buildProperties) {
return new GeneratorMetaProcessor(buildProperties);
}
} }

View File

@ -0,0 +1,43 @@
package run.halo.app.theme.dialect;
import static org.thymeleaf.model.AttributeValueQuotes.DOUBLE;
import java.util.Map;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.info.BuildProperties;
import org.springframework.core.annotation.Order;
import org.thymeleaf.context.ITemplateContext;
import org.thymeleaf.model.IModel;
import org.thymeleaf.processor.element.IElementModelStructureHandler;
import reactor.core.publisher.Mono;
/**
* Processor for generating generator meta.
* Set the order to 0 for removing the meta in later TemplateHeadProcessor.
*
* @author johnniang
*/
@Order(0)
public class GeneratorMetaProcessor implements TemplateHeadProcessor {
private final String generatorValue;
public GeneratorMetaProcessor(ObjectProvider<BuildProperties> buildProperties) {
this.generatorValue = "Halo " + buildProperties.stream().findFirst()
.map(BuildProperties::getVersion)
.orElse("Unknown");
}
@Override
public Mono<Void> process(ITemplateContext context, IModel model,
IElementModelStructureHandler structureHandler) {
return Mono.fromRunnable(() -> {
var modelFactory = context.getModelFactory();
var generatorMeta = modelFactory.createStandaloneElementTag("meta",
Map.of("name", "generator", "content", generatorValue),
DOUBLE, false, true);
model.add(generatorMeta);
});
}
}

View File

@ -1,7 +1,10 @@
package run.halo.app.theme.finders.impl; package run.halo.app.theme.finders.impl;
import static org.apache.commons.lang3.ObjectUtils.defaultIfNull;
import java.security.Principal; import java.security.Principal;
import java.time.Instant;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
@ -9,7 +12,6 @@ import java.util.function.Function;
import java.util.function.Predicate; import java.util.function.Predicate;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springframework.lang.Nullable; import org.springframework.lang.Nullable;
import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.security.core.context.ReactiveSecurityContextHolder;
@ -254,35 +256,41 @@ public class CommentPublicQueryServiceImpl implements CommentPublicQueryService
public int compare(Comment c1, Comment c2) { public int compare(Comment c1, Comment c2) {
boolean c1Top = BooleanUtils.isTrue(c1.getSpec().getTop()); boolean c1Top = BooleanUtils.isTrue(c1.getSpec().getTop());
boolean c2Top = BooleanUtils.isTrue(c2.getSpec().getTop()); boolean c2Top = BooleanUtils.isTrue(c2.getSpec().getTop());
if (c1Top == c2Top) {
int c1Priority = ObjectUtils.defaultIfNull(c1.getSpec().getPriority(), 0);
int c2Priority = ObjectUtils.defaultIfNull(c2.getSpec().getPriority(), 0);
if (c1Top) {
// 都置顶
return Integer.compare(c1Priority, c2Priority);
}
// 两个评论不置顶根据 creationTime 降序排列 // c1 top = true && c2 top = false
return Comparator.comparing( if (c1Top && !c2Top) {
(Comment comment) -> comment.getSpec().getCreationTime(),
Comparators.nullsLow())
.thenComparing((Comment comment) -> comment.getMetadata().getName())
.compare(c2, c1);
} else if (c1Top) {
// 只有 c1 置顶c1 排前面
return -1; return -1;
} else { }
// 只有c2置顶, c2排在前面
// c1 top = false && c2 top = true
if (!c1Top && c2Top) {
return 1; return 1;
} }
// c1 top = c2 top = true || c1 top = c2 top = false
var priorityComparator = Comparator.<Comment, Integer>comparing(
comment -> defaultIfNull(comment.getSpec().getPriority(), 0));
var creationTimeComparator = Comparator.<Comment, Instant>comparing(
comment -> comment.getSpec().getCreationTime(),
Comparators.nullsLow(Comparator.<Instant>reverseOrder()));
var nameComparator = Comparator.<Comment, String>comparing(
comment -> comment.getMetadata().getName());
if (c1Top) {
return priorityComparator.thenComparing(creationTimeComparator)
.thenComparing(nameComparator)
.compare(c1, c2);
}
return creationTimeComparator.thenComparing(nameComparator).compare(c1, c2);
} }
} }
int pageNullSafe(Integer page) { int pageNullSafe(Integer page) {
return ObjectUtils.defaultIfNull(page, 1); return defaultIfNull(page, 1);
} }
int sizeNullSafe(Integer size) { int sizeNullSafe(Integer size) {
return ObjectUtils.defaultIfNull(size, DEFAULT_SIZE); return defaultIfNull(size, DEFAULT_SIZE);
} }
} }

View File

@ -1,6 +1,7 @@
package run.halo.app.config; package run.halo.app.config;
import java.util.List; import java.util.List;
import org.hamcrest.core.StringStartsWith;
import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@ -30,7 +31,8 @@ class WebFluxConfigTest {
.forEach(uri -> webClient.get().uri(uri) .forEach(uri -> webClient.get().uri(uri)
.exchange() .exchange()
.expectStatus().isOk() .expectStatus().isOk()
.expectBody(String.class).isEqualTo("console index\n")); .expectBody(String.class).value(StringStartsWith.startsWith("console index"))
);
} }
@Test @Test
@ -38,7 +40,7 @@ class WebFluxConfigTest {
webClient.get().uri("/console/assets/fake.txt") webClient.get().uri("/console/assets/fake.txt")
.exchange() .exchange()
.expectStatus().isOk() .expectStatus().isOk()
.expectBody(String.class).isEqualTo("fake.\n"); .expectBody(String.class).value(StringStartsWith.startsWith("fake."));
} }
@Test @Test

View File

@ -50,6 +50,7 @@ class PostEndpointTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
postEndpoint.setMaxAttemptsWaitForPublish(3);
webTestClient = WebTestClient webTestClient = WebTestClient
.bindToRouterFunction(postEndpoint.endpoint()) .bindToRouterFunction(postEndpoint.endpoint())
.build(); .build();
@ -170,7 +171,7 @@ class PostEndpointTest {
.is5xxServerError(); .is5xxServerError();
// Verify WebClient retry behavior // Verify WebClient retry behavior
verify(client, times(12)).get(eq(Post.class), eq("post-1")); verify(client, times(5)).get(eq(Post.class), eq("post-1"));
verify(client).update(any(Post.class)); verify(client).update(any(Post.class));
} }

View File

@ -3,7 +3,9 @@ package run.halo.app.core.extension.reconciler;
import static java.util.Objects.requireNonNull; import static java.util.Objects.requireNonNull;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
@ -369,12 +371,15 @@ class PluginReconcilerTest {
@Test @Test
void resolvePluginPathAnnotation() { void resolvePluginPathAnnotation() {
when(haloPluginManager.getPluginsRoot()).thenReturn(Paths.get("/tmp/plugins")); var pluginRoot = Paths.get("tmp", "plugins");
String path = pluginReconciler.resolvePluginPathForAnno("/tmp/plugins/sitemap-1.0.jar"); when(haloPluginManager.getPluginsRoot()).thenReturn(pluginRoot);
var path = pluginReconciler.resolvePluginPathForAnno(
pluginRoot.resolve("sitemap-1.0.jar").toString());
assertThat(path).isEqualTo("sitemap-1.0.jar"); assertThat(path).isEqualTo("sitemap-1.0.jar");
path = pluginReconciler.resolvePluginPathForAnno("/abc/plugins/sitemap-1.0.jar"); var givenPath = Paths.get("abc", "plugins", "sitemap-1.0.jar");
assertThat(path).isEqualTo("/abc/plugins/sitemap-1.0.jar"); path = pluginReconciler.resolvePluginPathForAnno(givenPath.toString());
assertThat(path).isEqualTo(givenPath.toString());
} }
@Nested @Nested
@ -457,17 +462,18 @@ class PluginReconcilerTest {
assertThat(pluginReconciler.toPath("")).isNull(); assertThat(pluginReconciler.toPath("")).isNull();
assertThat(pluginReconciler.toPath(" ")).isNull(); assertThat(pluginReconciler.toPath(" ")).isNull();
Path path = pluginReconciler.toPath("file:///path/to/file.txt"); final var filePath = Paths.get("path", "to", "file.txt").toAbsolutePath();
assertThat(path).isNotNull();
assertThat(path.toString()).isEqualTo("/path/to/file.txt");
assertThat(pluginReconciler.toPath("C:\\Users\\faker\\halo\\plugins").toString()) // test for file:///
.isEqualTo("C:\\Users\\faker\\halo\\plugins"); assertEquals(filePath, pluginReconciler.toPath(filePath.toUri().toString()));
assertThat(pluginReconciler.toPath("C:/Users/faker/halo/plugins").toString()) // test for absolute path /home/xyz or C:\Windows
.isEqualTo("C:/Users/faker/halo/plugins"); assertEquals(filePath, pluginReconciler.toPath(filePath.toString()));
Path windowsPath = Paths.get("C:/Users/username/Documents/file.txt");
assertThat(pluginReconciler.toPath("file://C:/Users/username/Documents/file.txt")) var exception = assertThrows(IllegalArgumentException.class, () -> {
.isEqualTo(windowsPath); var fileUri = filePath.toUri();
pluginReconciler.toPath(fileUri.toString().replaceFirst("file", "http"));
});
assertTrue(exception.getMessage().contains("not reside in the file system"));
} }
@Test @Test
@ -483,8 +489,9 @@ class PluginReconcilerTest {
}); });
// Test with non-empty pathString // Test with non-empty pathString
URI uri = pluginReconciler.toUri("/path/to/file"); var filePath = Paths.get("path", "to", "file");
Assertions.assertEquals("file:///path/to/file", uri.toString()); URI uri = pluginReconciler.toUri(filePath.toString());
assertEquals(filePath.toUri(), uri);
} }
} }

View File

@ -207,8 +207,9 @@ class PluginServiceImplTest {
String pluginName = "test-plugin"; String pluginName = "test-plugin";
PluginWrapper pluginWrapper = mock(PluginWrapper.class); PluginWrapper pluginWrapper = mock(PluginWrapper.class);
when(pluginManager.getPlugin(pluginName)).thenReturn(pluginWrapper); when(pluginManager.getPlugin(pluginName)).thenReturn(pluginWrapper);
var pluginPath = Paths.get("tmp", "plugins", "fake-plugin.jar");
when(pluginWrapper.getPluginPath()) when(pluginWrapper.getPluginPath())
.thenReturn(Paths.get("/tmp/plugins/fake-plugin.jar")); .thenReturn(pluginPath);
Plugin plugin = new Plugin(); Plugin plugin = new Plugin();
plugin.setMetadata(new Metadata()); plugin.setMetadata(new Metadata());
plugin.getMetadata().setName(pluginName); plugin.getMetadata().setName(pluginName);
@ -224,7 +225,7 @@ class PluginServiceImplTest {
verify(client, times(1)).update( verify(client, times(1)).update(
argThat(p -> { argThat(p -> {
String reloadPath = p.getMetadata().getAnnotations().get(PluginConst.RELOAD_ANNO); String reloadPath = p.getMetadata().getAnnotations().get(PluginConst.RELOAD_ANNO);
assertThat(reloadPath).isEqualTo("/tmp/plugins/fake-plugin.jar"); assertThat(reloadPath).isEqualTo(pluginPath.toString());
return true; return true;
}) })
); );

View File

@ -64,8 +64,9 @@ class FileUtilsTest {
unzip(zis, unzipTarget); unzip(zis, unzipTarget);
} }
var content = Files.readString(unzipTarget.resolve("examplefile")); var lines = Files.readAllLines(unzipTarget.resolve("examplefile"));
assertEquals("Here is an example file.\n", content); assertEquals(1, lines.size());
assertEquals("Here is an example file.", lines.get(0));
} }
@Test @Test
@ -79,9 +80,9 @@ class FileUtilsTest {
try (var zis = new ZipInputStream(Files.newInputStream(zipPath))) { try (var zis = new ZipInputStream(Files.newInputStream(zipPath))) {
unzip(zis, unzipTarget); unzip(zis, unzipTarget);
} }
var lines = Files.readAllLines(unzipTarget.resolve("examplefile"));
var content = Files.readString(unzipTarget.resolve("examplefile")); assertEquals(1, lines.size());
assertEquals("Here is an example file.\n", content); assertEquals("Here is an example file.", lines.get(0));
} }
@Test @Test

View File

@ -23,7 +23,7 @@ class ThemeConfigurationTest {
@InjectMocks @InjectMocks
private ThemeConfiguration themeConfiguration; private ThemeConfiguration themeConfiguration;
private final Path themeRoot = Paths.get("/tmp/.halo/themes"); private final Path themeRoot = Paths.get("tmp", ".halo", "themes");
@BeforeEach @BeforeEach
void setUp() { void setUp() {
@ -33,25 +33,28 @@ class ThemeConfigurationTest {
@Test @Test
void themeAssets() { void themeAssets() {
Path path = themeConfiguration.getThemeAssetsPath("fake-theme", "hello.jpg"); Path path = themeConfiguration.getThemeAssetsPath("fake-theme", "hello.jpg");
assertThat(path).isEqualTo(themeRoot.resolve("fake-theme/templates/assets/hello.jpg")); assertThat(path).isEqualTo(
themeRoot.resolve(Paths.get("fake-theme", "templates", "assets", "hello.jpg")));
path = themeConfiguration.getThemeAssetsPath("fake-theme", "./hello.jpg"); path = themeConfiguration.getThemeAssetsPath("fake-theme", "./hello.jpg");
assertThat(path).isEqualTo(themeRoot.resolve("fake-theme/templates/assets/./hello.jpg")); assertThat(path).isEqualTo(
themeRoot.resolve(Paths.get("fake-theme", "templates", "assets", ".", "hello.jpg")));
assertThatThrownBy(() -> { assertThatThrownBy(() ->
themeConfiguration.getThemeAssetsPath("fake-theme", "../../hello.jpg"); themeConfiguration.getThemeAssetsPath("fake-theme", "../../hello.jpg"))
}).isInstanceOf(AccessDeniedException.class) .isInstanceOf(AccessDeniedException.class)
.hasMessage( .hasMessageContaining("Directory traversal detected");
"403 FORBIDDEN \"Directory traversal detected: /tmp/"
+ ".halo/themes/fake-theme/templates/assets/../../hello.jpg\"");
path = themeConfiguration.getThemeAssetsPath("fake-theme", "%2e%2e/f.jpg"); path = themeConfiguration.getThemeAssetsPath("fake-theme", "%2e%2e/f.jpg");
assertThat(path).isEqualTo(themeRoot.resolve("fake-theme/templates/assets/%2e%2e/f.jpg")); assertThat(path).isEqualTo(
themeRoot.resolve(Paths.get("fake-theme", "templates", "assets", "%2e%2e", "f.jpg")));
path = themeConfiguration.getThemeAssetsPath("fake-theme", "f/./../p.jpg"); path = themeConfiguration.getThemeAssetsPath("fake-theme", "f/./../p.jpg");
assertThat(path).isEqualTo(themeRoot.resolve("fake-theme/templates/assets/f/./../p.jpg")); assertThat(path).isEqualTo(themeRoot.resolve(
Paths.get("fake-theme", "templates", "assets", "f", ".", "..", "p.jpg")));
path = themeConfiguration.getThemeAssetsPath("fake-theme", "f../p.jpg"); path = themeConfiguration.getThemeAssetsPath("fake-theme", "f../p.jpg");
assertThat(path).isEqualTo(themeRoot.resolve("fake-theme/templates/assets/f../p.jpg")); assertThat(path).isEqualTo(
themeRoot.resolve(Paths.get("fake-theme", "templates", "assets", "f..", "p.jpg")));
} }
} }

View File

@ -0,0 +1,59 @@
package run.halo.app.theme.dialect;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import java.io.FileNotFoundException;
import java.net.URISyntaxException;
import java.nio.file.Path;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.util.ResourceUtils;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import run.halo.app.infra.InitializationStateGetter;
import run.halo.app.theme.ThemeContext;
import run.halo.app.theme.ThemeResolver;
@SpringBootTest
@AutoConfigureWebTestClient
class GeneratorMetaProcessorTest {
@Autowired
WebTestClient webClient;
@MockBean
InitializationStateGetter initializationStateGetter;
@MockBean
ThemeResolver themeResolver;
@BeforeEach
void setUp() throws FileNotFoundException, URISyntaxException {
when(initializationStateGetter.userInitialized()).thenReturn(Mono.just(true));
var themeContext = ThemeContext.builder()
.name("default")
.path(Path.of(ResourceUtils.getURL("classpath:themes/default").toURI()))
.active(true)
.build();
when(themeResolver.getTheme(any(ServerWebExchange.class)))
.thenReturn(Mono.just(themeContext));
}
@Test
void requestIndexPage() {
webClient.get().uri("/")
.exchange()
.expectStatus().isOk()
.expectBody()
.consumeWith(System.out::println)
.xpath("/html/head/meta[@name=\"generator\"][starts-with(@content, \"Halo \")]")
.exists();
}
}

View File

@ -164,7 +164,7 @@ class CommentPublicQueryServiceImplTest {
.map(Comment::getMetadata) .map(Comment::getMetadata)
.map(MetadataOperator::getName) .map(MetadataOperator::getName)
.collect(Collectors.joining(", ")); .collect(Collectors.joining(", "));
assertThat(result).isEqualTo("1, 2, 3, 4, 5, 6, 9, 14, 10, 8, 7, 13, 12, 11"); assertThat(result).isEqualTo("1, 2, 4, 3, 5, 6, 10, 14, 9, 8, 7, 11, 12, 13");
} }
@Test @Test

View File

@ -68,21 +68,9 @@ public class ThemeMessageResolverIntegrationTest {
.exchange() .exchange()
.expectStatus() .expectStatus()
.isOk() .isOk()
.expectBody(String.class) .expectBody()
.isEqualTo(""" .xpath("/html/body/div[1]").isEqualTo("zh")
<!DOCTYPE html> .xpath("/html/body/div[2]").isEqualTo("欢迎来到首页");
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
index
<div>zh</div>
<div></div>
</body>
</html>
""");
} }
@Test @Test
@ -92,21 +80,9 @@ public class ThemeMessageResolverIntegrationTest {
.exchange() .exchange()
.expectStatus() .expectStatus()
.isOk() .isOk()
.expectBody(String.class) .expectBody()
.isEqualTo(""" .xpath("/html/body/div[1]").isEqualTo("en")
<!DOCTYPE html> .xpath("/html/body/div[2]").isEqualTo("Welcome to the index");
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
index
<div>en</div>
<div>Welcome to the index</div>
</body>
</html>
""");
} }
@Test @Test
@ -116,21 +92,9 @@ public class ThemeMessageResolverIntegrationTest {
.exchange() .exchange()
.expectStatus() .expectStatus()
.isOk() .isOk()
.expectBody(String.class) .expectBody()
.isEqualTo(""" .xpath("/html/body/div[1]").isEqualTo("foo")
<!DOCTYPE html> .xpath("/html/body/div[2]").isEqualTo("欢迎来到首页");
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
index
<div>foo</div>
<div></div>
</body>
</html>
""");
} }
@Test @Test
@ -140,21 +104,11 @@ public class ThemeMessageResolverIntegrationTest {
.exchange() .exchange()
.expectStatus() .expectStatus()
.isOk() .isOk()
.expectBody(String.class) .expectBody()
.isEqualTo(""" .xpath("/html/head/title").isEqualTo("Title")
<!DOCTYPE html> .xpath("/html/body/div[1]").isEqualTo("zh")
<html lang="en"> .xpath("/html/body/div[2]").isEqualTo("欢迎来到首页")
<head> ;
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
index
<div>zh</div>
<div></div>
</body>
</html>
""");
// For other theme // For other theme
when(themeResolver.getTheme(any(ServerWebExchange.class))) when(themeResolver.getTheme(any(ServerWebExchange.class)))
@ -162,35 +116,16 @@ public class ThemeMessageResolverIntegrationTest {
webTestClient.get() webTestClient.get()
.uri("/index?language=zh") .uri("/index?language=zh")
.exchange() .exchange()
.expectBody(String.class) .expectBody()
.isEqualTo(""" .xpath("/html/head/title").isEqualTo("Other theme title")
<!DOCTYPE html> .xpath("/html/body/p").isEqualTo("Other 首页");
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Other theme title</title>
</head>
<body>
<p>Other </p>
</body>
</html>
""");
webTestClient.get() webTestClient.get()
.uri("/index?language=en") .uri("/index?language=en")
.exchange() .exchange()
.expectBody(String.class) .expectBody()
.isEqualTo(""" .xpath("/html/head/title").isEqualTo("Other theme title")
<!DOCTYPE html> .xpath("/html/body/p").isEqualTo("other index");
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Other theme title</title>
</head>
<body>
<p>other index</p>
</body>
</html>
""");
} }
ThemeContext createDefaultContext() throws URISyntaxException { ThemeContext createDefaultContext() throws URISyntaxException {

View File

@ -1,7 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8"/>
<title>Title</title> <title>Title</title>
</head> </head>
<body> <body>

View File

@ -1,7 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8"/>
<title>Other theme title</title> <title>Other theme title</title>
</head> </head>
<body> <body>

View File

@ -1,6 +1,6 @@
import { usePluginModuleStore } from "@/stores/plugin"; import { usePluginModuleStore } from "@/stores/plugin";
import type { EditorProvider, PluginModule } from "@halo-dev/console-shared"; import type { EditorProvider, PluginModule } from "@halo-dev/console-shared";
import { onMounted, ref, type Ref, defineAsyncComponent } from "vue"; import { onMounted, ref, type Ref, defineAsyncComponent, markRaw } from "vue";
import { VLoading } from "@halo-dev/components"; import { VLoading } from "@halo-dev/components";
import Logo from "@/assets/logo.png"; import Logo from "@/assets/logo.png";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
@ -18,11 +18,13 @@ export function useEditorExtensionPoints(): useEditorExtensionPointsReturn {
{ {
name: "default", name: "default",
displayName: t("core.plugin.extension_points.editor.providers.default"), displayName: t("core.plugin.extension_points.editor.providers.default"),
component: defineAsyncComponent({ component: markRaw(
loader: () => import("@/components/editor/DefaultEditor.vue"), defineAsyncComponent({
loadingComponent: VLoading, loader: () => import("@/components/editor/DefaultEditor.vue"),
delay: 200, loadingComponent: VLoading,
}), delay: 200,
})
),
rawType: "HTML", rawType: "HTML",
logo: Logo, logo: Logo,
}, },

View File

@ -21,7 +21,7 @@ export async function setupPluginModules(app: App) {
const { load } = useScriptTag( const { load } = useScriptTag(
`${ `${
import.meta.env.VITE_API_URL import.meta.env.VITE_API_URL
}/apis/api.console.halo.run/v1alpha1/plugins/-/bundle.js` }/apis/api.console.halo.run/v1alpha1/plugins/-/bundle.js?t=${Date.now()}`
); );
await load(); await load();
@ -45,7 +45,7 @@ export async function setupPluginModules(app: App) {
await loadStyle( await loadStyle(
`${ `${
import.meta.env.VITE_API_URL import.meta.env.VITE_API_URL
}/apis/api.console.halo.run/v1alpha1/plugins/-/bundle.css` }/apis/api.console.halo.run/v1alpha1/plugins/-/bundle.css?t=${Date.now()}`
); );
} catch (e) { } catch (e) {
const message = i18n.global.t("core.plugin.loader.toast.style_load_failed"); const message = i18n.global.t("core.plugin.loader.toast.style_load_failed");

View File

@ -86,6 +86,7 @@ import { OverlayScrollbarsComponent } from "overlayscrollbars-vue";
import { usePluginModuleStore } from "@/stores/plugin"; import { usePluginModuleStore } from "@/stores/plugin";
import type { PluginModule } from "@halo-dev/console-shared"; import type { PluginModule } from "@halo-dev/console-shared";
import { useDebounceFn } from "@vueuse/core"; import { useDebounceFn } from "@vueuse/core";
import { onBeforeUnmount } from "vue";
const { t } = useI18n(); const { t } = useI18n();
@ -355,6 +356,10 @@ onMounted(() => {
}); });
}); });
onBeforeUnmount(() => {
editor.value?.destroy();
});
// image drag and paste upload // image drag and paste upload
const { policies } = useFetchAttachmentPolicy(); const { policies } = useFetchAttachmentPolicy();
@ -491,203 +496,205 @@ const currentLocale = i18n.global.locale.value as
</script> </script>
<template> <template>
<AttachmentSelectorModal <div>
v-model:visible="attachmentSelectorModal" <AttachmentSelectorModal
@select="onAttachmentSelect" v-model:visible="attachmentSelectorModal"
/> @select="onAttachmentSelect"
<RichTextEditor v-if="editor" :editor="editor" :locale="currentLocale"> />
<template #extra> <RichTextEditor v-if="editor" :editor="editor" :locale="currentLocale">
<OverlayScrollbarsComponent <template #extra>
element="div" <OverlayScrollbarsComponent
:options="{ scrollbars: { autoHide: 'scroll' } }" element="div"
class="h-full border-l bg-white" :options="{ scrollbars: { autoHide: 'scroll' } }"
defer class="h-full border-l bg-white"
> defer
<VTabs v-model:active-id="extraActiveId" type="outline"> >
<VTabItem <VTabs v-model:active-id="extraActiveId" type="outline">
id="toc" <VTabItem
:label="$t('core.components.default_editor.tabs.toc.title')" id="toc"
> :label="$t('core.components.default_editor.tabs.toc.title')"
<div class="p-1 pt-0"> >
<ul v-if="headingNodes?.length" class="space-y-1"> <div class="p-1 pt-0">
<li <ul v-if="headingNodes?.length" class="space-y-1">
v-for="(node, index) in headingNodes" <li
:key="index" v-for="(node, index) in headingNodes"
:class="[ :key="index"
{ 'bg-gray-100': node.id === selectedHeadingNode?.id }, :class="[
]" { 'bg-gray-100': node.id === selectedHeadingNode?.id },
class="group cursor-pointer truncate rounded-base px-1.5 py-1 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900" ]"
@click="handleSelectHeadingNode(node)" class="group cursor-pointer truncate rounded-base px-1.5 py-1 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
> @click="handleSelectHeadingNode(node)"
<div
:style="{
paddingLeft: `${(node.level - 1) * 0.8}rem`,
}"
class="flex items-center gap-2"
> >
<component
:is="headingIcons[node.level]"
class="h-4 w-4 rounded-sm bg-gray-100 p-0.5 group-hover:bg-white"
:class="[
{ '!bg-white': node.id === selectedHeadingNode?.id },
]"
/>
<span class="flex-1 truncate">{{ node.text }}</span>
</div>
</li>
</ul>
<div v-else class="flex flex-col items-center py-10">
<span class="text-sm text-gray-600">
{{ $t("core.components.default_editor.tabs.toc.empty") }}
</span>
</div>
</div>
</VTabItem>
<VTabItem
id="information"
:label="$t('core.components.default_editor.tabs.detail.title')"
>
<div class="flex flex-col gap-2 p-1 pt-0">
<div class="grid grid-cols-2 gap-2">
<div
class="group flex cursor-pointer flex-col gap-y-5 rounded-md bg-gray-100 px-1.5 py-1 transition-all"
>
<div class="flex items-center justify-between">
<div <div
class="text-sm text-gray-500 group-hover:text-gray-900" :style="{
paddingLeft: `${(node.level - 1) * 0.8}rem`,
}"
class="flex items-center gap-2"
> >
{{ <component
$t( :is="headingIcons[node.level]"
"core.components.default_editor.tabs.detail.fields.character_count" class="h-4 w-4 rounded-sm bg-gray-100 p-0.5 group-hover:bg-white"
) :class="[
}} { '!bg-white': node.id === selectedHeadingNode?.id },
</div> ]"
<div class="rounded bg-gray-200 p-0.5">
<IconCharacterRecognition
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
/> />
<span class="flex-1 truncate">{{ node.text }}</span>
</div> </div>
</div> </li>
<div class="text-base font-medium text-gray-900"> </ul>
{{ editor.storage.characterCount.characters() }} <div v-else class="flex flex-col items-center py-10">
</div> <span class="text-sm text-gray-600">
</div> {{ $t("core.components.default_editor.tabs.toc.empty") }}
<div </span>
class="group flex cursor-pointer flex-col gap-y-5 rounded-md bg-gray-100 px-1.5 py-1 transition-all"
>
<div class="flex items-center justify-between">
<div
class="text-sm text-gray-500 group-hover:text-gray-900"
>
{{
$t(
"core.components.default_editor.tabs.detail.fields.word_count"
)
}}
</div>
<div class="rounded bg-gray-200 p-0.5">
<IconCharacterRecognition
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
/>
</div>
</div>
<div class="text-base font-medium text-gray-900">
{{ editor.storage.characterCount.words() }}
</div>
</div> </div>
</div> </div>
</VTabItem>
<VTabItem
id="information"
:label="$t('core.components.default_editor.tabs.detail.title')"
>
<div class="flex flex-col gap-2 p-1 pt-0">
<div class="grid grid-cols-2 gap-2">
<div
class="group flex cursor-pointer flex-col gap-y-5 rounded-md bg-gray-100 px-1.5 py-1 transition-all"
>
<div class="flex items-center justify-between">
<div
class="text-sm text-gray-500 group-hover:text-gray-900"
>
{{
$t(
"core.components.default_editor.tabs.detail.fields.character_count"
)
}}
</div>
<div class="rounded bg-gray-200 p-0.5">
<IconCharacterRecognition
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
/>
</div>
</div>
<div class="text-base font-medium text-gray-900">
{{ editor.storage.characterCount.characters() }}
</div>
</div>
<div
class="group flex cursor-pointer flex-col gap-y-5 rounded-md bg-gray-100 px-1.5 py-1 transition-all"
>
<div class="flex items-center justify-between">
<div
class="text-sm text-gray-500 group-hover:text-gray-900"
>
{{
$t(
"core.components.default_editor.tabs.detail.fields.word_count"
)
}}
</div>
<div class="rounded bg-gray-200 p-0.5">
<IconCharacterRecognition
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
/>
</div>
</div>
<div class="text-base font-medium text-gray-900">
{{ editor.storage.characterCount.words() }}
</div>
</div>
</div>
<div v-if="publishTime" class="grid grid-cols-1 gap-2"> <div v-if="publishTime" class="grid grid-cols-1 gap-2">
<div <div
class="group flex cursor-pointer flex-col gap-y-5 rounded-md bg-gray-100 px-1.5 py-1 transition-all" class="group flex cursor-pointer flex-col gap-y-5 rounded-md bg-gray-100 px-1.5 py-1 transition-all"
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div <div
class="text-sm text-gray-500 group-hover:text-gray-900" class="text-sm text-gray-500 group-hover:text-gray-900"
> >
{{
$t(
"core.components.default_editor.tabs.detail.fields.publish_time"
)
}}
</div>
<div class="rounded bg-gray-200 p-0.5">
<IconCalendar
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
/>
</div>
</div>
<div class="text-base font-medium text-gray-900">
{{ {{
formatDatetime(publishTime) ||
$t( $t(
"core.components.default_editor.tabs.detail.fields.publish_time" "core.components.default_editor.tabs.detail.fields.draft"
) )
}} }}
</div> </div>
<div class="rounded bg-gray-200 p-0.5"> </div>
<IconCalendar </div>
class="h-4 w-4 text-gray-600 group-hover:text-gray-900" <div v-if="owner" class="grid grid-cols-1 gap-2">
/> <div
class="group flex cursor-pointer flex-col gap-y-5 rounded-md bg-gray-100 px-1.5 py-1 transition-all"
>
<div class="flex items-center justify-between">
<div
class="text-sm text-gray-500 group-hover:text-gray-900"
>
{{
$t(
"core.components.default_editor.tabs.detail.fields.owner"
)
}}
</div>
<div class="rounded bg-gray-200 p-0.5">
<IconUserFollow
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
/>
</div>
</div>
<div class="text-base font-medium text-gray-900">
{{ owner }}
</div> </div>
</div> </div>
<div class="text-base font-medium text-gray-900"> </div>
{{ <div v-if="permalink" class="grid grid-cols-1 gap-2">
formatDatetime(publishTime) || <div
$t( class="group flex cursor-pointer flex-col gap-y-5 rounded-md bg-gray-100 px-1.5 py-1 transition-all"
"core.components.default_editor.tabs.detail.fields.draft" >
) <div class="flex items-center justify-between">
}} <div
class="text-sm text-gray-500 group-hover:text-gray-900"
>
{{
$t(
"core.components.default_editor.tabs.detail.fields.permalink"
)
}}
</div>
<div class="rounded bg-gray-200 p-0.5">
<IconLink
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
/>
</div>
</div>
<div>
<a
:href="permalink"
:title="permalink"
target="_blank"
class="text-sm text-gray-900 hover:text-blue-600"
>
{{ permalink }}
</a>
</div>
</div> </div>
</div> </div>
</div> </div>
<div v-if="owner" class="grid grid-cols-1 gap-2"> </VTabItem>
<div </VTabs>
class="group flex cursor-pointer flex-col gap-y-5 rounded-md bg-gray-100 px-1.5 py-1 transition-all" </OverlayScrollbarsComponent>
> </template>
<div class="flex items-center justify-between"> </RichTextEditor>
<div </div>
class="text-sm text-gray-500 group-hover:text-gray-900"
>
{{
$t(
"core.components.default_editor.tabs.detail.fields.owner"
)
}}
</div>
<div class="rounded bg-gray-200 p-0.5">
<IconUserFollow
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
/>
</div>
</div>
<div class="text-base font-medium text-gray-900">
{{ owner }}
</div>
</div>
</div>
<div v-if="permalink" class="grid grid-cols-1 gap-2">
<div
class="group flex cursor-pointer flex-col gap-y-5 rounded-md bg-gray-100 px-1.5 py-1 transition-all"
>
<div class="flex items-center justify-between">
<div
class="text-sm text-gray-500 group-hover:text-gray-900"
>
{{
$t(
"core.components.default_editor.tabs.detail.fields.permalink"
)
}}
</div>
<div class="rounded bg-gray-200 p-0.5">
<IconLink
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
/>
</div>
</div>
<div>
<a
:href="permalink"
:title="permalink"
target="_blank"
class="text-sm text-gray-900 hover:text-blue-600"
>
{{ permalink }}
</a>
</div>
</div>
</div>
</div>
</VTabItem>
</VTabs>
</OverlayScrollbarsComponent>
</template>
</RichTextEditor>
</template> </template>

View File

@ -17,10 +17,12 @@ import { randomUUID } from "@/utils/id";
const themeStore = useThemeStore(); const themeStore = useThemeStore();
function keyValidationRule(node: FormKitNode) { function keyValidationRule(node: FormKitNode) {
return ( const validAnnotations = [
!annotations.value?.[node.value as string] && ...Object.keys(annotations.value),
!customAnnotationsDuplicateKey.value ...customAnnotationsState.value.map((item) => item.key),
); ];
const count = validAnnotations.filter((item) => item === node.value);
return count.length < 2;
} }
const props = withDefaults( const props = withDefaults(
@ -73,12 +75,6 @@ const annotations = ref<{
}>({}); }>({});
const customAnnotationsState = ref<{ key: string; value: string }[]>([]); const customAnnotationsState = ref<{ key: string; value: string }[]>([]);
const customAnnotationsDuplicateKey = computed(() => {
const keys = customAnnotationsState.value.map((item) => item.key);
const uniqueKeys = new Set(keys);
return keys.length !== uniqueKeys.size;
});
const customAnnotations = computed(() => { const customAnnotations = computed(() => {
return customAnnotationsState.value.reduce((acc, cur) => { return customAnnotationsState.value.reduce((acc, cur) => {
acc[cur.key] = cur.value; acc[cur.key] = cur.value;

View File

@ -1,5 +1,13 @@
<script lang="ts" setup> <script lang="ts" setup>
import { VModal, IconLink } from "@halo-dev/components"; import {
VModal,
IconLink,
VTabbar,
IconComputer,
IconTablet,
IconPhone,
} from "@halo-dev/components";
import { computed, markRaw, ref } from "vue";
withDefaults( withDefaults(
defineProps<{ defineProps<{
@ -25,6 +33,32 @@ const onVisibleChange = (visible: boolean) => {
emit("close"); emit("close");
} }
}; };
const mockDevices = [
{
id: "desktop",
icon: markRaw(IconComputer),
},
{
id: "tablet",
icon: markRaw(IconTablet),
},
{
id: "phone",
icon: markRaw(IconPhone),
},
];
const deviceActiveId = ref(mockDevices[0].id);
const iframeClasses = computed(() => {
if (deviceActiveId.value === "desktop") {
return "w-full h-full";
}
if (deviceActiveId.value === "tablet") {
return "w-2/3 h-2/3 ring-2 rounded ring-gray-300";
}
return "w-96 h-[50rem] ring-2 rounded ring-gray-300";
});
</script> </script>
<template> <template>
<VModal <VModal
@ -35,6 +69,14 @@ const onVisibleChange = (visible: boolean) => {
:layer-closable="true" :layer-closable="true"
@update:visible="onVisibleChange" @update:visible="onVisibleChange"
> >
<template #center>
<!-- TODO: Reactor VTabbar component to support icon prop -->
<VTabbar
v-model:active-id="deviceActiveId"
:items="mockDevices as any"
type="outline"
></VTabbar>
</template>
<template #actions> <template #actions>
<slot name="actions"></slot> <slot name="actions"></slot>
<span> <span>
@ -46,7 +88,8 @@ const onVisibleChange = (visible: boolean) => {
<div class="flex h-full items-center justify-center"> <div class="flex h-full items-center justify-center">
<iframe <iframe
v-if="visible" v-if="visible"
class="h-full w-full border-none transition-all duration-300" class="border-none transition-all duration-500"
:class="iframeClasses"
:src="url" :src="url"
></iframe> ></iframe>
</div> </div>

View File

@ -2,7 +2,7 @@ export function loadStyle(href: string) {
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {
let shouldAppend = false; let shouldAppend = false;
let el: HTMLLinkElement | null = document.querySelector( let el: HTMLLinkElement | null = document.querySelector(
'script[src="' + href + '"]' 'link[href="' + href + '"]'
); );
if (!el) { if (!el) {
el = document.createElement("link"); el = document.createElement("link");

View File

@ -1 +1,2 @@
version=2.11.0-SNAPSHOT version=2.11.0-SNAPSHOT
snakeyaml.version=2.2

Binary file not shown.

View File

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.2-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-all.zip
networkTimeout=10000 networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists

View File

@ -1,7 +1,7 @@
import org.springframework.boot.gradle.plugin.SpringBootPlugin import org.springframework.boot.gradle.plugin.SpringBootPlugin
plugins { plugins {
id 'org.springframework.boot' version '3.1.5' apply false id 'org.springframework.boot' version '3.2.0-RC2' apply false
id 'java-platform' id 'java-platform'
id 'halo.publish' id 'halo.publish'
id 'signing' id 'signing'
@ -18,7 +18,7 @@ ext {
guava = "32.0.1-jre" guava = "32.0.1-jre"
jsoup = '1.15.3' jsoup = '1.15.3'
jsonPatch = "1.13" jsonPatch = "1.13"
springDocOpenAPI = "2.2.0" springDocOpenAPI = "2.2.1-SNAPSHOT"
lucene = "9.7.0" lucene = "9.7.0"
resilience4jVersion = "2.0.2" resilience4jVersion = "2.0.2"
} }