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.regex.Pattern;
023
024import org.apache.avalon.framework.component.Component;
025import org.apache.avalon.framework.context.Context;
026import org.apache.avalon.framework.context.ContextException;
027import org.apache.avalon.framework.context.Contextualizable;
028import org.apache.avalon.framework.service.ServiceException;
029import org.apache.avalon.framework.service.ServiceManager;
030import org.apache.avalon.framework.service.Serviceable;
031import org.apache.cocoon.components.ContextHelper;
032import org.apache.cocoon.environment.Request;
033import org.apache.cocoon.xml.AttributesImpl;
034import org.apache.cocoon.xml.XMLUtils;
035import org.apache.commons.lang.StringUtils;
036import org.xml.sax.ContentHandler;
037import org.xml.sax.SAXException;
038
039import org.ametys.cms.ObservationConstants;
040import org.ametys.cms.repository.Content;
041import org.ametys.cms.repository.ReactionableObject.ReactionType;
042import org.ametys.cms.repository.ReactionableObjectHelper;
043import org.ametys.cms.repository.ReportableObjectHelper;
044import org.ametys.core.captcha.CaptchaHelper;
045import org.ametys.core.observation.Event;
046import org.ametys.core.observation.ObservationManager;
047import org.ametys.core.right.RightManager;
048import org.ametys.core.right.RightManager.RightResult;
049import org.ametys.core.ui.Callable;
050import org.ametys.core.user.CurrentUserProvider;
051import org.ametys.core.user.User;
052import org.ametys.core.user.UserIdentity;
053import org.ametys.core.user.UserManager;
054import org.ametys.core.util.DateUtils;
055import org.ametys.core.util.mail.SendMailHelper;
056import org.ametys.plugins.core.user.UserHelper;
057import org.ametys.plugins.repository.AmetysObjectResolver;
058import org.ametys.plugins.repository.metadata.UnknownMetadataException;
059import org.ametys.plugins.repository.provider.RequestAttributeWorkspaceSelector;
060import org.ametys.runtime.config.Config;
061import org.ametys.runtime.i18n.I18nizableText;
062import org.ametys.runtime.plugin.component.AbstractLogEnabled;
063
064/**
065 * DAO for content's comments
066 *
067 */
068public class CommentsDAO extends AbstractLogEnabled implements Component, Serviceable, Contextualizable
069{
070    /** The Avalon role */
071    public static final String ROLE = CommentsDAO.class.getName();
072    
073    /** The form input name for author name */
074    public static final String FORM_AUTHOR_NAME = "name";
075    /** The form input name for author email */
076    public static final String FORM_AUTHOR_EMAIL = "email";
077    /** The form input name for author email to hide */
078    public static final String FORM_AUTHOR_HIDEEMAIL = "hide-email";
079    /** The form input name for author url */
080    public static final String FORM_AUTHOR_URL = "url";
081    /** The form input name for content */
082    public static final String FORM_CONTENTTEXT = "text";
083    /** The form input name for captcha */
084    public static final String FORM_CAPTCHA_KEY = "captcha-key";
085    /** The form input name for captcha */
086    public static final String FORM_CAPTCHA_VALUE = "captcha-value";
087    
088    /** The pattern to check url */
089    public static final Pattern URL_VALIDATOR = Pattern.compile("^(https?:\\/\\/.+)?$");
090    
091    /** The Ametys object resolver */
092    protected AmetysObjectResolver _resolver;
093    /** The observation manager */
094    protected ObservationManager _observationManager;
095    /** The currenrt user provider */
096    protected CurrentUserProvider _userProvider;
097    /** The user helper */
098    protected UserHelper _userHelper;
099    /** The right manager */
100    protected RightManager _rightManager;
101    /** Helper for reactionable object */
102    protected ReactionableObjectHelper _reactionableHelper;
103    /** The user manager */
104    protected UserManager _userManager;
105    
106    /** The avalon context */
107    protected Context _context;
108
109    public void service(ServiceManager smanager) throws ServiceException
110    {
111        _resolver = (AmetysObjectResolver) smanager.lookup(AmetysObjectResolver.ROLE);
112        _userProvider = (CurrentUserProvider) smanager.lookup(CurrentUserProvider.ROLE);
113        _observationManager = (ObservationManager) smanager.lookup(ObservationManager.ROLE);
114        _userHelper = (UserHelper) smanager.lookup(UserHelper.ROLE);
115        _rightManager = (RightManager) smanager.lookup(RightManager.ROLE);
116        _reactionableHelper = (ReactionableObjectHelper) smanager.lookup(ReactionableObjectHelper.ROLE);
117        _userManager = (UserManager) smanager.lookup(UserManager.ROLE);
118    }
119    
120    public void contextualize(Context context) throws ContextException
121    {
122        _context = context;
123    }
124    
125    /**
126     * Get the content's comments
127     * @param contentId the content id
128     * @param contextualParameters the contextual parameters
129     * @return the comments
130     */
131    public List<Map<String, Object>> getComments(String contentId, Map<String, Object> contextualParameters)
132    {
133        Content content = _resolver.resolveById(contentId);
134        
135        List<Map<String, Object>> comments2json = new ArrayList<>();
136        
137        if (content instanceof CommentableContent)
138        {
139            CommentableContent cContent = (CommentableContent) content;
140            
141            List<Comment> comments = cContent.getComments(false, true);
142            for (Comment comment : comments)
143            {
144                comments2json.add(getComment(comment, 0, contextualParameters));
145            }
146        }   
147        
148        return comments2json;
149    }
150    
151    /**
152     * Add a new comment
153     * @param contentId the content id
154     * @param commentId the id of of parent comment. Can be null if it is not a subcomment
155     * @param formValues the form's values
156     * @param contextualParameters the contextual parameters
157     * @return the results
158     */
159    @Callable
160    public Map<String, Object> addComment(String contentId, String commentId, Map<String, Object> formValues, Map<String, Object> contextualParameters)
161    {
162        CommentableContent cContent = getContent(contentId);
163
164        List<I18nizableText> errors = getErrors(cContent, formValues);
165
166        if (errors.isEmpty())
167        {
168            Map<String, Object> results = new HashMap<>();
169            
170            if (StringUtils.isNotBlank(commentId))
171            {
172                Comment comment = cContent.getComment(commentId);
173                Comment subComment = comment.createSubComment();
174                results = _setCommentAttributes(cContent, subComment, formValues);
175                results.put("comment", getComment(subComment, 1, contextualParameters));
176            }
177            else
178            {
179                Comment comment = cContent.createComment();
180                results = _setCommentAttributes(cContent, comment, formValues);
181                results.put("comment", getComment(comment, 0, contextualParameters));
182            }
183            
184            return results;
185        }
186        else
187        {
188            Map<String, Object> results = new HashMap<>();
189            results.put("errors", errors);
190            return results;
191        }
192    }
193    
194    /**
195     * Delete a comment
196     * @param contentId the content id
197     * @param commentId the comment id to remove
198     * @return the results
199     * @throws IllegalAccessException if current user is null
200     */
201    @Callable
202    public Map<String, Object> deleteComment(String contentId, String commentId) throws IllegalAccessException
203    {
204        CommentableContent content = getContent(contentId);
205        Comment comment = content.getComment(commentId);
206        
207        UserIdentity currentUser = getCurrentUser();
208        if (currentUser == null)
209        {
210            throw new IllegalAccessException("Anonymous user can not delete a comment");
211        }
212        
213        User user = _userManager.getUser(currentUser);
214        String authorEmail = comment.getAuthorEmail();
215        boolean isUserOwnComment = user != null  && StringUtils.equals(authorEmail, user.getEmail());
216        
217        Map<String, Object> results = new HashMap<>();
218        
219        if (isUserOwnComment || _rightManager.hasRight(currentUser, "CMS_Rights_CommentModerate", content).equals(RightResult.RIGHT_ALLOW))
220        {
221            try
222            {
223                Map<String, Object> eventParams = new HashMap<>();
224                eventParams.put(ObservationConstants.ARGS_CONTENT, content);
225                eventParams.put(ObservationConstants.ARGS_COMMENT_ID, comment.getId());
226                eventParams.put(ObservationConstants.ARGS_COMMENT_AUTHOR, comment.getAuthorName());
227                eventParams.put(ObservationConstants.ARGS_COMMENT_AUTHOR_EMAIL, comment.getAuthorEmail());
228                eventParams.put(ObservationConstants.ARGS_COMMENT_VALIDATED, comment.isValidated());
229                eventParams.put("comment.content", comment.getContent());
230                
231                _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_COMMENT_DELETING, getCurrentUser(), eventParams));
232                
233                comment.remove();
234                content.saveChanges();
235                
236                _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_COMMENT_DELETED, getCurrentUser(), eventParams));
237    
238                results.put("deleted", true);
239            }
240            catch (UnknownMetadataException e)
241            {
242                getLogger().error("Can not remove a non existing comment", e);
243                results.put("deleted", false);
244            }
245        }
246        else
247        {
248            results.put("deleted", false);
249        }
250        
251        results.put("contentId", content.getId());
252        results.put("commentId", comment.getId());
253        
254        return results;
255    }
256    
257    /**
258     * Determines if current user is allowed to delete a comment 
259     * @param contentId the content id
260     * @return true if the current user is allowed
261     */
262    @Callable
263    public boolean canDeleteComment(String contentId)
264    {
265        CommentableContent content = getContent(contentId);
266        UserIdentity currentUser = getCurrentUser();
267        return currentUser != null && _rightManager.hasRight(currentUser, "CMS_Rights_CommentModerate", content).equals(RightResult.RIGHT_ALLOW);
268    }
269
270    /**
271     * React (like or unlike) to a comment
272     * @param contentId the content id
273     * @param commentId the comment id
274     * @return the results
275     * @throws IllegalAccessException if current user is null
276     */
277    @Callable
278    public Map<String, Object> likeOrUnlikeComment(String contentId, String commentId) throws IllegalAccessException
279    {
280        return likeOrUnlikeComment(contentId, commentId, null);
281    }
282    
283    /**
284     * React (like or unlike) to a comment
285     * @param contentId the content id
286     * @param commentId the comment id
287     * @param remove true to remove the reaction, false to add reaction. If null, check if the current user has already like the comment.
288     * @return the results
289     * @throws IllegalAccessException if current user is null
290     */
291    @Callable
292    public Map<String, Object> likeOrUnlikeComment(String contentId, String commentId, Boolean remove) throws IllegalAccessException
293    {
294        Map<String, Object> results = new HashMap<>();
295        
296        CommentableContent content = getContent(contentId);
297        Comment comment = content.getComment(commentId);
298        
299        UserIdentity currentUser = getCurrentUser();
300        if (currentUser == null)
301        {
302            throw new IllegalAccessException("Anonymous user can not react to a comment");
303        }
304        
305        
306        if (Boolean.TRUE.equals(remove) || (remove == null && comment.getReactionUsers(ReactionType.LIKE).contains(currentUser)))
307        {
308            comment.removeReaction(currentUser, ReactionType.LIKE);
309            results.put("liked", false);
310        }
311        else
312        {
313            comment.addReaction(currentUser, ReactionType.LIKE);
314            results.put("liked", true);
315        }
316        
317        content.saveChanges();
318        
319        Map<String, Object> eventParams = new HashMap<>();
320        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
321        eventParams.put(ObservationConstants.ARGS_COMMENT, comment);
322        eventParams.put(ObservationConstants.ARGS_REACTION_TYPE, ReactionType.LIKE);
323        eventParams.put(ObservationConstants.ARGS_REACTION_ISSUER, currentUser);
324        _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_COMMENT_REACTION_CHANGED, getCurrentUser(), eventParams));
325        
326        results.put("contentId", content.getId());
327        results.put("commentId", comment.getId());
328        return results;
329    }
330
331    /**
332     * Get the commentable content
333     * @param contentId The content id
334     * @return The content
335     */
336    protected CommentableContent getContent (String contentId)
337    {
338        Request request = ContextHelper.getRequest(_context);
339        String currentWorkspace = RequestAttributeWorkspaceSelector.getForcedWorkspace(request);
340
341        try
342        {
343            RequestAttributeWorkspaceSelector.setForcedWorkspace(request, "default");
344            return _resolver.resolveById(contentId);
345        }
346        finally
347        {
348            RequestAttributeWorkspaceSelector.setForcedWorkspace(request, currentWorkspace);
349        }
350    }
351    
352    /**
353     * Set comment attributes
354     * @param content the content
355     * @param comment the comment
356     * @param formValues the form's values
357     * @return the result
358     */
359    protected Map<String, Object> _setCommentAttributes(CommentableContent content, Comment comment, Map<String, Object> formValues)
360    {
361        String authorName = (String) formValues.get(FORM_AUTHOR_NAME);
362        String authorEmail = (String) formValues.get(FORM_AUTHOR_EMAIL);
363        String authorUrl = (String) formValues.get(FORM_AUTHOR_URL);
364        String text = (String) formValues.get(FORM_CONTENTTEXT);
365        boolean hideEmail = formValues.containsKey(FORM_AUTHOR_HIDEEMAIL) && Boolean.TRUE.equals(formValues.get(FORM_AUTHOR_HIDEEMAIL));
366        
367        comment.setAuthorName(authorName);
368        comment.setAuthorEmail(authorEmail);
369        comment.setEmailHiddenStatus(hideEmail);
370        comment.setAuthorURL(authorUrl);
371        comment.setContent(text.replaceAll("\r", ""));
372        
373        boolean isValidated = isValidatedByDefault(content);
374        comment.setValidated(isValidated);
375        
376        content.saveChanges();
377        
378        if (isValidated)
379        {
380            Map<String, Object> eventParams = new HashMap<>();
381            eventParams.put(ObservationConstants.ARGS_CONTENT, content);
382            eventParams.put(ObservationConstants.ARGS_COMMENT, comment);
383            _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_COMMENT_VALIDATED, getCurrentUser(), eventParams));
384        }
385        else 
386        {
387            Map<String, Object> eventParams = new HashMap<>();
388            eventParams.put(ObservationConstants.ARGS_CONTENT, content);
389            eventParams.put(ObservationConstants.ARGS_COMMENT, comment);
390            _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_COMMENT_ADDED, getCurrentUser(), eventParams));
391        }
392        
393        Map<String, Object> results = new HashMap<>();
394        
395        results.put("published", comment.isValidated());
396        results.put("contentId", content.getId());
397        results.put("commentId", comment.getId());
398        
399        return results;
400    }
401    
402    /**
403     * Report a comment
404     * @param contentId the content id
405     * @param commentId the comment id
406     * @return the results
407     */
408    @Callable
409    public Map<String, Object> reportComment(String contentId, String commentId)
410    {
411        CommentableContent content = getContent(contentId);
412        Comment comment = content.getComment(commentId);
413        
414        comment.addReport();
415        content.saveChanges();
416        
417        Map<String, Object> eventParams = new HashMap<>();
418        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
419        eventParams.put(ObservationConstants.ARGS_COMMENT, comment);
420        _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_COMMENT_REPORTED, getCurrentUser(), eventParams));
421        
422        Map<String, Object> results = new HashMap<>();
423        results.put("reported", true);
424        results.put("contentId", content.getId());
425        results.put("commentId", comment.getId());
426        return results;
427    }
428    
429    /**
430     * Get the validation flag default value for a content asking all listeners
431     * @param content The content having a new comment
432     * @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).
433     */
434    public boolean isValidatedByDefault(Content content)
435    {
436        boolean postValidation = Config.getInstance().getValue("cms.contents.comments.postvalidation");
437        return postValidation;
438    }
439    
440    /**
441     * Checks if a captcha have to be checked.
442     * @param content The content to comment
443     * @return true if the comments have to be protected by a captcha or false otherwise
444     */
445    public boolean isCaptchaRequired(Content content)
446    {
447        return false;
448    }   
449    
450    /**
451     * Get errors when submitting comment
452     * @param content The content to comment
453     * @param formValues The form values to submit a comment
454     * @return An list of error messages (empty if no errors)
455     */
456    public List<I18nizableText> getErrors(CommentableContent content, Map<String, Object> formValues)
457    {
458        List<I18nizableText> errors = new ArrayList<>();
459
460        String name = (String) formValues.get(FORM_AUTHOR_NAME);
461        if (StringUtils.isBlank(name))
462        {
463            errors.add(new I18nizableText("plugin.cms", "PLUGINS_CMS_CONTENT_COMMENTS_ADD_ERROR_NAME"));
464        }
465        
466        String email = (String) formValues.get(FORM_AUTHOR_EMAIL);
467        if (!SendMailHelper.EMAIL_VALIDATION.matcher(StringUtils.trimToEmpty(email)).matches())
468        {
469            errors.add(new I18nizableText("plugin.cms", "PLUGINS_CMS_CONTENT_COMMENTS_ADD_ERROR_EMAIL"));
470        }
471
472        String url = (String) formValues.get(FORM_AUTHOR_URL);
473        if (!URL_VALIDATOR.matcher(StringUtils.trimToEmpty(url)).matches())
474        {
475            errors.add(new I18nizableText("plugin.cms", "PLUGINS_CMS_CONTENT_COMMENTS_ADD_ERROR_URL"));
476        }
477        
478        String text = (String) formValues.get(FORM_CONTENTTEXT);
479        if (StringUtils.isBlank(text))
480        {
481            errors.add(new I18nizableText("plugin.cms", "PLUGINS_CMS_CONTENT_COMMENTS_ADD_ERROR_CONTENT"));
482        }
483        
484        if (isCaptchaRequired(content))
485        {
486            String captchaKey = (String) formValues.get(FORM_CAPTCHA_KEY);
487            String captchaValue = (String) formValues.get(FORM_CAPTCHA_VALUE);
488            if (!CaptchaHelper.checkAndInvalidate(captchaKey, captchaValue))
489            {
490                errors.add(new I18nizableText("plugin.cms", "plugin.cms:PLUGINS_CMS_CONTENT_COMMENTS_ADD_ERROR_CAPTCHA"));
491            }
492        }
493        
494        if (!_rightManager.hasReadAccess(getCurrentUser(), content))
495        {
496            errors.add(new I18nizableText("plugin.web", "PLUGINS_WEB_CONTENT_COMMENTS_ADD_ERROR_RIGHTS"));
497            return errors;
498        }
499
500        return errors;
501    }
502    /**
503     * Get the current user
504     * @return The current user
505     */
506    protected UserIdentity getCurrentUser()
507    {
508        return _userProvider.getUser();
509    }
510    
511    /**
512     * Get JSON representation of a comment
513     * @param comment the comment
514     * @param level the level of comment (0 for parent comment, 1 for sub-comment, etc ....)
515     * @param contextualParameters the contextual parameters
516     * @return the comment as JSON
517     */
518    public Map<String, Object> getComment(Comment comment, int level, Map<String, Object> contextualParameters)
519    {
520        Map<String, Object> comment2json = new HashMap<>();
521
522        comment2json.put("id", comment.getId());
523        comment2json.put("creation-date", comment.getCreationDate());
524        comment2json.put("level", level);
525        
526        if (!StringUtils.isBlank(comment.getAuthorName()))
527        {
528            comment2json.put("author-name", comment.getAuthorName());
529        }
530        
531        if (!comment.isEmailHidden() && !StringUtils.isBlank(comment.getAuthorEmail()))
532        {
533            comment2json.put("author-email", comment.getAuthorEmail());
534        }
535
536        if (!StringUtils.isBlank(comment.getAuthorURL()))
537        {
538            comment2json.put("author-url", comment.getAuthorURL());
539        }
540
541        if (comment.getContent() != null)
542        {
543            comment2json.put("content", comment.getContent());
544        }
545        
546        List<Map<String, Object>> reactions2json = _reactionableHelper.reactionsToJson(comment);
547        comment2json.put("reactions", reactions2json);
548        
549        comment2json.put("reports", comment.getReportsCount());
550
551        List<Comment> subComments = comment.getSubComment(false, true);
552        if (!subComments.isEmpty())
553        {
554            List<Map<String, Object>> subComments2json = new ArrayList<>();
555            for (Comment subComment : subComments)
556            {
557                subComments2json.add(getComment(subComment, level + 1, contextualParameters));
558            }
559            comment2json.put("sub-comments", subComments2json); 
560        }
561        
562        return comment2json;
563    }
564    
565    /**
566     * SAX a comment
567     * @param contentHandler the content handler
568     * @param comment the comment
569     * @param level the level of comment
570     * @throws SAXException if an error occurred hile saxing
571     */
572    public void saxComment(ContentHandler contentHandler, Comment comment, int level) throws SAXException
573    {
574        AttributesImpl attrs = new AttributesImpl();
575
576        attrs.addCDATAAttribute("id", comment.getId());
577        attrs.addCDATAAttribute("creation-date", DateUtils.zonedDateTimeToString(comment.getCreationDate()));
578        attrs.addCDATAAttribute("level", String.valueOf(level));
579        attrs.addCDATAAttribute("is-validated", String.valueOf(comment.isValidated()));
580        attrs.addCDATAAttribute("is-email-hidden", String.valueOf(comment.isEmailHidden()));
581        
582        if (!StringUtils.isBlank(comment.getAuthorName()))
583        {
584            attrs.addCDATAAttribute("author-name", comment.getAuthorName());
585        }
586        
587        if (!comment.isEmailHidden() && !StringUtils.isBlank(comment.getAuthorEmail()))
588        {
589            attrs.addCDATAAttribute("author-email", comment.getAuthorEmail());
590        }
591
592        if (!StringUtils.isBlank(comment.getAuthorURL()))
593        {
594            attrs.addCDATAAttribute("author-url", comment.getAuthorURL());
595        }
596
597        XMLUtils.startElement(contentHandler, "comment", attrs);
598
599        if (comment.getContent() != null)
600        {
601            String[] contents = comment.getContent().split("\r?\n");
602            for (String c : contents)
603            {
604                XMLUtils.createElement(contentHandler, "p", c);
605            }
606        }
607        
608        // The generated SAXed events for the comments' reaction have changed.
609        // In the SAXed events of the ContentGenerator (that should one day use this ContentSaxer):
610        // - there is a "nb-like" attributes on the comment node that disappears here
611        // - reactions are SAXed as a simple list of ("likers")
612        _reactionableHelper.saxReactions(comment, contentHandler);
613        
614        ReportableObjectHelper.saxReports(comment, contentHandler);
615        
616        // Additional properties
617        saxCommentAdditionalProperties(contentHandler, comment, level);
618        
619        List<Comment> subComments = comment.getSubComment(false, true);
620        if (!subComments.isEmpty())
621        {
622            XMLUtils.startElement(contentHandler, "sub-comments");
623            
624            for (Comment subComment : subComments)
625            {
626                saxComment(contentHandler, subComment, level);
627            }
628            
629            XMLUtils.endElement(contentHandler, "sub-comments");
630        }
631
632        XMLUtils.endElement(contentHandler, "comment");
633    }
634    
635    /**
636     * SAX additional comment properties
637     * @param contentHandler the content handler
638     * @param comment the comment
639     * @param level the level of comment
640     * @throws SAXException if an error occurred hile saxing
641     */
642    protected void saxCommentAdditionalProperties(ContentHandler contentHandler, Comment comment, int level) throws SAXException
643    {
644        // Nothing
645    }
646}