Add allow_anonymous namespace setting for email-free commenting

When enabled, commenters can post with just a display name (no email required).
Comments are attached to UserSurrogate instead of User.

Trade-offs for anonymous commenters:
- No email notifications for replies
- Cannot edit their comments
- Cannot log in to manage comments
- No cross-site identity

Works with existing moderation (hide_unless_approved) - anonymous
comments are never auto-approved when moderation is enabled.
This commit is contained in:
Russell Ballestrini 2025-12-20 10:50:52 -05:00
parent 8b353fc7ec
commit 19415fff06
7 changed files with 153 additions and 53 deletions

View file

@ -52,6 +52,7 @@ PROTECTED_ATTRIBUTES = {
"google_analytics_id": None,
"hide_unverified": False,
"hide_unless_approved": False,
"allow_anonymous": False,
"hide_powered_by": False,
"mathjax": False,
"link_protection": False,
@ -92,6 +93,8 @@ class Namespace(RBase, Base):
hide_unverified = Column(Boolean, default=False)
# should a node be hidden until approved by a moderator?
hide_unless_approved = Column(Boolean, default=False)
# allow anonymous commenting (name only, no email required)
allow_anonymous = Column(Boolean, default=False)
# should we hide the poweredby Remarkbox logo?
hide_powered_by = Column(Boolean, default=False)
# should the list of root nodes in this namespace be public or hidden?

View file

@ -0,0 +1,24 @@
"""Add allow_anonymous column to namespace
Revision ID: 108519de76ac
Revises: 5188e62d0afb
Create Date: 2025-12-20 11:05:36.134829
"""
# revision identifiers, used by Alembic.
revision = '108519de76ac'
down_revision = '5188e62d0afb'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column('rb_namespace', sa.Column('allow_anonymous', sa.Boolean(), nullable=True, server_default='0'))
def downgrade():
op.drop_column('rb_namespace', 'allow_anonymous')

View file

@ -39,6 +39,11 @@
If checked, hide new comments until approved by a moderator.
<br>
<br>
<label>Allow Anonymous Comments:</label>
<input type="checkbox" name="allow-anonymous-checkbox" id="allow-anonymous-checkbox" {% if request.namespace.allow_anonymous %}checked{% endif %}></input>
If checked, allow comments without email verification. Commenters enter a display name only. They cannot edit comments or receive reply notifications.
<br>
<br>
<label>Enable Link Protection:</label>
<input type="checkbox" name="link-protection-checkbox" id="link-protection-checkbox" {% if request.namespace.link_protection %}checked{% endif %}></input>
If checked, replace all links in comments with <code>[link removed]</code>.

View file

@ -1,4 +1,16 @@
{% if not request.user.authenticated %}
{% if request.namespace.allow_anonymous %}
{# Anonymous mode: show name field instead of email #}
<input
name = "anonymous_name"
type = "text"
id = "anonymous_name_input"
class = "common-text-input"
value = ""
placeholder = "Name (optional)"
maxlength = "64" />
{% else %}
{# Normal mode: require email #}
<input
name = "email2"
type = "email"
@ -16,16 +28,16 @@
{% if request.email %}style="display: none;"{% endif %}
required />
{% if show_whats_next %}
{% if show_whats_next %}
<center>
<p class="whats-next"><b>What's next?</b> check your email to log in!</p>
</center>
{% endif %}
{% endif %}
{% if show_whats_next_notifications %}
{% if show_whats_next_notifications %}
<center>
<p class="whats-next"><b>What's next?</b> verify your email address for reply notifications!</p>
</center>
{% endif %}
{% endif %}
{% endif %}

View file

@ -60,6 +60,7 @@ def namespace_settings(request):
)
hide_unless_approved_checkbox = p.get("hide-unless-approved-checkbox", "off")
allow_anonymous_checkbox = p.get("allow-anonymous-checkbox", "off")
link_protection_checkbox = p.get("link-protection-checkbox", "off")
reverse_order_checkbox = p.get("reverse-order-checkbox", "off")
group_conversations_checkbox = p.get("group-conversations-checkbox", "off")
@ -68,6 +69,7 @@ def namespace_settings(request):
hide_powered_by_checkbox = p.get("hide-powered-by-checkbox", "off")
hide_unless_approved = checkbox_to_bool(hide_unless_approved_checkbox)
allow_anonymous = checkbox_to_bool(allow_anonymous_checkbox)
link_protection = checkbox_to_bool(link_protection_checkbox)
reverse_order = checkbox_to_bool(reverse_order_checkbox)
group_conversations = checkbox_to_bool(group_conversations_checkbox)
@ -146,6 +148,17 @@ def namespace_settings(request):
)
)
if allow_anonymous != request.namespace.allow_anonymous:
request.namespace.allow_anonymous = allow_anonymous
request.session.flash(
(
"You turned {} allow_anonymous".format(
allow_anonymous_checkbox
),
"success",
)
)
if link_protection != request.namespace.link_protection:
request.namespace.link_protection = link_protection
request.session.flash(

View file

@ -4,7 +4,7 @@ from pyramid.csrf import check_csrf_token
from pyramid.httpexceptions import HTTPFound
from remarkbox.models import create_root_node
from remarkbox.models import create_root_node, get_or_create_user_surrogate_by_name
from . import get_referer_or_home, get_node_route_uri, set_node_to_pending_in_session
@ -21,6 +21,7 @@ def new_thread(request):
"""Display new page and handle posting of form."""
thread_title = request.params.get("thread_title", "")
thread_data = request.params.get("thread_data", "")
anonymous_name = request.params.get("anonymous_name", "").strip()
if request.spam:
return request.spam
@ -32,59 +33,76 @@ def new_thread(request):
if thread_title and thread_data:
# handle the submitted form new/create form.
if request.user is None:
# Handle anonymous mode vs regular mode
user_surrogate = None
if request.namespace.allow_anonymous and not request.user:
# Anonymous mode: create or get a surrogate
if not anonymous_name:
anonymous_name = "Anonymous"
user_surrogate = get_or_create_user_surrogate_by_name(
request.dbsession, anonymous_name, request.namespace
)
elif request.user is None:
# Regular mode: require email/user
request.session.flash(
("Press the back button to fix your email address", "error")
)
return HTTPFound(get_referer_or_home(request))
else:
# create a new root node.
node = create_root_node()
# create a new root node.
node = create_root_node()
node.namespace = request.namespace
node.ip_address = unicode(request.client_addr)
node.title = thread_title
node.set_data(thread_data)
# Handle anonymous vs authenticated user
if user_surrogate:
# Anonymous mode: attach surrogate, mark as verified
node.user_surrogate = user_surrogate
node.verified = True
node_event = None # No notifications for anonymous posts
request.dbsession.add(user_surrogate)
else:
# Normal mode: attach user
node.user = request.user
node.verified = request.user.authenticated
node.namespace = request.namespace
node.ip_address = unicode(request.client_addr)
node.title = thread_title
node.set_data(thread_data)
node_event = node.new_event(request.user, "created")
request.dbsession.add(node)
request.dbsession.add(node_event)
request.dbsession.add(request.user)
request.dbsession.add(node.namespace)
request.dbsession.flush()
request.dbsession.add(node)
if node_event:
request.dbsession.add(node_event)
request.dbsession.add(node.namespace)
request.dbsession.flush()
if node_event:
# TODO: schedule_notification expects the request to have a node.
request.node = node
schedule_notifications(request, node_event)
msg = ("Your post was successful!", "success")
request.session.flash(msg)
msg = ("Your post was successful!", "success")
request.session.flash(msg)
# set return_to to the node's URI.
return_to = get_node_route_uri(request, node)
# set return_to to the node's URI.
return_to = get_node_route_uri(request, node)
if node.verified == True:
# Redirect to new node if verified.
return HTTPFound(return_to)
# Anonymous users are always verified, redirect immediately
if user_surrogate or node.verified:
return HTTPFound(return_to)
set_node_to_pending_in_session(request, node)
set_node_to_pending_in_session(request, node)
# Redirect to join-or-log-in, posting email and submit.
uri = request.route_url(
route_name="basic-join-or-log-in",
_query={
"email": request.user.email,
"return-to": return_to,
"submit": True,
},
)
return HTTPFound(uri)
# Redirect to join-or-log-in, posting email and submit.
uri = request.route_url(
route_name="basic-join-or-log-in",
_query={
"email": request.user.email,
"return-to": return_to,
"submit": True,
},
)
return HTTPFound(uri)
return {
"title": "Create a new thread",

View file

@ -12,6 +12,7 @@ from . import (
)
from remarkbox.lib.notify import schedule_notifications
from remarkbox.models import get_or_create_user_surrogate_by_name
try:
unicode("")
@ -27,6 +28,7 @@ except:
def reply_node(request):
"""handle posting of reply form from show-node pages."""
thread_data = request.params.get("thread_data", "")
anonymous_name = request.params.get("anonymous_name", "").strip()
# return early if spam attribute is truthy.
if request.spam:
@ -43,8 +45,17 @@ def reply_node(request):
request.session.flash(("No remarks for the disabled.", "error"))
return HTTPFound(get_referer_or_home(request))
# flash error and return early if user is None.
if request.user is None:
# Handle anonymous mode vs regular mode
user_surrogate = None
if request.namespace.allow_anonymous and not request.user:
# Anonymous mode: create or get a surrogate
if not anonymous_name:
anonymous_name = "Anonymous"
user_surrogate = get_or_create_user_surrogate_by_name(
request.dbsession, anonymous_name, request.namespace
)
elif request.user is None:
# Regular mode: require email/user
request.session.flash(
("Press the back button to fix your email address", "error")
)
@ -72,16 +83,30 @@ def reply_node(request):
# STEP 2: attach a brand new child node to parent node.
child = parent.new_child()
child.user = request.user
child.ip_address = unicode(request.client_addr)
child.verified = request.user.authenticated
child.set_data(thread_data, namespace=request.namespace)
child_event = child.new_event(request.user, "commented")
# Handle anonymous vs authenticated user
if user_surrogate:
# Anonymous mode: attach surrogate, mark as verified (no email to verify)
child.user_surrogate = user_surrogate
child.verified = True
child_event = None # No notifications for anonymous comments
request.dbsession.add(user_surrogate)
else:
# Normal mode: attach user
child.user = request.user
child.verified = request.user.authenticated
child_event = child.new_event(request.user, "commented")
if request.namespace.hide_unless_approved:
# by default comments are approved, unless Namespace hide_unless_approved
# is enabled, moderators nodes are always auto approved.
child.approved = request.namespace.is_moderator(request.user)
if request.user:
child.approved = request.namespace.is_moderator(request.user)
else:
# Anonymous users are never auto-approved when moderation is on
child.approved = False
# STEP 3: update root's changed timestamp.
# TODO: maybe we should find a better way to "bump" a thread.
@ -90,29 +115,29 @@ def reply_node(request):
parent._invalidate_cache()
# STEP 4: commit to database.
request.dbsession.add(request.user)
if request.user:
request.dbsession.add(request.user)
request.dbsession.add(child)
request.dbsession.add(child_event)
if child_event:
request.dbsession.add(child_event)
request.dbsession.add(parent)
request.dbsession.add(parent.root)
request.dbsession.flush()
schedule_notifications(request, child_event)
if child_event:
schedule_notifications(request, child_event)
msg = ("Your post was successful!", "success")
request.session.flash(msg)
### TODO: everything below this is pretty much crap code...
# and likely deserves a flowchart...
# set return_to URI.
if request.mode == "embed":
return_to = get_embed_route_uri(request, child.root.uri.data, child.id)
else:
return_to = get_node_route_uri(request, child.root, child.id)
if child.verified == True:
# Redirect to new node if user and new node is verified.
# Anonymous users are always verified, redirect immediately
if user_surrogate or child.verified:
return HTTPFound(return_to)
set_node_to_pending_in_session(request, child)