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}