Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ public abstract class AbstractCommentService {
// Allow <code> tag's class attribute, for syntax highlighting
.addAttributes("code", "class")
// Allow <a> tag's target attribute
.addAttributes("a", "target");
.addAttributes("a", "target")
.preserveRelativeLinks(true);

protected Mono<User> fetchCurrentUser() {
return ReactiveSecurityContextHolder.getContext()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
import lombok.RequiredArgsConstructor;
import lombok.experimental.UtilityClass;
import org.apache.commons.lang3.StringUtils;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
Expand Down Expand Up @@ -87,13 +89,38 @@ boolean isPageComment(Comment comment) {
return Ref.groupKindEquals(comment.getSpec().getSubjectRef(), PAGE_GVK);
}

/**
* Comment content converter, convert relative links to absolute links.
*/
@Component
@RequiredArgsConstructor
static class CommentContentConverter {
private final ExternalLinkProcessor externalLinkProcessor;

/**
* Convert relative links to absolute links.
*
* @param content the content to convert
* @return the converted content
*/
public String convertRelativeLinks(String content) {
Document parse = Jsoup.parse(content);
parse.select("img").forEach(element -> {
var src = element.attr("src");
element.attr("src", externalLinkProcessor.processLink(src));
});
return parse.body().html();
}
}

@Component
@RequiredArgsConstructor
static class NewCommentOnPostReasonPublisher {

private final ExtensionClient client;
private final NotificationReasonEmitter notificationReasonEmitter;
private final ExternalLinkProcessor externalLinkProcessor;
private final CommentContentConverter commentContentConverter;

public void publishReasonBy(Comment comment) {
Ref subjectRef = comment.getSpec().getSubjectRef();
Expand All @@ -120,7 +147,8 @@ public void publishReasonBy(Comment comment) {
.postTitle(post.getSpec().getTitle())
.postUrl(postUrl)
.commenter(owner.getDisplayName())
.content(comment.getSpec().getContent())
.content(commentContentConverter.convertRelativeLinks(
comment.getSpec().getContent()))
.commentName(comment.getMetadata().getName())
.build();
builder.attributes(ReasonDataConverter.toAttributeMap(attributes))
Expand Down Expand Up @@ -159,6 +187,7 @@ static class NewCommentOnPageReasonPublisher {
private final ExtensionClient client;
private final NotificationReasonEmitter notificationReasonEmitter;
private final ExternalLinkProcessor externalLinkProcessor;
private final CommentContentConverter commentContentConverter;

public void publishReasonBy(Comment comment) {
Ref subjectRef = comment.getSpec().getSubjectRef();
Expand Down Expand Up @@ -188,7 +217,8 @@ public void publishReasonBy(Comment comment) {
.pageTitle(singlePage.getSpec().getTitle())
.pageUrl(pageUrl)
.commenter(defaultIfBlank(owner.getDisplayName(), owner.getName()))
.content(comment.getSpec().getContent())
.content(commentContentConverter.convertRelativeLinks(
comment.getSpec().getContent()))
.commentName(comment.getMetadata().getName())
.build();
builder.attributes(ReasonDataConverter.toAttributeMap(attributes))
Expand Down Expand Up @@ -236,6 +266,7 @@ static class NewReplyReasonPublisher {
private final ExtensionClient client;
private final NotificationReasonEmitter notificationReasonEmitter;
private final ExtensionGetter extensionGetter;
private final CommentContentConverter commentContentConverter;

public void publishReasonBy(Reply reply, Comment comment) {
boolean isQuoteReply = StringUtils.isNotBlank(reply.getSpec().getQuoteReply());
Expand Down Expand Up @@ -267,7 +298,8 @@ public void publishReasonBy(Reply reply, Comment comment) {
.orElse(comment.getSpec().getContent());

var quoteReplyContent = quoteReplyOptional
.map(quoteReply -> quoteReply.getSpec().getContent())
.map(quoteReply -> commentContentConverter
.convertRelativeLinks(quoteReply.getSpec().getContent()))
.orElse(null);
var replyOwner = reply.getSpec().getOwner();

Expand All @@ -276,12 +308,14 @@ public void publishReasonBy(Reply reply, Comment comment) {
.orElseGet(() -> comment.getSpec().getOwner());

var reasonAttributesBuilder = NewReplyReasonData.builder()
.commentContent(comment.getSpec().getContent())
.commentContent(
commentContentConverter.convertRelativeLinks(comment.getSpec().getContent())
)
.isQuoteReply(isQuoteReply)
.quoteContent(quoteReplyContent)
.commentName(comment.getMetadata().getName())
.replier(defaultIfBlank(replyOwner.getDisplayName(), replyOwner.getName()))
.content(reply.getSpec().getContent())
.content(commentContentConverter.convertRelativeLinks(reply.getSpec().getContent()))
.replyName(reply.getMetadata().getName())
.replyOwner(identityFrom(replyOwner).name())
.repliedOwner(identityFrom(repliedOwner).name());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import run.halo.app.extension.Metadata;
import run.halo.app.extension.Ref;
import run.halo.app.infra.ExternalLinkProcessor;
import run.halo.app.infra.ExternalUrlSupplier;
import run.halo.app.notification.NotificationReasonEmitter;
import run.halo.app.notification.ReasonPayload;
import run.halo.app.notification.UserIdentity;
Expand Down Expand Up @@ -134,8 +135,133 @@ void isPageComment() {
assertThat(reasonPublisher.isPageComment(comment)).isTrue();
}

@Nested
@ExtendWith(MockitoExtension.class)
class CommentContentConverterTest {
@Mock
ExternalUrlSupplier externalUrlSupplier;

@Mock
ExternalLinkProcessor externalLinkProcessor;

@InjectMocks
CommentNotificationReasonPublisher.CommentContentConverter commentContentConverter;

@Test
void shouldConvertRelativeImageLinksToAbsolute() {
var content =
"<p>Test content <img src=\"/upload/image.jpg\" alt=\"Test image\" /></p>";

when(externalLinkProcessor.processLink("/upload/image.jpg"))
.thenReturn("https://example.com/upload/image.jpg");

var result = commentContentConverter.convertRelativeLinks(content);

assertThat(result).contains("https://example.com/upload/image.jpg");
assertThat(result).contains("Test content");
verify(externalLinkProcessor).processLink("/upload/image.jpg");
}

@Test
void shouldHandleRelativeImageLinksWithoutLeadingSlash() {
var content = "<p><img src=\"upload/image.jpg\" /></p>";

when(externalLinkProcessor.processLink("upload/image.jpg"))
.thenReturn("https://example.com/upload/image.jpg");

var result = commentContentConverter.convertRelativeLinks(content);

assertThat(result).contains("https://example.com/upload/image.jpg");
verify(externalLinkProcessor).processLink("upload/image.jpg");
}

@Test
void shouldNotConvertAbsoluteImageLinks() {
var content = "<p><img src=\"https://cdn.example.com/image.jpg\" /></p>";

when(externalLinkProcessor.processLink("https://cdn.example.com/image.jpg"))
.thenReturn("https://cdn.example.com/image.jpg");

var result = commentContentConverter.convertRelativeLinks(content);

assertThat(result).contains("https://cdn.example.com/image.jpg");
verify(externalLinkProcessor).processLink("https://cdn.example.com/image.jpg");
}

@Test
void shouldHandleMultipleImages() {
var content = "<p>"
+ "<img src=\"/img1.jpg\" />"
+ "<img src=\"/img2.jpg\" />"
+ "<img src=\"https://example.com/img3.jpg\" />"
+ "</p>";

when(externalLinkProcessor.processLink("/img1.jpg"))
.thenReturn("https://example.com/img1.jpg");
when(externalLinkProcessor.processLink("/img2.jpg"))
.thenReturn("https://example.com/img2.jpg");
when(externalLinkProcessor.processLink("https://example.com/img3.jpg"))
.thenReturn("https://example.com/img3.jpg");

var result = commentContentConverter.convertRelativeLinks(content);

assertThat(result).contains("https://example.com/img1.jpg");
assertThat(result).contains("https://example.com/img2.jpg");
assertThat(result).contains("https://example.com/img3.jpg");
verify(externalLinkProcessor).processLink("/img1.jpg");
verify(externalLinkProcessor).processLink("/img2.jpg");
verify(externalLinkProcessor).processLink("https://example.com/img3.jpg");
}

@Test
void shouldHandleContentWithoutImages() {
var content = "<p>This is a comment content without images</p>";

var result = commentContentConverter.convertRelativeLinks(content);

assertThat(result).contains("This is a comment content without images");
assertThat(result).doesNotContain("img");
}

@Test
void shouldHandleEmptyContent() {
var content = "";

var result = commentContentConverter.convertRelativeLinks(content);

assertThat(result).isEmpty();
}

@Test
void shouldHandleComplexHtmlContent() {
var content = """
<div>
<h1>Title</h1>
<p>Paragraph content</p>
<img src="/images/photo1.png" alt="Photo 1" />
<p>More text</p>
<img src="assets/photo2.jpg" />
</div>
""";

when(externalLinkProcessor.processLink("/images/photo1.png"))
.thenReturn("https://example.com/images/photo1.png");
when(externalLinkProcessor.processLink("assets/photo2.jpg"))
.thenReturn("https://example.com/assets/photo2.jpg");

var result = commentContentConverter.convertRelativeLinks(content);

assertThat(result).contains("https://example.com/images/photo1.png");
assertThat(result).contains("https://example.com/assets/photo2.jpg");
assertThat(result).contains("Title");
assertThat(result).contains("Paragraph content");
verify(externalLinkProcessor).processLink("/images/photo1.png");
verify(externalLinkProcessor).processLink("assets/photo2.jpg");
}
}

@Nested
@ExtendWith(MockitoExtension.class)
class NewCommentOnPostReasonPublisherTest {
@Mock
ExtensionClient client;
Expand All @@ -149,6 +275,9 @@ class NewCommentOnPostReasonPublisherTest {
@Mock
ExternalLinkProcessor externalLinkProcessor;

@Mock
CommentNotificationReasonPublisher.CommentContentConverter commentContentConverter;

@InjectMocks
CommentNotificationReasonPublisher.NewCommentOnPostReasonPublisher
newCommentOnPostReasonPublisher;
Expand All @@ -171,6 +300,9 @@ void publishReasonByTest() {
when(client.fetch(eq(Post.class), eq(metadata.getName())))
.thenReturn(Optional.of(post));

when(commentContentConverter.convertRelativeLinks(eq("fake-comment-content")))
.thenReturn("fake-comment-content");

when(emitter.emit(eq("new-comment-on-post"), any()))
.thenReturn(Mono.empty());

Expand Down Expand Up @@ -242,6 +374,7 @@ void doNotEmitReasonTest() {
}

@Nested
@ExtendWith(MockitoExtension.class)
class NewCommentOnPageReasonPublisherTest {
@Mock
ExtensionClient client;
Expand All @@ -252,6 +385,9 @@ class NewCommentOnPageReasonPublisherTest {
@Mock
ExternalLinkProcessor externalLinkProcessor;

@Mock
CommentNotificationReasonPublisher.CommentContentConverter commentContentConverter;

@InjectMocks
CommentNotificationReasonPublisher.NewCommentOnPageReasonPublisher
newCommentOnPageReasonPublisher;
Expand All @@ -276,6 +412,9 @@ void publishReasonByTest() {
when(client.fetch(eq(SinglePage.class), eq(metadata.getName())))
.thenReturn(Optional.of(page));

when(commentContentConverter.convertRelativeLinks(eq("fake-comment-content")))
.thenReturn("fake-comment-content");

when(emitter.emit(eq("new-comment-on-single-page"), any()))
.thenReturn(Mono.empty());

Expand Down Expand Up @@ -347,6 +486,7 @@ void doNotEmitReasonTest() {
}

@Nested
@ExtendWith(MockitoExtension.class)
class NewReplyReasonPublisherTest {

@Mock
Expand All @@ -358,6 +498,9 @@ class NewReplyReasonPublisherTest {
@Mock
ExtensionGetter extensionGetter;

@Mock
CommentNotificationReasonPublisher.CommentContentConverter commentContentConverter;

@InjectMocks
CommentNotificationReasonPublisher.NewReplyReasonPublisher newReplyReasonPublisher;

Expand All @@ -380,6 +523,13 @@ void publishReasonByTest() {

doReturn(false).when(spyNewReplyReasonPublisher)
.doNotEmitReason(any(), any(), any());

// Mock commentContentConverter for all content conversions
when(commentContentConverter.convertRelativeLinks(eq("fake-comment-content")))
.thenReturn("fake-comment-content");
when(commentContentConverter.convertRelativeLinks(eq("fake-reply-content")))
.thenReturn("fake-reply-content");

when(notificationReasonEmitter.emit(any(), any()))
.thenReturn(Mono.empty());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,15 @@ defineProps<{
<template>
<div
class="comment-content markdown-body whitespace-pre-wrap rounded-lg !bg-transparent !text-sm !text-gray-900"
v-html="sanitizeHtml(content)"
v-html="
sanitizeHtml(content, {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
allowedAttributes: {
...sanitizeHtml.defaults.allowedAttributes,
code: ['class'],
},
})
"
></div>
</template>

Expand Down
Loading