001/*
002 *  Copyright 2020 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016package org.ametys.cms.repository.comment;
017
018import java.util.ArrayList;
019import java.util.HashMap;
020import java.util.List;
021import java.util.Map;
022import java.util.Optional;
023import java.util.Set;
024import java.util.regex.Pattern;
025
026import org.apache.avalon.framework.component.Component;
027import org.apache.avalon.framework.context.Context;
028import org.apache.avalon.framework.context.ContextException;
029import org.apache.avalon.framework.context.Contextualizable;
030import org.apache.avalon.framework.service.ServiceException;
031import org.apache.avalon.framework.service.ServiceManager;
032import org.apache.avalon.framework.service.Serviceable;
033import org.apache.cocoon.components.ContextHelper;
034import org.apache.cocoon.environment.Request;
035import org.apache.cocoon.xml.AttributesImpl;
036import org.apache.cocoon.xml.XMLUtils;
037import org.apache.commons.lang.StringUtils;
038import org.xml.sax.ContentHandler;
039import org.xml.sax.SAXException;
040
041import org.ametys.cms.ObservationConstants;
042import org.ametys.cms.repository.Content;
043import org.ametys.cms.repository.ReactionableObject.ReactionType;
044import org.ametys.cms.repository.ReactionableObjectHelper;
045import org.ametys.cms.repository.ReportableObjectHelper;
046import org.ametys.cms.repository.comment.contributor.ContributorCommentableAmetysObject;
047import org.ametys.cms.rights.ContentRightAssignmentContext;
048import org.ametys.core.captcha.CaptchaHelper;
049import org.ametys.core.observation.Event;
050import org.ametys.core.observation.ObservationManager;
051import org.ametys.core.right.RightManager;
052import org.ametys.core.right.RightManager.RightResult;
053import org.ametys.core.ui.Callable;
054import org.ametys.core.user.CurrentUserProvider;
055import org.ametys.core.user.User;
056import org.ametys.core.user.UserIdentity;
057import org.ametys.core.user.UserManager;
058import org.ametys.core.user.directory.NotUniqueUserException;
059import org.ametys.core.user.population.PopulationContextHelper;
060import org.ametys.core.user.population.UserPopulation;
061import org.ametys.core.user.population.UserPopulationDAO;
062import org.ametys.core.util.DateUtils;
063import org.ametys.core.util.mail.SendMailHelper;
064import org.ametys.plugins.core.ui.user.ProfileImageResolverHelper;
065import org.ametys.plugins.core.user.UserHelper;
066import org.ametys.plugins.repository.AmetysObjectResolver;
067import org.ametys.plugins.repository.provider.RequestAttributeWorkspaceSelector;
068import org.ametys.runtime.config.Config;
069import org.ametys.runtime.i18n.I18nizableText;
070import org.ametys.runtime.plugin.component.AbstractLogEnabled;
071
072/**
073 * DAO for content's comments
074 *
075 */
076public class CommentsDAO extends AbstractLogEnabled implements Component, Serviceable, Contextualizable
077{
078    /** The Avalon role */
079    public static final String ROLE = CommentsDAO.class.getName();
080    
081    /** The form input name for author name */
082    public static final String FORM_AUTHOR_NAME = "name";
083    /** The form input name for author email */
084    public static final String FORM_AUTHOR_EMAIL = "email";
085    /** The form input name for author email to hide */
086    public static final String FORM_AUTHOR_HIDEEMAIL = "hide-email";
087    /** The form input name for author url */
088    public static final String FORM_AUTHOR_URL = "url";
089    /** The form input name for content */
090    public static final String FORM_CONTENTTEXT = "text";
091    /** The form input name for captcha */
092    public static final String FORM_CAPTCHA_KEY = "captcha-key";
093    /** The form input name for captcha */
094    public static final String FORM_CAPTCHA_VALUE = "captcha-value";
095    
096    /** The pattern to check url */
097    public static final Pattern URL_VALIDATOR = Pattern.compile("^(https?:\\/\\/.+)?$");
098    
099    /** The Ametys object resolver */
100    protected AmetysObjectResolver _resolver;
101    /** The observation manager */
102    protected ObservationManager _observationManager;
103    /** The currenrt user provider */
104    protected CurrentUserProvider _userProvider;
105    /** The user helper */
106    protected UserHelper _userHelper;
107    /** The right manager */
108    protected RightManager _rightManager;
109    /** Helper for reactionable object */
110    protected ReactionableObjectHelper _reactionableHelper;
111    /** The user manager */
112    protected UserManager _userManager;
113    /** The population context helper */
114    protected PopulationContextHelper _populationContextHelper;
115    /** The user population DAO */
116    protected UserPopulationDAO _userPopulationDAO;
117    
118    /** The avalon context */
119    protected Context _context;
120
121    public void service(ServiceManager smanager) throws ServiceException
122    {
123        _resolver = (AmetysObjectResolver) smanager.lookup(AmetysObjectResolver.ROLE);
124        _userProvider = (CurrentUserProvider) smanager.lookup(CurrentUserProvider.ROLE);
125        _observationManager = (ObservationManager) smanager.lookup(ObservationManager.ROLE);
126        _userHelper = (UserHelper) smanager.lookup(UserHelper.ROLE);
127        _rightManager = (RightManager) smanager.lookup(RightManager.ROLE);
128        _reactionableHelper = (ReactionableObjectHelper) smanager.lookup(ReactionableObjectHelper.ROLE);
129        _populationContextHelper = (PopulationContextHelper) smanager.lookup(PopulationContextHelper.ROLE);
130        _userManager = (UserManager) smanager.lookup(UserManager.ROLE);
131        _userPopulationDAO = (UserPopulationDAO) smanager.lookup(UserPopulationDAO.ROLE);
132    }
133    
134    public void contextualize(Context context) throws ContextException
135    {
136        _context = context;
137    }
138    
139    /**
140     * Get the content's comments
141     * @param contentId the content id
142     * @param contextualParameters the contextual parameters
143     * @return the comments
144     * @throws IllegalAccessException if user is not allowed to get comments
145     */
146    @Callable (allowAnonymous = true, rights = Callable.READ_ACCESS, rightContext = ContentRightAssignmentContext.ID, paramIndex = 0)
147    public List<Map<String, Object>> getComments(String contentId, Map<String, Object> contextualParameters) throws IllegalAccessException
148    {
149        Content content = _resolver.resolveById(contentId);
150        
151        List<Map<String, Object>> comments2json = new ArrayList<>();
152        
153        if (content instanceof CommentableContent commentableContent)
154        {
155            List<Comment> comments = commentableContent.getComments(false, true);
156            for (Comment comment : comments)
157            {
158                comments2json.add(getComment(content, comment, 0, contextualParameters));
159            }
160        }   
161        
162        return comments2json;
163    }
164    
165    /**
166     * Get the content's contributor comments
167     * @param contentId the content id
168     * @param contextualParameters the contextual parameters
169     * @return the comments
170     */
171    public List<Map<String, Object>> getContributorComments(String contentId, Map<String, Object> contextualParameters)
172    {
173        Content content = _resolver.resolveById(contentId);
174        
175        List<Map<String, Object>> comments2json = new ArrayList<>();
176        
177        if (content instanceof ContributorCommentableAmetysObject commentableContent)
178        {
179            List<Comment> comments = commentableContent.getContributorComments();
180            for (Comment comment : comments)
181            {
182                comments2json.add(getComment(content, comment, 0, contextualParameters));
183            }
184        }   
185        
186        return comments2json;
187    }
188    
189    /**
190     * Add a new comment
191     * @param contentId the content id
192     * @param commentId the id of of parent comment. Can be null if it is not a subcomment
193     * @param formValues the form's values
194     * @param contextualParameters the contextual parameters
195     * @return the results
196     * @throws IllegalAccessException if anonymous or current user has no access to the content
197     */
198    @Callable (allowAnonymous = true, rights = Callable.READ_ACCESS, rightContext = ContentRightAssignmentContext.ID, paramIndex = 0)
199    public Map<String, Object> addComment(String contentId, String commentId, Map<String, Object> formValues, Map<String, Object> contextualParameters) throws IllegalAccessException
200    {
201        CommentableContent cContent = getContent(contentId);
202        
203        List<Map<String, Object>> errors = getErrors(cContent, formValues);
204
205        if (errors.isEmpty())
206        {
207            Map<String, Object> results = new HashMap<>();
208            
209            if (StringUtils.isNotBlank(commentId))
210            {
211                Comment comment = cContent.getComment(commentId);
212                Comment subComment = comment.createSubComment();
213                results = _setCommentAttributes(cContent, subComment, formValues);
214                results.put("comment", getComment(cContent, subComment, 1, contextualParameters));
215            }
216            else
217            {
218                Comment comment = cContent.createComment();
219                results = _setCommentAttributes(cContent, comment, formValues);
220                results.put("comment", getComment(cContent, comment, 0, contextualParameters));
221            }
222            
223            return results;
224        }
225        else
226        {
227            Map<String, Object> results = new HashMap<>();
228            results.put("errors", errors);
229            return results;
230        }
231    }
232    
233    /**
234     * Delete a comment
235     * @param contentId the content id
236     * @param commentId the comment id to remove
237     * @return the results
238     * @throws IllegalAccessException if current user is null
239     */
240    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
241    public Map<String, Object> deleteComment(String contentId, String commentId) throws IllegalAccessException
242    {
243        CommentableContent content = getContent(contentId);
244        Comment comment = content.getComment(commentId);
245        
246        if (!canDeleteComment(content, comment))
247        {
248            throw new IllegalAccessException("User " + getCurrentUser() + " tried to delete comment on content " + content + " without sufficient rights");
249        }
250        
251        Map<String, Object> results = new HashMap<>();
252        
253        Map<String, Object> eventParams = new HashMap<>();
254        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
255        eventParams.put(ObservationConstants.ARGS_COMMENT_ID, comment.getId());
256        eventParams.put(ObservationConstants.ARGS_COMMENT_AUTHOR, comment.getAuthorName());
257        eventParams.put(ObservationConstants.ARGS_COMMENT_AUTHOR_EMAIL, comment.getAuthorEmail());
258        eventParams.put(ObservationConstants.ARGS_COMMENT_VALIDATED, comment.isValidated());
259        eventParams.put(ObservationConstants.ARGS_COMMENT_CREATION_DATE, comment.getCreationDate());
260        
261        eventParams.put("comment.content", comment.getContent());
262
263        _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_COMMENT_DELETING, getCurrentUser(), eventParams));
264
265        comment.remove();
266        content.saveChanges();
267
268        _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_COMMENT_DELETED, getCurrentUser(), eventParams));
269
270        results.put("deleted", true);
271        results.put("contentId", content.getId());
272        results.put("commentId", comment.getId());
273        
274        return results;
275    }
276    
277    /**
278     * Determines if current user is allowed to delete a comment 
279     * @param contentId the content id
280     * @return true if the current user is allowed
281     */
282    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
283    public boolean canDeleteComment(String contentId)
284    {
285        CommentableContent content = getContent(contentId);
286        UserIdentity currentUser = getCurrentUser();
287        return currentUser != null && _rightManager.hasRight(currentUser, "CMS_Rights_CommentModerate", content).equals(RightResult.RIGHT_ALLOW);
288    }
289    
290    /**
291     * Determines if current user is allowed to delete a comment 
292     * @param content the content
293     * @param comment the comment
294     * @return true if the current user is allowed
295     */
296    public boolean canDeleteComment(Content content, Comment comment)
297    {
298        boolean isUserOwnComment = false;
299        
300        UserIdentity currentUser = getCurrentUser();
301        if (currentUser != null)
302        {
303            User user = _userManager.getUser(currentUser);
304            String authorEmail = comment.getAuthorEmail();
305            isUserOwnComment = user != null  && StringUtils.equals(authorEmail, user.getEmail());
306        }
307        
308        return isUserOwnComment || currentUser != null && _rightManager.hasRight(currentUser, "CMS_Rights_CommentModerate", content).equals(RightResult.RIGHT_ALLOW);
309    }
310
311    /**
312     * React (like or unlike) to a comment
313     * @param contentId the content id
314     * @param commentId the comment id
315     * @return the results
316     * @throws IllegalAccessException if current user is null or user has no access to the content
317     */
318    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
319    public Map<String, Object> likeOrUnlikeComment(String contentId, String commentId) throws IllegalAccessException
320    {
321        return likeOrUnlikeComment(contentId, commentId, null);
322    }
323    
324    /**
325     * React (like or unlike) to a comment
326     * @param contentId the content id
327     * @param commentId the comment id
328     * @param remove true to remove the reaction, false to add reaction. If null, check if the current user has already like the comment.
329     * @return the results
330     * @throws IllegalAccessException if current user is null or user has no access to the content
331     */
332    @Callable  (rights = Callable.READ_ACCESS, rightContext = ContentRightAssignmentContext.ID, paramIndex = 0)
333    public Map<String, Object> likeOrUnlikeComment(String contentId, String commentId, Boolean remove) throws IllegalAccessException
334    {
335        Map<String, Object> results = new HashMap<>();
336        
337        CommentableContent content = getContent(contentId);
338        Comment comment = content.getComment(commentId);
339        
340        UserIdentity currentUser = getCurrentUser();
341        if (currentUser == null)
342        {
343            throw new IllegalAccessException("Anonymous user is not allowed to react to a comment");
344        }
345        
346        List<UserIdentity> likers = comment.getReactionUsers(ReactionType.LIKE);
347        int nbLikes = likers.size();
348        
349        if (Boolean.TRUE.equals(remove)
350            || remove == null && likers.contains(currentUser))
351        {
352            comment.removeReaction(currentUser, ReactionType.LIKE);
353            results.put("liked", false);
354            nbLikes--;
355        }
356        else
357        {
358            comment.addReaction(currentUser, ReactionType.LIKE);
359            results.put("liked", true);
360            nbLikes++;
361        }
362        
363        results.put("nbLikes", nbLikes);
364        
365        content.saveChanges();
366        
367        Map<String, Object> eventParams = new HashMap<>();
368        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
369        eventParams.put(ObservationConstants.ARGS_COMMENT, comment);
370        eventParams.put(ObservationConstants.ARGS_REACTION_TYPE, ReactionType.LIKE);
371        eventParams.put(ObservationConstants.ARGS_REACTION_ISSUER, currentUser);
372        _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_COMMENT_REACTION_CHANGED, getCurrentUser(), eventParams));
373        
374        results.put("contentId", content.getId());
375        results.put("commentId", comment.getId());
376        return results;
377    }
378
379    /**
380     * Get the commentable content
381     * @param contentId The content id
382     * @return The content
383     */
384    protected CommentableContent getContent (String contentId)
385    {
386        Request request = ContextHelper.getRequest(_context);
387        String currentWorkspace = RequestAttributeWorkspaceSelector.getForcedWorkspace(request);
388
389        try
390        {
391            RequestAttributeWorkspaceSelector.setForcedWorkspace(request, "default");
392            return _resolver.resolveById(contentId);
393        }
394        finally
395        {
396            RequestAttributeWorkspaceSelector.setForcedWorkspace(request, currentWorkspace);
397        }
398    }
399    
400    /**
401     * Set comment attributes
402     * @param content the content
403     * @param comment the comment
404     * @param formValues the form's values
405     * @return the result
406     */
407    protected Map<String, Object> _setCommentAttributes(CommentableContent content, Comment comment, Map<String, Object> formValues)
408    {
409        String authorName = (String) formValues.get(FORM_AUTHOR_NAME);
410        String authorEmail = (String) formValues.get(FORM_AUTHOR_EMAIL);
411        String authorUrl = (String) formValues.get(FORM_AUTHOR_URL);
412        String text = (String) formValues.get(FORM_CONTENTTEXT);
413        boolean hideEmail = formValues.containsKey(FORM_AUTHOR_HIDEEMAIL) && Boolean.TRUE.equals(formValues.get(FORM_AUTHOR_HIDEEMAIL));
414        
415        comment.setAuthorName(authorName);
416        comment.setAuthorEmail(authorEmail);
417        comment.setEmailHiddenStatus(hideEmail);
418        comment.setAuthorURL(authorUrl);
419        comment.setContent(text.replaceAll("\r", ""));
420        
421        boolean isValidated = isValidatedByDefault(content);
422        comment.setValidated(isValidated);
423        
424        content.saveChanges();
425        
426        if (isValidated)
427        {
428            Map<String, Object> eventParams = new HashMap<>();
429            eventParams.put(ObservationConstants.ARGS_CONTENT, content);
430            eventParams.put(ObservationConstants.ARGS_COMMENT, comment);
431            _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_COMMENT_VALIDATED, getCurrentUser(), eventParams));
432        }
433        else 
434        {
435            Map<String, Object> eventParams = new HashMap<>();
436            eventParams.put(ObservationConstants.ARGS_CONTENT, content);
437            eventParams.put(ObservationConstants.ARGS_COMMENT, comment);
438            _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_COMMENT_ADDED, getCurrentUser(), eventParams));
439        }
440        
441        Map<String, Object> results = new HashMap<>();
442        
443        results.put("published", comment.isValidated());
444        results.put("contentId", content.getId());
445        results.put("commentId", comment.getId());
446        
447        return results;
448    }
449    
450    /**
451     * Report a comment
452     * @param contentId the content id
453     * @param commentId the comment id
454     * @return the results
455     * @throws IllegalAccessException if anonymous or current user has no access to the content
456     */
457    @Callable (allowAnonymous = true, rights = Callable.READ_ACCESS, rightContext = ContentRightAssignmentContext.ID, paramIndex = 0)
458    public Map<String, Object> reportComment(String contentId, String commentId) throws IllegalAccessException
459    {
460        CommentableContent content = getContent(contentId);
461        Comment comment = content.getComment(commentId);
462        
463        comment.addReport();
464        content.saveChanges();
465        
466        Map<String, Object> eventParams = new HashMap<>();
467        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
468        eventParams.put(ObservationConstants.ARGS_COMMENT, comment);
469        _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_COMMENT_REPORTED, getCurrentUser(), eventParams));
470        
471        Map<String, Object> results = new HashMap<>();
472        results.put("reported", true);
473        results.put("contentId", content.getId());
474        results.put("commentId", comment.getId());
475        return results;
476    }
477    
478    /**
479     * Get the validation flag default value for a content asking all listeners
480     * @param content The content having a new comment
481     * @return a positive value if the comments have to be validated by default or a negative value in the other case. The absolute value is the priority of your listener. E.G. If a listener set +1 and another -10: the sum is negative (so comments not validated be default).
482     */
483    public boolean isValidatedByDefault(Content content)
484    {
485        boolean postValidation = Config.getInstance().getValue("cms.contents.comments.postvalidation");
486        return postValidation;
487    }
488    
489    /**
490     * Checks if a captcha have to be checked.
491     * @param content The content to comment
492     * @param formValues The form values
493     * @return true if the comments have to be protected by a captcha or false otherwise
494     */
495    public boolean isCaptchaRequired(Content content, Map<String, Object> formValues)
496    {
497        return false;
498    }   
499    
500    /**
501     * Get errors when submitting comment
502     * @param content The content to comment
503     * @param formValues The form values to submit a comment
504     * @return An list of error messages (empty if no errors)
505     */
506    public List<Map<String, Object>> getErrors(CommentableContent content, Map<String, Object> formValues)
507    {
508        List<Map<String, Object>> errors = new ArrayList<>();
509
510        String name = (String) formValues.get(FORM_AUTHOR_NAME);
511        if (StringUtils.isBlank(name))
512        {
513            Map<String, Object> error = new HashMap<>();
514            error.put("name", FORM_AUTHOR_NAME);
515            error.put("error", new I18nizableText("plugin.cms", "PLUGINS_CMS_CONTENT_COMMENTS_ADD_ERROR_NAME"));
516            errors.add(error);
517        }
518        
519        String email = (String) formValues.get(FORM_AUTHOR_EMAIL);
520        if (!SendMailHelper.EMAIL_VALIDATION.matcher(StringUtils.trimToEmpty(email)).matches())
521        {
522            Map<String, Object> error = new HashMap<>();
523            error.put("name", FORM_AUTHOR_EMAIL);
524            error.put("error", new I18nizableText("plugin.cms", "PLUGINS_CMS_CONTENT_COMMENTS_ADD_ERROR_EMAIL"));
525            errors.add(error);
526        }
527
528        String url = (String) formValues.get(FORM_AUTHOR_URL);
529        if (!URL_VALIDATOR.matcher(StringUtils.trimToEmpty(url)).matches())
530        {
531            Map<String, Object> error = new HashMap<>();
532            error.put("name", FORM_AUTHOR_URL);
533            error.put("error", new I18nizableText("plugin.cms", "PLUGINS_CMS_CONTENT_COMMENTS_ADD_ERROR_URL"));
534            errors.add(error);
535        }
536        
537        String text = (String) formValues.get(FORM_CONTENTTEXT);
538        if (StringUtils.isBlank(text))
539        {
540            Map<String, Object> error = new HashMap<>();
541            error.put("name", FORM_CONTENTTEXT);
542            error.put("error", new I18nizableText("plugin.cms", "PLUGINS_CMS_CONTENT_COMMENTS_ADD_ERROR_CONTENT"));
543            errors.add(error);
544        }
545        
546        if (isCaptchaRequired(content, formValues))
547        {
548            String captchaKey = (String) formValues.get(FORM_CAPTCHA_KEY);
549            String captchaValue = (String) formValues.get(FORM_CAPTCHA_VALUE);
550            if (!CaptchaHelper.checkAndInvalidate(captchaKey, captchaValue))
551            {
552                Map<String, Object> error = new HashMap<>();
553                error.put("name", FORM_CAPTCHA_VALUE);
554                error.put("error", new I18nizableText("plugin.cms", "PLUGINS_CMS_CONTENT_COMMENTS_ADD_ERROR_CAPTCHA"));
555                errors.add(error);
556            }
557        }
558
559        return errors;
560    }
561    /**
562     * Get the current user
563     * @return The current user
564     */
565    protected UserIdentity getCurrentUser()
566    {
567        return _userProvider.getUser();
568    }
569    
570    /**
571     * Get JSON representation of a comment
572     * @param content The content
573     * @param comment the comment
574     * @param level the level of comment (0 for parent comment, 1 for sub-comment, etc ....)
575     * @param contextualParameters the contextual parameters
576     * @return the comment as JSON
577     */
578    public Map<String, Object> getComment(Content content, Comment comment, int level, Map<String, Object> contextualParameters)
579    {
580        Map<String, Object> comment2json = new HashMap<>();
581
582        comment2json.put("id", comment.getId());
583        comment2json.put("content-id", content.getId());
584        comment2json.put("creation-date", comment.getCreationDate());
585        comment2json.put("level", level);
586        
587        comment2json.putAll(getCommentAuthor(comment, contextualParameters));
588        
589        if (comment.getContent() != null)
590        {
591            String commentContent = comment.getContent();
592            comment2json.put("content", commentContent);
593            
594            List<Map<String, Object>> mentionedUsers2json = comment.extractMentions()
595                                                                   .stream()
596                                                                   .map(userIdentity -> getUserPropertiesFromIdentity(userIdentity, contextualParameters))
597                                                                   .toList();
598            if (!mentionedUsers2json.isEmpty())
599            {
600                comment2json.put("mentions", mentionedUsers2json);
601            }
602        }
603        
604        List<Map<String, Object>> reactions2json = _reactionableHelper.reactionsToJson(comment);
605        comment2json.put("reactions", reactions2json);
606        
607        comment2json.put("reports", comment.getReportsCount());
608
609        List<Comment> subComments = comment.getSubComment(false, true);
610        if (!subComments.isEmpty())
611        {
612            List<Map<String, Object>> subComments2json = new ArrayList<>();
613            for (Comment subComment : subComments)
614            {
615                subComments2json.add(getComment(content, subComment, level + 1, contextualParameters));
616            }
617            comment2json.put("sub-comments", subComments2json); 
618        }
619        
620        comment2json.put("canDelete", canDeleteComment(content, comment));
621        
622        return comment2json;
623    }
624    
625    /**
626     * Get JSON representation of a comment's author
627     * @param comment the comment
628     * @param contextualParameters the contextual parameters
629     * @return the comment's author as JSON
630     */
631    protected Map<String, Object> getCommentAuthor(Comment comment, Map<String, Object> contextualParameters)
632    {
633        Map<String, Object> author2json = new HashMap<>();
634        
635        UserIdentity authorIdentity = comment.getAuthor();
636        if (authorIdentity != null)
637        {
638            Map<String, Object> user2json = Optional.ofNullable(_userManager.getUser(authorIdentity))
639                                                    .map(author -> getUserProperties(author, contextualParameters))
640                                                    .orElseGet(() -> getUserIdentityProperties(authorIdentity, contextualParameters));
641            author2json.put("author", user2json);
642        }
643        else
644        {
645            String authorName = comment.getAuthorName();
646            if (StringUtils.isNotBlank(authorName))
647            {
648                author2json.put("author-name", authorName);
649            }
650
651            String authorEmail = comment.getAuthorEmail();
652            if (StringUtils.isNotBlank(authorEmail))
653            {
654                if (!comment.isEmailHidden())
655                {
656                    author2json.put("author-email", authorEmail);
657                }
658                
659                getUserByEmail(authorEmail, contextualParameters)
660                    .map(author -> getUserProperties(author, contextualParameters))
661                    .ifPresent(json -> author2json.put("author", json));
662            }
663
664            String authorURL = comment.getAuthorURL();
665            if (StringUtils.isNotBlank(authorURL))
666            {
667                author2json.put("author-url", authorURL);
668            }
669        }
670        
671        return author2json;
672    }
673    
674    /**
675     * Get the author as a User if exists
676     * @param userEmail the email of the user to retrieve
677     * @param contextualParameters the contextual parameters
678     * @return the user or null
679     */
680    protected Optional<User> getUserByEmail(String userEmail, Map<String, Object> contextualParameters)
681    {
682        Request request = ContextHelper.getRequest(_context);
683        
684        Set<String> userPopulationsOnSite = _populationContextHelper.getUserPopulationsOnContexts(getUserPopulationsContexts(request, contextualParameters), false, false);
685        
686        try
687        {
688            return Optional.ofNullable(_userManager.getUserByEmail(userPopulationsOnSite, userEmail));
689        }
690        catch (NotUniqueUserException e)
691        {
692            getLogger().error("Cannot find user because 2 or more users match", e);
693            return Optional.empty();
694        }
695    }
696    
697    /**
698     * Get JSON representation of a user from its identity. If the user does not exist, get all known identity properties
699     * @param userIdentity the user identity
700     * @param contextualParameters the contextual parameters
701     * @return the user as JSON
702     */
703    protected Map<String, Object> getUserPropertiesFromIdentity(UserIdentity userIdentity, Map<String, Object> contextualParameters)
704    {
705        return Optional.ofNullable(_userManager.getUser(userIdentity))
706                       .map(user -> getUserProperties(user, contextualParameters))
707                       .orElseGet(() -> getUserIdentityProperties(userIdentity, contextualParameters));
708    }
709    
710    /**
711     * Get JSON representation of a user
712     * @param user the user
713     * @param contextualParameters the contextual parameters
714     * @return the user as JSON
715     */
716    protected Map<String, Object> getUserProperties(User user, Map<String, Object> contextualParameters)
717    {
718        Map<String, Object> user2json = _userHelper.user2json(user, true);
719        user2json.put("imgUrl", ProfileImageResolverHelper.resolve(user.getIdentity().getLogin(), user.getIdentity().getPopulationId(), 64, null));
720        return user2json;
721    }
722    
723    /**
724     * Get JSON representation of a user identity. Put all known data in the json result
725     * @param userIdentity the user identity
726     * @param contextualParameters the contextual parameters
727     * @return the user identity as JSON
728     */
729    protected Map<String, Object> getUserIdentityProperties(UserIdentity userIdentity, Map<String, Object> contextualParameters)
730    {
731        Map<String, Object> user2json = new HashMap<>();
732        
733        user2json.put("login", userIdentity.getLogin());
734        user2json.put("populationId", userIdentity.getPopulationId());
735
736        UserPopulation userPopulation = _userPopulationDAO.getUserPopulation(userIdentity.getPopulationId());
737        if (userPopulation != null)
738        {
739            user2json.put("populationLabel", userPopulation.getLabel());
740        }
741        
742        return user2json;
743    }
744    
745    /**
746     * Get the user population contexts
747     * @param request The request
748     * @param contextualParameters The contextual parameters
749     * @return The contexts
750     * @throws IllegalArgumentException If there is no context set
751     */
752    protected List<String> getUserPopulationsContexts(Request request, Map<String, Object> contextualParameters)
753    {
754        return List.of("/application");
755    }
756    
757    /**
758     * SAX a comment
759     * @param contentHandler the content handler
760     * @param comment the comment
761     * @param level the level of comment
762     * @param contextualParameters the contextual parameters
763     * @throws SAXException if an error occurred hile saxing
764     */
765    public void saxComment(ContentHandler contentHandler, Comment comment, int level, Map<String, Object> contextualParameters) throws SAXException
766    {
767        AttributesImpl attrs = new AttributesImpl();
768
769        attrs.addCDATAAttribute("id", comment.getId());
770        attrs.addCDATAAttribute("creation-date", DateUtils.zonedDateTimeToString(comment.getCreationDate()));
771        attrs.addCDATAAttribute("level", String.valueOf(level));
772        attrs.addCDATAAttribute("is-validated", String.valueOf(comment.isValidated()));
773        attrs.addCDATAAttribute("is-email-hidden", String.valueOf(comment.isEmailHidden()));
774        
775        UserIdentity authorIdentity = comment.getAuthor();
776        if (authorIdentity == null)
777        {
778            String authorName = comment.getAuthorName();
779            if (StringUtils.isNotBlank(authorName))
780            {
781                attrs.addCDATAAttribute("author-name", authorName);
782            }
783            
784            String authorEmail = comment.getAuthorEmail();
785            if (!comment.isEmailHidden() && StringUtils.isNotBlank(authorEmail))
786            {
787                attrs.addCDATAAttribute("author-email", authorEmail);
788            }
789    
790            String authorURL = comment.getAuthorURL();
791            if (!StringUtils.isBlank(authorURL))
792            {
793                attrs.addCDATAAttribute("author-url", authorURL);
794            }
795        }
796
797        XMLUtils.startElement(contentHandler, "comment", attrs);
798        
799        if (authorIdentity != null)
800        {
801            User author = _userManager.getUser(authorIdentity);
802            if (author != null)
803            {
804                _userHelper.saxUser(author, contentHandler, "author");
805            }
806            else
807            {
808                // If author does not exist anymore, still generate sax event for its identity
809                saxUserIdentity(contentHandler, authorIdentity, "author");
810            }
811        }
812        else
813        {
814            String authorEmail = comment.getAuthorEmail();
815            if (StringUtils.isNotBlank(authorEmail))
816            {
817                Optional<User> author = getUserByEmail(authorEmail, Map.of());
818                if (author.isPresent())
819                {
820                    _userHelper.saxUser(author.get(), contentHandler, "author");
821                }
822            }
823        }
824
825        if (comment.getContent() != null)
826        {
827            String[] contents = comment.getContent().split("\r?\n");
828            for (String c : contents)
829            {
830                XMLUtils.createElement(contentHandler, "p", c);
831            }
832        }
833        
834        // The generated SAXed events for the comments' reaction have changed.
835        // In the SAXed events of the ContentGenerator (that should one day use this ContentSaxer):
836        // - there is a "nb-like" attributes on the comment node that disappears here
837        // - reactions are SAXed as a simple list of ("likers")
838        _reactionableHelper.saxReactions(comment, contentHandler);
839        
840        ReportableObjectHelper.saxReports(comment, contentHandler);
841        
842        // Additional properties
843        saxCommentAdditionalProperties(contentHandler, comment, level, contextualParameters);
844        
845        List<Comment> subComments = comment.getSubComment(false, true);
846        if (!subComments.isEmpty())
847        {
848            XMLUtils.startElement(contentHandler, "sub-comments");
849            
850            for (Comment subComment : subComments)
851            {
852                saxComment(contentHandler, subComment, level + 1, contextualParameters);
853            }
854            
855            XMLUtils.endElement(contentHandler, "sub-comments");
856        }
857
858        XMLUtils.endElement(contentHandler, "comment");
859    }
860    
861    /**
862     * Generate SAX events for all known data of the given user identity
863     * @param contentHandler the content handler
864     * @param userIdentity the user identity
865     * @param tagName The XML tag for saxed user
866     * @throws SAXException if an error occurred while generating SAX events
867     */
868    protected void saxUserIdentity(ContentHandler contentHandler, UserIdentity userIdentity, String tagName) throws SAXException
869    {
870        AttributesImpl attr = new AttributesImpl();
871        attr.addCDATAAttribute("login", userIdentity.getLogin());
872        attr.addCDATAAttribute("population", userIdentity.getPopulationId());
873        
874        XMLUtils.startElement(contentHandler, tagName, attr);
875        
876        UserPopulation userPopulation = _userPopulationDAO.getUserPopulation(userIdentity.getPopulationId());
877        if (userPopulation != null)
878        {
879            userPopulation.getLabel().toSAX(contentHandler, "populationLabel");
880        }
881        
882        XMLUtils.endElement(contentHandler, tagName);
883    }
884    
885    /**
886     * SAX additional comment properties
887     * @param contentHandler the content handler
888     * @param comment the comment
889     * @param level the level of comment
890     * @param contextualParameters the contextual parameters
891     * @throws SAXException if an error occurred while saxing
892     */
893    protected void saxCommentAdditionalProperties(ContentHandler contentHandler, Comment comment, int level, Map<String, Object> contextualParameters) throws SAXException
894    {
895        // Nothing
896    }
897}