From 19415fff06e01e5da3cc0cdf48537e54e341d79c Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Sat, 20 Dec 2025 10:50:52 -0500 Subject: [PATCH 1/2] 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) -- 2.49.1 From 6b2be261f5a15efab65886f05435fa92d9943dd2 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Sat, 20 Dec 2025 11:17:12 -0500 Subject: [PATCH 2/2] Add functional tests for anonymous commenting feature Tests cover: - Anonymous reply creates UserSurrogate - Anonymous reply with no name defaults to 'Anonymous' - Regular namespace still requires email - Anonymous comments are marked as verified - Namespace settings toggle for allow_anonymous --- remarkbox/tests/test_views.py | 269 ++++++++++++++++++++++++++++++++++ 1 file changed, 269 insertions(+) diff --git a/remarkbox/tests/test_views.py b/remarkbox/tests/test_views.py index f1101cb..a34f4ce 100644 --- a/remarkbox/tests/test_views.py +++ b/remarkbox/tests/test_views.py @@ -7,7 +7,9 @@ from remarkbox.models import ( get_tm_session, get_or_create_user_by_email, get_user_by_email, + get_or_create_namespace, NodeEventNotification, + UserSurrogate, ) from remarkbox.models.meta import Base @@ -477,3 +479,270 @@ class AuthenticatedFunctionalTests(FunctionalTests): redirect_res = self.testapp.get("/billing/success", status=302) res = redirect_res.follow() self.assertIn(b"Missing session information", res.body) + + +class AnonymousCommentingFunctionalTests(FunctionalTests): + """Tests for anonymous commenting feature.""" + + @classmethod + def setUpClass(cls): + try: + FunctionalTests.setUpClass.im_func(cls) + except AttributeError: + FunctionalTests.setUpClass.__func__(cls) + + def setUp(self): + # Create a namespace with allow_anonymous enabled + anon_ns = get_or_create_namespace( + self.dbsession, "anon-test.example.com" + ) + anon_ns.allow_anonymous = True + self.dbsession.add(anon_ns) + + # Create a namespace with allow_anonymous disabled (default) + regular_ns = get_or_create_namespace( + self.dbsession, "regular-test.example.com" + ) + regular_ns.allow_anonymous = False + self.dbsession.add(regular_ns) + + # Create a test user for namespace ownership + test_user = get_or_create_user_by_email( + self.dbsession, "anon-test@remarkbox.com" + ) + self.raw_otp = test_user.new_password() + self.dbsession.add(test_user) + + self.dbsession.flush() + + # Store IDs and names before commit + self.anon_namespace_id = anon_ns.id + self.anon_namespace_name = str(anon_ns.name) + self.regular_namespace_id = regular_ns.id + self.regular_namespace_name = str(regular_ns.name) + + self.tm.commit() + + self.test_creds = ("anon-test@remarkbox.com", self.raw_otp) + + def tearDown(self): + super(AnonymousCommentingFunctionalTests, self).tearDown() + # Clean up surrogates created during tests + self.dbsession.query(UserSurrogate).filter( + UserSurrogate.namespace_id.in_([ + self.anon_namespace_id, + self.regular_namespace_id + ]) + ).delete(synchronize_session=False) + # Requery user before delete + user = get_user_by_email(self.dbsession, "anon-test@remarkbox.com") + if user: + self.dbsession.delete(user) + self.dbsession.flush() + self.tm.commit() + + def _log_in_test_user(self): + res_login = self.testapp.post( + "/verification-challenge?email={}&raw-otp={}&submit".format(*self.test_creds) + ) + res_csrf = self.testapp.get("/") + self.csrf = res_csrf.form.fields["csrf_token"][0].value + return res_login + + def test_anonymous_reply_creates_surrogate(self): + """Test that anonymous reply creates a UserSurrogate.""" + # First create a thread with an authenticated user + self._log_in_test_user() + + # Create a root node in the anonymous namespace + from remarkbox.models import create_root_node + anon_ns = get_or_create_namespace(self.dbsession, self.anon_namespace_name) + user = get_or_create_user_by_email(self.dbsession, "anon-test@remarkbox.com") + + root = create_root_node() + root.namespace = anon_ns + root.user = user + root.verified = True + root.title = "Test Thread" + root.set_data("Test content") + self.dbsession.add(root) + self.dbsession.flush() + root_id = str(root.id) + self.tm.commit() + + # Log out + self.testapp.get("/log-out") + + # Post anonymous reply (no email, just name) + redirect_res = self.testapp.post( + "/{}/reply".format(root_id), + { + "thread_data": "Anonymous comment here", + "anonymous_name": "TestAnon", + }, + status=302, + ) + + # Should redirect to the thread (not to login) + res = redirect_res.follow() + self.assertIn(b"Your post was successful!", res.body) + + # Verify a surrogate was created + surrogate = self.dbsession.query(UserSurrogate).filter( + UserSurrogate.name == "TestAnon", + UserSurrogate.namespace_id == self.anon_namespace_id + ).first() + self.assertIsNotNone(surrogate) + + def test_anonymous_reply_default_name(self): + """Test that anonymous reply without name uses 'Anonymous'.""" + self._log_in_test_user() + + from remarkbox.models import create_root_node + anon_ns = get_or_create_namespace(self.dbsession, self.anon_namespace_name) + user = get_or_create_user_by_email(self.dbsession, "anon-test@remarkbox.com") + + root = create_root_node() + root.namespace = anon_ns + root.user = user + root.verified = True + root.title = "Test Thread 2" + root.set_data("Test content 2") + self.dbsession.add(root) + self.dbsession.flush() + root_id = str(root.id) + self.tm.commit() + + self.testapp.get("/log-out") + + # Post without anonymous_name + redirect_res = self.testapp.post( + "/{}/reply".format(root_id), + { + "thread_data": "Anonymous comment without name", + }, + status=302, + ) + + res = redirect_res.follow() + self.assertIn(b"Your post was successful!", res.body) + + # Verify surrogate with default name + surrogate = self.dbsession.query(UserSurrogate).filter( + UserSurrogate.name == "Anonymous", + UserSurrogate.namespace_id == self.anon_namespace_id + ).first() + self.assertIsNotNone(surrogate) + + def test_regular_namespace_requires_email(self): + """Test that non-anonymous namespace still requires email.""" + self._log_in_test_user() + + from remarkbox.models import create_root_node + regular_ns = get_or_create_namespace(self.dbsession, self.regular_namespace_name) + user = get_or_create_user_by_email(self.dbsession, "anon-test@remarkbox.com") + + root = create_root_node() + root.namespace = regular_ns + root.user = user + root.verified = True + root.title = "Regular Thread" + root.set_data("Regular content") + self.dbsession.add(root) + self.dbsession.flush() + root_id = str(root.id) + self.tm.commit() + + self.testapp.get("/log-out") + + # Try to post without email on regular namespace + redirect_res = self.testapp.post( + "/{}/reply".format(root_id), + { + "thread_data": "This should fail", + "anonymous_name": "ShouldFail", + }, + status=302, + ) + + res = redirect_res.follow() + self.assertIn(b"Press the back button to fix your email address", res.body) + + def test_anonymous_comment_is_verified(self): + """Test that anonymous comments are marked as verified.""" + self._log_in_test_user() + + from remarkbox.models import create_root_node + anon_ns = get_or_create_namespace(self.dbsession, self.anon_namespace_name) + user = get_or_create_user_by_email(self.dbsession, "anon-test@remarkbox.com") + + root = create_root_node() + root.namespace = anon_ns + root.user = user + root.verified = True + root.title = "Verified Test Thread" + root.set_data("Verified test content") + self.dbsession.add(root) + self.dbsession.flush() + root_id = root.id + self.tm.commit() + + self.testapp.get("/log-out") + + self.testapp.post( + "/{}/reply".format(root_id), + { + "thread_data": "Anonymous verified comment", + "anonymous_name": "VerifiedAnon", + }, + status=302, + ) + + # Check the node is verified + child = self.dbsession.query(Node).filter( + Node.parent_id == root_id + ).first() + self.assertIsNotNone(child) + self.assertTrue(child.verified) + self.assertIsNotNone(child.user_surrogate) + self.assertIsNone(child.user) + + def test_namespace_settings_toggle(self): + """Test that namespace owner can toggle allow_anonymous setting.""" + self._log_in_test_user() + + # Make user owner of namespace + from remarkbox.models import Namespace + anon_ns = get_or_create_namespace(self.dbsession, self.anon_namespace_name) + user = get_or_create_user_by_email(self.dbsession, "anon-test@remarkbox.com") + + anon_ns.set_role_for_user(user, "owner") + self.dbsession.add(anon_ns) + self.dbsession.flush() + self.tm.commit() + + # Toggle off + self.testapp.post( + "/ns/{}/settings".format(self.anon_namespace_name), + { + "csrf_token": self.csrf, + # Not including allow-anonymous-checkbox means it's unchecked + }, + ) + + ns = self.dbsession.query(Namespace).filter( + Namespace.id == self.anon_namespace_id + ).first() + self.assertFalse(ns.allow_anonymous) + + # Toggle on + self.testapp.post( + "/ns/{}/settings".format(self.anon_namespace_name), + { + "csrf_token": self.csrf, + "allow-anonymous-checkbox": "on", + }, + ) + + self.dbsession.expire(ns) + self.assertTrue(ns.allow_anonymous) -- 2.49.1