Add allow_anonymous namespace setting for email-free commenting #35

Merged
russellballestrini merged 2 commits from anonymous into main 2025-12-20 11:26:52 -05:00
8 changed files with 422 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

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

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)