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> getValuesInternal() { return getConfigMap() diff --git a/src/main/java/run/halo/app/infra/utils/IpAddressUtils.java b/src/main/java/run/halo/app/infra/utils/IpAddressUtils.java new file mode 100644 index 000000000..7afdd3ef0 --- /dev/null +++ b/src/main/java/run/halo/app/infra/utils/IpAddressUtils.java @@ -0,0 +1,72 @@ +package run.halo.app.infra.utils; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.HttpHeaders; +import org.springframework.web.reactive.function.server.ServerRequest; + +/** + * Ip address utils. + * Code from internet. + */ +public class IpAddressUtils { + private static final String UNKNOWN = "unknown"; + private static final String X_REAL_IP = "X-Real-IP"; + private static final String X_FORWARDED_FOR = "X-Forwarded-For"; + private static final String PROXY_CLIENT_IP = "Proxy-Client-IP"; + private static final String WL_PROXY_CLIENT_IP = "WL-Proxy-Client-IP"; + private static final String HTTP_CLIENT_IP = "HTTP_CLIENT_IP"; + private static final String HTTP_X_FORWARDED_FOR = "HTTP_X_FORWARDED_FOR"; + + /** + * Gets the ip address from request. + * + * @param request http request + * @return ip address if found, otherwise {@link #UNKNOWN}. + */ + public static String getIpAddress(ServerRequest request) { + try { + return getIpAddressInternal(request); + } catch (Exception e) { + return UNKNOWN; + } + } + + private static String getIpAddressInternal(ServerRequest request) { + HttpHeaders httpHeaders = request.headers().asHttpHeaders(); + String xrealIp = httpHeaders.getFirst(X_REAL_IP); + String xforwardedFor = httpHeaders.getFirst(X_FORWARDED_FOR); + + if (StringUtils.isNotEmpty(xforwardedFor) && !UNKNOWN.equalsIgnoreCase(xforwardedFor)) { + // After multiple reverse proxies, there will be multiple IP values. The first IP is + // the real IP + int index = xforwardedFor.indexOf(","); + if (index != -1) { + return xforwardedFor.substring(0, index); + } else { + return xforwardedFor; + } + } + xforwardedFor = xrealIp; + if (StringUtils.isNotEmpty(xforwardedFor) && !UNKNOWN.equalsIgnoreCase(xforwardedFor)) { + return xforwardedFor; + } + if (StringUtils.isBlank(xforwardedFor) || UNKNOWN.equalsIgnoreCase(xforwardedFor)) { + xforwardedFor = httpHeaders.getFirst(PROXY_CLIENT_IP); + } + if (StringUtils.isBlank(xforwardedFor) || UNKNOWN.equalsIgnoreCase(xforwardedFor)) { + xforwardedFor = httpHeaders.getFirst(WL_PROXY_CLIENT_IP); + } + if (StringUtils.isBlank(xforwardedFor) || UNKNOWN.equalsIgnoreCase(xforwardedFor)) { + xforwardedFor = httpHeaders.getFirst(HTTP_CLIENT_IP); + } + if (StringUtils.isBlank(xforwardedFor) || UNKNOWN.equalsIgnoreCase(xforwardedFor)) { + xforwardedFor = httpHeaders.getFirst(HTTP_X_FORWARDED_FOR); + } + if (StringUtils.isBlank(xforwardedFor) || UNKNOWN.equalsIgnoreCase(xforwardedFor)) { + xforwardedFor = request.remoteAddress() + .map(remoteAddress -> remoteAddress.getAddress().getHostAddress()) + .orElse(UNKNOWN); + } + return xforwardedFor; + } +} diff --git a/src/main/java/run/halo/app/plugin/ExtensionComponentsFinder.java b/src/main/java/run/halo/app/plugin/ExtensionComponentsFinder.java new file mode 100644 index 000000000..a4bc66741 --- /dev/null +++ b/src/main/java/run/halo/app/plugin/ExtensionComponentsFinder.java @@ -0,0 +1,66 @@ +package run.halo.app.plugin; + +import java.util.ArrayList; +import java.util.List; +import org.pf4j.ExtensionPoint; +import org.springframework.context.ApplicationContext; +import org.springframework.stereotype.Component; + +/** + * Extension components finder for {@link ExtensionPoint}. + * + * @author guqing + * @since 2.0.0 + */ +@Component +public class ExtensionComponentsFinder { + public static final String SYSTEM_PLUGIN_ID = "system"; + private final HaloPluginManager haloPluginManager; + private final ApplicationContext applicationContext; + + public ExtensionComponentsFinder(HaloPluginManager haloPluginManager, + ApplicationContext applicationContext) { + this.haloPluginManager = haloPluginManager; + this.applicationContext = applicationContext; + } + + /** + * Finds all extension components. + * + * @param type subclass type of extension point + * @param extension component type + * @return extension components + */ + public List getExtensions(Class type) { + assertExtensionPoint(type); + List components = new ArrayList<>(haloPluginManager.getExtensions(type)); + components.addAll(applicationContext.getBeansOfType(type).values()); + return List.copyOf(components); + } + + /** + *

Finds all extension components by plugin id.

+ * If the plugin id is system or null, it means to find from halo. + * + * @param type subclass type of extension point + * @param extension component type + * @return extension components + */ + public List getExtensions(Class type, String pluginId) { + assertExtensionPoint(type); + List components = new ArrayList<>(); + if (pluginId == null || SYSTEM_PLUGIN_ID.equals(pluginId)) { + components.addAll(applicationContext.getBeansOfType(type).values()); + return components; + } else { + components.addAll(haloPluginManager.getExtensions(type, pluginId)); + } + return components; + } + + private void assertExtensionPoint(Class type) { + if (!ExtensionPoint.class.isAssignableFrom(type)) { + throw new IllegalArgumentException("The type must be a subclass of ExtensionPoint"); + } + } +} diff --git a/src/main/java/run/halo/app/theme/finders/CommentFinder.java b/src/main/java/run/halo/app/theme/finders/CommentFinder.java index 00362be1b..a420f8d8e 100644 --- a/src/main/java/run/halo/app/theme/finders/CommentFinder.java +++ b/src/main/java/run/halo/app/theme/finders/CommentFinder.java @@ -3,6 +3,7 @@ package run.halo.app.theme.finders; import org.springframework.lang.Nullable; import run.halo.app.core.extension.Comment; import run.halo.app.extension.ListResult; +import run.halo.app.extension.Ref; import run.halo.app.theme.finders.vo.CommentVo; import run.halo.app.theme.finders.vo.ReplyVo; @@ -16,7 +17,7 @@ public interface CommentFinder { CommentVo getByName(String name); - ListResult list(Comment.CommentSubjectRef ref, @Nullable Integer page, + ListResult list(Ref ref, @Nullable Integer page, @Nullable Integer size); ListResult listReply(String commentName, @Nullable Integer page, diff --git a/src/main/java/run/halo/app/theme/finders/impl/CommentFinderImpl.java b/src/main/java/run/halo/app/theme/finders/impl/CommentFinderImpl.java index ecbeb7c76..6f73fcf57 100644 --- a/src/main/java/run/halo/app/theme/finders/impl/CommentFinderImpl.java +++ b/src/main/java/run/halo/app/theme/finders/impl/CommentFinderImpl.java @@ -12,6 +12,7 @@ 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.ReactiveExtensionClient; +import run.halo.app.extension.Ref; import run.halo.app.theme.finders.CommentFinder; import run.halo.app.theme.finders.Finder; import run.halo.app.theme.finders.vo.CommentVo; @@ -40,7 +41,7 @@ public class CommentFinderImpl implements CommentFinder { } @Override - public ListResult list(Comment.CommentSubjectRef ref, Integer page, Integer size) { + public ListResult list(Ref ref, Integer page, Integer size) { return client.list(Comment.class, fixedPredicate(ref), defaultComparator(), pageNullSafe(page), sizeNullSafe(size)) @@ -69,9 +70,9 @@ public class CommentFinderImpl implements CommentFinder { .block(); } - private Predicate fixedPredicate(Comment.CommentSubjectRef ref) { + private Predicate fixedPredicate(Ref ref) { Assert.notNull(ref, "Comment subject reference must not be null"); - return comment -> ref.equals(comment.getSpec().getSubjectRef()) + return comment -> comment.getSpec().getSubjectRef().equals(ref) && Objects.equals(false, comment.getSpec().getHidden()) && Objects.equals(true, comment.getSpec().getApproved()); } diff --git a/src/main/resources/extensions/role-template-anonymous.yaml b/src/main/resources/extensions/role-template-anonymous.yaml index 4539f9cc7..338d9a129 100644 --- a/src/main/resources/extensions/role-template-anonymous.yaml +++ b/src/main/resources/extensions/role-template-anonymous.yaml @@ -5,4 +5,7 @@ metadata: labels: halo.run/role-template: "true" halo.run/hidden: "true" -rules: [ ] +rules: + - apiGroups: [ "api.halo.run" ] + resources: [ "comments", "comments/reply" ] + verbs: [ "create" ] diff --git a/src/test/java/run/halo/app/content/comment/CommentEmailOwnerTest.java b/src/test/java/run/halo/app/content/comment/CommentEmailOwnerTest.java new file mode 100644 index 000000000..6674b1cd7 --- /dev/null +++ b/src/test/java/run/halo/app/content/comment/CommentEmailOwnerTest.java @@ -0,0 +1,52 @@ +package run.halo.app.content.comment; + +import org.json.JSONException; +import org.junit.jupiter.api.Test; +import org.skyscreamer.jsonassert.JSONAssert; +import run.halo.app.core.extension.Comment; +import run.halo.app.infra.utils.JsonUtils; + +/** + * Tests for {@link CommentEmailOwner}. + * + * @author guqing + * @since 2.0.0 + */ +class CommentEmailOwnerTest { + + @Test + void constructorTest() throws JSONException { + CommentEmailOwner commentEmailOwner = + new CommentEmailOwner("example@example.com", "avatar", "displayName", "website"); + JSONAssert.assertEquals(""" + { + "email": "example@example.com", + "avatar": "avatar", + "displayName": "displayName", + "website": "website" + } + """, + JsonUtils.objectToJson(commentEmailOwner), + true); + } + + @Test + void toCommentOwner() throws JSONException { + CommentEmailOwner commentEmailOwner = + new CommentEmailOwner("example@example.com", "avatar", "displayName", "website"); + Comment.CommentOwner commentOwner = commentEmailOwner.toCommentOwner(); + JSONAssert.assertEquals(""" + { + "kind": "Email", + "name": "example@example.com", + "displayName": "displayName", + "annotations": { + "website": "website", + "avatar": "avatar" + } + } + """, + JsonUtils.objectToJson(commentOwner), + true); + } +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/content/comment/CommentQueryTest.java b/src/test/java/run/halo/app/content/comment/CommentQueryTest.java new file mode 100644 index 000000000..5eee2aada --- /dev/null +++ b/src/test/java/run/halo/app/content/comment/CommentQueryTest.java @@ -0,0 +1,203 @@ +package run.halo.app.content.comment; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +/** + * Tests for {@link CommentQuery}. + * + * @author guqing + * @since 2.0.0 + */ +class CommentQueryTest { + + @Test + void getKeyword() { + MultiValueMap queryParams = new LinkedMultiValueMap<>(); + queryParams.add("keyword", "test"); + CommentQuery commentQuery = new CommentQuery(queryParams); + assertThat(commentQuery.getKeyword()).isEqualTo("test"); + queryParams.clear(); + + queryParams.add("keyword", ""); + assertThat(commentQuery.getKeyword()).isNull(); + queryParams.clear(); + + queryParams.add("keyword", null); + assertThat(commentQuery.getKeyword()).isNull(); + queryParams.clear(); + } + + @Test + void getApproved() { + MultiValueMap queryParams = new LinkedMultiValueMap<>(); + queryParams.add("approved", "true"); + CommentQuery commentQuery = new CommentQuery(queryParams); + assertThat(commentQuery.getApproved()).isTrue(); + queryParams.clear(); + + queryParams.add("approved", ""); + assertThat(commentQuery.getApproved()).isNull(); + queryParams.clear(); + + queryParams.add("approved", "1"); + assertThat(commentQuery.getApproved()).isFalse(); + queryParams.clear(); + } + + @Test + void getHidden() { + MultiValueMap queryParams = new LinkedMultiValueMap<>(); + queryParams.add("hidden", "true"); + CommentQuery commentQuery = new CommentQuery(queryParams); + assertThat(commentQuery.getHidden()).isTrue(); + queryParams.clear(); + + queryParams.add("hidden", ""); + assertThat(commentQuery.getHidden()).isNull(); + queryParams.clear(); + + queryParams.add("hidden", "1"); + assertThat(commentQuery.getHidden()).isFalse(); + queryParams.clear(); + } + + @Test + void getAllowNotification() { + MultiValueMap queryParams = new LinkedMultiValueMap<>(); + queryParams.add("allowNotification", "true"); + CommentQuery commentQuery = new CommentQuery(queryParams); + assertThat(commentQuery.getAllowNotification()).isTrue(); + queryParams.clear(); + + queryParams.add("allowNotification", ""); + assertThat(commentQuery.getAllowNotification()).isNull(); + queryParams.clear(); + + queryParams.add("allowNotification", "1"); + assertThat(commentQuery.getAllowNotification()).isFalse(); + queryParams.clear(); + } + + @Test + void getTop() { + MultiValueMap queryParams = new LinkedMultiValueMap<>(); + queryParams.add("top", "true"); + CommentQuery commentQuery = new CommentQuery(queryParams); + assertThat(commentQuery.getTop()).isTrue(); + queryParams.clear(); + + queryParams.add("top", ""); + assertThat(commentQuery.getTop()).isNull(); + queryParams.clear(); + + queryParams.add("top", "1"); + assertThat(commentQuery.getTop()).isFalse(); + queryParams.clear(); + } + + @Test + void getOwnerKind() { + MultiValueMap queryParams = new LinkedMultiValueMap<>(); + queryParams.add("ownerKind", "test-owner-kind"); + CommentQuery commentQuery = new CommentQuery(queryParams); + assertThat(commentQuery.getOwnerKind()).isEqualTo("test-owner-kind"); + queryParams.clear(); + + queryParams.add("ownerKind", ""); + assertThat(commentQuery.getOwnerKind()).isNull(); + queryParams.clear(); + + queryParams.add("ownerKind", null); + assertThat(commentQuery.getOwnerKind()).isNull(); + queryParams.clear(); + } + + @Test + void getOwnerName() { + MultiValueMap queryParams = new LinkedMultiValueMap<>(); + queryParams.add("ownerName", "test-owner-name"); + CommentQuery commentQuery = new CommentQuery(queryParams); + assertThat(commentQuery.getOwnerName()).isEqualTo("test-owner-name"); + queryParams.clear(); + + queryParams.add("ownerName", ""); + assertThat(commentQuery.getOwnerName()).isNull(); + queryParams.clear(); + + queryParams.add("ownerName", null); + assertThat(commentQuery.getOwnerName()).isNull(); + queryParams.clear(); + } + + @Test + void getSubjectKind() { + MultiValueMap queryParams = new LinkedMultiValueMap<>(); + queryParams.add("subjectKind", "test-subject-kind"); + CommentQuery commentQuery = new CommentQuery(queryParams); + assertThat(commentQuery.getSubjectKind()).isEqualTo("test-subject-kind"); + queryParams.clear(); + + queryParams.add("subjectKind", ""); + assertThat(commentQuery.getSubjectKind()).isNull(); + queryParams.clear(); + + queryParams.add("subjectKind", null); + assertThat(commentQuery.getSubjectKind()).isNull(); + queryParams.clear(); + } + + @Test + void getSubjectName() { + MultiValueMap queryParams = new LinkedMultiValueMap<>(); + queryParams.add("subjectName", "test-subject-name"); + CommentQuery commentQuery = new CommentQuery(queryParams); + assertThat(commentQuery.getSubjectName()).isEqualTo("test-subject-name"); + queryParams.clear(); + + queryParams.add("subjectName", ""); + assertThat(commentQuery.getSubjectName()).isNull(); + queryParams.clear(); + + queryParams.add("subjectName", null); + assertThat(commentQuery.getSubjectName()).isNull(); + queryParams.clear(); + } + + @Test + void getSort() { + MultiValueMap queryParams = new LinkedMultiValueMap<>(); + queryParams.add("sort", CommentSorter.REPLY_COUNT.name()); + CommentQuery commentQuery = new CommentQuery(queryParams); + assertThat(commentQuery.getSort()).isEqualTo(CommentSorter.REPLY_COUNT); + queryParams.clear(); + + queryParams.add("sort", ""); + assertThat(commentQuery.getSort()).isNull(); + queryParams.clear(); + + queryParams.add("sort", "nothing"); + assertThat(commentQuery.getSort()).isNull(); + queryParams.clear(); + } + + @Test + void getSortOrder() { + MultiValueMap queryParams = new LinkedMultiValueMap<>(); + queryParams.add("sortOrder", "true"); + CommentQuery commentQuery = new CommentQuery(queryParams); + assertThat(commentQuery.getSortOrder()).isTrue(); + queryParams.clear(); + + queryParams.add("sortOrder", ""); + assertThat(commentQuery.getSortOrder()).isNull(); + queryParams.clear(); + + queryParams.add("sortOrder", null); + assertThat(commentQuery.getSortOrder()).isNull(); + queryParams.clear(); + } +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/content/comment/CommentRequestTest.java b/src/test/java/run/halo/app/content/comment/CommentRequestTest.java new file mode 100644 index 000000000..5c9d530f1 --- /dev/null +++ b/src/test/java/run/halo/app/content/comment/CommentRequestTest.java @@ -0,0 +1,86 @@ +package run.halo.app.content.comment; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.json.JSONException; +import org.junit.jupiter.api.Test; +import org.skyscreamer.jsonassert.JSONAssert; +import run.halo.app.core.extension.Comment; +import run.halo.app.extension.FakeExtension; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.Ref; +import run.halo.app.infra.utils.JsonUtils; + +/** + * Tests for {@link CommentRequest}. + * + * @author guqing + * @since 2.0.0 + */ +class CommentRequestTest { + + @Test + void constructor() throws JSONException { + CommentRequest commentRequest = createCommentRequest(); + + JSONAssert.assertEquals(""" + { + "subjectRef": { + "group": "fake.halo.run", + "version": "v1alpha1", + "kind": "Fake", + "name": "fake" + }, + "raw": "raw", + "content": "content", + "allowNotification": true + } + """, + JsonUtils.objectToJson(commentRequest), + true); + } + + @Test + void toComment() throws JSONException { + CommentRequest commentRequest = createCommentRequest(); + Comment comment = commentRequest.toComment(); + assertThat(comment.getMetadata().getName()).isNotNull(); + + comment.getMetadata().setName("fake"); + JSONAssert.assertEquals(""" + { + "spec": { + "raw": "raw", + "content": "content", + "allowNotification": true, + "subjectRef": { + "group": "fake.halo.run", + "version": "v1alpha1", + "kind": "Fake", + "name": "fake" + } + }, + "apiVersion": "content.halo.run/v1alpha1", + "kind": "Comment", + "metadata": { + "name": "fake" + } + } + """, + JsonUtils.objectToJson(comment), + true); + } + + private static CommentRequest createCommentRequest() { + CommentRequest commentRequest = new CommentRequest(); + commentRequest.setRaw("raw"); + commentRequest.setContent("content"); + commentRequest.setAllowNotification(true); + + FakeExtension fakeExtension = new FakeExtension(); + fakeExtension.setMetadata(new Metadata()); + fakeExtension.getMetadata().setName("fake"); + commentRequest.setSubjectRef(Ref.of(fakeExtension)); + return commentRequest; + } +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/content/comment/CommentServiceImplTest.java b/src/test/java/run/halo/app/content/comment/CommentServiceImplTest.java new file mode 100644 index 000000000..fe6a9bfed --- /dev/null +++ b/src/test/java/run/halo/app/content/comment/CommentServiceImplTest.java @@ -0,0 +1,394 @@ +package run.halo.app.content.comment; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; +import org.json.JSONException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.skyscreamer.jsonassert.JSONAssert; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import run.halo.app.content.TestPost; +import run.halo.app.core.extension.Comment; +import run.halo.app.core.extension.Post; +import run.halo.app.core.extension.User; +import run.halo.app.extension.ListResult; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.extension.Ref; +import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.SystemSetting; +import run.halo.app.infra.utils.JsonUtils; +import run.halo.app.plugin.ExtensionComponentsFinder; + +/** + * Tests for {@link CommentServiceImpl}. + * + * @author guqing + * @since 2.0.0 + */ +@ExtendWith(SpringExtension.class) +class CommentServiceImplTest { + + @Mock + private SystemConfigurableEnvironmentFetcher environmentFetcher; + + @Mock + private ReactiveExtensionClient client; + + @Mock + private ExtensionComponentsFinder extensionComponentsFinder; + + @InjectMocks + private CommentServiceImpl commentService; + + @BeforeEach + void setUp() { + SystemSetting.Comment commentSetting = getCommentSetting(); + lenient().when(environmentFetcher.fetchComment()).thenReturn(Mono.just(commentSetting)); + + ListResult comments = new ListResult<>(1, 10, 3, comments()); + when(client.list(eq(Comment.class), any(), any(), anyInt(), anyInt())) + .thenReturn(Mono.just(comments)); + + User user = new User(); + user.setMetadata(new Metadata()); + user.getMetadata().setName("B-owner"); + user.setSpec(new User.UserSpec()); + user.getSpec().setAvatar("B-avatar"); + user.getSpec().setDisplayName("B-displayName"); + user.getSpec().setEmail("B-email"); + when(client.fetch(eq(User.class), eq("B-owner"))) + .thenReturn(Mono.just(user)); + when(client.fetch(eq(User.class), eq("C-owner"))) + .thenReturn(Mono.empty()); + + PostCommentSubject postCommentSubject = Mockito.mock(PostCommentSubject.class); + when(extensionComponentsFinder.getExtensions(eq(CommentSubject.class))) + .thenReturn(List.of(postCommentSubject)); + + when(postCommentSubject.supports(any())).thenReturn(true); + when(postCommentSubject.get(eq("fake-post"))).thenReturn(Mono.just(post())); + } + + @Test + void listComment() { + Mono> listResultMono = + commentService.listComment(new CommentQuery(new LinkedMultiValueMap<>())); + StepVerifier.create(listResultMono) + .consumeNextWith(result -> { + try { + JSONAssert.assertEquals(expectListResultJson(), + JsonUtils.objectToJson(result), + true); + } catch (JSONException e) { + throw new RuntimeException(e); + } + }) + .verifyComplete(); + } + + @Test + @WithMockUser(username = "B-owner") + void create() throws JSONException { + CommentRequest commentRequest = new CommentRequest(); + commentRequest.setRaw("fake-raw"); + commentRequest.setContent("fake-content"); + commentRequest.setAllowNotification(true); + commentRequest.setSubjectRef(Ref.of(post())); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Comment.class); + + Comment commentToCreate = commentRequest.toComment(); + commentToCreate.getMetadata().setName("fake"); + Mono commentMono = commentService.create(commentToCreate); + when(client.create(any())).thenReturn(Mono.empty()); + StepVerifier.create(commentMono) + .verifyComplete(); + + verify(client, times(1)).create(captor.capture()); + Comment comment = captor.getValue(); + JSONAssert.assertEquals(""" + { + "spec": { + "raw": "fake-raw", + "content": "fake-content", + "owner": { + "kind": "User", + "name": "B-owner", + "displayName": "B-displayName" + }, + "priority": 0, + "top": false, + "allowNotification": true, + "approved": false, + "hidden": false, + "subjectRef": { + "group": "content.halo.run", + "version": "v1alpha1", + "kind": "Post", + "name": "fake-post" + } + }, + "apiVersion": "content.halo.run/v1alpha1", + "kind": "Comment", + "metadata": { + "name": "fake" + } + } + """, + JsonUtils.objectToJson(comment), + true); + } + + @Test + void commentPredicate() { + MultiValueMap queryParams = new LinkedMultiValueMap<>(); + queryParams.add("keyword", "hello"); + queryParams.add("approved", "true"); + queryParams.add("hidden", "false"); + queryParams.add("allowNotification", "true"); + queryParams.add("top", "false"); + queryParams.add("ownerKind", "User"); + queryParams.add("ownerName", "fake-user"); + queryParams.add("subjectKind", "Post"); + queryParams.add("subjectName", "fake-post"); + + final Predicate predicate = + commentService.commentPredicate(new CommentQuery(queryParams)); + + Comment comment = comment("A"); + comment.getSpec().setRaw("hello-world"); + comment.getSpec().setApproved(true); + comment.getSpec().setHidden(false); + comment.getSpec().setAllowNotification(true); + comment.getSpec().setTop(false); + Comment.CommentOwner commentOwner = new Comment.CommentOwner(); + commentOwner.setKind("User"); + commentOwner.setName("fake-user"); + commentOwner.setDisplayName("fake-user-display-name"); + comment.getSpec().setOwner(commentOwner); + comment.getSpec().setSubjectRef(Ref.of(post())); + assertThat(predicate.test(comment)).isTrue(); + + queryParams.remove("keyword"); + queryParams.add("keyword", "nothing"); + final Predicate predicateTwo = + commentService.commentPredicate(new CommentQuery(queryParams)); + assertThat(predicateTwo.test(comment)).isFalse(); + } + + private List comments() { + Comment a = comment("A"); + a.getSpec().getOwner().setKind(Comment.CommentOwner.KIND_EMAIL); + a.getSpec().getOwner() + .setAnnotations(Map.of(Comment.CommentOwner.AVATAR_ANNO, "avatar", + Comment.CommentOwner.WEBSITE_ANNO, "website")); + return List.of(a, comment("B"), comment("C")); + } + + private Comment comment(String name) { + Comment comment = new Comment(); + comment.setMetadata(new Metadata()); + comment.getMetadata().setName(name); + + comment.setSpec(new Comment.CommentSpec()); + Comment.CommentOwner commentOwner = new Comment.CommentOwner(); + commentOwner.setKind(User.KIND); + commentOwner.setDisplayName("displayName"); + commentOwner.setName(name + "-owner"); + comment.getSpec().setOwner(commentOwner); + + comment.getSpec().setSubjectRef(Ref.of(post())); + + comment.setStatus(new Comment.CommentStatus()); + return comment; + } + + private Post post() { + Post post = TestPost.postV1(); + post.getMetadata().setName("fake-post"); + return post; + } + + private static SystemSetting.Comment getCommentSetting() { + SystemSetting.Comment commentSetting = new SystemSetting.Comment(); + commentSetting.setEnable(true); + commentSetting.setSystemUserOnly(true); + commentSetting.setRequireReviewForNew(true); + return commentSetting; + } + + private String expectListResultJson() { + return """ + { + "page": 1, + "size": 10, + "total": 3, + "items": [ + { + "comment": { + "spec": { + "owner": { + "kind": "Email", + "name": "A-owner", + "displayName": "displayName", + "annotations": { + "website": "website", + "avatar": "avatar" + } + }, + "subjectRef": { + "group": "content.halo.run", + "version": "v1alpha1", + "kind": "Post", + "name": "fake-post" + } + }, + "status": { + "hasNewReply": false + }, + "apiVersion": "content.halo.run/v1alpha1", + "kind": "Comment", + "metadata": { + "name": "A" + } + }, + "owner": { + "kind": "Email", + "name": "A-owner", + "displayName": "displayName", + "avatar": "avatar", + "email": "A-owner" + }, + "subject": { + "spec": { + "title": "post-A", + "headSnapshot": "base-snapshot", + "baseSnapshot": "snapshot-A", + "version": 1 + }, + "apiVersion": "content.halo.run/v1alpha1", + "kind": "Post", + "metadata": { + "name": "fake-post" + } + } + }, + { + "comment": { + "spec": { + "owner": { + "kind": "User", + "name": "B-owner", + "displayName": "displayName" + }, + "subjectRef": { + "group": "content.halo.run", + "version": "v1alpha1", + "kind": "Post", + "name": "fake-post" + } + }, + "status": { + "hasNewReply": false + }, + "apiVersion": "content.halo.run/v1alpha1", + "kind": "Comment", + "metadata": { + "name": "B" + } + }, + "owner": { + "kind": "User", + "name": "B-owner", + "displayName": "B-displayName", + "avatar": "B-avatar", + "email": "B-email" + }, + "subject": { + "spec": { + "title": "post-A", + "headSnapshot": "base-snapshot", + "baseSnapshot": "snapshot-A", + "version": 1 + }, + "apiVersion": "content.halo.run/v1alpha1", + "kind": "Post", + "metadata": { + "name": "fake-post" + } + } + }, + { + "comment": { + "spec": { + "owner": { + "kind": "User", + "name": "C-owner", + "displayName": "displayName" + }, + "subjectRef": { + "group": "content.halo.run", + "version": "v1alpha1", + "kind": "Post", + "name": "fake-post" + } + }, + "status": { + "hasNewReply": false + }, + "apiVersion": "content.halo.run/v1alpha1", + "kind": "Comment", + "metadata": { + "name": "C" + } + }, + "owner": { + "kind": "User", + "name": "ghost", + "displayName": "Ghost", + "email": "" + }, + "subject": { + "spec": { + "title": "post-A", + "headSnapshot": "base-snapshot", + "baseSnapshot": "snapshot-A", + "version": 1 + }, + "apiVersion": "content.halo.run/v1alpha1", + "kind": "Post", + "metadata": { + "name": "fake-post" + } + } + } + ], + "first": true, + "last": true, + "hasNext": false, + "hasPrevious": false + } + """; + } +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/content/comment/CommentSorterTest.java b/src/test/java/run/halo/app/content/comment/CommentSorterTest.java new file mode 100644 index 000000000..3fd05b2e5 --- /dev/null +++ b/src/test/java/run/halo/app/content/comment/CommentSorterTest.java @@ -0,0 +1,139 @@ +package run.halo.app.content.comment; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.util.Comparator; +import java.util.List; +import org.junit.jupiter.api.Test; +import run.halo.app.core.extension.Comment; +import run.halo.app.extension.Metadata; + +/** + * Tests for {@link CommentSorter}. + * + * @author guqing + * @since 2.0.0 + */ +class CommentSorterTest { + + @Test + void sortByCreateTimeAsc() { + Comparator createTimeSorter = CommentSorter.from(CommentSorter.CREATE_TIME); + List commentNames = comments().stream() + .sorted(createTimeSorter) + .map(comment -> comment.getMetadata().getName()) + .toList(); + assertThat(commentNames).isEqualTo(List.of("B", "C", "A")); + + createTimeSorter = CommentSorter.from(CommentSorter.CREATE_TIME, true); + commentNames = comments().stream() + .sorted(createTimeSorter) + .map(comment -> comment.getMetadata().getName()) + .toList(); + assertThat(commentNames).isEqualTo(List.of("B", "C", "A")); + } + + @Test + void sortByCreateTimeDesc() { + Comparator createTimeSorter = CommentSorter.from(CommentSorter.CREATE_TIME, false); + List commentNames = comments().stream() + .sorted(createTimeSorter) + .map(comment -> comment.getMetadata().getName()) + .toList(); + assertThat(commentNames).isEqualTo(List.of("A", "C", "B")); + } + + @Test + void sortByReplyCountAsc() { + Comparator createTimeSorter = CommentSorter.from(CommentSorter.REPLY_COUNT); + List commentNames = comments().stream() + .sorted(createTimeSorter) + .map(comment -> comment.getMetadata().getName()) + .toList(); + assertThat(commentNames).isEqualTo(List.of("A", "B", "C")); + + createTimeSorter = CommentSorter.from(CommentSorter.REPLY_COUNT, true); + commentNames = comments().stream() + .sorted(createTimeSorter) + .map(comment -> comment.getMetadata().getName()) + .toList(); + assertThat(commentNames).isEqualTo(List.of("A", "B", "C")); + } + + @Test + void sortByReplyCountDesc() { + Comparator createTimeSorter = CommentSorter.from(CommentSorter.REPLY_COUNT, false); + List commentNames = comments().stream() + .sorted(createTimeSorter) + .map(comment -> comment.getMetadata().getName()) + .toList(); + assertThat(commentNames).isEqualTo(List.of("C", "B", "A")); + } + + @Test + void sortByLastReplyTimeAsc() { + Comparator createTimeSorter = CommentSorter.from(CommentSorter.LAST_REPLY_TIME); + List commentNames = comments().stream() + .sorted(createTimeSorter) + .map(comment -> comment.getMetadata().getName()) + .toList(); + assertThat(commentNames).isEqualTo(List.of("C", "A", "B")); + + createTimeSorter = CommentSorter.from(CommentSorter.LAST_REPLY_TIME, true); + commentNames = comments().stream() + .sorted(createTimeSorter) + .map(comment -> comment.getMetadata().getName()) + .toList(); + assertThat(commentNames).isEqualTo(List.of("C", "A", "B")); + } + + @Test + void sortByLastReplyTimeDesc() { + Comparator createTimeSorter = + CommentSorter.from(CommentSorter.LAST_REPLY_TIME, false); + List commentNames = comments().stream() + .sorted(createTimeSorter) + .map(comment -> comment.getMetadata().getName()) + .toList(); + assertThat(commentNames).isEqualTo(List.of("B", "A", "C")); + } + + List comments() { + final Instant now = Instant.now(); + Comment commentA = new Comment(); + commentA.setMetadata(new Metadata()); + commentA.getMetadata().setName("A"); + // create time + commentA.getMetadata().setCreationTimestamp(now.plusSeconds(10)); + + commentA.setSpec(new Comment.CommentSpec()); + commentA.setStatus(new Comment.CommentStatus()); + // last reply time + commentA.getStatus().setLastReplyTime(now.plusSeconds(5)); + // reply count + commentA.getStatus().setReplyCount(3); + + Comment commentB = new Comment(); + commentB.setMetadata(new Metadata()); + commentB.getMetadata().setName("B"); + commentB.getMetadata().setCreationTimestamp(now.plusSeconds(5)); + commentB.setSpec(new Comment.CommentSpec()); + commentB.setStatus(new Comment.CommentStatus()); + commentB.getStatus().setLastReplyTime(now.plusSeconds(15)); + commentB.getStatus().setReplyCount(8); + + Comment commentC = new Comment(); + commentC.setMetadata(new Metadata()); + commentC.getMetadata().setName("C"); + + commentC.getMetadata().setCreationTimestamp(now.plusSeconds(5)); + + commentC.setSpec(new Comment.CommentSpec()); + commentC.setStatus(new Comment.CommentStatus()); + commentC.getStatus().setLastReplyTime(now.plusSeconds(3)); + commentC.getStatus().setReplyCount(10); + + return List.of(commentA, commentB, commentC); + } +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/content/comment/PostCommentSubjectTest.java b/src/test/java/run/halo/app/content/comment/PostCommentSubjectTest.java new file mode 100644 index 000000000..837ab52d0 --- /dev/null +++ b/src/test/java/run/halo/app/content/comment/PostCommentSubjectTest.java @@ -0,0 +1,67 @@ +package run.halo.app.content.comment; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import run.halo.app.content.TestPost; +import run.halo.app.core.extension.Post; +import run.halo.app.extension.FakeExtension; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.extension.Ref; + +/** + * Tests for {@link PostCommentSubject}. + * + * @author guqing + * @since 2.0.0 + */ +@ExtendWith(MockitoExtension.class) +class PostCommentSubjectTest { + @Mock + private ReactiveExtensionClient client; + + @InjectMocks + private PostCommentSubject postCommentSubject; + + @Test + void get() { + when(client.fetch(eq(Post.class), any())) + .thenReturn(Mono.empty()); + when(client.fetch(eq(Post.class), eq("fake-post"))) + .thenReturn(Mono.just(TestPost.postV1())); + + postCommentSubject.get("fake-post") + .as(StepVerifier::create) + .expectNext(TestPost.postV1()) + .verifyComplete(); + + postCommentSubject.get("fake-post2") + .as(StepVerifier::create) + .verifyComplete(); + } + + @Test + void supports() { + Post post = new Post(); + post.setMetadata(new Metadata()); + post.getMetadata().setName("test"); + boolean supports = postCommentSubject.supports(Ref.of(post)); + assertThat(supports).isTrue(); + + FakeExtension fakeExtension = new FakeExtension(); + fakeExtension.setMetadata(new Metadata()); + fakeExtension.getMetadata().setName("test"); + supports = postCommentSubject.supports(Ref.of(fakeExtension)); + assertThat(supports).isFalse(); + } +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/content/comment/SinglePageCommentSubjectTest.java b/src/test/java/run/halo/app/content/comment/SinglePageCommentSubjectTest.java new file mode 100644 index 000000000..e58bb2643 --- /dev/null +++ b/src/test/java/run/halo/app/content/comment/SinglePageCommentSubjectTest.java @@ -0,0 +1,75 @@ +package run.halo.app.content.comment; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import run.halo.app.core.extension.SinglePage; +import run.halo.app.extension.FakeExtension; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.extension.Ref; + +/** + * Tests for {@link SinglePageCommentSubject}. + * + * @author guqing + * @since 2.0.0 + */ +@ExtendWith(MockitoExtension.class) +class SinglePageCommentSubjectTest { + @Mock + private ReactiveExtensionClient client; + + @InjectMocks + private SinglePageCommentSubject singlePageCommentSubject; + + @Test + void get() { + when(client.fetch(eq(SinglePage.class), any())) + .thenReturn(Mono.empty()); + + SinglePage singlePage = new SinglePage(); + singlePage.setMetadata(new Metadata()); + singlePage.getMetadata().setName("fake-single-page"); + + when(client.fetch(eq(SinglePage.class), eq("fake-single-page"))) + .thenReturn(Mono.just(singlePage)); + + singlePageCommentSubject.get("fake-single-page") + .as(StepVerifier::create) + .expectNext(singlePage) + .verifyComplete(); + + singlePageCommentSubject.get("fake-single-page-2") + .as(StepVerifier::create) + .verifyComplete(); + + verify(client, times(1)).fetch(eq(SinglePage.class), eq("fake-single-page")); + } + + @Test + void supports() { + SinglePage singlePage = new SinglePage(); + singlePage.setMetadata(new Metadata()); + singlePage.getMetadata().setName("test"); + boolean supports = singlePageCommentSubject.supports(Ref.of(singlePage)); + assertThat(supports).isTrue(); + + FakeExtension fakeExtension = new FakeExtension(); + fakeExtension.setMetadata(new Metadata()); + fakeExtension.getMetadata().setName("test"); + supports = singlePageCommentSubject.supports(Ref.of(fakeExtension)); + assertThat(supports).isFalse(); + } +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/core/extension/reconciler/CommentReconcilerTest.java b/src/test/java/run/halo/app/core/extension/reconciler/CommentReconcilerTest.java new file mode 100644 index 000000000..751b17ba8 --- /dev/null +++ b/src/test/java/run/halo/app/core/extension/reconciler/CommentReconcilerTest.java @@ -0,0 +1,132 @@ +package run.halo.app.core.extension.reconciler; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.time.Duration; +import java.time.Instant; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +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.Metadata; +import run.halo.app.extension.controller.Reconciler; + +/** + * Tests for {@link CommentReconciler}. + * + * @author guqing + * @since 2.0.0 + */ +@ExtendWith(MockitoExtension.class) +class CommentReconcilerTest { + + @Mock + private ExtensionClient client; + + @InjectMocks + private CommentReconciler commentReconciler; + + private final Instant now = Instant.now(); + + @Test + void reconcile() { + Comment comment = new Comment(); + comment.setMetadata(new Metadata()); + comment.getMetadata().setName("test"); + comment.setSpec(new Comment.CommentSpec()); + comment.getSpec().setLastReadTime(now.plusSeconds(5)); + comment.setStatus(new Comment.CommentStatus()); + + lenient().when(client.fetch(eq(Comment.class), eq("test"))) + .thenReturn(Optional.of(comment)); + + lenient().when(client.list(eq(Reply.class), any(), any())) + .thenReturn(replyList()); + + Reconciler.Result result = commentReconciler.reconcile(new Reconciler.Request("test")); + assertThat(result.reEnqueue()).isTrue(); + assertThat(result.retryAfter()).isEqualTo(Duration.ofMinutes(1)); + + verify(client, times(3)).fetch(eq(Comment.class), eq("test")); + verify(client, times(1)).list(eq(Reply.class), any(), any()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Comment.class); + verify(client, times(2)).update(captor.capture()); + List allValues = captor.getAllValues(); + Comment value = allValues.get(1); + + assertThat(value.getStatus().getReplyCount()).isEqualTo(3); + assertThat(value.getStatus().getLastReplyTime()).isEqualTo(now.plusSeconds(6)); + assertThat(value.getStatus().getUnreadReplyCount()).isEqualTo(1); + assertThat(value.getStatus().getHasNewReply()).isTrue(); + + assertThat(value.getMetadata().getFinalizers()).contains(CommentReconciler.FINALIZER_NAME); + } + + @Test + void reconcileDelete() { + Comment comment = new Comment(); + comment.setMetadata(new Metadata()); + comment.getMetadata().setName("test"); + comment.getMetadata().setDeletionTimestamp(Instant.now()); + Set finalizers = new HashSet<>(); + finalizers.add(CommentReconciler.FINALIZER_NAME); + comment.getMetadata().setFinalizers(finalizers); + comment.setSpec(new Comment.CommentSpec()); + comment.getSpec().setLastReadTime(now.plusSeconds(5)); + comment.setStatus(new Comment.CommentStatus()); + + lenient().when(client.list(eq(Reply.class), any(), any())) + .thenReturn(replyList()); + lenient().when(client.fetch(eq(Comment.class), eq("test"))) + .thenReturn(Optional.of(comment)); + + lenient().when(client.list(eq(Reply.class), any(), any())) + .thenReturn(replyList()); + + Reconciler.Result reconcile = commentReconciler.reconcile(new Reconciler.Request("test")); + assertThat(reconcile.reEnqueue()).isFalse(); + assertThat(reconcile.retryAfter()).isNull(); + + verify(client, times(1)).list(eq(Reply.class), any(), any()); + verify(client, times(3)).delete(any(Reply.class)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Comment.class); + verify(client, times(1)).update(captor.capture()); + Comment value = captor.getValue(); + assertThat(value.getMetadata().getFinalizers() + .contains(CommentReconciler.FINALIZER_NAME)).isFalse(); + } + + List replyList() { + Reply replyA = new Reply(); + replyA.setMetadata(new Metadata()); + replyA.getMetadata().setName("reply-A"); + replyA.getMetadata().setCreationTimestamp(now.plusSeconds(6)); + + Reply replyB = new Reply(); + replyB.setMetadata(new Metadata()); + replyB.getMetadata().setName("reply-B"); + replyB.getMetadata().setCreationTimestamp(now.plusSeconds(5)); + + Reply replyC = new Reply(); + replyC.setMetadata(new Metadata()); + replyC.getMetadata().setName("reply-C"); + replyC.getMetadata().setCreationTimestamp(now.plusSeconds(4)); + return List.of(replyA, replyB, replyC); + } +} \ No newline at end of file