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 {
id 'java-library'
id 'halo.publish'
id "io.freefair.lombok" version "8.0.0-rc2"
id "io.freefair.lombok" version "8.4"
}
group = 'run.halo.app'
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 {
mavenCentral()
maven { url 'https://repo.spring.io/milestone' }
maven { url 'https://s01.oss.sonatype.org/content/repositories/snapshots' }
}
dependencies {

View File

@ -1,16 +1,18 @@
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 "com.gorylenko.gradle-git-properties" version "2.3.2"
id "checkstyle"
id 'java'
id 'jacoco'
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"
sourceCompatibility = JavaVersion.VERSION_17
compileJava.options.encoding = "UTF-8"
compileTestJava.options.encoding = "UTF-8"
checkstyle {
toolVersion = "9.3"
@ -22,6 +24,7 @@ repositories {
mavenCentral()
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();
// ascending order by creation time
// asc nulls high will be placed at the end
return Comparator.comparing(creationTime, Comparators.nullsHigh())
return Comparator.comparing(creationTime, Comparators.nullsLow())
.thenComparing(metadataCreationTime)
.thenComparing(reply -> reply.getMetadata().getName());
}

View File

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

View File

@ -673,7 +673,7 @@ public class PluginEndpoint implements CustomEndpoint {
.flatMap(listResult -> ServerResponse.ok().bodyValue(listResult));
}
@Schema(name = "PluginInstallRequest")
@Schema(name = "PluginInstallRequest", types = "object")
public static class InstallRequest {
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 java.time.Duration;
import java.util.Objects;
import lombok.AllArgsConstructor;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springdoc.core.fn.builders.schema.Builder;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
@ -42,9 +42,10 @@ import run.halo.app.extension.router.QueryParamBuildUtil;
*/
@Slf4j
@Component
@AllArgsConstructor
@RequiredArgsConstructor
public class PostEndpoint implements CustomEndpoint {
private int maxAttemptsWaitForPublish = 10;
private final PostService postService;
private final ReactiveExtensionClient client;
@ -243,7 +244,7 @@ public class PostEndpoint implements CustomEndpoint {
})
.switchIfEmpty(Mono.error(
() -> 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));
}
@ -278,4 +279,11 @@ public class PostEndpoint implements CustomEndpoint {
return postService.listPost(postQuery)
.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));
}
@Schema(types = "object")
public interface IAvatarUploadRequest {
@Schema(requiredMode = REQUIRED, description = "Avatar file")
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 java.io.IOException;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
@ -40,6 +43,7 @@ import org.springframework.lang.Nullable;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.springframework.util.ResourceUtils;
import org.springframework.web.util.UriComponentsBuilder;
import run.halo.app.core.extension.Plugin;
import run.halo.app.core.extension.ReverseProxy;
@ -649,11 +653,19 @@ public class PluginReconciler implements Reconciler<Request> {
if (StringUtils.isBlank(pathString)) {
return null;
}
String processedPathString = pathString;
if (processedPathString.startsWith("file:")) {
processedPathString = processedPathString.substring(7);
try {
var pathURL = new URL(pathString);
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) {

View File

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

View File

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

View File

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

View File

@ -8,7 +8,10 @@ import java.io.IOException;
import java.nio.file.Path;
import java.time.Instant;
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.info.BuildProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.FileSystemResource;
@ -21,8 +24,10 @@ import org.thymeleaf.extras.springsecurity6.dialect.SpringSecurityDialect;
import reactor.core.publisher.Mono;
import run.halo.app.infra.ThemeRootGetter;
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.LinkExpressionObjectDialect;
import run.halo.app.theme.dialect.TemplateHeadProcessor;
/**
* @author guqing
@ -86,4 +91,12 @@ public class ThemeConfiguration {
ServerSecurityContextRepository 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;
import static org.apache.commons.lang3.ObjectUtils.defaultIfNull;
import java.security.Principal;
import java.time.Instant;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
@ -9,7 +12,6 @@ import java.util.function.Function;
import java.util.function.Predicate;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.lang.Nullable;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
@ -254,35 +256,41 @@ public class CommentPublicQueryServiceImpl implements CommentPublicQueryService
public int compare(Comment c1, Comment c2) {
boolean c1Top = BooleanUtils.isTrue(c1.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 降序排列
return Comparator.comparing(
(Comment comment) -> comment.getSpec().getCreationTime(),
Comparators.nullsLow())
.thenComparing((Comment comment) -> comment.getMetadata().getName())
.compare(c2, c1);
} else if (c1Top) {
// 只有 c1 置顶c1 排前面
// c1 top = true && c2 top = false
if (c1Top && !c2Top) {
return -1;
} else {
// 只有c2置顶, c2排在前面
}
// c1 top = false && c2 top = true
if (!c1Top && c2Top) {
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) {
return ObjectUtils.defaultIfNull(page, 1);
return defaultIfNull(page, 1);
}
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;
import java.util.List;
import org.hamcrest.core.StringStartsWith;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@ -30,7 +31,8 @@ class WebFluxConfigTest {
.forEach(uri -> webClient.get().uri(uri)
.exchange()
.expectStatus().isOk()
.expectBody(String.class).isEqualTo("console index\n"));
.expectBody(String.class).value(StringStartsWith.startsWith("console index"))
);
}
@Test
@ -38,7 +40,7 @@ class WebFluxConfigTest {
webClient.get().uri("/console/assets/fake.txt")
.exchange()
.expectStatus().isOk()
.expectBody(String.class).isEqualTo("fake.\n");
.expectBody(String.class).value(StringStartsWith.startsWith("fake."));
}
@Test

View File

@ -50,6 +50,7 @@ class PostEndpointTest {
@BeforeEach
void setUp() {
postEndpoint.setMaxAttemptsWaitForPublish(3);
webTestClient = WebTestClient
.bindToRouterFunction(postEndpoint.endpoint())
.build();
@ -170,7 +171,7 @@ class PostEndpointTest {
.is5xxServerError();
// 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));
}

View File

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

View File

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

View File

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

View File

@ -23,7 +23,7 @@ class ThemeConfigurationTest {
@InjectMocks
private ThemeConfiguration themeConfiguration;
private final Path themeRoot = Paths.get("/tmp/.halo/themes");
private final Path themeRoot = Paths.get("tmp", ".halo", "themes");
@BeforeEach
void setUp() {
@ -33,25 +33,28 @@ class ThemeConfigurationTest {
@Test
void themeAssets() {
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");
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(() -> {
themeConfiguration.getThemeAssetsPath("fake-theme", "../../hello.jpg");
}).isInstanceOf(AccessDeniedException.class)
.hasMessage(
"403 FORBIDDEN \"Directory traversal detected: /tmp/"
+ ".halo/themes/fake-theme/templates/assets/../../hello.jpg\"");
assertThatThrownBy(() ->
themeConfiguration.getThemeAssetsPath("fake-theme", "../../hello.jpg"))
.isInstanceOf(AccessDeniedException.class)
.hasMessageContaining("Directory traversal detected");
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");
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");
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(MetadataOperator::getName)
.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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import { usePluginModuleStore } from "@/stores/plugin";
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 Logo from "@/assets/logo.png";
import { useI18n } from "vue-i18n";
@ -18,11 +18,13 @@ export function useEditorExtensionPoints(): useEditorExtensionPointsReturn {
{
name: "default",
displayName: t("core.plugin.extension_points.editor.providers.default"),
component: defineAsyncComponent({
loader: () => import("@/components/editor/DefaultEditor.vue"),
loadingComponent: VLoading,
delay: 200,
}),
component: markRaw(
defineAsyncComponent({
loader: () => import("@/components/editor/DefaultEditor.vue"),
loadingComponent: VLoading,
delay: 200,
})
),
rawType: "HTML",
logo: Logo,
},

View File

@ -21,7 +21,7 @@ export async function setupPluginModules(app: App) {
const { load } = useScriptTag(
`${
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();
@ -45,7 +45,7 @@ export async function setupPluginModules(app: App) {
await loadStyle(
`${
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) {
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 type { PluginModule } from "@halo-dev/console-shared";
import { useDebounceFn } from "@vueuse/core";
import { onBeforeUnmount } from "vue";
const { t } = useI18n();
@ -355,6 +356,10 @@ onMounted(() => {
});
});
onBeforeUnmount(() => {
editor.value?.destroy();
});
// image drag and paste upload
const { policies } = useFetchAttachmentPolicy();
@ -491,203 +496,205 @@ const currentLocale = i18n.global.locale.value as
</script>
<template>
<AttachmentSelectorModal
v-model:visible="attachmentSelectorModal"
@select="onAttachmentSelect"
/>
<RichTextEditor v-if="editor" :editor="editor" :locale="currentLocale">
<template #extra>
<OverlayScrollbarsComponent
element="div"
:options="{ scrollbars: { autoHide: 'scroll' } }"
class="h-full border-l bg-white"
defer
>
<VTabs v-model:active-id="extraActiveId" type="outline">
<VTabItem
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">
<li
v-for="(node, index) in headingNodes"
:key="index"
: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)"
>
<div
:style="{
paddingLeft: `${(node.level - 1) * 0.8}rem`,
}"
class="flex items-center gap-2"
<div>
<AttachmentSelectorModal
v-model:visible="attachmentSelectorModal"
@select="onAttachmentSelect"
/>
<RichTextEditor v-if="editor" :editor="editor" :locale="currentLocale">
<template #extra>
<OverlayScrollbarsComponent
element="div"
:options="{ scrollbars: { autoHide: 'scroll' } }"
class="h-full border-l bg-white"
defer
>
<VTabs v-model:active-id="extraActiveId" type="outline">
<VTabItem
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">
<li
v-for="(node, index) in headingNodes"
:key="index"
: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)"
>
<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
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"
>
{{
$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"
<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>
</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>
</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
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
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"
>
<div v-if="publishTime" 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.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(
"core.components.default_editor.tabs.detail.fields.publish_time"
"core.components.default_editor.tabs.detail.fields.draft"
)
}}
</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 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 class="text-base font-medium text-gray-900">
{{
formatDatetime(publishTime) ||
$t(
"core.components.default_editor.tabs.detail.fields.draft"
)
}}
</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>
<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 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>
</VTabItem>
</VTabs>
</OverlayScrollbarsComponent>
</template>
</RichTextEditor>
</div>
</template>

View File

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

View File

@ -1,5 +1,13 @@
<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(
defineProps<{
@ -25,6 +33,32 @@ const onVisibleChange = (visible: boolean) => {
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>
<template>
<VModal
@ -35,6 +69,14 @@ const onVisibleChange = (visible: boolean) => {
:layer-closable="true"
@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>
<slot name="actions"></slot>
<span>
@ -46,7 +88,8 @@ const onVisibleChange = (visible: boolean) => {
<div class="flex h-full items-center justify-center">
<iframe
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"
></iframe>
</div>

View File

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

View File

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

Binary file not shown.

View File

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
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
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

4
gradlew vendored
View File

@ -144,7 +144,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
# shellcheck disable=SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
@ -152,7 +152,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
# shellcheck disable=SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac

View File

@ -1,7 +1,7 @@
import org.springframework.boot.gradle.plugin.SpringBootPlugin
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 'halo.publish'
id 'signing'
@ -18,7 +18,7 @@ ext {
guava = "32.0.1-jre"
jsoup = '1.15.3'
jsonPatch = "1.13"
springDocOpenAPI = "2.2.0"
springDocOpenAPI = "2.2.1-SNAPSHOT"
lucene = "9.7.0"
resilience4jVersion = "2.0.2"
}