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}