From 9fdc9c1bb7a4d305808080184711833c702918c8 Mon Sep 17 00:00:00 2001
From: guqing <38999863+guqing@users.noreply.github.com>
Date: Mon, 19 Sep 2022 16:38:13 +0800
Subject: [PATCH] feat: add comment custom endpoint for list view (#2412)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
#### What type of PR is this?
/kind feature
/milestone 2.0
/area core
/kind api-change
#### What this PR does / why we need it:
- 新增评论列表自定义 API,支持过滤和排序条件
- 新增评论 Reconciler 以支持:
- 是否有新回复
- 最新回复时间
- 上次查看回复时间
- 未读回复数量
- 新增评论主体信息获取扩展点 `CommentSubject` 用于获取评论的 subject 信息 ,默认实现 `Post` 模型和 `SinglePage`
#### Which issue(s) this PR fixes:
Fixes #2409
#### Special notes for your reviewer:
/cc @halo-dev/sig-halo
#### Does this PR introduce a user-facing change?
```release-note
新增评论列表自定义 API (包括回复)并支持过滤和排序条件
新增发表评论和回复的自定义 API
支持是否有新回复及未读回复数量
```
---
.../app/config/ExtensionConfiguration.java | 10 +
.../content/comment/CommentEmailOwner.java | 43 ++
.../app/content/comment/CommentQuery.java | 85 ++++
.../app/content/comment/CommentRequest.java | 55 +++
.../app/content/comment/CommentService.java | 18 +
.../content/comment/CommentServiceImpl.java | 230 ++++++++++
.../app/content/comment/CommentSorter.java | 73 ++++
.../app/content/comment/CommentSubject.java | 19 +
.../app/content/comment/ListedComment.java | 26 ++
.../halo/app/content/comment/ListedReply.java | 23 +
.../halo/app/content/comment/OwnerInfo.java | 77 ++++
.../content/comment/PostCommentSubject.java | 38 ++
.../halo/app/content/comment/ReplyQuery.java | 25 ++
.../app/content/comment/ReplyRequest.java | 53 +++
.../app/content/comment/ReplyService.java | 18 +
.../app/content/comment/ReplyServiceImpl.java | 155 +++++++
.../comment/SinglePageCommentSubject.java | 38 ++
.../run/halo/app/core/extension/Comment.java | 46 +-
.../extension/endpoint/CommentEndpoint.java | 137 ++++++
.../extension/endpoint/ReplyEndpoint.java | 55 +++
.../reconciler/CommentReconciler.java | 122 ++++++
.../SystemConfigurableEnvironmentFetcher.java | 9 +-
.../halo/app/infra/utils/IpAddressUtils.java | 72 ++++
.../app/plugin/ExtensionComponentsFinder.java | 66 +++
.../halo/app/theme/finders/CommentFinder.java | 3 +-
.../theme/finders/impl/CommentFinderImpl.java | 7 +-
.../extensions/role-template-anonymous.yaml | 5 +-
.../comment/CommentEmailOwnerTest.java | 52 +++
.../app/content/comment/CommentQueryTest.java | 203 +++++++++
.../content/comment/CommentRequestTest.java | 86 ++++
.../comment/CommentServiceImplTest.java | 394 ++++++++++++++++++
.../content/comment/CommentSorterTest.java | 139 ++++++
.../comment/PostCommentSubjectTest.java | 67 +++
.../comment/SinglePageCommentSubjectTest.java | 75 ++++
.../reconciler/CommentReconcilerTest.java | 132 ++++++
35 files changed, 2643 insertions(+), 13 deletions(-)
create mode 100644 src/main/java/run/halo/app/content/comment/CommentEmailOwner.java
create mode 100644 src/main/java/run/halo/app/content/comment/CommentQuery.java
create mode 100644 src/main/java/run/halo/app/content/comment/CommentRequest.java
create mode 100644 src/main/java/run/halo/app/content/comment/CommentService.java
create mode 100644 src/main/java/run/halo/app/content/comment/CommentServiceImpl.java
create mode 100644 src/main/java/run/halo/app/content/comment/CommentSorter.java
create mode 100644 src/main/java/run/halo/app/content/comment/CommentSubject.java
create mode 100644 src/main/java/run/halo/app/content/comment/ListedComment.java
create mode 100644 src/main/java/run/halo/app/content/comment/ListedReply.java
create mode 100644 src/main/java/run/halo/app/content/comment/OwnerInfo.java
create mode 100644 src/main/java/run/halo/app/content/comment/PostCommentSubject.java
create mode 100644 src/main/java/run/halo/app/content/comment/ReplyQuery.java
create mode 100644 src/main/java/run/halo/app/content/comment/ReplyRequest.java
create mode 100644 src/main/java/run/halo/app/content/comment/ReplyService.java
create mode 100644 src/main/java/run/halo/app/content/comment/ReplyServiceImpl.java
create mode 100644 src/main/java/run/halo/app/content/comment/SinglePageCommentSubject.java
create mode 100644 src/main/java/run/halo/app/core/extension/endpoint/CommentEndpoint.java
create mode 100644 src/main/java/run/halo/app/core/extension/endpoint/ReplyEndpoint.java
create mode 100644 src/main/java/run/halo/app/core/extension/reconciler/CommentReconciler.java
create mode 100644 src/main/java/run/halo/app/infra/utils/IpAddressUtils.java
create mode 100644 src/main/java/run/halo/app/plugin/ExtensionComponentsFinder.java
create mode 100644 src/test/java/run/halo/app/content/comment/CommentEmailOwnerTest.java
create mode 100644 src/test/java/run/halo/app/content/comment/CommentQueryTest.java
create mode 100644 src/test/java/run/halo/app/content/comment/CommentRequestTest.java
create mode 100644 src/test/java/run/halo/app/content/comment/CommentServiceImplTest.java
create mode 100644 src/test/java/run/halo/app/content/comment/CommentSorterTest.java
create mode 100644 src/test/java/run/halo/app/content/comment/PostCommentSubjectTest.java
create mode 100644 src/test/java/run/halo/app/content/comment/SinglePageCommentSubjectTest.java
create mode 100644 src/test/java/run/halo/app/core/extension/reconciler/CommentReconcilerTest.java
diff --git a/src/main/java/run/halo/app/config/ExtensionConfiguration.java b/src/main/java/run/halo/app/config/ExtensionConfiguration.java
index ca738ffa2..b6534c600 100644
--- a/src/main/java/run/halo/app/config/ExtensionConfiguration.java
+++ b/src/main/java/run/halo/app/config/ExtensionConfiguration.java
@@ -12,6 +12,7 @@ import run.halo.app.content.permalinks.CategoryPermalinkPolicy;
import run.halo.app.content.permalinks.PostPermalinkPolicy;
import run.halo.app.content.permalinks.TagPermalinkPolicy;
import run.halo.app.core.extension.Category;
+import run.halo.app.core.extension.Comment;
import run.halo.app.core.extension.Menu;
import run.halo.app.core.extension.MenuItem;
import run.halo.app.core.extension.Plugin;
@@ -24,6 +25,7 @@ import run.halo.app.core.extension.Theme;
import run.halo.app.core.extension.User;
import run.halo.app.core.extension.attachment.Attachment;
import run.halo.app.core.extension.reconciler.CategoryReconciler;
+import run.halo.app.core.extension.reconciler.CommentReconciler;
import run.halo.app.core.extension.reconciler.MenuItemReconciler;
import run.halo.app.core.extension.reconciler.MenuReconciler;
import run.halo.app.core.extension.reconciler.PluginReconciler;
@@ -195,6 +197,14 @@ public class ExtensionConfiguration {
.extension(new SinglePage())
.build();
}
+
+ @Bean
+ Controller commentController(ExtensionClient client) {
+ return new ControllerBuilder("comment-controller", client)
+ .reconciler(new CommentReconciler(client))
+ .extension(new Comment())
+ .build();
+ }
}
}
diff --git a/src/main/java/run/halo/app/content/comment/CommentEmailOwner.java b/src/main/java/run/halo/app/content/comment/CommentEmailOwner.java
new file mode 100644
index 000000000..2007c5fa6
--- /dev/null
+++ b/src/main/java/run/halo/app/content/comment/CommentEmailOwner.java
@@ -0,0 +1,43 @@
+package run.halo.app.content.comment;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.util.Assert;
+import run.halo.app.core.extension.Comment;
+
+/**
+ *
The creator info of the comment.
+ * This {@link CommentEmailOwner} is only applicable to the user who is allowed to comment
+ * without logging in.
+ *
+ * @param email email for comment owner
+ * @param avatar avatar for comment owner
+ * @param displayName display name for comment owner
+ * @param website website for comment owner
+ */
+public record CommentEmailOwner(String email, String avatar, String displayName, String website) {
+
+ public CommentEmailOwner {
+ Assert.hasText(displayName, "The 'displayName' must not be empty.");
+ }
+
+ /**
+ * Converts {@link CommentEmailOwner} to {@link Comment.CommentOwner}.
+ *
+ * @return a comment owner
+ */
+ public Comment.CommentOwner toCommentOwner() {
+ Comment.CommentOwner commentOwner = new Comment.CommentOwner();
+ commentOwner.setKind(Comment.CommentOwner.KIND_EMAIL);
+ // email nullable
+ commentOwner.setName(StringUtils.defaultString(email, StringUtils.EMPTY));
+
+ commentOwner.setDisplayName(displayName);
+ Map annotations = new LinkedHashMap<>();
+ commentOwner.setAnnotations(annotations);
+ annotations.put(Comment.CommentOwner.AVATAR_ANNO, avatar);
+ annotations.put(Comment.CommentOwner.WEBSITE_ANNO, website);
+ return commentOwner;
+ }
+}
diff --git a/src/main/java/run/halo/app/content/comment/CommentQuery.java b/src/main/java/run/halo/app/content/comment/CommentQuery.java
new file mode 100644
index 000000000..a106a42f9
--- /dev/null
+++ b/src/main/java/run/halo/app/content/comment/CommentQuery.java
@@ -0,0 +1,85 @@
+package run.halo.app.content.comment;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.util.MultiValueMap;
+import run.halo.app.extension.router.IListRequest;
+
+/**
+ * Query criteria for comment list.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+public class CommentQuery extends IListRequest.QueryListRequest {
+
+ public CommentQuery(MultiValueMap queryParams) {
+ super(queryParams);
+ }
+
+ @Schema(description = "Comments filtered by keyword.")
+ public String getKeyword() {
+ String keyword = queryParams.getFirst("keyword");
+ return StringUtils.isBlank(keyword) ? null : keyword;
+ }
+
+ @Schema(description = "Comments approved.")
+ public Boolean getApproved() {
+ return convertBooleanOrNull(queryParams.getFirst("approved"));
+ }
+
+ @Schema(description = "The comment is hidden from the theme side.")
+ public Boolean getHidden() {
+ return convertBooleanOrNull(queryParams.getFirst("hidden"));
+ }
+
+ @Schema(description = "Send notifications when there are new replies.")
+ public Boolean getAllowNotification() {
+ return convertBooleanOrNull(queryParams.getFirst("allowNotification"));
+ }
+
+ @Schema(description = "Comment top display.")
+ public Boolean getTop() {
+ return convertBooleanOrNull(queryParams.getFirst("top"));
+ }
+
+ @Schema(description = "Commenter kind.")
+ public String getOwnerKind() {
+ String ownerKind = queryParams.getFirst("ownerKind");
+ return StringUtils.isBlank(ownerKind) ? null : ownerKind;
+ }
+
+ @Schema(description = "Commenter name.")
+ public String getOwnerName() {
+ String ownerName = queryParams.getFirst("ownerName");
+ return StringUtils.isBlank(ownerName) ? null : ownerName;
+ }
+
+ @Schema(description = "Comment subject kind.")
+ public String getSubjectKind() {
+ String subjectKind = queryParams.getFirst("subjectKind");
+ return StringUtils.isBlank(subjectKind) ? null : subjectKind;
+ }
+
+ @Schema(description = "Comment subject name.")
+ public String getSubjectName() {
+ String subjectName = queryParams.getFirst("subjectName");
+ return StringUtils.isBlank(subjectName) ? null : subjectName;
+ }
+
+ @Schema(description = "Comment collation.")
+ public CommentSorter getSort() {
+ String sort = queryParams.getFirst("sort");
+ return CommentSorter.convertFrom(sort);
+ }
+
+ @Schema(description = "ascending order If it is true; otherwise, it is in descending order.")
+ public Boolean getSortOrder() {
+ String sortOrder = queryParams.getFirst("sortOrder");
+ return convertBooleanOrNull(sortOrder);
+ }
+
+ private Boolean convertBooleanOrNull(String value) {
+ return StringUtils.isBlank(value) ? null : Boolean.parseBoolean(value);
+ }
+}
diff --git a/src/main/java/run/halo/app/content/comment/CommentRequest.java b/src/main/java/run/halo/app/content/comment/CommentRequest.java
new file mode 100644
index 000000000..fedfec732
--- /dev/null
+++ b/src/main/java/run/halo/app/content/comment/CommentRequest.java
@@ -0,0 +1,55 @@
+package run.halo.app.content.comment;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.util.UUID;
+import lombok.Data;
+import run.halo.app.core.extension.Comment;
+import run.halo.app.extension.Metadata;
+import run.halo.app.extension.Ref;
+
+/**
+ * Request parameter object for {@link Comment}.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+@Data
+public class CommentRequest {
+
+ @Schema(required = true)
+ private Ref subjectRef;
+
+ private CommentEmailOwner owner;
+
+ @Schema(required = true, minLength = 1)
+ private String raw;
+
+ @Schema(required = true, minLength = 1)
+ private String content;
+
+ @Schema(defaultValue = "false")
+ private Boolean allowNotification;
+
+ /**
+ * Converts {@link CommentRequest} to {@link Comment}.
+ *
+ * @return a comment
+ */
+ public Comment toComment() {
+ Comment comment = new Comment();
+ comment.setMetadata(new Metadata());
+ comment.getMetadata().setName(UUID.randomUUID().toString());
+
+ Comment.CommentSpec spec = new Comment.CommentSpec();
+ comment.setSpec(spec);
+ spec.setSubjectRef(subjectRef);
+ spec.setRaw(raw);
+ spec.setContent(content);
+ spec.setAllowNotification(allowNotification);
+
+ if (owner != null) {
+ spec.setOwner(owner.toCommentOwner());
+ }
+ return comment;
+ }
+}
diff --git a/src/main/java/run/halo/app/content/comment/CommentService.java b/src/main/java/run/halo/app/content/comment/CommentService.java
new file mode 100644
index 000000000..c69de3eee
--- /dev/null
+++ b/src/main/java/run/halo/app/content/comment/CommentService.java
@@ -0,0 +1,18 @@
+package run.halo.app.content.comment;
+
+import reactor.core.publisher.Mono;
+import run.halo.app.core.extension.Comment;
+import run.halo.app.extension.ListResult;
+
+/**
+ * An application service for {@link Comment}.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+public interface CommentService {
+
+ Mono> listComment(CommentQuery query);
+
+ Mono create(Comment comment);
+}
diff --git a/src/main/java/run/halo/app/content/comment/CommentServiceImpl.java b/src/main/java/run/halo/app/content/comment/CommentServiceImpl.java
new file mode 100644
index 000000000..2db5f8a68
--- /dev/null
+++ b/src/main/java/run/halo/app/content/comment/CommentServiceImpl.java
@@ -0,0 +1,230 @@
+package run.halo.app.content.comment;
+
+import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToPredicate;
+
+import java.util.Comparator;
+import java.util.Objects;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.security.core.context.ReactiveSecurityContextHolder;
+import org.springframework.stereotype.Component;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import run.halo.app.core.extension.Comment;
+import run.halo.app.core.extension.User;
+import run.halo.app.extension.Extension;
+import run.halo.app.extension.ListResult;
+import run.halo.app.extension.ReactiveExtensionClient;
+import run.halo.app.extension.Ref;
+import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
+import run.halo.app.infra.exception.AccessDeniedException;
+import run.halo.app.plugin.ExtensionComponentsFinder;
+
+/**
+ * Comment service implementation.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+@Component
+public class CommentServiceImpl implements CommentService {
+
+ private final ReactiveExtensionClient client;
+ private final ExtensionComponentsFinder extensionComponentsFinder;
+
+ private final SystemConfigurableEnvironmentFetcher environmentFetcher;
+
+ public CommentServiceImpl(ReactiveExtensionClient client,
+ ExtensionComponentsFinder extensionComponentsFinder,
+ SystemConfigurableEnvironmentFetcher environmentFetcher) {
+ this.client = client;
+ this.extensionComponentsFinder = extensionComponentsFinder;
+ this.environmentFetcher = environmentFetcher;
+ }
+
+ @Override
+ public Mono> listComment(CommentQuery commentQuery) {
+ Comparator comparator =
+ CommentSorter.from(commentQuery.getSort(), commentQuery.getSortOrder());
+ return this.client.list(Comment.class, commentPredicate(commentQuery),
+ comparator,
+ commentQuery.getPage(), commentQuery.getSize())
+ .flatMap(comments -> Flux.fromStream(comments.get()
+ .map(this::toListedComment))
+ .flatMap(Function.identity())
+ .collectList()
+ .map(list -> new ListResult<>(comments.getPage(), comments.getSize(),
+ comments.getTotal(), list)
+ )
+ );
+ }
+
+ @Override
+ public Mono create(Comment comment) {
+ return environmentFetcher.fetchComment()
+ .flatMap(commentSetting -> {
+ if (Boolean.FALSE.equals(commentSetting.getEnable())) {
+ return Mono.error(
+ new AccessDeniedException("The comment function has been turned off."));
+ }
+ if (comment.getSpec().getTop() == null) {
+ comment.getSpec().setTop(false);
+ }
+ if (comment.getSpec().getPriority() == null) {
+ comment.getSpec().setPriority(0);
+ }
+ comment.getSpec()
+ .setApproved(Boolean.FALSE.equals(commentSetting.getRequireReviewForNew()));
+ comment.getSpec().setHidden(comment.getSpec().getApproved());
+ if (comment.getSpec().getOwner() != null) {
+ return Mono.just(comment);
+ }
+ // populate owner from current user
+ return fetchCurrentUser()
+ .map(this::toCommentOwner)
+ .map(owner -> {
+ comment.getSpec().setOwner(owner);
+ return comment;
+ })
+ .switchIfEmpty(
+ Mono.error(new IllegalStateException("The owner must not be null.")));
+ })
+ .flatMap(client::create);
+ }
+
+ private Comment.CommentOwner toCommentOwner(User user) {
+ Comment.CommentOwner owner = new Comment.CommentOwner();
+ owner.setKind(User.KIND);
+ owner.setName(user.getMetadata().getName());
+ owner.setDisplayName(user.getSpec().getDisplayName());
+ return owner;
+ }
+
+ private Mono fetchCurrentUser() {
+ return ReactiveSecurityContextHolder.getContext()
+ .map(securityContext -> securityContext.getAuthentication().getName())
+ .flatMap(username -> client.fetch(User.class, username));
+ }
+
+ private Mono toListedComment(Comment comment) {
+ ListedComment.ListedCommentBuilder commentBuilder = ListedComment.builder()
+ .comment(comment);
+ return Mono.just(commentBuilder)
+ .flatMap(builder -> {
+ Comment.CommentOwner owner = comment.getSpec().getOwner();
+ // not empty
+ return getCommentOwnerInfo(owner)
+ .map(builder::owner);
+ })
+ .flatMap(builder -> getCommentSubject(comment.getSpec().getSubjectRef())
+ .map(subject -> {
+ builder.subject(subject);
+ return builder;
+ })
+ )
+ .map(ListedComment.ListedCommentBuilder::build);
+ }
+
+ private Mono getCommentOwnerInfo(Comment.CommentOwner owner) {
+ if (User.KIND.equals(owner.getKind())) {
+ return client.fetch(User.class, owner.getName())
+ .map(OwnerInfo::from)
+ .switchIfEmpty(Mono.just(OwnerInfo.ghostUser()));
+ }
+ if (Comment.CommentOwner.KIND_EMAIL.equals(owner.getKind())) {
+ return Mono.just(OwnerInfo.from(owner));
+ }
+ throw new IllegalStateException(
+ "Unsupported owner kind: " + owner.getKind());
+ }
+
+ @SuppressWarnings("unchecked")
+ Mono getCommentSubject(Ref ref) {
+ return extensionComponentsFinder.getExtensions(CommentSubject.class)
+ .stream()
+ .filter(commentSubject -> commentSubject.supports(ref))
+ .findFirst()
+ .map(commentSubject -> commentSubject.get(ref.getName()))
+ .orElseGet(Mono::empty);
+ }
+
+ Predicate commentPredicate(CommentQuery query) {
+ Predicate predicate = comment -> true;
+
+ String keyword = query.getKeyword();
+ if (keyword != null) {
+ predicate = predicate.and(comment -> {
+ String raw = comment.getSpec().getRaw();
+ return StringUtils.containsIgnoreCase(raw, keyword);
+ });
+ }
+
+ Boolean approved = query.getApproved();
+ if (approved != null) {
+ predicate =
+ predicate.and(comment -> Objects.equals(comment.getSpec().getApproved(), approved));
+ }
+ Boolean hidden = query.getHidden();
+ if (hidden != null) {
+ predicate =
+ predicate.and(comment -> Objects.equals(comment.getSpec().getHidden(), hidden));
+ }
+
+ Boolean top = query.getTop();
+ if (top != null) {
+ predicate = predicate.and(comment -> Objects.equals(comment.getSpec().getTop(), top));
+ }
+
+ Boolean allowNotification = query.getAllowNotification();
+ if (allowNotification != null) {
+ predicate = predicate.and(
+ comment -> Objects.equals(comment.getSpec().getAllowNotification(),
+ allowNotification));
+ }
+
+ String ownerKind = query.getOwnerKind();
+ if (ownerKind != null) {
+ predicate = predicate.and(comment -> {
+ Comment.CommentOwner owner = comment.getSpec().getOwner();
+ return Objects.equals(owner.getKind(), ownerKind);
+ });
+ }
+
+ String ownerName = query.getOwnerName();
+ if (ownerName != null) {
+ predicate = predicate.and(comment -> {
+ Comment.CommentOwner owner = comment.getSpec().getOwner();
+ if (Comment.CommentOwner.KIND_EMAIL.equals(owner.getKind())) {
+ return Objects.equals(owner.getKind(), ownerKind)
+ && (StringUtils.containsIgnoreCase(owner.getName(), ownerName)
+ || StringUtils.containsIgnoreCase(owner.getDisplayName(), ownerName));
+ }
+ return Objects.equals(owner.getKind(), ownerKind)
+ && StringUtils.containsIgnoreCase(owner.getName(), ownerName);
+ });
+ }
+
+ String subjectKind = query.getSubjectKind();
+ if (subjectKind != null) {
+ predicate = predicate.and(comment -> {
+ Ref subjectRef = comment.getSpec().getSubjectRef();
+ return Objects.equals(subjectRef.getKind(), subjectKind);
+ });
+ }
+
+ String subjectName = query.getSubjectName();
+ if (subjectName != null) {
+ predicate = predicate.and(comment -> {
+ Ref subjectRef = comment.getSpec().getSubjectRef();
+ return Objects.equals(subjectRef.getKind(), subjectKind)
+ && StringUtils.containsIgnoreCase(subjectRef.getName(), subjectName);
+ });
+ }
+
+ Predicate labelAndFieldSelectorPredicate =
+ labelAndFieldSelectorToPredicate(query.getLabelSelector(),
+ query.getFieldSelector());
+ return predicate.and(labelAndFieldSelectorPredicate);
+ }
+}
diff --git a/src/main/java/run/halo/app/content/comment/CommentSorter.java b/src/main/java/run/halo/app/content/comment/CommentSorter.java
new file mode 100644
index 000000000..be8a94e62
--- /dev/null
+++ b/src/main/java/run/halo/app/content/comment/CommentSorter.java
@@ -0,0 +1,73 @@
+package run.halo.app.content.comment;
+
+import java.time.Instant;
+import java.util.Comparator;
+import java.util.Objects;
+import java.util.function.Function;
+import org.springframework.util.comparator.Comparators;
+import run.halo.app.core.extension.Comment;
+
+/**
+ * Comment sorter.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+public enum CommentSorter {
+ LAST_REPLY_TIME,
+ REPLY_COUNT,
+ CREATE_TIME;
+
+ static final Function name = comment -> comment.getMetadata().getName();
+
+ static Comparator from(CommentSorter sorter, Boolean ascending) {
+ if (Objects.equals(true, ascending)) {
+ return from(sorter);
+ }
+ return from(sorter).reversed();
+ }
+
+ static Comparator from(CommentSorter sorter) {
+ if (sorter == null) {
+ return defaultCommentComparator();
+ }
+ if (CREATE_TIME.equals(sorter)) {
+ Function comparatorFunc =
+ comment -> comment.getMetadata().getCreationTimestamp();
+ return Comparator.comparing(comparatorFunc)
+ .thenComparing(name);
+ }
+
+ if (REPLY_COUNT.equals(sorter)) {
+ Function comparatorFunc =
+ comment -> comment.getStatusOrDefault().getReplyCount();
+ return Comparator.comparing(comparatorFunc, Comparators.nullsLow())
+ .thenComparing(name);
+ }
+
+ if (LAST_REPLY_TIME.equals(sorter)) {
+ Function comparatorFunc =
+ comment -> comment.getStatusOrDefault().getLastReplyTime();
+ return Comparator.comparing(comparatorFunc, Comparators.nullsLow())
+ .thenComparing(name);
+ }
+
+ throw new IllegalStateException("Unsupported sort value: " + sorter);
+ }
+
+ static CommentSorter convertFrom(String sort) {
+ for (CommentSorter sorter : values()) {
+ if (sorter.name().equalsIgnoreCase(sort)) {
+ return sorter;
+ }
+ }
+ return null;
+ }
+
+ static Comparator defaultCommentComparator() {
+ Function lastReplyTime =
+ comment -> comment.getStatusOrDefault().getLastReplyTime();
+ return Comparator.comparing(lastReplyTime, Comparators.nullsLow())
+ .thenComparing(name);
+ }
+}
diff --git a/src/main/java/run/halo/app/content/comment/CommentSubject.java b/src/main/java/run/halo/app/content/comment/CommentSubject.java
new file mode 100644
index 000000000..a5d7a31de
--- /dev/null
+++ b/src/main/java/run/halo/app/content/comment/CommentSubject.java
@@ -0,0 +1,19 @@
+package run.halo.app.content.comment;
+
+import org.pf4j.ExtensionPoint;
+import reactor.core.publisher.Mono;
+import run.halo.app.extension.AbstractExtension;
+import run.halo.app.extension.Ref;
+
+/**
+ * Comment subject.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+public interface CommentSubject extends ExtensionPoint {
+
+ Mono get(String name);
+
+ boolean supports(Ref ref);
+}
diff --git a/src/main/java/run/halo/app/content/comment/ListedComment.java b/src/main/java/run/halo/app/content/comment/ListedComment.java
new file mode 100644
index 000000000..d716de6ed
--- /dev/null
+++ b/src/main/java/run/halo/app/content/comment/ListedComment.java
@@ -0,0 +1,26 @@
+package run.halo.app.content.comment;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+import lombok.Value;
+import run.halo.app.core.extension.Comment;
+import run.halo.app.extension.Extension;
+
+/**
+ * Listed comment.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+@Value
+@Builder
+public class ListedComment {
+
+ @Schema(required = true)
+ Comment comment;
+
+ @Schema(required = true)
+ OwnerInfo owner;
+
+ Extension subject;
+}
diff --git a/src/main/java/run/halo/app/content/comment/ListedReply.java b/src/main/java/run/halo/app/content/comment/ListedReply.java
new file mode 100644
index 000000000..f75b241dd
--- /dev/null
+++ b/src/main/java/run/halo/app/content/comment/ListedReply.java
@@ -0,0 +1,23 @@
+package run.halo.app.content.comment;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+import lombok.Value;
+import run.halo.app.core.extension.Reply;
+
+/**
+ * Listed reply for {@link Reply}.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+@Value
+@Builder
+public class ListedReply {
+
+ @Schema(required = true)
+ Reply reply;
+
+ @Schema(required = true)
+ OwnerInfo owner;
+}
diff --git a/src/main/java/run/halo/app/content/comment/OwnerInfo.java b/src/main/java/run/halo/app/content/comment/OwnerInfo.java
new file mode 100644
index 000000000..46a89c057
--- /dev/null
+++ b/src/main/java/run/halo/app/content/comment/OwnerInfo.java
@@ -0,0 +1,77 @@
+package run.halo.app.content.comment;
+
+import lombok.Builder;
+import lombok.Value;
+import org.apache.commons.lang3.StringUtils;
+import run.halo.app.core.extension.Comment;
+import run.halo.app.core.extension.User;
+
+/**
+ * Comment owner info.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+@Value
+@Builder
+public class OwnerInfo {
+
+ String kind;
+
+ String name;
+
+ String displayName;
+
+ String avatar;
+
+ String email;
+
+ /**
+ * Convert user to owner info by owner that has an email kind .
+ *
+ * @param owner comment owner reference.
+ * @return owner info.
+ */
+ public static OwnerInfo from(Comment.CommentOwner owner) {
+ if (!Comment.CommentOwner.KIND_EMAIL.equals(owner.getKind())) {
+ throw new IllegalArgumentException("Only support 'email' owner kind.");
+ }
+ return OwnerInfo.builder()
+ .kind(owner.getKind())
+ .name(owner.getName())
+ .email(owner.getName())
+ .displayName(owner.getDisplayName())
+ .avatar(owner.getAnnotation(Comment.CommentOwner.AVATAR_ANNO))
+ .build();
+ }
+
+ /**
+ * Convert user to owner info by {@link User}.
+ *
+ * @param user user extension.
+ * @return owner info.
+ */
+ public static OwnerInfo from(User user) {
+ return OwnerInfo.builder()
+ .kind(user.getKind())
+ .name(user.getMetadata().getName())
+ .email(user.getSpec().getEmail())
+ .avatar(user.getSpec().getAvatar())
+ .displayName(user.getSpec().getDisplayName())
+ .build();
+ }
+
+ /**
+ * Obtain a ghost owner info when user not found.
+ *
+ * @return a ghost user if user not found.
+ */
+ public static OwnerInfo ghostUser() {
+ return OwnerInfo.builder()
+ .kind(User.KIND)
+ .name("ghost")
+ .email(StringUtils.EMPTY)
+ .displayName("Ghost")
+ .build();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/run/halo/app/content/comment/PostCommentSubject.java b/src/main/java/run/halo/app/content/comment/PostCommentSubject.java
new file mode 100644
index 000000000..380fbb5d1
--- /dev/null
+++ b/src/main/java/run/halo/app/content/comment/PostCommentSubject.java
@@ -0,0 +1,38 @@
+package run.halo.app.content.comment;
+
+import org.springframework.stereotype.Component;
+import org.springframework.util.Assert;
+import reactor.core.publisher.Mono;
+import run.halo.app.core.extension.Post;
+import run.halo.app.extension.GroupVersionKind;
+import run.halo.app.extension.ReactiveExtensionClient;
+import run.halo.app.extension.Ref;
+
+/**
+ * Comment subject for post.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+@Component
+public class PostCommentSubject implements CommentSubject {
+
+ private final ReactiveExtensionClient client;
+
+ public PostCommentSubject(ReactiveExtensionClient client) {
+ this.client = client;
+ }
+
+ @Override
+ public Mono get(String name) {
+ return client.fetch(Post.class, name);
+ }
+
+ @Override
+ public boolean supports(Ref ref) {
+ Assert.notNull(ref, "Subject ref must not be null.");
+ GroupVersionKind groupVersionKind =
+ new GroupVersionKind(ref.getGroup(), ref.getVersion(), ref.getKind());
+ return GroupVersionKind.fromExtension(Post.class).equals(groupVersionKind);
+ }
+}
diff --git a/src/main/java/run/halo/app/content/comment/ReplyQuery.java b/src/main/java/run/halo/app/content/comment/ReplyQuery.java
new file mode 100644
index 000000000..00f855531
--- /dev/null
+++ b/src/main/java/run/halo/app/content/comment/ReplyQuery.java
@@ -0,0 +1,25 @@
+package run.halo.app.content.comment;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.util.MultiValueMap;
+import run.halo.app.extension.router.IListRequest;
+
+/**
+ * Query criteria for {@link run.halo.app.core.extension.Reply} list.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+public class ReplyQuery extends IListRequest.QueryListRequest {
+
+ public ReplyQuery(MultiValueMap queryParams) {
+ super(queryParams);
+ }
+
+ @Schema(description = "Replies filtered by commentName.")
+ public String getCommentName() {
+ String commentName = queryParams.getFirst("commentName");
+ return StringUtils.isBlank(commentName) ? null : commentName;
+ }
+}
diff --git a/src/main/java/run/halo/app/content/comment/ReplyRequest.java b/src/main/java/run/halo/app/content/comment/ReplyRequest.java
new file mode 100644
index 000000000..dbcbdc452
--- /dev/null
+++ b/src/main/java/run/halo/app/content/comment/ReplyRequest.java
@@ -0,0 +1,53 @@
+package run.halo.app.content.comment;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.util.UUID;
+import lombok.Data;
+import run.halo.app.core.extension.Reply;
+import run.halo.app.extension.Metadata;
+
+/**
+ * A request parameter object for {@link Reply}.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+@Data
+public class ReplyRequest {
+
+ @Schema(required = true, minLength = 1)
+ private String raw;
+
+ @Schema(required = true, minLength = 1)
+ private String content;
+
+ @Schema(defaultValue = "false")
+ private Boolean allowNotification;
+
+ private CommentEmailOwner owner;
+
+ private String quoteReply;
+
+ /**
+ * Converts {@link ReplyRequest} to {@link Reply}.
+ *
+ * @return a reply
+ */
+ public Reply toReply() {
+ Reply reply = new Reply();
+ reply.setMetadata(new Metadata());
+ reply.getMetadata().setName(UUID.randomUUID().toString());
+
+ Reply.ReplySpec spec = new Reply.ReplySpec();
+ reply.setSpec(spec);
+ spec.setRaw(raw);
+ spec.setContent(content);
+ spec.setAllowNotification(allowNotification);
+ spec.setQuoteReply(quoteReply);
+
+ if (owner != null) {
+ spec.setOwner(owner.toCommentOwner());
+ }
+ return reply;
+ }
+}
diff --git a/src/main/java/run/halo/app/content/comment/ReplyService.java b/src/main/java/run/halo/app/content/comment/ReplyService.java
new file mode 100644
index 000000000..8b6a6b69b
--- /dev/null
+++ b/src/main/java/run/halo/app/content/comment/ReplyService.java
@@ -0,0 +1,18 @@
+package run.halo.app.content.comment;
+
+import reactor.core.publisher.Mono;
+import run.halo.app.core.extension.Reply;
+import run.halo.app.extension.ListResult;
+
+/**
+ * An application service for {@link Reply}.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+public interface ReplyService {
+
+ Mono create(String commentName, Reply reply);
+
+ Mono> list(ReplyQuery query);
+}
diff --git a/src/main/java/run/halo/app/content/comment/ReplyServiceImpl.java b/src/main/java/run/halo/app/content/comment/ReplyServiceImpl.java
new file mode 100644
index 000000000..b8d0852b5
--- /dev/null
+++ b/src/main/java/run/halo/app/content/comment/ReplyServiceImpl.java
@@ -0,0 +1,155 @@
+package run.halo.app.content.comment;
+
+import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToPredicate;
+
+import java.time.Instant;
+import java.util.Comparator;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import org.springframework.security.core.context.ReactiveSecurityContextHolder;
+import org.springframework.stereotype.Service;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import run.halo.app.core.extension.Comment;
+import run.halo.app.core.extension.Reply;
+import run.halo.app.core.extension.User;
+import run.halo.app.extension.Extension;
+import run.halo.app.extension.ListResult;
+import run.halo.app.extension.ReactiveExtensionClient;
+import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
+import run.halo.app.infra.exception.AccessDeniedException;
+
+/**
+ * A default implementation of {@link ReplyService}.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+@Service
+public class ReplyServiceImpl implements ReplyService {
+
+ private final ReactiveExtensionClient client;
+ private final SystemConfigurableEnvironmentFetcher environmentFetcher;
+
+ public ReplyServiceImpl(ReactiveExtensionClient client,
+ SystemConfigurableEnvironmentFetcher environmentFetcher) {
+ this.client = client;
+ this.environmentFetcher = environmentFetcher;
+ }
+
+ @Override
+ public Mono create(String commentName, Reply reply) {
+ return client.fetch(Comment.class, commentName)
+ .flatMap(comment -> {
+ // Boolean allowNotification = reply.getSpec().getAllowNotification();
+ // TODO send notification if allowNotification is true
+ reply.getSpec().setCommentName(commentName);
+ if (reply.getSpec().getTop() == null) {
+ reply.getSpec().setTop(false);
+ }
+ if (reply.getSpec().getPriority() == null) {
+ reply.getSpec().setPriority(0);
+ }
+ return environmentFetcher.fetchComment()
+ .map(commentSetting -> {
+ if (Boolean.FALSE.equals(commentSetting.getEnable())) {
+ throw new AccessDeniedException(
+ "The comment function has been turned off.");
+ }
+ reply.getSpec().setApproved(
+ Boolean.FALSE.equals(commentSetting.getRequireReviewForNew()));
+ reply.getSpec().setHidden(reply.getSpec().getApproved());
+ return reply;
+ });
+ })
+ .flatMap(replyToUse -> {
+ if (replyToUse.getSpec().getOwner() != null) {
+ return Mono.just(replyToUse);
+ }
+ // populate owner from current user
+ return fetchCurrentUser()
+ .map(user -> {
+ replyToUse.getSpec().setOwner(toCommentOwner(user));
+ return replyToUse;
+ })
+ .switchIfEmpty(
+ Mono.error(new IllegalStateException("Reply owner must not be null.")));
+ })
+ .flatMap(client::create)
+ .switchIfEmpty(Mono.error(
+ new IllegalArgumentException(
+ String.format("Comment not found for name [%s].", commentName)))
+ );
+ }
+
+ @Override
+ public Mono> list(ReplyQuery query) {
+ return client.list(Reply.class, getReplyPredicate(query), defaultComparator(),
+ query.getPage(), query.getSize())
+ .flatMap(list -> Flux.fromStream(list.get()
+ .map(this::toListedReply))
+ .flatMap(Function.identity())
+ .collectList()
+ .map(listedReplies -> new ListResult<>(list.getPage(), list.getSize(),
+ list.getTotal(), listedReplies))
+ );
+ }
+
+ private Mono toListedReply(Reply reply) {
+ ListedReply.ListedReplyBuilder builder = ListedReply.builder()
+ .reply(reply);
+ return getOwnerInfo(reply)
+ .map(ownerInfo -> {
+ builder.owner(ownerInfo);
+ return builder;
+ })
+ .map(ListedReply.ListedReplyBuilder::build);
+ }
+
+ private Mono getOwnerInfo(Reply reply) {
+ Comment.CommentOwner owner = reply.getSpec().getOwner();
+ if (User.KIND.equals(owner.getKind())) {
+ return client.fetch(User.class, owner.getName())
+ .map(OwnerInfo::from)
+ .switchIfEmpty(Mono.just(OwnerInfo.ghostUser()));
+ }
+ if (Comment.CommentOwner.KIND_EMAIL.equals(owner.getKind())) {
+ return Mono.just(OwnerInfo.from(owner));
+ }
+ throw new IllegalStateException(
+ "Unsupported owner kind: " + owner.getKind());
+ }
+
+ Comparator defaultComparator() {
+ Function createTime = reply -> reply.getMetadata().getCreationTimestamp();
+ return Comparator.comparing(createTime)
+ .thenComparing(reply -> reply.getMetadata().getName());
+ }
+
+ Predicate getReplyPredicate(ReplyQuery query) {
+ Predicate predicate = reply -> true;
+ if (query.getCommentName() != null) {
+ predicate = predicate.and(
+ reply -> query.getCommentName().equals(reply.getSpec().getCommentName()));
+ }
+
+ Predicate labelAndFieldSelectorPredicate =
+ labelAndFieldSelectorToPredicate(query.getLabelSelector(),
+ query.getFieldSelector());
+ return predicate.and(labelAndFieldSelectorPredicate);
+ }
+
+ private Comment.CommentOwner toCommentOwner(User user) {
+ Comment.CommentOwner owner = new Comment.CommentOwner();
+ owner.setKind(User.KIND);
+ owner.setName(user.getMetadata().getName());
+ owner.setDisplayName(user.getSpec().getDisplayName());
+ return owner;
+ }
+
+ private Mono fetchCurrentUser() {
+ return ReactiveSecurityContextHolder.getContext()
+ .map(securityContext -> securityContext.getAuthentication().getName())
+ .flatMap(username -> client.fetch(User.class, username));
+ }
+}
diff --git a/src/main/java/run/halo/app/content/comment/SinglePageCommentSubject.java b/src/main/java/run/halo/app/content/comment/SinglePageCommentSubject.java
new file mode 100644
index 000000000..4129eb2bc
--- /dev/null
+++ b/src/main/java/run/halo/app/content/comment/SinglePageCommentSubject.java
@@ -0,0 +1,38 @@
+package run.halo.app.content.comment;
+
+import org.springframework.stereotype.Component;
+import org.springframework.util.Assert;
+import reactor.core.publisher.Mono;
+import run.halo.app.core.extension.SinglePage;
+import run.halo.app.extension.GroupVersionKind;
+import run.halo.app.extension.ReactiveExtensionClient;
+import run.halo.app.extension.Ref;
+
+/**
+ * Comment subject for {@link SinglePage}.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+@Component
+public class SinglePageCommentSubject implements CommentSubject {
+
+ private final ReactiveExtensionClient client;
+
+ public SinglePageCommentSubject(ReactiveExtensionClient client) {
+ this.client = client;
+ }
+
+ @Override
+ public Mono get(String name) {
+ return client.fetch(SinglePage.class, name);
+ }
+
+ @Override
+ public boolean supports(Ref ref) {
+ Assert.notNull(ref, "Subject ref must not be null.");
+ GroupVersionKind groupVersionKind =
+ new GroupVersionKind(ref.getGroup(), ref.getVersion(), ref.getKind());
+ return GroupVersionKind.fromExtension(SinglePage.class).equals(groupVersionKind);
+ }
+}
diff --git a/src/main/java/run/halo/app/core/extension/Comment.java b/src/main/java/run/halo/app/core/extension/Comment.java
index 323513c50..666da8708 100644
--- a/src/main/java/run/halo/app/core/extension/Comment.java
+++ b/src/main/java/run/halo/app/core/extension/Comment.java
@@ -1,12 +1,16 @@
package run.halo.app.core.extension;
+import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.Instant;
import java.util.Map;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
+import org.springframework.lang.Nullable;
import run.halo.app.extension.AbstractExtension;
import run.halo.app.extension.GVK;
+import run.halo.app.extension.Ref;
/**
* @author guqing
@@ -24,12 +28,25 @@ public class Comment extends AbstractExtension {
@Schema(required = true)
private CommentSpec spec;
+ @Schema
+ private CommentStatus status;
+
+ @JsonIgnore
+ public CommentStatus getStatusOrDefault() {
+ if (this.status == null) {
+ this.status = new CommentStatus();
+ }
+ return this.status;
+ }
+
@Data
@EqualsAndHashCode(callSuper = true)
public static class CommentSpec extends BaseCommentSpec {
@Schema(required = true)
- private CommentSubjectRef subjectRef;
+ private Ref subjectRef;
+
+ private Instant lastReadTime;
}
@Data
@@ -66,23 +83,38 @@ public class Comment extends AbstractExtension {
@Data
public static class CommentOwner {
+ public static final String KIND_EMAIL = "Email";
+ public static final String AVATAR_ANNO = "avatar";
+ public static final String WEBSITE_ANNO = "website";
+
@Schema(required = true, minLength = 1)
private String kind;
- @Schema(required = true, minLength = 1, maxLength = 64)
+ @Schema(required = true, maxLength = 64)
private String name;
private String displayName;
private Map annotations;
+
+ @Nullable
+ @JsonIgnore
+ public String getAnnotation(String key) {
+ return annotations == null ? null : annotations.get(key);
+ }
}
@Data
- public static class CommentSubjectRef {
- @Schema(required = true, minLength = 1)
- private String kind;
+ public static class CommentStatus {
- @Schema(required = true, minLength = 1, maxLength = 64)
- private String name;
+ private Instant lastReplyTime;
+
+ private Integer replyCount;
+
+ private Integer unreadReplyCount;
+
+ public boolean getHasNewReply() {
+ return unreadReplyCount != null && unreadReplyCount > 0;
+ }
}
}
diff --git a/src/main/java/run/halo/app/core/extension/endpoint/CommentEndpoint.java b/src/main/java/run/halo/app/core/extension/endpoint/CommentEndpoint.java
new file mode 100644
index 000000000..aee039035
--- /dev/null
+++ b/src/main/java/run/halo/app/core/extension/endpoint/CommentEndpoint.java
@@ -0,0 +1,137 @@
+package run.halo.app.core.extension.endpoint;
+
+import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder;
+import static org.springdoc.core.fn.builders.content.Builder.contentBuilder;
+import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder;
+import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder;
+
+import io.swagger.v3.oas.annotations.enums.ParameterIn;
+import org.apache.commons.lang3.StringUtils;
+import org.springdoc.core.fn.builders.schema.Builder;
+import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Component;
+import org.springframework.web.reactive.function.server.RouterFunction;
+import org.springframework.web.reactive.function.server.ServerRequest;
+import org.springframework.web.reactive.function.server.ServerResponse;
+import reactor.core.publisher.Mono;
+import run.halo.app.content.comment.CommentQuery;
+import run.halo.app.content.comment.CommentRequest;
+import run.halo.app.content.comment.CommentService;
+import run.halo.app.content.comment.ListedComment;
+import run.halo.app.content.comment.ReplyRequest;
+import run.halo.app.content.comment.ReplyService;
+import run.halo.app.core.extension.Comment;
+import run.halo.app.core.extension.Reply;
+import run.halo.app.extension.ListResult;
+import run.halo.app.extension.router.QueryParamBuildUtil;
+import run.halo.app.infra.utils.IpAddressUtils;
+
+
+/**
+ * Endpoint for managing comment.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+@Component
+public class CommentEndpoint implements CustomEndpoint {
+
+ private final CommentService commentService;
+ private final ReplyService replyService;
+
+ public CommentEndpoint(CommentService commentService, ReplyService replyService) {
+ this.commentService = commentService;
+ this.replyService = replyService;
+ }
+
+ @Override
+ public RouterFunction endpoint() {
+ final var tag = "api.halo.run/v1alpha1/Comment";
+ return SpringdocRouteBuilder.route()
+ .GET("comments", this::listComments, builder -> {
+ builder.operationId("ListComments")
+ .description("List comments.")
+ .tag(tag)
+ .response(responseBuilder()
+ .implementation(ListResult.generateGenericClass(ListedComment.class))
+ );
+ QueryParamBuildUtil.buildParametersFromType(builder, CommentQuery.class);
+ }
+ )
+ .POST("comments", this::createComment,
+ builder -> builder.operationId("CreateComment")
+ .description("Create a comment.")
+ .tag(tag)
+ .requestBody(requestBodyBuilder()
+ .required(true)
+ .content(contentBuilder()
+ .mediaType(MediaType.APPLICATION_JSON_VALUE)
+ .schema(Builder.schemaBuilder()
+ .implementation(CommentRequest.class))
+ ))
+ .response(responseBuilder()
+ .implementation(Comment.class))
+ )
+ .POST("comments/{name}/reply", this::createReply,
+ builder -> builder.operationId("CreateReply")
+ .description("Create a reply.")
+ .tag(tag)
+ .parameter(parameterBuilder().name("name")
+ .in(ParameterIn.PATH)
+ .required(true)
+ .implementation(String.class))
+ .requestBody(requestBodyBuilder()
+ .required(true)
+ .content(contentBuilder()
+ .mediaType(MediaType.APPLICATION_JSON_VALUE)
+ .schema(Builder.schemaBuilder()
+ .implementation(ReplyRequest.class))
+ ))
+ .response(responseBuilder()
+ .implementation(Reply.class))
+ )
+ .build();
+ }
+
+ Mono listComments(ServerRequest request) {
+ CommentQuery commentQuery = new CommentQuery(request.queryParams());
+ return commentService.listComment(commentQuery)
+ .flatMap(listedComments -> ServerResponse.ok().bodyValue(listedComments));
+ }
+
+ Mono createComment(ServerRequest request) {
+ return request.bodyToMono(CommentRequest.class)
+ .flatMap(commentRequest -> {
+ Comment comment = commentRequest.toComment();
+ comment.getSpec().setIpAddress(IpAddressUtils.getIpAddress(request));
+ comment.getSpec().setUserAgent(userAgentFrom(request));
+ return commentService.create(comment);
+ })
+ .flatMap(comment -> ServerResponse.ok().bodyValue(comment));
+ }
+
+ Mono createReply(ServerRequest request) {
+ String commentName = request.pathVariable("name");
+ return request.bodyToMono(ReplyRequest.class)
+ .flatMap(replyRequest -> {
+ Reply reply = replyRequest.toReply();
+ reply.getSpec().setIpAddress(IpAddressUtils.getIpAddress(request));
+ reply.getSpec().setUserAgent(userAgentFrom(request));
+ return replyService.create(commentName, reply);
+ })
+ .flatMap(comment -> ServerResponse.ok().bodyValue(comment));
+ }
+
+ private String userAgentFrom(ServerRequest request) {
+ HttpHeaders httpHeaders = request.headers().asHttpHeaders();
+ // https://en.wikipedia.org/wiki/User_agent
+ String userAgent = httpHeaders.getFirst(HttpHeaders.USER_AGENT);
+ if (StringUtils.isBlank(userAgent)) {
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-CH-UA
+ userAgent = httpHeaders.getFirst("Sec-CH-UA");
+ }
+ return StringUtils.defaultString(userAgent, "UNKNOWN");
+ }
+}
diff --git a/src/main/java/run/halo/app/core/extension/endpoint/ReplyEndpoint.java b/src/main/java/run/halo/app/core/extension/endpoint/ReplyEndpoint.java
new file mode 100644
index 000000000..036676d56
--- /dev/null
+++ b/src/main/java/run/halo/app/core/extension/endpoint/ReplyEndpoint.java
@@ -0,0 +1,55 @@
+package run.halo.app.core.extension.endpoint;
+
+import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder;
+
+import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
+import org.springframework.stereotype.Component;
+import org.springframework.web.reactive.function.server.RouterFunction;
+import org.springframework.web.reactive.function.server.ServerRequest;
+import org.springframework.web.reactive.function.server.ServerResponse;
+import reactor.core.publisher.Mono;
+import run.halo.app.content.comment.ListedReply;
+import run.halo.app.content.comment.ReplyQuery;
+import run.halo.app.content.comment.ReplyService;
+import run.halo.app.core.extension.Reply;
+import run.halo.app.extension.ListResult;
+import run.halo.app.extension.router.QueryParamBuildUtil;
+
+/**
+ * Endpoint for managing {@link Reply}.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+@Component
+public class ReplyEndpoint implements CustomEndpoint {
+
+ private final ReplyService replyService;
+
+ public ReplyEndpoint(ReplyService replyService) {
+ this.replyService = replyService;
+ }
+
+ @Override
+ public RouterFunction endpoint() {
+ final var tag = "api.halo.run/v1alpha1/Reply";
+ return SpringdocRouteBuilder.route()
+ .GET("replies", this::listReplies, builder -> {
+ builder.operationId("ListReplies")
+ .description("List replies.")
+ .tag(tag)
+ .response(responseBuilder()
+ .implementation(ListResult.generateGenericClass(ListedReply.class))
+ );
+ QueryParamBuildUtil.buildParametersFromType(builder, ReplyQuery.class);
+ }
+ )
+ .build();
+ }
+
+ Mono listReplies(ServerRequest request) {
+ ReplyQuery replyQuery = new ReplyQuery(request.queryParams());
+ return replyService.list(replyQuery)
+ .flatMap(listedReplies -> ServerResponse.ok().bodyValue(listedReplies));
+ }
+}
diff --git a/src/main/java/run/halo/app/core/extension/reconciler/CommentReconciler.java b/src/main/java/run/halo/app/core/extension/reconciler/CommentReconciler.java
new file mode 100644
index 000000000..dcc4b2aa0
--- /dev/null
+++ b/src/main/java/run/halo/app/core/extension/reconciler/CommentReconciler.java
@@ -0,0 +1,122 @@
+package run.halo.app.core.extension.reconciler;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.function.Function;
+import run.halo.app.core.extension.Comment;
+import run.halo.app.core.extension.Reply;
+import run.halo.app.extension.ExtensionClient;
+import run.halo.app.extension.controller.Reconciler;
+import run.halo.app.infra.utils.JsonUtils;
+
+/**
+ * Reconciler for {@link Comment}.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+public class CommentReconciler implements Reconciler {
+ public static final String FINALIZER_NAME = "comment-protection";
+ private final ExtensionClient client;
+
+ public CommentReconciler(ExtensionClient client) {
+ this.client = client;
+ }
+
+ @Override
+ public Result reconcile(Request request) {
+ return client.fetch(Comment.class, request.name())
+ .map(comment -> {
+ if (isDeleted(comment)) {
+ cleanUpResourcesAndRemoveFinalizer(request.name());
+ return new Result(false, null);
+ }
+ addFinalizerIfNecessary(comment);
+ reconcileStatus(request.name());
+ return new Result(true, Duration.ofMinutes(1));
+ })
+ .orElseGet(() -> new Result(false, null));
+ }
+
+ private boolean isDeleted(Comment comment) {
+ return comment.getMetadata().getDeletionTimestamp() != null;
+ }
+
+ private void reconcileStatus(String name) {
+ client.fetch(Comment.class, name).ifPresent(comment -> {
+ final Comment oldComment = JsonUtils.deepCopy(comment);
+
+ List replies = client.list(Reply.class,
+ reply -> name.equals(reply.getSpec().getCommentName()),
+ defaultReplyComparator());
+ // calculate reply count
+ comment.getStatusOrDefault().setReplyCount(replies.size());
+ // calculate last reply time
+ if (!replies.isEmpty()) {
+ Instant lastReplyTime = replies.get(0).getMetadata().getCreationTimestamp();
+ comment.getStatusOrDefault().setLastReplyTime(lastReplyTime);
+ }
+ // calculate unread reply count
+ Instant lastReadTime = comment.getSpec().getLastReadTime();
+ long unreadReplyCount = replies.stream()
+ .filter(reply -> {
+ if (lastReadTime == null) {
+ return true;
+ }
+ return reply.getMetadata().getCreationTimestamp().isAfter(lastReadTime);
+ })
+ .count();
+ comment.getStatusOrDefault().setUnreadReplyCount((int) unreadReplyCount);
+
+ if (!oldComment.equals(comment)) {
+ client.update(comment);
+ }
+ });
+ }
+
+ private void addFinalizerIfNecessary(Comment oldComment) {
+ Set finalizers = oldComment.getMetadata().getFinalizers();
+ if (finalizers != null && finalizers.contains(FINALIZER_NAME)) {
+ return;
+ }
+ client.fetch(Comment.class, oldComment.getMetadata().getName())
+ .ifPresent(comment -> {
+ Set newFinalizers = comment.getMetadata().getFinalizers();
+ if (newFinalizers == null) {
+ newFinalizers = new HashSet<>();
+ comment.getMetadata().setFinalizers(newFinalizers);
+ }
+ newFinalizers.add(FINALIZER_NAME);
+ client.update(comment);
+ });
+ }
+
+ private void cleanUpResourcesAndRemoveFinalizer(String commentName) {
+ client.fetch(Comment.class, commentName).ifPresent(comment -> {
+ cleanUpResources(comment);
+ if (comment.getMetadata().getFinalizers() != null) {
+ comment.getMetadata().getFinalizers().remove(FINALIZER_NAME);
+ }
+ client.update(comment);
+ });
+ }
+
+ private void cleanUpResources(Comment comment) {
+ // delete all replies under current comment
+ client.list(Reply.class, reply -> comment.getMetadata().getName()
+ .equals(reply.getSpec().getCommentName()),
+ null)
+ .forEach(client::delete);
+ }
+
+ Comparator defaultReplyComparator() {
+ Function createTime = reply -> reply.getMetadata().getCreationTimestamp();
+ return Comparator.comparing(createTime)
+ .thenComparing(reply -> reply.getMetadata().getName())
+ .reversed();
+ }
+}
diff --git a/src/main/java/run/halo/app/infra/SystemConfigurableEnvironmentFetcher.java b/src/main/java/run/halo/app/infra/SystemConfigurableEnvironmentFetcher.java
index 3e698865f..f86222e66 100644
--- a/src/main/java/run/halo/app/infra/SystemConfigurableEnvironmentFetcher.java
+++ b/src/main/java/run/halo/app/infra/SystemConfigurableEnvironmentFetcher.java
@@ -25,7 +25,9 @@ public class SystemConfigurableEnvironmentFetcher {
}
public Mono fetch(String key, Class type) {
- return getValuesInternal().map(map -> map.get(key))
+ return getValuesInternal()
+ .filter(map -> map.containsKey(key))
+ .map(map -> map.get(key))
.mapNotNull(stringValue -> {
if (conversionService.canConvert(String.class, type)) {
return conversionService.convert(stringValue, type);
@@ -34,6 +36,11 @@ public class SystemConfigurableEnvironmentFetcher {
});
}
+ public Mono fetchComment() {
+ return fetch(SystemSetting.Comment.GROUP, SystemSetting.Comment.class)
+ .switchIfEmpty(Mono.just(new SystemSetting.Comment()));
+ }
+
@NonNull
private Mono