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