diff --git a/news/274.feature b/news/274.feature new file mode 100644 index 00000000..e19df38e --- /dev/null +++ b/news/274.feature @@ -0,0 +1 @@ +Implement content filtering for comments with configurable words @rohnsha0 \ No newline at end of file diff --git a/src/plone/app/discussion/browser/comments.py b/src/plone/app/discussion/browser/comments.py index e7f82293..7da0a565 100644 --- a/src/plone/app/discussion/browser/comments.py +++ b/src/plone/app/discussion/browser/comments.py @@ -1,5 +1,7 @@ from AccessControl import getSecurityManager from AccessControl import Unauthorized +from AccessControl.SecurityManagement import newSecurityManager +from AccessControl.users import SimpleUser from Acquisition import aq_inner from DateTime import DateTime from plone.app.discussion import _ @@ -260,6 +262,28 @@ def handleComment(self, action): ) captcha.validate(data["captcha"]) + from plone.app.discussion.filter import get_content_filter + + content_filter = get_content_filter(context=self.context, request=self.request) + + if content_filter.is_enabled(): + filter_result = content_filter.check_content(data.get("text", "")) + + if filter_result["filtered"]: + action = filter_result["action"] + + if action == "reject": + # Reject the comment immediately + message = content_filter.get_rejection_message( + filter_result["matches"] + ) + IStatusMessage(self.request).add(message, type="error") + return + else: + # Store action for post-creation handling + data["_content_filter_action"] = action + data["_content_filter_matches"] = filter_result["matches"] + # Create comment comment = self.create_comment(data) @@ -274,6 +298,19 @@ def handleComment(self, action): # Add a comment to the conversation comment_id = conversation.addComment(comment) + # Get the actual comment object from the conversation + # This is important because the workflow operations need the persistent object + comment = conversation.get(comment_id) + + # Handle content filtering actions post-creation + filter_action = data.get("_content_filter_action") + + if filter_action and self._handle_content_filter_action( + comment, filter_action, content_filter + ): + self.request.response.redirect(self.action) + return + # Redirect after form submit: # If a user posts a comment and moderation is enabled, a message is # shown to the user that his/her comment awaits moderation. If the user @@ -281,11 +318,8 @@ def handleComment(self, action): # to the comment. can_review = getSecurityManager().checkPermission("Review comments", context) workflowTool = getToolByName(context, "portal_workflow") - comment_review_state = workflowTool.getInfoFor( - comment, - "review_state", - None, - ) + comment_review_state = workflowTool.getInfoFor(comment, "review_state", None) + if comment_review_state == "pending" and not can_review: # Show info message when comment moderation is enabled IStatusMessage(self.context.REQUEST).addStatusMessage( @@ -302,6 +336,63 @@ def handleCancel(self, action): # a cancel button that is handled by a jQuery method. pass # pragma: no cover + def _handle_content_filter_action(self, comment, action, content_filter): + """Handle content filter actions (spam/moderate) for a comment.""" + context = aq_inner(self.context) + workflowTool = getToolByName(context, "portal_workflow") + + if action == "spam": + try: + # Check if workflow supports spam state + comment_workflow = workflowTool.getWorkflowsFor(comment) + if comment_workflow and "spam" in comment_workflow[0].states: + # Use Zope security manager for privileged operations + # Store current security manager + old_security_manager = getSecurityManager() + + try: + # Create a temporary security manager with Manager role + user = SimpleUser("system", "", ["Manager"], []) + user = user.__of__(self.context.acl_users) + newSecurityManager(None, user) + + # Perform the workflow action with elevated privileges + workflowTool.doActionFor(comment, "mark_as_spam") + comment.reindexObject() + finally: + # Restore original security manager + newSecurityManager(None, old_security_manager.getUser()) + + message = _( + "comment_marked_spam_by_filter", + default="Your comment has been marked as spam due to filtered content.", + ) + IStatusMessage(self.request).addStatusMessage( + message, type="warning" + ) + return True + except Exception: + # Fallback to moderation if spam action fails + action = "moderate" + + if action == "moderate": + try: + current_state = workflowTool.getInfoFor(comment, "review_state", None) + if current_state != "pending": + transitions = workflowTool.getTransitionsFor(comment) + available_transitions = [t["id"] for t in transitions] + if "recall" in available_transitions: + workflowTool.doActionFor(comment, "recall") + comment.reindexObject() + + message = content_filter.get_moderation_message() + IStatusMessage(self.request).addStatusMessage(message, type="info") + return True + except Exception: + pass + + return False + class CommentsViewlet(ViewletBase): form = CommentForm diff --git a/src/plone/app/discussion/browser/controlpanel.py b/src/plone/app/discussion/browser/controlpanel.py index bb01a60b..cf61f74e 100644 --- a/src/plone/app/discussion/browser/controlpanel.py +++ b/src/plone/app/discussion/browser/controlpanel.py @@ -52,6 +52,9 @@ def updateFields(self): self.fields["user_notification_enabled"].widgetFactory = ( SingleCheckBoxFieldWidget ) + self.fields["content_filter_enabled"].widgetFactory = SingleCheckBoxFieldWidget + self.fields["filter_case_sensitive"].widgetFactory = SingleCheckBoxFieldWidget + self.fields["filter_whole_words_only"].widgetFactory = SingleCheckBoxFieldWidget def updateWidgets(self): try: @@ -70,6 +73,9 @@ def updateWidgets(self): self.widgets["user_notification_enabled"].label = _( "User Email Notification", ) + self.widgets["content_filter_enabled"].label = _( + "Content Filtering", + ) @button.buttonAndHandler(_("Save"), name=None) def handleSave(self, action): diff --git a/src/plone/app/discussion/configure.zcml b/src/plone/app/discussion/configure.zcml index 50a6ccd5..91130fdd 100644 --- a/src/plone/app/discussion/configure.zcml +++ b/src/plone/app/discussion/configure.zcml @@ -112,6 +112,13 @@ component=".vocabularies.text_transform_vocabulary" /> + + + False False + False + + reject + False + True diff --git a/src/plone/app/discussion/vocabularies.py b/src/plone/app/discussion/vocabularies.py index 3d28ee49..be66c377 100644 --- a/src/plone/app/discussion/vocabularies.py +++ b/src/plone/app/discussion/vocabularies.py @@ -69,6 +69,33 @@ def captcha_vocabulary(context): return SimpleVocabulary(terms) +def filter_action_vocabulary(context): + """Vocabulary with all available content filter actions.""" + terms = [] + terms.append( + SimpleTerm( + value="reject", + token="reject", + title=_("filter_action_reject", default="Reject"), + ) + ) + terms.append( + SimpleTerm( + value="moderate", + token="moderate", + title=_("filter_action_moderate", default="Send to moderation"), + ) + ) + terms.append( + SimpleTerm( + value="spam", + token="spam", + title=_("filter_action_spam", default="Mark as spam"), + ) + ) + return SimpleVocabulary(terms) + + def text_transform_vocabulary(context): """Vocabulary with all available portal_transform transformations.""" terms = []