From ed8dddbafa4bc3e15c19bb476727525c005fd85e Mon Sep 17 00:00:00 2001 From: guqing <38999863+guqing@users.noreply.github.com> Date: Mon, 26 Sep 2022 16:26:13 +0800 Subject: [PATCH] feat: add comment list APIs for theme-side (#2433) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### What type of PR is this? /kind feature /milestone 2.0 /area core /kind api-change #### What this PR does / why we need it: - 新增主题端评论列表功能 - 新增 `` html tag 用于扩展评论组件 - 插件可以实现 `CommentWidget` 扩展点来渲染评论组件 html 块,以替换 `` 标签 #### Which issue(s) this PR fixes: Fixes #2432 #### Special notes for your reviewer: /cc @halo-dev/sig-halo #### Does this PR introduce a user-facing change? ```release-note 新增主题端评论列表功能 ``` --- .../run/halo/app/config/SwaggerConfig.java | 12 +- .../content/comment/CommentServiceImpl.java | 16 +- .../app/content/comment/ReplyServiceImpl.java | 13 +- .../extension/endpoint/CommentEndpoint.java | 10 +- .../dialect/CommentElementTagProcessor.java | 58 ++++ .../halo/app/theme/dialect/CommentWidget.java | 18 ++ .../theme/dialect/HaloProcessorDialect.java | 1 + .../theme/endpoint/CommentFinderEndpoint.java | 268 ++++++++++++++++++ .../theme/finders/impl/CommentFinderImpl.java | 54 +++- .../halo/app/theme/finders/vo/CommentVo.java | 12 + .../halo/app/theme/finders/vo/ReplyVo.java | 9 + .../extensions/role-template-anonymous.yaml | 4 +- .../comment/CommentServiceImplTest.java | 2 +- .../CommentElementTagProcessorTest.java | 127 +++++++++ .../endpoint/CommentFinderEndpointTest.java | 175 ++++++++++++ 15 files changed, 759 insertions(+), 20 deletions(-) create mode 100644 src/main/java/run/halo/app/theme/dialect/CommentElementTagProcessor.java create mode 100644 src/main/java/run/halo/app/theme/dialect/CommentWidget.java create mode 100644 src/main/java/run/halo/app/theme/endpoint/CommentFinderEndpoint.java create mode 100644 src/test/java/run/halo/app/theme/dialect/CommentElementTagProcessorTest.java create mode 100644 src/test/java/run/halo/app/theme/endpoint/CommentFinderEndpointTest.java diff --git a/src/main/java/run/halo/app/config/SwaggerConfig.java b/src/main/java/run/halo/app/config/SwaggerConfig.java index 9b0480891..4d651b2bb 100644 --- a/src/main/java/run/halo/app/config/SwaggerConfig.java +++ b/src/main/java/run/halo/app/config/SwaggerConfig.java @@ -43,7 +43,8 @@ public class SwaggerConfig { .group("extension-api") .displayName("Extension APIs") .pathsToMatch("/apis/**") - .pathsToExclude("/apis/api.console.halo.run/**", "/apis/api.plugin.halo.run/**") + .pathsToExclude("/apis/api.console.halo.run/**", "/apis/api.halo.run/**", + "/apis/api.plugin.halo.run/**") .build(); } @@ -56,6 +57,15 @@ public class SwaggerConfig { .build(); } + @Bean + GroupedOpenApi customApi() { + return GroupedOpenApi.builder() + .group("api.halo.run") + .displayName("api.halo.run") + .pathsToMatch("/apis/api.halo.run/**") + .build(); + } + @Bean GroupedOpenApi pluginCustomApi() { return GroupedOpenApi.builder() diff --git a/src/main/java/run/halo/app/content/comment/CommentServiceImpl.java b/src/main/java/run/halo/app/content/comment/CommentServiceImpl.java index 2db5f8a68..e2a2cd335 100644 --- a/src/main/java/run/halo/app/content/comment/CommentServiceImpl.java +++ b/src/main/java/run/halo/app/content/comment/CommentServiceImpl.java @@ -68,6 +68,11 @@ public class CommentServiceImpl implements CommentService { return Mono.error( new AccessDeniedException("The comment function has been turned off.")); } + if (checkCommentOwner(comment, commentSetting.getSystemUserOnly())) { + return Mono.error( + new AccessDeniedException("Allow system user comments only.")); + } + if (comment.getSpec().getTop() == null) { comment.getSpec().setTop(false); } @@ -76,7 +81,7 @@ public class CommentServiceImpl implements CommentService { } comment.getSpec() .setApproved(Boolean.FALSE.equals(commentSetting.getRequireReviewForNew())); - comment.getSpec().setHidden(comment.getSpec().getApproved()); + comment.getSpec().setHidden(!comment.getSpec().getApproved()); if (comment.getSpec().getOwner() != null) { return Mono.just(comment); } @@ -93,6 +98,14 @@ public class CommentServiceImpl implements CommentService { .flatMap(client::create); } + private boolean checkCommentOwner(Comment comment, Boolean onlySystemUser) { + Comment.CommentOwner owner = comment.getSpec().getOwner(); + if (Boolean.TRUE.equals(onlySystemUser)) { + return owner != null && Comment.CommentOwner.KIND_EMAIL.equals(owner.getKind()); + } + return false; + } + private Comment.CommentOwner toCommentOwner(User user) { Comment.CommentOwner owner = new Comment.CommentOwner(); owner.setKind(User.KIND); @@ -122,6 +135,7 @@ public class CommentServiceImpl implements CommentService { builder.subject(subject); return builder; }) + .switchIfEmpty(Mono.just(builder)) ) .map(ListedComment.ListedCommentBuilder::build); } diff --git a/src/main/java/run/halo/app/content/comment/ReplyServiceImpl.java b/src/main/java/run/halo/app/content/comment/ReplyServiceImpl.java index b8d0852b5..30d73e66e 100644 --- a/src/main/java/run/halo/app/content/comment/ReplyServiceImpl.java +++ b/src/main/java/run/halo/app/content/comment/ReplyServiceImpl.java @@ -56,9 +56,12 @@ public class ReplyServiceImpl implements ReplyService { throw new AccessDeniedException( "The comment function has been turned off."); } + if (checkReplyOwner(reply, commentSetting.getSystemUserOnly())) { + throw new AccessDeniedException("Allow system user reply only."); + } reply.getSpec().setApproved( Boolean.FALSE.equals(commentSetting.getRequireReviewForNew())); - reply.getSpec().setHidden(reply.getSpec().getApproved()); + reply.getSpec().setHidden(!reply.getSpec().getApproved()); return reply; }); }) @@ -82,6 +85,14 @@ public class ReplyServiceImpl implements ReplyService { ); } + private boolean checkReplyOwner(Reply reply, Boolean onlySystemUser) { + Comment.CommentOwner owner = reply.getSpec().getOwner(); + if (Boolean.TRUE.equals(onlySystemUser)) { + return owner != null && Comment.CommentOwner.KIND_EMAIL.equals(owner.getKind()); + } + return false; + } + @Override public Mono> list(ReplyQuery query) { return client.list(Reply.class, getReplyPredicate(query), defaultComparator(), 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 index 0a7b9d69c..39bb7afb8 100644 --- a/src/main/java/run/halo/app/core/extension/endpoint/CommentEndpoint.java +++ b/src/main/java/run/halo/app/core/extension/endpoint/CommentEndpoint.java @@ -124,7 +124,13 @@ public class CommentEndpoint implements CustomEndpoint { .flatMap(comment -> ServerResponse.ok().bodyValue(comment)); } - private String userAgentFrom(ServerRequest request) { + /** + * Gets user-agent from server request. + * + * @param request server request + * @return user-agent string if found, otherwise "unknown" + */ + public static String userAgentFrom(ServerRequest request) { HttpHeaders httpHeaders = request.headers().asHttpHeaders(); // https://en.wikipedia.org/wiki/User_agent String userAgent = httpHeaders.getFirst(HttpHeaders.USER_AGENT); @@ -132,6 +138,6 @@ public class CommentEndpoint implements CustomEndpoint { // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-CH-UA userAgent = httpHeaders.getFirst("Sec-CH-UA"); } - return StringUtils.defaultString(userAgent, "UNKNOWN"); + return StringUtils.defaultString(userAgent, "unknown"); } } diff --git a/src/main/java/run/halo/app/theme/dialect/CommentElementTagProcessor.java b/src/main/java/run/halo/app/theme/dialect/CommentElementTagProcessor.java new file mode 100644 index 000000000..7f01467a0 --- /dev/null +++ b/src/main/java/run/halo/app/theme/dialect/CommentElementTagProcessor.java @@ -0,0 +1,58 @@ +package run.halo.app.theme.dialect; + +import java.util.List; +import org.springframework.context.ApplicationContext; +import org.thymeleaf.context.ITemplateContext; +import org.thymeleaf.model.IProcessableElementTag; +import org.thymeleaf.processor.element.AbstractElementTagProcessor; +import org.thymeleaf.processor.element.IElementTagStructureHandler; +import org.thymeleaf.spring6.context.SpringContextUtils; +import org.thymeleaf.templatemode.TemplateMode; +import run.halo.app.plugin.ExtensionComponentsFinder; + + +/** + *

Comment element tag processor.

+ *

Replace the comment tag <halo:comment /> with the given content.

+ * + * @author guqing + * @since 2.0.0 + */ +public class CommentElementTagProcessor extends AbstractElementTagProcessor { + + private static final String TAG_NAME = "comment"; + + private static final int PRECEDENCE = 1000; + + /** + * Constructor footer element tag processor with HTML mode, dialect prefix, comment tag name. + * + * @param dialectPrefix dialect prefix + */ + public CommentElementTagProcessor(final String dialectPrefix) { + super( + TemplateMode.HTML, // This processor will apply only to HTML mode + dialectPrefix, // Prefix to be applied to name for matching + TAG_NAME, // Tag name: match specifically this tag + true, // Apply dialect prefix to tag name + null, // No attribute name: will match by tag name + false, // No prefix to be applied to attribute name + PRECEDENCE); // Precedence (inside dialect's own precedence) + } + + @Override + protected void doProcess(ITemplateContext context, IProcessableElementTag tag, + IElementTagStructureHandler structureHandler) { + final ApplicationContext appCtx = SpringContextUtils.getApplicationContext(context); + ExtensionComponentsFinder componentsFinder = + appCtx.getBean(ExtensionComponentsFinder.class); + List commentWidgets = componentsFinder.getExtensions(CommentWidget.class); + if (commentWidgets.isEmpty()) { + structureHandler.replaceWith("", false); + return; + } + // TODO if find more than one comment widget, query CommentWidget setting to decide which + // one to use. + commentWidgets.get(0).render(context, tag, structureHandler); + } +} diff --git a/src/main/java/run/halo/app/theme/dialect/CommentWidget.java b/src/main/java/run/halo/app/theme/dialect/CommentWidget.java new file mode 100644 index 000000000..21214115e --- /dev/null +++ b/src/main/java/run/halo/app/theme/dialect/CommentWidget.java @@ -0,0 +1,18 @@ +package run.halo.app.theme.dialect; + +import org.pf4j.ExtensionPoint; +import org.thymeleaf.context.ITemplateContext; +import org.thymeleaf.model.IProcessableElementTag; +import org.thymeleaf.processor.element.IElementTagStructureHandler; + +/** + * Comment widget extension point to extend the <halo:comment /> tag of the theme-side. + * + * @author guqing + * @since 2.0.0 + */ +public interface CommentWidget extends ExtensionPoint { + + void render(ITemplateContext context, IProcessableElementTag tag, + IElementTagStructureHandler structureHandler); +} diff --git a/src/main/java/run/halo/app/theme/dialect/HaloProcessorDialect.java b/src/main/java/run/halo/app/theme/dialect/HaloProcessorDialect.java index 8aea154e2..171b7ddf8 100644 --- a/src/main/java/run/halo/app/theme/dialect/HaloProcessorDialect.java +++ b/src/main/java/run/halo/app/theme/dialect/HaloProcessorDialect.java @@ -28,6 +28,7 @@ public class HaloProcessorDialect extends AbstractProcessorDialect { processors.add(new GlobalHeadInjectionProcessor(dialectPrefix)); processors.add(new TemplateFooterElementTagProcessor(dialectPrefix)); processors.add(new JsonNodePropertyAccessorBoundariesProcessor()); + processors.add(new CommentElementTagProcessor(dialectPrefix)); return processors; } } diff --git a/src/main/java/run/halo/app/theme/endpoint/CommentFinderEndpoint.java b/src/main/java/run/halo/app/theme/endpoint/CommentFinderEndpoint.java new file mode 100644 index 000000000..b1f953c48 --- /dev/null +++ b/src/main/java/run/halo/app/theme/endpoint/CommentFinderEndpoint.java @@ -0,0 +1,268 @@ +package run.halo.app.theme.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 com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +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.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.MultiValueMap; +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 reactor.core.scheduler.Schedulers; +import run.halo.app.content.comment.CommentRequest; +import run.halo.app.content.comment.CommentService; +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.core.extension.endpoint.CommentEndpoint; +import run.halo.app.core.extension.endpoint.CustomEndpoint; +import run.halo.app.extension.GroupVersion; +import run.halo.app.extension.ListResult; +import run.halo.app.extension.Ref; +import run.halo.app.extension.router.IListRequest; +import run.halo.app.extension.router.QueryParamBuildUtil; +import run.halo.app.infra.utils.IpAddressUtils; +import run.halo.app.theme.finders.CommentFinder; +import run.halo.app.theme.finders.vo.CommentVo; +import run.halo.app.theme.finders.vo.ReplyVo; + +/** + * Endpoint for {@link CommentFinder}. + */ +@Component +public class CommentFinderEndpoint implements CustomEndpoint { + + private final CommentFinder commentFinder; + private final CommentService commentService; + private final ReplyService replyService; + + /** + * Construct a {@link CommentFinderEndpoint} instance. + * + * @param commentFinder comment finder + * @param commentService comment service to create comment + * @param replyService reply service to create reply + */ + public CommentFinderEndpoint(CommentFinder commentFinder, CommentService commentService, + ReplyService replyService) { + this.commentFinder = commentFinder; + this.commentService = commentService; + this.replyService = replyService; + } + + @Override + public RouterFunction endpoint() { + final var tag = "api.halo.run/v1alpha1/Comment"; + return SpringdocRouteBuilder.route() + .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)) + ) + .GET("comments", this::listComments, builder -> { + builder.operationId("ListComments") + .description("List comments.") + .tag(tag) + .response(responseBuilder() + .implementation(ListResult.generateGenericClass(CommentVo.class)) + ); + QueryParamBuildUtil.buildParametersFromType(builder, CommentQuery.class); + }) + .GET("comments/{name}", this::getComment, builder -> { + builder.operationId("GetComment") + .description("Get a comment.") + .tag(tag) + .parameter(parameterBuilder().name("name") + .in(ParameterIn.PATH) + .required(true) + .implementation(String.class)) + .response(responseBuilder() + .implementation(ListResult.generateGenericClass(CommentVo.class)) + ); + }) + .GET("comments/{name}/reply", this::listCommentReplies, builder -> { + builder.operationId("ListCommentReplies") + .description("List comment replies.") + .tag(tag) + .parameter(parameterBuilder().name("name") + .in(ParameterIn.PATH) + .required(true) + .implementation(String.class)) + .response(responseBuilder() + .implementation(ListResult.generateGenericClass(ReplyVo.class)) + ); + QueryParamBuildUtil.buildParametersFromType(builder, PageableRequest.class); + }) + .build(); + } + + @Override + public GroupVersion groupVersion() { + return GroupVersion.parseAPIVersion("api.halo.run/v1alpha1"); + } + + Mono createComment(ServerRequest request) { + return request.bodyToMono(CommentRequest.class) + .flatMap(commentRequest -> { + Comment comment = commentRequest.toComment(); + comment.getSpec().setIpAddress(IpAddressUtils.getIpAddress(request)); + comment.getSpec().setUserAgent(CommentEndpoint.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(CommentEndpoint.userAgentFrom(request)); + return replyService.create(commentName, reply); + }) + .flatMap(comment -> ServerResponse.ok().bodyValue(comment)); + } + + Mono listComments(ServerRequest request) { + CommentQuery commentQuery = new CommentQuery(request.queryParams()); + return Mono.defer(() -> Mono.just( + commentFinder.list(commentQuery.toRef(), commentQuery.getPage(), + commentQuery.getSize()))) + .subscribeOn(Schedulers.boundedElastic()) + .flatMap(list -> ServerResponse.ok().bodyValue(list)); + } + + Mono getComment(ServerRequest request) { + String name = request.pathVariable("name"); + return Mono.defer(() -> Mono.justOrEmpty(commentFinder.getByName(name))) + .subscribeOn(Schedulers.boundedElastic()) + .flatMap(comment -> ServerResponse.ok().bodyValue(comment)); + } + + Mono listCommentReplies(ServerRequest request) { + String commentName = request.pathVariable("name"); + IListRequest.QueryListRequest queryParams = + new IListRequest.QueryListRequest(request.queryParams()); + return Mono.defer(() -> Mono.just( + commentFinder.listReply(commentName, queryParams.getPage(), queryParams.getSize()))) + .subscribeOn(Schedulers.boundedElastic()) + .flatMap(list -> ServerResponse.ok().bodyValue(list)); + } + + public static class CommentQuery extends PageableRequest { + + public CommentQuery(MultiValueMap queryParams) { + super(queryParams); + } + + @Schema(description = "The comment subject group.") + public String getGroup() { + return queryParams.getFirst("group"); + } + + @Schema(required = true, description = "The comment subject version.") + public String getVersion() { + return emptyToNull(queryParams.getFirst("version")); + } + + /** + * Gets the {@link Ref}s kind. + * + * @return comment subject ref kind + */ + @Schema(required = true, description = "The comment subject kind.") + public String getKind() { + String kind = emptyToNull(queryParams.getFirst("kind")); + if (kind == null) { + throw new IllegalArgumentException("The kind must not be null."); + } + return kind; + } + + /** + * Gets the {@link Ref}s name. + * + * @return comment subject ref name + */ + @Schema(required = true, description = "The comment subject name.") + public String getName() { + String name = emptyToNull(queryParams.getFirst("name")); + if (name == null) { + throw new IllegalArgumentException("The name must not be null."); + } + return name; + } + + Ref toRef() { + Ref ref = new Ref(); + ref.setGroup(getGroup()); + ref.setKind(getKind()); + ref.setVersion(getVersion()); + ref.setName(getName()); + return ref; + } + + String emptyToNull(String str) { + return StringUtils.isBlank(str) ? null : str; + } + } + + public static class PageableRequest extends IListRequest.QueryListRequest { + + public PageableRequest(MultiValueMap queryParams) { + super(queryParams); + } + + @Override + @JsonIgnore + public List getLabelSelector() { + throw new UnsupportedOperationException("Unsupported this parameter"); + } + + @Override + @JsonIgnore + public List getFieldSelector() { + throw new UnsupportedOperationException("Unsupported this parameter"); + } + } +} 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 6f73fcf57..f891e03fe 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 @@ -2,14 +2,17 @@ package run.halo.app.theme.finders.impl; import java.time.Instant; import java.util.Comparator; -import java.util.List; import java.util.Objects; import java.util.function.Function; import java.util.function.Predicate; import org.apache.commons.lang3.ObjectUtils; import org.springframework.util.Assert; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import run.halo.app.content.comment.OwnerInfo; 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.ListResult; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Ref; @@ -36,7 +39,7 @@ public class CommentFinderImpl implements CommentFinder { @Override public CommentVo getByName(String name) { return client.fetch(Comment.class, name) - .map(CommentVo::from) + .flatMap(this::toCommentVo) .block(); } @@ -45,11 +48,13 @@ public class CommentFinderImpl implements CommentFinder { return client.list(Comment.class, fixedPredicate(ref), defaultComparator(), pageNullSafe(page), sizeNullSafe(size)) - .map(list -> { - List commentVos = list.get().map(CommentVo::from).toList(); - return new ListResult<>(list.getPage(), list.getSize(), list.getTotal(), - commentVos); - }) + .flatMap(list -> Flux.fromStream(list.get().map(this::toCommentVo)) + .flatMap(Function.identity()) + .collectList() + .map(commentVos -> new ListResult<>(list.getPage(), list.getSize(), list.getTotal(), + commentVos) + ) + ) .block(); } @@ -62,14 +67,39 @@ public class CommentFinderImpl implements CommentFinder { && Objects.equals(false, reply.getSpec().getHidden()) && Objects.equals(true, reply.getSpec().getApproved()), comparator.reversed(), pageNullSafe(page), sizeNullSafe(size)) - .map(list -> { - List replyVos = list.get().map(ReplyVo::from).toList(); - return new ListResult<>(list.getPage(), list.getSize(), list.getTotal(), - replyVos); - }) + .flatMap(list -> Flux.fromStream(list.get().map(this::toReplyVo)) + .flatMap(Function.identity()) + .collectList() + .map(replyVos -> new ListResult<>(list.getPage(), list.getSize(), list.getTotal(), + replyVos)) + ) .block(); } + private Mono toCommentVo(Comment comment) { + Comment.CommentOwner owner = comment.getSpec().getOwner(); + return Mono.just(CommentVo.from(comment)) + .flatMap(commentVo -> getOwnerInfo(owner) + .map(commentVo::withOwner) + ); + } + + private Mono toReplyVo(Reply reply) { + return Mono.just(ReplyVo.from(reply)) + .flatMap(replyVo -> getOwnerInfo(reply.getSpec().getOwner()) + .map(replyVo::withOwner) + ); + } + + private Mono getOwnerInfo(Comment.CommentOwner owner) { + if (Comment.CommentOwner.KIND_EMAIL.equals(owner.getKind())) { + return Mono.just(OwnerInfo.from(owner)); + } + return client.fetch(User.class, owner.getName()) + .map(OwnerInfo::from) + .switchIfEmpty(Mono.just(OwnerInfo.ghostUser())); + } + private Predicate fixedPredicate(Ref ref) { Assert.notNull(ref, "Comment subject reference must not be null"); return comment -> comment.getSpec().getSubjectRef().equals(ref) diff --git a/src/main/java/run/halo/app/theme/finders/vo/CommentVo.java b/src/main/java/run/halo/app/theme/finders/vo/CommentVo.java index f0f5a27df..4210ec426 100644 --- a/src/main/java/run/halo/app/theme/finders/vo/CommentVo.java +++ b/src/main/java/run/halo/app/theme/finders/vo/CommentVo.java @@ -1,8 +1,11 @@ package run.halo.app.theme.finders.vo; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.EqualsAndHashCode; import lombok.Value; +import lombok.With; +import run.halo.app.content.comment.OwnerInfo; import run.halo.app.core.extension.Comment; import run.halo.app.extension.MetadataOperator; @@ -17,10 +20,18 @@ import run.halo.app.extension.MetadataOperator; @EqualsAndHashCode public class CommentVo { + @Schema(required = true) MetadataOperator metadata; + @Schema(required = true) Comment.CommentSpec spec; + Comment.CommentStatus status; + + @With + @Schema(required = true) + OwnerInfo owner; + /** * Convert {@link Comment} to {@link CommentVo}. * @@ -31,6 +42,7 @@ public class CommentVo { return CommentVo.builder() .metadata(comment.getMetadata()) .spec(comment.getSpec()) + .status(comment.getStatus()) .build(); } } diff --git a/src/main/java/run/halo/app/theme/finders/vo/ReplyVo.java b/src/main/java/run/halo/app/theme/finders/vo/ReplyVo.java index 6784d9c43..603c75834 100644 --- a/src/main/java/run/halo/app/theme/finders/vo/ReplyVo.java +++ b/src/main/java/run/halo/app/theme/finders/vo/ReplyVo.java @@ -1,9 +1,12 @@ package run.halo.app.theme.finders.vo; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.EqualsAndHashCode; import lombok.ToString; import lombok.Value; +import lombok.With; +import run.halo.app.content.comment.OwnerInfo; import run.halo.app.core.extension.Reply; import run.halo.app.extension.MetadataOperator; @@ -19,10 +22,16 @@ import run.halo.app.extension.MetadataOperator; @EqualsAndHashCode public class ReplyVo { + @Schema(required = true) MetadataOperator metadata; + @Schema(required = true) Reply.ReplySpec spec; + @With + @Schema(required = true) + OwnerInfo owner; + /** * Convert {@link Reply} to {@link ReplyVo}. * diff --git a/src/main/resources/extensions/role-template-anonymous.yaml b/src/main/resources/extensions/role-template-anonymous.yaml index fd1570c40..e83eb249c 100644 --- a/src/main/resources/extensions/role-template-anonymous.yaml +++ b/src/main/resources/extensions/role-template-anonymous.yaml @@ -6,6 +6,6 @@ metadata: halo.run/role-template: "true" halo.run/hidden: "true" rules: - - apiGroups: [ "api.console.halo.run" ] + - apiGroups: [ "api.halo.run" ] resources: [ "comments", "comments/reply" ] - verbs: [ "create" ] + verbs: [ "create", "get", "list" ] diff --git a/src/test/java/run/halo/app/content/comment/CommentServiceImplTest.java b/src/test/java/run/halo/app/content/comment/CommentServiceImplTest.java index fe6a9bfed..e95d9c67a 100644 --- a/src/test/java/run/halo/app/content/comment/CommentServiceImplTest.java +++ b/src/test/java/run/halo/app/content/comment/CommentServiceImplTest.java @@ -141,7 +141,7 @@ class CommentServiceImplTest { "top": false, "allowNotification": true, "approved": false, - "hidden": false, + "hidden": true, "subjectRef": { "group": "content.halo.run", "version": "v1alpha1", diff --git a/src/test/java/run/halo/app/theme/dialect/CommentElementTagProcessorTest.java b/src/test/java/run/halo/app/theme/dialect/CommentElementTagProcessorTest.java new file mode 100644 index 000000000..c4cc602db --- /dev/null +++ b/src/test/java/run/halo/app/theme/dialect/CommentElementTagProcessorTest.java @@ -0,0 +1,127 @@ +package run.halo.app.theme.dialect; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationContext; +import org.thymeleaf.IEngineConfiguration; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; +import org.thymeleaf.context.ITemplateContext; +import org.thymeleaf.model.IProcessableElementTag; +import org.thymeleaf.processor.element.IElementTagStructureHandler; +import org.thymeleaf.spring6.dialect.SpringStandardDialect; +import org.thymeleaf.spring6.expression.ThymeleafEvaluationContext; +import org.thymeleaf.templateresolver.StringTemplateResolver; +import org.thymeleaf.templateresource.ITemplateResource; +import org.thymeleaf.templateresource.StringTemplateResource; +import run.halo.app.plugin.ExtensionComponentsFinder; + +/** + * Tests for {@link CommentElementTagProcessor}. + * + * @author guqing + * @see ExtensionComponentsFinder + * @see HaloProcessorDialect + * @since 2.0.0 + */ +@ExtendWith(MockitoExtension.class) +class CommentElementTagProcessorTest { + + @Mock + private ApplicationContext applicationContext; + + @Mock + private ExtensionComponentsFinder componentsFinder; + + private TemplateEngine templateEngine; + + @BeforeEach + void setUp() { + HaloProcessorDialect haloProcessorDialect = new HaloProcessorDialect(); + templateEngine = new TemplateEngine(); + templateEngine.setDialects(Set.of(haloProcessorDialect, new SpringStandardDialect())); + templateEngine.addTemplateResolver(new TestTemplateResolver()); + when(applicationContext.getBean(eq(ExtensionComponentsFinder.class))) + .thenReturn(componentsFinder); + } + + @Test + void doProcess() { + Context context = getContext(); + + String result = templateEngine.process("commentWidget", context); + assertThat(result).isEqualTo(""" + + + +

comment widget:

+ \s + + + """); + + when(componentsFinder.getExtensions(CommentWidget.class)) + .thenReturn(List.of(new DefaultCommentWidget())); + result = templateEngine.process("commentWidget", context); + assertThat(result).isEqualTo(""" + + + +

comment widget:

+

Comment in default widget

+ + + """); + } + + static class DefaultCommentWidget implements CommentWidget { + + @Override + public void render(ITemplateContext context, IProcessableElementTag tag, + IElementTagStructureHandler structureHandler) { + structureHandler.replaceWith("

Comment in default widget

", false); + } + } + + private Context getContext() { + Context context = new Context(); + context.setVariable( + ThymeleafEvaluationContext.THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME, + new ThymeleafEvaluationContext(applicationContext, null)); + return context; + } + + static class TestTemplateResolver extends StringTemplateResolver { + @Override + protected ITemplateResource computeTemplateResource(IEngineConfiguration configuration, + String ownerTemplate, String template, + Map templateResolutionAttributes) { + if (template.equals("commentWidget")) { + return new StringTemplateResource(commentWidget()); + } + return null; + } + + private String commentWidget() { + return """ + + + +

comment widget:

+ + + + """; + } + } +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/theme/endpoint/CommentFinderEndpointTest.java b/src/test/java/run/halo/app/theme/endpoint/CommentFinderEndpointTest.java new file mode 100644 index 000000000..0802dc711 --- /dev/null +++ b/src/test/java/run/halo/app/theme/endpoint/CommentFinderEndpointTest.java @@ -0,0 +1,175 @@ +package run.halo.app.theme.endpoint; + +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.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +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.junit.jupiter.MockitoExtension; +import org.springframework.test.web.reactive.server.WebTestClient; +import reactor.core.publisher.Mono; +import run.halo.app.content.comment.CommentRequest; +import run.halo.app.content.comment.CommentService; +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.Ref; +import run.halo.app.theme.finders.CommentFinder; + +/** + * Tests for {@link CommentFinderEndpoint}. + * + * @author guqing + * @since 2.0.0 + */ +@ExtendWith(MockitoExtension.class) +class CommentFinderEndpointTest { + @Mock + private CommentFinder commentFinder; + + @Mock + private CommentService commentService; + + @Mock + private ReplyService replyService; + + @InjectMocks + private CommentFinderEndpoint commentFinderEndpoint; + + private WebTestClient webTestClient; + + @BeforeEach + void setUp() { + webTestClient = WebTestClient + .bindToRouterFunction(commentFinderEndpoint.endpoint()) + .build(); + } + + @Test + void listComments() { + when(commentFinder.list(any(), anyInt(), anyInt())) + .thenReturn(new ListResult<>(1, 10, 0, List.of())); + + Ref ref = new Ref(); + ref.setGroup("content.halo.run"); + ref.setVersion("v1alpha1"); + ref.setKind("Post"); + ref.setName("test"); + webTestClient.get() + .uri(uriBuilder -> { + return uriBuilder.path("/comments") + .queryParam("group", ref.getGroup()) + .queryParam("version", ref.getVersion()) + .queryParam("kind", ref.getKind()) + .queryParam("name", ref.getName()) + .queryParam("page", 1) + .queryParam("size", 10) + .build(); + }) + .exchange() + .expectStatus() + .isOk(); + ArgumentCaptor refCaptor = ArgumentCaptor.forClass(Ref.class); + verify(commentFinder, times(1)).list(refCaptor.capture(), eq(1), eq(10)); + Ref value = refCaptor.getValue(); + assertThat(value).isEqualTo(ref); + } + + @Test + void getComment() { + when(commentFinder.getByName(any())) + .thenReturn(null); + + webTestClient.get() + .uri("/comments/test-comment") + .exchange() + .expectStatus() + .isOk(); + + verify(commentFinder, times(1)).getByName(eq("test-comment")); + } + + @Test + void listCommentReplies() { + when(commentFinder.listReply(any(), anyInt(), anyInt())) + .thenReturn(new ListResult<>(2, 20, 0, List.of())); + + webTestClient.get() + .uri(uriBuilder -> { + return uriBuilder.path("/comments/test-comment/reply") + .queryParam("page", 2) + .queryParam("size", 20) + .build(); + }) + .exchange() + .expectStatus() + .isOk(); + + verify(commentFinder, times(1)).listReply(eq("test-comment"), eq(2), eq(20)); + } + + @Test + void createComment() { + when(commentService.create(any())).thenReturn(Mono.empty()); + + final CommentRequest commentRequest = new CommentRequest(); + Ref ref = new Ref(); + ref.setGroup("content.halo.run"); + ref.setVersion("v1alpha1"); + ref.setKind("Post"); + ref.setName("test-post"); + commentRequest.setSubjectRef(ref); + commentRequest.setContent("content"); + commentRequest.setRaw("raw"); + commentRequest.setAllowNotification(false); + webTestClient.post() + .uri("/comments") + .bodyValue(commentRequest) + .exchange() + .expectStatus() + .isOk(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Comment.class); + verify(commentService, times(1)).create(captor.capture()); + Comment value = captor.getValue(); + assertThat(value.getSpec().getIpAddress()).isNotNull(); + assertThat(value.getSpec().getUserAgent()).isNotNull(); + assertThat(value.getSpec().getSubjectRef()).isEqualTo(ref); + } + + @Test + void createReply() { + when(replyService.create(any(), any())).thenReturn(Mono.empty()); + + final ReplyRequest replyRequest = new ReplyRequest(); + replyRequest.setRaw("raw"); + replyRequest.setContent("content"); + replyRequest.setAllowNotification(true); + + webTestClient.post() + .uri("/comments/test-comment/reply") + .bodyValue(replyRequest) + .exchange() + .expectStatus() + .isOk(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Reply.class); + verify(replyService, times(1)).create(eq("test-comment"), captor.capture()); + Reply value = captor.getValue(); + assertThat(value.getSpec().getIpAddress()).isNotNull(); + assertThat(value.getSpec().getUserAgent()).isNotNull(); + assertThat(value.getSpec().getQuoteReply()).isNull(); + } +} \ No newline at end of file