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