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