Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Communication: Fix announcement emails not rendering correctly #9850

Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE;

import java.net.URL;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Set;

Expand Down Expand Up @@ -64,6 +66,8 @@ public class MailService implements InstantNotificationService {

private final MailSendingService mailSendingService;

private final List<MarkdownCustomRendererService> markdownCustomRendererServices;

// notification related variables

private static final String NOTIFICATION = "notification";
Expand All @@ -89,11 +93,16 @@ public class MailService implements InstantNotificationService {

private static final String WEEKLY_SUMMARY_NEW_EXERCISES = "weeklySummaryNewExercises";

public MailService(MessageSource messageSource, SpringTemplateEngine templateEngine, TimeService timeService, MailSendingService mailSendingService) {
private final HashMap<Long, String> renderedPosts;
PaRangger marked this conversation as resolved.
Show resolved Hide resolved

public MailService(MessageSource messageSource, SpringTemplateEngine templateEngine, TimeService timeService, MailSendingService mailSendingService,
MarkdownCustomLinkRendererService markdownCustomLinkRendererService, MarkdownCustomReferenceRendererService markdownCustomReferenceRendererService) {
this.messageSource = messageSource;
this.templateEngine = templateEngine;
this.timeService = timeService;
this.mailSendingService = mailSendingService;
markdownCustomRendererServices = List.of(markdownCustomLinkRendererService, markdownCustomReferenceRendererService);
renderedPosts = new HashMap<>();
PaRangger marked this conversation as resolved.
Show resolved Hide resolved
}

/**
Expand Down Expand Up @@ -266,14 +275,30 @@ public void sendNotification(Notification notification, User user, Object notifi

// Render markdown content of post to html
try {
Parser parser = Parser.builder().build();
HtmlRenderer renderer = HtmlRenderer.builder().build();
String postContent = post.getContent();
String renderedPostContent = renderer.render(parser.parse(postContent));
String renderedPostContent;

// To avoid having to re-render the same post multiple times we store it in a hash map
if (renderedPosts.containsKey(post.getId())) {
renderedPostContent = renderedPosts.get(post.getId());
}
else {
Parser parser = Parser.builder().build();
HtmlRenderer renderer = HtmlRenderer.builder()
.attributeProviderFactory(attributeContext -> new MarkdownRelativeToAbsolutePathAttributeProvider(artemisServerUrl.toString()))
.nodeRendererFactory(new MarkdownImageBlockRendererFactory(artemisServerUrl.toString())).build();
String postContent = post.getContent();
renderedPostContent = markdownCustomRendererServices.stream().reduce(renderer.render(parser.parse(postContent)), (s, service) -> service.render(s),
(s1, s2) -> s2);
if (post.getId() != null) {
renderedPosts.put(post.getId(), renderedPostContent);
}
}

PaRangger marked this conversation as resolved.
Show resolved Hide resolved
post.setContent(renderedPostContent);
}
catch (Exception e) {
// In case something goes wrong, leave content of post as-is
log.error("Error while parsing post content", e);
}
}
else {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package de.tum.cit.aet.artemis.communication.service.notifications;

import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE;

import java.net.URL;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import org.springframework.web.util.UriComponentsBuilder;

/**
* This service implements the rendering of markdown tags that represent a link.
* It takes the tag, transforms it into an <a></a> tag, and sets the corresponding href.
*/
@Profile(PROFILE_CORE)
@Service
public class MarkdownCustomLinkRendererService implements MarkdownCustomRendererService {

private static final Logger log = LoggerFactory.getLogger(MarkdownCustomLinkRendererService.class);

private final Set<String> supportedTags;

@Value("${server.url}")
private URL artemisServerUrl;

public MarkdownCustomLinkRendererService() {
this.supportedTags = Set.of("programming", "modeling", "quiz", "text", "file-upload", "lecture", "attachment", "lecture-unit", "slide", "faq");
}
PaRangger marked this conversation as resolved.
Show resolved Hide resolved

/**
* Takes a string and replaces all occurrences of custom markdown tags (e.g. [programming], [faq], etc.) with a link
*
* @param content string to render
*
* @return the newly rendered string.
*/
public String render(String content) {
String tagPattern = String.join("|", supportedTags);
// The pattern checks for the occurrence of any tag and then extracts the link from it
Pattern pattern = Pattern.compile("\\[(" + tagPattern + ")\\](.*?)\\((.*?)\\)(.*?)\\[/\\1\\]");
PaRangger marked this conversation as resolved.
Show resolved Hide resolved
Matcher matcher = pattern.matcher(content);
String parsedContent = content;

while (matcher.find()) {
try {
String textStart = matcher.group(2);
String link = matcher.group(3);
String textEnd = matcher.group(4);
String text = (textStart + " " + textEnd).trim();

String absoluteUrl = UriComponentsBuilder.fromUri(artemisServerUrl.toURI()).path(link).build().toUriString();

parsedContent = parsedContent.substring(0, matcher.start()) + "<a href=\"" + absoluteUrl + "\">" + text + "</a>" + parsedContent.substring(matcher.end());
}
catch (Exception e) {
log.error("Not able to render tag. Replacing with empty.", e);
parsedContent = parsedContent.substring(0, matcher.start()) + parsedContent.substring(matcher.end());
}

matcher = pattern.matcher(parsedContent);
}

return parsedContent;
}
PaRangger marked this conversation as resolved.
Show resolved Hide resolved
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package de.tum.cit.aet.artemis.communication.service.notifications;

import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE;

import java.util.HashMap;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;

/**
* This service implements the rendering of markdown tags that represent a reference (to e.g. a user).
* These references cannot directly represent a link, so they are rendered as their text only.
*/
@Profile(PROFILE_CORE)
@Service
public class MarkdownCustomReferenceRendererService implements MarkdownCustomRendererService {

private static final Logger log = LoggerFactory.getLogger(MarkdownCustomReferenceRendererService.class);

private final Set<String> supportedTags;

private final HashMap<String, String> startingCharacters;

public MarkdownCustomReferenceRendererService() {
supportedTags = Set.of("user", "channel");
startingCharacters = new HashMap<>();
startingCharacters.put("user", "@");
startingCharacters.put("channel", "#");
}

/**
* Takes a string and replaces all occurrences of custom markdown tags (e.g. [user], [channel], etc.) with text.
* To make it better readable, it prepends an appropriate character. (e.g. for users an @, for channels a #)
*
* @param content string to render
*
* @return the newly rendered string.
*/
@Override
public String render(String content) {
String tagPattern = String.join("|", supportedTags);
Pattern pattern = Pattern.compile("\\[(" + tagPattern + ")\\](.*?)\\((.*?)\\)(.*?)\\[/\\1\\]");
Matcher matcher = pattern.matcher(content);
PaRangger marked this conversation as resolved.
Show resolved Hide resolved
String parsedContent = content;

while (matcher.find()) {
try {
String tag = matcher.group(1);
String startingCharacter = startingCharacters.get(tag);
startingCharacter = startingCharacter == null ? "" : startingCharacter;
String textStart = matcher.group(2);
String textEnd = matcher.group(4);
String text = startingCharacter + (textStart + " " + textEnd).trim();

parsedContent = parsedContent.substring(0, matcher.start()) + text + parsedContent.substring(matcher.end());
}
catch (Exception e) {
log.error("Not able to render tag. Replacing with empty.", e);
parsedContent = parsedContent.substring(0, matcher.start()) + parsedContent.substring(matcher.end());
}

matcher = pattern.matcher(parsedContent);
}

return parsedContent;
}
PaRangger marked this conversation as resolved.
Show resolved Hide resolved
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package de.tum.cit.aet.artemis.communication.service.notifications;

public interface MarkdownCustomRendererService {

String render(String content);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package de.tum.cit.aet.artemis.communication.service.notifications;

import java.util.Map;
import java.util.Set;

import org.commonmark.node.Image;
import org.commonmark.node.Node;
import org.commonmark.node.Text;
import org.commonmark.renderer.NodeRenderer;
import org.commonmark.renderer.html.HtmlNodeRendererContext;
import org.commonmark.renderer.html.HtmlWriter;

public class MarkdownImageBlockRenderer implements NodeRenderer {

private final String baseUrl;

private final HtmlWriter html;

MarkdownImageBlockRenderer(HtmlNodeRendererContext context, String baseUrl) {
html = context.getWriter();
this.baseUrl = baseUrl;
}
PaRangger marked this conversation as resolved.
Show resolved Hide resolved

@Override
public Set<Class<? extends Node>> getNodeTypes() {
return Set.of(Image.class);
}

@Override
public void render(Node node) {
Image image = (Image) node;

html.tag("a", Map.of("href", baseUrl + image.getDestination()));

try {
html.text(((Text) image.getFirstChild()).getLiteral());
}
catch (Exception e) {
html.text(image.getDestination());
}

html.tag("/a");
}
PaRangger marked this conversation as resolved.
Show resolved Hide resolved
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package de.tum.cit.aet.artemis.communication.service.notifications;

import org.commonmark.renderer.NodeRenderer;
import org.commonmark.renderer.html.HtmlNodeRendererContext;
import org.commonmark.renderer.html.HtmlNodeRendererFactory;

public class MarkdownImageBlockRendererFactory implements HtmlNodeRendererFactory {

private final String baseUrl;

public MarkdownImageBlockRendererFactory(String baseUrl) {
this.baseUrl = baseUrl;
}
PaRangger marked this conversation as resolved.
Show resolved Hide resolved

@Override
public NodeRenderer create(HtmlNodeRendererContext context) {
return new MarkdownImageBlockRenderer(context, baseUrl);
}
PaRangger marked this conversation as resolved.
Show resolved Hide resolved
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package de.tum.cit.aet.artemis.communication.service.notifications;

import java.util.Map;

import org.commonmark.node.Node;
import org.commonmark.renderer.html.AttributeProvider;

public class MarkdownRelativeToAbsolutePathAttributeProvider implements AttributeProvider {

private final String baseUrl;

public MarkdownRelativeToAbsolutePathAttributeProvider(String baseUrl) {
this.baseUrl = baseUrl;
}

/**
* We store images and attachments with relative urls, so when rendering we need to replace them with absolute ones
*
* @param node rendered Node, if Image or Link we try to replace the source
* @param attributes of the Node
* @param tagName of the html element
*/
@Override
public void setAttributes(Node node, String tagName, Map<String, String> attributes) {
if ("a".equals(tagName)) {
String href = attributes.get("href");
if (href != null && href.startsWith("/")) {
attributes.put("href", baseUrl + href);
}
}
}
PaRangger marked this conversation as resolved.
Show resolved Hide resolved
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
import de.tum.cit.aet.artemis.communication.domain.notification.NotificationConstants;
import de.tum.cit.aet.artemis.communication.service.notifications.MailSendingService;
import de.tum.cit.aet.artemis.communication.service.notifications.MailService;
import de.tum.cit.aet.artemis.communication.service.notifications.MarkdownCustomLinkRendererService;
import de.tum.cit.aet.artemis.communication.service.notifications.MarkdownCustomReferenceRendererService;
import de.tum.cit.aet.artemis.core.domain.Course;
import de.tum.cit.aet.artemis.core.domain.User;
import de.tum.cit.aet.artemis.core.service.TimeService;
Expand Down Expand Up @@ -115,7 +117,8 @@ void setUp() throws MalformedURLException, URISyntaxException {

mailSendingService = new MailSendingService(jHipsterProperties, javaMailSender);

mailService = new MailService(messageSource, templateEngine, timeService, mailSendingService);
mailService = new MailService(messageSource, templateEngine, timeService, mailSendingService, new MarkdownCustomLinkRendererService(),
new MarkdownCustomReferenceRendererService());
PaRangger marked this conversation as resolved.
Show resolved Hide resolved
ReflectionTestUtils.setField(mailService, "artemisServerUrl", new URI("http://localhost:8080").toURL());
}

Expand Down
Loading
Loading