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/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)
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)