Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/274.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Implement content filtering for comments with configurable words @rohnsha0
101 changes: 96 additions & 5 deletions plone/app/discussion/browser/comments.py
Original file line number Diff line number Diff line change
@@ -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 _
Expand Down Expand Up @@ -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)

Expand All @@ -274,18 +298,28 @@ 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
# has 'review comments' permission, he/she is redirected directly
# 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(
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions plone/app/discussion/browser/controlpanel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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):
Expand Down
7 changes: 7 additions & 0 deletions plone/app/discussion/configure.zcml
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,13 @@
component=".vocabularies.text_transform_vocabulary"
/>

<!-- Filter Action Vocabulary -->
<utility
provides="zope.schema.interfaces.IVocabularyFactory"
name="plone.app.discussion.vocabularies.FilterActionVocabulary"
component=".vocabularies.filter_action_vocabulary"
/>

<!-- Conversation indexes -->
<adapter
factory=".catalog.total_comments"
Expand Down
138 changes: 138 additions & 0 deletions plone/app/discussion/filter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"""Content filtering utilities for comment moderation."""

from plone.app.discussion.interfaces import _
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")


class CommentContentFilter:
"""Utility for filtering comment content based on configured rules."""

def __init__(self, context=None, request=None):
self.context = context
self.request = request
self._settings = None

@property
def settings(self):
"""Get discussion settings from registry."""
if self._settings is None:
registry = queryUtility(IRegistry)
if registry is not None:
self._settings = registry.forInterface(IDiscussionSettings, check=False)
return self._settings

def is_enabled(self):
"""Check if content filtering is enabled."""
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 ""
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()]

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)

if whole_words_only:
pattern = r"\b" + pattern + r"\b"

flags = 0 if case_sensitive else re.IGNORECASE

try:
return re.compile(pattern, flags)
except re.error:
return None

def check_content(self, text):
"""
Check if text contains any filtered content.

Returns:
dict: {
'filtered': bool,
'matches': list of matched words/phrases,
'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": 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)

return result

def get_rejection_message(self, matches=None):
"""Get user-friendly message for rejected comments."""
if matches:
msgid = _(
"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)},
)
else:
msgid = _(
"comment_filtered",
default="Your comment contains filtered content and cannot be posted. "
"Please review and modify your comment.",
)

return translate(msgid, context=self.request) if self.request else msgid.default

def get_moderation_message(self):
"""Get user-friendly message for moderated comments."""
msgid = _(
"comment_filtered_moderation",
default="Your comment has been submitted for review due to content "
"that requires moderation approval.",
)

return translate(msgid, context=self.request) if self.request else msgid.default


def get_content_filter(context=None, request=None):
"""Factory function to get a content filter instance."""
return CommentContentFilter(context=context, request=request)
79 changes: 79 additions & 0 deletions plone/app/discussion/interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,85 @@ class IDiscussionSettings(Interface):
default=False,
)

# Content filtering settings
content_filter_enabled = schema.Bool(
title=_(
"label_content_filter_enabled",
default="Enable content filtering",
),
description=_(
"help_content_filter_enabled",
default="If selected, comments will be automatically checked "
"against a list of filtered words and phrases before being posted.",
),
required=False,
default=False,
)

filtered_words = schema.Text(
title=_(
"label_filtered_words",
default="Filtered words and phrases",
),
description=_(
"help_filtered_words",
default="Enter words and phrases to filter, one per line. "
"Comments containing these words will be subject to the "
"configured filter action. Supports basic wildcards: use * "
"for any characters (e.g., 'bad*word' matches 'badword', "
"'bad word', 'bad-word', etc.).",
),
required=False,
default="",
)

filter_action = schema.Choice(
title=_(
"label_filter_action",
default="Filter action",
),
description=_(
"help_filter_action",
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. This setting is only used when "
"comment moderation is enabled - if moderation is disabled, "
"filtered comments will always be rejected.",
),
required=False,
default="reject", # Default to reject when moderation is disabled
vocabulary="plone.app.discussion.vocabularies.FilterActionVocabulary",
)

filter_case_sensitive = schema.Bool(
title=_(
"label_filter_case_sensitive",
default="Case sensitive filtering",
),
description=_(
"help_filter_case_sensitive",
default="If selected, filtered words must match case exactly. "
"If not selected, filtering will be case-insensitive.",
),
required=False,
default=False,
)

filter_whole_words_only = schema.Bool(
title=_(
"label_filter_whole_words_only",
default="Filter whole words only",
),
description=_(
"help_filter_whole_words_only",
default="If selected, filtered words must be complete words. "
"If not selected, partial matches within words will also be filtered.",
),
required=False,
default=True,
)


class IDiscussionLayer(Interface):
"""Request marker installed via browserlayer.xml."""
Expand Down
Loading