From 8259ac5177503d73c4f4a8b48ee0a91d7a283acd Mon Sep 17 00:00:00 2001 From: Rohan Shaw Date: Sun, 6 Jul 2025 13:45:31 +0530 Subject: [PATCH 1/8] feat: implement content filtering for comments with configurable settings --- plone/app/discussion/browser/comments.py | 61 ++++++++ plone/app/discussion/browser/controlpanel.py | 12 ++ plone/app/discussion/configure.zcml | 7 + plone/app/discussion/filter.py | 137 ++++++++++++++++++ plone/app/discussion/interfaces.py | 77 ++++++++++ .../discussion/profiles/default/registry.xml | 5 + plone/app/discussion/vocabularies.py | 27 ++++ 7 files changed, 326 insertions(+) create mode 100644 plone/app/discussion/filter.py diff --git a/plone/app/discussion/browser/comments.py b/plone/app/discussion/browser/comments.py index e7f82293..78e130d4 100644 --- a/plone/app/discussion/browser/comments.py +++ b/plone/app/discussion/browser/comments.py @@ -260,6 +260,30 @@ def handleComment(self, action): ) captcha.validate(data["captcha"]) + # Content filtering check + 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 + elif action == 'spam': + # Mark comment as spam - we'll handle this after creation + data['_content_filter_action'] = 'spam' + data['_content_filter_matches'] = filter_result['matches'] + elif action == 'moderate': + # Force moderation - we'll handle this after creation + data['_content_filter_action'] = 'moderate' + data['_content_filter_matches'] = filter_result['matches'] + # Create comment comment = self.create_comment(data) @@ -274,6 +298,43 @@ def handleComment(self, action): # Add a comment to the conversation comment_id = conversation.addComment(comment) + # Handle content filtering actions post-creation + filter_action = data.get('_content_filter_action') + filter_matches = data.get('_content_filter_matches', []) + + if filter_action == 'spam': + # Mark comment as spam using workflow + workflowTool = getToolByName(context, "portal_workflow") + try: + workflowTool.doActionFor(comment, "mark_as_spam") + comment.reindexObject() + message = _("comment_marked_spam_by_filter", + default="Your comment has been marked as spam due to filtered content.") + IStatusMessage(self.context.REQUEST).addStatusMessage(message, type="warning") + self.request.response.redirect(self.action) + return + except Exception: + # Fallback if spam action not available - force moderation + filter_action = 'moderate' + + if filter_action == 'moderate': + # Force comment to pending state regardless of user permissions + workflowTool = getToolByName(context, "portal_workflow") + try: + # If comment is not already pending, force it to pending + current_state = workflowTool.getInfoFor(comment, "review_state", None) + if current_state != "pending": + # This might require custom workflow handling + pass + + message = content_filter.get_moderation_message() + IStatusMessage(self.context.REQUEST).addStatusMessage(message, type="info") + self.request.response.redirect(self.action) + return + except Exception: + # Fallback to normal moderation logic below + pass + # 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 diff --git a/plone/app/discussion/browser/controlpanel.py b/plone/app/discussion/browser/controlpanel.py index bb01a60b..e448c261 100644 --- a/plone/app/discussion/browser/controlpanel.py +++ b/plone/app/discussion/browser/controlpanel.py @@ -52,6 +52,15 @@ 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 +79,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/plone/app/discussion/configure.zcml b/plone/app/discussion/configure.zcml index 50a6ccd5..91130fdd 100644 --- a/plone/app/discussion/configure.zcml +++ b/plone/app/discussion/configure.zcml @@ -112,6 +112,13 @@ component=".vocabularies.text_transform_vocabulary" /> + + + False False + False + + moderate + False + True diff --git a/plone/app/discussion/vocabularies.py b/plone/app/discussion/vocabularies.py index 3d28ee49..cb61dde4 100644 --- a/plone/app/discussion/vocabularies.py +++ b/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 = [] From 71f5de7da6cf22859f27411439d8bd8dbc6fc9c1 Mon Sep 17 00:00:00 2001 From: Rohan Shaw Date: Sun, 6 Jul 2025 17:57:32 +0530 Subject: [PATCH 2/8] fix: working of automatically adding as spam --- plone/app/discussion/browser/comments.py | 109 +++++++++++++++++++---- plone/app/discussion/filter.py | 28 ++++-- 2 files changed, 111 insertions(+), 26 deletions(-) diff --git a/plone/app/discussion/browser/comments.py b/plone/app/discussion/browser/comments.py index 78e130d4..e83197b6 100644 --- a/plone/app/discussion/browser/comments.py +++ b/plone/app/discussion/browser/comments.py @@ -259,13 +259,14 @@ def handleComment(self, action): self.context, self.request, None, ICaptcha["captcha"], None ) captcha.validate(data["captcha"]) - - # Content filtering check + 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', '')) + comment_text = data.get('text', '') + + filter_result = content_filter.check_content(comment_text) if filter_result['filtered']: action = filter_result['action'] @@ -298,42 +299,110 @@ 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') filter_matches = data.get('_content_filter_matches', []) + if filter_action == 'spam': - # Mark comment as spam using workflow + # Mark comment as spam - we need to use a different approach since + # regular users don't have "Review comments" permission workflowTool = getToolByName(context, "portal_workflow") + try: - workflowTool.doActionFor(comment, "mark_as_spam") - comment.reindexObject() - message = _("comment_marked_spam_by_filter", - default="Your comment has been marked as spam due to filtered content.") - IStatusMessage(self.context.REQUEST).addStatusMessage(message, type="warning") - self.request.response.redirect(self.action) - return - except Exception: + # First, check if the workflow supports spam state + comment_workflow = workflowTool.getWorkflowsFor(comment) + + if comment_workflow and 'spam' in comment_workflow[0].states: + + # Get current comment state + current_state = workflowTool.getInfoFor(comment, "review_state", None) + + # Check available transitions + transitions = workflowTool.getTransitionsFor(comment) + available_transitions = [t['id'] for t in transitions] + + # Use plone.api for privileged operations if available + try: + import plone.api + # Use plone.api to bypass permission check + with plone.api.env.adopt_roles(['Manager']): + workflowTool.doActionFor(comment, "mark_as_spam") + comment.reindexObject() + + message = _("comment_marked_spam_by_filter", + default="Your comment has been marked as spam due to filtered content.") + IStatusMessage(self.context.REQUEST).addStatusMessage(message, type="warning") + self.request.response.redirect(self.action) + return + + except ImportError: + # plone.api not available, try direct workflow state manipulation + # Set the workflow state directly without transition + # This bypasses permission checks + workflow_history = workflowTool.getHistoryOf( + comment_workflow[0].getId(), comment + ) + + if workflow_history: + # Update the current state + workflow_history[-1]['review_state'] = 'spam' + workflow_history[-1]['action'] = 'Marked as spam by content filter' + + # Set the workflow state on the object + comment.review_state = 'spam' + comment.reindexObject() + + # Verify the state was set + new_state = workflowTool.getInfoFor(comment, "review_state", None) + + message = _("comment_marked_spam_by_filter", + default="Your comment has been marked as spam due to filtered content.") + IStatusMessage(self.context.REQUEST).addStatusMessage(message, type="warning") + self.request.response.redirect(self.action) + return + + except Exception as e: + # Log the specific error for debugging # Fallback if spam action not available - force moderation filter_action = 'moderate' if filter_action == 'moderate': - # Force comment to pending state regardless of user permissions + # Force comment to pending state - check if it's not already pending workflowTool = getToolByName(context, "portal_workflow") try: - # If comment is not already pending, force it to pending current_state = workflowTool.getInfoFor(comment, "review_state", None) + if current_state != "pending": - # This might require custom workflow handling - pass + # If comment is published or in another state, try to transition to pending + # This might happen if user has auto-approval permissions + transitions = workflowTool.getTransitionsFor(comment) + available_transitions = [t['id'] for t in transitions] + + # Look for a transition that leads to pending state + if 'recall' in available_transitions: + # From published to pending (if recall transition exists) + workflowTool.doActionFor(comment, "recall") + comment.reindexObject() + + # Verify state change + new_state = workflowTool.getInfoFor(comment, "review_state", None) message = content_filter.get_moderation_message() IStatusMessage(self.context.REQUEST).addStatusMessage(message, type="info") self.request.response.redirect(self.action) return - except Exception: - # Fallback to normal moderation logic below - pass + + except Exception as e: + import logging + logger = logging.getLogger("plone.app.discussion.filter") + # Log the specific error for debugging + logger.error(f"Failed to force comment moderation: {e}", exc_info=True) + # Continue with normal flow if we can't force moderation # Redirect after form submit: # If a user posts a comment and moderation is enabled, a message is @@ -347,6 +416,8 @@ def handleComment(self, action): "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( diff --git a/plone/app/discussion/filter.py b/plone/app/discussion/filter.py index 05195458..957c0dfc 100644 --- a/plone/app/discussion/filter.py +++ b/plone/app/discussion/filter.py @@ -31,7 +31,8 @@ def settings(self): def is_enabled(self): """Check if content filtering is enabled.""" - return getattr(self.settings, 'content_filter_enabled', False) + enabled = getattr(self.settings, 'content_filter_enabled', False) + return enabled def get_filtered_words(self): """Get list of filtered words from settings.""" @@ -39,6 +40,7 @@ def get_filtered_words(self): return [] words_text = getattr(self.settings, 'filtered_words', '') or '' + if not words_text.strip(): return [] @@ -48,6 +50,7 @@ def get_filtered_words(self): def compile_pattern(self, word): """Compile a single word/phrase into a regex pattern.""" + # Escape special regex characters except for our wildcard * escaped_word = re.escape(word) # Replace escaped asterisks with regex wildcard pattern @@ -56,15 +59,17 @@ def compile_pattern(self, word): case_sensitive = getattr(self.settings, 'filter_case_sensitive', False) whole_words_only = getattr(self.settings, 'filter_whole_words_only', True) + if whole_words_only: pattern = r'\b' + pattern + r'\b' flags = 0 if case_sensitive else re.IGNORECASE + try: - return re.compile(pattern, flags) + compiled_pattern = re.compile(pattern, flags) + return compiled_pattern except re.error as e: - logger.warning(f"Invalid filter pattern '{word}': {e}") return None def check_content(self, text): @@ -78,24 +83,33 @@ def check_content(self, text): 'action': str - action to take if filtered } """ + result = { 'filtered': False, 'matches': [], 'action': getattr(self.settings, 'filter_action', 'moderate') } - if not self.is_enabled() or not text: + + if not self.is_enabled(): + return result + + if not text: return result filtered_words = self.get_filtered_words() + if not filtered_words: return result for word in filtered_words: pattern = self.compile_pattern(word) - if pattern and pattern.search(text): - result['filtered'] = True - result['matches'].append(word) + if pattern: + match = pattern.search(text) + if match: + logger.debug(f"MATCH FOUND! Word '{word}' matched text '{match.group()}' at position {match.start()}-{match.end()}") + result['filtered'] = True + result['matches'].append(word) return result From cee64eecc68c052afb7d1e1611095c08c9ad62b4 Mon Sep 17 00:00:00 2001 From: Rohan Shaw Date: Sun, 6 Jul 2025 18:10:59 +0530 Subject: [PATCH 3/8] cleanup --- plone/app/discussion/browser/comments.py | 168 ++++++++--------------- plone/app/discussion/filter.py | 38 ++--- 2 files changed, 65 insertions(+), 141 deletions(-) diff --git a/plone/app/discussion/browser/comments.py b/plone/app/discussion/browser/comments.py index e83197b6..d47e4a55 100644 --- a/plone/app/discussion/browser/comments.py +++ b/plone/app/discussion/browser/comments.py @@ -264,9 +264,7 @@ def handleComment(self, action): content_filter = get_content_filter(context=self.context, request=self.request) if content_filter.is_enabled(): - comment_text = data.get('text', '') - - filter_result = content_filter.check_content(comment_text) + filter_result = content_filter.check_content(data.get('text', '')) if filter_result['filtered']: action = filter_result['action'] @@ -276,13 +274,9 @@ def handleComment(self, action): message = content_filter.get_rejection_message(filter_result['matches']) IStatusMessage(self.request).add(message, type="error") return - elif action == 'spam': - # Mark comment as spam - we'll handle this after creation - data['_content_filter_action'] = 'spam' - data['_content_filter_matches'] = filter_result['matches'] - elif action == 'moderate': - # Force moderation - we'll handle this after creation - data['_content_filter_action'] = 'moderate' + else: + # Store action for post-creation handling + data['_content_filter_action'] = action data['_content_filter_matches'] = filter_result['matches'] # Create comment @@ -305,104 +299,10 @@ def handleComment(self, action): # Handle content filtering actions post-creation filter_action = data.get('_content_filter_action') - filter_matches = data.get('_content_filter_matches', []) - - if filter_action == 'spam': - # Mark comment as spam - we need to use a different approach since - # regular users don't have "Review comments" permission - workflowTool = getToolByName(context, "portal_workflow") - - try: - # First, check if the workflow supports spam state - comment_workflow = workflowTool.getWorkflowsFor(comment) - - if comment_workflow and 'spam' in comment_workflow[0].states: - - # Get current comment state - current_state = workflowTool.getInfoFor(comment, "review_state", None) - - # Check available transitions - transitions = workflowTool.getTransitionsFor(comment) - available_transitions = [t['id'] for t in transitions] - - # Use plone.api for privileged operations if available - try: - import plone.api - # Use plone.api to bypass permission check - with plone.api.env.adopt_roles(['Manager']): - workflowTool.doActionFor(comment, "mark_as_spam") - comment.reindexObject() - - message = _("comment_marked_spam_by_filter", - default="Your comment has been marked as spam due to filtered content.") - IStatusMessage(self.context.REQUEST).addStatusMessage(message, type="warning") - self.request.response.redirect(self.action) - return - - except ImportError: - # plone.api not available, try direct workflow state manipulation - # Set the workflow state directly without transition - # This bypasses permission checks - workflow_history = workflowTool.getHistoryOf( - comment_workflow[0].getId(), comment - ) - - if workflow_history: - # Update the current state - workflow_history[-1]['review_state'] = 'spam' - workflow_history[-1]['action'] = 'Marked as spam by content filter' - - # Set the workflow state on the object - comment.review_state = 'spam' - comment.reindexObject() - - # Verify the state was set - new_state = workflowTool.getInfoFor(comment, "review_state", None) - - message = _("comment_marked_spam_by_filter", - default="Your comment has been marked as spam due to filtered content.") - IStatusMessage(self.context.REQUEST).addStatusMessage(message, type="warning") - self.request.response.redirect(self.action) - return - - except Exception as e: - # Log the specific error for debugging - # Fallback if spam action not available - force moderation - filter_action = 'moderate' - - if filter_action == 'moderate': - # Force comment to pending state - check if it's not already pending - workflowTool = getToolByName(context, "portal_workflow") - try: - current_state = workflowTool.getInfoFor(comment, "review_state", None) - - if current_state != "pending": - # If comment is published or in another state, try to transition to pending - # This might happen if user has auto-approval permissions - transitions = workflowTool.getTransitionsFor(comment) - available_transitions = [t['id'] for t in transitions] - - # Look for a transition that leads to pending state - if 'recall' in available_transitions: - # From published to pending (if recall transition exists) - workflowTool.doActionFor(comment, "recall") - comment.reindexObject() - - # Verify state change - new_state = workflowTool.getInfoFor(comment, "review_state", None) - - message = content_filter.get_moderation_message() - IStatusMessage(self.context.REQUEST).addStatusMessage(message, type="info") - self.request.response.redirect(self.action) - return - - except Exception as e: - import logging - logger = logging.getLogger("plone.app.discussion.filter") - # Log the specific error for debugging - logger.error(f"Failed to force comment moderation: {e}", exc_info=True) - # Continue with normal flow if we can't force moderation + 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 @@ -411,12 +311,7 @@ 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 @@ -434,6 +329,53 @@ 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: + try: + # Try to use plone.api for privileged operations + import plone.api + with plone.api.env.adopt_roles(['Manager']): + workflowTool.doActionFor(comment, "mark_as_spam") + comment.reindexObject() + except ImportError: + # Fallback: direct state manipulation + comment.review_state = 'spam' + comment.reindexObject() + + 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/plone/app/discussion/filter.py b/plone/app/discussion/filter.py index 957c0dfc..9eb3e954 100644 --- a/plone/app/discussion/filter.py +++ b/plone/app/discussion/filter.py @@ -40,17 +40,14 @@ def get_filtered_words(self): return [] words_text = getattr(self.settings, 'filtered_words', '') or '' - if not words_text.strip(): return [] # Split by lines and filter out empty lines - words = [word.strip() for word in words_text.split('\n') if word.strip()] - return words + return [word.strip() for word in words_text.split('\n') if word.strip()] def compile_pattern(self, word): """Compile a single word/phrase into a regex pattern.""" - # Escape special regex characters except for our wildcard * escaped_word = re.escape(word) # Replace escaped asterisks with regex wildcard pattern @@ -59,17 +56,14 @@ def compile_pattern(self, word): case_sensitive = getattr(self.settings, 'filter_case_sensitive', False) whole_words_only = getattr(self.settings, 'filter_whole_words_only', True) - if whole_words_only: pattern = r'\b' + pattern + r'\b' flags = 0 if case_sensitive else re.IGNORECASE - try: - compiled_pattern = re.compile(pattern, flags) - return compiled_pattern - except re.error as e: + return re.compile(pattern, flags) + except re.error: return None def check_content(self, text): @@ -83,33 +77,25 @@ def check_content(self, text): 'action': str - action to take if filtered } """ - result = { 'filtered': False, 'matches': [], 'action': getattr(self.settings, 'filter_action', 'moderate') } - - if not self.is_enabled(): - return result - - if not text: + if not self.is_enabled() or not text: return result filtered_words = self.get_filtered_words() - if not filtered_words: return result for word in filtered_words: pattern = self.compile_pattern(word) - if pattern: - match = pattern.search(text) - if match: - logger.debug(f"MATCH FOUND! Word '{word}' matched text '{match.group()}' at position {match.start()}-{match.end()}") - result['filtered'] = True - result['matches'].append(word) + if pattern and pattern.search(text): + logger.debug(f"Content filter match: '{word}' found in text") + result['filtered'] = True + result['matches'].append(word) return result @@ -129,9 +115,7 @@ def get_rejection_message(self, matches=None): "Please review and modify your comment." ) - if self.request: - return translate(msgid, context=self.request) - return msgid.default + return translate(msgid, context=self.request) if self.request else msgid.default def get_moderation_message(self): """Get user-friendly message for moderated comments.""" @@ -141,9 +125,7 @@ def get_moderation_message(self): "that requires moderation approval." ) - if self.request: - return translate(msgid, context=self.request) - return msgid.default + return translate(msgid, context=self.request) if self.request else msgid.default def get_content_filter(context=None, request=None): From cc2000105a11a723cfbe10b1980a1c400019c0e2 Mon Sep 17 00:00:00 2001 From: Rohan Shaw Date: Sun, 6 Jul 2025 20:11:03 +0530 Subject: [PATCH 4/8] fix: update content filtering settings to reject comments when moderation is disabled --- plone/app/discussion/filter.py | 10 +++++++++- plone/app/discussion/interfaces.py | 7 +++++-- plone/app/discussion/profiles/default/registry.xml | 2 +- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/plone/app/discussion/filter.py b/plone/app/discussion/filter.py index 9eb3e954..c6d5dc6a 100644 --- a/plone/app/discussion/filter.py +++ b/plone/app/discussion/filter.py @@ -77,10 +77,18 @@ def check_content(self, text): 'action': str - action to take if filtered } """ + # Determine the filter action based on moderation settings + filter_action = getattr(self.settings, 'filter_action', 'moderate') + moderation_enabled = getattr(self.settings, 'moderation_enabled', False) + + # If moderation is disabled, force action to 'reject' + if not moderation_enabled: + filter_action = 'reject' + result = { 'filtered': False, 'matches': [], - 'action': getattr(self.settings, 'filter_action', 'moderate') + 'action': filter_action } if not self.is_enabled() or not text: diff --git a/plone/app/discussion/interfaces.py b/plone/app/discussion/interfaces.py index 7e425170..da523a4b 100644 --- a/plone/app/discussion/interfaces.py +++ b/plone/app/discussion/interfaces.py @@ -7,6 +7,7 @@ from zope.component import getUtility from zope.interface import Interface from zope.interface import Invalid +from zope.interface import invariant from zope.interface.common.mapping import IIterableMapping from zope.interface.interfaces import IObjectEvent @@ -417,10 +418,12 @@ class IDiscussionSettings(Interface): default="Choose what happens when filtered content is detected. " "'Reject' blocks the comment immediately. 'Moderate' sends the " "comment to the moderation queue. 'Mark as spam' automatically " - "marks the comment as spam.", + "marks the comment as spam. This setting is only used when " + "comment moderation is enabled - if moderation is disabled, " + "filtered comments will always be rejected.", ), required=False, - default="moderate", + default="reject", # Default to reject when moderation is disabled vocabulary="plone.app.discussion.vocabularies.FilterActionVocabulary", ) diff --git a/plone/app/discussion/profiles/default/registry.xml b/plone/app/discussion/profiles/default/registry.xml index 7edaf09b..7da7e228 100644 --- a/plone/app/discussion/profiles/default/registry.xml +++ b/plone/app/discussion/profiles/default/registry.xml @@ -5,7 +5,7 @@ False False - moderate + reject False True From 080d9262782e572239aac635a7c5dcffab7f79bf Mon Sep 17 00:00:00 2001 From: Rohan Shaw Date: Sun, 6 Jul 2025 20:33:04 +0530 Subject: [PATCH 5/8] chnglg --- news/274.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 news/274.feature 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 From b73533b54bdef8d0fa8df3cbb3d939691a75e04a Mon Sep 17 00:00:00 2001 From: Rohan Shaw Date: Sun, 6 Jul 2025 20:35:00 +0530 Subject: [PATCH 6/8] refactor: lint --- plone/app/discussion/browser/comments.py | 74 +++++++++++-------- plone/app/discussion/browser/controlpanel.py | 12 +-- plone/app/discussion/filter.py | 71 +++++++++--------- plone/app/discussion/interfaces.py | 1 - .../discussion/profiles/default/registry.xml | 2 +- plone/app/discussion/vocabularies.py | 2 +- 6 files changed, 81 insertions(+), 81 deletions(-) diff --git a/plone/app/discussion/browser/comments.py b/plone/app/discussion/browser/comments.py index d47e4a55..110d92f5 100644 --- a/plone/app/discussion/browser/comments.py +++ b/plone/app/discussion/browser/comments.py @@ -259,25 +259,28 @@ def handleComment(self, action): self.context, self.request, None, ICaptcha["captcha"], None ) 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': + 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']) + 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'] + data["_content_filter_action"] = action + data["_content_filter_matches"] = filter_result["matches"] # Create comment comment = self.create_comment(data) @@ -296,11 +299,13 @@ def handleComment(self, action): # 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): + 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 @@ -312,7 +317,7 @@ def handleComment(self, action): can_review = getSecurityManager().checkPermission("Review comments", context) workflowTool = getToolByName(context, "portal_workflow") 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( @@ -333,47 +338,52 @@ 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': + + 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: + if comment_workflow and "spam" in comment_workflow[0].states: try: # Try to use plone.api for privileged operations import plone.api - with plone.api.env.adopt_roles(['Manager']): + + with plone.api.env.adopt_roles(["Manager"]): workflowTool.doActionFor(comment, "mark_as_spam") comment.reindexObject() except ImportError: # Fallback: direct state manipulation - comment.review_state = 'spam' + comment.review_state = "spam" comment.reindexObject() - - 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") + + 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': + 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: + 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 diff --git a/plone/app/discussion/browser/controlpanel.py b/plone/app/discussion/browser/controlpanel.py index e448c261..cf61f74e 100644 --- a/plone/app/discussion/browser/controlpanel.py +++ b/plone/app/discussion/browser/controlpanel.py @@ -52,15 +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 - ) + 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: diff --git a/plone/app/discussion/filter.py b/plone/app/discussion/filter.py index c6d5dc6a..76f73c42 100644 --- a/plone/app/discussion/filter.py +++ b/plone/app/discussion/filter.py @@ -1,13 +1,14 @@ """Content filtering utilities for comment moderation.""" -import re from plone.app.discussion.interfaces import _ -from plone.registry.interfaces import IRegistry from plone.app.discussion.interfaces import IDiscussionSettings +from plone.registry.interfaces import IRegistry from zope.component import queryUtility from zope.i18n import translate import logging +import re + logger = logging.getLogger("plone.app.discussion.filter") @@ -31,36 +32,36 @@ def settings(self): def is_enabled(self): """Check if content filtering is enabled.""" - enabled = getattr(self.settings, 'content_filter_enabled', False) + enabled = getattr(self.settings, "content_filter_enabled", False) return enabled def get_filtered_words(self): """Get list of filtered words from settings.""" if not self.settings: return [] - - words_text = getattr(self.settings, 'filtered_words', '') or '' + + words_text = getattr(self.settings, "filtered_words", "") or "" if not words_text.strip(): return [] - + # Split by lines and filter out empty lines - return [word.strip() for word in words_text.split('\n') if word.strip()] + return [word.strip() for word in words_text.split("\n") if word.strip()] def compile_pattern(self, word): """Compile a single word/phrase into a regex pattern.""" # Escape special regex characters except for our wildcard * escaped_word = re.escape(word) # Replace escaped asterisks with regex wildcard pattern - pattern = escaped_word.replace(r'\*', r'[^\s]*') - - case_sensitive = getattr(self.settings, 'filter_case_sensitive', False) - whole_words_only = getattr(self.settings, 'filter_whole_words_only', True) - + pattern = escaped_word.replace(r"\*", r"[^\s]*") + + case_sensitive = getattr(self.settings, "filter_case_sensitive", False) + whole_words_only = getattr(self.settings, "filter_whole_words_only", True) + if whole_words_only: - pattern = r'\b' + pattern + r'\b' - + pattern = r"\b" + pattern + r"\b" + flags = 0 if case_sensitive else re.IGNORECASE - + try: return re.compile(pattern, flags) except re.error: @@ -69,7 +70,7 @@ def compile_pattern(self, word): def check_content(self, text): """ Check if text contains any filtered content. - + Returns: dict: { 'filtered': bool, @@ -78,33 +79,29 @@ def check_content(self, text): } """ # Determine the filter action based on moderation settings - filter_action = getattr(self.settings, 'filter_action', 'moderate') - moderation_enabled = getattr(self.settings, 'moderation_enabled', False) - + filter_action = getattr(self.settings, "filter_action", "moderate") + moderation_enabled = getattr(self.settings, "moderation_enabled", False) + # If moderation is disabled, force action to 'reject' if not moderation_enabled: - filter_action = 'reject' - - result = { - 'filtered': False, - 'matches': [], - 'action': filter_action - } - + filter_action = "reject" + + result = {"filtered": False, "matches": [], "action": filter_action} + if not self.is_enabled() or not text: return result - + filtered_words = self.get_filtered_words() if not filtered_words: return result - + for word in filtered_words: pattern = self.compile_pattern(word) if pattern and pattern.search(text): logger.debug(f"Content filter match: '{word}' found in text") - result['filtered'] = True - result['matches'].append(word) - + result["filtered"] = True + result["matches"].append(word) + return result def get_rejection_message(self, matches=None): @@ -114,15 +111,15 @@ def get_rejection_message(self, matches=None): "comment_filtered_with_words", default="Your comment contains filtered content and cannot be posted. " "Please remove or modify the following: ${words}", - mapping={'words': ', '.join(matches)} + mapping={"words": ", ".join(matches)}, ) else: msgid = _( "comment_filtered", default="Your comment contains filtered content and cannot be posted. " - "Please review and modify your comment." + "Please review and modify your comment.", ) - + return translate(msgid, context=self.request) if self.request else msgid.default def get_moderation_message(self): @@ -130,9 +127,9 @@ def get_moderation_message(self): msgid = _( "comment_filtered_moderation", default="Your comment has been submitted for review due to content " - "that requires moderation approval." + "that requires moderation approval.", ) - + return translate(msgid, context=self.request) if self.request else msgid.default diff --git a/plone/app/discussion/interfaces.py b/plone/app/discussion/interfaces.py index da523a4b..adab8062 100644 --- a/plone/app/discussion/interfaces.py +++ b/plone/app/discussion/interfaces.py @@ -7,7 +7,6 @@ from zope.component import getUtility from zope.interface import Interface from zope.interface import Invalid -from zope.interface import invariant from zope.interface.common.mapping import IIterableMapping from zope.interface.interfaces import IObjectEvent diff --git a/plone/app/discussion/profiles/default/registry.xml b/plone/app/discussion/profiles/default/registry.xml index 7da7e228..a579ac37 100644 --- a/plone/app/discussion/profiles/default/registry.xml +++ b/plone/app/discussion/profiles/default/registry.xml @@ -4,7 +4,7 @@ False False False - + reject False True diff --git a/plone/app/discussion/vocabularies.py b/plone/app/discussion/vocabularies.py index cb61dde4..be66c377 100644 --- a/plone/app/discussion/vocabularies.py +++ b/plone/app/discussion/vocabularies.py @@ -82,7 +82,7 @@ def filter_action_vocabulary(context): terms.append( SimpleTerm( value="moderate", - token="moderate", + token="moderate", title=_("filter_action_moderate", default="Send to moderation"), ) ) From b5df144df5d47042e3fbfd737cd3ec6f91390c84 Mon Sep 17 00:00:00 2001 From: Rohan Shaw Date: Mon, 7 Jul 2025 09:26:24 +0530 Subject: [PATCH 7/8] fix: enhance spam handling by using Zope security manager for privileged operations --- plone/app/discussion/browser/comments.py | 25 +++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/plone/app/discussion/browser/comments.py b/plone/app/discussion/browser/comments.py index 110d92f5..6d92eccc 100644 --- a/plone/app/discussion/browser/comments.py +++ b/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 _ @@ -344,17 +346,22 @@ def _handle_content_filter_action(self, comment, action, content_filter): # 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: - # Try to use plone.api for privileged operations - import plone.api - - with plone.api.env.adopt_roles(["Manager"]): - workflowTool.doActionFor(comment, "mark_as_spam") - comment.reindexObject() - except ImportError: - # Fallback: direct state manipulation - comment.review_state = "spam" + # 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", From 19bba9931e708f9ca62599ec6877f1c2130a9afc Mon Sep 17 00:00:00 2001 From: Rohan Shaw Date: Mon, 7 Jul 2025 09:27:31 +0530 Subject: [PATCH 8/8] refactor: lint --- plone/app/discussion/browser/comments.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plone/app/discussion/browser/comments.py b/plone/app/discussion/browser/comments.py index 6d92eccc..7da0a565 100644 --- a/plone/app/discussion/browser/comments.py +++ b/plone/app/discussion/browser/comments.py @@ -349,13 +349,13 @@ def _handle_content_filter_action(self, comment, action, content_filter): # 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 = 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()