From 19415fff06e01e5da3cc0cdf48537e54e341d79c Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Sat, 20 Dec 2025 10:50:52 -0500 Subject: [PATCH] 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. --- remarkbox/models/namespace.py | 3 + ...add_allow_anonymous_column_to_namespace.py | 24 +++++ remarkbox/templates/namespace-settings.j2 | 5 ++ remarkbox/templates/snippets/email.j2 | 20 ++++- .../views/authenticated/authenticated.py | 13 +++ remarkbox/views/new_thread.py | 88 +++++++++++-------- remarkbox/views/reply_node.py | 53 ++++++++--- 7 files changed, 153 insertions(+), 53 deletions(-) create mode 100644 remarkbox/scripts/alembic/versions/108519de76ac_add_allow_anonymous_column_to_namespace.py diff --git a/remarkbox/models/namespace.py b/remarkbox/models/namespace.py index 976f343..5c711d7 100644 --- a/remarkbox/models/namespace.py +++ b/remarkbox/models/namespace.py @@ -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? diff --git a/remarkbox/scripts/alembic/versions/108519de76ac_add_allow_anonymous_column_to_namespace.py b/remarkbox/scripts/alembic/versions/108519de76ac_add_allow_anonymous_column_to_namespace.py new file mode 100644 index 0000000..d24feab --- /dev/null +++ b/remarkbox/scripts/alembic/versions/108519de76ac_add_allow_anonymous_column_to_namespace.py @@ -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') diff --git a/remarkbox/templates/namespace-settings.j2 b/remarkbox/templates/namespace-settings.j2 index 5645cf0..c4e1234 100644 --- a/remarkbox/templates/namespace-settings.j2 +++ b/remarkbox/templates/namespace-settings.j2 @@ -39,6 +39,11 @@ If checked, hide new comments until approved by a moderator.

+ + +If checked, allow comments without email verification. Commenters enter a display name only. They cannot edit comments or receive reply notifications. +
+
If checked, replace all links in comments with [link removed]. diff --git a/remarkbox/templates/snippets/email.j2 b/remarkbox/templates/snippets/email.j2 index 8b1cc1a..c6d1c6a 100644 --- a/remarkbox/templates/snippets/email.j2 +++ b/remarkbox/templates/snippets/email.j2 @@ -1,4 +1,16 @@ {% if not request.user.authenticated %} + {% if request.namespace.allow_anonymous %} + {# Anonymous mode: show name field instead of email #} + + {% else %} + {# Normal mode: require email #} - {% if show_whats_next %} + {% if show_whats_next %}

What's next? check your email to log in!

- {% endif %} + {% endif %} - {% if show_whats_next_notifications %} + {% if show_whats_next_notifications %}

What's next? verify your email address for reply notifications!

+ {% endif %} {% endif %} - {% endif %} diff --git a/remarkbox/views/authenticated/authenticated.py b/remarkbox/views/authenticated/authenticated.py index 39067e4..1685612 100644 --- a/remarkbox/views/authenticated/authenticated.py +++ b/remarkbox/views/authenticated/authenticated.py @@ -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( diff --git a/remarkbox/views/new_thread.py b/remarkbox/views/new_thread.py index d7c9a2c..315b6c9 100644 --- a/remarkbox/views/new_thread.py +++ b/remarkbox/views/new_thread.py @@ -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", diff --git a/remarkbox/views/reply_node.py b/remarkbox/views/reply_node.py index 80767d1..9fba08f 100644 --- a/remarkbox/views/reply_node.py +++ b/remarkbox/views/reply_node.py @@ -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)